Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@
<PackageProjectUrl>https://github.com/USACE-RMC/WPF-Framework</PackageProjectUrl>
<RepositoryUrl>https://github.com/USACE-RMC/WPF-Framework</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Version>1.0.1</Version>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>

<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<IsPackable>false</IsPackable>
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`:
Expand Down Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions codemeta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 1 addition & 2 deletions docs/assets/gallery/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file added docs/assets/gallery/generic-datagrid-blue.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 1 addition & 9 deletions docs/gallery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ For NuGet consumption, reference the Controls bundle. It brings in Core, Models,

```xml
<ItemGroup>
<PackageReference Include="RMC.Wpf.Framework.Controls" Version="1.0.0" />
<PackageReference Include="RMC.Wpf.Framework.Controls" Version="1.0.1" />
</ItemGroup>
```

Expand Down
40 changes: 26 additions & 14 deletions docs/software-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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<string>` | 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. |
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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!);
Expand All @@ -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 ...
```
Expand All @@ -379,6 +388,7 @@ The updater accepts these command-line arguments (built automatically by `Instal
| `--target <dir>` | Yes | Target installation directory |
| `--exe <name>` | Yes | Main executable name to restart |
| `--backup` | No | Flag to create a backup before updating |
| `--preserve <path>` | No | Repeatable installation-relative path to preserve |

### UpdaterBootstrapper

Expand All @@ -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" }
);
```

Expand Down Expand Up @@ -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.
73 changes: 72 additions & 1 deletion scripts/pack-wpf-framework.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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()
Expand Down
13 changes: 12 additions & 1 deletion src/NumericControls/Data/Time Series Editor/TimeSeriesRowItem.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,10 +138,21 @@ private void ReplaceOrdinate(SeriesOrdinate<DateTime, double> newOrdinate)

/// <summary>
/// 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.
/// </summary>
public override void AddValidationRules()
{
AddRule(nameof(DateTime),
() => _series != null
&& _series.TimeInterval == TimeInterval.Irregular
&& OrderRule<DateTime, TimeSeriesRowItem>(
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) });
}

Expand Down
Loading
Loading