#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
}