V16 / 6df58cd3ed86
WebsitePreviewSession.cs
Lilith/Core/Projects/WebsitePreviewSession.cs
Changed in this versionCS
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Lilith.Core.Workspace.Extensions;
using OpenQA.Selenium.Chrome;
namespace Lilith.Core.Projects;
public sealed record WebsiteScreenshot(string Label, string File, int Width, int Height, string Sha256);
public sealed record WebsitePreviewSource(string Name, ProjectSource[] Files, string SourceHash);
/// <summary>One immutable approved website snapshot, one server and one visible browser.</summary>
internal sealed class WebsitePreviewSession : IDisposable
{
private HttpListener? _server;
private Task? _serving;
private WebsitePreviewSource? _source;
private string _folder = "";
internal string Url { get; private set; } = "";
internal string Start(WebsitePreviewSource source, string folder)
{
if (_server is not null) throw new InvalidOperationException("A website preview is already running.");
ProjectGeneration.ValidateBundle(new(source.Files), "website");
if (!source.Files.Select(f => f.Path).ToHashSet(StringComparer.Ordinal).SetEquals(["index.html", "styles.css", "app.js"])) throw new InvalidDataException("Preview requires the three managed website files.");
folder.EnsureNoLinks(); Directory.CreateDirectory(folder); _source = source; _folder = folder;
File.WriteAllText(Path.Combine(folder, "source.json"), ProjectJson.Serialize(source));
for (int attempt = 0; ; attempt++)
{
var reservation = new TcpListener(IPAddress.Loopback, 0); reservation.Start();
int port = ((IPEndPoint)reservation.LocalEndpoint).Port; reservation.Stop();
var listener = new HttpListener(); Url = "http://127.0.0.1:" + port + "/"; listener.Prefixes.Add(Url);
try { listener.Start(); _server = listener; break; }
catch (HttpListenerException) when (attempt < 4) { listener.Close(); }
}
_serving = Serve(_server);
return ProjectJson.Serialize(new { status = "started", url = Url, source.Name, source.SourceHash });
}
private async Task Serve(HttpListener listener)
{
while (listener.IsListening)
{
HttpListenerContext context;
try { context = await listener.GetContextAsync(); } catch (Exception) when (!listener.IsListening) { break; }
try
{
var request = context.Request; var response = context.Response;
if (request.HttpMethod != "GET" || request.RemoteEndPoint is null || !IPAddress.IsLoopback(request.RemoteEndPoint.Address) || request.Url?.GetLeftPart(UriPartial.Authority) + "/" != Url)
{ response.StatusCode = 403; continue; }
string path = request.Url!.AbsolutePath.TrimStart('/'); if (path.Length == 0) path = "index.html";
var file = _source!.Files.SingleOrDefault(f => f.Path == path);
if (file is null) { response.StatusCode = 404; continue; }
response.Headers["Cache-Control"] = "no-store"; response.Headers["X-Content-Type-Options"] = "nosniff";
response.ContentType = Path.GetExtension(path) switch { ".html" => "text/html; charset=utf-8", ".css" => "text/css; charset=utf-8", _ => "text/javascript; charset=utf-8" };
byte[] bytes = Encoding.UTF8.GetBytes(file.Content); response.ContentLength64 = bytes.Length;
await response.OutputStream.WriteAsync(bytes);
}
catch (Exception) { /* A disconnected browser does not terminate the owned server. */ }
finally { context.Response.Close(); }
}
}
internal async Task<WebsiteScreenshot[]> CaptureAsync(CancellationToken ct)
{
if (_server is null || _source is null) throw new InvalidOperationException("Start the approved website preview first.");
var config = WebResearch.BrowserResearchOptions.Load();
string? directory = config.DriverDirectory;
if (directory is null)
{
string cache = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache/selenium/chromedriver");
directory = Directory.Exists(cache) ? Directory.EnumerateFiles(cache, "chromedriver.exe", SearchOption.AllDirectories).OrderByDescending(File.GetLastWriteTimeUtc).Select(Path.GetDirectoryName).FirstOrDefault() : null;
}
if (directory is null) throw new IOException("Existing ChromeDriver required; no driver is installed automatically.");
return await Task.Run(() =>
{
var chrome = new ChromeOptions(); if (config.ChromeBinary is not null) chrome.BinaryLocation = config.ChromeBinary;
chrome.AddArguments("--no-first-run", "--disable-background-networking");
using var service = ChromeDriverService.CreateDefaultService(directory); service.HideCommandPromptWindow = true;
using var browser = new ChromeDriver(service, chrome, TimeSpan.FromSeconds(10));
browser.Manage().Timeouts().PageLoad = TimeSpan.FromSeconds(10);
var images = new List<WebsiteScreenshot>();
foreach (var viewport in new[] { (Label: "Desktop", Width: 1440, Height: 1000), (Label: "Tablet", Width: 900, Height: 1000), (Label: "Mobile", Width: 390, Height: 844) })
{
ct.ThrowIfCancellationRequested();
browser.ExecuteCdpCommand("Emulation.setDeviceMetricsOverride", new() { ["width"] = viewport.Width, ["height"] = viewport.Height, ["deviceScaleFactor"] = 1, ["mobile"] = false });
browser.Navigate().GoToUrl(Url);
browser.ExecuteCdpCommand("Runtime.evaluate", new() { ["expression"] = "Promise.race([Promise.all([document.fonts.ready,...Array.from(document.images).map(i=>i.complete?Promise.resolve():new Promise(r=>{i.onload=r;i.onerror=r}))]),new Promise(r=>setTimeout(r,3000))])", ["awaitPromise"] = true, ["timeout"] = 4000 });
ct.ThrowIfCancellationRequested(); byte[] png = browser.GetScreenshot().AsByteArray;
if (png.Length < 24 || !png.AsSpan(0,8).SequenceEqual(new byte[] {137,80,78,71,13,10,26,10})) throw new IOException("Browser returned no valid PNG.");
if (System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(16,4)) != viewport.Width || System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(20,4)) != viewport.Height)
throw new IOException("Captured PNG does not match the requested viewport.");
string file = Path.Combine(_folder, viewport.Label.ToLowerInvariant() + ".png"); file.EnsureNoLinks(); File.WriteAllBytes(file, png);
images.Add(new(viewport.Label, file, viewport.Width, viewport.Height, Convert.ToHexString(SHA256.HashData(png))));
}
File.WriteAllText(Path.Combine(_folder, "screenshots.json"), ProjectJson.Serialize(new { status = "captured", _source.SourceHash, images, note = "Screenshots show rendered appearance, not proof of functional correctness." }));
return images.ToArray();
}, ct);
}
public void Dispose() { _server?.Close(); _server = null; }
}