From 6b8854853c0cd02d323ccccf6964f94f868cef06 Mon Sep 17 00:00:00 2001 From: naganohara_irene Date: Thu, 18 Dec 2025 16:41:35 +0800 Subject: [PATCH] feat: plugin system, need test and refactor --- .../Abstractions/Plugin/IServerPlugin.cs | 31 ++ .../Plugin/PluginVersionHelper.cs | 37 ++ .../Controllers/PluginController.cs | 138 +++++++ .../Services/Grpc/PluginGrpcService.cs | 136 +++++++ .../Services/PluginLoaderService.cs | 215 ++++++++++ .../Services/PluginManagerService.cs | 378 ++++++++++++++++++ .../Protobuf/Client/PluginServiceScReq.proto | 44 ++ .../Protobuf/Enum/CommandTypes.proto | 8 +- .../Protobuf/Enum/Retcode.proto | 7 +- .../Protobuf/Server/PluginServiceScRsp.proto | 35 ++ .../Protobuf/Service/PluginService.proto | 18 + 11 files changed, 1045 insertions(+), 2 deletions(-) create mode 100644 ClassIsland.ManagementServer.Server/Abstractions/Plugin/IServerPlugin.cs create mode 100644 ClassIsland.ManagementServer.Server/Abstractions/Plugin/PluginVersionHelper.cs create mode 100644 ClassIsland.ManagementServer.Server/Controllers/PluginController.cs create mode 100644 ClassIsland.ManagementServer.Server/Services/Grpc/PluginGrpcService.cs create mode 100644 ClassIsland.ManagementServer.Server/Services/PluginLoaderService.cs create mode 100644 ClassIsland.ManagementServer.Server/Services/PluginManagerService.cs create mode 100644 ClassIsland.Shared/Protobuf/Client/PluginServiceScReq.proto create mode 100644 ClassIsland.Shared/Protobuf/Server/PluginServiceScRsp.proto create mode 100644 ClassIsland.Shared/Protobuf/Service/PluginService.proto diff --git a/ClassIsland.ManagementServer.Server/Abstractions/Plugin/IServerPlugin.cs b/ClassIsland.ManagementServer.Server/Abstractions/Plugin/IServerPlugin.cs new file mode 100644 index 0000000..3e58320 --- /dev/null +++ b/ClassIsland.ManagementServer.Server/Abstractions/Plugin/IServerPlugin.cs @@ -0,0 +1,31 @@ +namespace ClassIsland.ManagementServer.Server.Abstractions.Plugin; + +public interface IServerPlugin +{ + string Identifier { get; } + + string Name { get; } + + string Description { get; } + + string Version { get; } + + string MinClientVersion { get; } + + string? MaxClientVersion { get; } + + Task OnLoadAsync(); + + Task OnUnloadAsync(); + + Task HandleMessageAsync(Guid clientId, string messageType, byte[] payload); + + bool IsCompatible(string clientPluginVersion); +} + +public class PluginMessageResponse +{ + public int RetCode { get; set; } + public string MessageType { get; set; } = string.Empty; + public byte[] Payload { get; set; } = Array.Empty(); +} diff --git a/ClassIsland.ManagementServer.Server/Abstractions/Plugin/PluginVersionHelper.cs b/ClassIsland.ManagementServer.Server/Abstractions/Plugin/PluginVersionHelper.cs new file mode 100644 index 0000000..deb2ecf --- /dev/null +++ b/ClassIsland.ManagementServer.Server/Abstractions/Plugin/PluginVersionHelper.cs @@ -0,0 +1,37 @@ +namespace ClassIsland.ManagementServer.Server.Abstractions.Plugin; + +public static class PluginVersionHelper +{ + public static bool IsVersionCompatible(string clientVersion, string minVersion, string? maxVersion) + { + if (!Version.TryParse(clientVersion, out var parsedClientVersion)) + { + return false; + } + + if (!Version.TryParse(minVersion, out var parsedMinVersion)) + { + return false; + } + + if (parsedClientVersion < parsedMinVersion) + { + return false; + } + + if (maxVersion != null && Version.TryParse(maxVersion, out var parsedMaxVersion)) + { + if (parsedClientVersion > parsedMaxVersion) + { + return false; + } + } + + return true; + } + + public static Version? TryParseVersion(string versionString) + { + return Version.TryParse(versionString, out var version) ? version : null; + } +} diff --git a/ClassIsland.ManagementServer.Server/Controllers/PluginController.cs b/ClassIsland.ManagementServer.Server/Controllers/PluginController.cs new file mode 100644 index 0000000..02727fc --- /dev/null +++ b/ClassIsland.ManagementServer.Server/Controllers/PluginController.cs @@ -0,0 +1,138 @@ +using ClassIsland.ManagementServer.Server.Authorization; +using ClassIsland.ManagementServer.Server.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace ClassIsland.ManagementServer.Server.Controllers; + +[ApiController] +[Authorize(Roles = Roles.Admin)] +[Route("api/v1/plugins/")] +public class PluginController(PluginManagerService pluginManagerService, PluginLoaderService pluginLoaderService) : ControllerBase +{ + private PluginManagerService PluginManagerService { get; } = pluginManagerService; + private PluginLoaderService PluginLoaderService { get; } = pluginLoaderService; + + [HttpGet] + public IActionResult GetPlugins() + { + var plugins = PluginManagerService.GetAllPlugins().Select(p => new + { + p.Identifier, + p.Name, + p.Description, + p.Version, + p.MinClientVersion, + p.MaxClientVersion + }); + return Ok(plugins); + } + + [HttpGet("{identifier}")] + public IActionResult GetPlugin(string identifier) + { + var plugin = PluginManagerService.Plugins.GetValueOrDefault(identifier); + if (plugin == null) + { + return NotFound(new { message = $"Plugin '{identifier}' not found" }); + } + + return Ok(new + { + plugin.Identifier, + plugin.Name, + plugin.Description, + plugin.Version, + plugin.MinClientVersion, + plugin.MaxClientVersion + }); + } + + [HttpPost("load")] + public async Task LoadPlugin([FromBody] LoadPluginRequest request) + { + var plugins = await PluginLoaderService.LoadPluginFromAssemblyAsync(request.AssemblyPath); + if (plugins.Count > 0) + { + return Ok(new + { + message = $"Loaded {plugins.Count} plugin(s)", + plugins = plugins.Select(p => new { p.Identifier, p.Name, p.Version }) + }); + } + return BadRequest(new { message = "No plugins found in the specified assembly" }); + } + + [HttpPost("load-directory")] + public async Task LoadPluginsFromDirectory([FromBody] LoadPluginDirectoryRequest request) + { + await PluginLoaderService.LoadPluginsFromDirectoryAsync(request.Directory); + return Ok(new { message = $"Loaded plugins from directory: {request.Directory}" }); + } + + [HttpDelete("{identifier}")] + public async Task UnloadPlugin(string identifier, [FromQuery] int timeoutSeconds = 30) + { + var result = await PluginLoaderService.UnloadPluginAsync(identifier, TimeSpan.FromSeconds(timeoutSeconds)); + if (result) + { + return Ok(new { message = $"Plugin '{identifier}' unloaded successfully" }); + } + return BadRequest(new { message = $"Failed to unload plugin '{identifier}'" }); + } + + [HttpPost("{identifier}/hot-reload")] + public async Task HotReloadPlugin(string identifier, [FromBody] HotReloadRequest request, [FromQuery] int timeoutSeconds = 30) + { + var result = await PluginLoaderService.HotReloadPluginAsync(identifier, request.NewAssemblyPath, TimeSpan.FromSeconds(timeoutSeconds)); + if (result) + { + return Ok(new { message = $"Plugin '{identifier}' hot reloaded successfully" }); + } + return BadRequest(new { message = $"Failed to hot reload plugin '{identifier}'" }); + } + + [HttpPost("{identifier}/notify-enable")] + public async Task NotifyPluginEnable(string identifier) + { + if (!PluginManagerService.Plugins.ContainsKey(identifier)) + { + return NotFound(new { message = $"Plugin '{identifier}' not found" }); + } + + await PluginManagerService.NotifyClientsPluginEnableAsync(identifier); + return Ok(new { message = $"Clients notified to enable plugin '{identifier}'" }); + } + + [HttpGet("{identifier}/pending-unload")] + public IActionResult GetPendingUnload(string identifier) + { + if (!PluginManagerService.PendingUnloadConfirmations.TryGetValue(identifier, out var confirmations)) + { + return NotFound(new { message = $"No pending unload for plugin '{identifier}'" }); + } + + return Ok(new + { + PluginIdentifier = identifier, + TotalClients = confirmations.Count, + ConfirmedClients = confirmations.Count(c => c.Value), + PendingClients = confirmations.Where(c => !c.Value).Select(c => c.Key.ToString()) + }); + } +} + +public class LoadPluginRequest +{ + public string AssemblyPath { get; set; } = string.Empty; +} + +public class LoadPluginDirectoryRequest +{ + public string Directory { get; set; } = string.Empty; +} + +public class HotReloadRequest +{ + public string NewAssemblyPath { get; set; } = string.Empty; +} diff --git a/ClassIsland.ManagementServer.Server/Services/Grpc/PluginGrpcService.cs b/ClassIsland.ManagementServer.Server/Services/Grpc/PluginGrpcService.cs new file mode 100644 index 0000000..b9e090b --- /dev/null +++ b/ClassIsland.ManagementServer.Server/Services/Grpc/PluginGrpcService.cs @@ -0,0 +1,136 @@ +using ClassIsland.ManagementServer.Server.Abstractions.Plugin; +using ClassIsland.Shared.Protobuf.Client; +using ClassIsland.Shared.Protobuf.Enum; +using ClassIsland.Shared.Protobuf.Server; +using ClassIsland.Shared.Protobuf.Service; +using Google.Protobuf; +using Grpc.Core; + +namespace ClassIsland.ManagementServer.Server.Services.Grpc; + +public class PluginGrpcService( + ILogger logger, + PluginManagerService pluginManagerService, + CyreneMspConnectionService connectionService) : PluginService.PluginServiceBase +{ + private ILogger Logger { get; } = logger; + private PluginManagerService PluginManagerService { get; } = pluginManagerService; + private CyreneMspConnectionService ConnectionService { get; } = connectionService; + + public override Task RegisterPlugins(PluginRegisterReq request, ServerCallContext context) + { + var clientPlugins = request.Plugins.Select(p => + (p.Identifier, p.Version, p.IsPureLocal)).ToList(); + + var (compatible, incompatible) = PluginManagerService.CheckPluginCompatibility(clientPlugins); + + var response = new PluginRegisterRsp(); + response.CompatiblePlugins.AddRange(compatible); + response.IncompatiblePlugins.AddRange(incompatible); + + Logger.LogInformation("插件兼容性检查: {} 兼容, {} 不兼容", + compatible.Count, incompatible.Count); + + return Task.FromResult(response); + } + + public override Task GetServerPlugins(GetServerPluginsReq request, ServerCallContext context) + { + var response = new PluginListRsp(); + + foreach (var plugin in PluginManagerService.GetAllPlugins()) + { + response.Plugins.Add(new ServerPluginInfo + { + Identifier = plugin.Identifier, + Version = plugin.Version, + Name = plugin.Name, + Description = plugin.Description + }); + } + + Logger.LogInformation("获取到 {} 个服务端插件", response.Plugins.Count); + + return Task.FromResult(response); + } + + public override async Task SendPluginMessage(PluginClientToServerReq request, ServerCallContext context) + { + if (!TryGetClientUid(context, out var clientUid)) + { + return CreateErrorResponse(request.PluginIdentifier, (int)Retcode.InvalidRequest); + } + + var result = await PluginManagerService.HandlePluginMessageAsync( + clientUid, + request.PluginIdentifier, + request.MessageType, + request.Payload.ToByteArray()); + + return new PluginClientToServerRsp + { + RetCode = result.RetCode, + PluginIdentifier = request.PluginIdentifier, + MessageType = result.MessageType, + Payload = ByteString.CopyFrom(result.Payload) + }; + } + + public override Task AcknowledgePluginDisable(PluginDisableAck request, ServerCallContext context) + { + if (!TryGetClientUid(context, out var clientUid)) + { + return Task.FromResult(CreateErrorResponse(request.PluginIdentifier, (int)Retcode.InvalidRequest)); + } + + PluginManagerService.HandlePluginDisableAck(clientUid, request.PluginIdentifier, request.Success); + + return Task.FromResult(new PluginClientToServerRsp + { + RetCode = (int)Retcode.Success, + PluginIdentifier = request.PluginIdentifier, + MessageType = "ack" + }); + } + + /// + /// Handle client acknowledgement of plugin enable + /// + public override Task AcknowledgePluginEnable(PluginEnableAck request, ServerCallContext context) + { + if (!TryGetClientUid(context, out var clientUid)) + { + return Task.FromResult(CreateErrorResponse(request.PluginIdentifier, (int)Retcode.InvalidRequest)); + } + + PluginManagerService.HandlePluginEnableAck(clientUid, request.PluginIdentifier, request.Success); + + return Task.FromResult(new PluginClientToServerRsp + { + RetCode = (int)Retcode.Success, + PluginIdentifier = request.PluginIdentifier, + MessageType = "ack" + }); + } + + /// + /// Try to extract client UID from request headers + /// + private static bool TryGetClientUid(ServerCallContext context, out Guid clientUid) + { + return Guid.TryParse(context.RequestHeaders.GetValue("cuid"), out clientUid); + } + + /// + /// Create a standard error response + /// + private static PluginClientToServerRsp CreateErrorResponse(string pluginIdentifier, int retCode) + { + return new PluginClientToServerRsp + { + RetCode = retCode, + PluginIdentifier = pluginIdentifier, + MessageType = "error" + }; + } +} diff --git a/ClassIsland.ManagementServer.Server/Services/PluginLoaderService.cs b/ClassIsland.ManagementServer.Server/Services/PluginLoaderService.cs new file mode 100644 index 0000000..6f1d2cb --- /dev/null +++ b/ClassIsland.ManagementServer.Server/Services/PluginLoaderService.cs @@ -0,0 +1,215 @@ +using System.Reflection; +using System.Runtime.Loader; +using ClassIsland.ManagementServer.Server.Abstractions.Plugin; + +namespace ClassIsland.ManagementServer.Server.Services; + +public class PluginLoaderService +{ + private readonly ILogger _logger; + private readonly PluginManagerService _pluginManager; + private readonly IServiceProvider _serviceProvider; + + private readonly Dictionary _loadedContexts = new(); + + public PluginLoaderService( + ILogger logger, + PluginManagerService pluginManager, + IServiceProvider serviceProvider) + { + _logger = logger; + _pluginManager = pluginManager; + _serviceProvider = serviceProvider; + } + + public async Task LoadPluginsFromDirectoryAsync(string directory) + { + if (!Directory.Exists(directory)) + { + _logger.LogWarning("Plugin directory does not exist: {}", directory); + return; + } + + var dllFiles = Directory.GetFiles(directory, "*.dll", SearchOption.AllDirectories); + foreach (var dllPath in dllFiles) + { + await LoadPluginFromAssemblyAsync(dllPath); + } + } + + public async Task> LoadPluginFromAssemblyAsync(string assemblyPath) + { + var loadedPlugins = new List(); + + try + { + var absolutePath = Path.GetFullPath(assemblyPath); + if (!File.Exists(absolutePath)) + { + _logger.LogWarning("Assembly file not found: {}", absolutePath); + return loadedPlugins; + } + + var loadContext = new PluginLoadContext(absolutePath); + var assembly = loadContext.LoadFromAssemblyPath(absolutePath); + + var pluginTypes = assembly.GetTypes() + .Where(t => typeof(IServerPlugin).IsAssignableFrom(t) && !t.IsInterface && !t.IsAbstract) + .ToList(); + + if (pluginTypes.Count == 0) + { + _logger.LogInformation("No plugins found in assembly: {}", absolutePath); + loadContext.Unload(); + return loadedPlugins; + } + + foreach (var pluginType in pluginTypes) + { + try + { + var plugin = CreatePluginInstance(pluginType); + if (plugin != null) + { + if (await _pluginManager.LoadPluginAsync(plugin)) + { + _loadedContexts[plugin.Identifier] = loadContext; + loadedPlugins.Add(plugin); + _logger.LogInformation("Loaded plugin {} from {}", plugin.Identifier, absolutePath); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create plugin instance from type {}", pluginType.FullName); + } + } + + if (loadedPlugins.Count == 0) + { + loadContext.Unload(); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load plugins from assembly: {}", assemblyPath); + } + + return loadedPlugins; + } + + public async Task UnloadPluginAsync(string identifier, TimeSpan timeout) + { + var result = await _pluginManager.UnloadPluginAsync(identifier, timeout); + + if (result && _loadedContexts.TryGetValue(identifier, out var context)) + { + context.Unload(); + _loadedContexts.Remove(identifier); + _logger.LogInformation("Unloaded assembly for plugin {}", identifier); + } + + return result; + } + + public async Task HotReloadPluginAsync(string identifier, string newAssemblyPath, TimeSpan timeout) + { + _logger.LogInformation("Hot reloading plugin {}", identifier); + + var unloadResult = await UnloadPluginAsync(identifier, timeout); + if (!unloadResult) + { + _logger.LogWarning("Failed to unload plugin {} for hot reload", identifier); + return false; + } + + var loadedPlugins = await LoadPluginFromAssemblyAsync(newAssemblyPath); + var reloadedPlugin = loadedPlugins.FirstOrDefault(p => p.Identifier == identifier); + + if (reloadedPlugin != null) + { + _logger.LogInformation("Successfully hot reloaded plugin {}", identifier); + return true; + } + + _logger.LogWarning("Hot reload failed - new assembly does not contain plugin {}", identifier); + return false; + } + + private IServerPlugin? CreatePluginInstance(Type pluginType) + { + var constructors = pluginType.GetConstructors(); + + foreach (var constructor in constructors.OrderByDescending(c => c.GetParameters().Length)) + { + var parameters = constructor.GetParameters(); + var args = new object?[parameters.Length]; + var canCreate = true; + + for (int i = 0; i < parameters.Length; i++) + { + var paramType = parameters[i].ParameterType; + var service = _serviceProvider.GetService(paramType); + + if (service != null) + { + args[i] = service; + } + else if (parameters[i].HasDefaultValue) + { + args[i] = parameters[i].DefaultValue; + } + else + { + canCreate = false; + break; + } + } + + if (canCreate) + { + return (IServerPlugin)constructor.Invoke(args); + } + } + + if (pluginType.GetConstructor(Type.EmptyTypes) != null) + { + return (IServerPlugin)Activator.CreateInstance(pluginType)!; + } + + _logger.LogWarning("Cannot create instance of plugin type {} - no suitable constructor", pluginType.FullName); + return null; + } +} + +internal class PluginLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + + public PluginLoadContext(string pluginPath) : base(isCollectible: true) + { + _resolver = new AssemblyDependencyResolver(pluginPath); + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + if (assemblyPath != null) + { + return LoadFromAssemblyPath(assemblyPath); + } + + return null; + } + + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + var libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + if (libraryPath != null) + { + return LoadUnmanagedDllFromPath(libraryPath); + } + + return IntPtr.Zero; + } +} diff --git a/ClassIsland.ManagementServer.Server/Services/PluginManagerService.cs b/ClassIsland.ManagementServer.Server/Services/PluginManagerService.cs new file mode 100644 index 0000000..1092e73 --- /dev/null +++ b/ClassIsland.ManagementServer.Server/Services/PluginManagerService.cs @@ -0,0 +1,378 @@ +using System.Collections.Concurrent; +using ClassIsland.ManagementServer.Server.Abstractions.Plugin; +using ClassIsland.ManagementServer.Server.Models.CyreneMsp; +using ClassIsland.Shared.Protobuf.Enum; +using ClassIsland.Shared.Protobuf.Server; +using Google.Protobuf; + +namespace ClassIsland.ManagementServer.Server.Services; + +public class PluginManagerService +{ + private readonly ILogger _logger; + private readonly CyreneMspConnectionService _connectionService; + private readonly IServiceProvider _serviceProvider; + + public ConcurrentDictionary Plugins { get; } = new(); + + public ConcurrentDictionary> PendingUnloadConfirmations { get; } = new(); + + public ConcurrentDictionary> PendingUnloads { get; } = new(); + + public PluginManagerService( + ILogger logger, + CyreneMspConnectionService connectionService, + IServiceProvider serviceProvider) + { + _logger = logger; + _connectionService = connectionService; + _serviceProvider = serviceProvider; + } + + public async Task LoadPluginAsync(IServerPlugin plugin) + { + if (Plugins.ContainsKey(plugin.Identifier)) + { + _logger.LogWarning("Plugin {} is already loaded", plugin.Identifier); + return false; + } + + try + { + await plugin.OnLoadAsync(); + Plugins[plugin.Identifier] = plugin; + _logger.LogInformation("Plugin {} v{} loaded successfully", plugin.Identifier, plugin.Version); + + await NotifyClientsPluginStateChangeAsync(plugin.Identifier, plugin.Version, isLoading: true); + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load plugin {}", plugin.Identifier); + return false; + } + } + + public async Task UnloadPluginAsync(string identifier, TimeSpan timeout) + { + if (!Plugins.TryGetValue(identifier, out var plugin)) + { + _logger.LogWarning("Plugin {} is not loaded", identifier); + return false; + } + + var activeSessions = _connectionService.Sessions + .Where(s => s.Value.IsActivated) + .Select(s => s.Key) + .ToList(); + + if (activeSessions.Count == 0) // 无活跃客户端,直接卸载插件 + { + return await CompleteUnloadAsync(identifier, plugin); + } + + var pendingConfirmations = new ConcurrentDictionary(); + foreach (var clientUid in activeSessions) + { + pendingConfirmations[clientUid] = false; + } + PendingUnloadConfirmations[identifier] = pendingConfirmations; + + var tcs = new TaskCompletionSource(); + PendingUnloads[identifier] = tcs; + + await NotifyClientsPluginDisableAsync(identifier, "Plugin is being unloaded"); + + using var cts = new CancellationTokenSource(timeout); + try + { + var completedTask = await Task.WhenAny(tcs.Task, Task.Delay(Timeout.Infinite, cts.Token)); + if (completedTask == tcs.Task && await tcs.Task) + { + return await CompleteUnloadAsync(identifier, plugin); + } + else + { + _logger.LogWarning("Timeout waiting for clients to confirm plugin {} disable", identifier); + // 超时直接强制卸载 + return await CompleteUnloadAsync(identifier, plugin); + } + } + catch (OperationCanceledException) + { + _logger.LogWarning("Plugin {} unload timed out after {} seconds, forcing unload", identifier, timeout.TotalSeconds); + return await CompleteUnloadAsync(identifier, plugin); + } + finally + { + PendingUnloadConfirmations.TryRemove(identifier, out _); + PendingUnloads.TryRemove(identifier, out _); + } + } + + public void HandlePluginDisableAck(Guid clientUid, string pluginIdentifier, bool success) + { + if (!PendingUnloadConfirmations.TryGetValue(pluginIdentifier, out var confirmations)) + { + return; + } + + if (confirmations.TryGetValue(clientUid, out _)) + { + confirmations[clientUid] = success; + _logger.LogInformation("Client {} confirmed disable of plugin {}: {}", clientUid, pluginIdentifier, success); + + if (confirmations.Values.All(v => v)) + { + if (PendingUnloads.TryGetValue(pluginIdentifier, out var tcs)) + { + tcs.TrySetResult(true); + } + } + } + } + + public void HandlePluginEnableAck(Guid clientUid, string pluginIdentifier, bool success) + { + _logger.LogInformation("Client {} confirmed enable of plugin {}: {}", clientUid, pluginIdentifier, success); + } + + public (List compatible, List incompatible) CheckPluginCompatibility( + IEnumerable<(string identifier, string version, bool isPureLocal)> clientPlugins) + { + var compatible = new List(); + var incompatible = new List(); + + foreach (var (identifier, version, isPureLocal) in clientPlugins) + { + if (isPureLocal) + { + compatible.Add(identifier); + } + else if (Plugins.TryGetValue(identifier, out var serverPlugin)) + { + if (serverPlugin.IsCompatible(version)) + { + compatible.Add(identifier); + } + else + { + incompatible.Add(identifier); + } + } + else + { + incompatible.Add(identifier); + } + } + + return (compatible, incompatible); + } + + public IEnumerable GetAllPlugins() => Plugins.Values; + + public async Task HandlePluginMessageAsync( + Guid clientId, string pluginIdentifier, string messageType, byte[] payload) + { + if (!Plugins.TryGetValue(pluginIdentifier, out var plugin)) + { + _logger.LogWarning("Plugin {} not found", pluginIdentifier); + return new PluginMessageResponse + { + RetCode = (int)Retcode.PluginNotFound, + MessageType = "error", + Payload = Array.Empty() + }; + } + + try + { + return await plugin.HandleMessageAsync(clientId, messageType, payload); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling message for plugin {}", pluginIdentifier); + return new PluginMessageResponse + { + RetCode = (int)Retcode.ServerInternalError, + MessageType = "error", + Payload = Array.Empty() + }; + } + } + + public async Task SendPluginMessageToClientAsync(Guid clientUid, string pluginIdentifier, string messageType, byte[] payload) + { + if (!_connectionService.Sessions.TryGetValue(clientUid, out var session) || + !session.IsActivated || + session.CommandFlowWriter == null) + { + _logger.LogWarning("Client {} is not connected or session not active", clientUid); + return; + } + + var response = new PluginServerToClientRsp + { + PluginIdentifier = pluginIdentifier, + MessageType = messageType, + Payload = ByteString.CopyFrom(payload) + }; + + await session.CommandFlowWriter.WriteAsync(new ClientCommandDeliverScRsp + { + RetCode = Retcode.Success, + Type = CommandTypes.PluginMessage, + Payload = response.ToByteString() + }); + } + + public async Task BroadcastPluginMessageAsync(string pluginIdentifier, string messageType, byte[] payload) + { + var activeSessions = _connectionService.Sessions + .Where(s => s.Value.IsActivated && s.Value.CommandFlowWriter != null) + .ToList(); + + var response = new PluginServerToClientRsp + { + PluginIdentifier = pluginIdentifier, + MessageType = messageType, + Payload = ByteString.CopyFrom(payload) + }; + + var rsp = new ClientCommandDeliverScRsp + { + RetCode = Retcode.Success, + Type = CommandTypes.PluginMessage, + Payload = response.ToByteString() + }; + + foreach (var (clientUid, session) in activeSessions) + { + try + { + await session.CommandFlowWriter!.WriteAsync(rsp); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send plugin message to client {}", clientUid); + } + } + } + + private async Task CompleteUnloadAsync(string identifier, IServerPlugin plugin) + { + try + { + await plugin.OnUnloadAsync(); + Plugins.TryRemove(identifier, out _); + _logger.LogInformation("Plugin {} unloaded successfully", identifier); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during plugin {} unload", identifier); + Plugins.TryRemove(identifier, out _); + return false; + } + } + + private async Task NotifyClientsPluginStateChangeAsync(string pluginIdentifier, string version, bool isLoading) + { + var notification = new PluginStateChangeNotification + { + PluginIdentifier = pluginIdentifier, + Version = version, + IsLoading = isLoading + }; + + var rsp = new ClientCommandDeliverScRsp + { + RetCode = Retcode.Success, + Type = CommandTypes.PluginStateChange, + Payload = notification.ToByteString() + }; + + var activeSessions = _connectionService.Sessions + .Where(s => s.Value.IsActivated && s.Value.CommandFlowWriter != null) + .ToList(); + + foreach (var (clientUid, session) in activeSessions) + { + try + { + await session.CommandFlowWriter!.WriteAsync(rsp); + _logger.LogInformation("Notified client {} about plugin {} state change", clientUid, pluginIdentifier); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to notify client {} about plugin state change", clientUid); + } + } + } + + private async Task NotifyClientsPluginDisableAsync(string pluginIdentifier, string reason) + { + var request = new PluginDisableRequest + { + PluginIdentifier = pluginIdentifier, + Reason = reason + }; + + var rsp = new ClientCommandDeliverScRsp + { + RetCode = Retcode.Success, + Type = CommandTypes.PluginDisableRequest, + Payload = request.ToByteString() + }; + + var activeSessions = _connectionService.Sessions + .Where(s => s.Value.IsActivated && s.Value.CommandFlowWriter != null) + .ToList(); + + foreach (var (clientUid, session) in activeSessions) + { + try + { + await session.CommandFlowWriter!.WriteAsync(rsp); + _logger.LogInformation("Requested client {} to disable plugin {}", clientUid, pluginIdentifier); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to request client {} to disable plugin", clientUid); + } + } + } + + public async Task NotifyClientsPluginEnableAsync(string pluginIdentifier) + { + var request = new PluginEnableRequest + { + PluginIdentifier = pluginIdentifier + }; + + var rsp = new ClientCommandDeliverScRsp + { + RetCode = Retcode.Success, + Type = CommandTypes.PluginEnableRequest, + Payload = request.ToByteString() + }; + + var activeSessions = _connectionService.Sessions + .Where(s => s.Value.IsActivated && s.Value.CommandFlowWriter != null) + .ToList(); + + foreach (var (clientUid, session) in activeSessions) + { + try + { + await session.CommandFlowWriter!.WriteAsync(rsp); + _logger.LogInformation("Requested client {} to enable plugin {}", clientUid, pluginIdentifier); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to request client {} to enable plugin", clientUid); + } + } + } +} diff --git a/ClassIsland.Shared/Protobuf/Client/PluginServiceScReq.proto b/ClassIsland.Shared/Protobuf/Client/PluginServiceScReq.proto new file mode 100644 index 0000000..a98fda4 --- /dev/null +++ b/ClassIsland.Shared/Protobuf/Client/PluginServiceScReq.proto @@ -0,0 +1,44 @@ +syntax = "proto3"; +package ClassIsland.Shared.Protobuf.Client; +option csharp_namespace = "ClassIsland.Shared.Protobuf.Client"; + +message PluginInfo { + string Identifier = 1; + string Version = 2; + bool IsPureLocal = 3; +} + +message PluginRegisterReq { + repeated PluginInfo Plugins = 1; +} + +message PluginRegisterRsp { + repeated string CompatiblePlugins = 1; + repeated string IncompatiblePlugins = 2; +} + +message GetServerPluginsReq { +} + +message PluginClientToServerReq { + string PluginIdentifier = 1; + string MessageType = 2; + bytes Payload = 3; +} + +message PluginClientToServerRsp { + int32 RetCode = 1; + string PluginIdentifier = 2; + string MessageType = 3; + bytes Payload = 4; +} + +message PluginDisableAck { + string PluginIdentifier = 1; + bool Success = 2; +} + +message PluginEnableAck { + string PluginIdentifier = 1; + bool Success = 2; +} diff --git a/ClassIsland.Shared/Protobuf/Enum/CommandTypes.proto b/ClassIsland.Shared/Protobuf/Enum/CommandTypes.proto index b75c3fc..0f389ff 100644 --- a/ClassIsland.Shared/Protobuf/Enum/CommandTypes.proto +++ b/ClassIsland.Shared/Protobuf/Enum/CommandTypes.proto @@ -8,10 +8,16 @@ enum CommandTypes { // Heartbeat Ping=10; Pong=11; - + // MiscCommands RestartApp=101; SendNotification=102; DataUpdated=103; GetClientConfig=104; + + // Plugin Commands + PluginMessage=200; + PluginStateChange=201; + PluginDisableRequest=202; + PluginEnableRequest=203; } \ No newline at end of file diff --git a/ClassIsland.Shared/Protobuf/Enum/Retcode.proto b/ClassIsland.Shared/Protobuf/Enum/Retcode.proto index 02daf79..a91b7d2 100644 --- a/ClassIsland.Shared/Protobuf/Enum/Retcode.proto +++ b/ClassIsland.Shared/Protobuf/Enum/Retcode.proto @@ -13,5 +13,10 @@ enum Retcode { Registered = 10001; ClientNotFound = 10002; // service: ClientCommandDeliver - + + // service: Plugin + PluginNotFound = 20001; + PluginIncompatible = 20002; + PluginDisabled = 20003; + PluginBusy = 20004; } diff --git a/ClassIsland.Shared/Protobuf/Server/PluginServiceScRsp.proto b/ClassIsland.Shared/Protobuf/Server/PluginServiceScRsp.proto new file mode 100644 index 0000000..27e519e --- /dev/null +++ b/ClassIsland.Shared/Protobuf/Server/PluginServiceScRsp.proto @@ -0,0 +1,35 @@ +syntax = "proto3"; +package ClassIsland.Shared.Protobuf.Server; +option csharp_namespace = "ClassIsland.Shared.Protobuf.Server"; + +message ServerPluginInfo { + string Identifier = 1; + string Version = 2; + string Name = 3; + string Description = 4; +} + +message PluginListRsp { + repeated ServerPluginInfo Plugins = 1; +} + +message PluginServerToClientRsp { + string PluginIdentifier = 1; + string MessageType = 2; + bytes Payload = 3; +} + +message PluginStateChangeNotification { + string PluginIdentifier = 1; + bool IsLoading = 2; + string Version = 3; +} + +message PluginDisableRequest { + string PluginIdentifier = 1; + string Reason = 2; +} + +message PluginEnableRequest { + string PluginIdentifier = 1; +} diff --git a/ClassIsland.Shared/Protobuf/Service/PluginService.proto b/ClassIsland.Shared/Protobuf/Service/PluginService.proto new file mode 100644 index 0000000..0d26594 --- /dev/null +++ b/ClassIsland.Shared/Protobuf/Service/PluginService.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; +package ClassIsland.Shared.Protobuf.Service; +option csharp_namespace = "ClassIsland.Shared.Protobuf.Service"; + +import "Protobuf/Client/PluginServiceScReq.proto"; +import "Protobuf/Server/PluginServiceScRsp.proto"; + +service PluginService { + rpc RegisterPlugins (Client.PluginRegisterReq) returns (Client.PluginRegisterRsp); + + rpc GetServerPlugins (Client.GetServerPluginsReq) returns (Server.PluginListRsp); + + rpc SendPluginMessage (Client.PluginClientToServerReq) returns (Client.PluginClientToServerRsp); + + rpc AcknowledgePluginDisable (Client.PluginDisableAck) returns (Client.PluginClientToServerRsp); + + rpc AcknowledgePluginEnable (Client.PluginEnableAck) returns (Client.PluginClientToServerRsp); +}