using System.Collections.Frozen; using System.Collections.Immutable; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; namespace Terminal.Gui.Configuration; /// /// Provides settings and configuration management for Terminal.Gui applications. See the Configuration Deep Dive for /// more information: . /// /// 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 .tui folder in the user's home /// directory (e.g. C:/Users/username/.tui, or /usr/username/.tui), the folder where the Terminal.Gui /// application was launched from (e.g. ./.tui ), or as a resource within the Terminal.Gui application's /// main assembly. /// /// /// Settings are defined in JSON format, according to this schema: /// https://gui-cs.github.io/Terminal.Gui/schemas/tui-config-schema.json /// /// /// Settings that will apply to all applications (global settings) reside in files named config.json. /// Settings that will apply to a specific Terminal.Gui application reside in files named /// appname.config.json, where appname is the assembly name of the application (e.g. /// UICatalog.config.json). /// /// /// Settings are applied using the precedence defined in . /// /// /// Configuration Management is based on static properties decorated with the /// . Since these properties are static, changes to /// configuration settings are applied process-wide. /// /// /// Configuration Management is disabled by default and can be enabled by setting calling /// . /// /// /// See the UICatalog example for a complete example of how to use ConfigurationManager. /// /// public static class ConfigurationManager { /// The backing property for (config settings of ). /// /// Is until is called. Gets set to a new instance by /// deserialization /// (see ). /// private static SettingsScope? _settings; #pragma warning disable IDE1006 // Naming Styles private static readonly ReaderWriterLockSlim _settingsLockSlim = new (); #pragma warning restore IDE1006 // Naming Styles /// /// The root object of Terminal.Gui configuration settings / JSON schema. /// public static SettingsScope? Settings { get { _settingsLockSlim.EnterReadLock (); try { return _settings; } finally { _settingsLockSlim.ExitReadLock (); } } set { _settingsLockSlim.EnterWriteLock (); try { _settings = value; } finally { _settingsLockSlim.ExitWriteLock (); } } } #region Initialization // ConfigurationManager is initialized when the module is loaded, via ModuleInitializers.InitializeConfigurationManager // Once initialized, the ConfigurationManager is never un-initialized. // The _initialized field is set to true when the module is loaded and the ConfigurationManager is initialized. private static bool _initialized; #pragma warning disable IDE1006 // Naming Styles private static readonly object _initializedLock = new (); #pragma warning restore IDE1006 // Naming Styles /// /// INTERNAL: For Testing - Indicates whether the has been initialized. /// internal static bool IsInitialized () { lock (_initializedLock) { { return _initialized; } } } // TODO: Find a way to make this cache truly read-only at the leaf node level. // TODO: Right now, the dictionary is frozen, but the ConfigProperty instances can still be modified // TODO: if the PropertyValue is a reference type. // TODO: See https://github.com/gui-cs/Terminal.Gui/issues/4288 /// /// A cache of all properties and their hard coded values. /// /// Is until is called. #pragma warning disable IDE1006 // Naming Styles internal static FrozenDictionary? _hardCodedConfigPropertyCache; private static readonly object _hardCodedConfigPropertyCacheLock = new (); #pragma warning restore IDE1006 // Naming Styles internal static FrozenDictionary? GetHardCodedConfigPropertyCache () { lock (_hardCodedConfigPropertyCacheLock) { if (_hardCodedConfigPropertyCache is null) { throw new InvalidOperationException ("_hardCodedConfigPropertyCache has not been set."); } return _hardCodedConfigPropertyCache; } } /// /// An immutable cache of all s in module decorated with the /// attribute. Both the dictionary and the contained /// s /// are immutable. /// /// Is until is called. private static ImmutableSortedDictionary? _uninitializedConfigPropertiesCache; #pragma warning disable IDE1006 // Naming Styles private static readonly object _uninitializedConfigPropertiesCacheCacheLock = new (); #pragma warning restore IDE1006 // Naming Styles /// /// INTERNAL: Initializes the . /// This method is called when the module is loaded, /// via . /// For ConfigurationManager to access config resources, needs to be /// set to after this method has been called. /// [RequiresDynamicCode ( "Uses reflection to scan assemblies for configuration properties. " + "Only called during initialization and not needed during normal operation. " + "In AOT environments, ensure all types with ConfigurationPropertyAttribute are preserved.")] [RequiresUnreferencedCode ( "Reflection requires all types with ConfigurationPropertyAttribute to be preserved in AOT. " + "Use the SourceGenerationContext to register all configuration property types.")] internal static void Initialize () { lock (_initializedLock) { if (_initialized) { throw new InvalidOperationException ("ConfigurationManager is already initialized."); } } // Ensure ConfigProperty has cached the list of all the classes with config properties. ConfigProperty.Initialize (); // Cache all configuration properties lock (_uninitializedConfigPropertiesCacheCacheLock) { // _allConfigProperties: for ordered, iterable access (LINQ-friendly) // _hardCodedConfigPropertyCache: for high-speed key lookup (frozen) // Note GetAllConfigProperties returns a new instance and all the properties !HasValue and Immutable. _uninitializedConfigPropertiesCache = ConfigProperty.GetAllConfigProperties (); } // Create a COPY of the _allConfigPropertiesCache to ensure that the original is not modified. lock (_hardCodedConfigPropertyCacheLock) { _hardCodedConfigPropertyCache = ConfigProperty.GetAllConfigProperties ().ToFrozenDictionary (); foreach (KeyValuePair hardCodedProperty in _hardCodedConfigPropertyCache) { // Set the PropertyValue to the hard coded value hardCodedProperty.Value.Immutable = false; hardCodedProperty.Value.UpdateToCurrentValue (); hardCodedProperty.Value.Immutable = true; } } lock (_initializedLock) { _initialized = true; } LoadHardCodedDefaults (); // BUGBUG: ThemeScope is broken and needs to be fixed to not have the hard coded schemes get overwritten. // BUGBUG: This a partial workaround. // BUGBUG: See https://github.com/gui-cs/Terminal.Gui/issues/4288 ThemeManager.Themes? [ThemeManager.Theme]?.Apply (); } #endregion Initialization #region Enable/Disable private static bool _enabled; #pragma warning disable IDE1006 // Naming Styles private static readonly object _enabledLock = new (); #pragma warning restore IDE1006 // Naming Styles /// /// Gets whether is enabled or not. /// If , only the hard coded defaults will be loaded. See and /// /// public static bool IsEnabled { get { lock (_enabledLock) { return _enabled; } } } /// /// Enables . If is , /// ConfigurationManager will be enabled as-is; no configuration will be loaded or applied. If /// is , /// ConfigurationManager will be enabled and reset to hard-coded defaults. /// For any other value, /// ConfigurationManager will be enabled and the configuration will be loaded from the specified locations and applied. /// /// [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] public static void Enable (ConfigLocations locations) { if (IsEnabled) { return; } lock (_enabledLock) { _enabled = true; } ClearJsonErrors (); if (locations == ConfigLocations.None) { return; } Load (locations); // Works even if ConfigurationManager is not enabled. InternalApply (); } /// /// Disables . /// /// /// If all static properties will be reset to their /// initial, hard-coded /// defaults. /// [RequiresUnreferencedCode ("Calls ResetToHardCodedDefaults")] [RequiresDynamicCode ("Calls ResetToHardCodedDefaults")] public static void Disable (bool resetToHardCodedDefaults = false) { lock (_enabledLock) { _enabled = false; } if (resetToHardCodedDefaults) { // Calls Apply ResetToHardCodedDefaults (); } } #endregion Enable/Disable #region Reset // `Update` - Updates the configuration from either the current values or the hard-coded defaults. // Updating does not load the configuration; it only updates the configuration to the values currently // in the static ConfigProperties. /// /// INTERNAL: Updates to the settings from the current /// values of the static properties. /// [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] internal static void UpdateToCurrentValues () { if (!IsInitialized ()) { throw new InvalidOperationException ("Initialize must be called first."); } _settingsLockSlim.EnterWriteLock (); try { _settings = new (); _settings.LoadHardCodedDefaults (); } finally { _settingsLockSlim.ExitWriteLock (); } Settings!.UpdateToCurrentValues (); ThemeManager.UpdateToCurrentValues (); AppSettings!.UpdateToCurrentValues (); } /// /// INTERNAL: Loads the hard-coded values of the /// properties and applies them. /// [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] internal static void ResetToHardCodedDefaults () { LoadHardCodedDefaults (); Applied = null; Updated = null; // Works even if ConfigurationManager is not enabled. InternalApply (); } #endregion Reset #region Load // `Load` - Load configuration from the given location(s), updating the configuration with any new values. // Loading does not apply the settings to the application; that happens when the `Apply` method is called. /// /// INTERNAL: Loads all hard-coded configuration properties. Use to cause the loaded settings to be /// applied to the running application. /// [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] internal static void LoadHardCodedDefaults () { if (!IsInitialized ()) { throw new InvalidOperationException ("Initialize must be called first."); } RuntimeConfig = null; SourcesManager!.Sources.Clear (); SourcesManager.AddSource (ConfigLocations.HardCoded, "HardCoded"); Settings = new (); Settings!.LoadHardCodedDefaults (); ThemeManager.LoadHardCodedDefaults (); AppSettings!.LoadHardCodedDefaults (); } /// /// Loads all settings found in . Use to cause the loaded settings to /// be applied to the running application. /// /// Configuration manager is not enabled. [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] public static void Load (ConfigLocations locations) { if (!IsEnabled) { throw new ConfigurationManagerNotEnabledException (); } // Only load the hard-coded defaults if the user has not specified any locations. if (locations == ConfigLocations.HardCoded) { LoadHardCodedDefaults (); } if (locations.HasFlag (ConfigLocations.LibraryResources)) { SourcesManager?.Load ( Settings, typeof (ConfigurationManager).Assembly, $"Terminal.Gui.Resources.{_configFilename}", ConfigLocations.LibraryResources); } if (locations.HasFlag (ConfigLocations.AppResources)) { string? embeddedStylesResourceName = Assembly.GetEntryAssembly () ? .GetManifestResourceNames () .FirstOrDefault (x => x.EndsWith (_configFilename)); if (string.IsNullOrEmpty (embeddedStylesResourceName)) { embeddedStylesResourceName = _configFilename; } SourcesManager?.Load (Settings, Assembly.GetEntryAssembly ()!, embeddedStylesResourceName!, ConfigLocations.AppResources); } // TODO: Determine if Runtime should be applied last. if (locations.HasFlag (ConfigLocations.Runtime) && !string.IsNullOrEmpty (RuntimeConfig)) { SourcesManager?.Load (Settings, RuntimeConfig, "ConfigurationManager.RuntimeConfig", ConfigLocations.Runtime); } if (locations.HasFlag (ConfigLocations.GlobalCurrent)) { SourcesManager?.Load (Settings, $"./.tui/{_configFilename}", ConfigLocations.GlobalCurrent); } if (locations.HasFlag (ConfigLocations.GlobalHome)) { SourcesManager?.Load (Settings, $"~/.tui/{_configFilename}", ConfigLocations.GlobalHome); } if (locations.HasFlag (ConfigLocations.AppCurrent)) { SourcesManager?.Load (Settings, $"./.tui/{AppName}.{_configFilename}", ConfigLocations.AppCurrent); } if (locations.HasFlag (ConfigLocations.AppHome)) { SourcesManager?.Load (Settings, $"~/.tui/{AppName}.{_configFilename}", ConfigLocations.AppHome); } } // TODO: Rename to Loaded? /// /// Called when the configuration has been updated from a configuration file or reset. Invokes the /// /// event. /// public static void OnUpdated () { //Logging.Trace (@""); if (!IsEnabled) { return; } // Use a local copy of the event delegate when invoking it to avoid race conditions. EventHandler? handler = Updated; handler?.Invoke (null, new ()); } /// Event fired when the configuration has been updated from a configuration source or reset. public static event EventHandler? Updated; #endregion Load #region Apply // `Apply` - Apply the configuration to the application; this means the settings are copied from the // configuration properties to the corresponding `static` `[ConfigurationProperty]` properties. /// /// Applies the configuration settings to static properties. /// ConfigurationManager must be Enabled. /// /// Configuration Manager is not enabled. [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] public static void Apply () { if (!IsEnabled) { throw new ConfigurationManagerNotEnabledException (); } InternalApply (); } [RequiresUnreferencedCode ("Calls Terminal.Gui.Scope.Apply()")] [RequiresDynamicCode ("Calls Terminal.Gui.Scope.Apply()")] private static void InternalApply () { var settings = false; var themes = false; var appSettings = false; try { settings = Settings?.Apply () ?? false; themes = ThemeManager.Themes? [ThemeManager.Theme]?.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 (); } } } /// /// Called when an updated configuration has been applied to the application. Fires the /// event. /// /// Configuration manager is not enabled. private static void OnApplied () { if (!IsEnabled) { return; } // Use a local copy of the event delegate when invoking it to avoid race conditions. EventHandler? handler = Applied; handler?.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 } /// Event fired when an updated configuration has been applied to the application. public static event EventHandler? Applied; #endregion Apply #region Sources // `Sources` - A source is a location where a configuration can be stored. Sources are defined in the `ConfigLocations` enum. [SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "")] internal static readonly SourceGenerationContext SerializerContext = new ( new() { // Be relaxed ReadCommentHandling = JsonCommentHandling.Skip, PropertyNameCaseInsensitive = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, WriteIndented = true, AllowTrailingCommas = 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 }); private static SourcesManager? _sourcesManager = new (); private static readonly object _sourcesManagerLock = new (); /// /// Gets the Sources Manager - manages the loading of configuration sources from files and resources. /// public static SourcesManager? SourcesManager { get { lock (_sourcesManagerLock) { return _sourcesManager; } } internal set { lock (_sourcesManagerLock) { _sourcesManager = value; } } } private static string? _runtimeConfig = """{ }"""; private static readonly object _runtimeConfigLock = new (); /// /// Gets or sets the in-memory config.json. See . /// public static string? RuntimeConfig { get { lock (_runtimeConfigLock) { return _runtimeConfig; } } set { lock (_runtimeConfigLock) { _runtimeConfig = value; } } } [SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "")] private static readonly string _configFilename = "config.json"; #endregion Sources #region AppSettings /// /// Gets or sets the application-specific configuration settings (config properties with the /// scope. /// [ConfigurationProperty (Scope = typeof (SettingsScope), OmitClassName = true)] [JsonPropertyName ("AppSettings")] public static AppSettingsScope? AppSettings { [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] get { if (!IsInitialized ()) { // We're being called from the module initializer. // Hard coded default value is an empty AppSettingsScope var appSettings = new AppSettingsScope (); appSettings.LoadHardCodedDefaults (); return appSettings; } if (Settings is null || !Settings.TryGetValue ("AppSettings", out ConfigProperty? appSettingsConfigProperty)) { throw new InvalidOperationException ("Settings is null."); } { if (!appSettingsConfigProperty.HasValue) { var appSettings = new AppSettingsScope (); appSettings.UpdateToCurrentValues (); return appSettings; } return (appSettingsConfigProperty.PropertyValue as AppSettingsScope)!; } } [RequiresUnreferencedCode ("AOT")] [RequiresDynamicCode ("AOT")] set { if (!IsInitialized ()) { throw new InvalidOperationException ("AppSettings cannot be set before ConfigurationManager is initialized."); } // Check if the AppSettings is the same as the previous one if (value != Settings! ["AppSettings"].PropertyValue) { // Update the backing store Settings! ["AppSettings"].PropertyValue = value; //Instance.OnThemeChanged (previousThemeValue); } } } #endregion AppSettings #region Error Logging [SuppressMessage ("Style", "IDE1006:Naming Styles", Justification = "")] internal static StringBuilder _jsonErrors = new (); private static bool? _throwOnJsonErrors = false; private static readonly object _throwOnJsonErrorsLock = new (); /// /// Gets or sets whether the should throw an exception if it encounters an /// error on deserialization. If (the default), the error is logged and printed to the console /// when is called. /// [ConfigurationProperty (Scope = typeof (SettingsScope))] public static bool? ThrowOnJsonErrors { get { lock (_throwOnJsonErrorsLock) { return _throwOnJsonErrors; } } set { lock (_throwOnJsonErrorsLock) { _throwOnJsonErrors = value; } } } #pragma warning disable IDE1006 // Naming Styles private static readonly object _jsonErrorsLock = new (); #pragma warning restore IDE1006 // Naming Styles internal static void AddJsonError (string error) { Logging.Error ($"{error}"); lock (_jsonErrorsLock) { _jsonErrors.AppendLine (@$" {error}"); } } private static void ClearJsonErrors () { lock (_jsonErrorsLock) { _jsonErrors.Clear (); } } /// Prints any Json deserialization errors that occurred during deserialization to the console. public static void PrintJsonErrors () { lock (_jsonErrorsLock) { if (_jsonErrors.Length > 0) { Console.WriteLine ( @"Terminal.Gui ConfigurationManager encountered these errors while reading configuration files" + @"(set ThrowOnJsonErrors to have these caught during execution):"); Console.WriteLine (_jsonErrors.ToString ()); } } } #endregion Error Logging /// Returns an empty Json document with just the $schema tag. /// public static string GetEmptyConfig () { var emptyScope = new SettingsScope (); emptyScope.Clear (); return JsonSerializer.Serialize (emptyScope, typeof (SettingsScope), SerializerContext!); } /// Returns a Json document containing the hard-coded config. /// public static string GetHardCodedConfig () { var emptyScope = new SettingsScope (); emptyScope.LoadHardCodedDefaults (); IEnumerable>? settings = GetHardCodedConfigPropertiesByScope ("SettingsScope"); if (settings is null) { throw new InvalidOperationException ("GetHardCodedConfigPropertiesByScope returned null."); } Dictionary settingsDict = settings.ToDictionary (); foreach (KeyValuePair p in Settings!.Where (cp => cp.Value.PropertyInfo is { })) { emptyScope [p.Key].PropertyValue = settingsDict [p.Key].PropertyValue; } return JsonSerializer.Serialize (emptyScope, typeof (SettingsScope), SerializerContext!); } private static string _appName = Assembly.GetEntryAssembly ()?.FullName?.Split (',') [0]?.Trim ()!; private static readonly object _appNameLock = new (); /// Name of the running application. By default, this property is set to the application's assembly name. public static string AppName { get { lock (_appNameLock) { return _appName; } } set { lock (_appNameLock) { _appName = value; } } } /// /// INTERNAL: Retrieves all uninitialized configuration properties that belong to a specific scope from the cache. /// The items in the collection are references to the original objects in the /// cache. They do not have values and have set. /// internal static IEnumerable>? GetUninitializedConfigPropertiesByScope (string scopeType) { // AOT Note: This method does NOT need the RequiresUnreferencedCode attribute as it is not using reflection // and is not using any dynamic code. _allConfigProperties is a static property that is set in the module initializer // and is not using any dynamic code. In addition, ScopeType are registered in SourceGenerationContext. if (_uninitializedConfigPropertiesCache is null) { throw new InvalidOperationException ("_allConfigPropertiesCache has not been set."); } if (string.IsNullOrEmpty (scopeType)) { return _uninitializedConfigPropertiesCache; } lock (_uninitializedConfigPropertiesCacheCacheLock) { // Filter properties by scope using the cached ScopeType property instead of reflection IEnumerable>? filtered = _uninitializedConfigPropertiesCache?.Where (cp => cp.Value.ScopeType == scopeType); Debug.Assert (filtered is { }); IEnumerable> configPropertiesByScope = filtered as KeyValuePair [] ?? filtered.ToArray (); Debug.Assert (configPropertiesByScope.All (v => !v.Value.HasValue)); return configPropertiesByScope; } } /// /// INTERNAL: Retrieves all configuration properties that belong to a specific scope from the hard coded value cache. /// The items in the collection are references to the original objects in the /// cache. They contain the hard coded values and have set. /// internal static IEnumerable>? GetHardCodedConfigPropertiesByScope (string scopeType) { // AOT Note: This method does NOT need the RequiresUnreferencedCode attribute as it is not using reflection // and is not using any dynamic code. _allConfigProperties is a static property that is set in the module initializer // and is not using any dynamic code. In addition, ScopeType are registered in SourceGenerationContext. // Filter properties by scope IEnumerable>? cache = GetHardCodedConfigPropertyCache (); if (cache is null) { throw new InvalidOperationException ("GetHardCodedConfigPropertyCache returned null"); } if (string.IsNullOrEmpty (scopeType)) { return cache; } // Use the cached ScopeType property instead of reflection IEnumerable>? scopedCache = cache?.Where (cp => cp.Value.ScopeType == scopeType); return scopedCache!; } }