Files
Terminal.Gui/Terminal.Gui/Configuration/ConfigurationManager.cs
2024-10-08 13:22:52 -04:00

639 lines
26 KiB
C#

global using static Terminal.Gui.ConfigurationManager;
global using CM = Terminal.Gui.ConfigurationManager;
using System.Collections;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.Versioning;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
#nullable enable
namespace Terminal.Gui;
/// <summary>
/// Provides settings and configuration management for Terminal.Gui applications.
/// <para>
/// Users can set Terminal.Gui settings on a global or per-application basis by providing JSON formatted
/// configuration files. The configuration files can be placed in at <c>.tui</c> folder in the user's home
/// directory (e.g. <c>C:/Users/username/.tui</c>, or <c>/usr/username/.tui</c>), the folder where the Terminal.Gui
/// application was launched from (e.g. <c>./.tui</c> ), or as a resource within the Terminal.Gui application's
/// main assembly.
/// </para>
/// <para>
/// Settings are defined in JSON format, according to this schema:
/// https://gui-cs.github.io/Terminal.GuiV2Docs/schemas/tui-config-schema.json
/// </para>
/// <para>
/// Settings that will apply to all applications (global settings) reside in files named <c>config.json</c>.
/// Settings that will apply to a specific Terminal.Gui application reside in files named
/// <c>appname.config.json</c>, where <c>appname</c> is the assembly name of the application (e.g.
/// <c>UICatalog.config.json</c>).
/// </para>
/// Settings are applied using the following precedence (higher precedence settings overwrite lower precedence
/// settings):
/// <para>
/// 1. Application configuration found in the users' home directory (<c>~/.tui/appname.config.json</c>) --
/// Highest precedence
/// </para>
/// <para>
/// 2. Application configuration found in the directory the app was launched from (
/// <c>./.tui/appname.config.json</c>).
/// </para>
/// <para>3. Application configuration found in the applications' resources (<c>Resources/config.json</c>).</para>
/// <para>4. Global configuration found in the user's home directory (<c>~/.tui/config.json</c>).</para>
/// <para>5. Global configuration found in the directory the app was launched from (<c>./.tui/config.json</c>).</para>
/// <para>
/// 6. Global configuration in <c>Terminal.Gui.dll</c>'s resources (<c>Terminal.Gui.Resources.config.json</c>) --
/// Lowest Precedence.
/// </para>
/// </summary>
[ComponentGuarantees (ComponentGuaranteesOptions.None)]
public static class ConfigurationManager
{
/// <summary>
/// Describes the location of the configuration files. The constants can be combined (bitwise) to specify multiple
/// locations.
/// </summary>
[Flags]
public enum ConfigLocations
{
/// <summary>No configuration will be loaded.</summary>
/// <remarks>
/// Used for development and testing only. For Terminal,Gui to function properly, at least
/// <see cref="DefaultOnly"/> should be set.
/// </remarks>
None = 0,
/// <summary>
/// Global configuration in <c>Terminal.Gui.dll</c>'s resources (<c>Terminal.Gui.Resources.config.json</c>) --
/// Lowest Precedence.
/// </summary>
DefaultOnly,
/// <summary>This constant is a combination of all locations</summary>
All = -1
}
/// <summary>
/// A dictionary of all properties in the Terminal.Gui project that are decorated with the
/// <see cref="SerializableConfigurationProperty"/> attribute. The keys are the property names pre-pended with the
/// class that implements the property (e.g. <c>Application.UseSystemConsole</c>). The values are instances of
/// <see cref="ConfigProperty"/> which hold the property's value and the <see cref="PropertyInfo"/> that allows
/// <see cref="ConfigurationManager"/> to get and set the property's value.
/// </summary>
/// <remarks>Is <see langword="null"/> until <see cref="Initialize"/> is called.</remarks>
[SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "<Pending>")]
internal static Dictionary<string, ConfigProperty>? _allConfigProperties;
[SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "<Pending>")]
internal static readonly JsonSerializerOptions _serializerOptions = new ()
{
ReadCommentHandling = JsonCommentHandling.Skip,
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
Converters =
{
// We override the standard Rune converter to support specifying Glyphs in
// a flexible way
new RuneJsonConverter (),
// Override Key to support "Ctrl+Q" format.
new KeyJsonConverter ()
},
// Enables Key to be "Ctrl+Q" vs "Ctrl\u002BQ"
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
TypeInfoResolver = SourceGenerationContext.Default
};
[SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "<Pending>")]
internal static readonly SourceGenerationContext _serializerContext = new (_serializerOptions);
[SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "<Pending>")]
internal static StringBuilder _jsonErrors = new ();
[SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "<Pending>")]
private static readonly string _configFilename = "config.json";
/// <summary>The backing property for <see cref="Settings"/>.</summary>
/// <remarks>
/// Is <see langword="null"/> until <see cref="Reset"/> is called. Gets set to a new instance by deserialization
/// (see <see cref="Load"/>).
/// </remarks>
private static SettingsScope? _settings;
/// <summary>Name of the running application. By default, this property is set to the application's assembly name.</summary>
public static string AppName { get; set; } = Assembly.GetEntryAssembly ()?.FullName?.Split (',') [0]?.Trim ()!;
/// <summary>Application-specific configuration settings scope.</summary>
[SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true)]
[JsonPropertyName ("AppSettings")]
public static AppScope? AppSettings { get; set; }
/// <summary>
/// The set of glyphs used to draw checkboxes, lines, borders, etc...See also
/// <seealso cref="Terminal.Gui.GlyphDefinitions"/>.
/// </summary>
[SerializableConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true)]
[JsonPropertyName ("Glyphs")]
public static GlyphDefinitions Glyphs { get; set; } = new ();
/// <summary>
/// Gets and sets the locations where <see cref="ConfigurationManager"/> will look for config files. The value is
/// <see cref="ConfigLocations.All"/>.
/// </summary>
public static ConfigLocations Locations { get; set; } = ConfigLocations.All;
/// <summary>
/// The root object of Terminal.Gui configuration settings / JSON schema. Contains only properties with the
/// <see cref="SettingsScope"/> attribute value.
/// </summary>
public static SettingsScope? Settings
{
[RequiresUnreferencedCode ("AOT")]
[RequiresDynamicCode ("AOT")]
get
{
if (_settings is null)
{
// If Settings is null, we need to initialize it.
Reset ();
}
return _settings;
}
set => _settings = value!;
}
/// <summary>
/// The root object of Terminal.Gui themes manager. Contains only properties with the <see cref="ThemeScope"/>
/// attribute value.
/// </summary>
public static ThemeManager? Themes => ThemeManager.Instance;
/// <summary>
/// Gets or sets whether the <see cref="ConfigurationManager"/> should throw an exception if it encounters an
/// error on deserialization. If <see langword="false"/> (the default), the error is logged and printed to the console
/// when <see cref="Application.Shutdown"/> is called.
/// </summary>
[SerializableConfigurationProperty (Scope = typeof (SettingsScope))]
public static bool? ThrowOnJsonErrors { get; set; } = false;
/// <summary>Event fired when an updated configuration has been applied to the application.</summary>
public static event EventHandler<ConfigurationManagerEventArgs>? Applied;
/// <summary>Applies the configuration settings to the running <see cref="Application"/> instance.</summary>
[RequiresUnreferencedCode ("AOT")]
[RequiresDynamicCode ("AOT")]
public static void Apply ()
{
var settings = false;
var themes = false;
var appSettings = false;
try
{
if (string.IsNullOrEmpty (ThemeManager.SelectedTheme))
{
// First start. Apply settings first. This ensures if a config sets Theme to something other than "Default", it gets used
settings = Settings?.Apply () ?? false;
themes = !string.IsNullOrEmpty (ThemeManager.SelectedTheme)
&& (ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply () ?? false);
}
else
{
// Subsequently. Apply Themes first using whatever the SelectedTheme is
themes = ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply () ?? false;
settings = Settings?.Apply () ?? false;
}
appSettings = AppSettings?.Apply () ?? false;
}
catch (JsonException e)
{
if (ThrowOnJsonErrors ?? false)
{
throw;
}
else
{
AddJsonError ($"Error applying Configuration Change: {e.Message}");
}
}
finally
{
if (settings || themes || appSettings)
{
OnApplied ();
}
}
}
/// <summary>Returns an empty Json document with just the $schema tag.</summary>
/// <returns></returns>
public static string GetEmptyJson ()
{
var emptyScope = new SettingsScope ();
emptyScope.Clear ();
return JsonSerializer.Serialize (emptyScope, typeof (SettingsScope), _serializerContext);
}
/// <summary>
/// Loads all settings found in the various configuration storage locations to the
/// <see cref="ConfigurationManager"/>. Optionally, resets all settings attributed with
/// <see cref="SerializableConfigurationProperty"/> to the defaults.
/// </summary>
/// <remarks>Use <see cref="Apply"/> to cause the loaded settings to be applied to the running application.</remarks>
/// <param name="reset">
/// If <see langword="true"/> the state of <see cref="ConfigurationManager"/> will be reset to the
/// defaults.
/// </param>
[RequiresUnreferencedCode ("AOT")]
[RequiresDynamicCode ("AOT")]
public static void Load (bool reset = false)
{
Debug.WriteLine ("ConfigurationManager.Load()");
if (reset)
{
Reset ();
}
// LibraryResources is always loaded by Reset
if (Locations == ConfigLocations.All)
{
string? embeddedStylesResourceName = Assembly.GetEntryAssembly ()
?
.GetManifestResourceNames ()
.FirstOrDefault (x => x.EndsWith (_configFilename));
if (string.IsNullOrEmpty (embeddedStylesResourceName))
{
embeddedStylesResourceName = _configFilename;
}
Settings = Settings?
// Global current directory
.Update ($"./.tui/{_configFilename}")
?
// Global home directory
.Update ($"~/.tui/{_configFilename}")
?
// App resources
.UpdateFromResource (Assembly.GetEntryAssembly ()!, embeddedStylesResourceName!)
?
// App current directory
.Update ($"./.tui/{AppName}.{_configFilename}")
?
// App home directory
.Update ($"~/.tui/{AppName}.{_configFilename}");
}
}
/// <summary>
/// Called when an updated configuration has been applied to the application. Fires the <see cref="Applied"/>
/// event.
/// </summary>
public static void OnApplied ()
{
Debug.WriteLine ("ConfigurationManager.OnApplied()");
Applied?.Invoke (null, new ());
// TODO: Refactor ConfigurationManager to not use an event handler for this.
// Instead, have it call a method on any class appropriately attributed
// to update the cached values. See Issue #2871
}
/// <summary>
/// Called when the configuration has been updated from a configuration file. Invokes the <see cref="Updated"/>
/// event.
/// </summary>
public static void OnUpdated ()
{
Debug.WriteLine (@"ConfigurationManager.OnApplied()");
Updated?.Invoke (null, new ());
}
/// <summary>Prints any Json deserialization errors that occurred during deserialization to the console.</summary>
public static void PrintJsonErrors ()
{
if (_jsonErrors.Length > 0)
{
Console.WriteLine (
@"Terminal.Gui ConfigurationManager encountered the following errors while deserializing configuration files:"
);
Console.WriteLine (_jsonErrors.ToString ());
}
}
/// <summary>
/// Resets the state of <see cref="ConfigurationManager"/>. Should be called whenever a new app session (e.g. in
/// <see cref="Application.Init"/> starts. Called by <see cref="Load"/> if the <c>reset</c> parameter is
/// <see langword="true"/>.
/// </summary>
/// <remarks></remarks>
[RequiresUnreferencedCode ("AOT")]
[RequiresDynamicCode ("AOT")]
public static void Reset ()
{
Debug.WriteLine (@"ConfigurationManager.Reset()");
if (_allConfigProperties is null)
{
Initialize ();
}
ClearJsonErrors ();
Settings = new ();
ThemeManager.Reset ();
AppSettings = new ();
// To enable some unit tests, we only load from resources if the flag is set
if (Locations.HasFlag (ConfigLocations.DefaultOnly))
{
Settings.UpdateFromResource (
typeof (ConfigurationManager).Assembly,
$"Terminal.Gui.Resources.{_configFilename}"
);
}
Apply ();
ThemeManager.Themes? [ThemeManager.SelectedTheme]?.Apply ();
AppSettings?.Apply ();
}
/// <summary>Event fired when the configuration has been updated from a configuration source. application.</summary>
public static event EventHandler<ConfigurationManagerEventArgs>? Updated;
internal static void AddJsonError (string error)
{
Debug.WriteLine ($"ConfigurationManager: {error}");
_jsonErrors.AppendLine (error);
}
/// <summary>
/// System.Text.Json does not support copying a deserialized object to an existing instance. To work around this,
/// we implement a 'deep, member-wise copy' method.
/// </summary>
/// <remarks>TOOD: When System.Text.Json implements `PopulateObject` revisit https://github.com/dotnet/corefx/issues/37627</remarks>
/// <param name="source"></param>
/// <param name="destination"></param>
/// <returns><paramref name="destination"/> updated from <paramref name="source"/></returns>
internal static object? DeepMemberWiseCopy (object? source, object? destination)
{
ArgumentNullException.ThrowIfNull (destination);
if (source is null)
{
return null!;
}
if (source.GetType () == typeof (SettingsScope))
{
return ((SettingsScope)destination).Update ((SettingsScope)source);
}
if (source.GetType () == typeof (ThemeScope))
{
return ((ThemeScope)destination).Update ((ThemeScope)source);
}
if (source.GetType () == typeof (AppScope))
{
return ((AppScope)destination).Update ((AppScope)source);
}
// If value type, just use copy constructor.
if (source.GetType ().IsValueType || source.GetType () == typeof (string))
{
return source;
}
// Dictionary
if (source.GetType ().IsGenericType
&& source.GetType ().GetGenericTypeDefinition ().IsAssignableFrom (typeof (Dictionary<,>)))
{
foreach (object? srcKey in ((IDictionary)source).Keys)
{
if (srcKey is string)
{ }
if (((IDictionary)destination).Contains (srcKey))
{
((IDictionary)destination) [srcKey] =
DeepMemberWiseCopy (((IDictionary)source) [srcKey], ((IDictionary)destination) [srcKey]);
}
else
{
((IDictionary)destination).Add (srcKey, ((IDictionary)source) [srcKey]);
}
}
return destination;
}
// ALl other object types
List<PropertyInfo>? sourceProps = source?.GetType ().GetProperties ().Where (x => x.CanRead).ToList ();
List<PropertyInfo>? destProps = destination?.GetType ().GetProperties ().Where (x => x.CanWrite).ToList ()!;
foreach ((PropertyInfo? sourceProp, PropertyInfo? destProp) in
from sourceProp in sourceProps
where destProps.Any (x => x.Name == sourceProp.Name)
let destProp = destProps.First (x => x.Name == sourceProp.Name)
where destProp.CanWrite
select (sourceProp, destProp))
{
object? sourceVal = sourceProp.GetValue (source);
object? destVal = destProp.GetValue (destination);
if (sourceVal is { })
{
try
{
if (destVal is { })
{
// Recurse
destProp.SetValue (destination, DeepMemberWiseCopy (sourceVal, destVal));
}
else
{
destProp.SetValue (destination, sourceVal);
}
}
catch (ArgumentException e)
{
throw new JsonException ($"Error Applying Configuration Change: {e.Message}", e);
}
}
}
return destination!;
}
/// <summary>
/// Retrieves the hard coded default settings from the Terminal.Gui library implementation. Used in development of
/// the library to generate the default configuration file. Before calling Application.Init, make sure
/// <see cref="Locations"/> is set to <see cref="ConfigLocations.None"/>.
/// </summary>
/// <remarks>
/// <para>
/// This method is only really useful when using ConfigurationManagerTests to generate the JSON doc that is
/// embedded into Terminal.Gui (during development).
/// </para>
/// <para>
/// WARNING: The <c>Terminal.Gui.Resources.config.json</c> resource has setting definitions (Themes) that are NOT
/// generated by this function. If you use this function to regenerate <c>Terminal.Gui.Resources.config.json</c>,
/// make sure you copy the Theme definitions from the existing <c>Terminal.Gui.Resources.config.json</c> file.
/// </para>
/// </remarks>
[RequiresUnreferencedCode ("AOT")]
[RequiresDynamicCode ("AOT")]
internal static void GetHardCodedDefaults ()
{
if (_allConfigProperties is null)
{
throw new InvalidOperationException ("Initialize must be called first.");
}
Settings = new ();
ThemeManager.GetHardCodedDefaults ();
AppSettings?.RetrieveValues ();
foreach (KeyValuePair<string, ConfigProperty> p in Settings!.Where (cp => cp.Value.PropertyInfo is { }))
{
Settings! [p.Key].PropertyValue = p.Value.PropertyInfo?.GetValue (null);
}
}
/// <summary>
/// Initializes the internal state of ConfigurationManager. Nominally called once as part of application startup
/// to initialize global state. Also called from some Unit Tests to ensure correctness (e.g. Reset()).
/// </summary>
[RequiresUnreferencedCode ("AOT")]
internal static void Initialize ()
{
_allConfigProperties = new ();
_settings = null;
Dictionary<string, Type> classesWithConfigProps = new (StringComparer.InvariantCultureIgnoreCase);
// Get Terminal.Gui.dll classes
IEnumerable<Type> types = from assembly in AppDomain.CurrentDomain.GetAssemblies ()
from type in assembly.GetTypes ()
where type.GetProperties ()
.Any (
prop => prop.GetCustomAttribute (
typeof (SerializableConfigurationProperty)
)
!= null
)
select type;
foreach (Type? classWithConfig in types)
{
classesWithConfigProps.Add (classWithConfig.Name, classWithConfig);
}
//Debug.WriteLine ($"ConfigManager.getConfigProperties found {classesWithConfigProps.Count} classes:");
classesWithConfigProps.ToList ().ForEach (x => Debug.WriteLine ($" Class: {x.Key}"));
foreach (PropertyInfo? p in from c in classesWithConfigProps
let props = c.Value
.GetProperties (
BindingFlags.Instance
| BindingFlags.Static
| BindingFlags.NonPublic
| BindingFlags.Public
)
.Where (
prop =>
prop.GetCustomAttribute (
typeof (SerializableConfigurationProperty)
) is
SerializableConfigurationProperty
)
let enumerable = props
from p in enumerable
select p)
{
if (p.GetCustomAttribute (typeof (SerializableConfigurationProperty)) is SerializableConfigurationProperty
scp)
{
if (p.GetGetMethod (true)!.IsStatic)
{
// If the class name is omitted, JsonPropertyName is allowed.
_allConfigProperties!.Add (
scp.OmitClassName
? ConfigProperty.GetJsonPropertyName (p)
: $"{p.DeclaringType?.Name}.{p.Name}",
new() { PropertyInfo = p, PropertyValue = null }
);
}
else
{
throw new (
$"Property {
p.Name
} in class {
p.DeclaringType?.Name
} is not static. All SerializableConfigurationProperty properties must be static."
);
}
}
}
_allConfigProperties = _allConfigProperties!.OrderBy (x => x.Key)
.ToDictionary (
x => x.Key,
x => x.Value,
StringComparer.InvariantCultureIgnoreCase
);
//Debug.WriteLine ($"ConfigManager.Initialize found {_allConfigProperties.Count} properties:");
//_allConfigProperties.ToList ().ForEach (x => Debug.WriteLine ($" Property: {x.Key}"));
AppSettings = new ();
}
/// <summary>Creates a JSON document with the configuration specified.</summary>
/// <returns></returns>
[RequiresUnreferencedCode ("AOT")]
[RequiresDynamicCode ("AOT")]
internal static string ToJson ()
{
//Debug.WriteLine ("ConfigurationManager.ToJson()");
return JsonSerializer.Serialize (Settings!, typeof (SettingsScope), _serializerContext);
}
[RequiresUnreferencedCode ("AOT")]
[RequiresDynamicCode ("AOT")]
internal static Stream ToStream ()
{
string json = JsonSerializer.Serialize (Settings!, typeof (SettingsScope), _serializerContext);
// turn it into a stream
var stream = new MemoryStream ();
var writer = new StreamWriter (stream);
writer.Write (json);
writer.Flush ();
stream.Position = 0;
return stream;
}
private static void ClearJsonErrors () { _jsonErrors.Clear (); }
}