Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions src/officecli/Core/HtmlScreenshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,20 @@ public sealed record Result(bool Ok, string Backend, string? Error);

public sealed record PaginationResult(int TotalPages, Dictionary<string, int> AnchorPageMap);

/// <summary>Path for a fresh, private headless-Chrome profile directory for one
/// launch. Keeps a headless run from touching — or blocking on a lock held by —
/// the caller's own default Chrome profile, and lets screenshotting work under
/// sandboxes that deny writes to the default profile directory. The directory
/// is created by Chrome itself on first use; callers delete it with
/// <see cref="CleanupChromeUserDataDir"/> once the launch that created it exits.</summary>
private static string NewChromeUserDataDir() =>
Path.Combine(Path.GetTempPath(), $"officecli-chrome-{Guid.NewGuid():N}");

private static void CleanupChromeUserDataDir(string dir)
{
try { Directory.Delete(dir, true); } catch { /* best effort */ }
}

/// Run a chromium-family browser in dump-dom mode against the given HTML
/// and parse the document title for "PAGES:N|MAP:anchor=p,anchor=p,...".
/// The HTML must set the title from JS after layout settles.
Expand All @@ -32,11 +46,13 @@ public sealed record PaginationResult(int TotalPages, Dictionary<string, int> An
var url = new Uri(Path.GetFullPath(htmlPath)).AbsoluteUri + "#screenshot";
var bin = FindChrome();
if (bin == null) return null;
var userDataDir = NewChromeUserDataDir();
var args = new List<string>
{
"--headless=new",
"--disable-gpu",
"--no-sandbox",
$"--user-data-dir={userDataDir}",
"--virtual-time-budget=15000",
"--timeout=20000", // wall-clock backstop: a stalled resource is not rescued by virtual time (issue #181)
};
Expand Down Expand Up @@ -64,6 +80,7 @@ public sealed record PaginationResult(int TotalPages, Dictionary<string, int> An
return stdout;
}
catch { return null; }
finally { CleanupChromeUserDataDir(userDataDir); }
}

/// <summary>True when a chrome-family browser (Chrome/Chromium/Edge) is available.</summary>
Expand All @@ -84,12 +101,14 @@ public static bool CaptureChromeSized(string htmlPath, string outPath, int w, in
var outDir = Path.GetDirectoryName(outPath);
if (!string.IsNullOrEmpty(outDir)) Directory.CreateDirectory(outDir);
var url = new Uri(Path.GetFullPath(htmlPath)).AbsoluteUri + "#screenshot";
var userDataDir = NewChromeUserDataDir();
var args = new[]
{
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--hide-scrollbars",
$"--user-data-dir={userDataDir}",
$"--force-device-scale-factor={scale}",
$"--window-size={w},{h}",
"--virtual-time-budget=15000",
Expand All @@ -98,8 +117,12 @@ public static bool CaptureChromeSized(string htmlPath, string outPath, int w, in
$"--screenshot={outPath}",
url,
};
var (ok, _) = RunBinary(bin, args);
return ok && File.Exists(outPath) && new FileInfo(outPath).Length > 0;
try
{
var (ok, _) = RunBinary(bin, args);
return ok && File.Exists(outPath) && new FileInfo(outPath).Length > 0;
}
finally { CleanupChromeUserDataDir(userDataDir); }
}

public static PaginationResult? GetPaginationFromDom(string htmlPath, int timeoutMs = 60000)
Expand Down Expand Up @@ -385,12 +408,14 @@ private static (bool, string?) TryChrome(string url, string outPath, int w, int
{
var bin = FindChrome();
if (bin == null) return (false, null);
var userDataDir = NewChromeUserDataDir();
var args = new[]
{
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--hide-scrollbars",
$"--user-data-dir={userDataDir}",
$"--window-size={w},{h}",
// Without these caps, new-headless --screenshot waits for the
// page's external resources (CDN fonts / KaTeX css+js) to settle;
Expand All @@ -406,7 +431,8 @@ private static (bool, string?) TryChrome(string url, string outPath, int w, int
$"--screenshot={outPath}",
url,
};
return RunBinary(bin, args);
try { return RunBinary(bin, args); }
finally { CleanupChromeUserDataDir(userDataDir); }
}

private static string? FindChrome()
Expand Down Expand Up @@ -542,6 +568,7 @@ private static (bool Ok, string? Stderr) RunChromeCapture(string htmlPath, strin
if (bin == null) return (false, null);
outPath = Path.GetFullPath(outPath);
var url = new Uri(Path.GetFullPath(htmlPath)).AbsoluteUri + "#screenshot";
var userDataDir = NewChromeUserDataDir();
try
{
var psi = new ProcessStartInfo
Expand All @@ -563,6 +590,7 @@ private static (bool Ok, string? Stderr) RunChromeCapture(string htmlPath, strin
"--hide-scrollbars",
"--enable-logging=stderr",
"--v=0",
$"--user-data-dir={userDataDir}",
$"--force-device-scale-factor={scale}",
$"--window-size={w},{h}",
"--virtual-time-budget=15000",
Expand All @@ -582,6 +610,7 @@ private static (bool Ok, string? Stderr) RunChromeCapture(string htmlPath, strin
return (p.ExitCode == 0, errTask.GetAwaiter().GetResult());
}
catch { return (false, null); }
finally { CleanupChromeUserDataDir(userDataDir); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for Chrome to exit before deleting its profile

When a Chrome capture times out, both this method and the RunBinary path call Kill(true) and return immediately; process termination is asynchronous, so this finally can attempt Directory.Delete while Chrome or its children still hold files in the profile. On platforms such as Windows the deletion then fails, and because CleanupChromeUserDataDir suppresses the exception, repeated timed-out captures permanently accumulate these supposedly temporary directories. Wait for the killed process tree to exit before running the cleanup.

Useful? React with 👍 / 👎.

}

private static (bool, string?) RunBinary(string bin, string[] args)
Expand Down