diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 053d5af..3e2ba97 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -18,6 +18,10 @@ jobs:
$exe = Join-Path $PWD 'test-output/Checkup.exe'
$info = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($exe)
if ($info.ProductName -ne 'Checkup' -or $info.FileDescription -ne 'Checkup') { throw 'Executable product branding is incorrect' }
+ - name: Verify unattended workflow
+ shell: pwsh
+ run: |
+ ./tests/Test-Headless.ps1 -Exe (Join-Path $PWD 'test-output/Checkup.exe')
- uses: actions/upload-artifact@v7
with:
name: Checkup-win-x64
diff --git a/.github/workflows/verify-release.yml b/.github/workflows/verify-release.yml
new file mode 100644
index 0000000..58de94b
--- /dev/null
+++ b/.github/workflows/verify-release.yml
@@ -0,0 +1,49 @@
+name: Verify published release
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Published release tag to verify
+ required: true
+ type: string
+permissions:
+ contents: read
+jobs:
+ verify:
+ runs-on: windows-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ ref: ${{ inputs.tag }}
+ - uses: actions/setup-dotnet@v6
+ with: { dotnet-version: 10.0.x }
+ - name: Download published assets and verify their GitHub digests
+ shell: pwsh
+ env:
+ GH_TOKEN: ${{ github.token }}
+ GH_REPO: ${{ github.repository }}
+ RELEASE_TAG: ${{ inputs.tag }}
+ run: |
+ if ($env:RELEASE_TAG -notmatch '^v\d+\.\d+\.\d+$') { throw 'Invalid release tag' }
+ gh release download $env:RELEASE_TAG --dir verified
+ if ($LASTEXITCODE -ne 0) { throw 'Release download failed' }
+ $release = gh release view $env:RELEASE_TAG --json assets | ConvertFrom-Json
+ if ($LASTEXITCODE -ne 0) { throw 'Release lookup failed' }
+ foreach ($asset in $release.assets) {
+ $hash = 'sha256:' + (Get-FileHash -LiteralPath (Join-Path verified $asset.name) -Algorithm SHA256).Hash.ToLowerInvariant()
+ if ($hash -ne $asset.digest) { throw "Asset hash mismatch: $($asset.name)" }
+ }
+ $zip = Join-Path verified ('Checkup-' + $env:RELEASE_TAG + '-win-x64.zip')
+ Expand-Archive -LiteralPath $zip -DestinationPath verified/portable
+ - name: Verify the downloaded portable command workflow
+ shell: pwsh
+ run: ./tests/Test-Headless.ps1 -Exe (Join-Path $PWD 'verified/portable/Checkup.exe')
+ - name: Verify the downloaded portable desktop workflow
+ shell: powershell
+ run: ./tests/Test-PackagedLaunch.ps1 -Exe "${{ github.workspace }}/verified/portable/Checkup.exe"
+ - name: Verify the downloaded installer lifecycle
+ shell: pwsh
+ env:
+ RELEASE_TAG: ${{ inputs.tag }}
+ run: ./tests/Test-Installer.ps1 -Setup (Join-Path $PWD ('verified/Checkup-' + $env:RELEASE_TAG + '-setup.exe'))
diff --git a/.gitignore b/.gitignore
index 296fdc5..7035b3a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,5 @@ tests/obj/
installer-payload/
installer-output/
+
+test-headless-output/
diff --git a/App.xaml.cs b/App.xaml.cs
index 97cc4c3..c422823 100644
--- a/App.xaml.cs
+++ b/App.xaml.cs
@@ -5,7 +5,7 @@ public partial class App : System.Windows.Application
{
public App()
{
- Startup += (_, _) => new MainWindow().Show();
+ Startup += (_, e) => { if (e.Args.Length > 0) { Shutdown(CommandLine.Run(e.Args)); return; } new MainWindow().Show(); };
DispatcherUnhandledException += (_, e) => { ShowStartupError(e.Exception); e.Handled = true; };
AppDomain.CurrentDomain.UnhandledException += (_, e) => ShowStartupError(e.ExceptionObject as Exception ?? new Exception("Unknown startup error."));
}
diff --git a/Checkup.csproj b/Checkup.csproj
index 52f92f3..a5aeac0 100644
--- a/Checkup.csproj
+++ b/Checkup.csproj
@@ -1 +1 @@
-WinExenet10.0-windowstruetrueenableenable1.1.1falsefalsefalseCheckupCheckupinforment
+WinExenet10.0-windowstruetrueenableenable1.2.0falsefalsefalseCheckupCheckupinforment
diff --git a/CheckupEngine.cs b/CheckupEngine.cs
index ad6517f..e827af1 100644
--- a/CheckupEngine.cs
+++ b/CheckupEngine.cs
@@ -7,15 +7,19 @@ public enum FindingLevel { Good, Attention, Critical }
public sealed record Finding(string Area, FindingLevel Level, string Title, string Explanation, string Evidence, bool CanRepair = false);
public static class CheckupEngine
{
- public static IReadOnlyList Scan()
+ public static IReadOnlyList Scan() => Scan(CancellationToken.None);
+ public static IReadOnlyList Scan(CancellationToken cancellationToken)
{
+ cancellationToken.ThrowIfCancellationRequested();
var findings = new List();
var system = DriveInfo.GetDrives().FirstOrDefault(d => d.IsReady && string.Equals(d.Name, Path.GetPathRoot(Environment.SystemDirectory), StringComparison.OrdinalIgnoreCase));
if (system is not null) { var free = system.AvailableFreeSpace / 1_073_741_824d; findings.Add(free < 10 ? new Finding("Storage", FindingLevel.Attention, "Low system-drive space", "Windows may become unstable when the system drive is nearly full.", $"{free:0.0} GB available", true) : new Finding("Storage", FindingLevel.Good, "System-drive space is healthy", "There is enough free space for normal Windows operation.", $"{free:0.0} GB available")); }
- var temp = Path.GetTempPath(); var tempBytes = MeasureTempBytes(temp);
+ var temp = Path.GetTempPath(); var inventory = MeasureTemp(temp, cancellationToken); var tempBytes = inventory.Bytes;
findings.Add(tempBytes > 5_000_000_000 ? new Finding("Storage", FindingLevel.Attention, "Temporary files are growing large", "Temporary files can be reviewed before cleanup. Checkup will never delete them without an explicit preview.", $"{tempBytes / 1_073_741_824d:0.0} GB in the current temp tree", true) : new Finding("Storage", FindingLevel.Good, "Temporary-file footprint is reasonable", "No unusually large temporary-file footprint was detected.", $"{tempBytes / 1_048_576d:0.0} MB measured"));
+ if (!inventory.Complete) findings.Add(new Finding("Storage", FindingLevel.Attention, "Temporary-file scan has partial coverage", "Linked or unreadable entries were skipped, or the scan budget was reached. The measured size is a lower bound.", $"{inventory.Files} files measured; {inventory.Skipped} entries skipped; budget reached: {inventory.BudgetReached}"));
+ cancellationToken.ThrowIfCancellationRequested();
findings.Add(NetworkInterface.GetIsNetworkAvailable() ? new Finding("Connectivity", FindingLevel.Good, "Network is available", "Windows reports an active network interface.", "NetworkInterface.GetIsNetworkAvailable() = true") : new Finding("Connectivity", FindingLevel.Attention, "No network is currently available", "This may be intentional if the device is offline.", "No active network interface reported"));
- var run = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run"); var startupCount = run?.GetValueNames().Length ?? 0;
+ using var run = Registry.CurrentUser.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\Run"); var startupCount = run?.GetValueNames().Length ?? 0;
findings.Add(new Finding("Startup", FindingLevel.Good, "Startup entries inventoried", "Checkup found the current-user startup entries without changing them.", $"{startupCount} current-user entries"));
var activation = ReadActivationEvidence();
findings.Add(new Finding("Activation", activation.IsActivated ? FindingLevel.Good : FindingLevel.Attention, activation.IsActivated ? "Windows reports activation is valid" : "Windows activation needs review", activation.IsActivated ? "The official Windows licensing script reports an active license." : "Checkup cannot activate Windows, but you can review the official status and troubleshoot it from Windows Settings.", activation.Evidence));
@@ -39,21 +43,42 @@ private static (bool IsActivated, string Evidence) ReadActivationEvidence()
}
catch (Exception ex) { return (false, $"Activation status unavailable: {ex.Message}"); }
}
- private static long SafeLength(string path) { try { return new FileInfo(path).Length; } catch { return 0; } }
- private static long MeasureTempBytes(string root)
+ public sealed record TempInventory(long Bytes, int Files, int Skipped, bool BudgetReached)
{
- if (!Directory.Exists(root)) return 0;
- long total = 0;
- var pending = new Stack();
- pending.Push(root);
- while (pending.Count > 0)
+ public bool Complete => Skipped == 0 && !BudgetReached;
+ }
+
+ public static TempInventory MeasureTemp(string root, CancellationToken cancellationToken = default, int maxEntries = 200_000, TimeSpan? budget = null)
+ {
+ if (maxEntries < 1 || budget is { } duration && duration <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(maxEntries));
+ cancellationToken.ThrowIfCancellationRequested();
+ var timer = Stopwatch.StartNew(); var timeout = budget ?? TimeSpan.FromSeconds(15);
+ long total = 0; var files = 0; var skipped = 0; var visited = 0;
+ var pending = new Stack(); pending.Push(Path.GetFullPath(root));
+ while (pending.TryPop(out var directory))
{
- var directory = pending.Pop();
- try { foreach (var file in Directory.EnumerateFiles(directory)) total = SaturatingAdd(total, SafeLength(file)); } catch { }
- try { foreach (var child in Directory.EnumerateDirectories(directory)) pending.Push(child); } catch { }
+ cancellationToken.ThrowIfCancellationRequested();
+ if (timer.Elapsed >= timeout) return new(total, files, skipped, true);
+ try
+ {
+ if ((File.GetAttributes(directory) & FileAttributes.ReparsePoint) != 0) { skipped++; continue; }
+ foreach (var entry in Directory.EnumerateFileSystemEntries(directory))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (++visited > maxEntries || timer.Elapsed >= timeout) return new(total, files, skipped, true);
+ try
+ {
+ var attributes = File.GetAttributes(entry);
+ if ((attributes & FileAttributes.ReparsePoint) != 0) { skipped++; continue; }
+ if ((attributes & FileAttributes.Directory) != 0) pending.Push(entry);
+ else { var length = new FileInfo(entry).Length; total = length > long.MaxValue - total ? long.MaxValue : total + length; files++; }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) { skipped++; }
+ }
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) { skipped++; }
}
- return total;
+ return new(total, files, skipped, false);
}
- internal static long MeasureTempBytesForTesting(string root) => MeasureTempBytes(root);
- private static long SaturatingAdd(long total, long value) => value > long.MaxValue - total ? long.MaxValue : total + value;
+ internal static long MeasureTempBytesForTesting(string root) => MeasureTemp(root).Bytes;
}
diff --git a/CommandLine.cs b/CommandLine.cs
new file mode 100644
index 0000000..e7585be
--- /dev/null
+++ b/CommandLine.cs
@@ -0,0 +1,19 @@
+using System.IO;
+namespace Checkup;
+
+public static class CommandLine
+{
+ public static int Run(string[] args)
+ {
+ if (args.Length != 3 || args[0] != "--scan" || args[1] != "--output" || string.IsNullOrWhiteSpace(args[2]) || !args[2].EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) return 2;
+ try
+ {
+ if (File.Exists(args[2])) return 2;
+ using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(2));
+ var findings = CheckupEngine.Scan(timeout.Token);
+ SupportBundle.Export(args[2], findings, overwrite: false);
+ return 0;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or OperationCanceledException or System.Security.SecurityException) { return 1; }
+ }
+}
diff --git a/README.md b/README.md
index 65860d4..20a004d 100644
--- a/README.md
+++ b/README.md
@@ -34,16 +34,29 @@ Checkup is not affiliated with Microsoft or Windows.
Checkup replaces OpenFix. New builds use Checkup.exe and Checkup.csproj. Existing releases remain available under their original names.
+## Unattended support bundles
+
+Version 1.2.0 can generate a private support ZIP without opening a window:
+
+```powershell
+$p = Start-Process .\Checkup.exe -WindowStyle Hidden -ArgumentList '--scan --output "C:\Reports\checkup.zip"' -Wait -PassThru
+$p.ExitCode
+```
+
+Use a new ZIP filename. Existing reports are never replaced by this command. Exit 0 means the report was written, 2 means invalid arguments or an existing output, and 1 means the operation failed. Findings inside the report determine health; a successful export does not mean the machine has no issues.
+
+Temporary-file inventory now skips links and junctions, supports cancellation, and stops after 200,000 entries or 15 seconds of enumeration. Partial coverage creates an attention finding with measured-file and skipped-entry counts, so a lower-bound size is not mistaken for a complete scan. Filesystem calls already in progress can outlast the enumeration budget. Bundles remain local and should be reviewed before sharing.
+
## Automatic setup
Windows setup packages install Checkup for your current account, bundle the .NET runtime, create a Start menu shortcut and register an uninstaller. A desktop shortcut is optional. No administrator password or manual file copying is required. Existing settings and saved data stay in their current locations. The portable ZIP remains available.
-Download `Checkup-v1.1.1-setup.exe` from Releases and run it.
+Download `Checkup-v1.2.0-setup.exe` from Releases and run it.
For unattended deployment:
```powershell
-Start-Process .\Checkup-v1.1.1-setup.exe -ArgumentList '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-' -Wait -PassThru
+Start-Process .\Checkup-v1.2.0-setup.exe -ArgumentList '/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-' -Wait -PassThru
```
Build from source with the [open-source Inno Setup compiler](https://github.com/jrsoftware/issrc) installed:
diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
new file mode 100644
index 0000000..ed25c1f
--- /dev/null
+++ b/RELEASE-NOTES.md
@@ -0,0 +1,10 @@
+# Checkup 1.2.0
+
+Checkup can now generate a local support bundle from a scheduled task without opening the desktop. Temporary-file inventory skips links, reports partial coverage, and has entry and time budgets. Existing command output is preserved.
+
+## Verification
+
+Engine regressions cover partial counts, missing roots, cancellation, cyclic junctions and exclusive exports. The self-contained EXE passed unattended report generation, existing-output protection and invalid argument checks locally.
+
+Automatic setup and portable builds include the .NET runtime. Installers are unsigned. ZIP checksums verify downloaded bytes; they do not authenticate an unknown publisher.
+
diff --git a/SupportBundle.cs b/SupportBundle.cs
index f21fc47..9851af3 100644
--- a/SupportBundle.cs
+++ b/SupportBundle.cs
@@ -32,7 +32,7 @@ public static HealthReport CreateReport(IReadOnlyList findings, DateTim
return new HealthReport(now ?? DateTimeOffset.UtcNow, score, grade, summary, safeFindings);
}
- public static BundleResult Export(string destination, IReadOnlyList findings)
+ public static BundleResult Export(string destination, IReadOnlyList findings, bool overwrite = true)
{
if (string.IsNullOrWhiteSpace(destination)) throw new ArgumentException("A destination is required.", nameof(destination));
var report = CreateReport(findings);
@@ -61,7 +61,7 @@ public static BundleResult Export(string destination, IReadOnlyList fin
using var stream = entry.Open();
stream.Write(file.Value);
}
- File.Move(temporary, fullPath, true);
+ File.Move(temporary, fullPath, overwrite);
}
finally { if (File.Exists(temporary)) File.Delete(temporary); }
var bytes = File.ReadAllBytes(fullPath);
diff --git a/tests/CheckupEngineTests.cs b/tests/CheckupEngineTests.cs
index 6fe6adc..9fb5967 100644
--- a/tests/CheckupEngineTests.cs
+++ b/tests/CheckupEngineTests.cs
@@ -50,3 +50,31 @@
}
finally { if (File.Exists(output)) File.Delete(output); }
Console.WriteLine("PASS: support bundle scoring, redaction, contents, and hashes verified");
+
+var inventoryRoot = Path.Combine(Path.GetTempPath(), "checkup-budget-" + Guid.NewGuid().ToString("N"));
+Directory.CreateDirectory(inventoryRoot);
+try
+{
+ for (var i = 0; i < 4; i++) File.WriteAllText(Path.Combine(inventoryRoot, i + ".txt"), "fixture");
+ var limited = CheckupEngine.MeasureTemp(inventoryRoot, maxEntries: 2);
+ if (limited.Complete || !limited.BudgetReached || limited.Files != 2 || limited.Bytes != 14) throw new Exception("Inventory budget did not preserve partial counts.");
+ var complete = CheckupEngine.MeasureTemp(inventoryRoot);
+ if (!complete.Complete || complete.Files != 4 || complete.Bytes != 28) throw new Exception("Complete inventory is inaccurate.");
+ var missing = CheckupEngine.MeasureTemp(Path.Combine(inventoryRoot, "missing"));
+ if (missing.Complete || missing.Skipped != 1) throw new Exception("Missing inventory root appeared complete.");
+ using var canceled = new CancellationTokenSource(); canceled.Cancel();
+ try { CheckupEngine.MeasureTemp(inventoryRoot, canceled.Token); throw new Exception("Canceled inventory continued."); } catch (OperationCanceledException) { }
+ var bundlePath = Path.Combine(inventoryRoot, "preserved.zip"); File.WriteAllText(bundlePath, "preserve");
+ try { SupportBundle.Export(bundlePath, sample, overwrite: false); throw new Exception("Existing bundle overwritten."); } catch (IOException) { }
+ if (File.ReadAllText(bundlePath) != "preserve" || Directory.GetFiles(inventoryRoot, "*.tmp-*").Length != 0) throw new Exception("Exclusive export failed cleanup.");
+ if (CommandLine.Run(new[] { "--scan", "--output", bundlePath }) != 2 || CommandLine.Run(new[] { "--bad" }) != 2) throw new Exception("CLI accepted invalid or existing output.");
+ var junction = Path.Combine(inventoryRoot, "loop");
+ var start = new System.Diagnostics.ProcessStartInfo("cmd.exe") { UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true };
+ foreach (var argument in new[] { "/c", "mklink", "/J", junction, inventoryRoot }) start.ArgumentList.Add(argument);
+ using var process = System.Diagnostics.Process.Start(start)!; process.WaitForExit();
+ if (process.ExitCode != 0) throw new Exception("Junction fixture creation failed.");
+ try { var linked = CheckupEngine.MeasureTemp(inventoryRoot); if (linked.Skipped != 1 || linked.Complete) throw new Exception("Linked inventory entry was followed."); }
+ finally { Directory.Delete(junction); }
+}
+finally { Directory.Delete(inventoryRoot, true); }
+Console.WriteLine("PASS: inventory budgets, cancellation, missing roots, cyclic links, exclusive bundles and CLI validation");
diff --git a/tests/Test-Headless.ps1 b/tests/Test-Headless.ps1
new file mode 100644
index 0000000..93d3841
--- /dev/null
+++ b/tests/Test-Headless.ps1
@@ -0,0 +1,43 @@
+param([Parameter(Mandatory=$true)][string]$Exe)
+$ErrorActionPreference = 'Stop'
+$root = Join-Path ([IO.Path]::GetTempPath()) ('Checkup-headless-' + [Guid]::NewGuid().ToString('N'))
+New-Item -ItemType Directory -Path $root | Out-Null
+function Invoke-Headless([string]$Arguments, [int]$Expected) {
+ $process = Start-Process -FilePath $Exe -ArgumentList $Arguments -WindowStyle Hidden -PassThru
+ try {
+ $deadline = [DateTime]::UtcNow.AddSeconds(150)
+ while (!$process.HasExited) {
+ $process.Refresh()
+ if ($process.MainWindowHandle -ne 0) { throw 'Unattended command opened a desktop window' }
+ if ([DateTime]::UtcNow -ge $deadline) { throw 'Unattended command timed out' }
+ Start-Sleep -Milliseconds 100
+ }
+ $process.WaitForExit()
+ if ($process.ExitCode -ne $Expected) { throw "Expected exit $Expected, received $($process.ExitCode)" }
+ }
+ finally { if (!$process.HasExited) { $process.Kill(); $process.WaitForExit() }; $process.Dispose() }
+}
+try {
+ $report = Join-Path $root 'support.zip'
+ $arguments = '--scan --output "' + $report + '"'
+ Invoke-Headless $arguments 0
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+ $archive = [IO.Compression.ZipFile]::OpenRead($report)
+ try {
+ if ($archive.Entries.Count -ne 4 -or !$archive.GetEntry('report.json') -or !$archive.GetEntry('manifest-sha256.txt')) { throw 'Support bundle is incomplete' }
+ $reader = [IO.StreamReader]::new($archive.GetEntry('report.json').Open())
+ try { $json = $reader.ReadToEnd() | ConvertFrom-Json } finally { $reader.Dispose() }
+ if ($json.Findings.Count -lt 4) { throw 'Health findings missing' }
+ } finally { $archive.Dispose() }
+ $before = (Get-FileHash -LiteralPath $report -Algorithm SHA256).Hash
+ Invoke-Headless $arguments 2
+ if ((Get-FileHash -LiteralPath $report -Algorithm SHA256).Hash -ne $before) { throw 'Existing report changed' }
+ Invoke-Headless '--invalid' 2
+ Write-Output 'PASS: packaged unattended workflow, report content, existing-output protection, argument exits and no desktop window'
+}
+finally {
+ $resolved = [IO.Path]::GetFullPath($root)
+ $temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\'
+ if (!$resolved.StartsWith($temp, [StringComparison]::OrdinalIgnoreCase) -or !([IO.Path]::GetFileName($resolved)).StartsWith('Checkup-headless-')) { throw 'Unsafe cleanup path' }
+ Remove-Item -LiteralPath $resolved -Recurse -Force
+}
diff --git a/tests/Test-PackagedLaunch.ps1 b/tests/Test-PackagedLaunch.ps1
new file mode 100644
index 0000000..92b5d32
--- /dev/null
+++ b/tests/Test-PackagedLaunch.ps1
@@ -0,0 +1,10 @@
+param([Parameter(Mandatory=$true)][string]$Exe)
+$ErrorActionPreference = 'Stop'
+$process = Start-Process -FilePath $Exe -WindowStyle Hidden -PassThru
+try {
+ Start-Sleep -Seconds 3
+ $process.Refresh()
+ if ($process.HasExited -or $process.MainWindowHandle -eq 0) { throw 'Downloaded Checkup did not create its desktop window' }
+ Write-Output 'PASS: exact downloaded Checkup desktop executable starts successfully'
+}
+finally { if (!$process.HasExited) { $process.Kill(); $process.WaitForExit() }; $process.Dispose() }