diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..d1b8800 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,53 @@ +name: Bug report +description: Something in FieldKit did not work as expected. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Every run writes a timestamped log. Attaching it makes a report far + easier to act on — use **Open Log** in the toolbar to find it. + + - type: textarea + id: what-happened + attributes: + label: What happened + description: What you did, what you expected, and what you got instead. + validations: + required: true + + - type: input + id: operation + attributes: + label: Which operation + description: The name shown in the operations list, if the problem was in one specific task. + placeholder: Clear Temporary Files + validations: + required: false + + - type: input + id: version + attributes: + label: FieldKit version + description: Right-click FieldKit.exe, then Properties, then Details. + placeholder: 1.1.0 + validations: + required: true + + - type: input + id: windows + attributes: + label: Windows version + description: Run `winver`, or Settings, then System, then About. + placeholder: Windows 11 24H2 (26100.2314) + validations: + required: true + + - type: textarea + id: log + attributes: + label: Log output + description: Paste the relevant part of the log, or attach the whole file. + render: text + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..9e977cc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/tsudo/fieldkit/security/policy + about: Report privately. Do not open a public issue for a security problem. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..53a7c19 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,33 @@ +name: Feature request +description: Suggest an operation or change for FieldKit. +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + [ROADMAP.md](https://github.com/tsudo/fieldkit/blob/main/ROADMAP.md) + covers what is already planned and what is deliberately out of scope. + Worth a look first. + + - type: textarea + id: problem + attributes: + label: What problem does this solve + description: Describe the situation you run into, not the implementation you have in mind. + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What you would like it to do + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: How you handle it today + description: The manual steps, script, or other tool you currently use. + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..82119c4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## What this changes + + + +## Why + + + +## How it was verified + + + +- [ ] `dotnet build FieldKit.sln --configuration Release` succeeds +- [ ] Ran the affected operation on Windows 10 or 11 +- [ ] No new warnings + +## Notes for the reviewer + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml index fbbad3c..9da2e3c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,3 +5,11 @@ updates: schedule: interval: "weekly" open-pull-requests-limit: 5 + + # The workflows pin actions by major tag; without this ecosystem those pins + # get no update PRs and drift silently. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d3b7074..634bd10 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,12 +6,53 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: build: runs-on: windows-latest steps: - - uses: actions/checkout@v5 - - uses: actions/setup-dotnet@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 with: - dotnet-version: "9.0.x" + # Latest 10.0 SDK. global.json sets the floor; the SDK in use + # determines which runtime version gets embedded in the + # self-contained exe, so tracking forward here is deliberate. + dotnet-version: "10.0.x" + - run: dotnet build FieldKit.sln --configuration Release + + # Verify the configuration that actually ships. `dotnet build` does not + # exercise single-file packing, self-contained runtime bundling, or + # compression — all of which have their own failure modes. + - name: Verify release publish + run: dotnet publish FieldKit/FieldKit.csproj --configuration Release --output publish + + - name: Confirm single-file artifact + shell: pwsh + run: | + $exe = "publish/FieldKit.exe" + if (-not (Test-Path $exe)) { throw "Expected single-file artifact at $exe" } + + # A size floor alone does not prove single-file: dropping + # IncludeNativeLibrariesForSelfExtract still yields a ~59 MB exe but + # scatters native DLLs beside it, which an elevated binary would then + # resolve from its launch directory. Assert nothing else is emitted. + $stray = Get-ChildItem publish -File | + Where-Object { $_.Name -ne 'FieldKit.exe' -and $_.Extension -ne '.pdb' } + if ($stray) { throw "Not single-file - loose files present: $($stray.Name -join ', ')" } + + $sizeMb = [math]::Round((Get-Item $exe).Length / 1MB, 1) + Write-Host "FieldKit.exe = $sizeMb MB" + if ($sizeMb -lt 40) { throw "Artifact is $sizeMb MB - self-contained runtime looks missing" } + if ($sizeMb -gt 100) { throw "Artifact is $sizeMb MB - compression looks disabled" } + + # Retain it: without this the artifact that just passed verification is + # destroyed with the runner, so a failed assertion leaves nothing to + # inspect and the exe is never smoke-tested off the author's machine. + - uses: actions/upload-artifact@v7 + with: + name: FieldKit-exe + path: publish/FieldKit.exe + if-no-files-found: error diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..6eae4c2 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,59 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly, so advisories that land after a quiet period still get caught. + - cron: "23 5 * * 1" + +jobs: + analyze: + name: Analyze ${{ matrix.language }} + runs-on: windows-latest + permissions: + security-events: write + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + # Manual build mode rather than buildless: FieldKit already has a + # known-good build, and compiling gives CodeQL full type resolution + # across the partial classes WPF generates from XAML. + - language: csharp + build-mode: manual + # The workflows in this repo are themselves analyzable. + - language: actions + build-mode: none + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-dotnet@v6 + if: matrix.language == 'csharp' + with: + # Latest 10.0 SDK, matching build.yml. Resolving from global.json + # instead would freeze the embedded self-contained runtime at + # whatever that SDK bundles — and a self-contained app only gets + # runtime CVE fixes by being rebuilt. + dotnet-version: "10.0.x" + + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Build + if: matrix.language == 'csharp' + run: dotnet build FieldKit.sln --configuration Release + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.gitignore b/.gitignore index 2789d71..94b2bc4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ bin/ obj/ +publish/ *.user *.suo .vs/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..98e91e6 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,71 @@ +# Changelog + +All notable changes to FieldKit are recorded here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and +this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0] - 2026-08-15 + +Maintenance release. No new operations — this moves the runtime off a version +heading for end of support and fixes two defects found during review. + +### Changed + +- Runtime moved from .NET 9 to .NET 10 (LTS). .NET 9 reaches end of support on + 2026-11-10; because FieldKit publishes as a self-contained single file, the + runtime is embedded in the exe, so shipping on .NET 9 past that date would + hand every downloader an unpatched runtime. .NET 10 is supported through + 2028-11-14. +- `System.Management` and `System.ServiceProcess.ServiceController` updated from + 9.0.0 to 10.0.11. +- `global.json` sets .NET 10 as the minimum SDK, so a build on an older SDK + fails immediately with a clear message. It is a floor rather than a pin by + design: the SDK in use determines which runtime version is embedded in the + self-contained exe, and a self-contained app only receives runtime fixes when + it is rebuilt, so tracking forward is the safer default. + +### Fixed + +- Removed a duplicate `Logger` instance. `MainWindow`'s parameterless + constructor created its own logger, and the real constructor chained through + it before replacing the field, leaving an undisposed `StreamWriter` behind. + The visible symptom was an empty `FieldKit-*.log` file appearing next to the + exe on every launch. +- Unhandled exceptions on the UI thread are now caught, written to the log, and + reported with the log path, instead of closing the app without explanation. +- A run that stops early now says so. Previously the status bar read "Run + complete" at 100% whether or not the run finished; an interrupted run reports + "Run aborted", marks the operations that did not finish, and shows what went + wrong instead of a completion summary. +- Closing the window mid-run no longer disposes the cancellation token while the + run loop is still using it. Every remaining operation used to fail with + `ObjectDisposedException`, which read in the log as many broken operations + rather than one lifecycle bug. + +### Added + +- CodeQL static analysis, running on every push and pull request to `main` plus + a weekly schedule. +- Continuous integration now verifies the published single-file artifact, not + just `dotnet build`. Single-file packing, self-contained runtime bundling, and + compression have their own failure modes that a plain build never exercises. +- Issue and pull request templates. +- This changelog. + +## [1.0.0] - 2026-04-06 + +First release. + +### Added + +- 12 maintenance operations across cleanup, updates, and system health. +- Presets for focused runs: All, Cleanup, Updates, Health, Defaults. +- WPF interface with a Fluent dark theme. +- System Information panel with export. +- Timestamped log file for every run. +- Security hardening: absolute paths for system binaries, and symlink and + junction protection on temp cleanup. + +[1.1.0]: https://github.com/tsudo/fieldkit/releases/tag/v1.1.0 +[1.0.0]: https://github.com/tsudo/fieldkit/releases/tag/v1.0.0 diff --git a/FieldKit/App.xaml.cs b/FieldKit/App.xaml.cs index 6857d30..8e630b3 100644 --- a/FieldKit/App.xaml.cs +++ b/FieldKit/App.xaml.cs @@ -1,4 +1,5 @@ using System.Windows; +using System.Windows.Threading; using FieldKit.Services; using FieldKit.Tasks; @@ -44,6 +45,12 @@ private void App_Startup(object sender, StartupEventArgs e) var mainWindow = new MainWindow(tasks, _logger); mainWindow.Show(); + + // Registered only once startup has succeeded. Anything that fails + // before this point belongs to the catch below, which shuts down + // deliberately — including failures inside the disclaimer dialog, + // whose whole job is obtaining consent before destructive work. + DispatcherUnhandledException += OnDispatcherUnhandledException; } catch (Exception ex) { @@ -53,6 +60,27 @@ private void App_Startup(object sender, StartupEventArgs e) } } + /// + /// Last-chance handler for exceptions raised on the UI thread after startup. + /// Maintenance tasks catch their own failures and the run loop reports its + /// own aborts, so anything reaching here is a UI-layer bug outside a run. + /// The app stays alive so the user keeps access to the log path rather than + /// losing it to a silent crash. + /// + private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) + { + var logger = _logger; + logger?.Log($"Unhandled UI exception: {e.Exception}", "ERROR"); + + var logNote = logger is null ? "" : $"\n\nLog: {logger.LogFilePath}"; + MessageBox.Show( + $"FieldKit hit an unexpected error:\n\n{e.Exception.Message}{logNote}", + "FieldKit — Unexpected Error", + MessageBoxButton.OK, MessageBoxImage.Error); + + e.Handled = true; + } + protected override void OnExit(ExitEventArgs e) { _logger?.Dispose(); diff --git a/FieldKit/FieldKit.csproj b/FieldKit/FieldKit.csproj index d342d5e..bf9fc57 100644 --- a/FieldKit/FieldKit.csproj +++ b/FieldKit/FieldKit.csproj @@ -2,7 +2,7 @@ WinExe - net9.0-windows + net10.0-windows enable true enable @@ -10,7 +10,7 @@ fieldkit.ico FieldKit FieldKit - 1.0.0 + 1.1.0 Keith S. Crawford FieldKit Portable Windows maintenance in a single exe. No installer, no bloat, no telemetry. @@ -23,8 +23,8 @@ - - + + diff --git a/FieldKit/MainWindow.xaml.cs b/FieldKit/MainWindow.xaml.cs index 311fb19..9518849 100644 --- a/FieldKit/MainWindow.xaml.cs +++ b/FieldKit/MainWindow.xaml.cs @@ -15,15 +15,9 @@ public partial class MainWindow : Window private readonly ObservableCollection _taskViewModels = new(); private CancellationTokenSource? _runCts; - public MainWindow() + public MainWindow(IReadOnlyList tasks, Logger logger) { InitializeComponent(); - _tasks = Array.Empty(); - _logger = new Logger(); - } - - public MainWindow(IReadOnlyList tasks, Logger logger) : this() - { _tasks = tasks; _logger = logger; @@ -37,7 +31,10 @@ public MainWindow(IReadOnlyList tasks, Logger logger) : this() Closed += (_, _) => { _logger.LogWritten -= OnLogWritten; - _runCts?.Dispose(); + // Cancel, don't dispose — the run loop is still reading this token + // and owns disposal in its finally. Disposing here made every + // remaining operation fail with ObjectDisposedException. + _runCts?.Cancel(); }; UpdateSummary(); @@ -176,6 +173,7 @@ private async Task RunSelectedTasksAsync() SetRunningState(true); _logger.Log($"Run started. Preview: {(dryRun ? "ON" : "OFF")}. Log: {_logger.LogFilePath}", "INFO"); + var completed = false; try { for (int i = 0; i < selected.Count; i++) @@ -215,18 +213,36 @@ private async Task RunSelectedTasksAsync() RefreshDetails(); } + + completed = true; + } + catch (Exception ex) + { + // The run owns its own failure. Letting this reach the global + // dispatcher handler would leave the status bar reading + // "Run complete" on a run that stopped halfway through. + _logger.Log($"Run aborted: {ex}", "ERROR"); + + foreach (var vm in selected.Where(vm => vm.Status == "Running")) + vm.Status = "Aborted"; + + MessageBox.Show(this, + $"The run stopped early and did not finish.\n\n{ex.Message}\n\nLog: {_logger.LogFilePath}", + "FieldKit — Run Aborted", MessageBoxButton.OK, MessageBoxImage.Error); } finally { - ProgressBar.Value = 100; - StatusText.Text = "Run complete"; + ProgressBar.Value = completed ? 100 : 0; + StatusText.Text = completed ? "Run complete" : "Run aborted"; SetRunningState(false); _runCts.Dispose(); _runCts = null; + RefreshDetails(); } - ShowCompletionSummary(dryRun, success, warning, error, skipped, - startFree, TryGetSystemDriveFreeSpace(), rebootRecommended); + if (completed) + ShowCompletionSummary(dryRun, success, warning, error, skipped, + startFree, TryGetSystemDriveFreeSpace(), rebootRecommended); } private void ShowCompletionSummary(bool dryRun, int success, int warning, int error, int skipped, diff --git a/FieldKit/app.manifest b/FieldKit/app.manifest index 1e04c29..d311e66 100644 --- a/FieldKit/app.manifest +++ b/FieldKit/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/README.md b/README.md index 092833d..eedabe4 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ![License](https://img.shields.io/github/license/tsudo/fieldkit) ![Release](https://img.shields.io/github/v/release/tsudo/fieldkit) ![Downloads](https://img.shields.io/github/downloads/tsudo/fieldkit/total) -![.NET](https://img.shields.io/badge/.NET-9.0-512BD4) +![.NET](https://img.shields.io/badge/.NET-10.0-512BD4) Portable Windows maintenance in a single exe. No installer, no bloat, no telemetry. @@ -11,7 +11,7 @@ FieldKit runs cleanup, updates, and system health checks from one window. Pick a ## Download -Grab the latest `FieldKit.exe` from the [Releases](https://github.com/tsudo/fieldkit/releases) page. +Grab the latest `FieldKit.exe` from the [Releases](https://github.com/tsudo/fieldkit/releases) page. See the [changelog](CHANGELOG.md) for what changed. - Windows 10 or 11, 64-bit - No installer — run it from anywhere @@ -110,7 +110,7 @@ FieldKit reports these cases clearly in the status column and log instead of fai ## Building From Source -Requires the [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0). +Requires the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0). `global.json` sets `10.0.100` as the minimum; the build uses the newest .NET 10 SDK you have installed. ```bash dotnet build FieldKit.sln @@ -119,10 +119,10 @@ dotnet build FieldKit.sln To publish a portable single-file exe: ```bash -dotnet publish FieldKit.sln -c Release +dotnet publish FieldKit/FieldKit.csproj -c Release -o publish ``` -Output: `FieldKit/bin/Release/net9.0-windows/win-x64/publish/FieldKit.exe` +Output: `publish/FieldKit.exe` ## Privacy diff --git a/ROADMAP.md b/ROADMAP.md index 93e978c..09b25c6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -99,7 +99,7 @@ That means the user experience should answer: ## UI Direction -v1.0 migrated from WinForms to WPF with .NET 9 Fluent dark theme. +v1.0 migrated from WinForms to WPF with a Fluent dark theme. v1.1 moved the runtime to .NET 10 (LTS). Current state: diff --git a/global.json b/global.json new file mode 100644 index 0000000..28820ed --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "//": "Minimum SDK, not a pin. rollForward latestFeature uses the newest .NET 10 SDK installed, which keeps the embedded self-contained runtime current on rebuild.", + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +}