diff --git a/AGENTS.md b/AGENTS.md
index bc6ac194..4c272cb8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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`.
diff --git a/OpenNest/Forms/MainForm.Designer.cs b/OpenNest/Forms/MainForm.Designer.cs
index 9618d7fb..835f1c42 100644
--- a/OpenNest/Forms/MainForm.Designer.cs
+++ b/OpenNest/Forms/MainForm.Designer.cs
@@ -20,6 +20,8 @@ namespace OpenNest.Forms
{
components.Dispose();
}
+ if (disposing)
+ updater?.Dispose();
base.Dispose(disposing);
}
diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs
index 7c59ae55..f046272e 100644
--- a/OpenNest/Forms/MainForm.cs
+++ b/OpenNest/Forms/MainForm.cs
@@ -140,6 +140,57 @@ namespace OpenNest.Forms
engineComboBox.SelectedItem = EngineSelection.EngineName;
engineComboBox.SelectedIndexChanged += EngineComboBox_SelectedIndexChanged;
+
+ InitializeUpdater();
+ }
+
+ private global::OpenNest.UpdaterService updater;
+ private ToolStripStatusLabel updateStatusLabel;
+
+ ///
+ /// 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.
+ ///
+ 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()
diff --git a/OpenNest/Forms/OptionsForm.Designer.cs b/OpenNest/Forms/OptionsForm.Designer.cs
index 47e5dd08..8fdec50d 100644
--- a/OpenNest/Forms/OptionsForm.Designer.cs
+++ b/OpenNest/Forms/OptionsForm.Designer.cs
@@ -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;
}
}
\ No newline at end of file
diff --git a/OpenNest/Forms/OptionsForm.cs b/OpenNest/Forms/OptionsForm.cs
index 95fec44a..3483f426 100644
--- a/OpenNest/Forms/OptionsForm.cs
+++ b/OpenNest/Forms/OptionsForm.cs
@@ -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);
+ }
}
}
diff --git a/OpenNest/MainApp.cs b/OpenNest/MainApp.cs
index 67660c04..7878ef64 100644
--- a/OpenNest/MainApp.cs
+++ b/OpenNest/MainApp.cs
@@ -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());
diff --git a/OpenNest/OpenNest.csproj b/OpenNest/OpenNest.csproj
index c8b0f4b8..da812550 100644
--- a/OpenNest/OpenNest.csproj
+++ b/OpenNest/OpenNest.csproj
@@ -6,6 +6,7 @@
OpenNest
true
DpiUnaware
+ 0.4.0
@@ -31,6 +32,7 @@
+
diff --git a/OpenNest/Properties/Settings.Designer.cs b/OpenNest/Properties/Settings.Designer.cs
index 366191c9..af52019e 100644
--- a/OpenNest/Properties/Settings.Designer.cs
+++ b/OpenNest/Properties/Settings.Designer.cs
@@ -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;
+ }
+ }
}
}
diff --git a/OpenNest/Properties/Settings.settings b/OpenNest/Properties/Settings.settings
index c0ab09cc..cd5839ec 100644
--- a/OpenNest/Properties/Settings.settings
+++ b/OpenNest/Properties/Settings.settings
@@ -62,5 +62,8 @@
Classic
+
+
+
diff --git a/OpenNest/UpdaterService.cs b/OpenNest/UpdaterService.cs
new file mode 100644
index 00000000..90abcf76
--- /dev/null
+++ b/OpenNest/UpdaterService.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using OpenNest.Properties;
+using Velopack;
+using Velopack.Sources;
+
+namespace OpenNest
+{
+ ///
+ /// 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 and call
+ /// once the user agrees to restart.
+ ///
+ ///
+ /// The feed URL is never hard-coded: it is read from the per-machine
+ /// 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.
+ ///
+ public sealed class UpdaterService : IDisposable
+ {
+ private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(30);
+
+ private readonly UpdateManager _manager;
+ private Timer _timer;
+ private UpdateInfo _pendingUpdate;
+
+ /// Raised on the thread pool once an update has finished downloading.
+ public event Action UpdateReady;
+
+ public UpdaterService()
+ : this(Settings.Default.UpdateFeedUrl) { }
+
+ public UpdaterService(string feedUrl)
+ {
+ if (string.IsNullOrWhiteSpace(feedUrl))
+ return;
+
+ _manager = new UpdateManager(new SimpleWebSource(feedUrl));
+ }
+
+ /// True once a downloaded update is waiting for .
+ public bool HasPendingUpdate => _pendingUpdate != null;
+
+ public void Start()
+ {
+ if (_manager is null || !_manager.IsInstalled)
+ return;
+
+ _timer = new Timer(_ => _ = TickAsync(), null, CheckInterval, CheckInterval);
+ }
+
+ /// Applies the downloaded update and restarts the process. Call only after the user agrees.
+ 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.
+ }
+ }
+ }
+}
diff --git a/docs/auto-update.md b/docs/auto-update.md
new file mode 100644
index 00000000..57c35350
--- /dev/null
+++ b/docs/auto-update.md
@@ -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 -FeedUser -RemoteDataDir
+```
+
+Bump `` 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 `` via
+`-p:Version`, so the two don't conflict.
diff --git a/scripts/Deploy-OpenNest.ps1 b/scripts/Deploy-OpenNest.ps1
new file mode 100644
index 00000000..7f885b71
--- /dev/null
+++ b/scripts/Deploy-OpenNest.ps1
@@ -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 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 '(?[\d.]+)') { throw "Could not find 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}:), then restart OpenNest."
+Write-Host " This value is per-machine and never stored in source control."