Add PluginManager

This commit is contained in:
js6pak committed 2021-06-12 15:51:39 +02:00
1 parent ced530b83a
commit 9d31752bf0
15 files changed
+334 -120

No files matched your search

@@ -0,0 +1,42 @@
using System;
namespace Impostor.Api.Plugins
{
public interface IPluginInformation
{
/// <summary>
/// Gets the unique id of this plugin.
/// </summary>
string Id { get; }
/// <summary>
/// Gets the human readable name of this plugin.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the author of this plugin.
/// </summary>
string Author { get; }
/// <summary>
/// Gets the version of this plugin.
/// </summary>
string Version { get; }
/// <summary>
/// Gets the optional startup object of this plugin.
/// </summary>
IPluginStartup? Startup { get; }
/// <summary>
/// Gets the instance of this plugin.
/// </summary>
IPlugin? Instance { get; }
/// <summary>
/// Gets the instance type of this plugin.
/// </summary>
Type PluginType { get; }
}
}
@@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace Impostor.Api.Plugins
{
public interface IPluginManager
{
IReadOnlyList<IPluginInformation> Plugins { get; }
}
}
@@ -1,3 +1,4 @@
using System.Linq;
using System.Threading.Tasks;
using Impostor.Api.Games.Managers;
using Impostor.Api.Innersloth;
@@ -11,17 +12,21 @@ namespace Impostor.Plugins.Example
{
private readonly ILogger<ExamplePlugin> _logger;
private readonly IGameManager _gameManager;
private readonly IPluginManager _pluginManager;
public ExamplePlugin(ILogger<ExamplePlugin> logger, IGameManager gameManager)
public ExamplePlugin(ILogger<ExamplePlugin> logger, IGameManager gameManager, IPluginManager pluginManager)
{
_logger = logger;
_gameManager = gameManager;
_pluginManager = pluginManager;
}
public override async ValueTask EnableAsync()
{
_logger.LogInformation("Example is being enabled.");
_logger.LogInformation("Following plugins are installed: {@plugins}", _pluginManager.Plugins.Select(x => $"{x.Id} ({x.Version})"));
var game = await _gameManager.CreateAsync(new GameOptionsData());
if (game == null)
{
@@ -1,9 +1,11 @@
using System.Collections.Generic;
namespace Impostor.Server.Plugins
namespace Impostor.Server.Config
{
public class PluginConfig
public class PluginLoaderConfig
{
public const string Section = "PluginLoader";
public List<string> Paths { get; set; } = new List<string>();
public List<string> LibraryPaths { get; set; } = new List<string>();
+3 -1
View File
@@ -26,10 +26,12 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="5.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="5.0.0" />
<PackageReference Include="Microsoft.Extensions.ObjectPool" Version="5.0.4" />
<PackageReference Include="Microsoft.Extensions.ObjectPool" Version="5.0.7" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="4.1.2" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
<PackageReference Include="StyleCop.Analyzers.Unstable" Version="1.2.0.333">
<PrivateAssets>all</PrivateAssets>
@@ -8,17 +8,20 @@ namespace Impostor.Server.Plugins
{
private Assembly? _assembly;
public AssemblyInformation(AssemblyName assemblyName, string path, bool isPlugin)
public AssemblyInformation(AssemblyName assemblyName, string path, bool isPlugin, bool isDefaultAssembly)
{
AssemblyName = assemblyName;
Path = path;
IsPlugin = isPlugin;
IsDefaultAssembly = isDefaultAssembly;
}
public string Path { get; }
public bool IsPlugin { get; }
public bool IsDefaultAssembly { get; }
public AssemblyName AssemblyName { get; }
public Assembly Load(AssemblyLoadContext context)
@@ -9,6 +9,8 @@ namespace Impostor.Server.Plugins
bool IsPlugin { get; }
bool IsDefaultAssembly { get; }
Assembly Load(AssemblyLoadContext context);
}
}
@@ -7,16 +7,19 @@ namespace Impostor.Server.Plugins
{
private readonly Assembly _assembly;
public LoadedAssemblyInformation(Assembly assembly)
public LoadedAssemblyInformation(Assembly assembly, bool isDefaultAssembly)
{
AssemblyName = assembly.GetName();
_assembly = assembly;
IsDefaultAssembly = isDefaultAssembly;
}
public AssemblyName AssemblyName { get; }
public bool IsPlugin => false;
public bool IsDefaultAssembly { get; }
public Assembly Load(AssemblyLoadContext context)
{
return _assembly;
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Runtime.Loader;
namespace Impostor.Server.Plugins
{
public class PluginAssemblyLoadContext : AssemblyLoadContext, IDisposable
{
private readonly IDictionary<string, IAssemblyInformation> _assembliesByName;
public PluginAssemblyLoadContext(IDictionary<string, IAssemblyInformation> assembliesByName)
: base(true)
{
_assembliesByName = assembliesByName;
}
public void Dispose()
{
_assembliesByName.Clear();
this.Unload();
}
protected override Assembly? Load(AssemblyName name)
{
return name.Name != null && _assembliesByName.TryGetValue(name.Name, out var assemblyInfo)
? assemblyInfo.Load(this)
: null;
}
}
}
@@ -6,7 +6,7 @@ using Impostor.Api.Plugins;
namespace Impostor.Server.Plugins
{
public class PluginInformation
public class PluginInformation : IPluginInformation
{
private readonly ImpostorPluginAttribute _attribute;
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Impostor.Api.Plugins;
@@ -13,20 +12,20 @@ namespace Impostor.Server.Plugins
{
private readonly ILogger<PluginLoaderService> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly List<PluginInformation> _plugins;
private readonly PluginManager _pluginManager;
public PluginLoaderService(ILogger<PluginLoaderService> logger, IServiceProvider serviceProvider, List<PluginInformation> plugins)
public PluginLoaderService(ILogger<PluginLoaderService> logger, IServiceProvider serviceProvider, PluginManager pluginManager)
{
_logger = logger;
_serviceProvider = serviceProvider;
_plugins = plugins;
_pluginManager = pluginManager;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Loading plugins.");
_logger.LogInformation("Enabling plugins.");
foreach (var plugin in _plugins)
foreach (var plugin in _pluginManager.Plugins)
{
_logger.LogInformation("Enabling plugin {0}.", plugin);
@@ -38,10 +37,10 @@ namespace Impostor.Server.Plugins
}
_logger.LogInformation(
_plugins.Count == 1
? "Loaded {0} plugin."
: "Loaded {0} plugins.",
_plugins.Count
_pluginManager.Plugins.Count == 1
? "Enabled {0} plugin."
: "Enabled {0} plugins.",
_pluginManager.Plugins.Count
);
}
@@ -49,7 +48,7 @@ namespace Impostor.Server.Plugins
{
// Disable all plugins with a valid instance set.
// In the case of a failed startup, some can be null.
foreach (var plugin in _plugins)
foreach (var plugin in _pluginManager.Plugins)
{
if (plugin.Instance != null)
{
@@ -57,6 +56,7 @@ namespace Impostor.Server.Plugins
// Disable plugin.
await plugin.Instance.DisableAsync();
plugin.Instance = null;
}
}
}
@@ -4,24 +4,60 @@ using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using System.Threading;
using System.Threading.Tasks;
using Impostor.Api.Plugins;
using Impostor.Server.Config;
using Impostor.Server.Plugins.Proxies;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.Hosting;
using Serilog;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Impostor.Server.Plugins
{
public static class PluginLoader
public class PluginManager : IPluginManager, IHostedService
{
private static readonly ILogger Logger = Log.ForContext(typeof(PluginLoader));
private readonly ILogger<PluginManager> _logger;
private readonly IOptions<PluginLoaderConfig> _pluginConfig;
private readonly IConfiguration _configuration;
private readonly IServiceProvider _serviceProvider;
private readonly ServiceProxyCollection _serviceProxyCollection;
public static IHostBuilder UsePluginLoader(this IHostBuilder builder, PluginConfig config)
private PluginAssemblyLoadContext? _currentContext;
public PluginManager(ILogger<PluginManager> logger, IOptions<PluginLoaderConfig> pluginConfig, IConfiguration configuration, IServiceProvider serviceProvider, ServiceProxyCollection serviceProxyCollection)
{
var assemblyInfos = new List<IAssemblyInformation>();
var context = AssemblyLoadContext.Default;
_logger = logger;
_pluginConfig = pluginConfig;
_configuration = configuration;
_serviceProvider = serviceProvider;
_serviceProxyCollection = serviceProxyCollection;
}
public IHost? CurrentHost { get; private set; }
public List<PluginInformation> Plugins { get; } = new List<PluginInformation>();
IReadOnlyList<IPluginInformation> IPluginManager.Plugins => Plugins.AsReadOnly();
public Task StartAsync(CancellationToken cancellationToken)
{
CurrentHost = CreatePluginHost();
return CurrentHost.StartAsync(cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken)
{
return CurrentHost?.StopAsync(cancellationToken) ?? Task.CompletedTask;
}
private IDictionary<string, IAssemblyInformation> GetAssemblyInformation()
{
// Add the plugins and libraries.
var config = _pluginConfig.Value;
var pluginPaths = new List<string>(config.Paths);
var libraryPaths = new List<string>(config.LibraryPaths);
CheckPaths(pluginPaths);
@@ -35,38 +71,69 @@ namespace Impostor.Server.Plugins
var matcher = new Matcher(StringComparison.OrdinalIgnoreCase);
matcher.AddInclude("*.dll");
matcher.AddExclude("Impostor.Api.dll");
matcher.AddExclude("Impostor.Server.dll");
RegisterAssemblies(pluginPaths, matcher, assemblyInfos, true);
RegisterAssemblies(libraryPaths, matcher, assemblyInfos, false);
var assembliesByName = AssemblyLoadContext.Default.Assemblies
.Where(a => a.GetName().Name != null)
.ToDictionary(
a => a.GetName().Name!,
a => (IAssemblyInformation)new LoadedAssemblyInformation(a, true));
// Register the resolver to the current context.
// TODO: Move this to a new context so we can unload/reload plugins.
context.Resolving += (loadContext, name) =>
RegisterAssemblies(assembliesByName, pluginPaths, matcher, true);
RegisterAssemblies(assembliesByName, libraryPaths, matcher, false);
return assembliesByName;
}
private IHost CreatePluginHost()
{
var assembliesByName = GetAssemblyInformation();
_currentContext ??= new PluginAssemblyLoadContext(assembliesByName);
var pluginHostBuilder = new HostBuilder()
.UseContentRoot(_configuration.GetValue<string>(HostDefaults.ContentRootKey))
.ConfigureHostConfiguration(builder => { builder.AddConfiguration(_configuration); })
.UseServiceProviderFactory(_ => new DefaultServiceProviderFactory());
Plugins.Clear();
Plugins.AddRange(GetPluginInformation(assembliesByName, _currentContext));
foreach (var plugin in Plugins)
{
Logger.Verbose("Loading assembly {0} v{1}", name.Name, name.Version);
plugin.Startup?.ConfigureHost(pluginHostBuilder);
}
// Some plugins may be referencing another Impostor.Api version and try to load it.
// We want to only use the one shipped with the server.
if (name.Name == "Impostor.Api")
pluginHostBuilder.ConfigureServices(services =>
{
services.AddHostedService<PluginLoaderService>();
foreach (var (type, lifetime) in _serviceProxyCollection.ServiceTypes)
{
return typeof(IPlugin).Assembly;
services.Add(new ServiceDescriptor(
type,
_ => _serviceProvider.GetService(type)!,
lifetime));
}
var info = assemblyInfos.FirstOrDefault(a => a.AssemblyName.Name == name.Name);
foreach (var plugin in Plugins)
{
plugin.Startup?.ConfigureServices(services);
}
});
return info?.Load(loadContext);
};
return pluginHostBuilder.Build();
}
// TODO: Catch uncaught exceptions.
var assemblies = assemblyInfos
private List<PluginInformation> GetPluginInformation(IDictionary<string, IAssemblyInformation> assembliesByName, PluginAssemblyLoadContext context)
{
var plugins = new List<PluginInformation>();
var pluginAssemblies = assembliesByName
.Values
.Where(a => a.IsPlugin)
.Select(a => context.LoadFromAssemblyName(a.AssemblyName))
.ToList();
// Find all plugins.
var plugins = new List<PluginInformation>();
foreach (var assembly in assemblies)
foreach (var assembly in pluginAssemblies)
{
// Find plugin startup.
var pluginStartup = assembly
@@ -76,7 +143,7 @@ namespace Impostor.Server.Plugins
if (pluginStartup.Count > 1)
{
Logger.Warning("A plugin may only define zero or one IPluginStartup implementation ({0}).", assembly);
_logger.LogWarning("A plugin may only define zero or one IPluginStartup implementation ({0}).", assembly);
continue;
}
@@ -91,7 +158,7 @@ namespace Impostor.Server.Plugins
if (plugin.Count != 1)
{
Logger.Warning("A plugin must define exactly one IPlugin or PluginBase implementation ({0}).", assembly);
_logger.LogWarning("A plugin must define exactly one IPlugin or PluginBase implementation ({0}).", assembly);
continue;
}
@@ -104,61 +171,75 @@ namespace Impostor.Server.Plugins
plugin.Single()));
}
var orderedPlugins = LoadOrderPlugins(plugins);
foreach (var plugin in orderedPlugins)
{
plugin.Startup?.ConfigureHost(builder);
}
builder.ConfigureServices(services =>
{
services.AddHostedService(provider => ActivatorUtilities.CreateInstance<PluginLoaderService>(provider, orderedPlugins));
foreach (var plugin in orderedPlugins)
{
plugin.Startup?.ConfigureServices(services);
}
});
return builder;
return OrderPlugins(plugins);
}
private static void CheckPaths(IEnumerable<string> paths)
private void CheckPaths(IEnumerable<string> paths)
{
foreach (var path in paths)
{
if (!Directory.Exists(path))
{
Logger.Warning("Path {path} was specified in the PluginLoader configuration, but this folder doesn't exist!", path);
_logger.LogWarning("Path {path} was specified in the PluginLoader configuration, but this folder doesn't exist!", path);
}
}
}
private static void RegisterAssemblies(
private void RegisterAssemblies(
IDictionary<string, IAssemblyInformation> assemblies,
IEnumerable<string> paths,
Matcher matcher,
ICollection<IAssemblyInformation> assemblyInfos,
bool isPlugin)
{
foreach (var path in paths.SelectMany(matcher.GetResultsInFullPath))
{
AssemblyName assemblyName;
AssemblyName name;
try
{
assemblyName = AssemblyName.GetAssemblyName(path);
name = AssemblyName.GetAssemblyName(path);
}
catch (BadImageFormatException)
{
continue;
}
assemblyInfos.Add(new AssemblyInformation(assemblyName, path, isPlugin));
if (name.Name == null)
{
_logger.LogWarning(
"Skipped {0} because the assembly name could not be resolved",
path);
continue;
}
if (assemblies.TryGetValue(name.Name, out var loaded))
{
var version = name.Version;
var loadedVersion = loaded.AssemblyName.Version;
if (loadedVersion != null && version != null && version.Major != loadedVersion.Major)
{
_logger.LogError(
"Assembly {0} is already loaded with version {1} while loading version {2}",
name.Name,
loadedVersion,
version);
}
else
{
_logger.LogDebug("Skipped assembly {0} because it's already loaded", name.Name);
}
continue;
}
_logger.LogTrace("Registered assembly {name} v{version} ({path})", name.Name, name.Version, path);
assemblies.Add(name.Name, new AssemblyInformation(name, path, isPlugin, false));
}
}
private static List<PluginInformation> LoadOrderPlugins(IEnumerable<PluginInformation> plugins)
private List<PluginInformation> OrderPlugins(IEnumerable<PluginInformation> plugins)
{
var pluginDictionary = new Dictionary<string, PluginInformation>();
var hardDependencies = new Dictionary<string, List<string>>();
@@ -208,7 +289,7 @@ namespace Impostor.Server.Plugins
return ordered;
}
private static List<string> CheckHardDependencies(
private List<string> CheckHardDependencies(
List<string> plugins,
IReadOnlyDictionary<string, List<string>> hardDependencies)
{
@@ -221,7 +302,7 @@ namespace Impostor.Server.Plugins
foreach (var dependency in hardDependencies[plugin].Where(dependency => !plugins.Contains(dependency)))
{
Logger.Error(
_logger.LogError(
"The plugin {plugin} has defined the plugin {dependency} as a hard dependency but its not present! {plugin} will not loaded.",
plugin,
dependency,
@@ -239,7 +320,7 @@ namespace Impostor.Server.Plugins
return plugins;
}
private static void RecursiveOrder(
private void RecursiveOrder(
string plugin,
IReadOnlyDictionary<string, List<string>> dependencyGraph,
ICollection<string> processed,
@@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace Impostor.Server.Plugins.Proxies
{
public class ServiceProxyCollection
{
public ServiceProxyCollection(IReadOnlyList<ServiceRegistration> serviceTypes)
{
ServiceTypes = serviceTypes;
}
public IReadOnlyList<ServiceRegistration> ServiceTypes { get; }
}
}
@@ -0,0 +1,7 @@
using System;
using Microsoft.Extensions.DependencyInjection;
namespace Impostor.Server.Plugins.Proxies
{
public record ServiceRegistration(Type Type, ServiceLifetime Lifetime);
}
+59 -46
View File
@@ -1,12 +1,15 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Impostor.Api.Events;
using Impostor.Api.Events.Managers;
using Impostor.Api.Games;
using Impostor.Api.Games.Managers;
using Impostor.Api.Net.Custom;
using Impostor.Api.Net.Manager;
using Impostor.Api.Net.Messages;
using Impostor.Api.Plugins;
using Impostor.Api.Utils;
using Impostor.Hazel.Extensions;
using Impostor.Server.Config;
@@ -18,11 +21,13 @@ using Impostor.Server.Net.Manager;
using Impostor.Server.Net.Messages;
using Impostor.Server.Net.Redirector;
using Impostor.Server.Plugins;
using Impostor.Server.Plugins.Proxies;
using Impostor.Server.Recorder;
using Impostor.Server.Utils;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.ObjectPool;
using Serilog;
using Serilog.Events;
@@ -31,38 +36,16 @@ namespace Impostor.Server
{
internal static class Program
{
private static int Main(string[] args)
private static async Task<int> Main(string[] args)
{
#if DEBUG
var logLevel = LogEventLevel.Debug;
#else
var logLevel = LogEventLevel.Information;
#endif
if (args.Contains("--verbose"))
{
logLevel = LogEventLevel.Verbose;
}
else if (args.Contains("--errors-only"))
{
logLevel = LogEventLevel.Error;
}
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Is(logLevel)
#if DEBUG
.MinimumLevel.Override("Microsoft", LogEventLevel.Debug)
#else
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
#endif
.Enrich.FromLogContext()
.WriteTo.Console()
.CreateLogger();
.CreateBootstrapLogger();
try
{
Log.Information("Starting Impostor v{0}", DotnetUtils.Version);
CreateHostBuilder(args).Build().Run();
await CreateHostBuilder(args).RunConsoleAsync();
return 0;
}
catch (Exception ex)
@@ -76,25 +59,8 @@ namespace Impostor.Server
}
}
private static IConfiguration CreateConfiguration(string[] args)
{
var configurationBuilder = new ConfigurationBuilder();
configurationBuilder.SetBasePath(Directory.GetCurrentDirectory());
configurationBuilder.AddJsonFile("config.json", true);
configurationBuilder.AddJsonFile("config.Development.json", true);
configurationBuilder.AddEnvironmentVariables(prefix: "IMPOSTOR_");
configurationBuilder.AddCommandLine(args);
return configurationBuilder.Build();
}
private static IHostBuilder CreateHostBuilder(string[] args)
{
var configuration = CreateConfiguration(args);
var pluginConfig = configuration.GetSection("PluginLoader")
.Get<PluginConfig>() ?? new PluginConfig();
return Host.CreateDefaultBuilder(args)
.UseContentRoot(Directory.GetCurrentDirectory())
#if DEBUG
@@ -104,7 +70,10 @@ namespace Impostor.Server
#endif
.ConfigureAppConfiguration(builder =>
{
builder.AddConfiguration(configuration);
builder.AddJsonFile("config.json", true);
builder.AddJsonFile("config.Development.json", true);
builder.AddEnvironmentVariables(prefix: "IMPOSTOR_");
builder.AddCommandLine(args);
})
.ConfigureServices((host, services) =>
{
@@ -134,6 +103,7 @@ namespace Impostor.Server
services.Configure<AnnouncementsServerConfig>(host.Configuration.GetSection(AnnouncementsServerConfig.Section));
services.Configure<AuthServerConfig>(host.Configuration.GetSection(AuthServerConfig.Section));
services.Configure<ServerRedirectorConfig>(host.Configuration.GetSection(ServerRedirectorConfig.Section));
services.Configure<PluginLoaderConfig>(host.Configuration.GetSection(PluginLoaderConfig.Section));
if (redirector.Enabled)
{
@@ -228,10 +198,53 @@ namespace Impostor.Server
{
services.AddHostedService<AuthService>();
}
services.AddSingleton<PluginManager>();
services.AddSingleton<IPluginManager>(p => p.GetRequiredService<PluginManager>());
services.AddHostedService(p => p.GetRequiredService<PluginManager>());
})
.UseSerilog()
.UseConsoleLifetime()
.UsePluginLoader(pluginConfig);
.UseSerilog((context, loggerConfiguration) =>
{
#if DEBUG
var logLevel = LogEventLevel.Debug;
#else
var logLevel = LogEventLevel.Information;
#endif
if (args.Contains("--verbose"))
{
logLevel = LogEventLevel.Verbose;
}
else if (args.Contains("--errors-only"))
{
logLevel = LogEventLevel.Error;
}
loggerConfiguration
.MinimumLevel.Is(logLevel)
#if DEBUG
.MinimumLevel.Override("Microsoft", LogEventLevel.Debug)
#else
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
#endif
.Enrich.FromLogContext()
.WriteTo.Console()
.ReadFrom.Configuration(context.Configuration);
})
.ConfigureServices(services =>
{
services.AddTransient(serviceProvider => serviceProvider.GetRequiredService<PluginManager>().CurrentHost?.Services.GetServices<IEventListener>() ?? Enumerable.Empty<IEventListener>());
var apiAssembly = typeof(IPlugin).Assembly;
var serilogAssembly = typeof(Serilog.ILogger).Assembly;
services.AddSingleton(new ServiceProxyCollection(
services
.Where(t => t.ServiceType.Assembly == apiAssembly || t.ServiceType.Assembly == serilogAssembly || t.ServiceType == typeof(PluginManager) || t.ServiceType == typeof(ILoggerFactory))
.Select(t => new ServiceRegistration(t.ServiceType, t.Lifetime))
.Distinct()
.ToArray()));
});
}
}
}