diff --git a/Obsidian.GuiConsole/GlobalUsings.cs b/Obsidian.GuiConsole/GlobalUsings.cs new file mode 100644 index 000000000..a975fd2c3 --- /dev/null +++ b/Obsidian.GuiConsole/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using System; +global using System.Collections.Generic; +global using System.IO; +global using System.Threading.Tasks; diff --git a/Obsidian.GuiConsole/Logger/TerminalGuiLogger.cs b/Obsidian.GuiConsole/Logger/TerminalGuiLogger.cs new file mode 100644 index 000000000..a01ed916c --- /dev/null +++ b/Obsidian.GuiConsole/Logger/TerminalGuiLogger.cs @@ -0,0 +1,41 @@ +using Microsoft.Extensions.Logging; +using Obsidian.GuiConsole.Window; +using Terminal.Gui.Views; + +namespace Obsidian.GuiConsole.Logger; + +/// +/// Logger implementation that logs messages to the Terminal GUI Console +/// +public class TerminalGuiLogger : ILogger +{ + private readonly ObsidianConsole _console; + private readonly LogLevel _minLevel; + + public TerminalGuiLogger(ObsidianConsole console, LogLevel minLevel) + { + _console = console; + _minLevel = minLevel; + } + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => logLevel >= _minLevel; + /// + /// Log message to the GUI Console + /// + /// level + /// evt + /// state + /// error + /// format + /// type of state + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (!IsEnabled(logLevel)) return; + + var message = formatter(state, exception); + + _console.AppendLog(logLevel,message); + } +} diff --git a/Obsidian.GuiConsole/Logger/TerminalGuiLoggerProvider.cs b/Obsidian.GuiConsole/Logger/TerminalGuiLoggerProvider.cs new file mode 100644 index 000000000..b5843930d --- /dev/null +++ b/Obsidian.GuiConsole/Logger/TerminalGuiLoggerProvider.cs @@ -0,0 +1,25 @@ +using Microsoft.Extensions.Logging; +using Obsidian.GuiConsole.Window; + +namespace Obsidian.GuiConsole.Logger; + +public class TerminalGuiLoggerProvider : ILoggerProvider +{ + private readonly ObsidianConsole _console; + private readonly LogLevel _minLevel; + + public TerminalGuiLoggerProvider(ObsidianConsole console, LogLevel minLevel) + { + _console = console; + _minLevel = minLevel; + } + + /// + /// Create a logger for the specified category + /// + /// + /// + public ILogger CreateLogger(string categoryName) => new TerminalGuiLogger(_console, _minLevel); + + public void Dispose() { } +} diff --git a/Obsidian.GuiConsole/Obsidian.GuiConsole.csproj b/Obsidian.GuiConsole/Obsidian.GuiConsole.csproj new file mode 100644 index 000000000..803106445 --- /dev/null +++ b/Obsidian.GuiConsole/Obsidian.GuiConsole.csproj @@ -0,0 +1,24 @@ + + + + Exe + net10.0 + enable + enable + True + + + + + + + + + + + + + + + + diff --git a/Obsidian.GuiConsole/Program.Functions.cs b/Obsidian.GuiConsole/Program.Functions.cs new file mode 100644 index 000000000..4ea560b88 --- /dev/null +++ b/Obsidian.GuiConsole/Program.Functions.cs @@ -0,0 +1,58 @@ +using System.Reflection; + +namespace Obsidian.GuiConsole; + +public partial class Program +{ + /// + /// release default config files if not exist + /// + private static async ValueTask GenerateConfigFiles() + { + const string path = "config"; + + Directory.CreateDirectory(path); + + var serverJsonFile = Path.Combine(path, "server.json"); + var whitelistJsonFile = Path.Combine(path, "whitelist.json"); + + if (!File.Exists(serverJsonFile)) + { + await using var file = File.Create(serverJsonFile); + + await using var embeddedFile = Assembly.GetExecutingAssembly().GetManifestResourceStream("Obsidian.GuiConsole.config.server.json"); + + await embeddedFile!.CopyToAsync(file); + } + + if (!File.Exists(whitelistJsonFile)) + { + await using var file = File.Create(whitelistJsonFile); + + await using var embeddedFile = Assembly.GetExecutingAssembly().GetManifestResourceStream("Obsidian.GuiConsole.config.whitelist.json"); + + await embeddedFile!.CopyToAsync(file); + } + } + + /// + /// Logo is cool,but not render well on my machine -- stevesensei + /// + public static void DrawLogo() + { + const string asciilogo = + "\n" + + " ▄▄▄▄ ▄▄ ▄▄▄▄ ▀ ▄▄▄ ▐ ▄ \n" + + " ▐█ ▀█ ▐█ ▀ ██ ██ ██ ██ ▐█ ▀█ █▌▐█\n" + + " ▄█▀▄ ▐█▀▀█▄▄▀▀▀█▄▐█ ▐█ ▐█▌▐█ ▄█▀▀█ ▐█▐▐▌\n" + + "▐█▌ ▐▌██▄ ▐█▐█▄ ▐█▐█▌██ ██ ▐█▌▐█ ▐▌██▐█▌\n" + + " ▀█▄▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀ ▀ ▀ ▀▀ █ \n\n"; + + Console.Title = $"Obsidian for {ServerConstants.DefaultProtocol} ({ServerConstants.VERSION})"; + Console.BackgroundColor = ConsoleColor.White; + Console.ForegroundColor = ConsoleColor.Black; + Console.CursorVisible = false; + Console.WriteLine(asciilogo); + Console.ResetColor(); + } +} diff --git a/Obsidian.GuiConsole/Program.cs b/Obsidian.GuiConsole/Program.cs new file mode 100644 index 000000000..5e145358c --- /dev/null +++ b/Obsidian.GuiConsole/Program.cs @@ -0,0 +1,74 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Obsidian.GuiConsole.Logger; +using Obsidian.GuiConsole.Services; +using Obsidian.GuiConsole.Services.Command; +using Obsidian.GuiConsole.Window; +using Obsidian.Hosting; +using Terminal.Gui.App; +using Terminal.Gui.Configuration; + +namespace Obsidian.GuiConsole; + +public partial class Program +{ + /// + /// entry point + /// + /// currently useless + public static async Task Main(string[] args) + { + //Cool logo,but not render well on my machine -- stevesensei + DrawLogo(); + + //Init Gui App + ConfigurationManager.Enable(ConfigLocations.All); + + //Create console window + + + //Normal Obsidian setup + await GenerateConfigFiles(); + var builder = Host.CreateApplicationBuilder(); + builder.ConfigureObsidian(); + if (!Directory.Exists("logs")) + { + Directory.CreateDirectory("logs"); + } + + //Add Obsidian with GUI logger + var console = new ObsidianConsole(); + + builder.AddObsidianWithGui(x => + { + x.AddProvider(new TerminalGuiLoggerProvider(console, LogLevel.Information)); + return x; + }); + + //Give the server some time to shut down after CTRL-C or SIGTERM. + builder.Services.Configure(opts => + { + opts.ShutdownTimeout = TimeSpan.FromSeconds(10); + }); + builder.Services.AddSingleton(); + var hostApp = builder.Build(); + + IApplication? app = null; + try + { + //Run the application + await hostApp.StartAsync(); + var commandMiddleware = hostApp.Services.GetRequiredService(); + console.AddCommandMiddleware(commandMiddleware); + app = Application.Create().Init(); + app.Run(console); + } + finally + { + //Cleanup when application stops + await hostApp.StopAsync(); + app?.Dispose(); + } + } +} diff --git a/Obsidian.GuiConsole/Services/Command/CommandMiddleware.cs b/Obsidian.GuiConsole/Services/Command/CommandMiddleware.cs new file mode 100644 index 000000000..51b722920 --- /dev/null +++ b/Obsidian.GuiConsole/Services/Command/CommandMiddleware.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; +using Obsidian.API; +using Obsidian.API.Commands; + +namespace Obsidian.GuiConsole.Services.Command; + +/// +/// Command middleware to process commands from GUI console +/// +/// Server Instance +/// log +public class CommandMiddleware(IServer server,ILogger logger) +{ + public async Task ExecuteCommandFromConsoleAsync(string commandText) + { + await server.CommandHandler.ProcessCommand(new CommandContext( + "/"+commandText, + new ConsoleCommandSender(logger),null, + server + )); + } + +} diff --git a/Obsidian.GuiConsole/Services/Command/ConsoleCommandSender.cs b/Obsidian.GuiConsole/Services/Command/ConsoleCommandSender.cs new file mode 100644 index 000000000..83c86e43b --- /dev/null +++ b/Obsidian.GuiConsole/Services/Command/ConsoleCommandSender.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.Logging; +using Obsidian.API; +using System.Text.RegularExpressions; + +namespace Obsidian.GuiConsole.Services.Command; + +/// +/// Command sender for console input and output +/// +/// Temp use +public class ConsoleCommandSender(ILogger logger): ICommandSender +{ + public CommandIssuers Issuer { get; } = CommandIssuers.Console; + public IPlayer? Player { get; } = null; + + /// + /// Implementation of SendMessageAsync for console logging + /// + /// data + /// + public Task SendMessageAsync(ChatMessage message) + { + List messageParts = new(); + messageParts.Add(message.Text); + foreach (var extra in message.GetExtras()) + messageParts.Add(extra.Text); + //log + foreach (var messagePart in messageParts) + { + if (string.IsNullOrEmpty(messagePart) || messagePart == "\n") + { + continue; + } + //remove color codes § + var clearMessage = Regex + .Replace(messagePart, "§[0-9a-fk-or]", string.Empty, RegexOptions.IgnoreCase); + //log to console + logger.LogInformation("[Console] {MessagePart}", clearMessage); + } + return Task.CompletedTask; + } + + public Task SendMessageAsync(ChatMessage message, Guid sender) => throw new NotImplementedException(); +} diff --git a/Obsidian.GuiConsole/Window/ObsidianConsole.cs b/Obsidian.GuiConsole/Window/ObsidianConsole.cs new file mode 100644 index 000000000..fc164fdc4 --- /dev/null +++ b/Obsidian.GuiConsole/Window/ObsidianConsole.cs @@ -0,0 +1,303 @@ +using Microsoft.Extensions.Logging; +using Obsidian.GuiConsole.Services.Command; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using Terminal.Gui.Drawing; +using Terminal.Gui.Input; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace Obsidian.GuiConsole.Window; +/// +/// Render a GUI Console for Obsidian Server +/// +public class ObsidianConsole : Terminal.Gui.Views.Window +{ + private readonly ListView logView; + private readonly TextField commandInput; + private readonly ObservableCollection logEntries; + private readonly Dictionary logLevels = new(); + private readonly List<(LogLevel level, string message)> originalLogs = new(); // 存储原始日志 + private int maxLineWidth = 0; + + private CommandMiddleware? commandMiddleware; + + public string GuiTitle => $"Obsidian GUI Console for Minecraft {ServerConstants.DefaultProtocol}"; + + public ListView LogView => logView; + + /// + /// Add Command Middleware to handle command input + /// + /// Instance + public void AddCommandMiddleware(CommandMiddleware? comm) + { + commandMiddleware = comm; + CommandEntered += async (command) => + { + if (this.commandMiddleware != null) + { + AppendLog(LogLevel.Trace, $"> {command}"); + try + { + await this.commandMiddleware.ExecuteCommandFromConsoleAsync(command); + } + catch (Exception e) + { + AppendLog(LogLevel.Error, $"Error: {e.Message}"); + } + } + }; + } + + /// + /// Build the GUI Console Window + /// + public ObsidianConsole() + { + Title = GuiTitle; + + // Initial logs source + logEntries = new ObservableCollection(); + + FrameChanged += (_, s) => + { + var newWidth = s.Value.Width - 4; + if (newWidth != maxLineWidth && newWidth > 0) + { + maxLineWidth = newWidth; + RewrapAllLogs(); + } + }; + logView = new ListView + { + X = 0, + Y = 1, + Width = Dim.Fill(), + Height = Dim.Fill(1), + Source = new ListWrapper(logEntries), + Arrangement = ViewArrangement.BottomResizable + }; + + //Add Color Rendering + logView.RowRender += OnRowRender; + + //Auto Scroll Handling + logEntries.CollectionChanged += OnLogEntriesChanged; + + commandInput = new TextField + { + X = 2, + Y = Pos.AnchorEnd(1), + Width = Dim.Fill(), + Height = 1 + }; + + var prompt = new Label + { + X = 0, + Y = Pos.AnchorEnd(1), + Text = "> ", + Width = 2, + Height = 1, + }; + prompt.SetScheme(new Scheme() + { + Normal = new Attribute(Color.DarkGray, Color.Black) + }); + + commandInput.KeyDown += (_, x) => + { + if (x == Key.Enter) + { + var command = commandInput.Text; + OnCommandEntered(command); + commandInput.Text = ""; + x.Handled = true; + } + }; + + Add(prompt); + Add(logView); + Add(commandInput); + + var windowScheme = GetScheme(); + var newScheme = new Scheme() + { + Normal = new Attribute(windowScheme.Normal.Foreground, Color.Black), + Focus = new Attribute(windowScheme.Focus.Foreground, windowScheme.Focus.Background), + HotNormal = new Attribute(windowScheme.HotNormal.Foreground, windowScheme.HotNormal.Background), + HotFocus = new Attribute(windowScheme.HotFocus.Foreground, windowScheme.HotFocus.Background) + }; + SetScheme(newScheme); + } + /// + /// Command Entered Event + /// + public event Action? CommandEntered; + /// + /// On Command Entered + /// + protected virtual void OnCommandEntered(string command) + { + CommandEntered?.Invoke(command); + + } + + /// + /// Row Render Event for Color Coding + /// + /// nope + /// row + private void OnRowRender(object? sender, ListViewRowEventArgs e) + { + if (logLevels.TryGetValue(e.Row, out LogLevel level)) + { + Color color = level switch + { + LogLevel.Trace or LogLevel.Debug => Color.Gray, + LogLevel.Information => Color.Green, + LogLevel.Warning => Color.Yellow, + LogLevel.Error or LogLevel.Critical => Color.Red, + _ => Color.Gray + }; + + e.RowAttribute = new Attribute(color, Color.Black); + } + } + + /// + /// Auto Scroll to Bottom on New Log Entry + /// + /// nope + /// argument + private void OnLogEntriesChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (e.Action == NotifyCollectionChangedAction.Add) + { + AutoScrollToBottom(); + } + } + /// + /// Auto Scroll to Bottom Implementation + /// + private void AutoScrollToBottom() + { + if (logEntries.Count > 0) + { + logView.SelectedItem = logEntries.Count - 1; + logView.TopItem = Math.Max(0, logEntries.Count - logView.Viewport.Height); + } + } + + /// + /// Add Log Entry to Console + /// + /// Log level + /// message + public void AppendLog(LogLevel level, string message) + { + //keep original log for re-wrapping + originalLogs.Add((level, message)); + + var logEntry = $"{DateTime.Now:HH:mm:ss} [{level}] {message}"; + + var wrapWidth = maxLineWidth > 0 ? maxLineWidth : 80; + + //shift lines if too long + if (logEntry.Length > wrapWidth) + { + var lines = WrapText(logEntry, wrapWidth); + foreach (var line in lines) + { + logLevels[logEntries.Count] = level; + logEntries.Add(line); + } + } + else + { + logLevels[logEntries.Count] = level; + logEntries.Add(logEntry); + } + } + + /// + /// Wrap text into multiple lines with indentation for wrapped lines + /// + /// text content + /// console width + /// + private List WrapText(string text, int width) + { + var result = new List(); + + if (string.IsNullOrEmpty(text) || width <= 0) + { + result.Add(text ?? string.Empty); + return result; + } + + var currentIndex = 0; + var isFirstLine = true; + + while (currentIndex < text.Length) + { + var remainingLength = text.Length - currentIndex; + var takeLength = Math.Min(width, remainingLength); + + // add line + var line = text.Substring(currentIndex, takeLength); + if (!isFirstLine) + { + line = " " + line.TrimStart(); + } + + result.Add(line); + currentIndex += takeLength; + isFirstLine = false; + } + + return result; + } + + /// + /// Rewrap all logs when windows size changed + /// + private void RewrapAllLogs() + { + if (originalLogs.Count == 0 || maxLineWidth <= 0) + return; + var wasAtBottom = logEntries.Count > 0 && + logView.TopItem >= logEntries.Count - logView.Viewport.Height - 5; + + //clear current logs + logEntries.Clear(); + logLevels.Clear(); + + //Re-add all logs with new wrapping + foreach (var (level, message) in originalLogs) + { + var logEntry = $"{DateTime.Now:HH:mm:ss} [{level}] {message}"; + + if (logEntry.Length > maxLineWidth) + { + var lines = WrapText(logEntry, maxLineWidth); + foreach (var line in lines) + { + logLevels[logEntries.Count] = level; + logEntries.Add(line); + } + } + else + { + logLevels[logEntries.Count] = level; + logEntries.Add(logEntry); + } + } + if (wasAtBottom) + { + AutoScrollToBottom(); + } + } +} diff --git a/Obsidian.GuiConsole/config/server.json b/Obsidian.GuiConsole/config/server.json new file mode 100644 index 000000000..5b519c857 --- /dev/null +++ b/Obsidian.GuiConsole/config/server.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://raw.githubusercontent.com/ObsidianMC/Obsidian/master/.schema/server.json", + + "allowLan": true, + "allowOperatorRequests": true, + + "maxPlayers": 25, + "motd": "�k||||�r �5Obsidian �cPre�r-�cRelease �r�k||||�r \n�r�lRunning on .NET �l�c8 �r�l<3", + + "onlineMode": true, + "port": 25565, + "pregenerateChunkRange": 15, + "serverListQuery": "Full", + "timeTickSpeedMultiplier": 1, + "whitelist": false, + + "network": { + "connectionThrottle": 15000, + "keepAliveInterval": 10000, + "keepAliveTimeoutInterval": 30000, + "mulitplayerDebugMode": false + }, + + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning" + } + } +} \ No newline at end of file diff --git a/Obsidian.GuiConsole/config/whitelist.json b/Obsidian.GuiConsole/config/whitelist.json new file mode 100644 index 000000000..ed3d6011f --- /dev/null +++ b/Obsidian.GuiConsole/config/whitelist.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://raw.githubusercontent.com/ObsidianMC/Obsidian/master/.schema/whitelist.json", + "whitelistedPlayers": [], + "whitelistedIps": [] +} \ No newline at end of file diff --git a/Obsidian.slnx b/Obsidian.slnx index d8b418551..030b1a3ea 100644 --- a/Obsidian.slnx +++ b/Obsidian.slnx @@ -1,7 +1,8 @@ - + + diff --git a/Obsidian/Hosting/DependencyInjection.cs b/Obsidian/Hosting/DependencyInjection.cs index 527dcfffb..0398a8315 100644 --- a/Obsidian/Hosting/DependencyInjection.cs +++ b/Obsidian/Hosting/DependencyInjection.cs @@ -114,4 +114,75 @@ private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostAppli return builder; } + /// + /// Add GUI Console Support via Terminal.GUI + /// + /// + /// Add Terminal Logger + /// + public static IHostApplicationBuilder AddObsidianWithGui(this IHostApplicationBuilder builder,Func loggingAdd) + { + // filename with date,time + var logFile = $"logs/{DateTime.Now:yyyy-MM-dd_HH-mm-ss}.log"; + var logFileStream = new FileStream(logFile, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read); + + builder.Logging.AddOpenTelemetry(x => + { + x.IncludeScopes = true; + x.IncludeFormattedMessage = true; + }); + builder.Services.AddLogging(loggingBuilder => + { + loggingBuilder.ClearProviders(); + loggingAdd(loggingBuilder); + loggingBuilder.AddProvider(new StreamLoggerProvider(logFileStream)); + }); + + builder.Services.Configure(builder.Configuration); + builder.Services.Configure(builder.Configuration); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + //builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + builder.Services.AddHttpClient(); + + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + builder.Services.AddHostedService(); + + builder.Services.AddSingleton(x => x.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + builder.Services.AddSingleton(sp => sp.GetRequiredService()); + + builder.Services.AddOpenTelemetry() + .WithTracing(tracing => + { + if(builder.Environment.IsDevelopment()) + { + tracing.SetSampler(); + } + + //tracing.AddConsoleExporter(); + tracing.AddHttpClientInstrumentation(); + }) + .WithMetrics(metrics => + { + //metrics.AddConsoleExporter(); + + metrics.AddRuntimeInstrumentation().AddMeter("Obsidian.Server", "Obsidian.Client", "System.Net.Http"); + }); + + builder.AddOpenTelemetryExporters(); + + builder.Services.AddSingleton(); + return builder; + } + }