← All source files

V17 / d6d373561692

WebsitePreviewSession.cs

Lilith/Core/Projects/WebsitePreviewSession.cs

Unchanged from the preceding version
CS
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; }
}