diff --git a/CITATION.cff b/CITATION.cff index f8884451..565733bf 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -2,8 +2,8 @@ cff-version: 1.2.0 message: "If you use this software, please cite it as below." type: software title: "WPF Framework" -version: "1.0.0" -date-released: "2026-06-30" +version: "1.0.1" +date-released: "2026-07-10" license: 0BSD repository-code: "https://github.com/USACE-RMC/WPF-Framework" url: "https://github.com/USACE-RMC/WPF-Framework" diff --git a/Directory.Build.props b/Directory.Build.props index 79ac2446..1579c77b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,9 +14,9 @@ https://github.com/USACE-RMC/WPF-Framework https://github.com/USACE-RMC/WPF-Framework git - 1.0.0 - 1.0.0.0 - 1.0.0.0 + 1.0.1 + 1.0.1.0 + 1.0.1.0 false false diff --git a/README.md b/README.md index 96259198..ede7edeb 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ dotnet test WPF-Framework.sln ### Build Packages ```bash -.\scripts\pack-wpf-framework.ps1 -Configuration Release -Version 1.0.0 +.\scripts\pack-wpf-framework.ps1 -Configuration Release -Version 1.0.1 ``` This creates and validates the following NuGet packages in `artifacts/packages/`: @@ -144,7 +144,7 @@ Multi-format database abstraction supporting SQLite, CSV, DBF, and in-memory tab ### Software Updates -Automatic update checking and installation from GitHub Releases with SemVer 2.0, SHA256 checksum validation, and backup/recovery. +Automatic update checking and staged installation from GitHub Releases with SemVer 2.0, SHA256 checksum validation, protected settings, transactional rollback, and safe replacement of the updater itself. ## Support diff --git a/codemeta.json b/codemeta.json index 191367de..dc43e75f 100644 --- a/codemeta.json +++ b/codemeta.json @@ -3,9 +3,9 @@ "@type": "SoftwareSourceCode", "name": "WPF Framework", "description": "A free and open-source .NET 10 application framework for building Windows desktop project management applications. Provides a docking application shell with theming, project explorer, undo/redo, and specialized controls for charting, numeric input, databases, expression parsing, and directed acyclic graphs.", - "version": "1.0.0", + "version": "1.0.1", "dateCreated": "2025-12-29", - "dateModified": "2026-06-30", + "dateModified": "2026-07-10", "license": "https://spdx.org/licenses/0BSD", "codeRepository": "https://github.com/USACE-RMC/WPF-Framework", "issueTracker": "https://github.com/USACE-RMC/WPF-Framework/issues", diff --git a/docs/assets/gallery/README.md b/docs/assets/gallery/README.md index 9be6eb33..cbce9b90 100644 --- a/docs/assets/gallery/README.md +++ b/docs/assets/gallery/README.md @@ -13,9 +13,8 @@ Save screenshots in this folder using the filenames below. Blue theme is the def - `generic-input-controls-blue.png` - `generic-date-time-color-blue.png` -- `generic-property-editors-blue.png` +- `generic-property-editors-light.png` - `generic-datagrid-blue.png` -- `generic-color-collection-blue.png` ## NumericControls diff --git a/docs/assets/gallery/generic-datagrid-blue.png b/docs/assets/gallery/generic-datagrid-blue.png new file mode 100644 index 00000000..d88a22f4 Binary files /dev/null and b/docs/assets/gallery/generic-datagrid-blue.png differ diff --git a/docs/assets/gallery/generic-date-time-color-blue.png b/docs/assets/gallery/generic-date-time-color-blue.png new file mode 100644 index 00000000..6040f64d Binary files /dev/null and b/docs/assets/gallery/generic-date-time-color-blue.png differ diff --git a/docs/assets/gallery/generic-input-controls-blue.png b/docs/assets/gallery/generic-input-controls-blue.png new file mode 100644 index 00000000..19589468 Binary files /dev/null and b/docs/assets/gallery/generic-input-controls-blue.png differ diff --git a/docs/assets/gallery/generic-property-editors-light.png b/docs/assets/gallery/generic-property-editors-light.png new file mode 100644 index 00000000..1201febb Binary files /dev/null and b/docs/assets/gallery/generic-property-editors-light.png differ diff --git a/docs/gallery.md b/docs/gallery.md index dd08e173..92dcb87a 100644 --- a/docs/gallery.md +++ b/docs/gallery.md @@ -15,7 +15,6 @@ Screenshots belong in `docs/assets/gallery/` using the filenames shown below. - [Date, Time, and Color](#date-time-and-color) - [Property Editors](#property-editors) - [Data Grid](#data-grid) - - [Color Collection](#color-collection) - [NumericControls](#numericcontrols) - [Distribution Selector](#distribution-selector) - [Uncertain Curve Editor](#uncertain-curve-editor) @@ -90,7 +89,7 @@ See: [Generic Controls](generic-controls.md) ### Property Editors -![Generic property editors in blue theme](assets/gallery/generic-property-editors-blue.png) +![Generic property editors in blue theme](assets/gallery/generic-property-editors-light.png) Property editors for text, numbers, booleans, fonts, colors, lines, alignment, points, dates, and custom content. @@ -104,13 +103,6 @@ Data grid toolbar and copy/paste data grid controls for editable tabular workflo See: [Generic Controls](generic-controls.md) -### Color Collection - -![Generic color collection editor in blue theme](assets/gallery/generic-color-collection-blue.png) - -Editable color collection with popup color editing. - -See: [Generic Controls](generic-controls.md) ## NumericControls diff --git a/docs/getting-started.md b/docs/getting-started.md index 58ca6599..31d7b06f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -30,7 +30,7 @@ For NuGet consumption, reference the Controls bundle. It brings in Core, Models, ```xml - + ``` diff --git a/docs/software-update.md b/docs/software-update.md index 4afa0128..c71c8737 100644 --- a/docs/software-update.md +++ b/docs/software-update.md @@ -54,8 +54,10 @@ var options = new UpdateOptions GitHubOwner = "USACE-RMC", GitHubRepo = "RMC-BestFit", CurrentVersion = new SemanticVersion(2, 0, 0), - AssetNamePattern = "RMC-BestFit.*.zip" + AssetNamePattern = "RMC-BestFit.*.zip", + RequireSha256Checksum = true }; +options.AdditionalPreservedRelativePaths.Add("data/user"); ``` ### 3. Create the update service @@ -191,9 +193,9 @@ Assets: Mark pre-release versions as "pre-release" in GitHub. They are excluded by default unless `IncludePreReleases = true`. Draft releases are always excluded. -### Optional SHA256 checksum +### SHA256 checksum policy -If the `UpdateInfo.Sha256Checksum` property is populated, the download is validated against it. Set this property via the release metadata if your build pipeline generates checksums. +When release notes contain `SHA256: <64-character hexadecimal value>`, the download is validated against it. Set `RequireSha256Checksum = true` to reject releases that do not provide this token. The default remains optional for compatibility. ## UpdateOptions Reference @@ -208,6 +210,8 @@ If the `UpdateInfo.Sha256Checksum` property is populated, the download is valida | `InstallDirectory` | `string?` | `null` | Installation directory. Defaults to the entry assembly's directory. | | `MainExecutableName` | `string?` | `null` | Executable to restart after update. Defaults to the current process name. | | `CreateBackup` | `bool` | `true` | Whether to create a backup before updating. | +| `AdditionalPreservedRelativePaths` | `IList` | Empty | Additional installation-relative paths that must never be changed by an update. | +| `RequireSha256Checksum` | `bool` | `false` | Whether release notes must provide a valid SHA256 checksum. | | `SkippedVersionsFilePath` | `string?` | `null` | Path for storing skipped versions. Defaults to `%LOCALAPPDATA%/{repo}/skipped_versions.txt`. | | `RequestTimeoutSeconds` | `int` | `30` | HTTP request timeout in seconds. | | `UpdaterExecutablePath` | `string?` | `null` | Path to the updater executable. Defaults to `SoftwareUpdate.Updater.exe` in the install directory. | @@ -291,7 +295,7 @@ else if (!result.Success) ### 2. Download update -`DownloadUpdateAsync()` downloads the asset to a temp directory (`%TEMP%/SoftwareUpdate/{repo}/`) with progress reporting every 100ms. If a SHA256 checksum is provided, it validates the download. +`DownloadUpdateAsync()` downloads the asset to a GUID-named directory under `%TEMP%/SoftwareUpdate/` with progress reporting every 100ms. If a SHA256 checksum is provided, it validates the download; required-checksum mode fails before network transfer when metadata is missing or malformed. ```csharp var downloadResult = await updateService.DownloadUpdateAsync( @@ -321,7 +325,7 @@ else ### 3. Install and restart -`InstallUpdateAndRestart()` launches the updater process with the required arguments and calls `Environment.Exit(0)`. No code executes after this call. +`InstallUpdateAndRestart()` copies the complete updater payload to a disposable `%TEMP%/SoftwareUpdate/runner-{guid}` directory, launches that copy, and calls `Environment.Exit(0)`. Running outside the installation allows the update to replace all installed updater files. No code executes after this call. ```csharp updateService.InstallUpdateAndRestart(downloadResult.FilePath!); @@ -344,24 +348,29 @@ Skipped versions are persisted to `ResolvedSkippedVersionsPath`. ## Backup and Recovery -When `CreateBackup` is `true` (the default), the updater creates a backup of the current installation before extracting new files. +When `CreateBackup` is `true` (the default), the updater backs up only existing files that the validated update manifest will overwrite. - **Backup location:** `{TargetDirectory}/.backup_{timestamp}/` - **Retention:** Only the 2 most recent backups are kept. Older backups are deleted during cleanup. -- **Recovery:** If the extraction or file replacement fails, the updater automatically restores the entire backup directory. +- **Recovery:** If file replacement fails, overwritten files are restored and files or directories introduced by the failed update are removed. -To disable backups, set `UpdateOptions.CreateBackup = false`. Without backups, a failed update cannot be automatically restored. +To disable backups, set `UpdateOptions.CreateBackup = false`. Newly introduced files are still removed after failure, but overwritten files cannot be restored. + +The updater always preserves root-level `settings`, `logs`, and `updates_pending` paths plus every `.backup_*` path. These built-in protections cannot be disabled by callers or archive layout. ## Deployment ### File layout -Deploy `SoftwareUpdate.Updater.exe` alongside your main application: +Deploy the complete updater payload alongside your main application: ``` MyApp/ MyApp.exe - SoftwareUpdate.Updater.exe <-- Required for installation + SoftwareUpdate.Updater.exe + SoftwareUpdate.Updater.dll + SoftwareUpdate.Updater.deps.json + SoftwareUpdate.Updater.runtimeconfig.json SoftwareUpdate.dll ... other assemblies ... ``` @@ -379,6 +388,7 @@ The updater accepts these command-line arguments (built automatically by `Instal | `--target ` | Yes | Target installation directory | | `--exe ` | Yes | Main executable name to restart | | `--backup` | No | Flag to create a backup before updating | +| `--preserve ` | No | Repeatable installation-relative path to preserve | ### UpdaterBootstrapper @@ -391,7 +401,8 @@ UpdaterBootstrapper.LaunchUpdater( targetDirectory: @"C:\MyApp", mainExecutable: "MyApp.exe", createBackup: true, - exitApplication: true // calls Environment.Exit(0) + exitApplication: true, // calls Environment.Exit(0) + additionalPreservedRelativePaths: new[] { "data/user" } ); ``` @@ -452,7 +463,8 @@ The updater process itself logs all operations to `{TargetDirectory}/logs/update ### Updater process considerations -- The updater waits up to 60 seconds for the main application to exit. If the application does not exit in time, the updater proceeds. -- The updater detects single root folders in zip files and extracts their contents directly, avoiding nested directories. -- Zip entries containing path traversal sequences (`..`) are skipped with a warning. +- The updater waits up to 60 seconds for the main application to exit and aborts without changing files if the timeout expires. +- The updater detects a single wrapper folder and removes it while applying protection checks both before and after removal. +- The entire archive is preflighted and staged before installation; unsafe paths, collisions, reparse points, excessive file counts, and excessive uncompressed size abort the update. +- Settings, logs, backups, and update staging remain untouched by archive content. - The updater console auto-closes after 3 seconds on success. On failure, it waits for a key press. diff --git a/scripts/pack-wpf-framework.ps1 b/scripts/pack-wpf-framework.ps1 index b75c0107..2055bbf1 100644 --- a/scripts/pack-wpf-framework.ps1 +++ b/scripts/pack-wpf-framework.ps1 @@ -76,8 +76,22 @@ $expectedEntries = @{ "lib/net10.0-windows7.0/Xceed.Wpf.AvalonDock.Themes.VS2013.dll") } +$expectedReleaseNotes = @{ + "RMC.Wpf.Framework.Core" = "Coordinated WPF Framework 1.0.1 release; no package-specific functional changes." + "RMC.Wpf.Framework.Models" = "Coordinated WPF Framework 1.0.1 release; no package-specific functional changes." + "RMC.Wpf.Framework.Support" = "Version 1.0.1 protects settings and runtime data during updates, supports transactional rollback, checksum enforcement, and safe replacement of the installed updater payload." + "RMC.Wpf.Framework.Controls" = "Version 1.0.1 improves irregular time-series ordering validation and defaults new OxyPlot line annotations to editable text." +} + foreach ($packageId in $expectedEntries.Keys) { - $package = Get-ChildItem -Path $outputPath -Filter "$packageId.*.nupkg" | + $packageFilter = if ([string]::IsNullOrWhiteSpace($Version)) { + "$packageId.*.nupkg" + } + else { + "$packageId.$Version.nupkg" + } + + $package = Get-ChildItem -Path $outputPath -Filter $packageFilter | Sort-Object LastWriteTime -Descending | Select-Object -First 1 @@ -93,6 +107,63 @@ foreach ($packageId in $expectedEntries.Keys) { throw "$($package.Name) is missing $expectedEntry." } } + + $nuspecEntry = $zip.Entries | Where-Object { $_.FullName -like "*.nuspec" } | Select-Object -First 1 + if ($nuspecEntry -eq $null) { + throw "$($package.Name) is missing its nuspec metadata." + } + + $reader = [System.IO.StreamReader]::new($nuspecEntry.Open()) + try { + [xml]$nuspec = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } + + $namespaceManager = [System.Xml.XmlNamespaceManager]::new($nuspec.NameTable) + $namespaceManager.AddNamespace("n", $nuspec.DocumentElement.NamespaceURI) + $packageVersion = $nuspec.SelectSingleNode("/n:package/n:metadata/n:version", $namespaceManager).InnerText + $releaseNotes = $nuspec.SelectSingleNode("/n:package/n:metadata/n:releaseNotes", $namespaceManager).InnerText + + if (-not [string]::IsNullOrWhiteSpace($Version) -and $packageVersion -ne $Version) { + throw "$($package.Name) declares version $packageVersion instead of $Version." + } + + if ($releaseNotes -ne $expectedReleaseNotes[$packageId]) { + throw "$($package.Name) has unexpected NuGet release notes." + } + + if ($packageId -eq "RMC.Wpf.Framework.Support") { + $numericVersion = ($packageVersion -split '-')[0] + $expectedFileVersion = "$numericVersion.0" + $payloadBinaryEntries = @( + "contentFiles/any/net10.0-windows7.0/SoftwareUpdate.Updater.exe", + "contentFiles/any/net10.0-windows7.0/SoftwareUpdate.Updater.dll") + $inspectionDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "wpf-framework-package-$([Guid]::NewGuid().ToString('N'))" + New-Item -ItemType Directory -Path $inspectionDirectory | Out-Null + + try { + foreach ($payloadEntryName in $payloadBinaryEntries) { + $payloadEntry = $zip.GetEntry($payloadEntryName) + $payloadPath = Join-Path $inspectionDirectory ([System.IO.Path]::GetFileName($payloadEntryName)) + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($payloadEntry, $payloadPath, $false) + $versionInfo = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($payloadPath) + + if ($versionInfo.FileVersion -ne $expectedFileVersion) { + throw "$($package.Name) payload $payloadEntryName has file version $($versionInfo.FileVersion) instead of $expectedFileVersion." + } + + if ([string]::IsNullOrWhiteSpace($versionInfo.ProductVersion) -or + -not $versionInfo.ProductVersion.StartsWith($packageVersion, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "$($package.Name) payload $payloadEntryName has product version $($versionInfo.ProductVersion) instead of $packageVersion." + } + } + } + finally { + Remove-Item -LiteralPath $inspectionDirectory -Recurse -Force -ErrorAction SilentlyContinue + } + } } finally { $zip.Dispose() diff --git a/src/NumericControls/Data/Time Series Editor/TimeSeriesRowItem.cs b/src/NumericControls/Data/Time Series Editor/TimeSeriesRowItem.cs index b29e051f..4993ba64 100644 --- a/src/NumericControls/Data/Time Series Editor/TimeSeriesRowItem.cs +++ b/src/NumericControls/Data/Time Series Editor/TimeSeriesRowItem.cs @@ -138,10 +138,21 @@ private void ReplaceOrdinate(SeriesOrdinate newOrdinate) /// /// Adds validation rules for the time series row item properties. - /// Validates that the value is a valid number. + /// Validates that irregular date-time entries are in ascending data order + /// and that the value is a valid number. /// public override void AddValidationRules() { + AddRule(nameof(DateTime), + () => _series != null + && _series.TimeInterval == TimeInterval.Irregular + && OrderRule( + row => row.DateTime.Ticks, + nameof(DateTime), + ascending: true, + canBeEqual: false), + "Date/time values must be in ascending data order. Grid sorting does not reorder the time series used by the plot.", + new[] { nameof(DateTime) }); AddRule(nameof(Value), () => double.IsInfinity(Value), "The value must be a finite number.", new[] { nameof(Value) }); } diff --git a/src/NumericControls/Data/Time Series Editor/TimeSeriesTable.xaml b/src/NumericControls/Data/Time Series Editor/TimeSeriesTable.xaml index 6c4a45b7..7194ce5b 100644 --- a/src/NumericControls/Data/Time Series Editor/TimeSeriesTable.xaml +++ b/src/NumericControls/Data/Time Series Editor/TimeSeriesTable.xaml @@ -59,6 +59,36 @@ + + + + + +