diff --git a/src/officecli/Core/CdpScreenshotClient.cs b/src/officecli/Core/CdpScreenshotClient.cs new file mode 100644 index 000000000..ebcfb7803 --- /dev/null +++ b/src/officecli/Core/CdpScreenshotClient.cs @@ -0,0 +1,250 @@ +// Copyright 2026 OfficeCLI (https://OfficeCLI.AI) +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Diagnostics; +using System.IO; +using System.Net.Http; +using System.Net.Sockets; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; + +namespace OfficeCli.Core; + +/// +/// Maintains a warm, in-memory Chrome DevTools Protocol (CDP) WebSocket connection +/// for ultra-fast (~35ms) HTML-to-PNG screenshot rendering within a ResidentServer lifecycle. +/// Supports zero-disk in-memory HTML injection over data URIs. +/// +public sealed class CdpScreenshotClient : IDisposable +{ + private Process? _chromeProcess; + private ClientWebSocket? _ws; + private int _messageId; + private int _port; + private readonly object _lock = new(); + private bool _disposed; + + private static int GetFreeTcpPort() + { + using var l = new TcpListener(System.Net.IPAddress.Loopback, 0); + l.Start(); + int port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; + } + + private static string EscapeJsonString(string s) + { + return "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r") + "\""; + } + + public async Task EnsureStartedAsync(CancellationToken ct = default) + { + if (_ws != null && _ws.State == WebSocketState.Open) return; + + var bin = HtmlScreenshot.FindChrome(); + if (bin == null) throw new InvalidOperationException("No Chromium-family browser found on system."); + + _port = GetFreeTcpPort(); + var psi = new ProcessStartInfo + { + FileName = bin, + Arguments = $"--headless=new --disable-gpu --no-sandbox --hide-scrollbars --remote-debugging-port={_port} --remote-allow-origins=*", + UseShellExecute = false, + CreateNoWindow = true, + }; + + _chromeProcess = Process.Start(psi); + if (_chromeProcess == null) throw new InvalidOperationException("Failed to launch browser process."); + + string? wsUrl = await PollPageWebSocketUrlAsync(_port, ct); + if (string.IsNullOrEmpty(wsUrl)) + { + Dispose(); + throw new InvalidOperationException("Failed to connect to CDP HTTP debug endpoint."); + } + + _ws = new ClientWebSocket(); + await _ws.ConnectAsync(new Uri(wsUrl), ct); + + await SendRawCdpAsync("{\"id\":1,\"method\":\"Page.enable\"}", 1, ct); + } + + private static async Task PollPageWebSocketUrlAsync(int port, CancellationToken ct) + { + using var http = new HttpClient { Timeout = TimeSpan.FromMilliseconds(500) }; + string targetUrl = $"http://127.0.0.1:{port}/json/list"; + + for (int i = 0; i < 30; i++) + { + try + { + var jsonStr = await http.GetStringAsync(targetUrl, ct); + using var doc = JsonDocument.Parse(jsonStr); + foreach (var el in doc.RootElement.EnumerateArray()) + { + if (el.TryGetProperty("type", out var typeProp) && typeProp.GetString() == "page") + { + if (el.TryGetProperty("webSocketDebuggerUrl", out var wsProp)) + return wsProp.GetString(); + } + } + } + catch + { + await Task.Delay(50, ct); + } + } + return null; + } + + private async Task SendRawCdpAsync(string reqJson, int msgId, CancellationToken ct) + { + if (_ws == null || _ws.State != WebSocketState.Open) + await EnsureStartedAsync(ct); + + byte[] reqBytes = Encoding.UTF8.GetBytes(reqJson); + await _ws!.SendAsync(new ArraySegment(reqBytes), WebSocketMessageType.Text, true, ct); + + var buffer = new byte[8192]; + using var ms = new MemoryStream(); + + while (true) + { + WebSocketReceiveResult result; + do + { + result = await _ws.ReceiveAsync(new ArraySegment(buffer), ct); + if (result.MessageType == WebSocketMessageType.Close) + { + await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", ct); + throw new InvalidOperationException("WebSocket closed unexpectedly."); + } + ms.Write(buffer, 0, result.Count); + } + while (!result.EndOfMessage); + + ms.Position = 0; + using var doc = JsonDocument.Parse(ms); + var root = doc.RootElement.Clone(); + ms.SetLength(0); + + if (root.TryGetProperty("id", out var idProp) && idProp.GetInt32() == msgId) + { + if (root.TryGetProperty("result", out var resProp)) + return resProp; + if (root.TryGetProperty("error", out var errProp)) + throw new InvalidOperationException($"CDP Error: {errProp.GetRawText()}"); + } + } + } + + private async Task WaitForLoadEventAsync(CancellationToken ct) + { + if (_ws == null || _ws.State != WebSocketState.Open) return; + + var buffer = new byte[4096]; + using var ms = new MemoryStream(); + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(3000); // 3-second safety timeout for load event + + try + { + while (!cts.IsCancellationRequested) + { + WebSocketReceiveResult result; + do + { + result = await _ws.ReceiveAsync(new ArraySegment(buffer), cts.Token); + ms.Write(buffer, 0, result.Count); + } + while (!result.EndOfMessage); + + ms.Position = 0; + using var doc = JsonDocument.Parse(ms); + var root = doc.RootElement.Clone(); + ms.SetLength(0); + + if (root.TryGetProperty("method", out var methodProp) && methodProp.GetString() == "Page.loadEventFired") + { + return; + } + } + } + catch (OperationCanceledException) { } + } + + public async Task CaptureScreenshotAsync(string inputHtmlOrPath, int w = 1600, int h = 1200, bool isRawHtml = false, CancellationToken ct = default) + { + try + { + await EnsureStartedAsync(ct); + + int id1 = Interlocked.Increment(ref _messageId); + string metricsJson = $"{{\"id\":{id1},\"method\":\"Emulation.setDeviceMetricsOverride\",\"params\":{{\"width\":{w},\"height\":{h},\"deviceScaleFactor\":1,\"mobile\":false}}}}"; + await SendRawCdpAsync(metricsJson, id1, ct); + + string dataUrl; + if (isRawHtml) + { + string b64Html = Convert.ToBase64String(Encoding.UTF8.GetBytes(inputHtmlOrPath)); + dataUrl = "data:text/html;charset=utf-8;base64," + b64Html + "#screenshot"; + } + else + { + dataUrl = new Uri(Path.GetFullPath(inputHtmlOrPath)).AbsoluteUri + "#screenshot"; + } + + int id2 = Interlocked.Increment(ref _messageId); + string encodedUrl = EscapeJsonString(dataUrl); + string navJson = $"{{\"id\":{id2},\"method\":\"Page.navigate\",\"params\":{{\"url\":{encodedUrl}}}}}"; + await SendRawCdpAsync(navJson, id2, ct); + + if (!isRawHtml) + await WaitForLoadEventAsync(ct); + + int id3 = Interlocked.Increment(ref _messageId); + string shotJson = $"{{\"id\":{id3},\"method\":\"Page.captureScreenshot\",\"params\":{{\"format\":\"png\"}}}}"; + var result = await SendRawCdpAsync(shotJson, id3, ct); + + if (result.TryGetProperty("data", out var dataProp)) + { + string? b64 = dataProp.GetString(); + if (!string.IsNullOrEmpty(b64)) + return Convert.FromBase64String(b64); + } + } + catch (Exception ex) + { + Console.Error.WriteLine($"[CDP Warm Screenshot Error]: {ex.Message}"); + Dispose(); + } + return null; + } + + public void Dispose() + { + lock (_lock) + { + if (_disposed) return; + _disposed = true; + + try { _ws?.Dispose(); } catch { } + _ws = null; + + try + { + if (_chromeProcess != null && !_chromeProcess.HasExited) + { + _chromeProcess.Kill(true); + _chromeProcess.Dispose(); + } + } + catch { } + _chromeProcess = null; + } + } +} diff --git a/src/officecli/Core/HtmlScreenshot.cs b/src/officecli/Core/HtmlScreenshot.cs index 787e77288..4246282f6 100644 --- a/src/officecli/Core/HtmlScreenshot.cs +++ b/src/officecli/Core/HtmlScreenshot.cs @@ -409,7 +409,7 @@ private static (bool, string?) TryChrome(string url, string outPath, int w, int return RunBinary(bin, args); } - private static string? FindChrome() + internal static string? FindChrome() { string[] names = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "chrome", "microsoft-edge", "microsoft-edge-stable", "msedge"]; diff --git a/src/officecli/ResidentServer.cs b/src/officecli/ResidentServer.cs index 1659f0171..8a17800f6 100644 --- a/src/officecli/ResidentServer.cs +++ b/src/officecli/ResidentServer.cs @@ -72,6 +72,7 @@ public class ResidentServer : IDisposable // idle-shutdown follows. Autosave firing does NOT reset the shutdown timer, // so the resident still exits the configured time after the last command. private CancellationTokenSource _autosaveCts = new(); + private Core.CdpScreenshotClient? _cdpClient; private bool _disposed; // Safe stderr logging: the parent process may have redirected our stderr @@ -1737,17 +1738,38 @@ private void ExecuteView(ResidentRequest req, OutputFormat format) { var tmpHtml = Path.Combine(Path.GetTempPath(), $"officecli_preview_{Path.GetFileNameWithoutExtension(_filePath)}_{DateTime.Now:HHmmss}_{Guid.NewGuid():N}.html"); File.WriteAllText(tmpHtml, html!); - var rs = rangeArg != null - ? OfficeCli.Core.HtmlScreenshot.CaptureClipped(tmpHtml, pngPath, - OfficeCli.Core.HtmlScreenshot.ResolveClipDataPaths(rangeArg)) - : OfficeCli.Core.HtmlScreenshot.Capture(tmpHtml, pngPath, sw, sh); - try { File.Delete(tmpHtml); } catch { /* ignore */ } - if (!rs.Ok) + + bool captured = false; + if (rangeArg == null) { - Console.Error.WriteLine("No headless browser available. Install Chrome/Edge/Chromium or Firefox, or `pip install playwright && playwright install chromium`." - + (rs.Error != null ? $" Last error: {rs.Error}" : "")); - return; + try + { + _cdpClient ??= new Core.CdpScreenshotClient(); + var cdpBytes = _cdpClient.CaptureScreenshotAsync(html!, sw, sh, isRawHtml: true).GetAwaiter().GetResult(); + if (cdpBytes != null && cdpBytes.Length > 0) + { + File.WriteAllBytes(pngPath, cdpBytes); + captured = true; + } + } + catch { } } + + if (!captured) + { + var rs = rangeArg != null + ? OfficeCli.Core.HtmlScreenshot.CaptureClipped(tmpHtml, pngPath, + OfficeCli.Core.HtmlScreenshot.ResolveClipDataPaths(rangeArg)) + : OfficeCli.Core.HtmlScreenshot.Capture(tmpHtml, pngPath, sw, sh); + if (!rs.Ok) + { + Console.Error.WriteLine("No headless browser available. Install Chrome/Edge/Chromium or Firefox, or `pip install playwright && playwright install chromium`." + + (rs.Error != null ? $" Last error: {rs.Error}" : "")); + try { File.Delete(tmpHtml); } catch { /* ignore */ } + return; + } + } + try { File.Delete(tmpHtml); } catch { /* ignore */ } } Console.WriteLine(Path.GetFullPath(pngPath)); if (_handler is OfficeCli.Handlers.PowerPointHandler pptCnt) @@ -2773,6 +2795,7 @@ private async Task DoShutdownAsync() disposeFailed = true; LogStderr($"Warning: handler dispose error: {ex.Message}"); } + try { _cdpClient?.Dispose(); } catch { } // BUG-BT-R26-2 / BUG-R43: detect data loss. The original probe used // File.Exists(_filePath) post-Dispose — but on macOS, renaming the