#nullable enable using System.Net.NetworkInformation; using System.Net.Sockets; using System.Threading; namespace Lib.io.http; /// /// A simple HTTP server operator for T3 to serve a user-provided HTML string. /// [Guid("1D2E3F4A-5B6C-4789-0123-456789ABCDEF")] // Updated GUID internal sealed class WebServer : Instance, IStatusProvider, ICustomDropdownHolder, IDisposable // Added ICustomDropdownHolder { #region Constructor public WebServer() { IsRunning.UpdateAction = Update; PortOutput.UpdateAction = Update; } #endregion #region IDisposable Implementation public void Dispose() { if (_disposed) return; _disposed = true; StopServer(); // Stop server and cleanup listener _cancellationTokenSource?.Dispose(); // Dispose CancellationTokenSource } #endregion #region Main Update Loop private void Update(EvaluationContext context) { if (_disposed) return; _printToLog = PrintToLog.GetValue(context); // Update PrintToLog flag var shouldListen = Listen.GetValue(context); var localIp = LocalIpAddress.GetValue(context); // Get local IP var port = Port.GetValue(context); var htmlContent = HtmlContent.GetValue(context); // Get HTML content from input // Check if settings have changed and require a restart or content update var settingsChanged = shouldListen != _lastListenState || localIp != _lastLocalIp || port != _lastPort; if (settingsChanged) { StopServer(); if (shouldListen) StartServer(localIp, port); // Pass localIp to StartServer _lastListenState = shouldListen; _lastLocalIp = localIp; // Store last local IP _lastPort = port; } // Always update the stored HTML content _lastHtmlContent = htmlContent; // Update output slots IsRunning.Value = _listener?.IsListening == true; PortOutput.Value = _lastPort; } #endregion #region Outputs [Output(Guid = "2A3B4C5D-6E7F-4890-1234-567890ABCDEF")] // Updated GUID public readonly Slot IsRunning = new(); [Output(Guid = "3B4C5D6E-7F8A-4901-2345-67890ABCDEF1")] // Updated GUID public readonly Slot PortOutput = new(); #endregion #region State and Fields private HttpListener? _listener; private CancellationTokenSource? _cancellationTokenSource; private bool _lastListenState; private string? _lastLocalIp; // Added for LocalIpAddress input private int _lastPort; private string? _lastHtmlContent; // Store last HTML content private bool _disposed; private bool _printToLog; // Added for PrintToLog functionality private string _statusMessage = "Not running"; private IStatusProvider.StatusLevel _statusLevel = IStatusProvider.StatusLevel.Notice; #endregion #region Server Control Logic private void StartServer(string? localIpAddress, int port) { string prefixHost; // Determine the IP address to listen on if (string.IsNullOrEmpty(localIpAddress) || localIpAddress == "0.0.0.0 (Any)") { prefixHost = "+"; // Use '+' for HttpListener to listen on all interfaces } else if (!IPAddress.TryParse(localIpAddress, out _)) { SetStatus($"Invalid Local IP '{localIpAddress}'. Defaulting to listen on all interfaces.", IStatusProvider.StatusLevel.Warning); if (_printToLog) { Log.Warning($"WebServer: Invalid Local IP '{localIpAddress}', defaulting to listen on all interfaces.", this); } prefixHost = "+"; } else { prefixHost = localIpAddress; // Use the specific IP for the prefix } var prefix = $"http://{prefixHost}:{port}/"; _listener = new HttpListener(); _cancellationTokenSource = new CancellationTokenSource(); try { _listener.Prefixes.Add(prefix); _listener.Start(); SetStatus($"Running on {prefix.Replace("+", "localhost")}", IStatusProvider.StatusLevel.Success); if (_printToLog) { Log.Debug($"WebServer started on {prefix.Replace("+", "localhost")}", this); } // Start the listening loop on a background task _ = Task.Run(ListenLoop, _cancellationTokenSource.Token); } catch (HttpListenerException hlex) when (hlex.ErrorCode == 5) // Access Denied { SetStatus($"Access Denied on port {port}. Try port >1024 or run as admin. Error: {hlex.Message}", IStatusProvider.StatusLevel.Error); if (_printToLog) { Log.Error($"WebServer: Access Denied starting WebServer on {prefix}: {hlex.Message}", this); } CleanupListener(); } catch (Exception e) { SetStatus($"Failed to start: {e.Message}", IStatusProvider.StatusLevel.Error); if (_printToLog) { Log.Error($"WebServer: Error starting WebServer: {e.Message}", this); } CleanupListener(); } } private async Task ListenLoop() { try { while (_listener?.IsListening == true && !_cancellationTokenSource!.Token.IsCancellationRequested) { HttpListenerContext context; try { context = await _listener.GetContextAsync(); } catch (ObjectDisposedException) { if (_printToLog) Log.Debug("WebServer: Listener disposed, exiting ListenLoop.", this); break; // Listener was disposed, exiting the loop gracefully } catch (HttpListenerException hex) when (hex.ErrorCode == 995) // The I/O operation has been aborted because of either a thread exit or an application request. { if (_printToLog) Log.Debug("WebServer: HttpListenerException 995 (Operation Aborted), likely during shutdown. Exiting ListenLoop.", this); break; } catch (OperationCanceledException) { if (_printToLog) Log.Debug("WebServer: ListenLoop cancelled via token. Exiting.", this); break; } catch (Exception ex) { if (_printToLog) Log.Warning($"WebServer: Error getting request context: {ex.Message}", this); continue; } // Handle the request asynchronously // Detach from the current task to allow the loop to continue accepting connections _ = Task.Run(() => HandleRequestAsync(context), _cancellationTokenSource.Token); } } catch (Exception e) when (e is OperationCanceledException || e is ObjectDisposedException) { // Expected during shutdown if (_printToLog) Log.Debug($"WebServer: ListenLoop stopped gracefully due to {e.GetType().Name}.", this); } catch (Exception e) { if (_printToLog) Log.Warning($"WebServer: Unexpected error in ListenLoop: {e.Message}", this); } finally { if (_printToLog) Log.Debug("WebServer: ListenLoop finished.", this); } } private async Task HandleRequestAsync(HttpListenerContext context) { var request = context.Request; var response = context.Response; var urlPath = "/"; // Initialize to a non-null default try { if (_printToLog) Log.Debug($"WebServer received request for '{request.Url?.AbsolutePath}' from {request.RemoteEndPoint}", this); // FIX for CS8600: Explicitly handle null for request.Url before accessing AbsolutePath if (request.Url != null) { urlPath = request.Url.AbsolutePath; // Assign after confirming request.Url is not null } // Serve the HTML content for the root path if (string.IsNullOrEmpty(urlPath) || urlPath == "/") { // Get the current HTML content from the stored variable var htmlToServe = _lastHtmlContent ?? "

