feat(desktop): add Velopack auto-update support

Lets the desktop app self-update like CncSender, but only restarts after
the user confirms via a clickable status-bar prompt -- an open, unsaved
nest must never be discarded automatically. The feed URL is a blank-by-
default, per-machine setting (File > Options), since it's operator
infrastructure that doesn't belong in source control; Deploy-OpenNest.ps1
packages releases for whatever feed host/path the operator supplies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ajandClaude Sonnet 5 committed 2026-10-12 06:54:46 -04:00
1 parent e3bcc77145
commit 18d63ffd61
12 files changed
+344 -3

No files matched your search

+1
View File
@@ -62,3 +62,4 @@ Read the relevant contract before changing its behavior:
- [Pre-post verification](docs/post-verification.md)
- [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`.
+2
View File
@@ -20,6 +20,8 @@ namespace OpenNest.Forms
{
components.Dispose();
}
if (disposing)
updater?.Dispose();
base.Dispose(disposing);
}
+51
View File
@@ -140,6 +140,57 @@ namespace OpenNest.Forms
engineComboBox.SelectedItem = EngineSelection.EngineName;
engineComboBox.SelectedIndexChanged += EngineComboBox_SelectedIndexChanged;
InitializeUpdater();
}
private global::OpenNest.UpdaterService updater;
private ToolStripStatusLabel updateStatusLabel;
/// <summary>
/// Starts background update checks against the per-machine feed URL
/// (Options dialog; blank disables checks). Never restarts on its
/// own -- shows a clickable status item once an update is ready and
/// restarts only when the user clicks it.
/// </summary>
private void InitializeUpdater()
{
updateStatusLabel = new ToolStripStatusLabel
{
Visible = false,
ForeColor = Color.DarkGreen,
Font = new Font(statusLabel1.Font, FontStyle.Bold),
};
updateStatusLabel.Click += (_, _) =>
{
if (!updater.HasPendingUpdate)
return;
var result = MessageBox.Show(
this,
"An OpenNest update has been downloaded. Restart now to apply it? Any unsaved nest will be lost.",
"Update ready",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question
);
if (result == DialogResult.Yes)
updater.ApplyAndRestart();
};
statusStrip1.Items.Insert(statusStrip1.Items.IndexOf(gpuStatusLabel), updateStatusLabel);
updater = new global::OpenNest.UpdaterService();
updater.UpdateReady += version =>
{
if (IsHandleCreated)
BeginInvoke(new System.Action(() => ShowUpdateReady(version)));
};
updater.Start();
}
private void ShowUpdateReady(string version)
{
updateStatusLabel.Text = $"Update {version} ready - click to restart";
updateStatusLabel.Visible = true;
}
private Nest CreateDefaultNest()
+42 -3
View File
@@ -44,10 +44,14 @@
this.serverGroupBox = new System.Windows.Forms.GroupBox();
this.serverUrlBox = new System.Windows.Forms.TextBox();
this.setServerButton = new System.Windows.Forms.Button();
this.updateGroupBox = new System.Windows.Forms.GroupBox();
this.updateFeedUrlBox = new System.Windows.Forms.TextBox();
this.setUpdateFeedButton = new System.Windows.Forms.Button();
((System.ComponentModel.ISupportInitialize)(this.numericUpDown1)).BeginInit();
this.tableLayoutPanel1.SuspendLayout();
this.bottomPanel1.SuspendLayout();
this.serverGroupBox.SuspendLayout();
this.updateGroupBox.SuspendLayout();
this.SuspendLayout();
//
// checkBox1
@@ -139,7 +143,7 @@
this.bottomPanel1.Controls.Add(this.cancelButton);
this.bottomPanel1.Controls.Add(this.saveButton);
this.bottomPanel1.Dock = System.Windows.Forms.DockStyle.Bottom;
this.bottomPanel1.Location = new System.Drawing.Point(0, 443);
this.bottomPanel1.Location = new System.Drawing.Point(0, 509);
this.bottomPanel1.Name = "bottomPanel1";
this.bottomPanel1.Size = new System.Drawing.Size(708, 50);
this.bottomPanel1.TabIndex = 1;
@@ -162,7 +166,7 @@
// strategyGroupBox
//
this.strategyGroupBox.Controls.Add(this.strategyGrid);
this.strategyGroupBox.Location = new System.Drawing.Point(12, 253);
this.strategyGroupBox.Location = new System.Drawing.Point(12, 319);
this.strategyGroupBox.Name = "strategyGroupBox";
this.strategyGroupBox.Size = new System.Drawing.Size(684, 180);
this.strategyGroupBox.TabIndex = 2;
@@ -217,13 +221,43 @@
this.setServerButton.UseVisualStyleBackColor = true;
this.setServerButton.Click += new System.EventHandler(this.SetServer_Click);
//
// updateGroupBox
//
this.updateGroupBox.Controls.Add(this.updateFeedUrlBox);
this.updateGroupBox.Controls.Add(this.setUpdateFeedButton);
this.updateGroupBox.Location = new System.Drawing.Point(12, 253);
this.updateGroupBox.Name = "updateGroupBox";
this.updateGroupBox.Size = new System.Drawing.Size(684, 60);
this.updateGroupBox.TabIndex = 3;
this.updateGroupBox.TabStop = false;
this.updateGroupBox.Text = "Auto-update feed URL (blank disables update checks; set per machine)";
//
// updateFeedUrlBox
//
this.updateFeedUrlBox.Location = new System.Drawing.Point(12, 24);
this.updateFeedUrlBox.Name = "updateFeedUrlBox";
this.updateFeedUrlBox.PlaceholderText = "http://host:port";
this.updateFeedUrlBox.Size = new System.Drawing.Size(485, 22);
this.updateFeedUrlBox.TabIndex = 0;
//
// setUpdateFeedButton
//
this.setUpdateFeedButton.Location = new System.Drawing.Point(504, 21);
this.setUpdateFeedButton.Name = "setUpdateFeedButton";
this.setUpdateFeedButton.Size = new System.Drawing.Size(168, 28);
this.setUpdateFeedButton.TabIndex = 1;
this.setUpdateFeedButton.Text = "Save Feed URL";
this.setUpdateFeedButton.UseVisualStyleBackColor = true;
this.setUpdateFeedButton.Click += new System.EventHandler(this.SetUpdateFeed_Click);
//
// OptionsForm
//
this.AcceptButton = this.saveButton;
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.None;
this.CancelButton = this.cancelButton;
this.ClientSize = new System.Drawing.Size(708, 493);
this.ClientSize = new System.Drawing.Size(708, 559);
this.Controls.Add(this.strategyGroupBox);
this.Controls.Add(this.updateGroupBox);
this.Controls.Add(this.serverGroupBox);
this.Controls.Add(this.tableLayoutPanel1);
this.Controls.Add(this.bottomPanel1);
@@ -242,6 +276,8 @@
this.bottomPanel1.ResumeLayout(false);
this.serverGroupBox.ResumeLayout(false);
this.serverGroupBox.PerformLayout();
this.updateGroupBox.ResumeLayout(false);
this.updateGroupBox.PerformLayout();
this.ResumeLayout(false);
}
@@ -263,5 +299,8 @@
private System.Windows.Forms.GroupBox serverGroupBox;
private System.Windows.Forms.TextBox serverUrlBox;
private System.Windows.Forms.Button setServerButton;
private System.Windows.Forms.GroupBox updateGroupBox;
private System.Windows.Forms.TextBox updateFeedUrlBox;
private System.Windows.Forms.Button setUpdateFeedButton;
}
}
+28
View File
@@ -74,6 +74,7 @@ namespace OpenNest.Forms
private void LoadSettings()
{
serverUrlBox.Text = NestStorage.Settings.ServerUrl;
updateFeedUrlBox.Text = Settings.Default.UpdateFeedUrl;
checkBox1.Checked = Settings.Default.CreateNewNestOnOpen;
numericUpDown1.Value = (decimal)Settings.Default.AutoSizePlateFactor;
@@ -167,5 +168,32 @@ namespace OpenNest.Forms
"Server URL", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void SetUpdateFeed_Click(object sender, EventArgs e)
{
var text = updateFeedUrlBox.Text?.Trim() ?? "";
if (text.Length == 0)
{
Settings.Default.UpdateFeedUrl = "";
Settings.Default.Save();
updateFeedUrlBox.Text = "";
MessageBox.Show(this, "Update feed URL cleared. Update checks are disabled.",
"Update Feed URL", MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
if (!NestStorageSettings.TryNormalizeServerUrl(text, out var url))
{
MessageBox.Show(this, "Enter a valid http or https feed URL, or clear the field to disable updates.",
"Update Feed URL", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
Settings.Default.UpdateFeedUrl = url;
Settings.Default.Save();
updateFeedUrlBox.Text = url;
MessageBox.Show(this, "Update feed URL saved. Restart OpenNest for the new feed to take effect.",
"Update Feed URL", MessageBoxButtons.OK, MessageBoxIcon.Information);
}
}
}
+6
View File
@@ -1,6 +1,7 @@
using System;
using System.Windows.Forms;
using OpenNest.Forms;
using Velopack;
namespace OpenNest
{
@@ -9,6 +10,11 @@ namespace OpenNest
[STAThread]
private static void Main()
{
// Must run before anything else: on first launch after an update,
// Velopack re-invokes the new exe with hidden hook arguments and
// this call handles them and exits, so the UI below never runs.
VelopackApp.Build().Run();
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new MainForm());
+2
View File
@@ -6,6 +6,7 @@
<AssemblyName>OpenNest</AssemblyName>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationHighDpiMode>DpiUnaware</ApplicationHighDpiMode>
<Version>0.4.0</Version>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Controls\LayoutViewGL.cs" />
@@ -31,6 +32,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="Velopack" Version="1.2.0" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
+14
View File
@@ -256,5 +256,19 @@ namespace OpenNest.Properties {
this["ActiveColorScheme"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("")]
// Velopack update feed base URL (e.g. http://host:port). Blank disables
// update checks; each machine sets its own value via Options, never committed.
public string UpdateFeedUrl {
get {
return ((string)(this["UpdateFeedUrl"]));
}
set {
this["UpdateFeedUrl"] = value;
}
}
}
}
+3
View File
@@ -62,5 +62,8 @@
<Setting Name="ActiveColorScheme" Type="System.String" Scope="User">
<Value Profile="(Default)">Classic</Value>
</Setting>
<Setting Name="UpdateFeedUrl" Type="System.String" Scope="User">
<Value Profile="(Default)" />
</Setting>
</Settings>
</SettingsFile>
+86
View File
@@ -0,0 +1,86 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using OpenNest.Properties;
using Velopack;
using Velopack.Sources;
namespace OpenNest
{
/// <summary>
/// Periodically checks a Velopack release feed for updates and downloads
/// them in the background. Never restarts the app on its own -- an open,
/// unsaved nest must not be discarded out from under the user -- so
/// callers must observe <see cref="UpdateReady"/> and call
/// <see cref="ApplyAndRestart"/> once the user agrees to restart.
/// </summary>
/// <remarks>
/// The feed URL is never hard-coded: it is read from the per-machine
/// <see cref="Settings.UpdateFeedUrl"/> setting (Options dialog), which is
/// user-scoped and lives outside source control. A blank URL disables
/// update checks entirely, which is the default for every install.
/// </remarks>
public sealed class UpdaterService : IDisposable
{
private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(30);
private readonly UpdateManager _manager;
private Timer _timer;
private UpdateInfo _pendingUpdate;
/// <summary>Raised on the thread pool once an update has finished downloading.</summary>
public event Action<string> UpdateReady;
public UpdaterService()
: this(Settings.Default.UpdateFeedUrl) { }
public UpdaterService(string feedUrl)
{
if (string.IsNullOrWhiteSpace(feedUrl))
return;
_manager = new UpdateManager(new SimpleWebSource(feedUrl));
}
/// <summary>True once a downloaded update is waiting for <see cref="ApplyAndRestart"/>.</summary>
public bool HasPendingUpdate => _pendingUpdate != null;
public void Start()
{
if (_manager is null || !_manager.IsInstalled)
return;
_timer = new Timer(_ => _ = TickAsync(), null, CheckInterval, CheckInterval);
}
/// <summary>Applies the downloaded update and restarts the process. Call only after the user agrees.</summary>
public void ApplyAndRestart()
{
if (_manager != null && _pendingUpdate != null)
_manager.ApplyUpdatesAndRestart(_pendingUpdate);
}
public void Dispose() => _timer?.Dispose();
private async Task TickAsync()
{
if (_manager is null || _pendingUpdate != null)
return;
try
{
var newVersion = await _manager.CheckForUpdatesAsync();
if (newVersion == null)
return;
await _manager.DownloadUpdatesAsync(newVersion);
_pendingUpdate = newVersion;
UpdateReady?.Invoke(newVersion.TargetFullRelease.Version.ToString());
}
catch
{
// Network hiccup or feed unreachable -- retry next tick.
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
# Desktop auto-update
The Windows desktop app (`OpenNest.exe`) can self-update via
[Velopack](https://velopack.io): `OpenNest.UpdaterService` polls a static
file feed every 30 minutes, downloads a newer release in the background, and
only restarts to apply it after the user confirms (a clickable status-bar
item appears once a download finishes). It never force-restarts; an open,
unsaved nest is never discarded automatically.
## Configuring the feed
The feed URL is a per-machine setting
(`OpenNest.Properties.Settings.UpdateFeedUrl`), set from **File > Options >
Auto-update feed URL**. It is blank by default, which disables update checks
entirely — this is intentional: the feed URL is operator infrastructure, not
something that belongs in source control, so no default ships in the repo.
Enter a feed's base URL (`http://host:port`) and restart the app to pick it
up.
Velopack only applies updates to an installed copy (`UpdateManager.IsInstalled`);
running from a build output directory never attempts an update.
## Hosting a feed
Any static file server that can serve a `vpk pack` output directory works.
`scripts/Deploy-OpenNest.ps1` builds a self-contained `win-x64` publish,
packs it with the `vpk` CLI (`dotnet tool install -g vpk`), and `scp`s the
result to a directory you provide:
```powershell
./scripts/Deploy-OpenNest.ps1 -FeedHost <host> -FeedUser <user> -RemoteDataDir <path>
```
Bump `<Version>` in `OpenNest/OpenNest.csproj` before each run. This is
independent of [the GitHub release process](releasing.md) and of
`Publish-Windows.ps1` — the latter always overrides `<Version>` via
`-p:Version`, so the two don't conflict.
+72
View File
@@ -0,0 +1,72 @@
<#
Package a Velopack release of the desktop app and push it to a self-hosted
update feed (a static file server serving the `vpk pack` output). Installed
clients configured with that feed URL (Options > Auto-update feed URL) pull
and apply updates themselves.
This is separate from scripts/Publish-Windows.ps1 and the GitHub release
process (docs/releasing.md): it targets a privately hosted feed, not a
public GitHub Release, and never embeds that feed's host or path in source.
Bump <Version> in OpenNest/OpenNest.csproj before running this for a new
release.
Examples:
powershell -ExecutionPolicy Bypass -File scripts/Deploy-OpenNest.ps1 `
-FeedHost myhost -FeedUser me -RemoteDataDir ~/docker/opennest-releases/data
#>
Param(
[string]$PublishConfiguration = "Release",
[Parameter(Mandatory)]
[string]$FeedHost,
[string]$FeedUser = "aj",
[Parameter(Mandatory)]
[string]$RemoteDataDir
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $PSCommandPath
$RepoRoot = if ((Split-Path -Leaf $ScriptDir) -eq 'scripts') { Split-Path -Parent $ScriptDir } else { $ScriptDir }
Write-Host "Repository root: $RepoRoot"
$ProjectPath = Join-Path $RepoRoot 'OpenNest\OpenNest.csproj'
if (-not (Test-Path -LiteralPath $ProjectPath)) { throw "Project not found at: $ProjectPath" }
$csprojContent = Get-Content -LiteralPath $ProjectPath -Raw
if ($csprojContent -notmatch '<Version>(?<v>[\d.]+)</Version>') { throw "Could not find <Version> in $ProjectPath" }
$Version = $Matches.v
Write-Host "Packaging OpenNest v$Version"
# Ensure the vpk (Velopack) CLI is available
if (-not (Get-Command vpk -ErrorAction SilentlyContinue)) {
Write-Host "Installing vpk CLI (dotnet tool)..."
dotnet tool install -g vpk
if ($LASTEXITCODE -ne 0) { throw "dotnet tool install vpk failed" }
}
$PublishDir = Join-Path $RepoRoot 'OpenNest\bin\publish-tmp'
$ReleasesDir = Join-Path $RepoRoot 'OpenNest\bin\releases'
if (Test-Path $PublishDir) { Remove-Item $PublishDir -Recurse -Force }
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" }
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" }
Write-Host "Pushing release feed to $FeedHost..."
& scp -r "$ReleasesDir\*" "${FeedUser}@${FeedHost}:${RemoteDataDir}/"
if ($LASTEXITCODE -ne 0) { throw "scp failed" }
Write-Host "Deployment complete. Machines already configured with this feed URL will pick up v$Version within 30 minutes."
Write-Host ""
Write-Host "First-time setup on a machine (one-time per machine):"
Write-Host " 1. Copy $ReleasesDir\OpenNest-win-Setup.exe to the machine and run it."
Write-Host " 2. In OpenNest, open File > Options and set 'Auto-update feed URL' to"
Write-Host " your feed's base URL (e.g. http://${FeedHost}:<port>), then restart OpenNest."
Write-Host " This value is per-machine and never stored in source control."