feat(desktop): add WebView2 help window with F1 topics and pinned help packaging

Help > OpenNest Help (F1) and context F1 in Auto Nest and the CAD Converter open
the bundled opennest-help site in a single WebView2 window on its own UI thread.
Both packaging scripts install the opennest-help release pinned in
scripts/help.json, verified by SHA-256. The missing lead-in finding now says
the contour is missing lead-in geometry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Opus 5.5 committed 2026-10-22 21:08:18 -04:00
1 parent 00cb639c8e
commit ed92c37845
15 files changed
+421 -7

No files matched your search

+2 -1
View File
@@ -7,7 +7,7 @@ OpenNest is a .NET 8 Windows CNC-nesting application with cross-platform librari
- Prefer Roslyn Bridge MCP for symbols, references and diagnostics when available; fall back to text search.
- Use `var` for locals and namespaces matching project directories. Follow `.editorconfig`; format only changed C# files with `dotnet format OpenNest.sln --include <paths>`, then repeat with `--verify-no-changes`. On Linux, prefix both commands with `EnableWindowsTargeting=true`.
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`.
- Keep instructions concise: commands, boundaries and non-obvious safeguards, not class inventories or session history. Update affected instructions and user-facing docs with behavior/build changes; put detailed contracts in `docs/`. User guides and their screenshots live in the separate `ajisaacs/opennest-help` repo: update or recapture them when a UI change alters a documented screen, and never rename a `HelpTopics` ID.
- Never commit design specs, implementation plans, progress notes or temporary benchmark reports. Keep working records under local, ignored `.hermes/plans/` or `.hermes/progress/`; retain reusable verification procedures in `docs/`.
- Keep vendor manuals/full-text extracts out of source control unless redistribution is authorized. Write project-specific behavior summaries with citations, separating controller rules, machine macros and unconfirmed behavior.
@@ -68,3 +68,4 @@ Read the relevant contract before changing its behavior:
- [Cincinnati CL](docs/cincinnati-post-output.md) and [CI Fiber](docs/cincinnati-ci-fiber-post-output.md)
- [Fill verification](docs/performance/fill-verification.md): opt-in benchmarks, frozen oracles, Debug-only counters and predictor initialization. Zero Release counters do not prove work removal.
- [Desktop auto-update](docs/auto-update.md): Velopack feed, `UpdateFeedUrl` setting and `Deploy-OpenNest.ps1`.
- [Desktop help](docs/desktop-help.md): WebView2 help window, F1 topic IDs and the pinned help release both packaging scripts install.
@@ -95,7 +95,7 @@ public sealed class ReleasedContourState
Finish();
if (reportMissingLeadIns && !cutoff && !hasLead)
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
"A break cut has no nonzero placed lead-in motion.")
"A break cut is missing lead-in geometry.")
{ Location = move.Curve.Start }, owned);
inBreakCut = true;
hasLead = false;
@@ -179,7 +179,7 @@ public sealed class ReleasedContourState
contourNumber++;
if (reportMissingLeadIns && !cutoff && !hasLead)
Add(new(PostVerificationKind.MissingLeadIn, plate, part, null,
$"Cutting contour {contourNumber} has no nonzero placed lead-in motion.")
$"Cutting contour {contourNumber} is missing lead-in geometry.")
{ Location = move.Curve.Start }, owned);
hasLead = false;
// A rapid can pause/reposition without leaving any material gap.
@@ -0,0 +1,40 @@
using OpenNest.Help;
namespace OpenNest.WinForms.Tests;
public class HelpTopicsTests : IDisposable
{
private readonly string directory = Directory.CreateTempSubdirectory("opennest-help-").FullName;
public void Dispose() => Directory.Delete(directory, recursive: true);
private void WriteTopics(string json) => File.WriteAllText(Path.Combine(directory, "topics.json"), json);
[Fact]
public void MapsTopicToItsPageAndAnchor()
{
WriteTopics("""{ "topics": { "home": "index.html", "auto-nest": "first-nest.html#run-auto-nest" } }""");
Assert.Equal("first-nest.html#run-auto-nest", HelpTopics.ResolvePage(directory, HelpTopics.AutoNest));
}
[Fact]
public void UnknownTopicOpensTheHomeTopic()
{
WriteTopics("""{ "topics": { "home": "welcome.html" } }""");
Assert.Equal("welcome.html", HelpTopics.ResolvePage(directory, "not-a-topic"));
}
[Theory]
[InlineData(null)]
[InlineData("not json")]
[InlineData("""{ "pages": {} }""")]
public void MissingOrUnreadableTopicsFallBackToIndex(string json)
{
if (json != null)
WriteTopics(json);
Assert.Equal("index.html", HelpTopics.ResolvePage(directory, HelpTopics.Post));
}
}
+2
View File
@@ -6,6 +6,7 @@ using System.Text.RegularExpressions;
using System.Windows.Forms;
using OpenNest.Controls;
using OpenNest.Engine;
using OpenNest.Help;
namespace OpenNest.Forms
{
@@ -29,6 +30,7 @@ namespace OpenNest.Forms
LoadDrawings(nest);
LoadDefaultPlateOptions();
SetPlateOptimizerVisible(false);
HelpTopics.Attach(this, HelpTopics.AutoNest);
partsGrid.DataError += PartsGrid_DataError;
}
+2
View File
@@ -13,6 +13,7 @@ using OpenNest.CNC;
using OpenNest.Controls;
using OpenNest.Converters;
using OpenNest.Geometry;
using OpenNest.Help;
using OpenNest.IO;
using OpenNest.IO.Bending;
using OpenNest.TabbedCutouts;
@@ -44,6 +45,7 @@ namespace OpenNest.Forms
public CadConverterForm()
{
InitializeComponent();
HelpTopics.Attach(this, HelpTopics.ImportDrawings);
fileList.SelectedIndexChanged += OnFileSelected;
filterPanel.FilterChanged += OnFilterChanged;
+13
View File
@@ -8,6 +8,7 @@ using System.Windows.Forms;
using OpenNest.Engine.BestFit;
using OpenNest.Engine.Jobs;
using OpenNest.Forms.Commands;
using OpenNest.Help;
using OpenNest.IO;
using OpenNest.Properties;
@@ -119,6 +120,7 @@ namespace OpenNest.Forms
mnuFile.DropDownItems.Insert(mnuFile.DropDownItems.IndexOf(mnuFileExport), exportNestMenu);
mnuFile.DropDownItems.Insert(mnuFile.DropDownItems.IndexOf(mnuFileExit), storageModeMenu);
InitializeAlignMenu();
InitializeHelpMenu();
UpdateOverlapMenu();
MainWindowSettings.Load(this);
@@ -160,6 +162,17 @@ namespace OpenNest.Forms
private UpdaterStatus updaterStatus;
private void InitializeHelpMenu()
{
var contents = new ToolStripMenuItem("OpenNest &Help") { ShortcutKeys = Keys.F1 };
contents.Click += (_, _) => HelpTopics.Show(this, HelpTopics.Home);
var firstNest = new ToolStripMenuItem("&Your First Nest");
firstNest.Click += (_, _) => HelpTopics.Show(this, HelpTopics.FirstNest);
var help = new ToolStripMenuItem("&Help");
help.DropDownItems.AddRange(new ToolStripItem[] { contents, firstNest });
menuStrip1.Items.Add(help);
}
private static ToolStripButton CreateActionButton(string name, string text, Image image,
EventHandler click)
{
+167
View File
@@ -0,0 +1,167 @@
using System;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Windows.Forms;
using Microsoft.Web.WebView2.Core;
using Microsoft.Web.WebView2.WinForms;
namespace OpenNest.Help
{
/// <summary>
/// Single help window hosting the bundled HTML in WebView2. The help folder is served
/// from a private virtual host so the site's search and scripts run as on a web server.
/// The window runs its own UI thread, so modal dialogs in the app never disable it:
/// F1 in a dialog opens help that can be scrolled and searched while the dialog is open.
/// </summary>
public sealed class HelpForm : Form
{
private const string Host = "help.opennest.local";
private static readonly string Origin = $"https://{Host}/";
private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(10);
private static readonly object Sync = new();
private static HelpForm instance;
private readonly WebView2 browser = new() { Dock = DockStyle.Fill };
private string pendingPage;
private bool ready;
private HelpForm(string page)
{
pendingPage = page;
Text = "OpenNest Help";
Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
StartPosition = FormStartPosition.Manual;
var area = Screen.FromPoint(Cursor.Position).WorkingArea;
Size = new Size(System.Math.Min(1100, area.Width), System.Math.Min(900, area.Height));
Location = new Point(area.Right - Width, area.Top);
Controls.Add(browser);
}
/// <summary>Shows <paramref name="page"/> (relative to the help folder) in the help window.</summary>
public static void ShowPage(string page)
{
lock (Sync)
{
var open = instance;
if (open != null && !open.IsDisposed)
{
try
{
open.BeginInvoke(() => open.Reveal(page));
return;
}
catch (InvalidOperationException)
{
// Closed between the check and the call: start a new window below.
}
}
using var started = new ManualResetEventSlim();
var thread = new Thread(() =>
{
var form = new HelpForm(page);
form.HandleCreated += (_, _) => started.Set();
instance = form;
Application.Run(form);
})
{
IsBackground = true,
Name = "OpenNest Help",
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
started.Wait(StartTimeout);
}
}
private void Reveal(string page)
{
if (WindowState == FormWindowState.Minimized)
WindowState = FormWindowState.Normal;
Activate();
Navigate(page);
}
protected override async void OnLoad(EventArgs e)
{
base.OnLoad(e);
try
{
var dataFolder = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"OpenNest", "WebView2");
var environment = await CoreWebView2Environment.CreateAsync(null, dataFolder);
await browser.EnsureCoreWebView2Async(environment);
}
catch (Exception ex) when (ex is WebView2RuntimeNotFoundException or COMException or IOException
or UnauthorizedAccessException)
{
MessageBox.Show(this,
"OpenNest Help needs the Microsoft Edge WebView2 Runtime, which could not be started.\n\n"
+ ex.Message,
"OpenNest Help", MessageBoxButtons.OK, MessageBoxIcon.Warning);
Close();
return;
}
var core = browser.CoreWebView2;
core.SetVirtualHostNameToFolderMapping(Host, HelpTopics.Directory,
CoreWebView2HostResourceAccessKind.DenyCors);
core.Settings.AreDevToolsEnabled = false;
core.Settings.IsStatusBarEnabled = false;
core.NavigationStarting += Core_NavigationStarting;
core.NewWindowRequested += Core_NewWindowRequested;
// Site page titles already end in "OpenNest Help".
core.DocumentTitleChanged += (_, _) => Text = core.DocumentTitle;
ready = true;
if (pendingPage != null)
Navigate(pendingPage);
}
private void Navigate(string page)
{
if (!ready)
{
pendingPage = page;
return;
}
pendingPage = null;
browser.CoreWebView2.Navigate(Origin + page.TrimStart('/'));
}
// Help pages stay in this window; anything off-site opens in the user's browser.
private static void Core_NavigationStarting(object sender, CoreWebView2NavigationStartingEventArgs e)
{
if (IsHelpPage(e.Uri))
return;
e.Cancel = true;
OpenExternally(e.Uri);
}
private void Core_NewWindowRequested(object sender, CoreWebView2NewWindowRequestedEventArgs e)
{
e.Handled = true;
if (IsHelpPage(e.Uri))
browser.CoreWebView2.Navigate(e.Uri);
else
OpenExternally(e.Uri);
}
private static bool IsHelpPage(string uri) =>
uri.StartsWith(Origin, StringComparison.OrdinalIgnoreCase) || uri.StartsWith("about:", StringComparison.Ordinal);
private static void OpenExternally(string uri)
{
if (!Uri.TryCreate(uri, UriKind.Absolute, out var parsed)
|| (parsed.Scheme != Uri.UriSchemeHttps && parsed.Scheme != Uri.UriSchemeHttp))
return;
Process.Start(new ProcessStartInfo(parsed.AbsoluteUri) { UseShellExecute = true });
}
}
}
+88
View File
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Windows.Forms;
namespace OpenNest.Help
{
/// <summary>
/// Opens the bundled help (built from the ajisaacs/opennest-help repository) at a topic.
/// Topic IDs are a stable contract: the help's <c>topics.json</c> maps each ID to a page,
/// so pages can move without app changes. Never rename an ID the app uses.
/// </summary>
public static class HelpTopics
{
public const string Home = "home";
public const string FirstNest = "first-nest";
public const string ImportDrawings = "import-drawings";
public const string AutoNest = "auto-nest";
public const string Post = "post";
/// <summary>Overrides the help folder, e.g. a local opennest-help <c>site/</c> build.</summary>
public const string DirectoryVariable = "OPENNEST_HELP_DIR";
public static string Directory
{
get
{
var overridden = Environment.GetEnvironmentVariable(DirectoryVariable);
return string.IsNullOrWhiteSpace(overridden)
? Path.Combine(Application.StartupPath, "Help")
: overridden;
}
}
public static bool IsInstalled => File.Exists(Path.Combine(Directory, "index.html"));
/// <summary>Opens <paramref name="topic"/> when F1 is pressed anywhere in <paramref name="form"/>.</summary>
public static void Attach(Form form, string topic)
{
form.HelpRequested += (_, e) =>
{
e.Handled = true;
Show(form, topic);
};
}
public static void Show(IWin32Window owner, string topic)
{
if (!IsInstalled)
{
MessageBox.Show(owner,
$"Help is not installed. Expected it in:\n{Directory}",
"OpenNest Help", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
HelpForm.ShowPage(ResolvePage(Directory, topic));
}
/// <summary>Maps a topic ID to a site-relative page, falling back to the home page.</summary>
public static string ResolvePage(string helpDirectory, string topic)
{
var topics = LoadTopics(helpDirectory);
if (topics.TryGetValue(topic, out var page))
return page;
return topics.TryGetValue(Home, out var home) ? home : "index.html";
}
private static Dictionary<string, string> LoadTopics(string helpDirectory)
{
var path = Path.Combine(helpDirectory, "topics.json");
try
{
using var document = JsonDocument.Parse(File.ReadAllText(path));
var topics = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var property in document.RootElement.GetProperty("topics").EnumerateObject())
topics[property.Name] = property.Value.GetString();
return topics;
}
catch (Exception ex) when (ex is IOException or JsonException or KeyNotFoundException
or InvalidOperationException or UnauthorizedAccessException)
{
return new Dictionary<string, string>();
}
}
}
}
+1
View File
@@ -31,6 +31,7 @@
<ProjectReference Include="..\Posts\OpenNest.Posts.Cincinnati\OpenNest.Posts.Cincinnati.csproj" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\Posts\OpenNest.Posts.CincinnatiCIFiber\OpenNest.Posts.CincinnatiCIFiber.csproj" ReferenceOutputAssembly="false" />
<PackageReference Include="System.Drawing.Common" Version="8.0.10" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<PackageReference Include="Velopack" Version="1.2.0" />
</ItemGroup>
<ItemGroup>
+40
View File
@@ -0,0 +1,40 @@
# Desktop help
User guides live in [ajisaacs/opennest-help](https://github.com/ajisaacs/opennest-help):
Markdown pages plus annotated screenshots, built with MkDocs into offline HTML. This repo
holds only the code that shows that HTML and the pin of which help release ships.
## In the app
- **Help > OpenNest Help** (F1 in the main window) opens the home topic;
**Help > Your First Nest** opens the tutorial.
- A dialog opts in to context help with `HelpTopics.Attach(this, HelpTopics.<Topic>)`;
F1 anywhere in it opens that topic. Auto Nest and the CAD Converter do this today.
- `HelpForm` is a single WebView2 window on its own STA thread, so modal dialogs never
disable it. It serves the help folder from the private virtual host
`https://help.opennest.local/`; off-site links open in the default browser. WebView2
data goes to `%LOCALAPPDATA%\OpenNest\WebView2`.
- The help folder is `Help\` beside `OpenNest.exe`. Set `OPENNEST_HELP_DIR` to a local
opennest-help `site\` build to preview unreleased pages. Without either, the menu
explains that help is not installed.
## Topic IDs
`HelpTopics` constants are IDs, not file names. The help's `topics.json` maps each ID
to a page or `page.html#anchor`, and its build fails if a mapped page is missing. An
unknown ID falls back to the home page. Add the ID to `topics.json` (in the help repo)
before using a new constant here, and never rename an existing ID.
## Packaging
`scripts/help.json` pins one opennest-help release (tag and SHA-256 of its
`opennest-help.zip`). `scripts/Install-Help.ps1` downloads it, refuses a hash mismatch
and expands it into the package's `Help\` folder; both `Publish-Windows.ps1` and
`Deploy-OpenNest.ps1` call it, and `build-info.json` records the help version. Pass
`-HelpDirectory <site folder>` to either script to package a local build instead.
To ship new help: tag the help repo `vX.Y.Z`, wait for its release, then update `tag`
and `sha256` here (the release attaches `opennest-help.zip.sha256`).
When a UI change alters a documented screen or workflow, update or recapture the
affected opennest-help pages (its README covers capture and markup).
+3 -1
View File
@@ -23,7 +23,9 @@ pulls from GitHub every hour; it refuses pushes.
4. Download the `OpenNest-X.Y.Z-win-x64` artifact and verify its `.sha256`.
It contains `OpenNest.vX.Y.Z.win-x64.zip`, with the .NET runtime, native
dependencies, shipped configurations, all three post-processors, license,
and `build-info.json` identifying the exact source commit. The nesting engines
the `Help` folder from the opennest-help release pinned in `scripts/help.json`
(see [desktop help](desktop-help.md)), and `build-info.json` identifying the exact
source commit and help version. The nesting engines
are built into `OpenNest.Engine.dll`; no external engine repository or plug-in
DLL is packaged. `scripts/ReleaseSmoke` loads the packaged engine assembly and
checks that every built-in engine instantiates from it, that the renamed
+6 -1
View File
@@ -22,7 +22,9 @@ Param(
[string]$FeedHost,
[string]$FeedUser = "aj",
[Parameter(Mandatory)]
[string]$RemoteDataDir
[string]$RemoteDataDir,
# A built opennest-help site/ folder to package instead of the release pinned in help.json.
[string]$HelpDirectory
)
Set-StrictMode -Version Latest
@@ -55,6 +57,9 @@ Write-Host "Publishing self-contained build..."
& dotnet publish $ProjectPath -c $PublishConfiguration -r win-x64 --self-contained true -o $PublishDir
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed with exit code $LASTEXITCODE" }
$helpVersion = & (Join-Path $ScriptDir 'Install-Help.ps1') -Destination (Join-Path $PublishDir 'Help') -HelpDirectory $HelpDirectory
Write-Host "Packaged help $helpVersion"
Write-Host "Packing Velopack release v$Version..."
& vpk pack -u OpenNest -v $Version -p $PublishDir -e OpenNest.exe -o $ReleasesDir
if ($LASTEXITCODE -ne 0) { throw "vpk pack failed with exit code $LASTEXITCODE" }
+41
View File
@@ -0,0 +1,41 @@
<#
Put the OpenNest help site into a package's Help folder.
By default this downloads the opennest-help release pinned in scripts/help.json and
refuses it unless its SHA-256 matches. Pass -HelpDirectory to use a locally built
site/ folder instead (e.g. while writing new help pages). Returns a short
description of the help version for build-info.json.
Example:
./scripts/Install-Help.ps1 -Destination artifacts/OpenNest/Help
./scripts/Install-Help.ps1 -Destination bin/publish/Help -HelpDirectory ../opennest-help/site
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $Destination,
[string] $HelpDirectory
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
if (Test-Path $Destination) { throw "Refusing to overwrite existing help folder: $Destination" }
if ($HelpDirectory) {
if (-not (Test-Path (Join-Path $HelpDirectory 'index.html'))) { throw "No help site in $HelpDirectory (index.html missing)." }
New-Item -ItemType Directory -Path $Destination | Out-Null
Copy-Item (Join-Path $HelpDirectory '*') $Destination -Recurse
return "local:$([IO.Path]::GetFullPath($HelpDirectory))"
}
$pin = Get-Content (Join-Path $PSScriptRoot 'help.json') -Raw | ConvertFrom-Json
$version = "$($pin.repository)@$($pin.tag)"
$zip = Join-Path ([IO.Path]::GetTempPath()) "opennest-help-$([guid]::NewGuid().ToString('N')).zip"
try {
Invoke-WebRequest "https://github.com/$($pin.repository)/releases/download/$($pin.tag)/opennest-help.zip" -OutFile $zip -UseBasicParsing
$hash = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant()
if ($hash -ne $pin.sha256) { throw "Help $version hash $hash does not match scripts/help.json." }
Expand-Archive -Path $zip -DestinationPath $Destination
}
finally { Remove-Item $zip -ErrorAction SilentlyContinue }
return $version
+9 -2
View File
@@ -3,7 +3,9 @@ param(
[Parameter(Mandatory)]
[ValidatePattern('^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$')]
[string] $Version,
[string] $OutputDirectory = 'artifacts'
[string] $OutputDirectory = 'artifacts',
# A built opennest-help site/ folder to package instead of the release pinned in help.json.
[string] $HelpDirectory
)
$ErrorActionPreference = 'Stop'
@@ -39,6 +41,9 @@ try {
if ($name -eq 'GravographIS') { Copy-Item "$staging/System.IO.Ports.dll" $package }
}
# Help ships as the opennest-help release pinned in help.json, verified against its hash.
$helpVersion = & (Join-Path $PSScriptRoot 'Install-Help.ps1') -Destination (Join-Path $package 'Help') -HelpDirectory $HelpDirectory
New-Item -ItemType Directory -Path (Join-Path $package 'Schemes') -Force | Out-Null
Copy-Item LICENSE $package
@{
@@ -46,6 +51,7 @@ try {
sourceCommit = $commit
runtime = 'win-x64'
selfContained = $true
help = $helpVersion
} | ConvertTo-Json | Set-Content (Join-Path $package 'build-info.json') -Encoding utf8NoBOM
$required = @(
@@ -54,7 +60,8 @@ try {
'onnxruntime.dll', 'System.IO.Ports.dll', 'Configurations/PipeFlangeShape.json',
'Posts/OpenNest.Posts.Cincinnati.dll', 'Posts/OpenNest.Posts.Cincinnati.json',
'Posts/OpenNest.Posts.CincinnatiCIFiber.dll', 'Posts/OpenNest.Posts.CincinnatiCIFiber.json',
'Posts/OpenNest.Posts.GravographIS.dll', 'LICENSE', 'build-info.json'
'Posts/OpenNest.Posts.GravographIS.dll', 'Help/index.html', 'Help/topics.json',
'LICENSE', 'build-info.json'
)
foreach ($file in $required) {
if (-not (Test-Path (Join-Path $package $file) -PathType Leaf)) { throw "Package missing $file" }
+5
View File
@@ -0,0 +1,5 @@
{
"repository": "ajisaacs/opennest-help",
"tag": "v0.1.0",
"sha256": ""
}