No HTML content provided.

"; byte[] buffer = Encoding.UTF8.GetBytes(htmlToServe); response.ContentType = "text/html"; response.ContentLength64 = buffer.Length; response.StatusCode = (int)HttpStatusCode.OK; await response.OutputStream.WriteAsync(buffer, 0, buffer.Length); if (_printToLog) Log.Debug("WebServer served HTML content.", this); } else { // For any other path, return 404 if (_printToLog) Log.Debug($"WebServer: Path not found: '{urlPath}'", this); SendErrorResponse(response, 404, "Not Found"); } } catch (Exception ex) { if (_printToLog) Log.Error($"WebServer: Error handling request for '{request.Url?.AbsolutePath}': {ex.Message}", this); SendErrorResponse(response, 500, "Internal Server Error"); } finally { try { response.Close(); } catch (Exception ex) { if (_printToLog) Log.Debug($"WebServer: Error closing response: {ex.Message}", this); } } } private void SendErrorResponse(HttpListenerResponse response, int statusCode, string description) { try { byte[] buffer = Encoding.UTF8.GetBytes($"

{statusCode} {description}

"); response.ContentType = "text/html"; response.ContentLength64 = buffer.Length; response.StatusCode = statusCode; response.OutputStream.Write(buffer, 0, buffer.Length); response.OutputStream.Close(); // Ensure the stream is closed after writing } catch (Exception ex) { if (_printToLog) Log.Debug($"WebServer: Error sending error response ({statusCode}): {ex.Message}", this); } } private void StopServer() { try { _cancellationTokenSource?.Cancel(); // Signal cancellation if (_printToLog) Log.Debug("WebServer: Cancellation requested for server.", this); } catch (Exception ex) { if (_printToLog) Log.Debug($"WebServer: Error during cancellation: {ex.Message}", this); } CleanupListener(); // Stop and close HttpListener if (!_lastListenState) SetStatus("Not running", IStatusProvider.StatusLevel.Notice); else if (_printToLog) { Log.Debug("WebServer: Server stopped.", this); } IsRunning.DirtyFlag.Invalidate(); PortOutput.DirtyFlag.Invalidate(); } private void CleanupListener() { try { if (_listener != null) { if (_listener.IsListening) { _listener.Stop(); if (_printToLog) Log.Debug("WebServer: HttpListener stopped.", this); } _listener.Close(); if (_printToLog) Log.Debug("WebServer: HttpListener closed.", this); _listener = null; } } catch (Exception ex) { if (_printToLog) Log.Debug($"WebServer: Error stopping/closing HttpListener: {ex.Message}", this); } } #endregion #region IStatusProvider Implementation public IStatusProvider.StatusLevel GetStatusLevel() => _statusLevel; public string GetStatusMessage() => _statusMessage; private void SetStatus(string message, IStatusProvider.StatusLevel level) { _statusMessage = message; _statusLevel = level; } #endregion #region ICustomDropdownHolder Implementation string ICustomDropdownHolder.GetValueForInput(Guid id) => id == LocalIpAddress.Id ? LocalIpAddress.Value : string.Empty; IEnumerable ICustomDropdownHolder.GetOptionsForInput(Guid id) { return id == LocalIpAddress.Id ? GetLocalIPv4Addresses() : Empty(); } void ICustomDropdownHolder.HandleResultForInput(Guid id, string? s, bool i) { if (string.IsNullOrEmpty(s) || !i || id != LocalIpAddress.Id) return; LocalIpAddress.SetTypedInputValue(s.Split(' ')[0]); } private static IEnumerable GetLocalIPv4Addresses() { yield return "0.0.0.0 (Any)"; // Option to listen on all available interfaces yield return "127.0.0.1"; // Loopback address if (!NetworkInterface.GetIsNetworkAvailable()) yield break; foreach (var ni in NetworkInterface.GetAllNetworkInterfaces()) { // Only consider operational and non-loopback interfaces if (ni.OperationalStatus != OperationalStatus.Up || ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue; foreach (var ipInfo in ni.GetIPProperties().UnicastAddresses) { // Only consider IPv4 addresses if (ipInfo.Address.AddressFamily == AddressFamily.InterNetwork) { yield return ipInfo.Address.ToString(); } } } } #endregion #region Input Slots [Input(Guid = "4C5D6E7F-8A9B-4012-3456-7890ABCDEF12")] // Updated GUID public readonly InputSlot Listen = new(); [Input(Guid = "5D6E7F8A-9B0C-4123-4567-890ABCDEF123")] // Updated GUID public readonly InputSlot LocalIpAddress = new("0.0.0.0 (Any)"); // New input slot [Input(Guid = "6E7F8A9B-0C1D-4234-5678-90ABCDEF1234")] // Updated GUID public readonly InputSlot Port = new(8080); /// /// The HTML content to serve when the root path (/) is requested. /// [Input(Guid = "7F8A9B0C-1D2E-4345-6789-0ABCDEF12345")] // Updated GUID public readonly InputSlot HtmlContent = new(@" Default T3 WebServer Page

Welcome to the T3 WebServer

Provide your HTML content via the 'HtmlContent' input slot.

Example interaction with a T3 WebSocket:

50
Connecting...
"); [Input(Guid = "8A9B0C1D-2E3F-4456-7890-ABCDEF123456")] // New GUID for PrintToLog public readonly InputSlot PrintToLog = new(); #endregion }