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