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: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 49 additions & 0 deletions .github/workflows/verify-release.yml
Original file line number Diff line number Diff line change
@@ -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'))
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ tests/obj/

installer-payload/
installer-output/

test-headless-output/
2 changes: 1 addition & 1 deletion App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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."));
}
Expand Down
2 changes: 1 addition & 1 deletion Checkup.csproj
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>WinExe</OutputType><TargetFramework>net10.0-windows</TargetFramework><UseWPF>true</UseWPF><UseWindowsForms>true</UseWindowsForms><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><Version>1.1.1</Version><EnableDefaultCompileItems>false</EnableDefaultCompileItems><EnableDefaultPageItems>false</EnableDefaultPageItems><EnableDefaultApplicationDefinition>false</EnableDefaultApplicationDefinition><Product>Checkup</Product><Title>Checkup</Title><Company>informent</Company></PropertyGroup><ItemGroup><Compile Include="App.xaml.cs;MainWindow.xaml.cs;CheckupEngine.cs;SupportBundle.cs"/><ApplicationDefinition Include="App.xaml"/><Page Include="MainWindow.xaml"/><InternalsVisibleTo Include="CheckupEngineTests"/></ItemGroup></Project>
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>WinExe</OutputType><TargetFramework>net10.0-windows</TargetFramework><UseWPF>true</UseWPF><UseWindowsForms>true</UseWindowsForms><ImplicitUsings>enable</ImplicitUsings><Nullable>enable</Nullable><Version>1.2.0</Version><EnableDefaultCompileItems>false</EnableDefaultCompileItems><EnableDefaultPageItems>false</EnableDefaultPageItems><EnableDefaultApplicationDefinition>false</EnableDefaultApplicationDefinition><Product>Checkup</Product><Title>Checkup</Title><Company>informent</Company></PropertyGroup><ItemGroup><Compile Include="CommandLine.cs;App.xaml.cs;MainWindow.xaml.cs;CheckupEngine.cs;SupportBundle.cs"/><ApplicationDefinition Include="App.xaml"/><Page Include="MainWindow.xaml"/><InternalsVisibleTo Include="CheckupEngineTests"/></ItemGroup></Project>
57 changes: 41 additions & 16 deletions CheckupEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Finding> Scan()
public static IReadOnlyList<Finding> Scan() => Scan(CancellationToken.None);
public static IReadOnlyList<Finding> Scan(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var findings = new List<Finding>();
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));
Expand All @@ -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<string>();
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<string>(); 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;
}
19 changes: 19 additions & 0 deletions CommandLine.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
}
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
@@ -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.

4 changes: 2 additions & 2 deletions SupportBundle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public static HealthReport CreateReport(IReadOnlyList<Finding> findings, DateTim
return new HealthReport(now ?? DateTimeOffset.UtcNow, score, grade, summary, safeFindings);
}

public static BundleResult Export(string destination, IReadOnlyList<Finding> findings)
public static BundleResult Export(string destination, IReadOnlyList<Finding> findings, bool overwrite = true)
{
if (string.IsNullOrWhiteSpace(destination)) throw new ArgumentException("A destination is required.", nameof(destination));
var report = CreateReport(findings);
Expand Down Expand Up @@ -61,7 +61,7 @@ public static BundleResult Export(string destination, IReadOnlyList<Finding> 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);
Expand Down
28 changes: 28 additions & 0 deletions tests/CheckupEngineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Loading