diff --git a/!RED+.sln b/!RED+.sln
index e881935..97030d6 100644
--- a/!RED+.sln
+++ b/!RED+.sln
@@ -10,22 +10,44 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
.editorconfig = .editorconfig
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RED.Tests", "RED.Tests\RED.Tests.csproj", "{91DC5578-B3A6-4C5C-AAC8-F77C3170D943}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "RED", "RED", "{B4F0820F-41E8-57EB-F9D8-0C51BF3D66C8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{91B25075-18EE-4ADE-8704-A9C0935E15F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{91B25075-18EE-4ADE-8704-A9C0935E15F7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{91B25075-18EE-4ADE-8704-A9C0935E15F7}.Debug|x64.ActiveCfg = Debug|x64
{91B25075-18EE-4ADE-8704-A9C0935E15F7}.Debug|x64.Build.0 = Debug|x64
+ {91B25075-18EE-4ADE-8704-A9C0935E15F7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {91B25075-18EE-4ADE-8704-A9C0935E15F7}.Debug|x86.Build.0 = Debug|Any CPU
{91B25075-18EE-4ADE-8704-A9C0935E15F7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{91B25075-18EE-4ADE-8704-A9C0935E15F7}.Release|Any CPU.Build.0 = Release|Any CPU
{91B25075-18EE-4ADE-8704-A9C0935E15F7}.Release|x64.ActiveCfg = Release|x64
{91B25075-18EE-4ADE-8704-A9C0935E15F7}.Release|x64.Build.0 = Release|x64
+ {91B25075-18EE-4ADE-8704-A9C0935E15F7}.Release|x86.ActiveCfg = Release|Any CPU
+ {91B25075-18EE-4ADE-8704-A9C0935E15F7}.Release|x86.Build.0 = Release|Any CPU
+ {91DC5578-B3A6-4C5C-AAC8-F77C3170D943}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {91DC5578-B3A6-4C5C-AAC8-F77C3170D943}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {91DC5578-B3A6-4C5C-AAC8-F77C3170D943}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {91DC5578-B3A6-4C5C-AAC8-F77C3170D943}.Debug|x64.Build.0 = Debug|Any CPU
+ {91DC5578-B3A6-4C5C-AAC8-F77C3170D943}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {91DC5578-B3A6-4C5C-AAC8-F77C3170D943}.Debug|x86.Build.0 = Debug|Any CPU
+ {91DC5578-B3A6-4C5C-AAC8-F77C3170D943}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {91DC5578-B3A6-4C5C-AAC8-F77C3170D943}.Release|Any CPU.Build.0 = Release|Any CPU
+ {91DC5578-B3A6-4C5C-AAC8-F77C3170D943}.Release|x64.ActiveCfg = Release|Any CPU
+ {91DC5578-B3A6-4C5C-AAC8-F77C3170D943}.Release|x64.Build.0 = Release|Any CPU
+ {91DC5578-B3A6-4C5C-AAC8-F77C3170D943}.Release|x86.ActiveCfg = Release|Any CPU
+ {91DC5578-B3A6-4C5C-AAC8-F77C3170D943}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2bdf9e6..6aeece6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -15,16 +15,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Setup MSBuild
- uses: microsoft/setup-msbuild@v2
-
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Build Release
- run: msbuild "!RED+.sln" /p:Configuration=Release /p:Platform="Any CPU"
+ run: dotnet build "RED/RED+.csproj" -c Release
- name: Engine unit tests
run: dotnet test "RED.Tests/RED.Tests.csproj" -c Release --logger "console;verbosity=normal"
@@ -48,7 +45,7 @@ jobs:
New-Item -ItemType Directory -Path $emptyDir, $nonEmpty, $junctionTarget, $deny | Out-Null
Set-Content -Path (Join-Path $nonEmpty 'keep.txt') -Value 'content' -NoNewline
New-Item -ItemType File -Path (Join-Path $root 'empty-file.txt') | Out-Null
- cmd /c mklink /J "$junction" "$junctionTarget"
+ New-Item -ItemType Junction -Path $junction -Target $junctionTarget | Out-Null
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
$denyAcl = $currentUser + ':(OI)(CI)(R)'
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 30adf96..e543668 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -21,22 +21,71 @@ jobs:
steps:
- uses: actions/checkout@v4
- - name: Setup MSBuild
- uses: microsoft/setup-msbuild@v2
+ - name: Setup .NET SDK
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '9.0.x'
+
+ # Self-contained single-file: no .NET runtime install required on the target
+ # machine, preserving RED++'s "unzip and run" portability. Larger artifact
+ # (~65 MB) than a framework-dependent build, but no external dependency. WPF +
+ # reflection-based resource loading are not trim-safe, so trimming is left off.
+ - name: Publish self-contained single-file
+ run: dotnet publish "RED/RED+.csproj" -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -o publish
+
+ # Gate the release on the actual shipping artifact passing the safety
+ # invariants (empty-dir/file deletion; reparse-point, deny-ACL, and
+ # AutoProtectRoot protection). Never ship a file-deleting tool that fails this.
+ - name: Safety smoke on published artifact
+ shell: pwsh
+ run: |
+ $ErrorActionPreference = 'Stop'
+ $exe = Join-Path $PWD 'publish\RED+.exe'
+ if (!(Test-Path $exe)) { throw "Missing published RED+.exe at $exe" }
+
+ $root = Join-Path $env:RUNNER_TEMP ("redpp-rel-" + [guid]::NewGuid().ToString("N"))
+ $junctionTarget = $root + '-junction-target'
+ New-Item -ItemType Directory -Path $root | Out-Null
+ try {
+ $emptyDir = Join-Path $root 'empty-child'
+ $nonEmpty = Join-Path $root 'non-empty'
+ $junction = Join-Path $root 'junction'
+ $deny = Join-Path $root 'deny-access'
+ New-Item -ItemType Directory -Path $emptyDir, $nonEmpty, $junctionTarget, $deny | Out-Null
+ Set-Content -Path (Join-Path $nonEmpty 'keep.txt') -Value 'content' -NoNewline
+ New-Item -ItemType File -Path (Join-Path $root 'empty-file.txt') | Out-Null
+ New-Item -ItemType Junction -Path $junction -Target $junctionTarget | Out-Null
- - name: Setup NuGet
- uses: NuGet/setup-nuget@v2
+ $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
+ icacls "$deny" /deny ($currentUser + ':(OI)(CI)(R)') | Out-Null
+ try {
+ & $exe -silent -path $root -emptyfiles -mode direct | Tee-Object -Variable output
+ if ($LASTEXITCODE -ne 0) { throw "RED++ exited with $LASTEXITCODE`n$output" }
+ } finally {
+ icacls "$deny" /remove:d "$currentUser" | Out-Null
+ }
- - name: Restore NuGet packages
- run: nuget restore "!RED+.sln"
+ if (($output -join "`n") -notmatch 'Found 1 empty directories') { throw "Unexpected found-directory count" }
+ if (!(Test-Path $root)) { throw 'Root deleted despite AutoProtectRoot' }
+ if (Test-Path $emptyDir) { throw 'Empty child not deleted' }
+ if (!(Test-Path (Join-Path $nonEmpty 'keep.txt'))) { throw 'Non-empty payload deleted' }
+ if (!(Test-Path $junction)) { throw 'Junction deleted' }
+ if (!(Test-Path $junctionTarget)) { throw 'Junction target deleted' }
+ if (!(Test-Path $deny)) { throw 'Deny-ACL dir deleted' }
+ if (Test-Path (Join-Path $root 'empty-file.txt')) { throw 'Empty file not deleted' }
- - name: Build Release
- run: msbuild "!RED+.sln" /p:Configuration=Release /p:Platform="Any CPU"
+ & $exe -version | Tee-Object -Variable ver
+ if (($ver -join "`n") -notmatch 'RED\+\+\s+\d+\.\d+\.\d+') { throw "Unexpected -version output: $ver" }
+ }
+ finally {
+ Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue
+ Remove-Item -LiteralPath $junctionTarget -Recurse -Force -ErrorAction SilentlyContinue
+ }
- name: Create release archive
run: |
mkdir release
- copy bin\Release\RED+.exe release\
+ copy publish\RED+.exe release\
copy LICENSE release\
xcopy help release\help\ /E /I
xcopy language release\language\ /E /I
diff --git a/.gitignore b/.gitignore
index c7a4061..380b1f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,6 +20,5 @@ RESEARCH.md
# Local build/scratch (refasm, screenshots, smoke fixtures)
tmp/
-# Local-only build shim (lets the legacy net481 project build under the bare
-# .NET SDK without Visual Studio). Not shipped; proper fix is the SDK migration.
-Directory.Build.targets
+# dotnet publish output (self-contained single-file artifact)
+publish/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0364098..29a2c81 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,13 @@
## Unreleased
+### Build / Runtime
+- Migrate from .NET Framework 4.8.1 (old-style csproj + `packages.config`) to an SDK-style project targeting `net9.0-windows` (WPF + WinForms). The verified scan/delete engine and every P/Invoke struct are unchanged; the 50-test xUnit suite and the headless safety smoke (empty-dir/file deletion, reparse-point/junction protection, deny-ACL fail-closed, AutoProtectRoot, recycle→undo round-trip) pass identically on the new runtime.
+- Replace the removed `Directory.GetAccessControl(path)` static with the `DirectoryInfo.GetAccessControl()` extension (same default ACL sections) in the deletion lock check.
+- Make version reporting single-file safe: read the file version from `Environment.ProcessPath` / the embedded `AssemblyFileVersion` attribute instead of `Assembly.Location` (which is empty in a published single-file bundle and previously crashed `-version`/`-json`).
+- Modern .NET fixes the .resx resource friction that forced a build-time workaround on 4.8.1: the WPF shell and WinForms fallback now build and render cleanly (icons and image resources load natively).
+- Release builds are now a **self-contained single-file** `win-x64` artifact (`dotnet publish -p:PublishSingleFile=true --self-contained`) — no .NET runtime install required on the target machine, preserving the "unzip and run" portability. CI builds and tests with `dotnet` (Visual Studio / MSBuild no longer required).
+
### Documentation
- Drop the advertised "Enter to scan, Del to delete selected" keyboard shortcuts from the feature list: the default modern WPF shell is click-only by design, matching the project's no-keyboard-shortcuts convention.
diff --git a/README.md b/README.md
index 48b3a59..cc697c6 100644
--- a/README.md
+++ b/README.md
@@ -71,9 +71,9 @@ RED+.exe -silent -path "D:\Shares" -log cleanup.log
## System Requirements
-- Windows 10 22H2, Windows 11, or Windows Server 2022/2025
-- Microsoft .NET Framework 4.8.1
-- No installer required. Unzip and run.
+- Windows 10 22H2, Windows 11, or Windows Server 2022/2025 (64-bit / x64)
+- No runtime to install — the release is a self-contained single-file build that bundles .NET 9. Just unzip and run.
+- No installer required.
## Verify Your Download
diff --git a/RED.Tests/RED.Tests.csproj b/RED.Tests/RED.Tests.csproj
index 684ed03..fe8e952 100644
--- a/RED.Tests/RED.Tests.csproj
+++ b/RED.Tests/RED.Tests.csproj
@@ -1,14 +1,17 @@
- net481
- 9.0
+ net9.0-windows
+
+ true
+ true
+ latest
disable
+ disable
false
RED.Tests
RED.Tests
-
- true
@@ -19,15 +22,11 @@
-
+
-
- ..\bin\Release\RED+.exe
- true
-
+
diff --git a/RED/Program.cs b/RED/Program.cs
index 2849231..14a281b 100644
--- a/RED/Program.cs
+++ b/RED/Program.cs
@@ -851,8 +851,23 @@ private static void PrintVersion()
private static string GetFileVersion()
{
- var vi = System.Diagnostics.FileVersionInfo.GetVersionInfo(System.Reflection.Assembly.GetExecutingAssembly().Location);
- return vi.FileVersion;
+ // Single-file safe: Assembly.Location is empty in a published single-file
+ // bundle, which would throw here. Environment.ProcessPath is the apphost
+ // exe (it carries the version resource); fall back to the embedded
+ // AssemblyFileVersion attribute if the path is unavailable.
+ try
+ {
+ string path = Environment.ProcessPath;
+ if (!string.IsNullOrEmpty(path))
+ {
+ string fv = System.Diagnostics.FileVersionInfo.GetVersionInfo(path).FileVersion;
+ if (!string.IsNullOrEmpty(fv)) return fv;
+ }
+ }
+ catch { }
+ var attr = (System.Reflection.AssemblyFileVersionAttribute)Attribute.GetCustomAttribute(
+ System.Reflection.Assembly.GetExecutingAssembly(), typeof(System.Reflection.AssemblyFileVersionAttribute));
+ return attr != null ? attr.Version : "0.0.0";
}
private static void PrintUsage()
diff --git a/RED/RED+.csproj b/RED/RED+.csproj
index 935b63f..2229048 100644
--- a/RED/RED+.csproj
+++ b/RED/RED+.csproj
@@ -1,386 +1,59 @@
-
-
-
+
+
- Debug
- AnyCPU
- {91B25075-18EE-4ADE-8704-A9C0935E15F7}
WinExe
+ net9.0-windows
+ true
+
+ true
RED
RED+
- v4.8.1
- 512
- true
- true
- $(ProjectDir)..\obj\
-
-
-
-
-
- AnyCPU
- true
- full
- false
- ..\bin\Debug\
- DEBUG;TRACE
- prompt
- 4
- MinimumRecommendedRules.ruleset
-
-
- AnyCPU
- pdbonly
- true
- ..\bin\Release\
- TRACE
- prompt
- 4
-
-
- true
- ..\bin\x64\Debug\
- DEBUG;TRACE
- full
- x64
- 7.3
- prompt
- true
-
-
- ..\bin\x64\Release\
- TRACE
- true
- pdbonly
- x64
- 7.3
- prompt
- true
-
-
- Resources\Images\iconProject.ico
-
-
RED.Program
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- True
- True
- Resources.resx
-
-
-
- Component
-
-
-
-
- Component
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Form
-
-
- FormLanguage.cs
-
-
- Form
-
-
- FormRtfHelp.cs
-
-
- Component
-
-
- Form
-
-
- NBMsgBox.cs
-
-
- UserControl
-
-
- UCFilterList.cs
-
-
- Component
-
-
-
-
-
-
- Form
-
-
- DeletionError.cs
-
-
- Form
-
-
- LogWindow.cs
-
-
- Form
-
-
- MainWindow.cs
-
-
- FormLanguage.cs
-
-
- FormRtfHelp.cs
-
-
- NBMsgBox.cs
-
-
- UCFilterList.cs
-
-
- ResXFileCodeGenerator
- Designer
- Resources.Designer.cs
-
-
- DeletionError.cs
-
-
- LogWindow.cs
-
-
- MainWindow.cs
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- powershell -NoProfile -Command "[DateTime]::Now.ToString('yyyy-MM-ddTHH:mm:ss') | Set-Content '$(ProjectDir)Resources\BuildTime$(ConfigurationName).txt' -NoNewline"
-
+ Resources\Images\iconProject.ico
+ app.manifest
+
+ disable
+ disable
+ true
+
+
+ WFO0003
+
+
+ false
+
+
+ false
+ ..\bin\$(Configuration)\
+
+
+ true
+
+
+ true
+ true
+ true
+ en
+
+
+
+
+
+
+
diff --git a/RED/RedHelpers/RedDebug.cs b/RED/RedHelpers/RedDebug.cs
index ee79498..4d0a889 100644
--- a/RED/RedHelpers/RedDebug.cs
+++ b/RED/RedHelpers/RedDebug.cs
@@ -21,7 +21,9 @@ public string GatherDebugInfo(RedConfiguration RedConfig)
try
{
Assembly asm = Assembly.GetExecutingAssembly();
- FileVersionInfo vi = FileVersionInfo.GetVersionInfo(asm.Location);
+ // Environment.ProcessPath (the apphost exe) is valid in single-file
+ // builds where asm.Location is empty.
+ FileVersionInfo vi = FileVersionInfo.GetVersionInfo(Environment.ProcessPath);
info.Append(string.Format("File Version={0}", vi.FileVersion.ToString()));
info.Append(string.Format(", Product Version={0}", asm.GetName().Version.ToString()));
#if DEBUG
diff --git a/RED/RedLib/SystemFunctions.cs b/RED/RedLib/SystemFunctions.cs
index ddad40a..1e8c130 100644
--- a/RED/RedLib/SystemFunctions.cs
+++ b/RED/RedLib/SystemFunctions.cs
@@ -374,7 +374,10 @@ public static bool IsDirLocked(string path)
{
try
{
- var acl = System.IO.Directory.GetAccessControl(path);
+ // .NET (Core) removed the static Directory.GetAccessControl; the
+ // DirectoryInfo.GetAccessControl() extension is the 1:1 replacement
+ // (same default AccessControlSections: Access | Owner | Group).
+ var acl = new System.IO.DirectoryInfo(path).GetAccessControl();
var rules = acl.GetAccessRules(true, true, typeof(System.Security.Principal.SecurityIdentifier));
var identity = WindowsIdentity.GetCurrent();
var principal = new WindowsPrincipal(identity);
diff --git a/RED/UI/MainWindow.cs b/RED/UI/MainWindow.cs
index d67ca7d..bf961c2 100644
--- a/RED/UI/MainWindow.cs
+++ b/RED/UI/MainWindow.cs
@@ -97,8 +97,9 @@ private void MainWindow_Load(object sender, EventArgs e)
// Update labels
lblRedStats.Text = string.Format("{0}: {1}", TXT.Words.DeletedSoFar, RedConfig.Volatile.CountOfDeletions);
- // NotBob - use file version info rather than product version
- FileVersionInfo vi = FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location);
+ // NotBob - use file version info rather than product version.
+ // Environment.ProcessPath is single-file safe (Assembly.Location is empty there).
+ FileVersionInfo vi = FileVersionInfo.GetVersionInfo(Environment.ProcessPath);
lbAppTitle.Text = string.Format("{0} v{1}", RedGetText.Red.Title, vi.FileVersion.ToString());
#if DEBUG
lbAppTitle.Text += " (DBUG)";
diff --git a/RED/UI/Wpf/ModernMainWindow.cs b/RED/UI/Wpf/ModernMainWindow.cs
index 5030796..8cf82c6 100644
--- a/RED/UI/Wpf/ModernMainWindow.cs
+++ b/RED/UI/Wpf/ModernMainWindow.cs
@@ -867,7 +867,8 @@ private UIElement BuildAboutTab()
var group = Frame("About");
var stack = new StackPanel { Margin = new Thickness(28) };
SetFrameContent(group, stack);
- FileVersionInfo vi = FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location);
+ // Environment.ProcessPath (apphost exe) is single-file safe; Assembly.Location is empty in a bundle.
+ FileVersionInfo vi = FileVersionInfo.GetVersionInfo(Environment.ProcessPath);
stack.Children.Add(Label("RED++", 28, Text, FontWeights.SemiBold, new Thickness(0, 0, 0, 8)));
stack.Children.Add(Label("Remove Empty Directories+ v" + vi.FileVersion, 18, Muted, FontWeights.Normal, new Thickness(0, 0, 0, 18)));
stack.Children.Add(Label("Modern WPF shell using the existing RED++ scanner and deletion engine.", 16, Muted, FontWeights.Normal, new Thickness(0, 0, 0, 18)));
diff --git a/RED/app.config b/RED/app.config
deleted file mode 100644
index 682e705..0000000
--- a/RED/app.config
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/RED/packages.config b/RED/packages.config
deleted file mode 100644
index 79ece06..0000000
--- a/RED/packages.config
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/packaging/winget/SysAdminDoc.REDplusplus.yaml b/packaging/winget/SysAdminDoc.REDplusplus.yaml
index 96bb193..23281a6 100644
--- a/packaging/winget/SysAdminDoc.REDplusplus.yaml
+++ b/packaging/winget/SysAdminDoc.REDplusplus.yaml
@@ -10,7 +10,7 @@ InstallerType: zip
Commands:
- red++
Installers:
- - Architecture: x86
+ - Architecture: x64
InstallerUrl: https://github.com/SysAdminDoc/REDplusplus/releases/download/v1.5.18/RED++_v1.5.18.zip
InstallerSha256:
NestedInstallerType: portable