From 18d63ffd61b22f9a3790a87a83ceedb74c8d76cc Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 12 Oct 2026 06:54:46 -0400 Subject: [PATCH] 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 --- AGENTS.md | 1 + OpenNest/Forms/MainForm.Designer.cs | 2 + OpenNest/Forms/MainForm.cs | 51 ++++++++++++++ OpenNest/Forms/OptionsForm.Designer.cs | 45 ++++++++++++- OpenNest/Forms/OptionsForm.cs | 28 ++++++++ OpenNest/MainApp.cs | 6 ++ OpenNest/OpenNest.csproj | 2 + OpenNest/Properties/Settings.Designer.cs | 14 ++++ OpenNest/Properties/Settings.settings | 3 + OpenNest/UpdaterService.cs | 86 ++++++++++++++++++++++++ docs/auto-update.md | 37 ++++++++++ scripts/Deploy-OpenNest.ps1 | 72 ++++++++++++++++++++ 12 files changed, 344 insertions(+), 3 deletions(-) create mode 100644 OpenNest/UpdaterService.cs create mode 100644 docs/auto-update.md create mode 100644 scripts/Deploy-OpenNest.ps1 diff --git a/AGENTS.md b/AGENTS.md index bc6ac19..4c272cb 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 9618d7f..835f1c4 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 7c59ae5..f046272 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 47e5dd0..8fdec50 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 95fec44..3483f42 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 67660c0..7878ef6 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 c8b0f4b..da81255 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 366191c..af52019 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 c0ab09c..cd5839e 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 0000000..90abcf7 --- /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 0000000..57c3535 --- /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 0000000..7f885b7 --- /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."