add configuration update callback for script plugins & update plugins to utilize
This commit is contained in:
parent
c3be7f7de5
commit
bb8f3fbe5b
@ -118,6 +118,8 @@ public class BaseConfigurationHandlerV2<TConfigurationType> : IConfigurationHand
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public event Action<TConfigurationType> Updated;
|
||||||
|
|
||||||
private async Task InternalSet(TConfigurationType configuration, bool awaitSemaphore)
|
private async Task InternalSet(TConfigurationType configuration, bool awaitSemaphore)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@ -163,6 +165,7 @@ public class BaseConfigurationHandlerV2<TConfigurationType> : IConfigurationHand
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
CopyUpdatedProperties(readConfiguration);
|
CopyUpdatedProperties(readConfiguration);
|
||||||
|
Updated?.Invoke(readConfiguration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.Collections.Generic;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@ -6,15 +7,20 @@ using System.Threading.Tasks;
|
|||||||
using IW4MAdmin.Application.Configuration;
|
using IW4MAdmin.Application.Configuration;
|
||||||
using Jint;
|
using Jint;
|
||||||
using Jint.Native;
|
using Jint.Native;
|
||||||
|
using Jint.Native.Json;
|
||||||
using SharedLibraryCore.Interfaces;
|
using SharedLibraryCore.Interfaces;
|
||||||
|
|
||||||
namespace IW4MAdmin.Application.Plugin.Script;
|
namespace IW4MAdmin.Application.Plugin.Script;
|
||||||
|
|
||||||
public class ScriptPluginConfigurationWrapper
|
public class ScriptPluginConfigurationWrapper
|
||||||
{
|
{
|
||||||
|
public event Action<JsValue, Delegate> ConfigurationUpdated;
|
||||||
|
|
||||||
private readonly ScriptPluginConfiguration _config;
|
private readonly ScriptPluginConfiguration _config;
|
||||||
private readonly IConfigurationHandlerV2<ScriptPluginConfiguration> _configHandler;
|
private readonly IConfigurationHandlerV2<ScriptPluginConfiguration> _configHandler;
|
||||||
private readonly Engine _scriptEngine;
|
private readonly Engine _scriptEngine;
|
||||||
|
private readonly JsonParser _engineParser;
|
||||||
|
private readonly List<(string, Delegate)> _updateCallbackActions = new();
|
||||||
private string _pluginName;
|
private string _pluginName;
|
||||||
|
|
||||||
public ScriptPluginConfigurationWrapper(string pluginName, Engine scriptEngine, IConfigurationHandlerV2<ScriptPluginConfiguration> configHandler)
|
public ScriptPluginConfigurationWrapper(string pluginName, Engine scriptEngine, IConfigurationHandlerV2<ScriptPluginConfiguration> configHandler)
|
||||||
@ -22,7 +28,14 @@ public class ScriptPluginConfigurationWrapper
|
|||||||
_pluginName = pluginName;
|
_pluginName = pluginName;
|
||||||
_scriptEngine = scriptEngine;
|
_scriptEngine = scriptEngine;
|
||||||
_configHandler = configHandler;
|
_configHandler = configHandler;
|
||||||
|
_configHandler.Updated += OnConfigurationUpdated;
|
||||||
_config = configHandler.Get("ScriptPluginSettings", new ScriptPluginConfiguration()).GetAwaiter().GetResult();
|
_config = configHandler.Get("ScriptPluginSettings", new ScriptPluginConfiguration()).GetAwaiter().GetResult();
|
||||||
|
_engineParser = new JsonParser(_scriptEngine);
|
||||||
|
}
|
||||||
|
|
||||||
|
~ScriptPluginConfigurationWrapper()
|
||||||
|
{
|
||||||
|
_configHandler.Updated -= OnConfigurationUpdated;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetName(string name)
|
public void SetName(string name)
|
||||||
@ -64,7 +77,9 @@ public class ScriptPluginConfigurationWrapper
|
|||||||
await _configHandler.Set(_config);
|
await _configHandler.Set(_config);
|
||||||
}
|
}
|
||||||
|
|
||||||
public JsValue GetValue(string key)
|
public JsValue GetValue(string key) => GetValue(key, null);
|
||||||
|
|
||||||
|
public JsValue GetValue(string key, Delegate updateCallback)
|
||||||
{
|
{
|
||||||
if (!_config.ContainsKey(_pluginName))
|
if (!_config.ContainsKey(_pluginName))
|
||||||
{
|
{
|
||||||
@ -83,6 +98,20 @@ public class ScriptPluginConfigurationWrapper
|
|||||||
item = jElem.Deserialize<List<dynamic>>();
|
item = jElem.Deserialize<List<dynamic>>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updateCallback is not null)
|
||||||
|
{
|
||||||
|
_updateCallbackActions.Add((key, updateCallback));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return _engineParser.Parse(item!.ToString()!);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
|
||||||
return JsValue.FromObject(_scriptEngine, item);
|
return JsValue.FromObject(_scriptEngine, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,4 +119,12 @@ public class ScriptPluginConfigurationWrapper
|
|||||||
{
|
{
|
||||||
return int.TryParse(value.ToString(CultureInfo.InvariantCulture), out var parsed) ? parsed : null;
|
return int.TryParse(value.ToString(CultureInfo.InvariantCulture), out var parsed) ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnConfigurationUpdated(ScriptPluginConfiguration config)
|
||||||
|
{
|
||||||
|
foreach (var callback in _updateCallbackActions)
|
||||||
|
{
|
||||||
|
ConfigurationUpdated?.Invoke(GetValue(callback.Item1), callback.Item2);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -291,6 +291,15 @@ public class ScriptPluginV2 : IPluginV2
|
|||||||
_scriptPluginConfigurationWrapper =
|
_scriptPluginConfigurationWrapper =
|
||||||
new ScriptPluginConfigurationWrapper(_fileName.Split(Path.DirectorySeparatorChar).Last(), ScriptEngine,
|
new ScriptPluginConfigurationWrapper(_fileName.Split(Path.DirectorySeparatorChar).Last(), ScriptEngine,
|
||||||
_configHandler);
|
_configHandler);
|
||||||
|
|
||||||
|
_scriptPluginConfigurationWrapper.ConfigurationUpdated += (configValue, callbackAction) =>
|
||||||
|
{
|
||||||
|
WrapJavaScriptErrorHandling(() =>
|
||||||
|
{
|
||||||
|
callbackAction.DynamicInvoke(JsValue.Undefined, new[] { configValue });
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}, _logger, _fileName, _onProcessingScript);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UnregisterScriptEntities(IManager manager)
|
private void UnregisterScriptEntities(IManager manager)
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
const init = (registerEventCallback, serviceResolver, _) => {
|
const init = (registerEventCallback, serviceResolver, configWrapper) => {
|
||||||
plugin.onLoad(serviceResolver);
|
plugin.onLoad(serviceResolver, configWrapper);
|
||||||
|
|
||||||
registerEventCallback('IManagementEventSubscriptions.ClientPenaltyAdministered', (penaltyEvent, _) => {
|
registerEventCallback('IManagementEventSubscriptions.ClientPenaltyAdministered', (penaltyEvent, _) => {
|
||||||
plugin.onPenalty(penaltyEvent);
|
plugin.onPenalty(penaltyEvent);
|
||||||
@ -10,18 +10,17 @@ const init = (registerEventCallback, serviceResolver, _) => {
|
|||||||
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
author: 'RaidMax',
|
author: 'RaidMax',
|
||||||
version: '2.0',
|
version: '2.1',
|
||||||
name: 'Action on Report',
|
name: 'Action on Report',
|
||||||
|
config: {
|
||||||
enabled: false, // indicates if the plugin is enabled
|
enabled: false, // indicates if the plugin is enabled
|
||||||
reportAction: 'TempBan', // can be TempBan or Ban
|
reportAction: 'TempBan', // can be TempBan or Ban
|
||||||
maxReportCount: 5, // how many reports before action is taken
|
maxReportCount: 5, // how many reports before action is taken
|
||||||
tempBanDurationMinutes: 60, // how long to temporarily ban the player
|
tempBanDurationMinutes: 60 // how long to temporarily ban the player
|
||||||
penaltyType: {
|
|
||||||
'report': 0
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onPenalty: function (penaltyEvent) {
|
onPenalty: function (penaltyEvent) {
|
||||||
if (!this.enabled || penaltyEvent.penalty.type !== this.penaltyType['report']) {
|
if (!this.config.enabled || penaltyEvent.penalty.type !== 'Report') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -34,11 +33,11 @@ const plugin = {
|
|||||||
reportCount++;
|
reportCount++;
|
||||||
this.reportCounts[penaltyEvent.client.networkId] = reportCount;
|
this.reportCounts[penaltyEvent.client.networkId] = reportCount;
|
||||||
|
|
||||||
if (reportCount >= this.maxReportCount) {
|
if (reportCount >= this.config.maxReportCount) {
|
||||||
switch (this.reportAction) {
|
switch (this.config.reportAction) {
|
||||||
case 'TempBan':
|
case 'TempBan':
|
||||||
this.logger.logInformation(`TempBanning client (id) ${penaltyEvent.client.clientId} because they received ${reportCount} reports`);
|
this.logger.logInformation(`TempBanning client (id) ${penaltyEvent.client.clientId} because they received ${reportCount} reports`);
|
||||||
penaltyEvent.client.tempBan(this.translations['PLUGINS_REPORT_ACTION'], System.TimeSpan.FromMinutes(this.tempBanDurationMinutes), penaltyEvent.Client.CurrentServer.asConsoleClient());
|
penaltyEvent.client.tempBan(this.translations['PLUGINS_REPORT_ACTION'], System.TimeSpan.FromMinutes(this.config.tempBanDurationMinutes), penaltyEvent.Client.CurrentServer.asConsoleClient());
|
||||||
break;
|
break;
|
||||||
case 'Ban':
|
case 'Ban':
|
||||||
this.logger.logInformation(`Banning client (id) ${penaltyEvent.client.clientId} because they received ${reportCount} reports`);
|
this.logger.logInformation(`Banning client (id) ${penaltyEvent.client.clientId} because they received ${reportCount} reports`);
|
||||||
@ -48,10 +47,25 @@ const plugin = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad: function (serviceResolver) {
|
onLoad: function (serviceResolver, configWrapper) {
|
||||||
this.translations = serviceResolver.resolveService('ITranslationLookup');
|
this.translations = serviceResolver.resolveService('ITranslationLookup');
|
||||||
this.logger = serviceResolver.resolveService('ILogger', ['ScriptPluginV2']);
|
this.logger = serviceResolver.resolveService('ILogger', ['ScriptPluginV2']);
|
||||||
this.logger.logInformation('ActionOnReport {version} by {author} loaded. Enabled={enabled}', this.version, this.author, this.enabled);
|
this.configWrapper = configWrapper;
|
||||||
|
|
||||||
|
const storedConfig = this.configWrapper.getValue('config', newConfig => {
|
||||||
|
if (newConfig) {
|
||||||
|
plugin.logger.logInformation('ActionOnReport config reloaded. Enabled={Enabled}', newConfig.enabled);
|
||||||
|
plugin.config = newConfig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (storedConfig != null) {
|
||||||
|
this.config = storedConfig
|
||||||
|
} else {
|
||||||
|
this.configWrapper.setValue('config', this.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.logInformation('ActionOnReport {version} by {author} loaded. Enabled={Enabled}', this.version, this.author, this.config.enabled);
|
||||||
this.reportCounts = {};
|
this.reportCounts = {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -7,15 +7,16 @@ const init = (registerNotify, serviceResolver, config) => {
|
|||||||
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
author: 'Amos, RaidMax',
|
author: 'Amos, RaidMax',
|
||||||
version: '2.0',
|
version: '2.1',
|
||||||
name: 'Broadcast Bans',
|
name: 'Broadcast Bans',
|
||||||
config: null,
|
config: null,
|
||||||
logger: null,
|
logger: null,
|
||||||
translations: null,
|
translations: null,
|
||||||
manager: null,
|
manager: null,
|
||||||
|
enableBroadcastBans: false,
|
||||||
|
|
||||||
onClientPenalty: function (penaltyEvent) {
|
onClientPenalty: function (penaltyEvent) {
|
||||||
if (!this.enableBroadcastBans || penaltyEvent.penalty.type !== 5) {
|
if (!this.enableBroadcastBans || penaltyEvent.penalty.type !== 'Ban') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,7 +44,10 @@ const plugin = {
|
|||||||
onLoad: function (serviceResolver, config) {
|
onLoad: function (serviceResolver, config) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.config.setName(this.name);
|
this.config.setName(this.name);
|
||||||
this.enableBroadcastBans = this.config.getValue('EnableBroadcastBans');
|
this.enableBroadcastBans = this.config.getValue('EnableBroadcastBans', newConfig => {
|
||||||
|
plugin.logger.logInformation('{Name} config reloaded. Enabled={Enabled}', plugin.name, newConfig);
|
||||||
|
plugin.enableBroadcastBans = newConfig;
|
||||||
|
});
|
||||||
|
|
||||||
this.manager = serviceResolver.resolveService('IManager');
|
this.manager = serviceResolver.resolveService('IManager');
|
||||||
this.logger = serviceResolver.resolveService('ILogger', ['ScriptPluginV2']);
|
this.logger = serviceResolver.resolveService('ILogger', ['ScriptPluginV2']);
|
||||||
@ -54,7 +58,7 @@ const plugin = {
|
|||||||
this.config.setValue('EnableBroadcastBans', this.enableBroadcastBans);
|
this.config.setValue('EnableBroadcastBans', this.enableBroadcastBans);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.logInformation('{Name} {Version} by {Author} loaded. Enabled={enabled}', this.name, this.version,
|
this.logger.logInformation('{Name} {Version} by {Author} loaded. Enabled={Enabled}', this.name, this.version,
|
||||||
this.author, this.enableBroadcastBans);
|
this.author, this.enableBroadcastBans);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -2,23 +2,24 @@ let vpnExceptionIds = [];
|
|||||||
const vpnAllowListKey = 'Webfront::Nav::Admin::VPNAllowList';
|
const vpnAllowListKey = 'Webfront::Nav::Admin::VPNAllowList';
|
||||||
const vpnWhitelistKey = 'Webfront::Profile::VPNWhitelist';
|
const vpnWhitelistKey = 'Webfront::Profile::VPNWhitelist';
|
||||||
|
|
||||||
const init = (registerNotify, serviceResolver, config, pluginHelper) => {
|
const init = (registerNotify, serviceResolver, configWrapper, pluginHelper) => {
|
||||||
registerNotify('IManagementEventSubscriptions.ClientStateAuthorized', (authorizedEvent, token) => plugin.onClientAuthorized(authorizedEvent, token));
|
registerNotify('IManagementEventSubscriptions.ClientStateAuthorized', (authorizedEvent, token) => plugin.onClientAuthorized(authorizedEvent, token));
|
||||||
|
|
||||||
plugin.onLoad(serviceResolver, config, pluginHelper);
|
plugin.onLoad(serviceResolver, configWrapper, pluginHelper);
|
||||||
return plugin;
|
return plugin;
|
||||||
};
|
};
|
||||||
|
|
||||||
const plugin = {
|
const plugin = {
|
||||||
author: 'RaidMax',
|
author: 'RaidMax',
|
||||||
version: '2.0',
|
version: '2.1',
|
||||||
name: 'VPN Detection Plugin',
|
name: 'VPN Detection Plugin',
|
||||||
manager: null,
|
manager: null,
|
||||||
config: null,
|
configWrapper: null,
|
||||||
logger: null,
|
logger: null,
|
||||||
serviceResolver: null,
|
serviceResolver: null,
|
||||||
translations: null,
|
translations: null,
|
||||||
pluginHelper: null,
|
pluginHelper: null,
|
||||||
|
enabled: true,
|
||||||
|
|
||||||
commands: [{
|
commands: [{
|
||||||
name: 'whitelistvpn',
|
name: 'whitelistvpn',
|
||||||
@ -32,7 +33,7 @@ const plugin = {
|
|||||||
}],
|
}],
|
||||||
execute: (gameEvent) => {
|
execute: (gameEvent) => {
|
||||||
vpnExceptionIds.push(gameEvent.Target.ClientId);
|
vpnExceptionIds.push(gameEvent.Target.ClientId);
|
||||||
plugin.config.setValue('vpnExceptionIds', vpnExceptionIds);
|
plugin.configWrapper.setValue('vpnExceptionIds', vpnExceptionIds);
|
||||||
|
|
||||||
gameEvent.origin.tell(`Successfully whitelisted ${gameEvent.target.name}`);
|
gameEvent.origin.tell(`Successfully whitelisted ${gameEvent.target.name}`);
|
||||||
}
|
}
|
||||||
@ -49,7 +50,7 @@ const plugin = {
|
|||||||
}],
|
}],
|
||||||
execute: (gameEvent) => {
|
execute: (gameEvent) => {
|
||||||
vpnExceptionIds = vpnExceptionIds.filter(exception => parseInt(exception) !== parseInt(gameEvent.Target.ClientId));
|
vpnExceptionIds = vpnExceptionIds.filter(exception => parseInt(exception) !== parseInt(gameEvent.Target.ClientId));
|
||||||
plugin.config.setValue('vpnExceptionIds', vpnExceptionIds);
|
plugin.configWrapper.setValue('vpnExceptionIds', vpnExceptionIds);
|
||||||
|
|
||||||
gameEvent.origin.tell(`Successfully disallowed ${gameEvent.target.name} from connecting with VPN`);
|
gameEvent.origin.tell(`Successfully disallowed ${gameEvent.target.name} from connecting with VPN`);
|
||||||
}
|
}
|
||||||
@ -148,30 +149,45 @@ const plugin = {
|
|||||||
],
|
],
|
||||||
|
|
||||||
onClientAuthorized: async function (authorizeEvent, token) {
|
onClientAuthorized: async function (authorizeEvent, token) {
|
||||||
if (authorizeEvent.client.isBot) {
|
if (authorizeEvent.client.isBot || !this.enabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.checkForVpn(authorizeEvent.client, token);
|
await this.checkForVpn(authorizeEvent.client, token);
|
||||||
},
|
},
|
||||||
|
|
||||||
onLoad: function (serviceResolver, config, pluginHelper) {
|
onLoad: function (serviceResolver, configWrapper, pluginHelper) {
|
||||||
this.serviceResolver = serviceResolver;
|
this.serviceResolver = serviceResolver;
|
||||||
this.config = config;
|
this.configWrapper = configWrapper;
|
||||||
this.pluginHelper = pluginHelper;
|
this.pluginHelper = pluginHelper;
|
||||||
this.manager = this.serviceResolver.resolveService('IManager');
|
this.manager = this.serviceResolver.resolveService('IManager');
|
||||||
this.logger = this.serviceResolver.resolveService('ILogger', ['ScriptPluginV2']);
|
this.logger = this.serviceResolver.resolveService('ILogger', ['ScriptPluginV2']);
|
||||||
this.translations = this.serviceResolver.resolveService('ITranslationLookup');
|
this.translations = this.serviceResolver.resolveService('ITranslationLookup');
|
||||||
|
|
||||||
this.config.setName(this.name); // use legacy key
|
this.configWrapper.setName(this.name); // use legacy key
|
||||||
this.config.getValue('vpnExceptionIds').forEach(element => vpnExceptionIds.push(parseInt(element)));
|
this.configWrapper.getValue('vpnExceptionIds').forEach(element => vpnExceptionIds.push(parseInt(element)));
|
||||||
this.logger.logInformation(`Loaded ${vpnExceptionIds.length} ids into whitelist`);
|
this.logger.logInformation(`Loaded ${vpnExceptionIds.length} ids into whitelist`);
|
||||||
|
|
||||||
|
this.enabled = this.configWrapper.getValue('enabled', newValue => {
|
||||||
|
if (newValue) {
|
||||||
|
plugin.logger.logInformation('{Name} configuration updated. Enabled={Enabled}', newValue);
|
||||||
|
plugin.enabled = newValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.enabled === undefined) {
|
||||||
|
this.configWrapper.setValue('enabled', true);
|
||||||
|
this.enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
this.interactionRegistration = this.serviceResolver.resolveService('IInteractionRegistration');
|
this.interactionRegistration = this.serviceResolver.resolveService('IInteractionRegistration');
|
||||||
this.interactionRegistration.unregisterInteraction(vpnWhitelistKey);
|
this.interactionRegistration.unregisterInteraction(vpnWhitelistKey);
|
||||||
this.interactionRegistration.unregisterInteraction(vpnAllowListKey);
|
this.interactionRegistration.unregisterInteraction(vpnAllowListKey);
|
||||||
|
|
||||||
|
this.logger.logInformation('{Name} {Version} by {Author} loaded. Enabled={Enabled}', this.name, this.version,
|
||||||
|
this.author, this.enabled);
|
||||||
},
|
},
|
||||||
|
|
||||||
checkForVpn: async function (origin, token) {
|
checkForVpn: async function (origin, _) {
|
||||||
let exempt = false;
|
let exempt = false;
|
||||||
// prevent players that are exempt from being kicked
|
// prevent players that are exempt from being kicked
|
||||||
vpnExceptionIds.forEach(function (id) {
|
vpnExceptionIds.forEach(function (id) {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
using System.Threading.Tasks;
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace SharedLibraryCore.Interfaces;
|
namespace SharedLibraryCore.Interfaces;
|
||||||
|
|
||||||
@ -7,4 +8,5 @@ public interface IConfigurationHandlerV2<TConfigurationType> where TConfiguratio
|
|||||||
Task<TConfigurationType> Get(string configurationName, TConfigurationType defaultConfiguration = null);
|
Task<TConfigurationType> Get(string configurationName, TConfigurationType defaultConfiguration = null);
|
||||||
Task Set(TConfigurationType configuration);
|
Task Set(TConfigurationType configuration);
|
||||||
Task Set();
|
Task Set();
|
||||||
|
event Action<TConfigurationType> Updated;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user