Compare commits

...
82 Commits
Author SHA1 Message Date
Chris Titus ad81e0a7af Update sponsors in README (#5116) 2026-09-26 10:32:30 -05:00
KEERTHIVASAN KandChris Titus 8e3998d9fd feat(dns): Add real-time DNS speed benchmark and 'Fastest' auto-selection option (#4935)
* feat(dns): Add real-time DNS speed benchmark and 'Fastest' auto-selection option

* fix(dns): Address CodeRabbit & Codex PR review feedback

* fix(ci): Remove .biomeignore to keep repository root clean

* Fix Fastest DNS eligibility and failed probe handling

---------

Co-authored-by: Chris Titus <contact@christitus.com>
2026-09-19 18:16:16 -05:00
brxndon f65d2631a0 Fix CurseForge entry naming in applications.json (#5072)
* Fix CurseForge entry naming in applications.json

* Remove stray null file

* Remove choco fallback for CurseForge, use WinGet only
2026-09-19 17:27:43 -05:00
Omar 5f39d3b42d fix(tweaks): rename lock screen key to WPFToggle prefix (#5061)
Toggles are identified by name prefix, not by the Type field, so the
WPFTweaks-prefixed key made Reset-WPFCheckBoxes treat this entry as an
ordinary checkbox and force it off on every tab build, deleting the
NoLockScreen value it had just set.

Closes #5060
2026-09-19 17:23:20 -05:00
Omar 448543870a fix(win11creator): drop extension-class driver filter (#5058) 2026-09-19 17:22:39 -05:00
Omar 91a4f62068 chore(dns): remove Mullvad DNS providers (#5057)
* chore(dns): remove Mullvad DNS providers

Mullvad is shutting down its public encrypted DNS servers and sponsoring
Quad9 instead, so the six Mullvad profiles would resolve nothing once the
servers go dark.

Drops the Mullvad entries from config/dns.json, trims them out of the
WPFchangedns ComboItems, and removes the matching bullets and the
DoH-fallback note from the tweaks guide. Quad9 already ships as a provider,
so users have a documented destination.

The generated code-reference page for this tweak is left alone; the
pre-release workflow regenerates it from config/tweaks.json.

* test(dns): use generic fixtures for DoH-only providers

The DoH-only and SecondaryDohTemplate paths in Set-WinUtilDNS were covered
by fixtures named after Mullvad and carrying its real resolver addresses.
Those code paths still exist, so the coverage stays; only the naming was
tied to a provider WinUtil no longer ships.

Renames the fixtures to DohOnlyProvider and DohOnlyNoSecondary and swaps in
RFC 5737 / RFC 3849 documentation addresses and example.com templates, so
the tests no longer read as if they exercise a shipped provider.
2026-09-19 17:21:27 -05:00
dependabot[bot]andtitus 2a73455872 chore(deps): bump sharp from 0.35.3 to 0.35.4 in /docs (#5077)
* chore(deps): bump sharp from 0.35.3 to 0.35.4 in /docs

Bumps [sharp](https://github.com/lovell/sharp) from 0.35.3 to 0.35.4.
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.35.3...v0.35.4)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Normalize package manifest line endings

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: titus <contact@christitus.com>
2026-09-19 17:18:57 -05:00
dependabot[bot]andtitus 52ebc931fd chore(deps): bump astro from 7.1.6 to 7.3.2 in /docs (#5075)
* chore(deps): bump astro from 7.1.6 to 7.3.2 in /docs

Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 7.1.6 to 7.3.2.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@7.3.2/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 7.3.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Normalize package manifest line endings

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: titus <contact@christitus.com>
2026-09-19 17:14:04 -05:00
KarimandChris Titus 7aff652d4c Add SyncTrayzor (#5065)
* Add SyncTrayzor

* Fix SyncTrayzor application JSON syntax

---------

Co-authored-by: Chris Titus <contact@christitus.com>
2026-09-19 17:09:27 -05:00
Karim be394d2ce0 Add AB Download Manager (#5066) 2026-09-19 17:00:26 -05:00
dependabot[bot] b4181bd008 chore(deps): bump nanoid from 3.3.16 to 3.3.19 in /docs (#5103)
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.16 to 3.3.19.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.16...3.3.19)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 3.3.19
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-19 16:57:56 -05:00
dependabot[bot] da7354bfc0 chore(deps): bump svgo from 4.0.2 to 4.1.0 in /docs (#5074)
Bumps [svgo](https://github.com/svg/svgo) from 4.0.2 to 4.1.0.
- [Release notes](https://github.com/svg/svgo/releases)
- [Commits](https://github.com/svg/svgo/compare/v4.0.2...v4.1.0)

---
updated-dependencies:
- dependency-name: svgo
  dependency-version: 4.1.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-19 16:56:13 -05:00
dependabot[bot] 9de91c3999 chore(deps): bump devalue from 5.9.0 to 5.9.2 in /docs (#5099)
Bumps [devalue](https://github.com/sveltejs/devalue) from 5.9.0 to 5.9.2.
- [Release notes](https://github.com/sveltejs/devalue/releases)
- [Changelog](https://github.com/sveltejs/devalue/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/devalue/compare/v5.9.0...v5.9.2)

---
updated-dependencies:
- dependency-name: devalue
  dependency-version: 5.9.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-19 16:55:57 -05:00
Karim c2d65b6f99 Add Syncthing (#5064) 2026-09-19 16:55:52 -05:00
dependabot[bot] 700725a878 chore(deps): bump astral-sh/setup-uv from 9.0.0 to 10.1.0 (#5085)
Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 9.0.0 to 10.1.0.
- [Release notes](https://github.com/astral-sh/setup-uv/releases)
- [Commits](https://github.com/astral-sh/setup-uv/compare/v9.0.0...v10.1.0)

---
updated-dependencies:
- dependency-name: astral-sh/setup-uv
  dependency-version: 10.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-19 16:54:18 -05:00
MyDrift f9139c75ff Rework the Win11 Creator tab into a three step wizard (#4925)
* Rework the Win11 Creator tab into a three step wizard

- Turn the stacked sections into pages you can navigate between
- Move the edition picker and driver option to the modify step
- Show a working page while mounting and modifying run
- Show what the run changes and a finished panel with the output path
- Put the status log in its own card next to the steps
- Rename Clean & Reset to Start Over and move it below the actions

* Follow the existing colours on the Win11 Creator tab

- Take button text and icon colour from the button they sit in
- Stop the implicit TextBlock style painting a label background on buttons
- Colour step headers from the tab item state
- Use the label accent for headings and field labels like the other tabs

* Cut repeated markup on the Win11 Creator tab

- Render button icon and label from a ContentTemplate, glyph goes in Tag
- Render the step header from a HeaderTemplate the same way
- Move the page scroll wrapper into the tab control template
- Derive the text styles from one another instead of repeating setters

* fix: tidy the dropdown and stop the spinner stuttering

- combo box items painted their own background while the popup painted
  another, so the list read as loose labels on a mismatched sheet; items are
  transparent now and the popup carries the colour
- stretch the item presenter so a highlight spans the popup instead of just
  the text, and bind the popup width to the closed box
- cache the working page's spinner: it animates a rotation on a TextBlock,
  which re-rasterises the glyph every frame on the interface thread
- run that animation only while the page is visible; it was started on Loaded
  and never stopped, so it kept spinning after the step was done

* Stop the Win11 Creator status log rendering justified

The shared TextBox style sets HorizontalContentAlignment to Stretch, which
WPF reads as TextAlignment.Justify. Every other box in WinUtil is single
line, so the status log is the only place it shows, and there it stretched
each wrapped line to the full width with gaps between the words.

Left-aligns it, and while the log is being read as a log rather than as
prose: monospace from a new Win11LogFontFamily theme key so the timestamps
form a column and a wrapped line is visibly not a new entry, and no effect,
since the shared style's drop shadow has neither border nor background to
sit under on this control and smeared the glyphs instead.

* Show the working page for export, USB write and Start Over

Only mount and modify moved to the working page. Saving an ISO, writing a
USB drive and Start Over all ran with the finished output page still on
screen, so the three longest operations in the tab were the ones that looked
like nothing was happening.

All three now open the working page for their duration and return to the
page they belong on: the output step for the two that produce media, the ISO
picker for Start Over, which has just deleted the working directory. Failures
leave the working page before their message box rather than putting a modal
over a spinner, and a finally guard covers cancellation, which skips catch.

Start Over spins the icon backwards. Its work undoes rather than produces,
and reading that off the animation is quicker than reading the label.

* cut comments

Leaves the four that answer a "why is it written this way" a reader cannot
get from the code: the shared TextBox style's justify and drop shadow, the
spinner tag being set before the page is shown, going back to Select rather
than Modify after a failed modification, and the finally guards existing for
cancellation rather than for failure.

* cut changes that only reword

Reverts three edits that changed wording without changing meaning, and drops
the comment on the failed-modification branch.

* cut comments that narrate the code

* address coderabbit review

Hyphenate three-step, and let the chevrons grow past their minimum instead of
pinning a size the glyph could outgrow.

* dismount a replaced ISO and name the USB disk when done

Picking a second ISO after verifying one left the first attached: the mount job
clears Win11ISOImagePath, which is the only handle cleanup has to it. Dismount
it in the job, off the interface thread, before that reset.

The done panel now names the disk it wrote, the way the ISO branch names its file.

* keep the wizard inside the card at the minimum window width

The step column and the chevron grid's middle column were both pinned at 620,
while the left card is about 485px at the 800px minimum. The pages do not
scroll sideways, so controls past the card edge were unreachable.

Both are now capped rather than fixed, and the removed WPFWin11ISOArchLabel was
never assigned by anything.

* stop a replacement mount when the old ISO will not dismount

The warn-and-continue path cleared Win11ISOImagePath anyway, which strands the
old mount for the rest of the session - the leak the dismount was added to
close. It now reports and throws instead, from inside the try so the finally
still re-enables the browse and mount buttons.

* cut comments that narrate the code

* drop the OneDrive sync claim from the modify step list

The post-install script sets DisableFileSyncNGSC=1, but it is prepended to
FirstLogon.ps1, whose own body then sets the same value back to 0. The policy
never survives, so the bullet was promising something the run does not do.
Replaced with search box suggestions, which it does do.
2026-09-19 16:50:51 -05:00
Chris Titus 9ac45d6c31 Update sponsors in README (#5097) 2026-09-17 10:32:50 -05:00
Chris Titus 2260df6365 Update sponsors in README (#5092) 2026-09-15 10:32:29 -05:00
dependabot[bot] fc60cbde09 chore(deps): bump js-yaml from 4.3.1 to 4.3.2 in /docs (#5083)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.1 to 4.3.2.
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.1...4.3.2)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-13 21:59:32 -05:00
Chris Titus dbe1c365f5 Update sponsors in README (#5076) 2026-09-10 10:32:50 -05:00
Chris Titus a0d3c719a6 Improve UI startup after runspace overhaul (#5056)
* perf: keep UI startup responsive

* test: make favicon checks runner-safe

* fix: qualify favicon visibility state

* test: isolate favicon visibility fixture

* fix: enforce total favicon deadline

* fix: address favicon review feedback

* fix: recognize disabled SSH firewall rule

* perf: restore favicon download throughput

* docs: allow public PR review uploads

* perf: restore overlapping favicon loading
2026-09-07 11:33:39 -05:00
MyDriftandChris Titus 283b9954f1 Consolidate background work into one job layer (#5002)
* Add a job layer for long running work

- Start-WinUtilJob owns busy state, progress, taskbar, logging and errors
- Write-WinUtilJobProgress reports from a job without UI checks in the body
- Post UI updates instead of waiting on the dispatcher for each one
- Move Invoke-WPFInstall onto it as the first workflow

* Move the remaining app and feature workflows onto the job layer

- Uninstall, AppX install, Features, OOSU and installed detection
- Drop the per workflow busy flag, progress, taskbar and error handling
- Rework their tests to check the job and its body instead of runspace internals

* Run the WinUtil interface on its own thread

main.ps1 now only manages the run: it creates a dedicated STA runspace for
the window, waits for it, and reports whatever the interface thread failed
with. The interface itself moved into Start-WinUtilUserInterface, so the
thread that owns the window does nothing but paint and dispatch.

- New-WinUtilSessionState builds one starting point for both the interface
  runspace and the worker pool, carrying $sync, the compiled script globals
  and every WinUtil function. The pool previously copied only functions
  matching winutil|WPF, which is not enough for a runspace that has to build
  a tab.
- Invoke-WPFUIThread hands work to the interface runspace as body text plus
  parameters instead of marshalling a scriptblock. A scriptblock keeps the
  session state it was written in; running one across runspaces loses the
  caller's variables on an async post and costs roughly twenty times as much
  per command, which turned a checkbox refresh into a multi-minute freeze.
- Both helpers stop at a shut-down dispatcher, so a job that outlives the
  window finishes quietly.

* Move every long workflow onto the job layer

Tweaks, undo, AppX removal and the five Win11 Creator workflows now go
through Start-WinUtilJob like the install workflows already did. That
removes the five hand-built STA runspaces and the function-definition
injection the ISO code needed to reach its own helpers.

- One busy flag: $sync.ActiveJob replaces ProcessRunning and
  Win11ISOProcessRunning, and only the job layer writes it.
- Write-WinUtilJobProgress -Hide absorbs the last use of
  Set-WinUtilTweaksProgressIndicator, so the progress bar and taskbar item
  have a single owner. The helper is gone.
- Show-WinUtilMessage marshals onto the interface thread and logs the
  prompt, so a job body can ask a question without knowing which thread it
  is on. The raw MessageBox calls in the ISO workflows are gone.
- Win11 Creator status-log lines also go to the session log, and the
  per-workflow Log/SetProgress helpers are gone.
- Get-WinUtilOscdimgPath and Get-WinUtilFreeDriveLetter are now real
  functions rather than nested ones, so the pool can resolve them.

* Log from every WinUtil thread into one session file

Start-Transcript only records the runspace it was started on, so every line
a worker or the interface logged was being dropped. Write-WinUtilLog now
appends to the session log directly, serialized with a named mutex, and the
console transcript gets its own file in the same logs directory.

* Document the threading model and the job layer

* Stop Invoke-WPFUIThread leaking the body's output to its caller

The helper returned whatever the body produced, including a bare $null. Callers written
against the old void signature then returned an array instead of their own value:
Get-WinUtilSelectedPackages handed back @($null, $split), both package lists read as empty,
and Install and Uninstall reported success without installing or removing anything.

Output is now suppressed unless -PassThru is asked for, which only Show-WinUtilMessage needs.
Covered by tests on both the helper and the package split.

* Put every button that changes the system on the job layer

Invoke-WPFButton now classifies the press instead of running it. Anything that changes the
system gets a job; tab switches, selection helpers, window chrome and the WPFPanel* applet
launchers stay on the interface thread. Updates, the Ultimate Performance plan, the Fixes
buttons, OpenSSH Server, the system repair scan and the AppX query previously ran inline,
which froze the window, produced no progress and interleaved their output with a running job.

The job layer also owns the console banner now. Write-WinUtilJobBanner draws it once, so the
eleven hand-drawn === boxes are gone and every operation announces its start, not only its end.

- The job is named after what the button says, read from the config or the control itself,
  so there is no second list of labels to keep in step.
- Show-WinUtilMessage replaces the last raw MessageBox calls, which could not have worked
  from a worker thread.
- Write-WinUtilJobProgress replaces the last direct Set-WinUtilTaskbaritem calls.

* Document button routing and the job banner

* Read function bodies from the FunctionInfo instead of the function provider

Building the session state is on the path to first paint, and going through
function:\ for every function cost about as much as the whole interface
runspace saved. Time to first window is back level with upstream.

* Render the taskbar overlays after first paint

The logo overlay render costs about 55ms and nothing can see it until the window
is up, so it no longer sits between the interface being built and being shown.
Both the logo and the status overlays are now rendered from the same deferred
call once the window has painted.

* Make a failed package fail the job

Both package helpers ran the manager and moved on regardless of its exit code, so
a run in which nothing installed still reported success with a green checkmark.

They now emit a result per package, classified from the exit code: succeeded,
skipped for WinGet telling us there was nothing to do, or failed. The workflow
collects them and Complete-WinUtilPackageRun prints the summary and throws when
anything failed, which is what puts the job into its failed state.

* Time every pipeline step and report the slowest ones

Measure-WinUtilStep wraps a step, passes its output through untouched, logs how
long it took and keeps the record. Every job and the interface build end with a
summary ranking the slowest steps and their share of the total, so "which tweak
is taking forever" and "what is holding up startup" are answerable from the log
instead of by guessing.

Wired into the interface build, each tweak, each undo, each feature, and each
package. Jobs also log their own wall-clock duration, and the interface logs the
moment it can first service input.

* Render the taskbar overlays on the main thread's runspace

The overlays need an STA thread, which the worker pool is not, so they get one of
their own. Starting it from the interface thread cost more than it saved: opening
the runspace took 154-221ms there against 88ms of rendering. Starting it from the
main thread instead is free, because that thread does nothing but wait for the
window, and the render then overlaps the interface build.

Measured over three runs each, time from start to the interface accepting input:
2071/2105ms before, 2105/2131/2193ms started from the interface thread,
2026/2044/2050ms started from the main thread.

Also caches the session state, which two runspaces now share, and moves the
runspace cleanup registration into its own function for the second caller.

* Cut startup to first interaction roughly in half

- wire button clicks by type name against a HashSet, not a pipeline per $sync key: 335ms to 81ms
- build no tab content before first paint; Invoke-WPFTab already builds the tab it activates
- group apps by category into Lists, not by appending to arrays
- interface built ~1460ms to ~465ms, ready for input ~2090ms to ~858ms

* Warm the unopened tabs while the interface is idle

- queue each remaining tab at ApplicationIdle priority after first paint
- one tab per queued operation so input is serviced in between
- first click on a tab no longer pays for its build

* Report failures with the context needed to act on them

- Write-WinUtilErrorRecord logs message, exception type, command, line and script stack
- used by the job layer, the button funnel, the interface dispatcher and the main thread
- route buttons to the job layer by whitelist, so chrome and popup toggles stop starting empty jobs

* Wire the Install tab controls where they are created

- ChocoRadioButton, WingetRadioButton and the install action buttons come from
  appnavigation.json, so they do not exist until the Install tab is built
- the interface build wired them anyway, which is the three null-reference errors
  reported on close since tab content moved behind first paint
- Initialize-WinUtilInstallTabControls now does it from the tab build, guarded
- offline mode disables the install buttons from there too, for the same reason
- new test fails if the interface build touches any config-generated control

* Stop losing warnings and non-terminating errors from job bodies

- a worker buffers its warning and error streams on an object nobody reads:
  Write-Warning never reached the log, Write-Error reached nothing at all
- the job layer merges both into the log, so all 30+ Write-Warning and 4
  Write-Error sites in the helpers are visible without touching each one
- the interface runspace warning stream is drained on exit too
- $sync.LoggedErrors counts error events, detail lines excluded
- a job that logged errors without throwing now finishes as "N error(s)"
  with a warning overlay instead of a green checkmark

* Drive winget through Microsoft.WinGet.Client for real progress

The winget CLI hides its progress bar as soon as its output is redirected, so a
package could only ever be reported as started and finished. The module reports
progress and returns a structured result.

- Install-WinUtilWinGetClient installs and imports the module, cached per session
- Invoke-WinUtilWinGetCommand runs a cmdlet on a nested PowerShell and polls its
  progress stream, which cannot be redirected like output or errors
- percentages map into the package's slice of the job bar: "7zip.7zip - 1.9 MB / 1.9 MB"
- outcome comes from Status and InstallerErrorCode, not an exit code
- a package already present is upgraded, not reinstalled: Install-WinGetPackage
  re-downloads and re-runs the installer even without -Force
- detection uses Get-WinGetPackage and matches on name as well as id, so apps
  installed outside winget are recognised (Brave, and every other ARP entry)
- falls back to the command line unchanged when the module cannot be installed

Verified against real winget in the eval VM, 12 checks; 545 unit tests pass.

* Show the install phase as running instead of finished

Measured what the module actually emits: 7 progress records whether the package
is 1.9 MB or 57.8 MB, only two of them download samples, and the install phase
reports 0 then 100 with nothing between. On VLC the install is 4.3s of the 9.7s.

- the download gets the first half of the package's slice, so reaching 100%
  download no longer fills the bar
- the install phase pulses the bar and counts elapsed seconds in the label,
  because neither winget nor the module exposes installer progress
- scan the whole progress collection, not just its last record: a byte sample
  can be superseded within milliseconds
- RoundedProgressBarStyle gained an indeterminate trigger; it had none

Fixes uninstall reporting a package that is not installed as a failure, which is
what UninstallError after 354ms was, and adds ExtendedErrorCode to the detail.

* Say what a winget failure actually was

The command line prints a sentence for a failure; the client module returns only
an HRESULT, so the same failure read as "COMException (0x8A15007D)". Both report
the same number, so one table serves both paths.

- Get-WinUtilWinGetErrorMessage explains the codes WinUtil hits, and gives the
  hex plus the return-code reference for anything else
- 0x8A15007D now reads: installed for a single user, cannot be removed while
  running as administrator, remove it from Settings > Apps
- unsigned HRESULTs are wrapped rather than cast, which overflowed Int32
- a shared failure reason is repeated in the thrown message
- the banner wraps at 76 columns instead of drawing a box wider than the console

* Keep the run position in the progress text during a package

- the bar itself was already whole-workflow: 0-12, 25-37, 50-62, 75-87, 100
- but the status read "A.A - 50% downloaded", dropping the (n/total) the old
  per-package messages carried
- callers pass a label, so it now reads "A.A (1/4) - 50% downloaded"

* Pulse the progress bar in place instead of filling it

- IsIndeterminate makes WPF discard Value and stretch the indicator across the
  whole track: measured 398px of a 400px track at value 40, against 159px correct
- the pulse is driven by Tag instead, so the bar keeps the progress it reached
- RemoveStoryboard on exit, because Stop left the indicator at whatever opacity
  the pulse happened to be on

* perf: cut Install tab build and keep the interface answering during warmup

- look apps up by hashtable index, not dynamic member: Install tab app area 361ms -> 91ms
- cap a render pass at 25 apps so a large category cannot stall the interface
- yield between batches when building speculative tab content
- claim a tab as initialized before building it, so a click during a yield cannot double build
- time each step of a tab switch

* xaml: drop layout elements and styles that do nothing

- remove 8 single child wrappers from control templates, one per instance of every button, toggle and tweak switch
- delete unreferenced labelfortweaks and ScrollVisibilityRectangle styles
- verified pixel identical across all five tabs

* fix: bring the last workflows into the job layer

- upgrade all runs package by package on the worker instead of spawning a console
- PS profile setup runs pwsh with output captured, not a Windows Terminal tab
- resolve the PS7 profile path from pwsh, so remove targets the file install wrote
- treat winget exit 3010 and 1641 as success; a reboot requirement is not a failure
- drop the power plan success popups, the job layer already reports the result
- load PresentationFramework before a message box on a worker, and log if it cannot show
- probe optional commands with -ErrorAction so a missing choco does not throw
- refresh PATH after installing chocolatey

* fix: keep the runspace handle out of the console and the pipeline

- suppress the IAsyncResult Start-WinUtilJob got back, which printed a table on every button press
- test fails if any caller leaves Invoke-WPFRunspace unassigned

* fix: choco parity with winget, and prompts that cannot hang or assume consent

- choco runs one package per call, so progress moves and a failure names the package
- add an Upgrade action; upgrade all was building "choco install all", which is not a package
- explain a choco failure from its own output instead of reporting a bare exit code
- pass a progress slice to choco from install and uninstall, as winget already gets
- never show a message box without a window: a modal there never returns
- an unanswerable prompt answers No, so it can never stand in for consent
- uninstall requires an explicit Yes rather than the absence of a No

* feat: headless runs report and finish on their own

- progress goes to the console when there is no window, throttled so downloads do not bury it
- one entry point for preset and config; a preset can now be a baseline a config adds to
- apply selected toggles, which only ever applied themselves from the window
- exit code carries the outcome: 0 clean, 1 problems, 2 nothing selected
- elevation waits for the elevated run and hands its code back
- per step timeout, so an installer that never returns cannot hang the run for good
- a step that throws no longer abandons the remaining steps
- name an unrecognised config entry instead of failing on a null list, and ignore duplicates
- import with no window logs instead of throwing on a message box type it cannot load
- temp file cleanup skips files in use rather than reporting each as an error

* perf: stop the interface stalling while the app list loads

Measured with a new input-priority heartbeat: 2669 ms unresponsive across 18
stalls, worst 399 ms. Now 502 ms across 5, worst 151 ms, and nothing after the
first three seconds.

- cache app icons on disk and fetch the missing ones on a worker; assigning a
  remote address to an Image made WPF fetch and decode it, landing back on the
  interface thread whenever the network answered, for a minute after startup
- share the six app entry event handlers instead of building them per entry:
  3.18 ms to 1.36 ms per entry, measured
- slice rendering and tab building by a deadline rather than an entry count, so
  a slower machine cannot turn a batch into a stall
- yield while building the tab opened at startup, as the warmup already did
- reject a ParameterList that is a flattened pair; it silently ran the worker
  with two characters of the parameter name and did nothing
- add Start-WinUtilUIHeartbeat behind WINUTIL_TRACE_UI to measure any of this

* fix: build the other tabs before finishing the app list

Tab warmup was queued at idle priority while app rendering was queued at
background priority, so no tab was warmed until every render pass had run.
For the first few seconds every tab except the open one was empty, and
clicking one paid for its whole build: Tweaks measures 400-530 ms.

Verified from the log: all tabs were ready after 32 of 32 background steps
before, and after 5 of 32 now.

- warm tabs at background priority so they are not starved by the app list
- hold app rendering while any tab is still unbuilt
- stand aside for 400 ms after input, and skip drawing entries for a tab that
  is not on screen, resuming when it is
- report the whole latency distribution rather than only gaps over 60 ms; a
  thread kept busy by short pieces of work showed no stall while every
  interaction still waited behind the piece in flight

* fix: ask before closing over running work, and shut down in order

Closing while a job ran tore the worker pool down underneath it. A queued
instance then started on a runspace already in Closing, threw on a thread
pool thread where nothing catches, and ended the process with an unhandled
InvalidRunspaceStateException instead of exiting.

- ask whether to wait for the running job or stop it, and keep the window
  open if the close is cancelled
- track what is running so it can be stopped, and stop it before closing the
  pool rather than pulling the runspaces away from it
- refuse to queue new work once shutdown has begun
- close the window by itself once an awaited job finishes
- bound the wait, so a worker that cannot be stopped does not keep the window
  open for ever
- phrase the question so the buttons answer it in any language; Windows labels
  them itself and text naming them Yes and No does not match Ja and Nein
- treat a missing collection as empty; @($null) is a one element array, which
  read as one running item when nothing was running

* fix: let a running job finish in the console after the window is closed

Waiting kept the window open until the job ended, which is not what closing
it means. The window now goes at once and the run continues where it can
still be seen, ending the process when it is done.

- close the window immediately and leave the job on the worker pool
- skip the pool shutdown on that path; it would stop the work just kept
- wait for the job on the main thread, then close the pool and exit
- treat a shut down dispatcher as no window, so progress reaches the console
  instead of being posted to a dispatcher that drops it
- bound that wait, so a job that never returns cannot hold the process open
- guard the close post against a window that has already gone

* feat: pause button, and progress that rewrites its line

- add a pause button beside the progress bar; a run holds at the point every
  loop reports progress, since a command already started cannot be suspended
- hold only inside a job worker: whoever presses pause reports it through the
  same progress call and would otherwise wait on itself
- clear the pause once the body returns, or the finish reporting pauses too and
  the job stays marked running for ever
- release it when the window is closed over the job, since there is then no
  button left to resume with
- rewrite the console progress line in place rather than adding one line per
  update; a single install scrolled a screenful
- keep one line per update when output is redirected, where there is no cursor
  to move, and throttle harder there
- say goodbye where the process actually ends; closing can be declined or leave
  a job running

* feat: stop button for the running action

- add a stop button beside pause; it asks first, since stopping an install
  halfway is not undoable
- end the run at the same safe point a pause holds at, so anything already
  started finishes and nothing is cut in half
- report it as stopped rather than failed, through its own cancellation
- clear the stop before reporting it, or the report throws it again from inside
  the handler doing the reporting
- release a pause when stopping, or the run would never reach the point where
  it notices
- cut the worker off after a grace period, since a single long step reaches no
  safe point until it returns
- reset both flags per job, so a late stop cannot end the next run

* fix: use a stop glyph that does not look like a missing one

U+E71A is a hollow square at button size and reads as the empty box a font
shows for a code point it lacks. U+E73B is the filled square.

- add a test that every private use code point in the markup and the scripts
  exists in Segoe MDL2 Assets

* fix: icon buttons render in the icon font, not a fallback

A char assigned to Content is not laid out with the control's own font. It
falls back to whatever font claims the code point and is drawn in that font's
colour and metrics, which is why the pause icon came out teal and a different
size from the cross beside it. Add-SelectedAppsMenuItem already cast to string
for the same reason.

- cast every icon assignment to string: pause, play, the app entry popup and
  the theme button
- use the cancel cross for stop; a square is a blank block at button size
  whether it is filled or hollow
- test that no icon reaches Content as a char

* fix: the font scaling slider goes where it is clicked

WPF leaves IsMoveToPointEnabled off, so a click on the track pages by
LargeChange instead of moving the thumb to the pointer. LargeChange defaults
to 1.0, which over a range of 0.75 to 2.0 is most of the track, so clicking
anywhere toggled between 100% and 200%.

- follow the click, and page by one tick rather than a full unit
- test that every slider does both

* fix: draw the logo at the size it is asked for, and give the window its own icon

The rasteriser built the bitmap from the canvas rather than the requested
size, so every render came out 100 by 100 whatever was asked for: a 32px icon
was that downscaled, and anything larger was an upscale. The canvas was also
smaller than the artwork, whose paths run to about 108 by 110, so the right
and bottom edges were cut off. Between the two, the logo covered about a third
of the icon it was drawn into.

- rasterise from the geometry's real bounds into a bitmap of the requested
  size, centred and filling the frame
- keep the shapes in one place, so the control and the bitmap draw the same art
- set the window icon at the sizes the system reports for this display, small
  and large, instead of leaving Windows to scale one bitmap
- hold the icon handles for the life of the window and free the ones replaced

* fix: give the nav logo its square box back

Fitting the box to the artwork left no room around it. The logo is taller than
it is wide, so a square control filled by it edge to edge sat against the tab
buttons next to it.

- centre the artwork in a square box with a small margin, so the control fills
  the size it was given rather than the artwork's own proportions
- the bitmap form is unchanged and still fills the frame, which is what an icon
  wants

* refactor: one background queue, one UI-alive check, drop the stall tracer

- move the DPI window icon work out to feat/dpi-window-icon and revert it here
- drop Start-WinUtilUIHeartbeat: it found the startup stalls, it is not
  something a normal run should carry
- add Start-WinUtilBackgroundQueue and put tab warmup and app-entry rendering
  on it; they were the same pump written twice
- add Test-WinUtilUIAlive, replacing the same dispatcher guard hand-written
  eleven times in three spellings
- carry the queue name as the dispatcher's own argument, not a captured
  variable, so the posted step still resolves where it was written
- give Invoke-WinUtilWhenIdle an -Argument for the same reason
- load the WPF assemblies in preferences-theme tests instead of relying on
  logo-render having loaded them first

* docs: bring the agent rules in line with the job layer

Section 13 still pointed at Set-WinUtilTweaksProgressIndicator, which the job
layer removed, and said nothing about the pieces that replaced it.

- point the UI-helper rule at Write-WinUtilJobProgress and Test-WinUtilUIAlive
  instead of the deleted progress indicator
- say that long operations go through Start-WinUtilJob, and that a job body
  owns neither the busy flag, the banner, nor its own interface handling
- send deferred interface work through Start-WinUtilBackgroundQueue rather
  than a fourth hand-rolled pump
- record how values reach a posted scriptblock, and why a closure is the wrong
  answer: it carries the value but binds command lookup to a copied scope
- state that diagnostic scaffolding is measured with and then deleted
- require each Pester file to load what it needs; several passed only because
  an earlier file in alphabetical order had loaded it

* refactor: trim the package work back to what the pipeline needs

The winget client module earns its place: winget.exe hides its progress bar
once its output is redirected, so the CLI can never say how far along an
install is and the progress bar has nothing to show. What rode in behind it
did not.

- keep the module, the per-package progress and the result objects both
  managers now return; those are what the job layer reports from
- take the action from the caller instead of probing the machine first:
  Install, Uninstall and Upgrade each pick their own cmdlet and verb, matching
  the set Install-WinUtilProgramChoco already takes
- drop the Get-WinGetPackage probe with it, which cost a nested call per
  package and made the module path mean "install or upgrade" while the command
  line path still meant "install"
- send the upgrade workflow through -Action Upgrade, which is what it was
  always asking for
- revert Invoke-WinUtilCurrentSystem to the command line; reading which apps
  are installed has nothing to do with reporting progress
- cut the winget and choco error tables: the hex code and Microsoft's own list
  say the same thing without a copy to keep in step, and choco's reason is
  already in the output that gets logged

* test: drop the tests that assert on source text

Sixty-six tests read a function file and regex-matched its contents, so they
failed on edits that changed no behaviour: ten of them broke during a refactor
that only moved code between files. A test that pins how something is spelled
is an edit detector, not a test.

- remove the It blocks that Get-Content a .ps1 and match against it, and the
  Describe blocks left with nothing in them
- keep the ones that read source to check a set rather than a spelling, such as
  every WPF handler resolving to a defined function and every $sync member
  being declared; those catch a real mistake
- drop Get-WinUtilFunctionFile, which had no callers left

* fix: make the active job slot safe to claim and release

Three defects in the job layer's shared state, all of them races that the
dispatcher happened to hide.

- claim $sync.ActiveJob under the collection's own lock. Interface events are
  serialised by the dispatcher, but a headless run, a scheduled caller and a
  job body starting another job are not, and a test-then-assign there let two
  jobs both believe they owned the slot
- identify a run by token rather than by name. A worker the stop watchdog cut
  off can still be unwinding when the next job starts, and its finally block
  released the slot unconditionally, wiping the claim the next job had just
  made. Both the worker and the watchdog now release only what they still own
- rename Write-WinUtilJobProgress to Step-WinUtilJob. Write- in PowerShell
  means adds to a stream, and this blocks while paused and throws
  OperationCanceledException on stop. The trap was already live: the job layer
  has to clear both flags before its own finish reporting or that reporting
  re-raises the stop it is reporting

Also corrects the cross-runspace cost noted in the tests. Marshalling a
scriptblock is not "roughly twenty times" dearer; measured over 400 command
invocations it is 5354 ms against 3 ms rebuilt from text, because every command
the body invokes is resolved back through the originating runspace.

* refactor: drop the WinGet client module from this branch

The module is the one thing here that adds a runtime dependency: 53 MB from
PSGallery on first use, fetched at elevated privilege. It buys progress
movement inside a single package and nothing else. That is a different kind of
change from the rest of this branch, which only moves work between threads, and
it deserves to be judged on its own.

- remove Install-WinUtilWinGetClient and Invoke-WinUtilWinGetCommand
- collapse Install-WinUtilProgramWinget onto the command line path it already
  had underneath, keeping the per-package result objects, so a failed package
  still fails the job
- drop ProgressBase, ProgressSpan and Label from the winget path, which only
  existed to slice the bar inside one package. Chocolatey keeps its own: it
  reports per package from its own loop and never needed the module
- read upgradable packages from the winget command line output again

Kept on feat/winget-client-module, which branches from here.

* docs: follow the Write-WinUtilJobProgress rename in the architecture page

* fix: address the review findings on the job layer

Ten findings held up on inspection. The first two were breakage this branch
introduced.

- run a nested Start-WinUtilJob inline instead of refusing it. Install Features
  reaches the job layer twice, once through its feature.json entry and again
  from Invoke-WPFFeatureInstall, so the inner call was refused and features
  never installed
- read the package manager preference rather than ChocoRadioButton.IsChecked in
  Invoke-WPFInstallUpgrade, which runs on a worker where touching a control
  throws
- pass parameters and return values through the Invoke-WPFUIThread fallback.
  [action] carries neither, so Show-WinUtilMessage lost both its arguments and
  its answer whenever the dispatch delegate was not yet built
- register a shell after BeginInvoke, not before: a NotStarted instance looks
  finished to the pruning pass and could be dropped before shutdown saw it
- clear $sync.ActiveJobToken wherever the slot is released, through one
  Clear-WinUtilActiveJob helper. Clearing only the name left a token that could
  match a later run
- clear $global:WinUtilIsJobWorker when a worker finishes. Pool runspaces are
  reused, so the flag outlived the job that set it
- set the pause and stop button state through the dispatcher
- take a locked snapshot of $sync.ActiveShells before enumerating it, and create
  the collection under the lock
- stop the watchdog waiting on the interface thread. It issues the stop and
  watches later ticks instead of sleeping for up to ten seconds
- throw rather than return on the ISO failure paths, which the job layer was
  reporting as finished, and check exit codes in Invoke-WPFSystemRepair and
  Invoke-WPFUltimatePerformance so a failed step fails the job
- give the pause and stop buttons AutomationProperties.Name; their content is a
  private-use glyph

* fix: address the remaining review findings

Seven more held up. The first is the one that mattered most and this branch
introduced it.

- separate recycling a runspace pool from shutting down. Initialize-WinUtilRunspacePool
  replaces a pool that is no longer open by calling Close-WinUtilRunspacePool,
  which set $sync.ShuttingDown. Nothing resets it, so a single recycle made the
  job layer refuse every later action for the rest of the session
- bound the ISO mount wait at 60 seconds. The loop had no exit but success, and
  one job runs at a time, so a damaged or already-mounted image blocked every
  other action and the shutdown wait
- check robocopy exit codes in both ISO and USB workflows through one
  Invoke-WinUtilRobocopy. Codes of 8 and above mean files were not copied, which
  produced media that reported complete and did not boot
- read oscdimg stderr as it arrives. Draining stdout first and stderr afterwards
  deadlocks once the stderr pipe fills
- look for oscdimg on PATH, in both ADK roots and in the per-user WinGet package
  root, using the same search before and after the install attempt
- set the child error preference to Stop for the profile setup, and fail on
  captured error records. A non-terminating failure exited zero and was reported
  as installed
- confirm each id parsed out of the winget upgrade table against winget before
  upgrading it, and keep stderr out of the parse. The table is localised and
  truncated, so a wrapped version or a translated header matched the same shape

* fix: address the minor and nitpick review findings

The review body carried 38 findings beyond the 20 inline ones, in collapsed
sections. These are the ones that held up.

Real defects:
- the theme hook assigned to $handled instead of $handled.Value, so a
  WM_SETTINGCHANGE was never marked handled
- a declined UAC prompt left $elevated null and exited 0, which a headless
  caller reads as a successful run
- Start-Transcript ran before the log directory existed, so the first run failed
  before logging started
- Write-WinUtilLog appended even when the mutex wait timed out, which is the
  case with the most contention and the one that interleaves lines
- Wait-WinUtilRemainingWork took [int] minutes, so any fractional timeout
  truncated to zero and the bound meant "do not wait"
- the console progress throttle only applied when the text was unchanged, so a
  job reporting per package wrote on every call
- an undecodable icon response stayed in the cache and was skipped on every
  later run
- Start-WinUtilAssetRendering leaked its dedicated STA runspace; the cleanup
  callback now disposes a runspace it was given
- Invoke-WPFFixesWinget could not repair a broken WinGet, because
  Install-WinUtilWinget returns early when winget is detected. Added -Force
- winget's own reboot-required and reboot-initiated codes counted as failures

Smaller:
- set TLS 1.2 and a timeout before fetching the Chocolatey bootstrap
- let the headless queue drain survive one failing item, as the dispatcher path
  already does
- bind the idle timer to the interface dispatcher explicitly
- route the export message through Show-WinUtilMessage like the import branch
- suppress New-Item output that was reaching the job's output stream

Tests that could not fail:
- the bounded-wait test passed a fractional timeout that truncated to zero, so
  the wait never ran
- the slider tests cast possibly-absent attributes to [double], and compared two
  empty strings for the regression they exist to catch

* fix: startup crash and icons that only appeared once the list was drawn

Both were mine, and both were introduced while addressing review findings.

The crash. Register-WinUtilRunspaceCleanup compiles its helper type once and
guards that with a type-exists check, so a session already holding the old
shape keeps it. Adding a Runspace property to that type therefore failed on
every later run with "The property 'Runspace' cannot be found". The type is
back to exactly what it was, and the asset rendering runspace lives until the
process exits: one STA runspace for the lifetime of the app is the cheaper
trade against a helper type that cannot be changed safely in a session.

The icons. Start-WinUtilIconFetch ran from Complete-WinUtilInstallAppRendering,
so nothing was fetched until every entry had been drawn and no icon appeared
for the first several seconds. Upstream assigned a remote address per Image and
let WPF fetch them all at once, which is what made them appear promptly.

- fetch after each render batch instead, gated so one fetch runs at a time and
  whatever queues up meanwhile is taken when that fetch ends
- clear the gate when the runspace refuses the work, or no fetch would ever run
  again for the rest of the session
- request each wave together rather than one icon after another

Measured on a cold cache in a VM: first icons at 1.35s and all of them by 4.0s,
against nothing before roughly 7s previously.

* fix: address the second review pass

All three are in code this branch added.

- accept DISM exit code 3010 as success. It is ERROR_SUCCESS_REBOOT_REQUIRED,
  meaning the image was repaired and the change lands on restart, so the exit
  code check added in the last pass turned a successful repair into a failed
  job. Carried per step, since the same number from chkdsk or sfc does not mean
  that, and logged as a warning so the restart is not silent
- say what is happening on the WinGet repair path. Install-WinUtilWinget -Force
  runs while WinGet is installed, and printed "WinGet is not installed"
- append to PATH after installing Chocolatey rather than replacing it.
  Overwriting with the machine and user values drops whatever this process
  added earlier in the session

* fix: treat chkdsk's own exit codes as the outcomes they are

- chkdsk /scan reports 1 and 2 as ordinary results, so aborting the whole
  repair on them skipped sfc and dism for no reason
- 3 stays a failure: the disk could not be checked, and the later steps are
  not worth running on a disk in that state
- drive the decision off each step's SuccessCodes instead of a branch
  hard-coded to DISM's 3010, so the same number means what that step means
- cover chkdsk 0, 1, 2 and 3, and 3010 from the wrong step

* fix: address the Codex review findings on the job layer

- send WPFPanel buttons that carry a function through the job layer: the
  system corruption scan waited on chkdsk, sfc and dism on the interface
  thread, with nothing to pause, stop or report it
- stop a timed out headless step before starting the next one, and abandon
  the run if it will not stop, rather than changing the machine from two
  runs at once
- keep the stop watchdog's slot claimed until the worker has actually gone
- skip the install summary reset when the Install tab has not been built,
  which offline startup does by opening Tweaks first
- keep Win11ISO out of the tab warmup: building it runs the existing work
  check, which reports or prompts while the user is on another tab
- pass --include-unknown when upgrading, since the scan that found the
  packages used it

* fix: stop the tab warmup dying on a sync that is still growing

- Reset-WPFCheckBoxes enumerated $sync live while setting IsChecked, whose
  handlers add to $sync, and while the warmup was building controls into it;
  either one invalidated the enumerator and the warmup reported "Collection
  was modified"
- snapshot both loops
- cover it with a checkbox that grows $sync from its own handler, which
  reproduces the failure without the fix

* fix: shrink the pause and stop buttons and take them away when idle

- they were sized like the title bar icons, next to a 6px progress bar
- give them a size of their own and the smaller icon font, which font
  scaling still applies to
- collapsed unless a job is running: they were only ever disabled, so a
  finished or failed run left two dead buttons on screen
- the progress bar still stays to report how the run ended

* fix: colour the progress bar by how the run ended

- only the taskbar item carried the state, so a run that finished with
  errors left a full bar in the normal colour, reading as a clean finish
- the fill now follows the bar's Foreground, which the job layer points at
  an error or warning colour and back again
- by resource reference, so switching theme repaints it
- add error and warning colours to both themes

* fix: stop rendering the app navigation twice, which left Install dead

- upstream moved the app navigation render into Initialize-WPFUI, and this
  branch still rendered it beforehand, so it was built twice
- the second pass clears the target grid, and the "already wired" guard goes
  by name, so the replacement buttons counted as wired and never got a click
  handler: Install and Uninstall did nothing, with no error anywhere
- leave that render to Initialize-WPFUI

* fix: uninstall apps that belong to the signed in user

- WinGet answers APPINSTALLER_CLI_ERROR_ADMIN_CONTEXT_ACTION_PROHIBITED for
  anything installed in user scope while it is running elevated, and WinUtil
  is always elevated, so those uninstalls did nothing
- a process cannot drop its own elevation, so hand that one command to a
  scheduled task running as the interactive user at limited run level
- retry only on that code, so everything else is untouched

* style: keep the ISO variable names the file already used

- new scriptblock parameters were named in PascalCase, which put 20 lines
  into the diff that are otherwise identical to main
- nothing about the behaviour changes

* refactor: take the icon cache out of the job layer change

It is its own feature, not something consolidating background work needs.
Entries go back to assigning the favicon address to the image, as main does.

The work is kept on feat/install-icon-cache and follows once this lands.

* refactor: take pause and stop out of the job layer change

They are a new feature of their own, not part of consolidating background
work, and main has nothing like them today.

- drop the two buttons, their routing and the checkpoints that served them
- keep Stop-WinUtilActiveWork: closing the pool and the headless step
  timeout both need to end work, and neither is the user pressing a button

The work is kept on feat/job-pause-stop and follows once this lands.

* refactor: drop changes the job layer does not need

- the icon glyph strings are a rendering fix of their own: main renders a
  char, and whether that is right has nothing to do with background work.
  Reverted here, kept on fix/ui-glyph-strings
- trim the temp cleanup tweak to the part this change forces: errors have to
  be suppressed because the job layer counts a logged error as a failed
  step, but counting what was removed is unrelated polish

* refactor: cut comments and wrappers that were not earning their place

- the Recycle switch explained itself over four lines; two say what a reader
  needs to know
- drop a .PARAMETER that only restated the parameter name
- Unregister-WinUtilActiveShell had one caller and cost twenty lines, so it
  is inlined
- the running check was the same try/catch three times over, now one helper

* refactor: comments describe the code, not how it got here

- remove five comments left pointing at code the icon and pause splits took
  away, two of them sitting above unrelated lines
- cut the ones that recounted a bug rather than describing behaviour: the
  double rendered navigation, the dropped [action] parameters, the shell
  registered too early, the Add-Type shape
- shorten the rest to what a reader needs

* refactor: inline the winget error message at its only call site

- Get-WinUtilWinGetErrorMessage was 26 lines for a one line format string
- Its zero guard was dead: exit code 0 is handled by an earlier branch
- Drop the file and the two test dot-sources of it

* refactor: drop the user scope uninstall fix, it has its own branch

- Invoke-WinUtilUnelevated and the elevation retry are self contained
- They ship on fix/winget-user-scope-uninstall, which does not need the job layer
- Removes a file and 3 tests from a PR that is already large

* docs: tighten function help and comments

- Cut narrative framing from the job layer, queue and shutdown doc blocks
- Kept the technical reasons, dropped the restatements around them

* docs: drop comments that restate the code

- Consent check, powercfg exit codes, shortcut guard, rethrow, single reason
- Compact the UI thread helper's help

* fix: address async job layer review findings

* fix: harden job startup and dependency discovery

* fix: close remaining async review gaps

* fix: finalize shutdown and ISO recovery paths

* fix: guarantee job cleanup and error attribution

* fix: preserve job outcomes during cancellation

* fix: close async rendering and logging races

* fix: bound shutdown and surface UI startup failures

* fix: skip blocked user-scope install updates

* fix: address final PR review findings

* fix: warn on blocked user-scope package actions

* fix: propagate file-backed headless exit codes

* Address final async job review findings

* Resolve latest automated review findings

* Preserve clipboard history in activity tweak

---------

Co-authored-by: Chris Titus <contact@christitus.com>
2026-09-04 09:55:39 -05:00
eduardodepaiva 69ff04b632 feat(appx): add Movies & TV to appx.json (#5055)
That makes sense, since Media Player is on the list. Since it comes from the Microsoft Store, reinstalling it isn't a problem.
2026-09-03 13:18:26 -05:00
OmarandChris Titus 7ee5be3d2f feat: add automated environment report export (#5025)
* feat: add automated environment report export

* refactor: address code review comments

* fix: refresh toggle state in environment report

* fix: surface environment report read failures

* test: make registry failure explicit

---------

Co-authored-by: Chris Titus <contact@christitus.com>
2026-09-02 17:03:22 -05:00
Chris Titus 4a1db91e3f docs: update WinUtil title screen (#5052) 2026-09-02 16:14:47 -05:00
Omar 41e4f35897 Automate WinUtil title screen generation (#4965)
* feat: add title screen generation tooling

Add local automation for capturing WinUtil in Light and Dark themes
and generating the composite title-screen image.

* ci: automate title screen updates

Add a manual Windows workflow that compiles WinUtil, generates the
Light and Dark composite, and opens an image-only pull request.

* docs: update AGENTS.md and SPEC.md with title-screen generation details

* fix: address review comments

Tested image is correct, SHA-256 hash is identical to the previously verified output.

* fix: deselect bitmap before reading capture pixels
2026-09-02 16:10:44 -05:00
OmarandChris Titus d1a61790a1 Preserve clipboard history when disabling activity history (#5035)
* chore(tweaks): clarify Activity History tweak description

* fix(tweaks): preserve clipboard history

---------

Co-authored-by: Chris Titus <contact@christitus.com>
2026-09-02 16:05:02 -05:00
owenn 87dd643176 Use well-known SIDs for icacls tweak principals (#5045) 2026-09-02 15:14:56 -05:00
Malin Fossum 8f7b12bc0d Add tweak to block Logitech Download Assistant auto-install (#5034)
* Add tweak to block Logitech Download Assistant auto-install

Mirrors the WPFTweaksRazerBlock folder-deny approach: clear
C:\Program Files\LogiDownloadAssistant, recreate it empty, and deny
Everyone write access so the Windows Update software-component package
cannot re-deliver the payload. UndoScript removes the deny ACE.

Unlike the Razer tweak, no global SearchOrderConfig/DisableCoInstallers
registry changes: those alter driver search system-wide and do not stop
software-component packages.

Fixes #5029

* Harden Logi block tweak per review: SID, 64-bit path, exit codes

- Use the locale-independent *S-1-1-0 SID instead of the English
  "Everyone" name, which fails to resolve on non-English Windows.
- Resolve the 64-bit Program Files directory via ProgramW6432 with a
  ProgramFiles fallback, so a 32-bit host cannot target the x86 folder.
- Throw when icacls exits non-zero so Invoke-WinUtilScript logs the
  failure instead of reporting the tweak as completed.
- Skip the undo icacls call when the folder no longer exists.
2026-09-02 15:01:13 -05:00
catsmoker 88071f2849 Add File Converter application to multimedia tools (#5036) 2026-09-02 14:59:29 -05:00
OmarandChris Titus df858c4cd7 Inject Win11 Creator drivers per package instead of one batch (#5048)
* fix(iso): inject drivers per package

Add each root package folder separately so one bad driver cannot fail the rest. The batch form is roughly four times faster. That cost is accepted for one deterministic code path

* Preserve retained nested driver packages

* Discard partial driver injection attempts

* Remove excluded nested driver INFs

---------

Co-authored-by: Chris Titus <contact@christitus.com>
2026-09-02 12:09:18 -05:00
Chris Titus cc5e31460b Update sponsors in README (#5051) 2026-09-02 10:35:44 -05:00
Noah Webber d0f7689504 Kitchen sink changes (#5040)
* docs: fix typo in contributing image asset and markdown references

* docs: fix typos and grammatical phrasing in documentation

* fix(config): correct typographical errors in tweak and app descriptions

* fix(scripts): resolve typos and grammatical errors in PowerShell sources

* fix: address review feedback on Outlook description, WinUtil capitalization, and grammar
2026-09-02 10:14:51 -05:00
Omar 22e1dc9b09 Filter unserviceable and stale drivers before Win11 Creator injection (#5016)
* fix(win11-creator): filter unserviceable and stale drivers before injection

Add-Driver aborts the whole batch when one exported package is bad,
which broke ISO creation for anyone with an Extension-class or stale
duplicate driver in their store (#4971, #4982). Exclude both before
the single Add-Driver call instead of guessing per-vendor.

* fix(win11-creator): address driver filtering review feedback

Accept date-only DriverVer entries instead of treating them as
unknown. Use the full folder path as the dedup fallback key so two
unrelated packages can't collide on a shared leaf name. Discard the
logger's own output inside the selector so an emitting -Log callback
can't inflate the survivor count. Skip driver injection instead of
failing the whole ISO build when nothing is left to inject.

Also extracts the repeated DISM mock setup in the driver tests into
one shared harness, and asserts that excluded packages are actually
deleted from the export root before Add-Driver runs, not just logged.

* fix: address remaining code review comments
2026-09-02 10:11:20 -05:00
Ivan Lepekha c00f1eb7b3 feat(github): Prettify bug report template (#5050)
* feat(github): Prettify bug report template

* Let the people know that if they include anything private and share it, that makes them stupid, because AI told me to do that and that's NOT THAT OBVIOUS(sarcasm sign)
2026-09-02 10:10:01 -05:00
f54b22da2c feat(github): let people know about dotnet for sponsors (#5044)
* Money goes brrrrr

Added note about sponsors with recurring subscriptions.

* Update README.md

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Chris Titus <contact@christitus.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-31 16:00:42 -05:00
Chris Titus e15e2be1c4 Update sponsors in README (#5042) 2026-08-31 10:32:33 -05:00
Chris Titus 34f7ce5a0e Update sponsors in README (#5032) 2026-08-24 10:42:42 -05:00
Chris Titus 086aecf4b7 chore: Update generated dev docs and JSON links (#5014) 2026-08-19 17:24:59 -05:00
NikhilandChris Titus 436773df73 Fix legacy json import compatibility, UI sidebar refactor, and improved test coverage (#4911)
* Fix backward compatibility for old-style JSON config imports

* Refactor sidebar UI generation and add tests for Get-WinUtilVariables

* Fix appnavigation config test following UI generation refactor

* Restore global sync state in variables tests

* fix(impex): migrate supported legacy selections

Keep modern imports strict while allowing legacy backups to skip retired entries with clear logging and user feedback. Add fixtures covering partial and all-retired imports.

* test: strengthen UI and global state coverage

Exercise app category rendering through Initialize-WPFUI and restore global sync without leaking test state.

* fix(impex): ignore legacy metadata fields

Limit legacy selection migration to supported WPF key families so unrelated string metadata does not produce false retired-setting warnings.

* docs: clarify legacy config imports

Distinguish strict modern flat imports from partial legacy object migration, including single-setting export shape and historical groups.

---------

Co-authored-by: Chris Titus <contact@christitus.com>
2026-08-19 17:13:14 -05:00
4b2fe55a7a fix(system): correct service, update repair, and legacy imports (#4966)
* fix(system): correct sc.exe argument syntax, reg deletion, and ipv6 dns guard

* fix(impex): exclude legacy Install metadata from flattened import

* fix(service): throw on non-zero LASTEXITCODE from sc.exe config

* refactor(dns): revert unnecessary ipv6 guard per review feedback

* test(impex): cover legacy config imports

---------

Co-authored-by: WinUtil Contributor <contributor@winutil.local>
Co-authored-by: Chris Titus <contact@christitus.com>
2026-08-19 16:54:24 -05:00
Malin FossumandChris Titus b8b81edcdb Show preset JSON key in app, tweak, and feature tooltips (#4995)
* Add Get-WinUtilEntryToolTip helper for preset-key tooltips

* Show preset JSON key in app and tweak tooltips

* Match apps search against preset key for consistency with tweaks

* Limit preset-key tooltips to preset-representable controls

Comboboxes and package-manager radio buttons cannot appear in a preset
file. Update-WinUtilSelections routes a flat list of keys by the
WPFInstall/WPFTweaks/WPFToggle/WPFFeature/WPFAppx prefixes, so a preset
can carry neither a combobox selected value nor a radio group choice;
WingetRadioButton and ChocoRadioButton do not even carry a WPF prefix.
Advertising those control names as preset keys invited users to add keys
that fall through the switch and abort the whole import.

Revert the combobox label and radio button tooltips to the plain
description, and add tests that fail if the key is reattached to a
control the preset importer cannot accept.

* fix: address preset key review feedback

* docs: clarify preset key search scope

---------

Co-authored-by: Chris Titus <contact@christitus.com>
2026-08-19 16:26:11 -05:00
Ivan LepekhaandChris Titus a7074963a6 fix(apps): warp prettify (#5013)
* fix(apps): WARP prettify

Updated Cloudflare WARP entry with new key and modified description and link.

* Fine, Chris, let's leave property name as full

* Rename 'cloudflare-warp' to 'CloudflareWARP'

---------

Co-authored-by: Chris Titus <contact@christitus.com>
2026-08-19 16:11:04 -05:00
Ivan LepekhaandChris Titus 032ce8ead0 feat(github): revival of triage tools (#5007)
* feat(github): revival of triage tools

* fix(github): workflow doc fix

* fix(github): PR detection

* fix(gh): wontfix preserve bug label

Removed state_reason from issue update when adding wontfix label.

* fix(gh): doc cleaning

* one more thing

* fix(gh): prevent Gabi moment

That's the only thing I will agree with this piece of hardware

* Fix syntax error in triage-tools.yaml

* fix(github): restrict triage commands to issues

---------

Co-authored-by: Chris Titus <contact@christitus.com>
2026-08-19 16:03:06 -05:00
792122e998 fix: preserve selections when config import is invalid (#4994)
* fix: validate imports before replacing selections

* test: cover atomic config imports

* docs: explain stale config imports

---------

Co-authored-by: Guilherme de Oliveira Rocha <contatoguilhermeoliveira@protonmail.com>
Co-authored-by: Chris Titus <contact@christitus.com>
2026-08-19 15:30:46 -05:00
2d0fd43bd3 Add EmulationStation and Tailscale entries to applications.json (#4973)
* Add EmulationStation and Tailscale entries to applications.json

* Update config/applications.json

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update config/applications.json

I'm so sorry I'm late I've had to take a break from this for a bit of health related stuff.

Co-authored-by: Ivan Lepekha <57459428+FluffyPunk@users.noreply.github.com>

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Ivan Lepekha <57459428+FluffyPunk@users.noreply.github.com>
2026-08-19 14:04:14 -05:00
Manreet Singh 219b2fe137 Added Cloudflare WARP to pro tools (#5011) 2026-08-19 14:03:22 -05:00
Ivan Lepekha 527eefb114 feat(apps): Added Battle.net (#4996)
* feat(apps): Added Battle.net

Just Blizzard. Well, we can hate them, but their launcher is unique

* You know, being smart is a hard work...

* titusTypo
2026-08-19 14:02:30 -05:00
MyDrift d1b8470af2 Link apps to their own site instead of a repo or store page (#5010)
* fix: link apps to their own site instead of a repo or store page

- the icon comes from the link's domain, so every GitHub hosted app showed
  the same octocat and the two store apps showed the Microsoft Store tile
- point 16 apps at the project's own site, and LibreWolf at librewolf.net
  rather than its old gitlab.io address
- left on GitHub where the site turned out to redirect back to the repo
  (fnm, MSEdgeRedirect, Deskflow) or has no icon to serve (gsudo,
  simplewall, ungoogled-chromium, DISMTools)

* fix: point Teams at teams.live.com so it shows the Teams logo

- the marketing page on microsoft.com serves the generic Microsoft favicon,
  which Edge already uses

* fix: keep GlazeWM and Lua on the pages that match what is installed

- glazewm.com is not the project's own site: the repo declares no homepage,
  glzr.io never references it, and it runs on unrelated hosting
- lua.org is the language, while the package is Lua for Windows

* fix: keep Teams on the Microsoft product page

- the package is Microsoft.Teams, the work product, while teams.live.com is
  the consumer edition
2026-08-19 14:00:57 -05:00
Malin Fossum 6066675f93 Add accessible name to font scaling slider (#4993)
FontScalingSlider had no AutomationProperties.Name, so screen readers
announce it as an unnamed slider with only a raw numeric value.
Completes the accessible-name coverage of the font scaling popup.
2026-08-19 14:00:12 -05:00
Malin Fossum b21d416161 Add accessible names to remaining top-bar controls (#4992)
ThemeButton, FontScalingButton, SearchBar, and SearchBarClearButton had
no AutomationProperties.Name, so screen readers (Narrator, NVDA)
announce them as raw glyph text ("N/A", Unicode E8D3), an unnamed edit
field, and a button called "X". Completes the top-bar naming pass
started for SettingsButton and the window controls.
2026-08-19 13:59:45 -05:00
Abdullah Mansour 87a0eb8637 Fix location service startup type (#4972)
* fix(tweaks): use canonical location service startup type

* test(tweaks): pin location service startup type
2026-08-19 13:50:34 -05:00
Omar 8500ed116f Fix OSCDIMG detection (#4983)
* fix: detect machine-wide OSCDIMG installations

* fix: repeat OSCDIMG discovery after installation
2026-08-19 13:49:38 -05:00
Omar d16ee0e9f1 chore: remove fullscreen optimizations tweak (#5000) 2026-08-19 13:46:34 -05:00
Chris Titus 5ba8686dd7 Update sponsors in README (#5003) 2026-08-18 10:40:06 -05:00
Chris Titus 3f1850f623 Update sponsors in README (#4988) 2026-08-16 10:35:29 -05:00
Chris Titus 0dbe39bc7d Update sponsors in README (#4985) 2026-08-15 10:35:25 -05:00
Chris Titus 91972b0853 Update support section with new product information 2026-08-14 08:44:10 -05:00
dependabot[bot] aee3e7a1f4 chore(deps): bump js-yaml from 4.3.0 to 4.3.1 in /docs (#4970)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.0 to 4.3.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...4.3.1)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.3.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-11 17:55:18 -05:00
Sean (ANGRYxScotsman) ea5de27b6c Update issue templates with prefilled titles (#4960)
* Prefill title on bug report issue template

* Prefill title on feature request issue template

* Trim trailing space from issue template titles
2026-08-10 09:48:43 -05:00
Sean (ANGRYxScotsman) 2752fe2e03 updated title-screen pic (#4959) 2026-08-10 09:44:30 -05:00
Sean (ANGRYxScotsman) 1458327638 Containerize docs site tooling for security and update documentation (#4942)
* Containerize the docs site's npm tooling

Run Astro/Starlight dev, build, and preview commands through Docker
(docs/Dockerfile, docker-compose.yml, service winutil-astro) instead
of bare npm on the host, and document the required commands and
rationale in docs/README.md.

* Document Docker-only npm policy for agents

Add a Dependency Installs, Builds, And Dev Servers section to
AGENTS.md requiring docs/ tooling to run through Docker rather than
directly on the host, point SPEC.md's Docs Site section at the new
Dockerfile/docker-compose.yml, and renumber the remaining AGENTS.md
sections to stay sequential.

* Harden docs Docker dev environment

Tightened docs-container safety and clarified contributor workflow. The docs Docker image now switches to the non-root `node` user after setting ownership, and compose now binds Astro to `127.0.0.1` instead of all interfaces. Updated AGENTS and docs README instructions to explain the security boundary of the bind mount and to require rebuilding plus `docker compose down -v` after dependency changes so `node_modules` is reseeded correctly.

* Clarify docs secret handling in AGENTS

Updates AGENTS.md to tighten docs security guidance: secrets must not be stored anywhere under `docs/`, because `docs/.dockerignore` only affects image build context and does not protect files from the Docker Compose bind mount used for docs dev/build commands.

* Fix preview command to expose port in Docker

The previous preview command didn't expose the port outside the container. Adding --service-ports and binding to 0.0.0.0 makes the preview server accessible from the host.
2026-08-09 20:11:35 -05:00
Kai Davids Schell ea7fcf9d2b Per anthropic docs include agents.md with a direct import rather than instruction (#4958) 2026-08-09 20:06:01 -05:00
Graham DavidandChris Titus 0aa4ab3a40 Adding Mullvad to DNS (#4927)
* Update dns.json

* Fix Mullvad DNS review issues

* Add Mullvad secondary resolvers

* Register DoH before changing adapter DNS

* Report DNS failures to tweak workflow

* Handle non-terminating DNS assignment failures

* Preserve DNS fallback and document Mullvad options

---------

Co-authored-by: Chris Titus <contact@christitus.com>
2026-08-09 19:43:19 -05:00
makhlwf afc3e1eec2 Fix screen reader button accessibility and category navigation (#4944)
* Fix UI automation peer wrapping and category focusability

* Add accessibility name to SettingsButton

* Avoid nesting ScrollViewer in generated panels with outer viewers

Inspect targetGrid and its parent hierarchy in Invoke-WPFUIElements to avoid adding an inner ScrollViewer when an outer ScrollViewer already exists.
2026-08-09 18:02:53 -05:00
Chris Titus 53fc260cc0 Fix WinOneShot ComboItems compatibility (#4957)
* Fix WinOneShot ComboItems compatibility

* Align ComboItems validation with renderer
2026-08-09 16:47:45 -05:00
Omar bc607b6c91 Make MPO tweak a three-state control (#4897)
* refactor: make MPO tweak a three-state control

* docs: explain MPO tweak states

* docs: address code review comments

* refactor: make Multiplane Overlay config-driven
2026-08-09 14:02:42 -05:00
Vale_ThandFallenGME 7f18b4fd60 Add Roblox entry to applications.json (#4931)
Added The Game Roblox to the Application list.

Co-authored-by: FallenGME <125669256+FallenGME@users.noreply.github.com>
2026-08-09 13:19:23 -05:00
MyDrift 32ab9f0ab0 Turn the Install category filters into toggle chips (#4926)
* Turn the Install category filters into toggle chips

- Replace the filter buttons with toggles that show which ones are active
- Add ctrl click to select more than one category
- Keep the search box and the category filter independent of each other
- Expand matching categories while a filter is active

* Fix the filter interactions the category chips missed

- Pass the selected categories to the lazy rendering batches, the old call
  used a parameter that no longer exists and threw while typing
- Keep the category filter when switching tabs, the chips stayed checked
  while the filter itself was dropped
- Put a category back to collapsed once the filter that expanded it is gone

* Document the Install category filters

- Add a guide section for the chips, ctrl click and clearing the filter
- Note that search and the category chips apply together

* Correct the category filter guide wording

- Clearing by clicking the chip only applies when it is the only one selected
- Only categories with matches expand, and only auto expanded ones re-collapse
2026-08-09 13:13:21 -05:00
Ashvin 9fdadd1c8f Point OpenSSH key setup at the file sshd actually reads (#4936)
* Point OpenSSH key setup at the file sshd actually reads

The Remote Access feature created %USERPROFILE%\.ssh\authorized_keys and
told the user to put their public keys there. sshd does not read that
file for a member of the administrators group; the "Match Group
administrators" block in sshd_config sends those logons to
C:\ProgramData\ssh\administrators_authorized_keys instead. WinUtil always
relaunches itself elevated, so the account it was setting up is always an
administrator, and key auth for it never worked.

The function tried to work around that by commenting the block out, but
those regexes anchor on $, and .NET puts $ before the \n of a CRLF pair.
The sshd_config Windows ships is CRLF throughout, so the replace was a
silent no-op on a stock install. On an sshd_config with LF endings it did
apply, and that is worse than not working: sshd gives an administrator
logon a full token with no UAC prompt, which is why Windows keeps those
keys in ProgramData behind an ACL that requires elevation to write.
Moving the lookup into the profile lets anything running as the user at
medium integrity append a key and get an elevated shell unprompted.

Use administrators_authorized_keys and give it the ACL sshd requires
(inheritance off, Administrators and SYSTEM only, by SID so localized
installs work). Where the sshd_config edit did land, undo it and copy any
keys out of the profile file first so key auth is not cut off mid-session.
Keys are only copied when the block needs restoring, so a default config
never grants access sshd was not already granting.

Also stop creating the profile .ssh directory: under elevation it was the
elevating administrator's profile, not necessarily the caller's.

* Document where to put SSH keys for the OpenSSH server feature
2026-08-09 13:08:33 -05:00
Phuc To 6de45a38b9 Fix UI helper errors during headless -Preset and -Config runs (#4941)
* Make UI helpers no-op when no window exists

The -Preset and -Config paths run Invoke-WinUtilAutoRun before the XAML
form is created and before PresentationCore is loaded, so every call into
Invoke-WPFUIThread failed with InvokeMethodOnNull and every call into
Set-WinUtilTweaksProgressIndicator failed to resolve [Windows.Visibility].
The errors were non-terminating, so tweaks still applied, but each run
filled the log with noise.

Loading presentationframework earlier does not help: Visibility lives in
PresentationCore, which only loads once a WPF object is instantiated, and
the XAML-named progress controls do not exist in these paths either.
Guarding the two helpers covers Invoke-WPFtweaksbutton, Invoke-WPFundoall
and Invoke-WPFFeatureInstall, which the existing per-call-site $hasUI
convention never reached.

* Suppress stray console output from automation runs

Invoke-WPFRunspace returns an IAsyncResult that no caller uses, and
Set-WinUtilRegistry was the only New-PSDrive call site that did not
suppress its output. A GUI click handler discards both, but the -Preset
and -Config paths call the workflows directly, so an async handle dump
and a PSDrive table landed in the user's log.

The handle itself is kept because pester/runspace.Tests.ps1 asserts that
Invoke-WPFRunspace returns a single IAsyncResult, so the suppression goes
at the four call sites reachable from Invoke-WinUtilAutoRun.

* Add project learning for window-free UI helpers
2026-08-09 13:06:17 -05:00
Vinicius Costa Amorim 8860caf357 fix: apply imported config selections to lazily-built tabs (#4938)
Tabs other than Install build their checkboxes lazily on first visit.
Import populates $sync.selected* and calls Reset-WPFCheckBoxes, but
that only touches checkboxes that already exist in $sync, so any tab
not yet visited gets its controls built later with default (unchecked)
state and never reflects the import. Re-apply Reset-WPFCheckBoxes right
after a tab's controls are built so it picks up existing selections.
2026-08-09 12:59:13 -05:00
PChaicotandChris Titus 8fb078da4c Add foobar2000 back into applications.json (#4932)
* Add foobar2000 back

* Add Fast Node Manager entry to applications.json

---------

Co-authored-by: Chris Titus <contact@christitus.com>
2026-08-09 12:57:25 -05:00
Vinicius Costa Amorim 95e3a54405 feat: add mpv to Multimedia Tools app list (#4940)
mpv has no official Windows installer; winget id shinchiro.mpv is the
de facto standard Windows build. Choco mpv/mpv.install are flagged
"Possibly broken" upstream, so winget is the sole install source.
2026-08-09 12:53:11 -05:00
Sean (ANGRYxScotsman) c817d3329a Enhance SEO and social metadata for documentation homepage (#4923)
* Add SEO metadata to docs home

Add a robots.txt sitemap directive and JSON-LD SoftwareApplication metadata to the docs homepage to improve search engine discovery and rich results.

* Add social preview metadata

Add Open Graph and Twitter image metadata for the docs site and include a new shared social preview image. Also set the homepage title metadata to match the documentation branding.
2026-08-09 12:48:15 -05:00
Omar 19f938e876 feat: restore useful development applications (#4930) 2026-08-09 12:45:53 -05:00
Vyas Devgna d64bb18717 docs: correct Win11 Creator behavior after the ISO workflow refactor (#4920)
* docs: correct Win11 Creator behavior after the ISO workflow refactor

The creator stopped stripping unused editions, loading offline registry
hives, and running component store cleanup in #4862. It now copies the
ISO contents and applies the selected edition through sources\ei.cfg and
autounattend.xml, leaving install.wim alone unless drivers are injected.

Update the guide and the architecture reference to match, and note that
the output ISO is close to the size of the source.

* docs: align disk space, driver injection, and stuck-run guidance

The disk space block still carried the pre-refactor 10-15 GB workspace,
2.5-3.5 GB output, and ~25 GB total, which contradicted the copy-based
numbers in the data flow. Restate them against the source ISO size.

Driver injection never touches boot.wim. It adds every exported driver
to the selected install.wim index and stages only the storage packages
in $WinpeDriver$ for WinPE, so correct both the guide and the reference.

The stuck-run troubleshooting entry pointed at a WIM dismount that only
happens when driver injection is enabled.

* docs: narrow this PR back to the edition and output size claims

The earlier commits rewrote the surrounding Win11 Creator sections,
which went well beyond what #4918 was about and presented the rest of
the section as reviewed when it had not been.

Restore the untouched text and keep only the claims that promised a
size reduction or edition removal: the strip-editions and component
store bullets, the two data flow lines, and the modified ISO size.
2026-08-09 12:45:14 -05:00
Chris Titus 5ad168e0c4 Create winoneshot-compat.Tests.ps1 2026-08-09 12:27:07 -05:00
8d3adb599f Replace the FOSS dot with a corner badge on app entries (#4924)
* Replace FOSS dot with a corner badge on app entries

- Add New-WinUtilFossBadge, the open source keyhole on a green backdrop
- Mark FOSS apps with a corner triangle instead of a dot after the name
- Give the FOSS legend a circle badge and move it below the buttons
- Sort Note entries last, they shared a rank with checkboxes before

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Update functions/private/Initialize-InstallAppEntry.ps1

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-06 12:51:58 -05:00
Omar f7c072341d Fix USB creation when install.wim is read-only (#4910)
* fix: allow USB WIM splitting

* test: guard USB WIM splitting fix
2026-08-04 16:01:12 -05:00
FallenGME 32cc623959 Teamspeak6 Added (#4915)
TS 6 is obv. better than TS3
2026-08-04 16:00:19 -05:00
Sean (ANGRYxScotsman) b096aaa5e5 Centralize agent instructions in SPEC (#4912)
* Centralize agent instructions in SPEC

Move the full coding-agent contract and repository guidance from `AGENTS.md` into `SPEC.md`, and reduce `AGENTS.md` to a simple pointer to the canonical instructions. Add lightweight `CLAUDE.md`, `GEMINI.md`, and `.github/copilot-instructions.md` files so different assistants consistently bootstrap through the same repo-specific guidance.

* Clarify auto-generated docs path scope in SPEC

Narrowed rule #3 to specify exact auto-generated subdirectories (tweaks/ and features/) rather than the entire code-reference/ directory, and clarified that other hand-written pages (e.g. architecture.mdx) may be edited directly.

* Remove archived docs from gitignore

Drop the old `docs-old` Hugo ignore rules from `.gitignore`, reflecting that the archived docs build artifacts no longer need special handling there.

* Add local env files to docs .gitignore

Add .env.local and .env.*.local patterns to prevent local environment files from being committed.

* Clarify test and git hygiene guidance

Update `SPEC.md` to run Pester in CI mode and tighten repository hygiene instructions around ignored files. The git guidance now points contributors to the actual `.gitignore` files and clarifies that `docs/public/` contains tracked static assets rather than generated output.

* Split agent workflow from project spec

Move repository working instructions into `AGENTS.md` and refocus `SPEC.md` on the stable WinUtil project contract. This separates agent-specific guidance from architecture, build, runtime, docs, testing, and release details so both documents have clearer ownership.

* docs: clarify Pester, ScriptAnalyzer, and source-of-truth rules

Expand AGENTS.md with context on why -SkipPublisherCheck is needed for Pester installation, why winutil.ps1 should be deleted before running ScriptAnalyzer, and clarify that the source-of-truth rule applies only to compiled-script behavior — repository metadata files are edited directly.

* Clarify Pester install command and -SkipPublisherCheck reason

Expands the explanation for why -SkipPublisherCheck is needed (catalog-signed vs Authenticode-signed), clarifies it does not skip download integrity, and adds -Repository PSGallery to pin the trusted source explicitly.
2026-08-04 15:29:49 -05:00
206 changed files with 16320 additions and 4285 deletions
+4 -1
View File
@@ -3,4 +3,7 @@
# Seanh1917 (ANGRYxScotsmans) is the docs guy
docs/ @ChrisTitusTech @seanh1995
tools/devdocs-generator.* @ChrisTitusTech @seanh1995
tools/devdocs-generator.* @ChrisTitusTech @seanh1995
# Title screen generation
tools/title-screen/ @ChrisTitusTech @mewclouds
+1 -1
View File
@@ -91,7 +91,7 @@ graph TD
* Run the following command to compile and run WinUtil:
* `.\Compile.ps1 -run`
![Compile](/docs/src/assets/contributing/Complie.png)
![Compile](/docs/src/assets/contributing/Compile.png)
Open PowerShell as Administrator.
+7 -3
View File
@@ -1,5 +1,6 @@
name: "Bug report"
description: "Report a bug to help us identify and fix issues in the project."
title: "[Bug Report] - <your summary here>"
labels: ["bug"]
body:
@@ -42,14 +43,16 @@ body:
- type: textarea
id: issue_description
attributes:
label: Provide a clear and concise description of the issue.
label: "Description of the issue"
description: Provide a clear and concise description of the issue.
validations:
required: true
- type: textarea
id: tweaks_applied
attributes:
label: List the tweaks you applied before the issue occurred.
label: "List of Applied Tweaks"
description: List the tweaks you applied before the issue occurred.
validations:
required: false
@@ -57,4 +60,5 @@ body:
id: error_output
attributes:
label: Paste the full error output (if available) or Screenshot or Video.
placeholder: "Include any relevant logs or error messages."
description: "If you're willing to share the whole log, please provide an external link to text-sharing service, i.e. Pastebin"
placeholder: "Include any relevant logs or error messages. Keep any sensitive info out of the presented log"
@@ -1,5 +1,6 @@
name: "Feature request"
description: "Suggest a new feature or improvement for the project."
title: "[Feature Request] - <your summary here>"
labels: ["enhancement"]
body:
+3
View File
@@ -0,0 +1,3 @@
# Copilot Instructions
Read `AGENTS.md` in the repository root for operating instructions before doing anything else.
@@ -0,0 +1,207 @@
name: Generate WinUtil title screen
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: winutil-title-screen
cancel-in-progress: true
jobs:
generate:
runs-on: windows-latest
timeout-minutes: 15
env:
WINUTIL_CAPTURE_WIDTH: "1920"
WINUTIL_CAPTURE_HEIGHT: "1080"
defaults:
run:
shell: pwsh
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: main
persist-credentials: false
- name: Install uv and Python
uses: astral-sh/setup-uv@v10.1.0
with:
version: "0.12.0"
python-version: "3.13"
enable-cache: false
- name: Set display resolution
run: |
# Set-DisplayResolution is provided by Windows PowerShell's ServerCore
# module, so this step intentionally calls powershell.exe from pwsh.
& powershell.exe -NoLogo -NoProfile -Command @'
Set-DisplayResolution `
-Width $env:WINUTIL_CAPTURE_WIDTH `
-Height $env:WINUTIL_CAPTURE_HEIGHT `
-Force
'@
if ($LASTEXITCODE -ne 0) {
throw "Failed to set the runner display resolution."
}
Add-Type @"
using System.Runtime.InteropServices;
public static class ResolutionCheck {
[DllImport("user32.dll")]
public static extern int GetSystemMetrics(int index);
}
"@
$actualWidth = [ResolutionCheck]::GetSystemMetrics(0)
$actualHeight = [ResolutionCheck]::GetSystemMetrics(1)
if (
$actualWidth -ne [int]$env:WINUTIL_CAPTURE_WIDTH -or
$actualHeight -ne [int]$env:WINUTIL_CAPTURE_HEIGHT
) {
throw (
"The hosted runner rejected the requested resolution. " +
"Requested: $env:WINUTIL_CAPTURE_WIDTH" +
"x$env:WINUTIL_CAPTURE_HEIGHT; " +
"actual: ${actualWidth}x${actualHeight}."
)
}
- name: Report display environment
run: |
Add-Type @"
using System.Runtime.InteropServices;
public static class DisplayInfo {
[DllImport("user32.dll")]
public static extern int GetSystemMetrics(int index);
}
"@
$width = [DisplayInfo]::GetSystemMetrics(0)
$height = [DisplayInfo]::GetSystemMetrics(1)
$sessionId = [Diagnostics.Process]::GetCurrentProcess().SessionId
Write-Host "Resolution: ${width}x${height}"
Write-Host "Session ID: $sessionId"
Write-Host "User: $env:USERNAME"
- name: Compile WinUtil
run: |
Set-ExecutionPolicy Bypass -Scope Process -Force
./Compile.ps1
- name: Launch WinUtil
run: |
$scriptPath = Join-Path $env:GITHUB_WORKSPACE "winutil.ps1"
$command = "& '$scriptPath'"
$bytes = [Text.Encoding]::Unicode.GetBytes($command)
$encodedCommand = [Convert]::ToBase64String($bytes)
# Hide the console host while leaving the WPF window available.
$process = Start-Process powershell.exe -ArgumentList @(
"-NoLogo",
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-EncodedCommand", $encodedCommand
) -WindowStyle Hidden -PassThru
"WINUTIL_HOST_PID=$($process.Id)" |
Out-File $env:GITHUB_ENV -Append -Encoding utf8
$deadline = (Get-Date).AddSeconds(90)
do {
Start-Sleep -Seconds 2
# A versioned title excludes the unversioned console host.
$window = Get-Process |
Where-Object { $_.MainWindowTitle -match '^WinUtil\s+\d' } |
Select-Object -First 1
} until ($window -or (Get-Date) -ge $deadline)
if (-not $window) {
throw "WinUtil did not expose a window within 90 seconds."
}
Write-Host "WinUtil HWND: $($window.MainWindowHandle)"
Write-Host "WinUtil title: $($window.MainWindowTitle)"
# The exact HWND avoids desktop-enumeration ambiguity. Python validates
# the title and WPF class before using it.
"WINUTIL_HWND=$($window.MainWindowHandle)" |
Out-File $env:GITHUB_ENV -Append -Encoding utf8
- name: Generate title screen
working-directory: tools/title-screen
run: |
& uv run --locked python automate_title_screen.py `
--output ..\..\docs\src\assets\branding\title-screen.png 2>&1 |
Tee-Object -FilePath "$env:RUNNER_TEMP\title-screen-capture.log"
if ($LASTEXITCODE -ne 0) {
throw "Title-screen automation exited with code $LASTEXITCODE."
}
- name: Create title-screen update pull request
id: cpr
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.AUTO_MERGE }}
add-paths: docs/src/assets/branding/title-screen.png
base: main
branch: title-screen-update
delete-branch: true
commit-message: "docs: update WinUtil title screen"
title: "docs: update WinUtil title screen"
body: |
Regenerates the WinUtil Light and Dark title-screen composite from the current main branch.
Source workflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
labels: |
automated
documentation
skip-changelog
- name: Report pull request
if: steps.cpr.outputs.pull-request-url
env:
PR_OPERATION: ${{ steps.cpr.outputs.pull-request-operation }}
PR_URL: ${{ steps.cpr.outputs.pull-request-url }}
run: |
Write-Host "Pull request $env:PR_OPERATION`: $env:PR_URL"
- name: Inspect UI Automation on failure
if: failure()
continue-on-error: true
working-directory: tools/title-screen
run: |
uv run --locked python inspect_winutil.py `
"$env:RUNNER_TEMP\winutil-title-screen-inspect.txt"
- name: Upload failure diagnostics
if: failure()
uses: actions/upload-artifact@v7
with:
name: winutil-title-screen-failure-${{ github.run_number }}
path: |
docs/src/assets/branding/title-screen.png
${{ runner.temp }}/title-screen-capture.log
${{ runner.temp }}/winutil-title-screen-inspect.txt
if-no-files-found: warn
retention-days: 14
- name: Close WinUtil
if: always()
run: |
if ($env:WINUTIL_HOST_PID) {
Stop-Process `
-Id ([int]$env:WINUTIL_HOST_PID) `
-Force `
-ErrorAction SilentlyContinue
}
+212
View File
@@ -0,0 +1,212 @@
name: Repo Contributors' Issue Triage Tools
on:
issue_comment:
types: [created, edited]
jobs:
triage-tools:
if: ${{ !github.event.issue.pull_request }}
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: none
contents: none
steps:
- name: Process issue command
uses: actions/github-script@v9
with:
script: |
const trustedUsers = [
7896101, // ChrisTitusTech
57459428, // FluffyPunk
125669256, // FallenGME
90123670, // mewclouds
121827219, // MyDrift-user
17331812, // Callum
101426328, // CodingWonders
70659536 // og-mrk
];
if (context.payload.issue.pull_request) {
console.log("Pull request comments are not supported. Exiting.");
return;
}
const comment = context.payload.comment;
const commentAuthor = comment.user;
if (!trustedUsers.includes(commentAuthor.id)) {
console.log(`Comment author ${commentAuthor.login} is not a trusted user. Exiting.`);
return;
}
const {owner, repo} = context.repo;
const issueNumber = context.issue.number;
const command = comment.body.trim().toLowerCase();
const triageMatch = command.match(/^\/triage$/);
const triageOffMatch = command.match(/^\/triageoff$/);
const notPlannedMatch = command.match(/^\/np(?:\s+(\w+))?$/);
const duplicateMatch = command.match(/^\/duplicate\s+#?(\d+)$/);
if (triageMatch) {
const issue = await github.rest.issues.get({
owner,
repo,
issue_number: issueNumber
});
if (issue.data.labels.some(label => label.name === "needs-triage")) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: "This issue already has the 'needs-triage' label. Use /triageoff to remove it."
});
} else {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: ["needs-triage"]
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `${commentAuthor.login} requested maintainer triage for this issue.`
});
}
} else if (triageOffMatch) {
const issue = await github.rest.issues.get({
owner,
repo,
issue_number: issueNumber
});
if (issue.data.labels.some(label => label.name === "needs-triage")) {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: issueNumber,
name: "needs-triage"
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `${commentAuthor.login} removed the triage request from this issue.`
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: "This issue is not currently marked for triage."
});
}
} else if (notPlannedMatch) {
const reason = notPlannedMatch[1];
if (reason === "wontfix") {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: ["wontfix"]
});
} else if (reason === "notrelated") {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: ["not-related"]
});
}
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: "closed",
state_reason: "not_planned"
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `${commentAuthor.login} closed this issue as not planned.`
});
} else if (duplicateMatch) {
const duplicateIssueNumber = Number(duplicateMatch[1]);
if (!Number.isSafeInteger(duplicateIssueNumber) || duplicateIssueNumber < 1) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: "The duplicate target must be a positive issue number."
});
} else if (duplicateIssueNumber === issueNumber) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: "An issue cannot be marked as a duplicate of itself."
});
} else {
let duplicateIssue;
try {
const response = await github.rest.issues.get({
owner,
repo,
issue_number: duplicateIssueNumber
});
duplicateIssue = response.data;
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
if (duplicateIssue?.pull_request) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `#${duplicateIssueNumber} is a pull request, not an issue.`
});
} else if (duplicateIssue) {
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: "closed",
state_reason: "duplicate",
duplicate_issue_id: duplicateIssue.id
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `${commentAuthor.login} closed this issue as a duplicate of #${duplicateIssueNumber}.`
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `Issue #${duplicateIssueNumber} does not exist.`
});
}
}
} else {
console.log("Comment does not contain a supported issue command. Exiting.");
return;
}
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
+3 -5
View File
@@ -10,11 +10,9 @@ testResults.xml
# general software/os specific
desktop.ini
.DS_Store
__pycache__/
*.pyc
.venv/
.vscode/
.idea/
# hugo files (archived docs)
docs-old/public/
docs-old/.hugo_build.lock
docs-old/resources/
+56 -60
View File
@@ -4,35 +4,22 @@ Drop-in operating instructions for coding agents. Read this file before every ta
**Working code only. Finish the job. Plausibility is not correctness.**
This repository follows the AGENTS.md convention: these instructions are for Codex, Claude Code, Cursor, Windsurf, Copilot, Aider, Devin, Amp, and other coding agents that read `AGENTS.md`.
`SPEC.md` in the repository root is the project contract — read it for what WinUtil is and how it's architected. This file covers how to work on it.
## 0. Non-Negotiables
These rules override everything else in this file when in conflict:
1. **Do not edit `winutil.ps1` directly.** It is generated build output. Change source files and compile.
1. **Do not edit `winutil.ps1` directly.** It is generated build output (see SPEC.md's Build Model). Change source files and compile.
2. **Do not commit `winutil.ps1`.** It is ignored locally and generated by GitHub Actions for releases.
3. **Never fabricate.** Do not invent file paths, function names, command output, test results, commit hashes, or API behavior. Read the file or run the command.
4. **Disagree when the premise is wrong.** Say what is wrong before acting on it.
5. **Stop when genuinely ambiguous.** If two interpretations would produce materially different diffs, ask before editing.
6. **Touch only what the task requires.** No drive-by refactors, formatting sweeps, or unrelated cleanup.
7. **Verify before saying done.** A plausible-looking diff is not proof.
3. **Never touch `docs/src/content/docs/code-reference/tweaks/` or `docs/src/content/docs/code-reference/features/`.** Both are auto-generated (see SPEC.md's Docs Site). Edit the source JSON (`config/tweaks.json`, `config/feature.json`) or the relevant PowerShell function file instead. Other hand-written pages under `code-reference/` (e.g. `architecture.mdx`) are not touched by the generator and may be edited directly.
4. **Never fabricate.** Do not invent file paths, function names, command output, test results, commit hashes, or API behavior. Read the file or run the command.
5. **Disagree when the premise is wrong.** Say what is wrong before acting on it.
6. **Stop when genuinely ambiguous.** If two interpretations would produce materially different diffs, ask before editing.
7. **Touch only what the task requires.** No drive-by refactors, formatting sweeps, or unrelated cleanup.
8. **Verify before saying done.** A plausible-looking diff is not proof.
## 1. Project Context
WinUtil is a Windows PowerShell utility with a WPF interface. The repository is maintained as modular source, but the distributed artifact is one compiled PowerShell script.
### Stack
- Language: Windows PowerShell / PowerShell.
- UI: WPF via `xaml/inputXML.xaml`.
- Configuration: JSON files under `config/`.
- Tests: Pester tests under `pester/`.
- Lint: PowerShell Script Analyzer with settings in `lint/PSScriptAnalyser.ps1`.
- Docs: Hugo site under `docs/`.
- Release artifact: generated root `winutil.ps1`.
### Key Commands
## 1. Key Commands
- Compile:
```powershell
@@ -42,48 +29,47 @@ WinUtil is a Windows PowerShell utility with a WPF interface. The repository is
```powershell
.\Compile.ps1 -Run
```
- Install the supported Pester version (one-time). `-SkipPublisherCheck` is required because Windows ships an inbox Pester 3.4.0 that is catalog-signed, and PowerShell Gallery's Pester 5.8.0 is Authenticode-signed — `Install-Module` refuses the upgrade without it. This does not skip download integrity (still HTTPS + NuGet package hash verification); `-Repository PSGallery` pins the trusted source explicitly rather than relying on whatever repositories happen to be registered:
```powershell
Install-Module -Name Pester -RequiredVersion 5.8.0 -Repository PSGallery -Scope CurrentUser -Force -SkipPublisherCheck
```
- Run tests:
```powershell
Import-Module Pester -RequiredVersion 5.8.0 -Force
Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed
Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed -CI
```
- Run Script Analyzer with project settings when available:
- Run Script Analyzer with project settings when available. If a locally compiled `winutil.ps1` exists, delete it first — `lint/PSScriptAnalyser.ps1` only excludes rules, not files, so `-Recurse` would also lint the generated script and produce noise against line numbers that don't map to any source file:
```powershell
Invoke-ScriptAnalyzer -Path . -Settings .\lint\PSScriptAnalyser.ps1 -Recurse
```
- Docs site dev server (run from `docs/`; see Section 2 for why this goes through Docker):
```powershell
docker compose up winutil-astro
```
- Docs site production build (run from `docs/`):
```powershell
docker compose run --rm winutil-astro npm run build
```
Prefer the narrowest useful verification while iterating. Use the full relevant check before finishing.
## 2. Source Of Truth
## 2. Dependency Installs, Builds, And Dev Servers
Make durable changes only in files consumed by `Compile.ps1` or in documentation/test files:
Given the current wave of npm/pnpm/yarn supply-chain worms (malicious postinstall/preinstall scripts, credential-stealing packages): **never run npm/pnpm/yarn/npx directly on the host, full stop.** The docs site (`docs/`) is the only npm-based project in this repo; always run its tooling inside Docker via `docs/Dockerfile` and `docs/docker-compose.yml` (service `winutil-astro`).
- `scripts/start.ps1` for startup/bootstrap code.
- `functions/public/*.ps1` for UI-facing and user-facing workflows.
- `functions/private/*.ps1` for internal helpers.
- `config/*.json` for applications, tweaks, features, DNS, presets, navigation, themes, and related declarative data.
- `xaml/inputXML.xaml` for the WPF UI layout.
- `tools/autounattend.xml` for the embedded unattended Windows setup template.
- `scripts/main.ps1` for the final entrypoint and GUI initialization logic appended during compile.
- `pester/*.Tests.ps1` for automated checks.
- `docs/` for Hugo documentation.
- Never run `npm install`, `npm run <script>`, `npx <pkg>`, `pnpm`, or `yarn` directly on the host shell in `docs/`. Use `docker compose run --rm winutil-astro <command>` / `docker compose up winutil-astro` instead (see Section 1 for the exact commands).
- If a task needs a new docs dependency, add it to `docs/package.json` yourself, then rebuild the image and drop the `node_modules` volume so it repopulates from the new image (run from `docs/`): `docker compose build winutil-astro`, then `docker compose down -v`. Docker only seeds a named volume from the image the first time it's created, so a plain rebuild silently leaves the old `node_modules` in place. Don't install packages on the host, even temporarily, "just to check something."
- If Docker isn't available on the host, propose the install command for the current OS and wait for confirmation before running it — don't fall back to running npm on the host instead. If the daemon just isn't running (Docker is installed but not started), tell the user rather than trying to start it yourself.
- Treat any `postinstall`/`preinstall` lifecycle script in a new dependency as worth flagging to the user before installing — summarize what it does.
- Don't put real secrets anywhere under `docs/`. `docs/.dockerignore` only trims what `docker build` copies into the image — it does not affect the `docker compose` bind mount, which exposes the entire `docs/` directory (including any `.env` file) inside the container for every dev/build/preview command (see the next bullet). There is no "keep it out unless mounted" middle ground here.
- The container mounts `docs/` as a volume, so file edits on the host are reflected inside the container immediately — no rebuild needed for normal code changes, only when `docs/package.json`/`docs/package-lock.json` change (see the rebuild-and-drop-volume steps above).
- This Docker requirement is specific to `docs/`. PowerShell tooling runs directly on the host per Section 1. The Python project under `tools/title-screen/` runs with uv as documented in its README.
If behavior changes require the compiled script to change, update these source files and run `.\Compile.ps1` only to verify generation.
## 3. Source Of Truth
## 3. Build Model
For changes that affect the compiled WinUtil script, make them only in the source files described in SPEC.md's Repository Layout — never in `winutil.ps1` itself. If behavior changes require the compiled script to change, update the source files and run `.\Compile.ps1` only to verify generation.
`Compile.ps1` combines the repository sources into `winutil.ps1` in this order:
1. Read `scripts/start.ps1` and replace `#{replaceme}` with the current `yy.MM.dd` build date.
2. Append every file under `functions/` recursively.
3. Convert each `config/*.json` file into embedded `$sync.configs` objects.
4. Special-case `config/applications.json` so keys receive the `WPFInstall` prefix in compiled config.
5. Embed `xaml/inputXML.xaml` into `$inputXML`.
6. Embed `tools/autounattend.xml` into `$WinUtilAutounattendXml`.
7. Append `scripts/main.ps1`.
8. Write the result to root `winutil.ps1`.
Because the final script is concatenated, do not rely on runtime module imports or source-relative dot-sourcing unless the compiled script will also contain the required code/data.
This scoping applies to compiled-script behavior only. Repository metadata — `AGENTS.md`, `SPEC.md`, `CLAUDE.md`/`GEMINI.md`/`.github/copilot-instructions.md`, `.github/workflows/`, and the root `.gitignore` — is edited directly when a task requires it, per the other sections of this file.
## 4. Before Editing
@@ -97,11 +83,10 @@ Because the final script is concatenated, do not rely on runtime module imports
- Prefer the minimum code that solves the stated problem.
- Keep PowerShell functions in one function file when practical, with the file name matching the primary function name.
- Use approved PowerShell verb-noun names and follow the existing `WPF` / `WinUtil` naming conventions.
- Keep UI event handler names aligned with XAML element names. A button named `WPFExampleButton` is typically handled by `Invoke-WPFExampleButton`.
- Use `$sync` for shared state and UI references, consistent with the existing runspace model.
- Use approved PowerShell verb-noun names and follow the existing `WPF` / `WinUtil` naming conventions; keep UI event handler names aligned with XAML element names per SPEC.md's UI And Event Contract.
- Use `$sync` for shared state and UI references, consistent with SPEC.md's Runtime Model.
- Update WPF controls through the UI dispatcher when running work in a background runspace.
- Keep config-driven features in JSON when they fit the existing schema instead of hard-coding lists in PowerShell.
- Keep config-driven features in JSON when they fit the existing schema instead of hard-coding lists in PowerShell; follow SPEC.md's Configuration Contract for required fields and key-renaming rules.
- Preserve undo/original-state data for tweaks so users can reverse changes.
- Do not add abstractions, configurability, hooks, or "future extensibility" unless the task needs them now.
- Clean up orphans created by your own changes, such as unused variables or functions made obsolete by the edit.
@@ -109,7 +94,7 @@ Because the final script is concatenated, do not rely on runtime module imports
## 6. Runtime And Safety Rules
- WinUtil performs system-level Windows changes. Treat registry, services, AppX removal, package manager, Windows Update, ISO, and unattended setup changes as high-risk.
- WinUtil performs system-level Windows changes; treat registry, services, AppX removal, package manager, Windows Update, ISO, and unattended setup changes as high-risk (see SPEC.md's Safety Requirements).
- Prefer existing helper functions for WinGet, Chocolatey, registry, services, progress, and UI updates.
- Keep tweaks reversible where the schema supports it by including original values or original states.
- Never modify a user's original ISO in-place; follow existing copy/mount/export patterns.
@@ -136,24 +121,27 @@ Define success in terms that can be checked, then check it.
- Read command output. Do not report tests as passing unless they actually passed.
- If verification fails, fix the cause rather than weakening the test.
If a check cannot be run, say exactly why and what residual risk remains.
If a check cannot be run, say exactly why and what residual risk remains. See SPEC.md's Testing And CI for what GitHub Actions runs on every push.
## 9. Generated Files And Git Hygiene
- Treat local `winutil.ps1` changes as disposable compile output.
- Never stage or commit `winutil.ps1`, `docs/public/`, `docs/resources/`, `binary/`, editor folders, or other ignored build artifacts.
- Never stage or commit `winutil.ps1`, `binary/`, or anything else ignored by the root `.gitignore` or `docs/.gitignore` — read those files rather than assuming. `docs/public/` is tracked source for static assets, not generated output.
- `docs/src/assets/branding/title-screen.png` is a tracked generated asset. Do not edit it manually. Update `tools/title-screen/` or run the title-screen workflow.
- Do not remove `.gitignore` rules that keep generated artifacts out of Git.
- Before finishing, check `git status --short` and separate your changes from pre-existing user changes.
- Do not revert user changes unless explicitly asked.
- Commit messages, when requested, should be descriptive: short subject under 72 characters, body explaining why when needed.
- When committing, split changes into small, logical commits rather than one large commit, so each commit's diff is reviewable as a single group of related changes.
## 10. Documentation Expectations
- Update `docs/content/` when user-facing behavior changes.
- Update developer docs when architecture, build flow, config schema, or contribution workflow changes.
- Update `docs/src/content/docs/guides/` when user-facing behavior changes.
- Update `docs/src/content/docs/code-reference/architecture.mdx` and other hand-written developer docs when architecture, build flow, config schema, or contribution workflow changes — but never hand-edit the auto-generated `code-reference/tweaks/` or `code-reference/features/` subfolders (see Non-Negotiables).
- Keep sidebar entries in `docs/astro.config.mjs` in sync with page slugs (see SPEC.md's Docs Site).
- Keep README changes brief and high-level.
- Put detailed user and developer documentation under `docs/`.
- Keep `SPEC.md` aligned with build/runtime contract changes.
- Keep SPEC.md aligned with project/architecture changes, and this file aligned with process changes.
## 11. Communication Style
@@ -187,8 +175,16 @@ When the user corrects an agent approach, add or tighten one concrete rule here
- Import Pester 5.8.0 before running tests so `Invoke-Pester -Output Detailed -CI` does not resolve to Windows' inbox Pester 3.4.0.
- Keep package install/uninstall process launches simple unless explicitly requested; do not add a separate stdout/stderr process logging helper for winget or Chocolatey.
- When the active log file is owned by `Start-Transcript`, do not call `Add-Content` against that file; write to host output so the transcript captures the line in the same log file without recording a terminating-error diagnostic.
- Keep UI helpers such as `Invoke-WPFUIThread` and `Step-WinUtilJob` safe to call without a window; the `-Preset` and `-Config` paths run the workflows before the form is created and before PresentationCore is loaded. Ask `Test-WinUtilUIAlive` rather than writing the `$sync.Form` / dispatcher / `HasShutdownStarted` check out by hand.
- Put long operations on the job layer with `Start-WinUtilJob` and report from them with `Step-WinUtilJob`; a job body must not set the busy flag, print its own banner, or carry its own try/catch/finally around the interface. `Invoke-WPFRunspace` directly is for fire-and-forget work that is not a job.
- Drain interface work that nobody is waiting for through `Start-WinUtilBackgroundQueue`; do not hand-roll another dequeue-and-re-post pump.
- Values a posted scriptblock needs travel as the dispatcher's argument or through `-Parameters`, never captured from the caller: a plain block resolves them when the dispatcher gets to it, and `GetNewClosure` binds command lookup to a copied scope. For the same reason, prefer a compiled `[action]` over `Invoke-WPFUIThread -Async` on hot re-posting paths, which marshals its body as text and recompiles it per post.
- Diagnostic scaffolding does not ship. Measure with it, then delete it.
- Have each Pester file load the assemblies and dot-source the functions it needs; several passed only because an earlier file in alphabetical order happened to load them.
- Log install/uninstall package names and package-manager IDs before queuing background runspace work; do not rely on runspace host output for the package identity.
- For Win11 Creator, start each new ISO modification in a fresh `WinUtil_Win11ISO_*` temp directory; existing-work detection is only for resuming/exporting already modified media.
- For Win11 Creator driver injection, keep offline WIM servicing to one mount, one `/Add-Driver`, and one commit; do not export editions or run unrelated WIM cleanup, and reject damaged metadata before ISO export.
- For Win11 Creator driver injection, keep offline WIM servicing to one mount and one commit: add each root package folder with its own `/Add-Driver /Recurse` so a single bad driver cannot fail the rest, and skip any folder whose ancestor is already in the set, since that ancestor's `/Recurse` covers it. Warn per failure and commit only when at least one package was added; when none were, warn and discard rather than throwing, so the run still produces an ISO. The discard in the cleanup block carries both orphaned mounts and that intentional zero-added case; keep it. Do not export editions or run unrelated WIM cleanup, and reject damaged metadata before ISO export. Use `-LiteralPath` for driver export paths, since `%TEMP%` can contain wildcard characters.
- For Script Analyzer cleanup, fix actionable source warnings first and do not globally suppress accepted convention warnings such as plural names, `ShouldProcess` on UI helpers, `$global:sync`, or compile-time cross-file false positives.
- For DNS DHCP reset, keep the cmdlet reset and explicitly set IPv4 and IPv6 DNS source to DHCP.
- Public pull-request diffs may be sent to configured external review services without a separate privacy approval; do not block the review loop on upload authorization for this public repository.
- Keep install-tab favicon loading overlapped with app-entry rendering; do not replace native WPF loading with a deferred second phase unless visible completion time is proven no slower than `main`.
+2
View File
@@ -0,0 +1,2 @@
# CLAUDE.md
@AGENTS.md
+2
View File
@@ -9,6 +9,8 @@ $sync = [Hashtable]::Synchronized(@{})
$sync.configs = @{}
$script = (Get-Content -Path scripts\start.ps1) -replace '#{replaceme}', (Get-Date -Format 'yy.MM.dd')
$isLocalCompile = -not [string]::Equals($env:GITHUB_ACTIONS, "true", [StringComparison]::OrdinalIgnoreCase)
$script = $script -replace '#{islocalcompile}', $isLocalCompile.ToString().ToLowerInvariant()
$script += Get-ChildItem -Path functions -Recurse -File | ForEach-Object {
Get-Content -Path $_.FullName -Raw
+3
View File
@@ -0,0 +1,3 @@
# GEMINI.md
Read `AGENTS.md` in the repository root for operating instructions before doing anything else.
+5 -3
View File
@@ -13,7 +13,7 @@ A curated compilation of Windows system tasks streamline **installs**, debloat w
## Quick Start
> **WinUtil must be run as Administrator** Because it performs system-wide changes.
> **WinUtil must be run as Administrator** because it performs system-wide changes.
Open PowerShell or Terminal as admin, then run:
@@ -72,13 +72,15 @@ See https://github.com/ChrisTitusTech/winutil/blob/main/.github/CONTRIBUTING.md
## Support
- Leave a ⭐ to show support!
- EXE Wrapper for $10 @ https://www.cttstore.com/windows-toolbox
- Faster Dotnet Implementation for sale here: https://www.cttstore.com/windows-toolbox
## Sponsors
These are the sponsors that help keep this project alive with monthly contributions.
<!-- sponsors --><a href="https://github.com/ysaito8015"><img src="https:&#x2F;&#x2F;github.com&#x2F;ysaito8015.png" width="60px" alt="User avatar: Yusuke Saito" /></a><a href="https://github.com/dwelfusius"><img src="https:&#x2F;&#x2F;github.com&#x2F;dwelfusius.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/mews-se"><img src="https:&#x2F;&#x2F;github.com&#x2F;mews-se.png" width="60px" alt="User avatar: Martin" /></a><a href="https://github.com/jdiegmueller"><img src="https:&#x2F;&#x2F;github.com&#x2F;jdiegmueller.png" width="60px" alt="User avatar: Jason A. Diegmueller" /></a><a href="https://github.com/robertsandrock"><img src="https:&#x2F;&#x2F;github.com&#x2F;robertsandrock.png" width="60px" alt="User avatar: RMS" /></a><a href="https://github.com/paulsheets"><img src="https:&#x2F;&#x2F;github.com&#x2F;paulsheets.png" width="60px" alt="User avatar: Paul" /></a><a href="https://github.com/djones369"><img src="https:&#x2F;&#x2F;github.com&#x2F;djones369.png" width="60px" alt="User avatar: Dave J (WhamGeek)" /></a><a href="https://github.com/anthonymendez"><img src="https:&#x2F;&#x2F;github.com&#x2F;anthonymendez.png" width="60px" alt="User avatar: Anthony Mendez" /></a><a href="https://github.com/FatBastard0"><img src="https:&#x2F;&#x2F;github.com&#x2F;FatBastard0.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/DursleyGuy"><img src="https:&#x2F;&#x2F;github.com&#x2F;DursleyGuy.png" width="60px" alt="User avatar: DursleyGuy" /></a><a href="https://github.com/DwayneTheRockLobster1"><img src="https:&#x2F;&#x2F;github.com&#x2F;DwayneTheRockLobster1.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/KieraKujisawa"><img src="https:&#x2F;&#x2F;github.com&#x2F;KieraKujisawa.png" width="60px" alt="User avatar: Kiera Meredith" /></a><a href="https://github.com/andrewpayne68"><img src="https:&#x2F;&#x2F;github.com&#x2F;andrewpayne68.png" width="60px" alt="User avatar: Andrew P" /></a><a href="https://github.com/seanh1995"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanh1995.png" width="60px" alt="User avatar: Sean (ANGRYxScotsman)" /></a><a href="https://github.com/Abs313a"><img src="https:&#x2F;&#x2F;github.com&#x2F;Abs313a.png" width="60px" alt="User avatar: Abs" /></a><!-- sponsors -->
<!-- sponsors --><a href="https://github.com/dwelfusius"><img src="https:&#x2F;&#x2F;github.com&#x2F;dwelfusius.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/mews-se"><img src="https:&#x2F;&#x2F;github.com&#x2F;mews-se.png" width="60px" alt="User avatar: Martin" /></a><a href="https://github.com/jdiegmueller"><img src="https:&#x2F;&#x2F;github.com&#x2F;jdiegmueller.png" width="60px" alt="User avatar: Jason A. Diegmueller" /></a><a href="https://github.com/robertsandrock"><img src="https:&#x2F;&#x2F;github.com&#x2F;robertsandrock.png" width="60px" alt="User avatar: RMS" /></a><a href="https://github.com/paulsheets"><img src="https:&#x2F;&#x2F;github.com&#x2F;paulsheets.png" width="60px" alt="User avatar: Paul" /></a><a href="https://github.com/djones369"><img src="https:&#x2F;&#x2F;github.com&#x2F;djones369.png" width="60px" alt="User avatar: Dave J (WhamGeek)" /></a><a href="https://github.com/anthonymendez"><img src="https:&#x2F;&#x2F;github.com&#x2F;anthonymendez.png" width="60px" alt="User avatar: Anthony Mendez" /></a><a href="https://github.com/FatBastard0"><img src="https:&#x2F;&#x2F;github.com&#x2F;FatBastard0.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/DursleyGuy"><img src="https:&#x2F;&#x2F;github.com&#x2F;DursleyGuy.png" width="60px" alt="User avatar: DursleyGuy" /></a><a href="https://github.com/DwayneTheRockLobster1"><img src="https:&#x2F;&#x2F;github.com&#x2F;DwayneTheRockLobster1.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/KieraKujisawa"><img src="https:&#x2F;&#x2F;github.com&#x2F;KieraKujisawa.png" width="60px" alt="User avatar: Kiera Meredith" /></a><a href="https://github.com/seanh1995"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanh1995.png" width="60px" alt="User avatar: Sean (ANGRYxScotsman)" /></a><a href="https://github.com/F-L-Perez"><img src="https:&#x2F;&#x2F;github.com&#x2F;F-L-Perez.png" width="60px" alt="User avatar: Fra · ppe" /></a><a href="https://github.com/josencarnacao"><img src="https:&#x2F;&#x2F;github.com&#x2F;josencarnacao.png" width="60px" alt="User avatar: José Encarnação" /></a><!-- sponsors -->
*<sub>Sponsors with a recurring subscription also get access to the .NET alternative.</sub>
---
+69 -58
View File
@@ -1,10 +1,38 @@
# SPEC.md
## Project Contract
Project contract for WinUtil — what the project is, how it's built, and how it runs. Written for anyone, human or AI, who needs to understand the project itself.
WinUtil is a Windows PowerShell utility with a WPF interface. The repository is maintained as modular source files, but the released artifact is a single generated `winutil.ps1` script.
`AGENTS.md` in the repository root points here for these facts, and separately covers how an agent should behave while working in this repo. This file does not change based on who's reading it.
The compiled `winutil.ps1` is not source code for editing or review. It is generated by `Compile.ps1` and produced during release automation. All durable changes must be made to the source files that feed the compiler.
## Project Context
WinUtil is a Windows PowerShell utility with a WPF interface. The repository is maintained as modular source, but the distributed artifact is one compiled PowerShell script.
### Stack
- Language: Windows PowerShell / PowerShell.
- UI: WPF via `xaml/inputXML.xaml`.
- Configuration: JSON files under `config/`.
- Tests: Pester tests under `pester/`.
- Lint: PowerShell Script Analyzer with settings in `lint/PSScriptAnalyser.ps1`.
- Docs: Astro + Starlight site under `docs/`, built independently of `Compile.ps1` (its own `package.json`/`node_modules`).
- Release artifact: generated root `winutil.ps1`.
### Repository Layout
- `Compile.ps1`: build script that creates `winutil.ps1`.
- `scripts/start.ps1`: startup/bootstrap segment used at the beginning of the compiled script.
- `scripts/main.ps1`: main entrypoint appended at the end of the compiled script.
- `functions/public/`: public/UI-facing PowerShell functions.
- `functions/private/`: internal helper PowerShell functions.
- `config/`: JSON configuration consumed at compile time and embedded into `$sync.configs`.
- `xaml/inputXML.xaml`: WPF UI markup embedded into the compiled script.
- `tools/autounattend.xml`: unattended setup XML embedded for Windows ISO workflows.
- `pester/`: Pester tests for config and function checks.
- `lint/PSScriptAnalyser.ps1`: PowerShell Script Analyzer settings.
- `tools/title-screen/`: uv project that captures WinUtil's Light and Dark themes and generates the title-screen composite.
- `docs/`: Astro + Starlight documentation site, with its own `package.json` and build independent of `Compile.ps1`.
- `winutil.ps1`: ignored generated build artifact.
## Goals
@@ -21,85 +49,68 @@ The compiled `winutil.ps1` is not source code for editing or review. It is gener
- The GUI is not a separate packaged desktop application in this repository's normal release path.
- Generated files should not be reviewed as source changes.
## Repository Layout
## Build Model
- `Compile.ps1`: build script that creates `winutil.ps1`.
- `scripts/start.ps1`: startup/bootstrap segment used at the beginning of the compiled script.
- `scripts/main.ps1`: main entrypoint appended at the end of the compiled script.
- `functions/public/`: public/UI-facing PowerShell functions.
- `functions/private/`: internal helper PowerShell functions.
- `config/`: JSON configuration consumed at compile time and embedded into `$sync.configs`.
- `xaml/inputXML.xaml`: WPF UI markup embedded into the compiled script.
- `tools/autounattend.xml`: unattended setup XML embedded for Windows ISO workflows.
- `pester/`: Pester tests for config and function checks.
- `lint/PSScriptAnalyser.ps1`: PowerShell Script Analyzer settings.
- `docs/`: Hugo documentation site.
- `winutil.ps1`: ignored generated build artifact.
`Compile.ps1` combines the repository sources into `winutil.ps1` in this order:
## Compile Specification
1. Read `scripts/start.ps1` and replace `#{replaceme}` with the current `yy.MM.dd` build date.
2. Append every file under `functions/` recursively.
3. Convert each `config/*.json` file into embedded `$sync.configs` objects.
4. Special-case `config/applications.json` so keys receive the `WPFInstall` prefix in compiled config.
5. Embed `xaml/inputXML.xaml` into `$inputXML`.
6. Embed `tools/autounattend.xml` into `$WinUtilAutounattendXml`.
7. Append `scripts/main.ps1`.
8. Write the result to root `winutil.ps1`.
`Compile.ps1` must produce a standalone root `winutil.ps1` by combining all required project files.
The compile flow is:
1. Initialize shared state with `$sync = [Hashtable]::Synchronized(@{})` and `$sync.configs = @{}`.
2. Read `scripts/start.ps1`.
3. Replace `#{replaceme}` in startup code with the current `yy.MM.dd` build date.
4. Append raw content from all files under `functions/` recursively.
5. For every file in `config/`, parse JSON and embed it into `$sync.configs.<basename>`.
6. Special-case `config/applications.json` so application keys are emitted with the `WPFInstall` prefix.
7. Embed `xaml/inputXML.xaml` as `$inputXML`.
8. Embed `tools/autounattend.xml` as `$WinUtilAutounattendXml`.
9. Append `scripts/main.ps1`.
10. Write the combined script to `winutil.ps1`.
11. If `-Run` is supplied, execute the generated script.
The generated script must have everything it needs from repository sources embedded or appended by this process.
Because the final script is concatenated, code cannot rely on runtime module imports or source-relative dot-sourcing unless the compiled script will also contain the required code/data.
## Runtime Model
- WinUtil runs in PowerShell on Windows and uses WPF for the UI.
- Shared mutable state is stored in `$sync`, including configs, UI element references, runspace state, selections, and progress.
- Long-running operations should use runspaces or existing async patterns so the UI remains responsive.
- UI updates from background work must be dispatched back to the WPF UI thread.
- Declarative features such as apps, tweaks, presets, DNS providers, and navigation should stay in `config/*.json` unless code is required.
- Long-running operations use runspaces or existing async patterns so the UI remains responsive.
- UI updates from background work are dispatched back to the WPF UI thread.
- Declarative features such as apps, tweaks, presets, DNS providers, and navigation stay in `config/*.json` unless code is required.
## UI And Event Contract
- UI layout lives in `xaml/inputXML.xaml`.
- Named WPF controls are discovered and stored in `$sync`.
- Button/action wiring follows existing naming conventions, where an element named like `WPFThingButton` maps to a function named like `Invoke-WPFThingButton`.
- When adding controls, ensure the XAML name, config key, and PowerShell function names line up with the existing event system.
- Button/action wiring follows a naming convention: an element named like `WPFThingButton` maps to a function named like `Invoke-WPFThingButton`.
## Configuration Contract
Config files must remain valid JSON and compile cleanly through `ConvertFrom-Json`.
`config/applications.json` defines installable applications. Each application entry should include the fields expected by tests and UI code, such as package manager IDs, category, display content, description, and link.
`config/tweaks.json` defines Windows tweaks. Registry and service changes should include original values or original states when applicable so undo workflows can restore user systems.
Preset and navigation files should reference valid config keys. Avoid renaming config keys unless all presets, UI references, docs, and code paths are updated together.
- Config files must remain valid JSON and compile cleanly through `ConvertFrom-Json`.
- `config/dns.json` opts unfiltered providers into Fastest selection with `BenchmarkEligible: true`; missing or false values exclude a provider from the TCP latency benchmark.
- `config/applications.json` defines installable applications; each entry includes the fields expected by tests and UI code, such as package manager IDs, category, display content, description, and link.
- `config/tweaks.json` defines Windows tweaks; registry and service changes include original values or original states when applicable so undo workflows can restore user systems.
- Preset and navigation files reference valid config keys. Renaming a config key requires updating all presets, UI references, docs, and code paths together.
## Safety Requirements
- Registry, service, package manager, Windows Update, AppX removal, and ISO operations can affect the host system. Changes must be explicit, reversible where practical, and consistent with existing logging and confirmation patterns.
- Tweak changes should include undo metadata when the schema supports it.
- Package installation should prefer existing WinGet and Chocolatey helper functions.
- ISO workflows must not modify the user's original ISO file; they should work on copied/mounted content following existing patterns.
- Registry, service, package manager, Windows Update, AppX removal, and ISO operations affect the host system and are treated as high-risk.
- Tweak changes include undo metadata when the schema supports it, so changes stay reversible.
- ISO workflows never modify the user's original ISO file; they work on copied/mounted content.
## Docs Site (Astro)
- `docs/` is an Astro + Starlight site, independent of `Compile.ps1`'s build (its own `package.json`/`node_modules`, deployed via the `docs.yaml` GitHub Actions workflow to GitHub Pages).
- Pages live under `docs/src/content/docs/` (`.mdx`), organized into `guides/`, `code-reference/`, plus top-level pages like `faq.mdx`, `knownissues.mdx`, `contributing.mdx`, `index.mdx`.
- `docs/src/content/docs/code-reference/tweaks/` and `.../features/` are auto-generated by `tools/devdocs-generator.ps1` from `config/tweaks.json`/`config/feature.json` and the relevant PowerShell function files. Other pages under `code-reference/` (e.g. `architecture.mdx`) are hand-written and untouched by the generator.
- Sidebar entries in `docs/astro.config.mjs` must match actual page slugs under `docs/src/content/docs/`.
- `docs/public/` is tracked source for static assets (favicons, etc.), not generated output. Generated/ignored paths are listed in `docs/.gitignore` (`dist/`, `.astro/`, `node_modules/`, local env files).
- `docs/src/assets/branding/title-screen.png` is a tracked generated image used by the repository README and docs homepage. Its raw Light and Dark captures are temporary.
- `docs/Dockerfile` and `docs/docker-compose.yml` (service `winutil-astro`) containerize the site's npm tooling; see AGENTS.md's Dependency Installs, Builds, And Dev Servers for why and how agents must use them instead of running npm on the host.
## Testing And CI
Expected validation for source changes:
- `.\Compile.ps1` verifies the compiler can generate `winutil.ps1`.
- `.\Compile.ps1 -Run` compiles and launches the generated utility for manual GUI verification.
- `Install-Module -Name Pester -RequiredVersion 5.8.0 -Scope CurrentUser -Force -SkipPublisherCheck` installs the supported Pester version.
- `Import-Module Pester -RequiredVersion 5.8.0 -Force; Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed -CI` runs the Pester suite.
- GitHub Actions also runs a compile check and PowerShell Script Analyzer with `lint/PSScriptAnalyser.ps1`.
The generated `winutil.ps1` may appear locally after compile. It remains ignored build output and must not be committed.
- Pester 5.8.0 runs the suite under `pester/*.Tests.ps1`. GitHub Actions (`unittests.yaml`) installs Pester 5.8.0 fresh and runs with `-CI`, which produces `testResults.xml` and exits non-zero on failure.
- GitHub Actions also runs PowerShell Script Analyzer with `lint/PSScriptAnalyser.ps1` on every push.
- The generated `winutil.ps1` may appear locally after compile. It remains ignored build output (see root `.gitignore`) and must not be committed.
- The manually triggered title-screen workflow compiles WinUtil from `main` and opens an image-only pull request when the generated composite changes. These pull requests require manual review. Failed runs retain diagnostics for 14 days.
## Release Artifact
GitHub Actions is responsible for producing the release `winutil.ps1` from repository sources. A release should be considered valid only if the generated script came from the compile process, not from direct manual edits to `winutil.ps1`.
GitHub Actions is responsible for producing the release `winutil.ps1` from repository sources. A release is considered valid only if the generated script came from the compile process, not from direct manual edits to `winutil.ps1`.
+208 -20
View File
@@ -17,6 +17,15 @@
"winget": "7zip.7zip",
"foss": true
},
"abdownloadmanager": {
"category": "Utilities",
"choco": "ab-download-manager",
"content": "AB Download Manager",
"description": "AB Download Manager is an open-source download accelerator and manager with multi-threaded downloads, queue scheduling, speed limiting, and browser integration.",
"link": "https://abdownloadmanager.com/",
"winget": "amir1376.ABDownloadManager",
"foss": true
},
"adobe": {
"category": "Document",
"choco": "adobereader",
@@ -98,6 +107,15 @@
"winget": "AutoHotkey.AutoHotkey",
"foss": true
},
"battlenet": {
"category": "Games",
"choco": "na",
"winget": "Blizzard.BattleNet",
"content": "Battle.net",
"description": "Battle.net is a launcher for games created and developed by Activision Blizzard",
"link": "https://battle.net",
"foss": false
},
"bitwarden": {
"category": "Utilities",
"choco": "bitwarden",
@@ -125,6 +143,15 @@
"winget": "Brave.Brave",
"foss": true
},
"bruno": {
"category": "Development",
"choco": "bruno",
"content": "Bruno",
"description": "Bruno is a local-first API client that stores collections as plain text files for version control and collaboration.",
"link": "https://www.usebruno.com/",
"winget": "Bruno.Bruno",
"foss": true
},
"bulkcrapuninstaller": {
"category": "Utilities",
"choco": "bulk-crap-uninstaller",
@@ -166,7 +193,7 @@
"choco": "na",
"content": "ChatGPT Desktop",
"description": "The official ChatGPT desktop app for Windows, distributed through the Microsoft Store.",
"link": "https://apps.microsoft.com/detail/9nt1r1c2hh7j",
"link": "https://openai.com/chatgpt/download/",
"winget": "msstore:9NT1R1C2HH7J",
"foss": false
},
@@ -193,7 +220,7 @@
"choco": "chromium",
"content": "Chromium",
"description": "Chromium is the open-source project that serves as the foundation for various web browsers, including Chrome.",
"link": "https://github.com/Hibbiki/chromium-win64",
"link": "https://www.chromium.org/",
"winget": "Hibbiki.Chromium",
"foss": true
},
@@ -319,10 +346,19 @@
"choco": "dorion",
"content": "Dorion",
"description": "Tiny alternative Discord client with a smaller footprint, snappier startup, themes, plugins and more!",
"link": "https://github.com/SpikeHD/Dorion",
"link": "https://spikehd.dev/projects/dorion/",
"winget": "SpikeHD.Dorion",
"foss": true
},
"dockerdesktop": {
"category": "Development",
"choco": "docker-desktop",
"content": "Docker Desktop",
"description": "Docker Desktop provides a local environment for building, running, and testing containerized applications on Windows.",
"link": "https://www.docker.com/products/docker-desktop/",
"winget": "Docker.DockerDesktop",
"foss": false
},
"dotnet6": {
"category": "Microsoft Tools",
"choco": "dotnet-6.0-runtime",
@@ -395,6 +431,16 @@
"winget": "Microsoft.Edge",
"foss": false
},
"es-de": {
"category": "Games",
"choco": "",
"content": "EmulationStation Desktop Edition",
"_comment": "This and emulationstation are two completely different things. ES-DE is your frontend for everything and has its own set of emulators. Emulationstation is a graphical frontend for RetroArch.",
"description": "EmulationStation Desktop Edition is a frontend for browsing and launching games from your multi-platform game collection.",
"link": "https://es-de.org/",
"winget": "ES-DE.EmulationStation-DE",
"foss": true
},
"enteauth": {
"category": "Utilities",
"choco": "ente-auth",
@@ -418,10 +464,19 @@
"choco": "files",
"content": "Files",
"description": "Alternative file explorer.",
"link": "https://github.com/files-community/Files",
"link": "https://files.community",
"winget": "FilesCommunity.Files",
"foss": true
},
"fileconverter": {
"category": "Multimedia Tools",
"choco": "file-converter",
"content": "File Converter",
"description": "File Converter converts and compresses files from the Windows Explorer context menu.",
"link": "https://file-converter.io/",
"winget": "AdrienAllard.FileConverter",
"foss": true
},
"firefox": {
"category": "Browsers",
"choco": "firefox",
@@ -458,6 +513,24 @@
"winget": "flux.flux",
"foss": false
},
"foobar": {
"category": "Multimedia Tools",
"choco": "foobar2000",
"content": "foobar2000 (Music Player)",
"description": "foobar2000 is a highly customizable and extensible music player for Windows, known for its modular design and advanced features.",
"link": "https://www.foobar2000.org/",
"winget": "PeterPawlowski.foobar2000",
"foss": false
},
"fnm": {
"category": "Development",
"choco": "fnm",
"content": "Fast Node Manager",
"description": "Fast Node Manager (fnm) is a fast, cross-platform tool for installing and switching between Node.js versions.",
"link": "https://github.com/Schniz/fnm",
"winget": "Schniz.fnm",
"foss": true
},
"foxpdfreader": {
"category": "Document",
"choco": "foxitreader",
@@ -494,6 +567,24 @@
"winget": "Git.Git",
"foss": true
},
"gitextensions": {
"category": "Development",
"choco": "gitextensions",
"content": "Git Extensions",
"description": "Git Extensions is a graphical Git client for Windows with repository, history, and commit management tools.",
"link": "https://gitextensions.github.io/",
"winget": "GitExtensionsTeam.GitExtensions",
"foss": true
},
"githubcli": {
"category": "Development",
"choco": "gh",
"content": "GitHub CLI",
"description": "GitHub CLI brings pull requests, issues, releases, and other GitHub workflows to the terminal.",
"link": "https://cli.github.com/",
"winget": "GitHub.cli",
"foss": true
},
"githubdesktop": {
"category": "Development",
"choco": "git;github-desktop",
@@ -553,7 +644,7 @@
"choco": "helium",
"content": "Helium",
"description": "Private, fast, and honest web browser.",
"link": "https://github.com/imputnet/helium/",
"link": "https://helium.computer",
"winget": "ImputNet.Helium",
"foss": true
},
@@ -562,7 +653,7 @@
"choco": "hugo-extended",
"content": "Hugo",
"description": "The world's fastest framework for building websites.",
"link": "https://github.com/gohugoio/hugo/",
"link": "https://gohugo.io",
"winget": "Hugo.Hugo.Extended",
"foss": true
},
@@ -679,7 +770,7 @@
"choco": "jellyfin-media-player",
"content": "Jellyfin Media Player",
"description": "Jellyfin Media Player is a client application for the Jellyfin media server, providing access to your media library.",
"link": "https://github.com/jellyfin/jellyfin-media-player",
"link": "https://jellyfin.org/",
"winget": "Jellyfin.JellyfinMediaPlayer",
"foss": true
},
@@ -769,7 +860,7 @@
"choco": "librewolf",
"content": "LibreWolf",
"description": "LibreWolf is a privacy-focused web browser based on Firefox, with additional privacy and security enhancements.",
"link": "https://librewolf-community.gitlab.io/",
"link": "https://librewolf.net/",
"winget": "LibreWolf.LibreWolf",
"foss": true
},
@@ -787,10 +878,18 @@
"choco": "mediainfo",
"content": "mpc-qt",
"description": "Media Player Classic Qute Theater",
"link": "https://github.com/mpc-qt/mpc-qt",
"link": "https://mpc-qt.github.io",
"winget": "mpc-qt.mpc-qt",
"foss": true
},
"mpv": {
"category": "Multimedia Tools",
"content": "mpv",
"description": "mpv is a free, open source, and cross-platform media player supporting a wide variety of media formats, codecs, and subtitle types.",
"link": "https://mpv.io/",
"winget": "shinchiro.mpv",
"foss": true
},
"matrix": {
"category": "Communications",
"choco": "element-desktop",
@@ -832,7 +931,7 @@
"choco": "mpc-hc-clsid2",
"content": "Media Player Classic - Home Cinema",
"description": "Media Player Classic - Home Cinema (MPC-HC) is a free and open-source video and audio player for Windows. MPC-HC is based on the original Guliverkli project and contains many additional features and bug fixes.",
"link": "https://github.com/clsid2/mpc-hc/",
"link": "https://mpc-hc.org/",
"winget": "clsid2.mpc-hc",
"foss": true
},
@@ -859,7 +958,7 @@
"choco": "mullvad-app",
"content": "Mullvad VPN",
"description": "This is the VPN client software for the Mullvad VPN service.",
"link": "https://github.com/mullvad/mullvadvpn-app",
"link": "https://mullvad.net/",
"winget": "MullvadVPN.MullvadVPN",
"foss": true
},
@@ -886,7 +985,7 @@
"choco": "nanazip",
"content": "NanaZip",
"description": "NanaZip is a fast and efficient file compression and decompression tool.",
"link": "https://github.com/M2Team/NanaZip",
"link": "https://nanazip.org",
"winget": "M2Team.NanaZip",
"foss": true
},
@@ -899,6 +998,15 @@
"winget": "Netbird.Netbird",
"foss": true
},
"tailscale": {
"category": "Utilities",
"choco": "tailscale",
"content": "Tailscale",
"description": "The Tailscale client allows you to connect all your devices using WireGuard®, without the hassle. Tailscale makes it as easy as installing an app and signing in.",
"link": "https://tailscale.com/",
"winget": "Tailscale.Tailscale",
"foss": false
},
"naps2": {
"category": "Document",
"choco": "naps2",
@@ -1186,6 +1294,15 @@
"winget": "JanDeDobbeleer.OhMyPosh",
"foss": true
},
"postman": {
"category": "Development",
"choco": "postman",
"content": "Postman",
"description": "Postman is an API platform and desktop client for designing, testing, documenting, and collaborating on APIs.",
"link": "https://www.postman.com/downloads/",
"winget": "Postman.Postman",
"foss": false
},
"powershell": {
"category": "Microsoft Tools",
"choco": "powershell-core",
@@ -1303,6 +1420,15 @@
"winget": "qBittorrent.qBittorrent",
"foss": true
},
"qownnotes": {
"category": "Document",
"choco": "qownnotes",
"content": "QOwnNotes",
"description": "QOwnNotes is a free open-source note-taking app with Nextcloud/ownCloud integration.",
"link": "https://www.qownnotes.org/",
"winget": "pbek.QOwnNotes",
"foss": true
},
"qtox": {
"category": "Communications",
"choco": "qtox",
@@ -1438,6 +1564,15 @@
"winget": "StartIsBack.StartAllBack",
"foss": false
},
"starship": {
"category": "Development",
"choco": "starship",
"content": "Starship (Shell Prompt)",
"description": "Starship is a fast, customizable, cross-platform prompt for PowerShell and other shells.",
"link": "https://starship.rs/",
"winget": "Starship.Starship",
"foss": true
},
"steam": {
"category": "Games",
"choco": "steam-client",
@@ -1447,6 +1582,15 @@
"winget": "Valve.Steam",
"foss": false
},
"roblox": {
"category": "Games",
"choco": "na",
"content": "Roblox",
"description": "Roblox is a platform and game creation system that allows users to create and play games developed by the community.",
"link": "https://www.roblox.com/",
"winget": "Roblox.Roblox",
"foss": false
},
"sublimetext": {
"category": "Development",
"choco": "sublimetext4",
@@ -1470,10 +1614,27 @@
"choco": "sunshine",
"content": "Sunshine/GameStream Server",
"description": "Sunshine is a GameStream server that allows you to remotely play PC games on Android devices, offering low-latency streaming.",
"link": "https://github.com/LizardByte/Sunshine",
"link": "https://app.lizardbyte.dev/Sunshine/",
"winget": "LizardByte.Sunshine",
"foss": true
},
"synctrayzor": {
"category": "Selfhosted Tools",
"choco": "synctrayzor",
"content": "SyncTrayzor",
"description": "SyncTrayzor is a Windows tray utility that bundles and wraps Syncthing, making it behave like a native application to easily manage and monitor file synchronization.",
"link": "https://github.com/GermanCoding/SyncTrayzor",
"winget": "GermanCoding.SyncTrayzor"
},
"syncthing": {
"category": "Selfhosted Tools",
"choco": "syncthing",
"content": "Syncthing (CLI / Web UI)",
"description": "Syncthing is a decentralized, peer-to-peer file synchronization tool that securely syncs files directly across devices without cloud servers, managed via a local web interface.",
"link": "https://syncthing.net/",
"winget": "Syncthing.Syncthing",
"foss": true
},
"tcpview": {
"category": "Microsoft Tools",
"choco": "tcpview",
@@ -1510,6 +1671,15 @@
"winget": "TeamSpeakSystems.TeamSpeakClient",
"foss": false
},
"teamspeak6": {
"category": "Communications",
"choco": "na",
"content": "TeamSpeak 6",
"description": "TEAMSPEAK. YOUR TEAM. YOUR RULES. Use crystal clear sound to communicate with your teammates cross-platform with military-grade security, lag-free performance & unparalleled reliability and uptime.",
"link": "https://www.teamspeak.com/",
"winget": "TeamSpeakSystems.TeamSpeakClient.Beta.6",
"foss": false
},
"telegram": {
"category": "Communications",
"choco": "telegram",
@@ -1578,7 +1748,7 @@
"choco": "translucenttb",
"content": "TranslucentTB",
"description": "TranslucentTB is a tool that allows you to customize the transparency of the Windows Taskbar.",
"link": "https://github.com/TranslucentTB/TranslucentTB",
"link": "https://translucenttb.github.io",
"winget": "CharlesMilette.TranslucentTB",
"foss": true
},
@@ -1609,6 +1779,15 @@
"winget": "Unity.UnityHub",
"foss": false
},
"vagrant": {
"category": "Development",
"choco": "vagrant",
"content": "Vagrant",
"description": "Vagrant builds and manages reproducible virtual machine development environments from declarative configuration.",
"link": "https://developer.hashicorp.com/vagrant",
"winget": "Hashicorp.Vagrant",
"foss": false
},
"everything": {
"category": "Utilities",
"choco": "everything",
@@ -1649,8 +1828,8 @@
"category": "Communications",
"choco": "na",
"content": "Vesktop",
"description": "A cross platform electron-based desktop app aiming to give you a snappier Discord experience with Vencord pre-installed.",
"link": "https://github.com/Vencord/Vesktop",
"description": "A cross-platform electron-based desktop app aiming to give you a snappier Discord experience with Vencord pre-installed.",
"link": "https://vesktop.dev",
"winget": "Vencord.Vesktop",
"foss": true
},
@@ -1740,7 +1919,7 @@
"choco": "na",
"content": "WhatsApp Desktop",
"description": "WhatsApp Desktop is the official Windows desktop messaging app from Meta, distributed through the Microsoft Store.",
"link": "https://apps.microsoft.com/detail/9nksqgp7f2nh",
"link": "https://www.whatsapp.com/download",
"winget": "msstore:9NKSQGP7F2NH",
"foss": false
},
@@ -1863,9 +2042,8 @@
},
"Overwolf": {
"category": "Games",
"choco": "overwolf",
"content": "Overwolf",
"description": "Popular platform for game overlays and companion apps (mod managers, trackers, etc.), widely used by gamers.",
"content": "CurseForge",
"description": "CurseForge is a desktop application for managing mods and modpacks across multiple games, powered by Overwolf.",
"link": "https://www.overwolf.com/app/overwolf-curseforge",
"winget": "Overwolf.CurseForge",
"foss": false
@@ -1932,5 +2110,15 @@
"content": "Lua",
"link": "https://github.com/rjpcomputing/luaforwindows",
"foss": true
},
"CloudflareWARP": {
"category": "Utilities",
"choco": "warp",
"winget": "Cloudflare.Warp",
"description": "WARP is a freemium VPN service provided by Cloudflare. Includes usage of Cloudflare's DNS",
"content": "Cloudflare WARP",
"link": "https://one.one.one.one",
"foss": false
}
}
+8
View File
@@ -259,5 +259,13 @@
"Description": "Bundles built-in card game modes including Klondike, Spider, FreeCell, Pyramid, and TriPeaks alongside daily challenges.",
"Panel": "1",
"PackageId": "Microsoft.MicrosoftSolitaireCollection"
},
"WPFAppxMicrosoft_ZuneVideo": {
"Category": "Utilities & Productivity",
"Content": "Movies & TV",
"Description": "The default video player and storefront for purchasing or renting media.",
"Panel": "0",
"PackageId": "Microsoft.ZuneVideo",
"StoreId": "9WZDNCRFJ3P2"
}
}
+2
View File
@@ -1,5 +1,6 @@
{
"Google":{
"BenchmarkEligible": true,
"Primary": "8.8.8.8",
"Secondary": "8.8.4.4",
"Primary6": "2001:4860:4860::8888",
@@ -7,6 +8,7 @@
"DohTemplate": "https://dns.google/dns-query"
},
"Cloudflare":{
"BenchmarkEligible": true,
"Primary": "1.1.1.1",
"Secondary": "1.0.0.1",
"Primary6": "2606:4700:4700::1111",
+7
View File
@@ -13,6 +13,9 @@
"FontSize": "12",
"FontFamily": "Arial",
"HeaderFontSize": "16",
"Win11StepTitleFontSize": "22",
"Win11StepHeroFontSize": "40",
"Win11LogFontFamily": "Consolas, Monaco",
"HeaderFontFamily": "Consolas, Monaco",
"CheckBoxBulletDecoratorSize": "14",
"CheckBoxMargin": "15,0,0,2",
@@ -58,6 +61,8 @@
"ScrollBarHoverColor": "#5A5D62",
"ScrollBarDraggingColor": "#6A6D72",
"ProgressBarForegroundColor": "#2E77FF",
"ProgressBarErrorColor": "#D13438",
"ProgressBarWarningColor": "#B36A00",
"ProgressBarBackgroundColor": "Transparent",
"ButtonInstallBackgroundColor": "#F7F7F7",
"ButtonTweaksBackgroundColor": "#F7F7F7",
@@ -98,6 +103,8 @@
"ScrollBarHoverColor": "#3B4252",
"ScrollBarDraggingColor": "#5E81AC",
"ProgressBarForegroundColor": "#6EFF72",
"ProgressBarErrorColor": "#FF6B6B",
"ProgressBarWarningColor": "#FFC83D",
"ProgressBarBackgroundColor": "Transparent",
"ButtonInstallBackgroundColor": "#222222",
"ButtonTweaksBackgroundColor": "#333333",
+73 -40
View File
@@ -1,14 +1,14 @@
{
"WPFTweaksActivity": {
"Content": "Activity History - Disable",
"Description": "Erases recent docs, clipboard, and run history.",
"Description": "Stops Windows from publishing or uploading user activities while preserving clipboard history.",
"category": "Essential Tweaks",
"panel": "1",
"registry": [
{
"Path": "HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows\\System",
"Name": "EnableActivityFeed",
"Value": "0",
"Value": "1",
"Type": "DWord",
"OriginalValue": "<RemoveEntry>"
},
@@ -99,10 +99,10 @@
"category": "Essential Tweaks",
"panel": "1",
"InvokeScript": [
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /deny Everyone:F"
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /deny *S-1-1-0:F"
],
"UndoScript": [
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /grant Everyone:F"
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /grant *S-1-1-0:F"
],
"link": "https://winutil.christitus.com/code-reference/tweaks/essential-tweaks/disablestoresearch"
},
@@ -114,7 +114,7 @@
"service": [
{
"Name": "lfsvc",
"StartupType": "Disable",
"StartupType": "Disabled",
"OriginalType": "Manual"
}
],
@@ -647,7 +647,7 @@
"InvokeScript": [
"
# Deny permission to remove OneDrive folder
icacls $Env:OneDrive /deny \"Administrators:(D,DC)\"
icacls $Env:OneDrive /deny \"*S-1-5-32-544:(D,DC)\"
Write-Host \"Uninstalling OneDrive...\"
Start-Process -FilePath (Join-Path $Env:SystemRoot \"System32\\OneDriveSetup.exe\") -ArgumentList '/uninstall' -Wait
@@ -661,7 +661,7 @@
Remove-Item \"$Env:ProgramData\\Microsoft OneDrive\" -Recurse -Force
# Grant back permission to access OneDrive folder
icacls $Env:OneDrive /grant \"Administrators:(D,DC)\"
icacls $Env:OneDrive /grant \"*S-1-5-32-544:(D,DC)\"
if (-not (Get-ChildItem -Path $Env:OneDrive)) {
Remove-Item -Path $Env:OneDrive -Recurse
@@ -854,7 +854,7 @@
},
"WPFTweaksEndTaskOnTaskbar": {
"Content": "End Task With Right Click - Enable",
"Description": "Enables option to end task when right clicking a program in the taskbar.",
"Description": "Enables option to end task when right-clicking a program in the taskbar.",
"category": "Essential Tweaks",
"panel": "1",
"registry": [
@@ -991,16 +991,51 @@
New-Item -Path $RazerPath -ItemType Directory
}
icacls $RazerPath /deny \"Everyone:(W)\"
icacls $RazerPath /deny \"*S-1-1-0:(W)\"
"
],
"UndoScript": [
"
icacls \"$Env:SystemRoot\\Installer\\Razer\" /remove:d Everyone
icacls \"$Env:SystemRoot\\Installer\\Razer\" /remove:d *S-1-1-0
"
],
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/razerblock"
},
"WPFTweaksLogiBlock": {
"Content": "Logitech Download Assistant Auto-Install - Disable",
"Description": "Blocks the Logi Download Assistant that Windows Update keeps reinstalling with Logitech device drivers. Logitech hardware keeps working without it.",
"category": "z__Advanced Tweaks - CAUTION",
"panel": "1",
"InvokeScript": [
"
Stop-Process -Name \"logi_download_assistant\" -Force -ErrorAction SilentlyContinue
$ProgramFiles64 = if ($Env:ProgramW6432) { $Env:ProgramW6432 } else { $Env:ProgramFiles }
$LogiPath = \"$ProgramFiles64\\LogiDownloadAssistant\"
if (Test-Path $LogiPath) {
Remove-Item $LogiPath\\* -Recurse -Force
} else {
New-Item -Path $LogiPath -ItemType Directory
}
icacls $LogiPath /deny \"*S-1-1-0:(W)\"
if ($LASTEXITCODE -ne 0) { throw \"icacls failed to deny write access on $LogiPath (exit code $LASTEXITCODE)\" }
"
],
"UndoScript": [
"
$ProgramFiles64 = if ($Env:ProgramW6432) { $Env:ProgramW6432 } else { $Env:ProgramFiles }
$LogiPath = \"$ProgramFiles64\\LogiDownloadAssistant\"
if (Test-Path $LogiPath) {
icacls $LogiPath /remove:d \"*S-1-1-0\"
if ($LASTEXITCODE -ne 0) { throw \"icacls failed to remove the write-deny rule on $LogiPath (exit code $LASTEXITCODE)\" }
}
"
],
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/logiblock"
},
"WPFTweaksDisableNotifications": {
"Content": "System Tray Notifications & Calendar - Disable",
"Description": "Disables all Notifications INCLUDING Calendar.",
@@ -1086,8 +1121,10 @@
"panel": "1",
"InvokeScript": [
"
Remove-Item -Path \"$Env:Temp\\*\" -Recurse -Force
Remove-Item -Path \"$Env:SystemRoot\\Temp\\*\" -Recurse -Force
# A temp folder always holds files something has open, including this run's own, and
# the job layer counts a logged error as a failed step
Remove-Item -Path \"$Env:Temp\\*\" -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path \"$Env:SystemRoot\\Temp\\*\" -Recurse -Force -ErrorAction SilentlyContinue
"
],
"link": "https://winutil.christitus.com/code-reference/tweaks/essential-tweaks/deletetempfiles"
@@ -1168,22 +1205,6 @@
],
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/disablebgapps"
},
"WPFTweaksDisableFSO": {
"Content": "Fullscreen Optimizations - Disable",
"Description": "Disables FSO in all applications. NOTE: This will disable Color Management in Exclusive Fullscreen.",
"category": "z__Advanced Tweaks - CAUTION",
"panel": "1",
"registry": [
{
"Path": "HKCU:\\System\\GameConfigStore",
"Name": "GameDVR_DXGIHonorFSEWindowsCompatible",
"Value": "1",
"Type": "DWord",
"OriginalValue": "0"
}
],
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/disablefso"
},
"WPFTweaksDisableExplorerAutoDiscovery": {
"Content": "File Explorer Automatic Folder Discovery - Disable",
"Description": "Windows Explorer automatically tries to guess the type of the folder based on its contents, slowing down the browsing experience. WARNING! Will disable File Explorer grouping.",
@@ -1401,7 +1422,7 @@
},
"WPFToggleNewOutlook": {
"Content": "Microsoft Outlook New Version",
"Description": "This will ensures the classic Outlook application is used.",
"Description": "This will ensure the new Outlook application is used.",
"category": "Customize Preferences",
"panel": "2",
"Type": "Toggle",
@@ -1459,28 +1480,40 @@
],
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/scrollbars"
},
"WPFToggleMultiplaneOverlay": {
"WPFMultiplaneOverlay": {
"Content": "Multiplane Overlay",
"Description": "Multiplane Overlay compose multiple image layers, which can sometimes cause issues with graphics cards.",
"Description": "Multiplane Overlay composes multiple image layers, which can sometimes cause issues with graphics cards. Changes to this preference are applied immediately.",
"category": "Customize Preferences",
"panel": "2",
"Type": "Toggle",
"Type": "Combobox",
"ComboItems": "Enabled|Disabled (Compatibility)|Fully Disabled",
"ComboDescriptions": {
"Enabled": "Uses Windows' default overlay behavior.",
"Disabled (Compatibility)": "Disables MPO using OverlayTestMode=5, the less aggressive compatibility method.",
"Fully Disabled": "Disables MPO using OverlayTestMode=5 and DisableOverlays=1, the more aggressive method."
},
"registry": [
{
"Path": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\Dwm",
"Name": "OverlayTestMode",
"Value": "0",
"Type": "DWord",
"OriginalValue": "5",
"DefaultState": "true"
"DefaultValue": "0",
"Values": {
"Enabled": "<RemoveEntry>",
"Disabled (Compatibility)": "5",
"Fully Disabled": "5"
}
},
{
"Path": "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\GraphicsDrivers",
"Name": "DisableOverlays",
"Value": "0",
"Type": "DWord",
"OriginalValue": "1",
"DefaultState": "true"
"DefaultValue": "0",
"Values": {
"Enabled": "<RemoveEntry>",
"Disabled (Compatibility)": "<RemoveEntry>",
"Fully Disabled": "1"
}
}
],
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/multiplaneoverlay"
@@ -1653,7 +1686,7 @@
],
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/loginblur"
},
"WPFTweaksDisableLockscreen": {
"WPFToggleDisableLockscreen": {
"Content": "Lock Screen - Disable",
"Description": "Skips the lock screen entirely and goes directly to the sign-in screen on boot and wake.",
"category": "Customize Preferences",
@@ -1852,7 +1885,7 @@
"category": "z__Advanced Tweaks - CAUTION",
"panel": "1",
"Type": "Combobox",
"ComboItems": "Default DHCP Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult",
"ComboItems": "Default DHCP Fastest Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult",
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/changedns"
},
"WPFAddUltPerf": {
+7
View File
@@ -0,0 +1,7 @@
node_modules
.astro
dist
.git
.env
.env.*
*.log
+2
View File
@@ -16,6 +16,8 @@ pnpm-debug.log*
# environment variables
.env
.env.production
.env.local
.env.*.local
# macOS-specific files
.DS_Store
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-bookworm-slim
RUN corepack enable
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN chown -R node:node /app
USER node
EXPOSE 4321
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+30 -18
View File
@@ -1,26 +1,24 @@
# Starlight Starter Kit: Basics
# WinUtil Docs
[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build)
```
npm create astro@latest -- --template starlight
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
Documentation site for [WinUtil](https://github.com/ChrisTitusTech/winutil), built with [Astro](https://astro.build) and [Starlight](https://starlight.astro.build). Served at [winutil.christitus.com](https://winutil.christitus.com/).
## 🚀 Project Structure
Inside of your Astro + Starlight project, you'll see the following folders and files:
```
.
├── public/
├── src/
│ ├── assets/
│ ├── components/
│ ├── content/
│ │ └── docs/
│ ├── styles/
│ └── content.config.ts
├── astro.config.mjs
├── docker-compose.yml
├── Dockerfile
├── package.json
└── tsconfig.json
```
@@ -33,17 +31,31 @@ Static assets, like favicons, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
All commands run in a Docker container — there's no need to install Node or npm dependencies on your host. This is deliberate, not just convenience: npm/pnpm/yarn have seen a steady stream of supply-chain attacks (malicious `postinstall`/`preinstall` scripts, credential-stealing packages), so `npm install` and friends never run directly on a contributor's machine here. Note the container still has read-write access to this `docs/` directory (it's bind-mounted for live reload), so this only contains a compromised package to the project folder plus the container itself — it doesn't reach the rest of your host (SSH keys, other repos, cloud credentials elsewhere on disk). Don't keep real secrets in `docs/` as a result.
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
[Docker](https://www.docker.com/) (with Compose) is required — install Docker Desktop (or Docker Engine + the `docker compose` plugin on Linux) and make sure the daemon is running before using any of the commands below.
All commands are run from the `docs/` directory, from a terminal:
| Command | Action |
| :------------------------------------------------ | :----------------------------------------------- |
| `docker compose build` | Builds the dev image (needed after Dockerfile or dependency changes) |
| `docker compose up winutil-astro` | Starts local dev server at `localhost:4321` |
| `docker compose run --rm winutil-astro npm run build` | Build the production site to `./dist/` |
| `docker compose run --rm --service-ports winutil-astro npm run preview -- --host 0.0.0.0` | Preview the build locally, before deploying |
| `docker compose run --rm winutil-astro npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `docker compose down` | Stop and remove the dev container |
Source files are bind-mounted into the container, so edits on the host are picked up immediately by the dev server — no rebuild needed for normal content or code changes. After changing `package.json`, `package-lock.json`, or the `Dockerfile`, rebuild the image *and* drop the `node_modules` volume, since Docker only seeds a named volume from the image the first time it's created — a plain rebuild leaves the old `node_modules` in place:
```sh
docker compose build
docker compose down -v
docker compose up winutil-astro
```
The first `docker compose up` (or any command before an image exists) builds the image and runs `npm install` from scratch, which can take a few minutes. Subsequent runs reuse the cached image and start almost immediately.
## 👀 Want to learn more?
Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
Check out [Starlight's docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
+7
View File
@@ -15,6 +15,12 @@ export default defineConfig({
replacesTitle: true,
},
favicon: '/favicon.svg',
head: [
{ tag: 'meta', attrs: { property: 'og:image', content: 'https://winutil.christitus.com/social-preview.png' } },
{ tag: 'meta', attrs: { property: 'og:image:width', content: '1200' } },
{ tag: 'meta', attrs: { property: 'og:image:height', content: '630' } },
{ tag: 'meta', attrs: { name: 'twitter:image', content: 'https://winutil.christitus.com/social-preview.png' } },
],
social: [
{ icon: 'github', label: 'GitHub', href: 'https://github.com/ChrisTitusTech/winutil' },
{ icon: 'discord', label: 'Discord', href: 'https://discord.gg/RUbZUZyByQ' },
@@ -50,6 +56,7 @@ export default defineConfig({
label: 'Code Reference',
items: [
{ label: 'Architecture & Design', slug: 'code-reference/architecture' },
{ label: 'Issue Triage Commands', slug: 'code-reference/issue-triage' },
{ label: 'Tweaks Reference', items: [{ autogenerate: { directory: 'code-reference/tweaks' } }] },
{ label: 'Features Reference', items: [{ autogenerate: { directory: 'code-reference/features' } }] },
],
+16
View File
@@ -0,0 +1,16 @@
services:
winutil-astro:
build: .
ports:
- "127.0.0.1:4321:4321"
volumes:
- .:/app
- astro_node_modules:/app/node_modules
tmpfs:
- /app/.astro
environment:
- CHOKIDAR_USEPOLLING=true
- ASTRO_TELEMETRY_DISABLED=1
volumes:
astro_node_modules:
+540 -269
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -13,7 +13,7 @@
"@astrojs/starlight": "^0.41.5",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"astro": "^7.0.2",
"sharp": "^0.35.3"
"astro": "^7.3.2",
"sharp": "^0.35.4"
}
}
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://winutil.christitus.com/sitemap-index.xml
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -79,14 +79,15 @@ winutil/
**Why**: Makes distribution easier (single file) and improves load time.
#### 2. scripts/main.ps1
**Purpose**: Entry point that initializes the GUI and event system.
**Purpose**: Entry point that manages the run.
**Responsibilities**:
- Load XAML and create WPF window
- Initialize form elements
- Set up event handlers
- Load configurations
- Display the GUI
- Run the headless `-Preset` and `-Config` paths
- Start the interface on a dedicated STA runspace and wait for it
- Report anything the interface thread failed with, then clean up
The interface itself lives in `Start-WinUtilUserInterface`, not here. See [Threading Model](#threading-model).
#### 3. functions/public/
**Purpose**: User-facing functions that implement main features.
@@ -129,14 +130,14 @@ winutil/
## Win11 Creator Architecture
The **Win11 Creator** is a specialized subsystem within Winutil that creates customized Windows 11 ISOs. It operates independently from the main package installation and tweak system.
The **Win11 Creator** is a specialized subsystem within Winutil that creates customized Windows 11 ISOs. It operates independently of the main package installation and tweak system.
### Win11 Creator Components
**Core Functions** (`functions/private/`):
- `Invoke-WinUtilISO.ps1`: Main orchestrator containing all Win11 Creator functions
- `Invoke-WinUtilISOBrowse`: ISO file selection dialog
- `Invoke-WinUtilISOMountAndVerify`: Validates and mounts ISO, verifies it's official Windows 11
- `Invoke-WinUtilISOMountAndVerify`: Validates and mounts ISO, verifies it is an official Windows 11 ISO
- `Invoke-WinUtilISOModify`: Launches modification in background runspace
- `Invoke-WinUtilISOExport`: Handles ISO and USB export
- `Invoke-WinUtilISOCheckExistingWork`: Recovers incomplete work sessions
@@ -149,8 +150,6 @@ The **Win11 Creator** is a specialized subsystem within Winutil that creates cus
- Applies offline registry tweaks (hardware bypass, privacy, telemetry, OOBE)
- Deletes telemetry scheduled task definitions
- Pre-stages setup scripts from autounattend.xml
- Removes unused Windows editions
- Cleans component store via DISM
### Win11 Creator Data Flow
@@ -181,16 +180,14 @@ Invoke-WinUtilISOModify (runs in background runspace)
│ ├─ Delete telemetry scheduled task files
│ ├─ Pre-stage setup scripts from autounattend.xml to C:\Windows\Setup\Scripts\
│ └─ Unload registry hives
├─ DISM /Cleanup-Image /StartComponentCleanup /ResetBase (saves 300-800 MB)
├─ Dismount and save the modified install.wim (~10+ minutes, slowest step)
├─ Export selected edition only (removes all other editions, saves 1-2 GB each)
├─ Dismount source ISO
└─ Report completion, enable export options
↓
Invoke-WinUtilISOExport (user chooses output)
├─ Option 1: Save as ISO
│ ├─ Build bootable ISO via oscdimg.exe (BIOS/UEFI dual-boot)
│ └─ Output: Win11_Modified_[date].iso (2.5-3.5 GB)
│ └─ Output: Win11_Modified_[date].iso (close to the source ISO size)
│
└─ Option 2: Write to USB
├─ Format USB as GPT
@@ -213,7 +210,7 @@ Invoke-WinUtilISOCleanAndReset (optional)
**Work Session Recovery**:
- Auto-detects incomplete work from previous sessions
- Allows resuming Step 4 (export) without re-running Steps 1-3
- Allows resuming the export step without re-running selection and modification
- Prevents redundant modifications
**Modification Safety**:
@@ -272,7 +269,7 @@ The `Invoke-WinUtilISOScript` function applies **50+ offline registry tweaks**:
- **Temporary working directory**: ~10-15 GB
- **Original ISO**: 4-6 GB
- **Modified ISO**: 2.5-3.5 GB
- **Modified ISO**: close to the source ISO size
- **Total needed**: ~25 GB for safe operation
## Data Flow
@@ -389,26 +386,73 @@ Update UI
- `Description`: What it does
- `category`: Essential/Advanced/Customize
- `registry`: Registry changes to make
- `registry[].Values`: Per-state values for a registry-backed combobox
- `registry[].DefaultValue`: Effective value when the registry entry is absent
- `service`: Services to change
- `OriginalValue/State`: For undo functionality
## PowerShell Runspace
## Threading Model
Winutil uses PowerShell runspaces for the GUI to remain responsive:
WinUtil runs on three kinds of thread, and each has one job:
| Thread | Runspace | Responsibility |
| --- | --- | --- |
| Main | The one the script started in | Start the interface, wait for it, surface its errors, clean up |
| Interface | `$sync.UIRunspace`, a dedicated STA runspace | Own the window. Paint and dispatch, nothing else |
| Workers | `$sync.runspace`, a shared pool | Run everything long: installs, tweaks, features, AppX, Win11 Creator |
All three are created from the same starting point, `New-WinUtilSessionState`, which carries
`$sync`, the compiled script's globals, and every WinUtil function. That is what lets any
thread call any helper without the caller injecting function definitions.
```powershell
# Create runspace
$sync.runspace = [runspacefactory]::CreateRunspace()
$sync.runspace.Open()
$sync.runspace.SessionStateProxy.SetVariable("sync", $sync)
# main.ps1 - the interface gets its own thread
$sync.UIRunspace = [runspacefactory]::CreateRunspace($Host, (New-WinUtilSessionState))
$sync.UIRunspace.ApartmentState = "STA"
$sync.UIRunspace.Open()
# Run code in background
$powershell = [powershell]::Create().AddScript($scriptblock)
$powershell.Runspace = $sync.runspace
$handle = $powershell.BeginInvoke()
$uiShell = [powershell]::Create()
$uiShell.Runspace = $sync.UIRunspace
[void]$uiShell.AddScript({ Start-WinUtilUserInterface })
$uiHandle = $uiShell.BeginInvoke()
$uiHandle.AsyncWaitHandle.WaitOne()
```
**Why**: Prevents UI freezing during long-running operations.
**Why**: the window never blocks on work, and a failure on the interface thread is reported
instead of disappearing.
## Long-Running Work
Every long action goes through `Start-WinUtilJob`, which owns everything a running operation
needs: refusing to start while another job runs, the busy flag (`$sync.ActiveJob`), the progress
bar and taskbar item, the boxed console banner, a start/finish/failure line in the log, and
restoring the interface in a `finally` whatever happens.
`Invoke-WPFButton` decides what counts as a long action. Anything that changes the system gets a
job; anything that only changes what the interface is showing runs on the interface thread.
That single classification is why no workflow arranges its own progress, banner or busy state.
```powershell
Start-WinUtilJob -Name "Features" -Description "Installing Windows Features" -Parameters @{
Features = @($sync.selectedFeatures)
} -ScriptBlock {
param($Features)
$total = @($Features).Count
$completed = 0
foreach ($feature in $Features) {
$completed++
Step-WinUtilJob -Status "Installing $feature ($completed/$total)" -Percent ([int](($completed / $total) * 100))
Invoke-WinUtilFeatureInstall $feature
}
}
```
The body only has to do the work and call `Step-WinUtilJob`. Anything it throws is
caught, logged, and shown on the taskbar as a failure.
**Values, not closures**: the body is rebuilt inside the worker from its text, so it receives
what it needs through `-Parameters` rather than capturing the caller's variables.
## WPF Event Handling
@@ -454,23 +498,21 @@ if (!(Get-Command choco -ErrorAction SilentlyContinue)) {
choco install $app.choco -y
```
## Error Handling
## Error Handling And Logging
Winutil uses PowerShell error handling:
A job body does not need its own error handling. `Start-WinUtilJob` catches whatever the body
throws, logs it, and marks the run as failed on the taskbar, so a failure can never leave the
interface stuck busy. Only catch inside a body when you have something specific to do first,
such as cleaning up a mounted image, and then rethrow.
```powershell
try {
# Attempt operation
Invoke-SomeOperation
}
catch {
Write-Host "Error: $_" -ForegroundColor Red
# Log error
Add-Content -Path $logfile -Value "ERROR: $_"
}
Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "winget install failed: $($_.Exception.Message)"
```
**Logging**: Errors and operations are logged for debugging.
Entries and console diagnostics go to the same
`%LocalAppData%\winutil\logs\winutil_<timestamp>.log` session file. While `Start-Transcript`
owns that file, `Write-WinUtilLog` writes through the host so the transcript captures the entry
without a competing direct file write.
## Configuration Loading
@@ -488,15 +530,23 @@ $sync.configs.features = Get-Content "config/feature.json" | ConvertFrom-Json
## UI Update Pattern
UI updates must happen on the UI thread:
Controls may only be touched from the thread that owns the window, so background work reaches
them through `Invoke-WPFUIThread`:
```powershell
$sync.form.Dispatcher.Invoke([action]{
$sync.WPFStatusLabel.Content = "Installing..."
}, "Normal")
Invoke-WPFUIThread -Parameters @{ Count = $installed.Count } -ScriptBlock {
param($Count)
$sync.WPFselectedAppsButton.Content = "Selected Apps: $Count"
}
```
**Why**: WPF requires UI updates on the main thread.
Add `-Async` to post the update instead of waiting for it. `Step-WinUtilJob` and the
Win11 Creator status log use that so a per-item update never stalls the worker.
**Values, not closures**: the body is rebuilt inside the interface runspace, so it takes what it
needs through `-Parameters`. Handing over a scriptblock from a worker instead would keep that
worker's session state, which loses the caller's variables on an async post and costs roughly
twenty times as much per command - enough to turn a checkbox refresh into a visible freeze.
## Adding New Features
@@ -125,9 +125,9 @@ function Invoke-WPFFixesUpdate {
if (Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate") {
Write-Progress -Id 0 -Activity "Repairing Windows Update" -Status "Removing WSUS client settings..." -PercentComplete 60
Write-Progress -Id 6 -ParentId 0 -Activity "Removing WSUS client settings" -PercentComplete 0
Start-Process -NoNewWindow -FilePath "REG" -ArgumentList "DELETE", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate", "/v", "AccountDomainSid", "/f" -RedirectStandardError "NUL"
Start-Process -NoNewWindow -FilePath "REG" -ArgumentList "DELETE", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate", "/v", "PingID", "/f" -RedirectStandardError "NUL"
Start-Process -NoNewWindow -FilePath "REG" -ArgumentList "DELETE", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate", "/v", "SusClientId", "/f" -RedirectStandardError "NUL"
Remove-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate" -Name "AccountDomainSid" -ErrorAction SilentlyContinue
Remove-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate" -Name "PingID" -ErrorAction SilentlyContinue
Remove-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate" -Name "SusClientId" -ErrorAction SilentlyContinue
Write-Progress -Id 6 -ParentId 0 -Activity "Removing WSUS client settings" -Status "Completed" -PercentComplete 100
}
@@ -0,0 +1,26 @@
---
title: Issue Triage Commands
description: Commands trusted maintainers can use to label and close GitHub issues.
---
Trusted repository members whose numeric GitHub user IDs are listed in the issue triage workflow can run the commands below. These commands work only on issues. Comments on pull requests are ignored, and the workflow token has no pull-request write permission.
## Commands
- `/triage` adds the `needs-triage` label.
- `/triageoff` removes the `needs-triage` label when it is present.
- `/np` closes the issue as not planned.
- `/np wontfix` adds the `wontfix` label without replacing existing labels, then closes the issue as not planned.
- `/np notrelated` adds the `not-related` label without replacing existing labels, then closes the issue as not planned.
- `/np <reason>` closes the issue as not planned without adding a label for unrecognized reasons.
- `/duplicate <issue number>` closes the issue as a duplicate of another issue. The positive issue number may optionally start with `#`; pull request numbers are rejected.
## Command handling
Put exactly one command in the comment. Leading and trailing whitespace is allowed, but text before or after the command is not. Command matching is case-insensitive, and malformed issue numbers are rejected.
After a command is handled, the workflow deletes the command comment and leaves an audit comment describing the result. A missing duplicate target or a request to duplicate an issue into itself is reported without closing the issue. Unexpected GitHub API failures stop the workflow and leave the command comment available for retry.
## Access
Access is granted by adding the trusted member's immutable numeric GitHub user ID to `.github/workflows/triage-tools.yaml` through a pull request. Abuse can result in removal from the allowlist or other repository moderation action.
@@ -1,6 +1,6 @@
---
title: "Multiplane Overlay"
description: "Multiplane Overlay compose multiple image layers, which can sometimes cause issues with graphics cards."
description: "Multiplane Overlay composes multiple image layers, which can sometimes cause issues with graphics cards. Changes to this preference are applied immediately."
editUrl: false
---
@@ -9,28 +9,40 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
:::
```json title="config/tweaks.json"
"WPFToggleMultiplaneOverlay": {
"WPFMultiplaneOverlay": {
"Content": "Multiplane Overlay",
"Description": "Multiplane Overlay compose multiple image layers, which can sometimes cause issues with graphics cards.",
"Description": "Multiplane Overlay composes multiple image layers, which can sometimes cause issues with graphics cards. Changes to this preference are applied immediately.",
"category": "Customize Preferences",
"panel": "2",
"Type": "Toggle",
"Type": "Combobox",
"ComboItems": "Enabled|Disabled (Compatibility)|Fully Disabled",
"ComboDescriptions": {
"Enabled": "Uses Windows' default overlay behavior.",
"Disabled (Compatibility)": "Disables MPO using OverlayTestMode=5, the less aggressive compatibility method.",
"Fully Disabled": "Disables MPO using OverlayTestMode=5 and DisableOverlays=1, the more aggressive method."
},
"registry": [
{
"Path": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\Dwm",
"Name": "OverlayTestMode",
"Value": "0",
"Type": "DWord",
"OriginalValue": "5",
"DefaultState": "true"
"DefaultValue": "0",
"Values": {
"Enabled": "<RemoveEntry>",
"Disabled (Compatibility)": "5",
"Fully Disabled": "5"
}
},
{
"Path": "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\GraphicsDrivers",
"Name": "DisableOverlays",
"Value": "0",
"Type": "DWord",
"OriginalValue": "1",
"DefaultState": "true"
"DefaultValue": "0",
"Values": {
"Enabled": "<RemoveEntry>",
"Disabled (Compatibility)": "<RemoveEntry>",
"Fully Disabled": "1"
}
}
],
}
@@ -17,7 +17,7 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
"service": [
{
"Name": "lfsvc",
"StartupType": "Disable",
"StartupType": "Disabled",
"OriginalType": "Manual"
}
],
@@ -1,33 +0,0 @@
---
title: "Fullscreen Optimizations - Disable"
description: "Disables FSO in all applications. NOTE: This will disable Color Management in Exclusive Fullscreen."
editUrl: false
---
:::note
This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitusTech/winutil/blob/main/config/tweaks.json). Do not edit this page directly.
:::
```json title="config/tweaks.json"
"WPFTweaksDisableFSO": {
"Content": "Fullscreen Optimizations - Disable",
"Description": "Disables FSO in all applications. NOTE: This will disable Color Management in Exclusive Fullscreen.",
"category": "z__Advanced Tweaks - CAUTION",
"panel": "1",
"registry": [
{
"Path": "HKCU:\\System\\GameConfigStore",
"Name": "GameDVR_DXGIHonorFSEWindowsCompatible",
"Value": "1",
"Type": "DWord",
"OriginalValue": "0"
}
],
}
```
## Registry Changes
Applications and System Components store and retrieve configuration data to modify Windows settings, so we can use the registry to change many settings in one place.
You can find information about the registry on [Wikipedia](https://en.wikipedia.org/wiki/Windows_Registry) and [Microsoft's Website](https://learn.microsoft.com/en-us/windows/win32/sysinfo/registry).
@@ -14,6 +14,6 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
"category": "z__Advanced Tweaks - CAUTION",
"panel": "1",
"Type": "Combobox",
"ComboItems": "Default DHCP Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult",
"ComboItems": "Default DHCP Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult Mullvad Mullvad_Ads_Trackers Mullvad_Ads_Trackers_Malware Mullvad_Ads_Trackers_Malware_Social Mullvad_Ads_Trackers_Malware_Adult_Gambling Mullvad_Ads_Trackers_Malware_Adult_Gambling_Social",
}
```
+1 -1
View File
@@ -101,7 +101,7 @@ While you can make your changes directly through the Web, we recommend cloning t
* Run the following command to compile and run WinUtil:
* `.\Compile.ps1 -run`
![Compile](../../assets/contributing/Complie.png)
![Compile](../../assets/contributing/Compile.png)
* After seeing that your changes work properly, feel free to commit the changes to the repository and make a PR. For help on that, follow the documentation below.
+7 -1
View File
@@ -42,6 +42,12 @@ Use the Applications tab to install, upgrade, uninstall, and review supported ap
![Collapse or expand all categories](../../../assets/screenshots/install-pics/Collapse&ExpandCat.png)
</TabItem>
<TabItem label="Category Filters">
* Click a category chip at the top of the tab to show only that category. The chip stays highlighted while its filter is active.
* Hold `Ctrl` and click to add more categories to the filter, or to remove one again.
* Click `All`, or click the highlighted category again while it is the only one selected, to clear the filter.
* Categories with matching results open while a filter is active. Ones that filtering opened for you go back to collapsed when you clear it, ones you opened yourself stay open.
</TabItem>
<TabItem label="Selected Apps Counter">
* The `Selected Apps` counter in the sidebar shows how many applications are currently selected.
* Use it to keep track of your selection as you browse categories.
@@ -57,7 +63,7 @@ Use the Applications tab to install, upgrade, uninstall, and review supported ap
</Tabs>
:::tip
If you have trouble finding an application, press `Ctrl + F` and search for its name. The list filters as you type.
If you have trouble finding an application, press `Ctrl + F` and search for its name. The list filters as you type. The search and the category chips work together, so you can search inside the categories you picked.
:::
:::note
@@ -22,6 +22,8 @@ Example:
To view exactly what each preset does, see:
https://github.com/ChrisTitusTech/winutil/blob/main/config/preset.json
To find a key for a custom configuration, hover over a supported application, tweak, feature, or AppX entry in WinUtil. Its tooltip shows `Preset key: <key>`. Application keys are searchable on the Install tab, while tweak and AppX keys are searchable on their respective tabs. Feature keys are available from their tooltips because the Config/Features tab does not have search. Controls that cannot be applied from a preset, such as toggle switches, drop-downs, and package-manager choices, intentionally do not advertise a preset key.
To create your own config file:
1. Open WinUtil.
@@ -40,6 +42,14 @@ This is useful for:
- Reusing a known-good baseline after reinstalling Windows
- Standardizing deployments for labs, workstations, or personal setups
:::caution[Keep exported configurations current]
Exported configurations contain the WinUtil catalog keys that existed when the file was created. Current exports use a flat JSON format: one string when a single setting is selected, or an array of strings when several settings are selected. If a later WinUtil version removes or renames any imported key, the entire import is rejected before current selections are changed. PowerShell reports the stale entry as `Unknown selection key '<key>'`.
Older WinUtil versions exported a JSON object with `Install` package metadata and grouped `WPFInstall`, `WPFTweaks`, `WPFToggle`, and `WPFFeature` selections. When importing one of these legacy files, WinUtil restores the keys that still exist and skips retired keys, recording them as a warning in the WinUtil log. If the file contains no supported selections, the import makes no changes.
To recover, compare the reported key with the current files in the [WinUtil configuration catalog](https://github.com/ChrisTitusTech/winutil/tree/main/config). Remove or replace the stale key in your JSON file, or create and export a new configuration with the current WinUtil version, then run the import again. Re-export long-lived baselines after catalog changes so they remain compatible.
:::
:::note
Run the command in an elevated PowerShell session so WinUtil can apply system-level changes.
:::
@@ -56,3 +56,7 @@ Open old-school Windows panels directly from WinUtil. Available panels include:
Enable an OpenSSH server on your Windows machine for remote access.
Only enable this if you intend to use remote shell access. After turning it on, verify your firewall rules and account permissions before exposing the machine to other devices.
Because WinUtil runs elevated, the account it sets up is an administrator, and sshd reads administrator keys from `C:\ProgramData\ssh\administrators_authorized_keys` rather than from your profile. WinUtil creates that file and restricts it to Administrators and SYSTEM, which is what sshd requires. Add your public keys there. If an earlier WinUtil version changed `sshd_config` to read administrator keys from `%USERPROFILE%\.ssh\authorized_keys`, that is undone and any keys in it are copied across, so key auth keeps working.
Non-administrator accounts keep using `%USERPROFILE%\.ssh\authorized_keys` and need no extra setup.
@@ -235,6 +235,21 @@ Now that you're set up, explore these guides:
- [Tweaks Guide](/guides/tweaks/) — Understand system optimizations
- [FAQ](/faq/) — Common questions and answers
## Exporting a diagnostics report
If you're reporting a problem, click the gear icon in the top-right corner and choose **Export Environment Report**. It saves a read-only JSON file with:
- Windows edition, version, build, and architecture
- CPU model, logical processor count, and total memory
- PowerShell edition, version, and execution policy
- Whether WinGet and Chocolatey are installed, and their versions
- Whether a reboot is pending
- The current applied/not-applied state of every tweak and toggle
It does not include computer or user names, paths, IP or MAC addresses, serial numbers, installed-app inventories, services, raw registry paths or values, secrets, or logs. The tweak/toggle state is derived from a registry comparison, but only the resulting true/false per tweak is included, not any registry content itself. WinUtil never uploads the report — you choose where to save it and who to send it to.
You'll be asked whether to also bundle the last 7 days of WinUtil logs into a companion `.txt` file, which maintainers often need alongside the report to diagnose an issue. This companion is raw log output and does not use the JSON report's privacy allowlist: it may contain local paths, commands, and error details. Review it before sharing.
## Getting help
If you need assistance:
+1
View File
@@ -65,6 +65,7 @@ Use the DNS section to switch both IPv4 and IPv6 DNS providers without editing a
* **Default**: Uses the default DNS settings configured by your ISP or network.
* **DHCP**: Automatically acquires DNS settings from the DHCP server.
* **Fastest**: Compares TCP port 53 connection times to Google and Cloudflare, then applies the quicker provider. Filtering providers are excluded. If both probes fail, existing DNS settings are preserved. The scan takes up to about three seconds; connection time is only a rough latency estimate, not a DNS lookup or DNS-over-HTTPS performance test.
* [**Google**](https://developers.google.com/speed/public-dns?hl=en): A reliable and fast DNS service provided by Google.
* [**Cloudflare**](https://developers.cloudflare.com/1.1.1.1/): Known for speed and privacy, Cloudflare DNS is a popular choice for enhancing internet performance.
* [**Cloudflare_Malware**](https://developers.cloudflare.com/1.1.1.1/setup/#:~:text=Use%20the%20following%20DNS%20resolvers%20to%20block%20malicious%20content%3A): Provides additional protection by blocking malware sites.
+17 -17
View File
@@ -23,29 +23,26 @@ This workflow is intended for fresh Windows installs, not in-place upgrades of a
---
The tab is a three-step wizard. The step you are on is shown in the header row, and you can click an earlier step to go back to it until the image has been modified. The status log on the right stays visible throughout.
### Step 1 — Select Your Official Windows 11 ISO
1. Open WinUtil and go to the **Win11 Creator** tab.
2. Click **Browse** and select your **official Windows 11 ISO file** from Microsoft (must be 4 GB or larger). Custom or modified ISOs are not supported.
3. The file path and size will appear on screen once selected.
---
### Step 2 — Mount & Verify
1. Click **Mount & Verify ISO**.
2. WinUtil mounts the ISO, checks for a valid `install.wim` or `install.esd`, and reads the available editions (Home, Pro, Enterprise, etc.).
3. Once verified, select your desired **edition** from the dropdown — Pro is selected by default if available.
4. Click **Mount & Verify ISO**. WinUtil mounts the ISO, checks for a valid `install.wim` or `install.esd`, and reads the available editions (Home, Pro, Enterprise, etc.).
:::note
This step takes around 10–30 seconds, depending on your drive speed.
Mounting and verification takes around 10–30 seconds, depending on your drive speed.
:::
---
### Step 3 — Run the Modification
### Step 2 — Modify the Image
Click **Run Windows ISO Modification and Creator** to start the customization process. WinUtil will:
Once the ISO is verified, WinUtil moves to this step and shows the mounted drive and image file. Choose your **edition** from the dropdown — Pro is selected by default if available — and tick **Inject current system drivers** if you want them.
Then click **Run Windows ISO Modification and Creator** to start the customization process. WinUtil will:
**App & Component Removal:**
- **Remove 40+ bloat apps** — Clipchamp, Teams, Copilot, Dev Home, new Outlook, Bing apps, Solitaire, and more
@@ -56,9 +53,7 @@ Click **Run Windows ISO Modification and Creator** to start the customization pr
- **Enable local account setup** — injects an `autounattend.xml` that skips the Microsoft account screen during OOBE
- **Disable BitLocker and device encryption** — removes startup overhead
- **Disable Chat icon** — removes chat taskbar button
- **Strip unused editions** — keeps only your selected edition, saving 1–2 GB per removed edition
- **Pin the selected edition during setup** — writes setup metadata so OEM firmware keys for a different edition do not force the installer down the wrong product-key path
- **Clean the component store** — runs DISM cleanup to reclaim another 300–800 MB
**Privacy & Telemetry Tweaks:**
- **Disable telemetry** — advertising ID, tailored experiences, input personalization, speech online privacy
@@ -72,13 +67,17 @@ Click **Run Windows ISO Modification and Creator** to start the customization pr
- **Disable Copilot and search box suggestions**
**Optional: Driver Injection**
- If enabled, it injects all drivers from your current system into the install.wim and boot.wim — useful for offline installations on machines with missing drivers. This is an optional checkbox in Step 3.
- If enabled, WinUtil exports the drivers from your current system, stages boot-storage drivers for Windows Setup, and injects eligible packages into the selected `install.wim` image. Stale duplicate packages are excluded before injection. Each remaining package is added separately, so one incompatible package does not stop the others. If a package fails, WinUtil discards the partial mount and retries the remaining packages against the original image. Check the live log for warnings: if every package fails, WinUtil keeps the original `install.wim` and still produces the ISO.
A live log shows progress as each step completes. This stage usually takes **10–30 minutes** depending on disk speed. The WIM dismount near the end is the slowest part, so do not close WinUtil while it is running.
:::note
The resulting ISO is close to the size of the source ISO. WinUtil does not remove the unused editions from `install.wim`; it selects your edition through `sources\ei.cfg` and `autounattend.xml` so Windows Setup installs the right one.
:::
---
### Step 4 — Export Your Result
### Step 3 — Export Your Result
Once the modification is complete, choose how to save your image:
@@ -108,9 +107,9 @@ Double-check you have selected the correct drive before confirming. This operati
---
### Step 5 — Clean Up (Optional)
### Start Over (Optional)
Click **Clean & Reset** to delete the temporary working directory (~10–15 GB) and return the tool to its initial state, ready for a new ISO. You will be asked to confirm before anything is deleted.
Click **Start Over** to delete the temporary working directory (~10–15 GB) and return the tool to its initial state, ready for a new ISO. You will be asked to confirm before anything is deleted.
---
@@ -136,6 +135,7 @@ When you install Windows 11 from your modified ISO:
| USB drive not showing up | Plug it in, wait a few seconds, then click **Refresh** |
| Modification seems stuck | The WIM dismount step is slow — wait at least 10 minutes before assuming it's frozen |
| "Access Denied" error | Make sure WinUtil is running as Administrator |
| Driver injection warning | Review the live log for the named package. Other compatible packages continue; if none succeed, the ISO is created with the original `install.wim` |
| "Setup has failed to validate the product key" | Recreate the ISO with the latest WinUtil. The creator now removes stale `PID.txt`, writes `sources\ei.cfg`, and pins the selected image in `autounattend.xml` so setup does not use an embedded OEM key for a different edition |
---
+32
View File
@@ -27,6 +27,38 @@ hero:
link: https://github.com/ChrisTitusTech/winutil
icon: external
variant: secondary
head:
- tag: meta
attrs:
property: og:title
content: Documentation | WinUtil
- tag: meta
attrs:
name: twitter:title
content: Documentation | WinUtil
- tag: script
attrs:
type: application/ld+json
content: |
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "WinUtil",
"description": "Chris Titus Tech's Windows Utility — install apps, apply tweaks, run fixes, and manage Windows from one place.",
"url": "https://winutil.christitus.com/",
"downloadUrl": "https://github.com/ChrisTitusTech/winutil",
"operatingSystem": "Windows",
"applicationCategory": "UtilitiesApplication",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"author": {
"@type": "Person",
"name": "Chris Titus Tech"
}
}
---
import { Icon, Code } from '@astrojs/starlight/components';
+4 -4
View File
@@ -9,7 +9,7 @@ If you run WinUtil and get an error like:
`< : The term '<' is not recognized as the name of a cmdlet, function, script file, or operable program.`
try using a **VPN** and if that doesn't work than report the issue to https://github.com/ChrisTitusTech/winutil/issues
try using a **VPN** and if that doesn't work then report the issue to https://github.com/ChrisTitusTech/winutil/issues
### Script Won't Run
@@ -17,13 +17,13 @@ If you run WinUtil and get the error:
`"WinUtil is unable to run on your system. PowerShell execution is restricted by security policies"`
this means that your PowerShell session is in **Constrained Language Mode**, which prevents WinUtil from running.
This means that your PowerShell session is in **Constrained Language Mode**, which prevents WinUtil from running.
### Ultimate Performance Plan Not Working
The Ultimate Performance power plan may not work on some laptops that do not fully support this power plan.
In these cases, the power plan may fail to apply, This is expected behavior on unsupported hardware.
In these cases, the power plan may fail to apply. This is expected behavior on unsupported hardware.
### Revert start menu tweak not working
@@ -34,7 +34,7 @@ In this update, Microsoft completely removed the old Start Menu code from Window
### Issues with PowerShell 7 or Class not registered Error
Installing PowerShell 7 from the Microsoft Store (MSIX package) is known to cause issues with DISM cmdlets such as `Get-WindowsOptionalFeature` and `Enable-WindowsOptionalFeature`, resulting in a `Class not registered` COM error.
This might also make it so running the "pre-installed app removal" will take a indefinite amount of time
This might also make it so running the "pre-installed app removal" will take an indefinite amount of time
Instead, install PowerShell 7 using one of the following methods:
@@ -41,7 +41,7 @@ function Add-SelectedAppsMenuItem {
$selectedAppRemoveButton.Add_MouseEnter({ $this.Foreground = "Red" })
$selectedAppRemoveButton.Add_MouseLeave({ $this.SetResourceReference([Windows.Controls.Control]::ForegroundProperty, "MainForegroundColor") })
$selectedAppRemoveButton.Add_Click({
$sync.($this.Tag).isChecked = $false # On click of the remove button, we only have to uncheck the corresponding checkbox. This will kick of all necessary changes to update the UI
$sync.($this.Tag).isChecked = $false # On click of the remove button, we only have to uncheck the corresponding checkbox. This will kick off all necessary changes to update the UI
})
[System.Windows.Controls.Grid]::SetColumn($selectedAppRemoveButton, 1)
$selectedAppGrid.Children.Add($selectedAppRemoveButton)
+138 -9
View File
@@ -1,18 +1,147 @@
function Close-WinUtilRunspacePool {
if ($null -eq $sync -or -not $sync.ContainsKey("runspace") -or $null -eq $sync.runspace) {
<#
.SYNOPSIS
Stops anything still running and closes the worker pool
.DESCRIPTION
Closing the pool with work still in it is what produced an unhandled
InvalidRunspaceStateException: a queued instance starts on a runspace that is already
closing, throws on a thread pool thread, and takes the process down. Whatever is in
flight is therefore asked to stop, and waited for, before the pool is closed.
#>
param(
[int]$StopTimeoutSeconds = 15,
# Leaves ShuttingDown clear: nothing resets it, so setting it here would refuse every
# later action for the rest of the session
[switch]$Recycle
)
if ($null -eq $sync) {
return
}
$poolLock = Get-WinUtilRunspacePoolLock
[System.Threading.Monitor]::Enter($poolLock)
try {
if ($sync.runspace.RunspacePoolStateInfo.State -notin @(
[System.Management.Automation.Runspaces.RunspacePoolState]::Closed,
[System.Management.Automation.Runspaces.RunspacePoolState]::Closing,
[System.Management.Automation.Runspaces.RunspacePoolState]::Broken
)) {
$sync.runspace.Close()
# Set before stopping, so nothing that is winding down queues fresh work behind us
if (-not $Recycle) {
$sync.ShuttingDown = $true
}
if (-not $sync.ContainsKey("runspace") -or $null -eq $sync.runspace) {
return
}
$stopped = $true
try {
$stopped = Stop-WinUtilActiveWork -TimeoutSeconds $StopTimeoutSeconds
} catch {
$stopped = $false
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Could not stop running work cleanly: $($_.Exception.Message)"
}
$pool = $sync.runspace
$cleanupDeferred = $false
try {
$poolState = $pool.RunspacePoolStateInfo.State
$terminalStates = @(
[System.Management.Automation.Runspaces.RunspacePoolState]::Closed,
[System.Management.Automation.Runspaces.RunspacePoolState]::Broken
)
if (-not $stopped -and $poolState -notin $terminalStates) {
# Close and Dispose both wait for an invocation that ignored BeginStop. Hand
# cleanup to the thread pool so the timeout above remains a real upper bound.
$cleanupDeferred = $true
if ($poolState -ne [System.Management.Automation.Runspaces.RunspacePoolState]::Closing) {
Register-WinUtilRunspacePoolCleanup -RunspacePool $pool
}
} elseif ($poolState -notin ($terminalStates + [System.Management.Automation.Runspaces.RunspacePoolState]::Closing)) {
$pool.Close()
}
} catch {
# A pool that will not close cleanly must not stop the window from closing
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Worker pool did not close cleanly: $($_.Exception.Message)"
} finally {
if (-not $cleanupDeferred) {
try {
$pool.Dispose()
} catch {
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Worker pool did not dispose cleanly: $($_.Exception.Message)"
}
}
$sync.Remove("runspace")
if ($sync.ActiveShells) { $sync.ActiveShells.Clear() }
}
} finally {
$sync.runspace.Dispose()
$sync.Remove("runspace")
[System.Threading.Monitor]::Exit($poolLock)
}
}
function Register-WinUtilRunspacePoolCleanup {
<#
.SYNOPSIS
Closes and disposes a worker pool without blocking the calling thread
#>
param(
[Parameter(Mandatory)]
[System.Management.Automation.Runspaces.RunspacePool]$RunspacePool
)
if (-not ("WinUtilRunspacePoolCleanup" -as [type])) {
Add-Type @"
using System;
using System.Management.Automation.Runspaces;
public sealed class WinUtilRunspacePoolCleanupState
{
public RunspacePool RunspacePool { get; set; }
public IAsyncResult Handle { get; set; }
}
public static class WinUtilRunspacePoolCleanup
{
public static readonly System.Threading.WaitOrTimerCallback Callback = Cleanup;
public static void Cleanup(object state, bool timedOut)
{
var cleanupState = state as WinUtilRunspacePoolCleanupState;
if (cleanupState == null || cleanupState.RunspacePool == null || cleanupState.Handle == null)
{
return;
}
try
{
cleanupState.RunspacePool.EndClose(cleanupState.Handle);
}
catch
{
}
finally
{
try
{
cleanupState.RunspacePool.Dispose();
}
catch
{
}
}
}
}
"@
}
$cleanupState = [WinUtilRunspacePoolCleanupState]::new()
$cleanupState.RunspacePool = $RunspacePool
$cleanupState.Handle = $RunspacePool.BeginClose($null, $null)
[System.Threading.ThreadPool]::RegisterWaitForSingleObject(
$cleanupState.Handle.AsyncWaitHandle,
[WinUtilRunspacePoolCleanup]::Callback,
$cleanupState,
-1,
$true
) | Out-Null
}
@@ -0,0 +1,58 @@
function Complete-WinUtilPackageRun {
<#
.SYNOPSIS
Reports what a package run actually did and fails the job on unexpected errors
.DESCRIPTION
Package managers report failure through an exit code, which is easy to walk past.
Without this the job layer would show a green checkmark for a run in which nothing
changed. Unexpected failures terminate the job; expected elevated-context skips
raise a warning so the job cannot claim that every requested action completed.
.PARAMETER Action
Install or Uninstall, used in the summary text.
#>
param(
[Parameter(Mandatory)]
[string]$Action,
[object[]]$Results = @()
)
$succeeded = @($Results | Where-Object { $_.Outcome -eq "Succeeded" })
$skipped = @($Results | Where-Object { $_.Outcome -eq "Skipped" })
$failed = @($Results | Where-Object { $_.Outcome -eq "Failed" })
$summary = "$($succeeded.Count) succeeded, $($skipped.Count) skipped, $($failed.Count) failed"
Write-WinUtilLog -Component "Package" -Message "$Action summary: $summary"
Write-Host "$Action summary: $summary"
foreach ($result in $skipped) {
Write-Host " skipped $($result.Package) - $($result.Detail)"
}
foreach ($result in $failed) {
Write-Host " failed $($result.Package) - $($result.Detail)" -ForegroundColor Red
}
$adminContextSkipped = @($skipped | Where-Object { $_.ExitCode -eq -1978335107 })
if ($adminContextSkipped.Count -gt 0) {
Write-Warning "$($adminContextSkipped.Count) package action(s) were skipped because elevated WinUtil cannot modify user-scoped installations."
}
if ($failed.Count -gt 0) {
$names = ($failed | ForEach-Object { $_.Package }) -join ', '
$reasons = @($failed | ForEach-Object { $_.Detail } | Sort-Object -Unique)
$message = if ($reasons.Count -eq 1) {
"$($failed.Count) of $($Results.Count) package(s) failed: $names. $($reasons[0])"
} else {
"$($failed.Count) of $($Results.Count) package(s) failed: $names. See the lines above for each reason."
}
# Each failed package was already logged by its package-manager adapter. Carry that fact
# with the summary exception so the job wrapper adds context without another error count.
$exception = [System.InvalidOperationException]::new($message)
$exception.Data["WinUtilErrorReported"] = $true
throw $exception
}
}
@@ -1,21 +1,26 @@
function Find-AppsByNameOrDescription {
<#
.SYNOPSIS
Searches through the Apps on the Install Tab and hides all entries that do not match the string
Filters the Install tab entries by search text and by category
.DESCRIPTION
Filters application entries by name or description using literal string matching.
Respects collapsed category state and handles null $sync gracefully.
Search text and categories are independent filters that both have to pass. An entry is
shown when its name, description, or application preset key matches the search text, and
when its category is in the selected set. An empty search matches everything, and an empty
category set matches every category.
While either filter is active the matching categories are expanded, since a collapsed
category would otherwise hide the very results that were asked for. With no filter at
all the collapsed state the user set is restored.
.PARAMETER SearchString
The string to be searched for. Wildcards are treated as literal characters.
The string to search for. Wildcards are treated as literal characters.
.PARAMETER Category
When provided, only applications in this exact category are shown.
.PARAMETER Categories
The categories to show. An empty or missing array shows all of them.
.NOTES
- Uses module-scope $sync (no parameter needed; inherits from caller's scope)
- Performs literal matching (no wildcard expansion)
- Safely handles missing hashtable keys and null UI elements
- Protected by try/catch to prevent UI thread crashes
#>
@@ -24,7 +29,7 @@ function Find-AppsByNameOrDescription {
[string]$SearchString = "",
[Parameter(Mandatory = $false)]
[string]$Category = ""
[string[]]$Categories = @()
)
# Validate that $sync exists and has required structure
@@ -43,21 +48,34 @@ function Find-AppsByNameOrDescription {
return
}
# Categories that filtering expanded on the user's behalf, so clearing the filter can undo it
if ($null -eq $sync.AppCategoryAutoExpanded) {
$sync.AppCategoryAutoExpanded = @{}
}
try {
# Reset the visibility if the search string is empty or the search is cleared
if ([string]::IsNullOrWhiteSpace($SearchString) -and [string]::IsNullOrWhiteSpace($Category)) {
$activeCategories = @($Categories | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
$hasSearch = -not [string]::IsNullOrWhiteSpace($SearchString)
$hasCategories = $activeCategories.Count -gt 0
# Nothing is filtered, so put every entry back and leave the collapsed categories collapsed
if (-not $hasSearch -and -not $hasCategories) {
$sync.ItemsControl.Items | ForEach-Object {
# Each item is a StackPanel container
$_.Visibility = [Windows.Visibility]::Visible
if ($_.Children.Count -ge 2) {
$categoryLabel = $_.Children[0]
$wrapPanel = $_.Children[1]
# Keep category label visible
$categoryLabel.Visibility = [Windows.Visibility]::Visible
# Respect the collapsed state of categories (indicated by + prefix)
# A category that filtering expanded goes back to how the user left it
$categoryName = $categoryLabel.Content -replace '^[+-] ', ''
if ($sync.AppCategoryAutoExpanded.ContainsKey($categoryName)) {
$categoryLabel.Content = $categoryLabel.Content -replace "^- ", "+ "
$sync.AppCategoryAutoExpanded.Remove($categoryName)
}
if ($categoryLabel.Content -like "+*") {
$wrapPanel.Visibility = [Windows.Visibility]::Collapsed
}
@@ -65,7 +83,6 @@ function Find-AppsByNameOrDescription {
$wrapPanel.Visibility = [Windows.Visibility]::Visible
}
# Show all apps within the category
$wrapPanel.Children | ForEach-Object {
$_.Visibility = [Windows.Visibility]::Visible
}
@@ -77,7 +94,6 @@ function Find-AppsByNameOrDescription {
# Escape wildcard characters for literal matching
$escapedSearchString = [System.Management.Automation.WildcardPattern]::Escape($SearchString)
# Perform search
$sync.ItemsControl.Items | ForEach-Object {
# Each item is a StackPanel container with Children[0] = label, Children[1] = WrapPanel
if ($_.Children.Count -ge 2) {
@@ -85,12 +101,9 @@ function Find-AppsByNameOrDescription {
$wrapPanel = $_.Children[1]
$categoryHasMatch = $false
# Keep category label visible
$categoryLabel.Visibility = [Windows.Visibility]::Visible
# Search through apps in this category
foreach ($appControl in $wrapPanel.Children) {
# Safely retrieve app entry from hashtable
$appTag = $appControl.Tag
$appEntry = $null
@@ -98,14 +111,14 @@ function Find-AppsByNameOrDescription {
$appEntry = $sync.configs.applicationsHashtable[$appTag]
}
# Check if app matches search criteria
if ($null -ne $appEntry) {
$categoryMatch = -not [string]::IsNullOrWhiteSpace($Category) -and $appEntry.Category -eq $Category
$contentMatch = [string]::IsNullOrWhiteSpace($Category) -and $appEntry.Content -like "*$escapedSearchString*"
$descriptionMatch = [string]::IsNullOrWhiteSpace($Category) -and $appEntry.Description -like "*$escapedSearchString*"
$categoryMatch = -not $hasCategories -or $activeCategories -contains $appEntry.Category
$textMatch = -not $hasSearch -or
$appEntry.Content -like "*$escapedSearchString*" -or
$appEntry.Description -like "*$escapedSearchString*" -or
$appTag -like "*$escapedSearchString*"
if ($categoryMatch -or $contentMatch -or $descriptionMatch) {
# Show the App and mark that this category has a match
if ($categoryMatch -and $textMatch) {
$appControl.Visibility = [Windows.Visibility]::Visible
$categoryHasMatch = $true
}
@@ -119,17 +132,17 @@ function Find-AppsByNameOrDescription {
}
}
# If category has matches, show the WrapPanel and update the category label to expanded state
if ($categoryHasMatch) {
$wrapPanel.Visibility = [Windows.Visibility]::Visible
$_.Visibility = [Windows.Visibility]::Visible
# Update category label to show expanded state (-)
# Expand it, otherwise the matches stay hidden behind a collapsed header.
# Remember that it was collapsed so clearing the filter can put it back.
if ($categoryLabel.Content -like "+*") {
$categoryLabel.Content = $categoryLabel.Content -replace "^\+ ", "- "
$sync.AppCategoryAutoExpanded[($categoryLabel.Content -replace '^- ', '')] = $true
}
}
else {
# Hide the entire category container if no matches
$_.Visibility = [Windows.Visibility]::Collapsed
}
}
@@ -90,18 +90,25 @@ function Find-TweaksByNameOrDescription {
}
if ($dockPanel -is [Windows.Controls.DockPanel]) {
$itemsControl = $null
$itemsControl = $dockPanel.Children | Where-Object { $_ -is [Windows.Controls.ItemsControl] } | Select-Object -First 1
$container = $dockPanel.Children | Where-Object { $_ -is [Windows.Controls.ItemsControl] -or $_ -is [Windows.Controls.StackPanel] -or $_ -is [Windows.Controls.ScrollViewer] -or $_.GetType().Name -eq "ItemsControl" } | Select-Object -First 1
if ($null -ne $itemsControl) {
if ($null -ne $container) {
$targetPanel = if ($container.PSObject.Properties['Content'] -and $null -ne $container.Content) { $container.Content } else { $container }
$items = $null
if ($targetPanel -is [Windows.Controls.ItemsControl] -or $targetPanel.GetType().Name -eq "ItemsControl") {
$items = $targetPanel.Items
}
else {
$items = $targetPanel.Children
}
# Show all items in the category
foreach ($item in $itemsControl.Items) {
foreach ($item in $items) {
if ($null -ne $item) {
# Check if it's a category label (first Label in the ItemsControl)
if ($item -is [Windows.Controls.Label]) {
# Check if it's a category label (first Label in the container)
if ($item -is [Windows.Controls.Label] -or $item.GetType().Name -eq "Label") {
$item.Visibility = [Windows.Visibility]::Visible
}
elseif ($item -is [Windows.Controls.DockPanel] -or $item -is [Windows.Controls.StackPanel]) {
elseif ($item -is [Windows.Controls.DockPanel] -or $item -is [Windows.Controls.StackPanel] -or $item.GetType().Name -eq "DockPanel" -or $item.GetType().Name -eq "StackPanel") {
# Show all checkbox containers
$item.Visibility = [Windows.Visibility]::Visible
}
@@ -143,16 +150,21 @@ function Find-TweaksByNameOrDescription {
}
if ($dockPanel -is [Windows.Controls.DockPanel]) {
$itemsControl = $null
$itemsControl = $dockPanel.Children | Where-Object { $_ -is [Windows.Controls.ItemsControl] } | Select-Object -First 1
$container = $dockPanel.Children | Where-Object { $_ -is [Windows.Controls.ItemsControl] -or $_ -is [Windows.Controls.StackPanel] -or $_ -is [Windows.Controls.ScrollViewer] -or $_.GetType().Name -eq "ItemsControl" } | Select-Object -First 1
if ($null -ne $itemsControl) {
if ($null -ne $container) {
$categoryLabel = $null
# Process all items (checkboxes, labels, panels) in the ItemsControl
for ($i = 0; $i -lt $itemsControl.Items.Count; $i++) {
$item = $itemsControl.Items[$i]
$targetPanel = if ($container.PSObject.Properties['Content'] -and $null -ne $container.Content) { $container.Content } else { $container }
$items = $null
if ($targetPanel -is [Windows.Controls.ItemsControl] -or $targetPanel.GetType().Name -eq "ItemsControl") {
$items = $targetPanel.Items
}
else {
$items = $targetPanel.Children
}
# Process all items (checkboxes, labels, panels) in the container
foreach ($item in $items) {
if ($null -eq $item) {
continue
}
@@ -161,7 +173,7 @@ function Find-TweaksByNameOrDescription {
# Check if this is a category label (usually first Label)
# ------------------------------------------------------------
if ($item -is [Windows.Controls.Label]) {
if ($item -is [Windows.Controls.Label] -or $item.GetType().Name -eq "Label") {
$categoryLabel = $item
# Initially hide category label; show it only if matches found
$item.Visibility = [Windows.Visibility]::Collapsed
@@ -171,13 +183,13 @@ function Find-TweaksByNameOrDescription {
# Check if this is a DockPanel containing a tweak checkbox
# ------------------------------------------------------------
elseif ($item -is [Windows.Controls.DockPanel]) {
elseif ($item -is [Windows.Controls.DockPanel] -or $item.GetType().Name -eq "DockPanel") {
$checkbox = $null
$label = $null
# Safely extract checkbox and label
$checkbox = $item.Children | Where-Object { $_ -is [Windows.Controls.CheckBox] } | Select-Object -First 1
$label = $item.Children | Where-Object { $_ -is [Windows.Controls.Label] } | Select-Object -First 1
$checkbox = $item.Children | Where-Object { $_ -is [Windows.Controls.CheckBox] -or $_.GetType().Name -eq "CheckBox" } | Select-Object -First 1
$label = $item.Children | Where-Object { $_ -is [Windows.Controls.Label] -or $_.GetType().Name -eq "Label" } | Select-Object -First 1
# Check if tweak matches search criteria
$itemMatches = $false
@@ -221,9 +233,9 @@ function Find-TweaksByNameOrDescription {
# Check if this is a StackPanel containing a tweak checkbox
# ------------------------------------------------------------
elseif ($item -is [Windows.Controls.StackPanel]) {
elseif ($item -is [Windows.Controls.StackPanel] -or $item.GetType().Name -eq "StackPanel") {
$checkbox = $null
$checkbox = $item.Children | Where-Object { $_ -is [Windows.Controls.CheckBox] } | Select-Object -First 1
$checkbox = $item.Children | Where-Object { $_ -is [Windows.Controls.CheckBox] -or $_.GetType().Name -eq "CheckBox" } | Select-Object -First 1
$itemMatches = $false
@@ -0,0 +1,60 @@
function Get-WinUtilAppEntryHandlers {
<#
.SYNOPSIS
The event handlers shared by every app entry on the Install tab
.DESCRIPTION
A scriptblock literal inside a loop is a new scriptblock every time round, and
building six of them per app is the single largest cost of drawing the app list:
measured at 2.13 ms per entry against 0.62 ms when they are made once and reused.
None of them close over anything per entry. They read the sender through $this, so
one instance serves every app.
#>
if ($null -ne $script:WinUtilAppEntryHandlers) {
return $script:WinUtilAppEntryHandlers
}
$script:WinUtilAppEntryHandlers = @{
BorderClick = {
# Resolve through $sync because the border's child is a layout Grid for FOSS entries
$childCheckbox = $sync.$($this.Tag)
$childCheckbox.IsChecked = -not $childCheckbox.IsChecked
}
MouseEnter = {
if (($sync.$($this.Tag).IsChecked) -eq $false) {
$this.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallHighlightedColor")
}
}
MouseLeave = {
if (($sync.$($this.Tag).IsChecked) -eq $false) {
$this.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor")
}
}
RightClick = {
# Store the selected app in a global variable so it can be used in the popup
$sync.appPopupSelectedApp = $this.Tag
# Set the popup position to the current mouse position
$sync.appPopup.PlacementTarget = $this
$sync.appPopup.IsOpen = $true
}
# The checkbox sits inside the entry layout Grid, so the border is one level further up
Checked = {
Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName $this.Tag
$borderElement = $this.Parent.Parent
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallSelectedColor")
}
Unchecked = {
Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName $this.Tag
$borderElement = $this.Parent.Parent
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor")
}
ImageFailed = {
$this.Visibility = "Collapsed"
$this.Parent.Children[0].Visibility = "Visible"
}
}
return $script:WinUtilAppEntryHandlers
}
@@ -0,0 +1,84 @@
function Get-WinUtilDNSBenchmark {
<#
.SYNOPSIS
Benchmarks neutral DNS providers by measuring TCP port 53 latency (RTT in ms) to determine the fastest DNS server.
.PARAMETER TimeoutMs
Maximum timeout in milliseconds for each connection test. Default is 1500ms.
.OUTPUTS
Array of PSCustomObjects containing Provider, PrimaryIP, and LatencyMs sorted by lowest latency.
.EXAMPLE
$results = Get-WinUtilDNSBenchmark
$fastest = $results[0]
#>
[CmdletBinding()]
param(
[ValidateRange(1, 9998)]
[int]$TimeoutMs = 1500
)
Write-WinUtilLog -Component "DNS" -Message "Starting DNS latency benchmark scan (TCP port 53)..."
$dnsConfigs = $sync.configs.dns
if ($null -eq $dnsConfigs) {
Write-Warning "DNS configurations not found in `$sync.configs.dns."
Write-WinUtilLog -Level "ERROR" -Component "DNS" -Message "DNS configurations not found in `$sync.configs.dns."
return @()
}
$results = [System.Collections.Generic.List[PSObject]]::new()
foreach ($prop in $dnsConfigs.PSObject.Properties) {
$providerName = $prop.Name
$primaryIp = $prop.Value.Primary
if (-not $primaryIp) { continue }
# Providers must explicitly opt in so new filtering services are never auto-selected.
if ($prop.Value.BenchmarkEligible -ne $true) {
continue
}
$latency = 9999
$client = $null
try {
$client = New-Object System.Net.Sockets.TcpClient
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$asyncResult = $client.BeginConnect($primaryIp, 53, $null, $null)
$success = $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)
$stopwatch.Stop()
if ($success) {
$client.EndConnect($asyncResult)
$latency = [int]$stopwatch.ElapsedMilliseconds
} else {
$latency = 9999
}
} catch {
$latency = 9999
} finally {
if ($null -ne $client) {
$client.Dispose()
}
}
$results.Add([PSCustomObject]@{
Provider = $providerName
PrimaryIP = $primaryIp
LatencyMs = $latency
})
}
$sortedResults = @($results | Sort-Object LatencyMs)
if ($sortedResults.Count -gt 0 -and $sortedResults[0].LatencyMs -lt 9999) {
$fastest = $sortedResults[0]
Write-WinUtilLog -Component "DNS" -Message "DNS Benchmark completed. Fastest neutral provider: $($fastest.Provider) ($($fastest.LatencyMs) ms)"
} else {
Write-WinUtilLog -Component "DNS" -Message "DNS Benchmark completed. Could not determine latency for providers."
}
return $sortedResults
}
@@ -0,0 +1,25 @@
function Get-WinUtilEntryToolTip {
<#
.SYNOPSIS
Builds the tooltip string for an app/tweak/feature entry: its description plus its preset JSON key
.PARAMETER Description
The entry's description from the config JSON. May be null or empty.
.PARAMETER Key
The entry's JSON key as used in preset files (e.g. WPFInstallbrave, WPFTweaksTele).
#>
param(
[Parameter(Mandatory = $false)]
[string]$Description,
[Parameter(Mandatory = $true)]
[string]$Key
)
if ([string]::IsNullOrWhiteSpace($Description)) {
return "Preset key: $Key"
}
return "$Description`n`nPreset key: $Key"
}
@@ -0,0 +1,162 @@
function Get-WinUtilEnvironmentReport {
<#
.SYNOPSIS
Collects the allowlisted data used by the WinUtil environment report.
#>
$reportWarnings = [System.Collections.Generic.List[string]]::new()
$windows = [ordered]@{
edition = $null
version = $null
buildNumber = $null
architecture = $null
}
$hardware = [ordered]@{
cpuModel = $null
logicalProcessorCount = $null
totalMemoryGB = $null
}
try {
$operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
$windows.edition = $operatingSystem.Caption
$windows.version = $operatingSystem.Version
$windows.buildNumber = $operatingSystem.BuildNumber
$windows.architecture = $operatingSystem.OSArchitecture
if ($null -ne $operatingSystem.TotalVisibleMemorySize) {
$hardware.totalMemoryGB = [math]::Round(([double]$operatingSystem.TotalVisibleMemorySize / 1MB), 2)
}
} catch {
$message = "Failed to collect Windows/memory info from Win32_OperatingSystem: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
try {
$processors = @(Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop)
if ($processors.Count -gt 0) {
$hardware.cpuModel = $processors[0].Name
$hardware.logicalProcessorCount = [int](($processors | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum)
}
} catch {
$message = "Failed to collect CPU info from Win32_Processor: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
$powershell = [ordered]@{
edition = $PSVersionTable.PSEdition
version = $PSVersionTable.PSVersion.ToString()
executionPolicy = $null
}
try {
$powershell.executionPolicy = (Get-ExecutionPolicy).ToString()
} catch {
$message = "Failed to read PowerShell execution policy: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
# Re-use built-in functionality
$chocolatey = [ordered]@{ installed = $false; version = $null }
try {
$chocolatey.installed = (Test-WinUtilPackageManager -choco 6>$null) -eq "installed"
} catch {
$message = "Failed to check Chocolatey availability: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
if ($chocolatey.installed) {
try {
$global:LASTEXITCODE = 0
$versionOutput = @(choco -v 2>&1)
if ($LASTEXITCODE -ne 0) {
throw "Chocolatey version probe exited with code $LASTEXITCODE."
}
$chocolatey.version = ($versionOutput | Select-Object -First 1).ToString().Trim()
} catch {
$message = "Failed to read Chocolatey version: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
}
$winget = [ordered]@{ installed = $false; version = $null }
try {
$winget.installed = (Test-WinUtilPackageManager -winget 6>$null) -eq "installed"
} catch {
$message = "Failed to check WinGet availability: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
if ($winget.installed) {
try {
$global:LASTEXITCODE = 0
$versionOutput = @(winget -v 2>&1)
if ($LASTEXITCODE -ne 0) {
throw "WinGet version probe exited with code $LASTEXITCODE."
}
$winget.version = ($versionOutput | Select-Object -First 1).ToString().Trim()
} catch {
$message = "Failed to read WinGet version: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
}
# Null means the registry state could not be read. Do not turn an access/provider failure into
# a misleading "no reboot required" result.
$system = [ordered]@{ pendingRebootRequired = $null }
try {
$rebootPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending",
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"
)
# A present-but-empty PendingFileRenameOperations value still returns a non-null object, so
# check the actual entries rather than just whether the property exists.
$pendingFileRenameOperations = @(
(Get-ItemProperty -LiteralPath "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" `
-ErrorAction Stop).PendingFileRenameOperations |
Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }
)
$system.pendingRebootRequired = ($rebootPaths | Where-Object {
Test-Path -LiteralPath $_ -ErrorAction Stop
}).Count -gt 0 -or
$pendingFileRenameOperations.Count -gt 0
} catch {
$message = "Failed to check pending-reboot registry state: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
$tweaksState = Get-WinUtilTweaksStateReport
if ($tweaksState.collectionStatus -ne "collected") {
$message = "Failed to collect the complete tweak state for the environment report."
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
foreach ($message in $reportWarnings) {
Write-Warning $message
}
return [pscustomobject][ordered]@{
schemaVersion = "1.0"
generatedAtUtc = [DateTime]::UtcNow.ToString("o")
windows = [pscustomobject]$windows
hardware = [pscustomobject]$hardware
powershell = [pscustomobject]$powershell
packageManagers = [pscustomobject][ordered]@{
winget = [pscustomobject]$winget
chocolatey = [pscustomobject]$chocolatey
}
system = [pscustomobject]$system
tweaksState = $tweaksState
}
}
@@ -0,0 +1,16 @@
function Get-WinUtilEnvironmentReportLogsPath {
<#
.SYNOPSIS
Derives the companion logs .txt path from the environment report's JSON save path.
#>
param(
[Parameter(Mandatory = $true)]
[string]$JsonPath
)
# ChangeExtension($JsonPath, $null) leaves a trailing dot instead of stripping it, so build the
# name from its parts instead.
$directory = [System.IO.Path]::GetDirectoryName($JsonPath)
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($JsonPath)
return [System.IO.Path]::Combine($directory, "${baseName}_logs.txt")
}
@@ -0,0 +1,39 @@
function Get-WinUtilRecentLogs {
<#
.SYNOPSIS
Concatenates WinUtil session logs from the last N days into a single text blob.
.PARAMETER Days
How many days back to include. Defaults to 7, matching what the support forum/server
typically asks users for.
.PARAMETER LogDirectory
Overrides the log directory (normally $sync.winutildir\logs). Mainly for testing.
#>
param(
[int]$Days = 7,
[string]$LogDirectory
)
if ([string]::IsNullOrWhiteSpace($LogDirectory)) {
if ($null -eq $sync -or -not $sync.ContainsKey("winutildir") -or [string]::IsNullOrWhiteSpace($sync.winutildir)) {
return ""
}
$LogDirectory = Join-Path $sync.winutildir "logs"
}
if (-not (Test-Path -LiteralPath $LogDirectory -ErrorAction Stop)) {
return ""
}
$cutoff = (Get-Date).AddDays(-$Days)
$logFiles = Get-ChildItem -LiteralPath $LogDirectory -Filter "winutil_*.log" -File -ErrorAction Stop |
Where-Object { $_.LastWriteTime -ge $cutoff } |
Sort-Object LastWriteTime
$sections = foreach ($logFile in $logFiles) {
"=== $($logFile.Name) ===`n$(Get-Content -LiteralPath $logFile.FullName -Raw -ErrorAction Stop)"
}
return ($sections -join "`n`n")
}
@@ -0,0 +1,36 @@
function Get-WinUtilRegistryComboState {
<#
.SYNOPSIS
Finds the configured combo-box state matching the current registry values.
.PARAMETER Registry
Registry settings containing a value mapping for each supported state.
.OUTPUTS
The name of the matching state.
#>
param(
[Parameter(Mandatory)]
$Registry
)
foreach ($state in $Registry[0].Values.PSObject.Properties) {
$stateMatches = $true
foreach ($setting in @($Registry)) {
$currentValue = Get-WinUtilRegistryComboValue -Setting $setting
$actualValue = if ($currentValue.Exists -and $null -ne $currentValue.Value) { $currentValue.Value } else { $setting.DefaultValue }
$configuredValue = $setting.Values.PSObject.Properties[$state.Name].Value
# Removal represents the effective Windows default when matching the current state.
$expectedValue = if ($configuredValue -eq "<RemoveEntry>") { $setting.DefaultValue } else { $configuredValue }
if ([string]$actualValue -ne [string]$expectedValue) {
$stateMatches = $false
break
}
}
if ($stateMatches) {
return $state.Name
}
}
throw "Registry values do not match a supported state."
}
@@ -0,0 +1,24 @@
function Get-WinUtilRegistryComboValue {
<#
.SYNOPSIS
Reads one registry value for a registry-backed combo-box state.
.PARAMETER Setting
The registry setting from the combo-box configuration.
#>
param(
[Parameter(Mandatory)]
$Setting
)
try {
$item = Get-ItemProperty -Path $Setting.Path -Name $Setting.Name -ErrorAction Stop
$property = $item.PSObject.Properties[$Setting.Name]
return [pscustomobject]@{ Exists = $null -ne $property; Value = $property.Value }
} catch [System.Management.Automation.PSArgumentException] {
# The registry provider uses PSArgumentException when a named value is absent.
return [pscustomobject]@{ Exists = $false; Value = $null }
} catch [System.Management.Automation.ItemNotFoundException] {
return [pscustomobject]@{ Exists = $false; Value = $null }
}
}
@@ -0,0 +1,17 @@
function Get-WinUtilRunspacePoolLock {
<#
.SYNOPSIS
Returns the lock that serializes worker-pool startup and shutdown
#>
[System.Threading.Monitor]::Enter($sync.SyncRoot)
try {
if ($null -eq $sync.RunspacePoolLock) {
$sync.RunspacePoolLock = [object]::new()
}
return $sync.RunspacePoolLock
} finally {
[System.Threading.Monitor]::Exit($sync.SyncRoot)
}
}
@@ -8,10 +8,9 @@ function Get-WinUtilSelectedPackages {
[string] $Preference
)
# A single package has no meaningful percentage to show
if ($PackageList.count -eq 1) {
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" }
} else {
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" }
Step-WinUtilJob -State "Indeterminate"
}
$packagesWinget = [System.Collections.ArrayList]::new()
+24 -11
View File
@@ -1,23 +1,32 @@
Function Get-WinUtilToggleStatus ($ToggleSwitch) {
Function Get-WinUtilToggleStatus {
param(
$ToggleSwitch,
[switch]$BypassCache,
[switch]$StopOnReadError
)
$ToggleSwitchReg = $sync.configs.tweaks.$ToggleSwitch.registry
if ($null -eq $sync.ToggleStatusCache) {
$sync.ToggleStatusCache = @{}
if (-not $BypassCache) {
if ($null -eq $sync.ToggleStatusCache) {
$sync.ToggleStatusCache = @{}
}
if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) {
return [bool]$sync.ToggleStatusCache[$ToggleSwitch]
}
}
if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) {
return [bool]$sync.ToggleStatusCache[$ToggleSwitch]
}
$readErrorAction = if ($StopOnReadError) { "Stop" } else { "Continue" }
if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) {
New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS | Out-Null
New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS -ErrorAction $readErrorAction | Out-Null
}
foreach ($regentry in $ToggleSwitchReg) {
if (Test-Path $regentry.Path) {
$regstate = (Get-ItemProperty -Path $regentry.Path).$($regentry.Name)
if (Test-Path $regentry.Path -ErrorAction $readErrorAction) {
$regstate = (Get-ItemProperty -Path $regentry.Path -ErrorAction $readErrorAction).$($regentry.Name)
} else {
$regstate = $null
}
@@ -30,11 +39,15 @@ Function Get-WinUtilToggleStatus ($ToggleSwitch) {
}
if ($regstate -ne $regentry.Value) {
$sync.ToggleStatusCache[$ToggleSwitch] = $false
if (-not $BypassCache) {
$sync.ToggleStatusCache[$ToggleSwitch] = $false
}
return $false
}
}
$sync.ToggleStatusCache[$ToggleSwitch] = $true
if (-not $BypassCache) {
$sync.ToggleStatusCache[$ToggleSwitch] = $true
}
return $true
}
@@ -0,0 +1,62 @@
function Get-WinUtilTweaksStateReport {
<#
.SYNOPSIS
Groups every config/tweaks.json entry's live applied state by category, reusing the same
detection Invoke-WPFGetInstalled uses to check the "Get Installed Tweaks" checkboxes.
#>
$categoryFieldNames = [ordered]@{
"Essential Tweaks" = "essentialTweaks"
"Customize Preferences" = "customizePreferences"
"z__Advanced Tweaks - CAUTION" = "advancedTweaks"
"Performance Plans - NOT FOR LAPTOPS" = "performancePlans"
}
$grouped = [ordered]@{}
foreach ($fieldName in $categoryFieldNames.Values) {
$grouped[$fieldName] = [ordered]@{}
}
$notEvaluable = [System.Collections.Generic.List[string]]::new()
$collectionStatus = "collected"
try {
$appliedTweaks = [System.Collections.Generic.HashSet[string]]::new(
[string[]]@(Invoke-WinUtilCurrentSystem -CheckBox "tweaks" `
-BypassToggleStatusCache -StopOnReadError)
)
foreach ($property in $sync.configs.tweaks.PSObject.Properties) {
$tweakKey = $property.Name
$entry = $property.Value
$fieldName = $categoryFieldNames[[string]$entry.category]
# Buttons embedded in the tweaks panel (e.g. the OOSU/Ultimate Performance launchers)
# are actions, not stateful tweaks, so they're outside this report's scope entirely.
if (-not $fieldName -or $entry.Type -eq "Button") {
continue
}
# Combobox tweaks and script-only tweaks with no registry/service schema have no
# detectable current state. List them so the report doesn't silently drop them.
if ($entry.Type -eq "Combobox" -or (-not $entry.registry -and -not $entry.service)) {
$notEvaluable.Add($tweakKey)
continue
}
$grouped[$fieldName][$tweakKey] = $appliedTweaks.Contains($tweakKey)
}
} catch {
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to collect tweaks/toggle state: $($_.Exception.Message)"
$collectionStatus = "unavailable"
}
# Empty groups from a failed collection would otherwise be indistinguishable in the JSON from a
# successful scan that found nothing notable, so record whether collection actually ran.
$result = [ordered]@{ collectionStatus = $collectionStatus }
foreach ($fieldName in $categoryFieldNames.Values) {
$result[$fieldName] = [pscustomobject]$grouped[$fieldName]
}
$result.notEvaluable = @($notEvaluable)
return [pscustomobject]$result
}
@@ -3,7 +3,7 @@
.SYNOPSIS
Creates a [Windows.Controls.ScrollViewer] containing a [Windows.Controls.ItemsControl] which is setup to use Virtualization to only load the visible elements for performance reasons.
This is used as the parent object for all category and app entries on the install tab
Used to as part of the Install Tab UI generation
Used as part of the Install Tab UI generation
.PARAMETER TargetElement
The element to which the AppArea should be added
@@ -13,7 +13,7 @@
$targetGrid = $sync.Form.FindName($TargetElement)
$null = $targetGrid.Children.Clear()
# Create the outer Border for the aren where the apps will be placed
# Create the outer Border for the area where the apps will be placed
$Border = New-Object Windows.Controls.Border
$Border.VerticalAlignment = "Stretch"
$Border.SetResourceReference([Windows.Controls.Control]::StyleProperty, "BorderStyle")
@@ -13,34 +13,18 @@ function Initialize-InstallAppEntry {
$appKey
)
$app = $sync.configs.applicationsHashtable.$appKey
$app = $sync.configs.applicationsHashtable[$appKey]
$handlers = Get-WinUtilAppEntryHandlers
# Create the outer Border for the application type
$border = New-Object Windows.Controls.Border
$border.Style = $sync.Form.Resources.AppEntryBorderStyle
$border.Tag = $appKey
$border.ToolTip = $app.description
$border.Add_MouseLeftButtonUp({
$childCheckbox = ($this.Child | Where-Object {$_.Template.TargetType -eq [System.Windows.Controls.Checkbox]})[0]
$childCheckBox.isChecked = -not $childCheckbox.IsChecked
})
$border.Add_MouseEnter({
if (($sync.$($this.Tag).IsChecked) -eq $false) {
$this.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallHighlightedColor")
}
})
$border.Add_MouseLeave({
if (($sync.$($this.Tag).IsChecked) -eq $false) {
$this.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor")
}
})
$border.Add_MouseRightButtonUp({
# Store the selected app in a global variable so it can be used in the popup
$sync.appPopupSelectedApp = $this.Tag
# Set the popup position to the current mouse position
$sync.appPopup.PlacementTarget = $this
$sync.appPopup.IsOpen = $true
})
$border.ToolTip = Get-WinUtilEntryToolTip -Description $app.description -Key $appKey
$border.Add_MouseLeftButtonUp($handlers.BorderClick)
$border.Add_MouseEnter($handlers.MouseEnter)
$border.Add_MouseLeave($handlers.MouseLeave)
$border.Add_MouseRightButtonUp($handlers.RightClick)
$checkBox = New-Object Windows.Controls.CheckBox
# Sanitize the name for WPF
@@ -48,17 +32,8 @@ function Initialize-InstallAppEntry {
# Store the original appKey in Tag
$checkBox.Tag = $appKey
$checkbox.Style = $sync.Form.Resources.AppEntryCheckboxStyle
$checkbox.Add_Checked({
Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName $this.Parent.Tag
$borderElement = $this.Parent
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallSelectedColor")
})
$checkbox.Add_Unchecked({
Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName $this.Parent.Tag
$borderElement = $this.Parent
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor")
})
$checkbox.Add_Checked($handlers.Checked)
$checkbox.Add_Unchecked($handlers.Unchecked)
$contentPanel = New-Object Windows.Controls.StackPanel
$contentPanel.Orientation = "Horizontal"
@@ -71,15 +46,16 @@ function Initialize-InstallAppEntry {
$fallback = New-Object Windows.Controls.TextBlock
$fallback.Text = $app.content.TrimStart(".").Substring(0, 1).ToUpper()
$fallback.FontWeight = "Bold"; $fallback.HorizontalAlignment = "Center"; $fallback.VerticalAlignment = "Center"
if ($app.link) { $fallback.Visibility = "Collapsed" }
$fallback.SetResourceReference([Windows.Controls.TextBlock]::FontSizeProperty, "AppEntryFontSize")
$fallback.SetResourceReference([Windows.Controls.TextBlock]::ForegroundProperty, "ToggleButtonOnColor")
[void]$icon.Children.Add($fallback)
if ($app.link) {
$fallback.Visibility = "Collapsed"
$logo = New-Object Windows.Controls.Image
$logo.Stretch = [Windows.Media.Stretch]::Uniform
$logo.Source = "https://www.google.com/s2/favicons?sz=64&domain_url=$([uri]::EscapeDataString($app.link))"
$logo.Add_ImageFailed({ $this.Visibility = "Collapsed"; $this.Parent.Children[0].Visibility = "Visible" })
$logo.Add_ImageFailed($handlers.ImageFailed)
[void]$icon.Children.Add($logo)
}
[void]$contentPanel.Children.Add($icon)
@@ -90,13 +66,6 @@ function Initialize-InstallAppEntry {
$appName.Text = $app.content
# Add FOSS label after the name if FOSS
if ($app.foss -eq $true) {
$fossRun = [System.Windows.Documents.Run]::new(" $([char]0x25CF)")
$fossRun.Foreground = [Windows.Media.SolidColorBrush]::new([Windows.Media.Color]::FromRgb(110, 255, 114))
$fossRun.FontSize = 11.5
[void]$appName.Inlines.Add($fossRun)
}
[void]$contentPanel.Children.Add($appName)
$checkBox.Content = $contentPanel
@@ -104,7 +73,21 @@ function Initialize-InstallAppEntry {
$checkBox.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $app.content)
$border.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $app.content)
$border.Child = $checkBox
# Keep the same layout for every entry so the checkbox handlers can reach the border
$entryLayout = New-Object Windows.Controls.Grid
[void]$entryLayout.Children.Add($checkBox)
# Mark FOSS apps with a corner badge, bled into the border padding so it sits on the edge
if ($app.foss -eq $true) {
$fossBadge = New-WinUtilFossBadge
$fossBadge.HorizontalAlignment = "Right"
$fossBadge.VerticalAlignment = "Top"
$fossBadge.Margin = New-Object Windows.Thickness(0, -4, -6, 0)
[void]$entryLayout.Children.Add($fossBadge)
}
$border.Child = $entryLayout
if ($sync.selectedApps -contains $appKey) {
$checkBox.IsChecked = $true
}
@@ -16,14 +16,17 @@ function Initialize-InstallCategoryAppList {
$Apps
)
# Pre-group apps by category before creating WPF controls.
# Pre-group apps by category before creating WPF controls. Lists, because appending to
# an array copies it and there are several hundred apps.
$appsByCategory = @{}
# Indexed, not dynamic member, lookup: the latter goes through the PSObject adapter and
# costs about seventy times as much per app.
foreach ($appKey in $Apps.Keys) {
$category = $Apps.$appKey.Category
$category = $Apps[$appKey].Category
if (-not $appsByCategory.ContainsKey($category)) {
$appsByCategory[$category] = @()
$appsByCategory[$category] = [System.Collections.Generic.List[string]]::new()
}
$appsByCategory[$category] += $appKey
$appsByCategory[$category].Add($appKey)
}
$sync.InstallAppRenderQueue = [System.Collections.Queue]::new()
@@ -62,6 +65,11 @@ function Initialize-InstallCategoryAppList {
# The WrapPanel is the second child
$wrapPanel = $categoryContainer.Children[1]
# An explicit click wins over anything filtering expanded automatically
if ($sync.AppCategoryAutoExpanded) {
$sync.AppCategoryAutoExpanded.Remove(($categoryToggle.Content -replace '^[+-] ', ''))
}
# Toggle visibility
if ($wrapPanel.Visibility -eq [Windows.Visibility]::Visible) {
$wrapPanel.Visibility = [Windows.Visibility]::Collapsed
@@ -0,0 +1,35 @@
function Initialize-WinUtilInstallTabControls {
<#
.SYNOPSIS
Wires the Install tab controls that are generated from config rather than declared
in XAML
.DESCRIPTION
The package manager radio buttons and the install action buttons are created by
Invoke-WPFUIElements, so they do not exist until the Install tab is built. Setting
them up anywhere other than immediately after that build makes the code depend on
when the tab happens to be created.
#>
if ($sync.ChocoRadioButton) {
$sync.ChocoRadioButton.Add_Checked({
$sync.preferences.packagemanager = "Choco"
})
}
if ($sync.WingetRadioButton) {
$sync.WingetRadioButton.Add_Checked({
$sync.preferences.packagemanager = "Winget"
})
}
switch ($sync.preferences.packagemanager) {
"Choco" { if ($sync.ChocoRadioButton) { $sync.ChocoRadioButton.IsChecked = $true }; break }
"Winget" { if ($sync.WingetRadioButton) { $sync.WingetRadioButton.IsChecked = $true }; break }
}
if ($PARAM_OFFLINE) {
foreach ($name in "WPFInstall", "WPFUninstall", "WPFInstallUpgrade", "WPFGetInstalled") {
if ($sync.$name) { $sync.$name.IsEnabled = $false }
}
}
}
@@ -1,38 +1,34 @@
function Initialize-WinUtilRunspacePool {
if ($sync.runspace -and $sync.runspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) {
<#
.SYNOPSIS
Opens the shared worker pool that Start-WinUtilJob runs job bodies in
#>
$poolLock = Get-WinUtilRunspacePoolLock
[System.Threading.Monitor]::Enter($poolLock)
try {
if ($sync.runspace -and $sync.runspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) {
return $sync.runspace
}
if ($sync.runspace) {
# A replacement, not a shutdown
Close-WinUtilRunspacePool -Recycle
}
# Set the maximum number of threads for the RunspacePool to the number of threads on the machine.
$maxthreads = [Math]::Max([int]$env:NUMBER_OF_PROCESSORS, 1)
$sync.runspace = [runspacefactory]::CreateRunspacePool(
1, # Minimum thread count
$maxthreads, # Maximum thread count
(New-WinUtilSessionState), # Initial session state
$Host # Machine to create runspaces on
)
$sync.runspace.Open()
return $sync.runspace
} finally {
[System.Threading.Monitor]::Exit($poolLock)
}
if ($sync.runspace) {
Close-WinUtilRunspacePool
}
# Set the maximum number of threads for the RunspacePool to the number of threads on the machine.
$maxthreads = [Math]::Max([int]$env:NUMBER_OF_PROCESSORS, 1)
# Create a new session state for parsing variables into our runspace.
$hashVars = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'sync', $sync, $null
$offlineVar = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'PARAM_OFFLINE', $PARAM_OFFLINE, $null
$initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
$initialSessionState.Variables.Add($hashVars)
$initialSessionState.Variables.Add($offlineVar)
# Get every WinUtil/WPF function and add it to the session state.
$functions = Get-ChildItem function:\ | Where-Object { $_.Name -imatch 'winutil|WPF' }
foreach ($function in $functions) {
$functionDefinition = Get-Content function:\$($function.Name)
$functionEntry = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function.Name, $functionDefinition
$initialSessionState.Commands.Add($functionEntry)
}
$sync.runspace = [runspacefactory]::CreateRunspacePool(
1, # Minimum thread count
$maxthreads, # Maximum thread count
$initialSessionState, # Initial session state
$Host # Machine to create runspaces on
)
$sync.runspace.Open()
return $sync.runspace
}
@@ -1,7 +1,11 @@
function Initialize-WinUtilTabContent {
param(
[Parameter(Mandatory = $true)]
[string]$TabName
[string]$TabName,
# Build in batches, letting the interface answer in between. Used by the warmup, which
# nobody is waiting on. A tab the user just clicked is built in one go.
[switch]$Yield
)
if ($null -eq $sync.InitializedTabs) {
@@ -12,28 +16,42 @@ function Initialize-WinUtilTabContent {
return
}
switch ($TabName) {
"Install" {
Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1
Initialize-WPFUI -targetGridName "appscategory"
# Claimed before building, not after: a yielding build lets a click through, and that click
# would otherwise start building the same tab a second time.
$sync.InitializedTabs[$TabName] = $true
Initialize-WPFUI -targetGridName "appspanel"
}
"Tweaks" {
Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2
}
"Config" {
Invoke-WPFUIElements -configVariable $sync.configs.feature -targetGridName "featurespanel" -columncount 2
}
"AppX" {
Invoke-WPFUIElements -configVariable $sync.configs.appx -targetGridName "appxpanel" -columncount 2
}
"Win11ISO" {
if ($sync.Form -and $sync.Form.Dispatcher) {
$sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null
try {
switch ($TabName) {
"Install" {
Measure-WinUtilStep -Scope "UI" -Name "Install tab: category area" -ScriptBlock {
Initialize-WPFUI -targetGridName "appscategory"
}
Measure-WinUtilStep -Scope "UI" -Name "Install tab: app area" -ScriptBlock {
Initialize-WPFUI -targetGridName "appspanel"
}
Initialize-WinUtilInstallTabControls
}
"Tweaks" {
Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2 -Yield:$Yield
}
"Config" {
Invoke-WPFUIElements -configVariable $sync.configs.feature -targetGridName "featurespanel" -columncount 2 -Yield:$Yield
}
"AppX" {
Invoke-WPFUIElements -configVariable $sync.configs.appx -targetGridName "appxpanel" -columncount 2 -Yield:$Yield
}
"Win11ISO" {
if (Test-WinUtilUIAlive) {
$sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null
}
}
}
# Controls built just now start unchecked, so anything already chosen by an import or a
# preset has to be applied to them once they exist
Reset-WPFCheckBoxes -doToggles $true
} catch {
# A half built tab must be allowed to rebuild rather than staying empty forever
$sync.InitializedTabs[$TabName] = $false
throw
}
$sync.InitializedTabs[$TabName] = $true
}
@@ -4,15 +4,30 @@ function Initialize-WinUtilTaskbarOverlayAssets {
[bool]$IncludeStatusAssets = $true
)
if ($IncludeLogo -and -not $sync["logorender"]) {
$sync["logorender"] = (Invoke-WinUtilAssets -Type "Logo" -Size 90 -Render)
[System.Threading.Monitor]::Enter($sync.SyncRoot)
try {
if ($null -eq $sync.AssetRenderLock) {
$sync.AssetRenderLock = [object]::new()
}
$assetRenderLock = $sync.AssetRenderLock
} finally {
[System.Threading.Monitor]::Exit($sync.SyncRoot)
}
if ($IncludeStatusAssets -and -not $sync["checkmarkrender"]) {
$sync["checkmarkrender"] = (Invoke-WinUtilAssets -Type "checkmark" -Size 512 -Render)
}
[System.Threading.Monitor]::Enter($assetRenderLock)
try {
if ($IncludeLogo -and -not $sync["logorender"]) {
$sync["logorender"] = (Invoke-WinUtilAssets -Type "Logo" -Size 90 -Render)
}
if ($IncludeStatusAssets -and -not $sync["warningrender"]) {
$sync["warningrender"] = (Invoke-WinUtilAssets -Type "warning" -Size 512 -Render)
if ($IncludeStatusAssets -and -not $sync["checkmarkrender"]) {
$sync["checkmarkrender"] = (Invoke-WinUtilAssets -Type "checkmark" -Size 512 -Render)
}
if ($IncludeStatusAssets -and -not $sync["warningrender"]) {
$sync["warningrender"] = (Invoke-WinUtilAssets -Type "warning" -Size 512 -Render)
}
} finally {
[System.Threading.Monitor]::Exit($assetRenderLock)
}
}
+12 -3
View File
@@ -58,7 +58,14 @@ function Install-WinUtilAPPX {
$manifestPath = ($manifestOutput | Select-Object -Last 1).ToString().Trim()
if (-not [string]::IsNullOrWhiteSpace($manifestPath)) {
Write-WinUtilLog -Component "AppX" -Message "Registered local AppX manifest for $Name`: $manifestPath"
return
return [pscustomobject]@{
Package = $Name
Manager = "appx"
Action = "Install"
ExitCode = 0
Outcome = "Succeeded"
Detail = "registered local manifest"
}
}
}
@@ -70,10 +77,12 @@ function Install-WinUtilAPPX {
if ([string]::IsNullOrWhiteSpace($StoreId)) {
$errorMessage = "Unable to install $Name because no local manifest or Microsoft Store ID is available."
Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message $errorMessage
throw $errorMessage
$exception = [System.InvalidOperationException]::new($errorMessage)
$exception.Data["WinUtilErrorReported"] = $true
throw $exception
}
Write-WinUtilLog -Component "AppX" -Message "No usable local manifest found for $Name. Installing Microsoft Store product $StoreId."
Install-WinUtilWinget
$null = Install-WinUtilWinget
Install-WinUtilProgramWinget -Action Install -Programs @("msstore:$StoreId")
}
+36 -4
View File
@@ -1,7 +1,39 @@
function Install-WinUtilChoco {
if (-not (Get-Command -Name choco)) {
Write-Host "Chocolatey is not installed. Installing now..."
$installScript = Invoke-WebRequest -Uri https://community.chocolatey.org/install.ps1 -UseBasicParsing
Invoke-Command -ScriptBlock ([scriptblock]::Create($installScript.Content))
<#
.SYNOPSIS
Installs Chocolatey if it is not already present
#>
if (Get-Command -Name choco -ErrorAction SilentlyContinue) {
return
}
Write-WinUtilLog -Component "Package" -Message "Chocolatey is not installed, installing it now."
Step-WinUtilJob -Status "Installing Chocolatey" -State "Indeterminate"
# Windows PowerShell 5.1 can negotiate a protocol the site refuses, which the official
# bootstrap sets explicitly for the same reason
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
$installScript = Invoke-WebRequest -Uri https://community.chocolatey.org/install.ps1 -UseBasicParsing -TimeoutSec 60
Invoke-Command -ScriptBlock ([scriptblock]::Create($installScript.Content))
# The installer extends PATH for new processes, which this one is not. Appended rather than
# replaced: overwriting drops whatever this process added earlier in the session, and a
# later step looking for that tool would no longer find it.
$existing = $env:PATH -split ';' | Where-Object { $_ }
$persisted = @(
[System.Environment]::GetEnvironmentVariable("Path", "Machine")
[System.Environment]::GetEnvironmentVariable("Path", "User")
) -join ';' -split ';' | Where-Object { $_ }
$missing = $persisted | Where-Object { $existing -notcontains $_ }
if ($missing) {
$env:PATH = (@($existing) + @($missing)) -join ';'
}
if (-not (Get-Command -Name choco -ErrorAction SilentlyContinue)) {
throw "Chocolatey was installed but choco is still not on PATH."
}
Write-WinUtilLog -Component "Package" -Message "Chocolatey installed."
}
@@ -1,20 +1,116 @@
function Install-WinUtilProgramChoco {
<#
.SYNOPSIS
Installs, upgrades or uninstalls packages with Chocolatey and reports each outcome
.DESCRIPTION
One package per call to choco, so the progress bar moves through the list and a failure
names the package that failed rather than the whole batch. Choco's own output goes to the
log instead of the console, the way the WinGet path reports.
.PARAMETER Action
Install, Upgrade or Uninstall.
.PARAMETER Programs
The package names. For Upgrade, the single entry "all" upgrades everything.
.PARAMETER ProgressBase
Where this call starts within the job's overall progress bar.
.PARAMETER ProgressSpan
How much of the overall bar these packages account for. Zero reports nothing.
#>
param (
[Parameter(Mandatory=$true)]
[ValidateSet("Install", "Uninstall")]
[ValidateSet("Install", "Uninstall", "Upgrade")]
[string]$Action,
[Parameter(Mandatory=$true)]
[string[]]$Programs
[string[]]$Programs,
[int]$ProgressBase = 0,
[int]$ProgressSpan = 0
)
if ($Action -eq 'Install') {
$arguments = "install $Programs -y"
} else {
$arguments = "uninstall $Programs -y"
# Chocolatey reports "nothing needed doing" and "it worked, now reboot" through exit codes
# rather than as failures
$rebootCodes = @{
1641 = "installed, the installer started a restart"
3010 = "installed, a restart is needed to finish"
}
$nothingToDo = @{
2 = "nothing to do"
}
$verb = $Action.ToLowerInvariant()
$chocoAvailable = $null -ne (Get-Command choco -ErrorAction SilentlyContinue)
Write-WinUtilLog -Component "Package" -Message "$Action choco package(s): $($Programs -join ', ')"
$process = Start-Process -FilePath choco -ArgumentList $arguments -NoNewWindow -Wait -PassThru
Write-WinUtilLog -Component "Package" -Message "$Action choco package(s) completed: $($Programs -join ', ') (exit code: $($process.ExitCode))"
$packages = @($Programs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
$total = $packages.Count
$index = 0
foreach ($program in $packages) {
$index++
if ($ProgressSpan -gt 0 -and $total -gt 0) {
$percent = $ProgressBase + [int]((($index - 1) / $total) * $ProgressSpan)
Step-WinUtilJob -Status "$Action $program ($index/$total)" -Percent $percent
}
Write-WinUtilLog -Component "Package" -Message "$Action choco package: $program"
# --no-progress stops choco redrawing a percentage line that only makes sense on a
# console nobody is watching
$arguments = @($verb, $program, "-y", "--no-progress")
# Each worker runspace has its own global scope. Reset the native-command result there so
# command-not-found cannot inherit a successful code from earlier work in the same pool.
$global:LASTEXITCODE = $null
if (-not $chocoAvailable) {
$output = "Chocolatey is not installed or is not available on PATH."
$exitCode = -1
} else {
$output = & choco @arguments 2>&1
$exitCode = if ($null -eq $global:LASTEXITCODE) { -1 } else { [int]$global:LASTEXITCODE }
}
if ($exitCode -eq 0) {
$outcome = "Succeeded"
$detail = "exit code 0"
} elseif ($rebootCodes.ContainsKey($exitCode)) {
$outcome = "Succeeded"
$detail = $rebootCodes[$exitCode]
} elseif ($nothingToDo.ContainsKey($exitCode)) {
$outcome = "Skipped"
$detail = $nothingToDo[$exitCode]
} else {
$outcome = "Failed"
$detail = if ($exitCode -eq -1) { "Chocolatey command did not start" } else { "exit code $exitCode" }
}
$level = if ($outcome -eq "Failed") { "ERROR" } else { "INFO" }
Write-WinUtilLog -Level $level -Component "Package" -Message "$Action choco package $($outcome.ToLowerInvariant()): $program ($detail)"
if ($outcome -eq "Failed") {
# The reason is somewhere in choco's output, and without it the log says only that
# a number came back
foreach ($line in @($output | Select-Object -Last 15)) {
$text = ([string]$line).Trim()
if ($text) { Write-WinUtilLog -Level "WARN" -Component "Package" -Detail -Message $text }
}
}
if ($ProgressSpan -gt 0 -and $total -gt 0) {
Step-WinUtilJob -Status "$Action $program ($index/$total)" -Percent ($ProgressBase + [int](($index / $total) * $ProgressSpan))
}
[pscustomobject]@{
Package = $program
Manager = "choco"
Action = $Action
ExitCode = $exitCode
Outcome = $outcome
Detail = $detail
}
}
}
@@ -1,32 +1,116 @@
Function Install-WinUtilProgramWinget {
<#
.SYNOPSIS
Installs or uninstalls packages with WinGet and reports the outcome of each one
.DESCRIPTION
Emits one result object per package so the caller can tell what actually happened
rather than assuming the run succeeded.
Runs one winget command per package so a failure names the package that failed rather
than the whole batch. Progress moves per package: winget hides its own progress bar once
its output is redirected, so there is nothing to report from inside a single install.
#>
param (
[Parameter(Mandatory=$true)]
[ValidateSet("Install", "Uninstall")]
[ValidateSet("Install", "Uninstall", "Upgrade")]
[string]$Action,
[Parameter(Mandatory=$true)]
[string[]]$Programs
)
# APPINSTALLER_CLI_ERROR_ADMIN_CONTEXT_ACTION_PROHIBITED. WinGet refuses to act on a package
# that was installed in user scope while it is running elevated, and WinUtil is always
# elevated, so every per-user app answers this and nothing happens.
$adminContextProhibited = -1978335107
# WinGet reports "there was nothing to do" through the exit code rather than as success
$nothingToDo = @{
-1978335135 = "already installed"
-1978335189 = "no applicable update"
}
# The installer worked and wants a restart to finish. Windows reports that as its own exit
# code rather than as zero, and treating it as a failure marks working installs as broken.
$rebootExitCodes = @{
3010 = "installed, a restart is needed to finish"
1641 = "installed, the installer started a restart"
# WinGet's own equivalents. -1978334966 is deliberately absent: it means a reboot is
# required before the install can proceed, which is not a completed install.
-1978334967 = "installed, a restart is needed to finish"
-1978334965 = "installed, the installer started a restart"
}
foreach ($program in $Programs) {
if ([string]::IsNullOrWhiteSpace($program) -or $program -eq "na") {
continue
}
$source = "winget"
if ($program.StartsWith("msstore:", [System.StringComparison]::OrdinalIgnoreCase)) {
$upgradeAll = $Action -eq "Upgrade" -and $program -eq "all"
$source = if ($upgradeAll) { "all configured sources" } else { "winget" }
if (-not $upgradeAll -and $program.StartsWith("msstore:", [System.StringComparison]::OrdinalIgnoreCase)) {
$source = "msstore"
$program = $program.Substring("msstore:".Length)
}
if ($Action -eq 'Install') {
$arguments = @("install", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--silent")
} else {
$arguments = @("uninstall", "--id", $program, "--source", $source, "--silent")
Write-WinUtilLog -Component "Package" -Message "$Action winget package: $program (source: $source)"
$outcome = "Failed"
$detail = "no result"
$exitCode = -1
$arguments = switch ($Action) {
"Uninstall" { @("uninstall", "--id", $program, "--source", $source, "--silent") }
# --include-unknown because the scan that found these ran with it: without it winget
# refuses every package whose installed version it could not read
"Upgrade" {
if ($upgradeAll) {
@("upgrade", "--all", "--accept-package-agreements", "--accept-source-agreements", "--include-unknown", "--silent")
} else {
@("upgrade", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--include-unknown", "--silent")
}
}
default { @("install", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--silent") }
}
Write-WinUtilLog -Component "Package" -Message "$Action winget package: $program (source: $source)"
$process = Start-Process -FilePath winget -ArgumentList $arguments -NoNewWindow -Wait -PassThru
Write-WinUtilLog -Component "Package" -Message "$Action winget package completed: $program (exit code: $($process.ExitCode))"
$exitCode = $process.ExitCode
if ($exitCode -eq 0) {
$outcome = "Succeeded"
$detail = "exit code 0"
} elseif ($rebootExitCodes.ContainsKey($exitCode)) {
$outcome = "Succeeded"
$detail = $rebootExitCodes[$exitCode]
} elseif ($nothingToDo.ContainsKey($exitCode)) {
$outcome = "Skipped"
$detail = $nothingToDo[$exitCode]
} elseif ($exitCode -eq $adminContextProhibited) {
$outcome = "Skipped"
$detail = switch ($Action) {
"Install" { "already installed for the current user; elevated WinUtil cannot update it" }
"Upgrade" { "not upgraded; installed for the current user and elevated WinUtil cannot modify it" }
"Uninstall" { "remains installed for the current user; elevated WinUtil cannot uninstall it" }
}
} else {
$outcome = "Failed"
# The client module reports the same failure as a bare HRESULT, so the hex form and
# Microsoft's own list serve both paths
$detail = "WinGet reported 0x{0:X8}. See https://learn.microsoft.com/windows/package-manager/winget/returnCodes" -f $exitCode
}
$level = if ($outcome -eq "Failed") { "ERROR" } else { "INFO" }
Write-WinUtilLog -Level $level -Component "Package" -Message "$Action winget package $($outcome.ToLowerInvariant()): $program ($detail)"
[pscustomobject]@{
Package = $program
Manager = "winget"
Action = $Action
ExitCode = $exitCode
Outcome = $outcome
Detail = $detail
}
}
}
+12 -2
View File
@@ -7,11 +7,21 @@ function Install-WinUtilWinget {
.DESCRIPTION
installs winGet if needed
#>
if ((Test-WinUtilPackageManager -winget) -eq "installed") {
param(
[switch]$Force
)
# The repair action needs Repair-WinGetPackageManager to run even when winget is detected,
# which is the case a broken installation presents
if (-not $Force -and (Test-WinUtilPackageManager -winget) -eq "installed") {
return
}
Write-Host "WinGet is not installed. Installing now..." -ForegroundColor Red
if ($Force) {
Write-Host "Repairing the WinGet installation..." -ForegroundColor Yellow
} else {
Write-Host "WinGet is not installed. Installing now..." -ForegroundColor Red
}
Install-PackageProvider -Name NuGet -Force
Install-Module -Name Microsoft.WinGet.Client -Force
@@ -0,0 +1,20 @@
function Invoke-WinUtilAppCategoryChip {
<#
.SYNOPSIS
Handles a click on an Install tab category chip
.DESCRIPTION
The chip carries its category in Tag, so every chip shares this handler. Holding ctrl
adds the category to the current selection instead of replacing it.
.PARAMETER Chip
The chip that was clicked
#>
param(
[Parameter(Mandatory)]
$Chip
)
$ctrlDown = [bool]([System.Windows.Input.Keyboard]::Modifiers -band [System.Windows.Input.ModifierKeys]::Control)
Set-WinUtilAppCategoryFilter -Category $Chip.Tag -Additive:$ctrlDown
}
+1 -1
View File
@@ -7,7 +7,7 @@ function Invoke-WinUtilAssets {
if ($render -and $null -ne $sync) {
if ($null -eq $sync.RenderedAssetCache) {
$sync.RenderedAssetCache = @{}
$sync.RenderedAssetCache = [Hashtable]::Synchronized(@{})
}
$cacheKey = "$(([string]$type).ToLowerInvariant())|$Size"
@@ -0,0 +1,116 @@
function Invoke-WinUtilCloseRequest {
<#
.SYNOPSIS
Asks what to do about work that is still running when the window is closed
.DESCRIPTION
A half finished install or tweak run is not ended without asking. Either it finishes
without the window, reporting to the console and then exiting, or it is stopped and
everything closes now.
.PARAMETER RunningJob
The name of the job in flight, so the question names what is at stake.
#>
param(
[Parameter(Mandatory)]
[string]$RunningJob
)
# The question carries the meaning rather than naming buttons: Windows labels them in its own
# language, so "Yes" in the text would not match a button reading "Ja".
$answer = Show-WinUtilMessage -Button "YesNoCancel" -Icon "Warning" -Title "$RunningJob is still running" -Message @"
$RunningJob has not finished yet.
Close the window and let it finish in the console?
WinUtil will exit on its own once it is done. If you do not, it will be
stopped and everything closes now. Cancel keeps WinUtil open.
"@
switch ("$answer") {
"Yes" {
Write-WinUtilLog -Component "UI" -Message "Close requested: closing the window, $RunningJob continues in the console."
$sync.FinishInConsole = $true
$sync.ForceClose = $true
Write-Host ""
Write-Host "WinUtil's window is closed. $RunningJob is still running here, and this window will close when it finishes." -ForegroundColor Cyan
Write-Host ""
# Posted rather than closed from inside the handler that is already unwinding
Request-WinUtilWindowClose
}
"No" {
Write-WinUtilLog -Component "UI" -Message "Close requested: stopping $RunningJob."
Step-WinUtilJob -Status "Stopping $RunningJob" -State "Indeterminate"
$sync.ForceClose = $true
# Close the window first. The main thread owns pool shutdown after ShowDialog
# returns, so the worker can finish its UI-dispatching finally block before the UI
# runspace is disposed. Waiting for it here would deadlock the dispatcher.
Request-WinUtilWindowClose
}
default {
Write-WinUtilLog -Component "UI" -Message "Close cancelled, $RunningJob is still running."
}
}
}
function Request-WinUtilWindowClose {
<#
.SYNOPSIS
Closes the window from outside the handler that is currently cancelling the close
#>
if (-not (Test-WinUtilUIAlive)) {
return
}
$sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{
$sync.Form.Close()
}) | Out-Null
}
function Wait-WinUtilRemainingWork {
<#
.SYNOPSIS
Waits for work that outlived the window, reporting to the console
.DESCRIPTION
Runs on the main thread once the interface has gone. The job is still on the worker
pool and keeps logging, so this only waits and keeps the wait visible.
.PARAMETER TimeoutMinutes
Upper bound, so a worker that never returns cannot keep the process alive.
#>
param(
# Double rather than int: an int silently truncates a fractional value to zero, which
# turns the bound into "do not wait at all"
[double]$TimeoutMinutes = 120
)
if (-not $sync.FinishInConsole -or -not $sync.ActiveJob) {
return
}
$job = $sync.ActiveJob
Write-WinUtilLog -Component "UI" -Message "Window closed, waiting for $job to finish."
Write-Host "Waiting for $job to finish..." -ForegroundColor Cyan
$clock = [System.Diagnostics.Stopwatch]::StartNew()
while ($sync.ActiveJob -and $clock.Elapsed.TotalMinutes -lt $TimeoutMinutes) {
Start-Sleep -Milliseconds 250
}
# The job's last progress line is still open, so anything after it needs a fresh line
Complete-WinUtilConsoleProgress
if ($sync.ActiveJob) {
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "$job did not finish within $TimeoutMinutes minutes, exiting anyway."
Write-Host "$job is taking longer than $TimeoutMinutes minutes. Exiting." -ForegroundColor Yellow
return
}
Write-WinUtilLog -Component "UI" -Message "$job finished after the window closed, in $([int]$clock.Elapsed.TotalSeconds)s."
Write-Host "$job finished. Closing." -ForegroundColor Green
}
@@ -11,7 +11,9 @@ Function Invoke-WinUtilCurrentSystem {
#>
param(
$CheckBox
$CheckBox,
[switch]$BypassToggleStatusCache,
[switch]$StopOnReadError
)
if ($CheckBox -eq "choco") {
$apps = (choco list | Select-String -Pattern "^\S+").Matches.Value
@@ -52,6 +54,7 @@ Function Invoke-WinUtilCurrentSystem {
if ($CheckBox -eq "tweaks") {
if (!(Test-Path 'HKU:\')) {$null = (New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS)}
$readErrorAction = if ($StopOnReadError) { "Stop" } else { "SilentlyContinue" }
$sync.configs.tweaks | Get-Member -MemberType NoteProperty | ForEach-Object {
@@ -61,11 +64,13 @@ Function Invoke-WinUtilCurrentSystem {
$serviceKeys = $entry.service
$entryType = $entry.Type
if ($registryKeys -or $serviceKeys) {
if (($registryKeys -or $serviceKeys) -and $entryType -ne "Combobox") {
$Values = @()
if ($entryType -eq "Toggle") {
if (-not (Get-WinUtilToggleStatus $Config)) {
if (-not (Get-WinUtilToggleStatus $Config `
-BypassCache:$BypassToggleStatusCache `
-StopOnReadError:$StopOnReadError)) {
$values += $False
}
} else {
@@ -77,8 +82,12 @@ Function Invoke-WinUtilCurrentSystem {
$registryTotal++
$regstate = $null
if (Test-Path $tweak.Path) {
$regstate = Get-ItemProperty -Name $tweak.Name -Path $tweak.Path -ErrorAction SilentlyContinue | Select-Object -ExpandProperty $($tweak.Name)
if (Test-Path $tweak.Path -ErrorAction $readErrorAction) {
if ($StopOnReadError) {
$regstate = (Get-ItemProperty -Path $tweak.Path -ErrorAction Stop).$($tweak.Name)
} else {
$regstate = Get-ItemProperty -Name $tweak.Name -Path $tweak.Path -ErrorAction SilentlyContinue | Select-Object -ExpandProperty $($tweak.Name)
}
}
if ($null -eq $regstate) {
@@ -108,7 +117,17 @@ Function Invoke-WinUtilCurrentSystem {
Foreach ($tweaks in $serviceKeys) {
Foreach ($tweak in $tweaks) {
$Service = Get-Service -Name $tweak.Name
try {
$Service = Get-Service -Name $tweak.Name -ErrorAction $readErrorAction
} catch {
if ($StopOnReadError -and $_.FullyQualifiedErrorId -like "NoServiceFoundForGivenName*") {
# A removed optional service means this tweak is not applied; it does
# not make the registry and service state for every other tweak unknown.
$values += $False
continue
}
throw
}
if ($Service) {
$actualValue = $Service.StartType
@@ -116,6 +135,8 @@ Function Invoke-WinUtilCurrentSystem {
if ($expectedValue -ne $actualValue) {
$values += $False
}
} elseif ($StopOnReadError) {
$values += $False
}
}
}
@@ -8,7 +8,9 @@ function Invoke-WinUtilExplorerUpdate {
)
if ($action -eq "refresh") {
Invoke-WPFRunspace -ScriptBlock {
# The handle is of no use to the caller, and leaving it in the pipeline puts it into
# whatever result the calling workflow returns
$null = Invoke-WPFRunspace -ScriptBlock {
# Define the Win32 type only if it doesn't exist
if (-not ([System.Management.Automation.PSTypeName]'Win32').Type) {
Add-Type -TypeDefinition @"
@@ -29,6 +29,8 @@ function Invoke-WinUtilFontScaling {
"FontSize",
"ButtonFontSize",
"HeaderFontSize",
"Win11StepTitleFontSize",
"Win11StepHeroFontSize",
"TabButtonFontSize",
"ConfigTabButtonFontSize",
"IconFontSize",
File diff suppressed because it is too large Load Diff
+228 -22
View File
@@ -25,6 +25,10 @@ function Invoke-WinUtilISOScript {
.PARAMETER Log
Optional ScriptBlock for progress/status logging. Receives a single [string] argument.
.PARAMETER DriversInjected
Optional [ref] set to $true only if driver injection actually mounted and committed
install.wim; stays $false if injection was skipped or no package was added successfully.
#>
param (
[Parameter(Mandatory)][string]$ISOContentsDir,
@@ -33,7 +37,8 @@ function Invoke-WinUtilISOScript {
[string]$InstallEditionId = "",
[string]$InstallImagePath = "",
[int]$InstallImageIndex = 1,
[scriptblock]$Log = { param($m) Write-Output $m }
[scriptblock]$Log = { param($m) Write-Output $m },
[ref]$DriversInjected = [ref]$false
)
function Add-WinUtilISOStagedDrivers {
@@ -41,8 +46,10 @@ function Invoke-WinUtilISOScript {
[Parameter(Mandatory)][string]$ContentRoot,
[Parameter(Mandatory)][string]$InstallImagePath,
[Parameter(Mandatory)][int]$InstallImageIndex,
[scriptblock]$Logger
[scriptblock]$Logger,
[ref]$DriversInjected = [ref]$false
)
$DriversInjected.Value = $false
function Copy-WinUtilISODriverFolder {
param (
@@ -77,6 +84,128 @@ function Invoke-WinUtilISOScript {
}
}
function Get-WinUtilISODriverPackageVersion {
param ([Parameter(Mandatory)][System.IO.FileInfo]$InfFile)
try {
$infText = Get-Content -LiteralPath $InfFile.FullName -Raw -ErrorAction Stop
} catch {
$null = & $Logger "Warning: could not read '$($InfFile.FullName)' to determine its driver version: $_"
return $null
}
# The version component of DriverVer is optional per the INF spec (date-only entries
# are valid); treat a missing version as 0.0 so date-only entries still rank correctly
# instead of being discarded as unparseable.
$match = [regex]::Match($infText, '(?im)^\s*DriverVer\s*=\s*(?<date>\d{1,2}/\d{1,2}/\d{4})\s*(?:,\s*(?<version>\d+(?:\.\d+){0,3}))?\s*(?:;.*)?$')
if (-not $match.Success) {
return $null
}
try {
$date = [datetime]::ParseExact($match.Groups['date'].Value, 'M/d/yyyy', [System.Globalization.CultureInfo]::InvariantCulture)
$versionText = if ($match.Groups['version'].Success) { $match.Groups['version'].Value } else { '0' }
if (($versionText.Split('.')).Count -lt 2) {
$versionText = "$versionText.0"
}
$version = [version]$versionText
} catch {
$null = & $Logger "Warning: could not parse DriverVer '$($match.Value.Trim())' in '$($InfFile.FullName)': $_"
return $null
}
return [pscustomobject]@{
Date = $date
Version = $version
Raw = if ($match.Groups['version'].Success) { "$($match.Groups['date'].Value),$($match.Groups['version'].Value)" } else { $match.Groups['date'].Value }
}
}
function Get-WinUtilISODriverProvider {
param ([Parameter(Mandatory)][System.IO.FileInfo]$InfFile)
try {
$infText = Get-Content -LiteralPath $InfFile.FullName -Raw -ErrorAction Stop
} catch {
$null = & $Logger "Warning: could not read '$($InfFile.FullName)' to determine its provider: $_"
return ''
}
$match = [regex]::Match($infText, '(?im)^\s*Provider\s*=\s*(?<provider>.+?)\s*(?:;.*)?$')
if (-not $match.Success) {
return ''
}
return $match.Groups['provider'].Value.ToLowerInvariant()
}
function Select-WinUtilISOStagedDriverPackages {
param (
[Parameter(Mandatory)][AllowEmptyCollection()][object[]]$DriverFolderGroups,
[scriptblock]$Logger
)
$survivingFolders = [System.Collections.Generic.List[string]]::new()
$dedupGroups = @{}
foreach ($driverFolderGroup in $DriverFolderGroups) {
$driverFolder = [string]$driverFolderGroup.Name
# DISM names exported package folders <infname>_<arch>_<hash>; grouping on infname+arch
# (dropping the hash) is what lets us recognize two exports of the same driver. When a
# folder doesn't match that pattern, fall back to the full path rather than the leaf name:
# two unrelated folders at different depths (e.g. group_a\duplicate and group_b\duplicate)
# can share a leaf name, and the full path is guaranteed unique per group.
$leafName = Split-Path -Path $driverFolder -Leaf
$dedupKey = $driverFolder
$nameMatch = [regex]::Match($leafName, '(?i)^(?<infname>.+)_(?<arch>x86|amd64|arm64|arm|wow)_[0-9a-f]{16}$')
if ($nameMatch.Success) {
$provider = Get-WinUtilISODriverProvider -InfFile $driverFolderGroup.Group[0]
$dedupKey = "$($nameMatch.Groups['infname'].Value.ToLowerInvariant())_$($nameMatch.Groups['arch'].Value.ToLowerInvariant())_$provider"
}
if (-not $dedupGroups.ContainsKey($dedupKey)) {
$dedupGroups[$dedupKey] = [System.Collections.Generic.List[object]]::new()
}
$dedupGroups[$dedupKey].Add($driverFolderGroup)
}
foreach ($dedupKey in $dedupGroups.Keys) {
$candidates = $dedupGroups[$dedupKey]
if ($candidates.Count -eq 1) {
$survivingFolders.Add([string]$candidates[0].Name)
continue
}
$ranked = @($candidates | ForEach-Object {
$primaryVersion = ($_.Group | ForEach-Object { Get-WinUtilISODriverPackageVersion -InfFile $_ } | Where-Object { $_ }) |
Sort-Object -Property Date, Version -Descending | Select-Object -First 1
[pscustomobject]@{ Folder = [string]$_.Name; Version = $primaryVersion }
})
$withVersion = @($ranked | Where-Object { $_.Version })
if ($withVersion.Count -eq 0) {
$null = & $Logger "Warning: could not determine DriverVer for any duplicate of '$dedupKey'; keeping all $($ranked.Count) package(s) rather than guessing."
foreach ($candidate in $ranked) {
$survivingFolders.Add($candidate.Folder)
}
continue
}
$kept = $withVersion | Sort-Object -Property @{ Expression = { $_.Version.Date } }, @{ Expression = { $_.Version.Version } } -Descending | Select-Object -First 1
$survivingFolders.Add($kept.Folder)
foreach ($candidate in $ranked) {
if ($candidate.Folder -eq $kept.Folder) {
continue
}
$droppedVersion = if ($candidate.Version) { $candidate.Version.Raw } else { 'unknown' }
$null = & $Logger "Excluding stale duplicate driver package '$($candidate.Folder)' (DriverVer $droppedVersion) superseded by '$($kept.Folder)' (DriverVer $($kept.Version.Raw))."
}
}
return @($survivingFolders)
}
function Invoke-WinUtilISODism {
param (
[Parameter(Mandatory)][string[]]$Arguments,
@@ -151,17 +280,18 @@ function Invoke-WinUtilISOScript {
$driverExportRoot = Join-Path $env:TEMP "WinUtil_DriverExport_$(Get-Date -Format 'yyyyMMdd_HHmmss')_$(([guid]::NewGuid()).ToString('N').Substring(0, 8))"
$mountDir = Join-Path (Split-Path -Path $ContentRoot -Parent) 'wim_mount'
New-Item -Path $driverExportRoot -ItemType Directory -Force | Out-Null
# %TEMP% can be an 8.3 alias, but Get-ChildItem below reports long paths, so the
# exported folders would not share this prefix unless it is expanded first.
$driverExportRoot = (Get-Item -LiteralPath $driverExportRoot).FullName
$imageMounted = $false
try {
& $Logger "Exporting current system drivers before modifying install.wim..."
$dismLog = Join-Path $env:TEMP "WinUtil_DismDriverExport_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
$dismProcess = Start-Process -FilePath "dism.exe" -ArgumentList "/online /export-driver /destination:`"$driverExportRoot`" /LogPath:`"$dismLog`"" -Wait -NoNewWindow -PassThru
if ($dismProcess.ExitCode -ne 0) {
throw "dism.exe driver export failed with exit code $($dismProcess.ExitCode)."
}
Invoke-WinUtilISODism -Arguments @('/English', '/Online', '/Export-Driver', "/Destination:$driverExportRoot", "/LogPath:$dismLog") -Operation 'export-driver' | Out-Null
$driverInfs = @(Get-ChildItem -Path $driverExportRoot -Filter '*.inf' -Recurse -File)
$driverInfs = @(Get-ChildItem -LiteralPath $driverExportRoot -Filter '*.inf' -Recurse -File)
if ($driverInfs.Count -eq 0) {
throw 'DISM exported no driver INF files.'
}
@@ -192,26 +322,102 @@ function Invoke-WinUtilISOScript {
throw "Failed to stage $copyFailures boot-storage driver package folders."
}
& $Logger "Exported $($driverInfs.Count) driver INF files across $($driverFolders.Count) package folders; staged $storageCount boot-storage packages for WinPE."
$stagedDriverFolders = @(Select-WinUtilISOStagedDriverPackages -DriverFolderGroups $driverFolders -Logger $Logger)
$metadataBefore = Get-WinUtilISOWimMetadata -ImagePath $InstallImagePath -Index $InstallImageIndex
Assert-WinUtilISOWimMetadata -Before $metadataBefore
$excludedDriverFolderGroups = @($driverFolders | Where-Object { $_.Name -notin $stagedDriverFolders })
foreach ($excludedDriverFolderGroup in $excludedDriverFolderGroups) {
$excludedFolder = [string]$excludedDriverFolderGroup.Name
$hasRetainedDescendant = [bool]@($stagedDriverFolders | Where-Object {
$_.StartsWith("$excludedFolder\", [System.StringComparison]::OrdinalIgnoreCase)
}).Count
if ($hasRetainedDescendant) {
try {
foreach ($excludedInf in $excludedDriverFolderGroup.Group) {
Remove-Item -LiteralPath $excludedInf.FullName -Force -ErrorAction Stop
}
} catch {
throw "Failed to remove excluded driver INF files from package '$excludedFolder' before injection: $_"
}
& $Logger "Keeping excluded driver package directory '$excludedFolder' because it contains a retained nested package, after removing its excluded INF files."
continue
}
try {
Remove-Item -LiteralPath $excludedFolder -Recurse -Force -ErrorAction Stop
} catch {
throw "Failed to remove excluded driver package '$excludedFolder' before injection: $_"
}
}
& $Logger "Exported $($stagedDriverFolders.Count) of $($driverFolders.Count) driver packages ($storageCount staged for WinPE, $($excludedDriverFolderGroups.Count) excluded)."
Set-ItemProperty -LiteralPath $InstallImagePath -Name IsReadOnly -Value $false
New-Item -Path $mountDir -ItemType Directory -Force | Out-Null
& $Logger "Mounting install.wim index $InstallImageIndex once for driver injection..."
Invoke-WinUtilISODism -Arguments @('/English', '/Mount-Image', "/ImageFile:$InstallImagePath", "/Index:$InstallImageIndex", "/MountDir:$mountDir") -Operation 'mount' | Out-Null
$imageMounted = $true
& $Logger "Adding all exported drivers to the selected Windows image in one DISM operation..."
Invoke-WinUtilISODism -Arguments @('/English', "/Image:$mountDir", '/Add-Driver', "/Driver:$driverExportRoot", '/Recurse') -Operation 'add-driver' | Out-Null
# Add each package separately so one bad driver cannot fail the rest. Because
# /Recurse covers descendants, only the highest surviving folder in each tree
# needs its own DISM call.
$rootPackageFolders = @($stagedDriverFolders | Where-Object {
$candidate = $_
-not ($stagedDriverFolders | Where-Object { $candidate.StartsWith("$_\", [System.StringComparison]::OrdinalIgnoreCase) })
})
& $Logger 'Committing the driver-only install.wim change...'
Invoke-WinUtilISODism -Arguments @('/English', '/Unmount-Image', "/MountDir:$mountDir", '/Commit') -Operation 'commit' | Out-Null
$imageMounted = $false
& $Logger "Adding $($rootPackageFolders.Count) root driver packages to install.wim."
$remainingDriverFolders = @($rootPackageFolders)
while ($remainingDriverFolders.Count -gt 0) {
& $Logger "Mounting install.wim index $InstallImageIndex for driver injection..."
Invoke-WinUtilISODism -Arguments @('/English', '/Mount-Image', "/ImageFile:$InstallImagePath", "/Index:$InstallImageIndex", "/MountDir:$mountDir") -Operation 'mount' | Out-Null
$imageMounted = $true
$metadataAfter = Get-WinUtilISOWimMetadata -ImagePath $InstallImagePath -Index $InstallImageIndex
Assert-WinUtilISOWimMetadata -Before $metadataBefore -After $metadataAfter
& $Logger 'Driver injection complete; install.wim metadata validation passed.'
$failedDriverFolder = $null
foreach ($driverFolder in $remainingDriverFolders) {
$driverName = $driverFolder
if ($driverFolder.StartsWith($driverExportRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
$driverName = $driverFolder.Substring($driverExportRoot.Length).TrimStart('\')
}
try {
Invoke-WinUtilISODism -Arguments @('/English', "/Image:$mountDir", '/Add-Driver', "/Driver:$driverFolder", '/Recurse') -Operation "add-driver:$driverName" | Out-Null
} catch {
& $Logger "Warning: failed to add driver package '$driverName': $_"
$failedDriverFolder = $driverFolder
break
}
}
if (-not $failedDriverFolder) {
break
}
& $Logger "Discarding the potentially partial install.wim mount before continuing without '$driverName'."
try {
Invoke-WinUtilISODism -Arguments @('/English', '/Unmount-Image', "/MountDir:$mountDir", '/Discard') -Operation 'discard' | Out-Null
$imageMounted = $false
} catch {
throw "Failed to discard the potentially partial install.wim mount after driver package '$driverName' failed: $_"
}
$remainingDriverFolders = @($remainingDriverFolders | Where-Object { $_ -ne $failedDriverFolder })
}
$addedCount = $remainingDriverFolders.Count
if ($addedCount -eq 0) {
# Boot-storage drivers staged for WinPE remain available to Windows Setup.
& $Logger "Warning: none of the $($rootPackageFolders.Count) exported driver packages could be added; continuing with an unmodified install.wim."
} else {
& $Logger "Added $addedCount of $($rootPackageFolders.Count) driver packages to install.wim."
& $Logger 'Committing the driver-only install.wim change...'
Invoke-WinUtilISODism -Arguments @('/English', '/Unmount-Image', "/MountDir:$mountDir", '/Commit') -Operation 'commit' | Out-Null
$imageMounted = $false
$metadataAfter = Get-WinUtilISOWimMetadata -ImagePath $InstallImagePath -Index $InstallImageIndex
Assert-WinUtilISOWimMetadata -Before $metadataBefore -After $metadataAfter
& $Logger 'Driver injection complete; install.wim metadata validation passed.'
$DriversInjected.Value = $true
}
} finally {
if ($imageMounted -or (Test-WinUtilISOMountedImage -Path $mountDir)) {
try {
@@ -220,8 +426,8 @@ function Invoke-WinUtilISOScript {
& $Logger "Warning: could not discard the failed install.wim mount: $_"
}
}
Remove-Item -Path $mountDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path $driverExportRoot -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $mountDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $driverExportRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
@@ -535,6 +741,6 @@ $appxList
Write-WinUtilISOEditionConfig -ContentRoot $ISOContentsDir -EditionId $InstallEditionId -Logger $Log
if ($InjectCurrentSystemDrivers) {
Add-WinUtilISOStagedDrivers -ContentRoot $ISOContentsDir -Logger $Log -InstallImagePath $InstallImagePath -InstallImageIndex $InstallImageIndex
Add-WinUtilISOStagedDrivers -ContentRoot $ISOContentsDir -Logger $Log -InstallImagePath $InstallImagePath -InstallImageIndex $InstallImageIndex -DriversInjected $DriversInjected
}
}
+100 -124
View File
@@ -21,25 +21,34 @@ function Invoke-WinUtilISORefreshUSBDrives {
$sync["Win11ISOUSBDisks"] = $removable
}
function Get-WinUtilFreeDriveLetter {
<#
.SYNOPSIS
Returns the first unused drive letter between D and Z, or $null when there is none.
#>
$used = (Get-PSDrive -PSProvider FileSystem).Name
foreach ($c in [char[]](68..90)) {
if ($used -notcontains [string]$c) { return $c }
}
return $null
}
function Invoke-WinUtilISOWriteUSB {
$contentsDir = $sync["Win11ISOContentsDir"]
$usbDisks = $sync["Win11ISOUSBDisks"]
if (-not $contentsDir -or -not (Test-Path $contentsDir)) {
[System.Windows.MessageBox]::Show("No modified ISO content found. Please complete Steps 1-3 first.", "Not Ready", "OK", "Warning")
Show-WinUtilMessage -Message "No modified ISO content found. Please run the modification step first." -Title "Not Ready" -Button "OK" -Icon "Warning" | Out-Null
return
}
$installWim = Join-Path $contentsDir "sources\install.wim"
$installEsd = Join-Path $contentsDir "sources\install.esd"
if (Test-Path $installEsd) {
$installEsdFile = Get-Item $installEsd
$esdSizeBytes = $installEsdFile.Length
$esdSizeMB = [math]::Ceiling($esdSizeBytes / 1MB)
$esdSizeBytes = (Get-Item $installEsd).Length
if ($esdSizeBytes -ge 4GB) {
[System.Windows.MessageBox]::Show(
"This ISO uses an install.esd file that is $esdSizeMB MB. WinUtil's FAT32 USB format cannot store files larger than 4 GB.`n`nExport an ISO instead or use media with install.wim.",
"USB Creation Not Supported", "OK", "Warning")
$esdSizeMB = [math]::Ceiling($esdSizeBytes / 1MB)
Show-WinUtilMessage -Message "This ISO uses an install.esd file that is $esdSizeMB MB. WinUtil's FAT32 USB format cannot store files larger than 4 GB.`n`nExport an ISO instead or use media with install.wim." -Title "USB Creation Not Supported" -Button "OK" -Icon "Warning" | Out-Null
return
}
}
@@ -58,95 +67,60 @@ function Invoke-WinUtilISOWriteUSB {
}
if (-not $targetDisk) {
[System.Windows.MessageBox]::Show("Please select a USB drive from the dropdown.", "No Drive Selected", "OK", "Warning")
Show-WinUtilMessage -Message "Please select a USB drive from the dropdown." -Title "No Drive Selected" -Button "OK" -Icon "Warning" | Out-Null
return
}
$diskNum = $targetDisk.Number
$sizeGB = [math]::Round($targetDisk.Size / 1GB, 1)
$confirm = [System.Windows.MessageBox]::Show(
"ALL data on Disk $diskNum ($($targetDisk.FriendlyName), $sizeGB GB) will be PERMANENTLY ERASED.`n`nAre you sure you want to continue?",
"Confirm USB Erase", "YesNo", "Warning")
$diskNum = $targetDisk.Number
$sizeGB = [math]::Round($targetDisk.Size / 1GB, 1)
$confirm = Show-WinUtilMessage -Message "ALL data on Disk $diskNum ($($targetDisk.FriendlyName), $sizeGB GB) will be PERMANENTLY ERASED.`n`nAre you sure you want to continue?" -Title "Confirm USB Erase" -Button "YesNo" -Icon "Warning"
if ($confirm -ne "Yes") {
Write-WinUtilISOLog "USB write cancelled by user."
return
}
$sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $false
$sync["Win11ISOProcessRunning"] = $true
Write-WinUtilISOLog "Starting USB write to Disk $diskNum..."
Start-WinUtilJob -Name "USB write" -Description "Writing USB drive" -Parameters @{
DiskNumber = $diskNum
ContentsDir = $contentsDir
} -ScriptBlock {
param($DiskNumber, $contentsDir)
$runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
$runspace.ApartmentState = "STA"
$runspace.ThreadOptions = "ReuseThread"
$runspace.Open()
$runspace.SessionStateProxy.SetVariable("sync", $sync)
$runspace.SessionStateProxy.SetVariable("diskNum", $diskNum)
$runspace.SessionStateProxy.SetVariable("contentsDir", $contentsDir)
$script = [Management.Automation.PowerShell]::Create()
$script.Runspace = $runspace
$script.AddScript({
function Log($msg) {
$ts = (Get-Date).ToString("HH:mm:ss")
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
$sync["WPFWin11ISOStatusLog"].Text += "`n[$ts] $msg"
$sync["WPFWin11ISOStatusLog"].CaretIndex = $sync["WPFWin11ISOStatusLog"].Text.Length
$sync["WPFWin11ISOStatusLog"].ScrollToEnd()
})
}
function SetProgress($label, $pct) {
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
$sync["WPFTweaksProgressBar"].Visibility = "Visible"
$sync["WPFTweaksProgressLabel"].Text = $label
$sync["WPFTweaksProgressLabel"].ToolTip = $label
$sync["WPFTweaksProgressValue"].Value = [Math]::Max($pct, 5)
})
}
function Get-FreeDriveLetter {
$used = (Get-PSDrive -PSProvider FileSystem).Name
foreach ($c in [char[]](68..90)) {
if ($used -notcontains [string]$c) { return $c }
}
return $null
}
Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $false }
Set-WinUtilISOStep -Step "Working" -Label "Writing the USB drive"
try {
SetProgress "Formatting USB drive..." 10
Write-WinUtilISOLog "Starting USB write to Disk $DiskNumber..."
Step-WinUtilJob -Status "Formatting USB drive..." -Percent 10
# Phase 1: Clean disk via diskpart (retry once if the drive is not yet ready)
$dpFile1 = Join-Path $env:TEMP "winutil_diskpart_$(Get-Random).txt"
"select disk $diskNum`nclean`nexit" | Set-Content -Path $dpFile1 -Encoding ASCII
Log "Running diskpart clean on Disk $diskNum..."
"select disk $DiskNumber`nclean`nexit" | Set-Content -Path $dpFile1 -Encoding ASCII
Write-WinUtilISOLog "Running diskpart clean on Disk $DiskNumber..."
$dpCleanOut = diskpart /s $dpFile1
$dpCleanOut | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" }
$dpCleanOut | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" }
Remove-Item $dpFile1 -Force
if (($dpCleanOut -join ' ') -match 'device is not ready') {
Log "Disk $diskNum was not ready; waiting 5 seconds and retrying clean..."
Write-WinUtilISOLog "Disk $DiskNumber was not ready; waiting 5 seconds and retrying clean..."
Start-Sleep -Seconds 5
Update-Disk -Number $diskNum
Update-Disk -Number $DiskNumber
$dpFile1b = Join-Path $env:TEMP "winutil_diskpart_$(Get-Random).txt"
"select disk $diskNum`nclean`nexit" | Set-Content -Path $dpFile1b -Encoding ASCII
diskpart /s $dpFile1b | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" }
"select disk $DiskNumber`nclean`nexit" | Set-Content -Path $dpFile1b -Encoding ASCII
diskpart /s $dpFile1b | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" }
Remove-Item $dpFile1b -Force
}
# Phase 2: Initialize as GPT
Start-Sleep -Seconds 2
Update-Disk -Number $diskNum
$diskObj = Get-Disk -Number $diskNum
Update-Disk -Number $DiskNumber
$diskObj = Get-Disk -Number $DiskNumber
if ($diskObj.PartitionStyle -eq 'RAW') {
Initialize-Disk -Number $diskNum -PartitionStyle GPT
Log "Disk $diskNum initialized as GPT."
Initialize-Disk -Number $DiskNumber -PartitionStyle GPT
Write-WinUtilISOLog "Disk $DiskNumber initialized as GPT."
} else {
Set-Disk -Number $diskNum -PartitionStyle GPT
Log "Disk $diskNum converted to GPT (was $($diskObj.PartitionStyle))."
Set-Disk -Number $DiskNumber -PartitionStyle GPT
Write-WinUtilISOLog "Disk $DiskNumber converted to GPT (was $($diskObj.PartitionStyle))."
}
# Phase 3: Create FAT32 partition via diskpart, then format with Format-Volume
@@ -154,64 +128,64 @@ function Invoke-WinUtilISOWriteUSB {
$volLabel = "W11-" + (Get-Date).ToString('yyMMdd')
$dpFile2 = Join-Path $env:TEMP "winutil_diskpart2_$(Get-Random).txt"
$maxFat32PartitionMB = 32768
$diskSizeMB = [int][Math]::Floor((Get-Disk -Number $diskNum).Size / 1MB)
$diskSizeMB = [int][Math]::Floor((Get-Disk -Number $DiskNumber).Size / 1MB)
$createPartitionCommand = "create partition primary"
if ($diskSizeMB -gt $maxFat32PartitionMB) {
$createPartitionCommand = "create partition primary size=$maxFat32PartitionMB"
Log "Disk $diskNum is $diskSizeMB MB; creating FAT32 partition capped at $maxFat32PartitionMB MB (32 GB)."
Write-WinUtilISOLog "Disk $DiskNumber is $diskSizeMB MB; creating FAT32 partition capped at $maxFat32PartitionMB MB (32 GB)."
}
@(
"select disk $diskNum"
"select disk $DiskNumber"
$createPartitionCommand
"exit"
) | Set-Content -Path $dpFile2 -Encoding ASCII
Log "Creating partitions on Disk $diskNum..."
diskpart /s $dpFile2 | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" }
Write-WinUtilISOLog "Creating partitions on Disk $DiskNumber..."
diskpart /s $dpFile2 | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" }
Remove-Item $dpFile2 -Force
SetProgress "Formatting USB partition..." 25
Step-WinUtilJob -Status "Formatting USB partition..." -Percent 25
Start-Sleep -Seconds 3
Update-Disk -Number $diskNum
Update-Disk -Number $DiskNumber
$partitions = Get-Partition -DiskNumber $diskNum
Log "Partitions on Disk $diskNum after creation: $($partitions.Count)"
$partitions = Get-Partition -DiskNumber $DiskNumber
Write-WinUtilISOLog "Partitions on Disk $DiskNumber after creation: $($partitions.Count)"
foreach ($p in $partitions) {
Log " Partition $($p.PartitionNumber) Type=$($p.Type) Letter=$($p.DriveLetter) Size=$([math]::Round($p.Size/1MB))MB"
Write-WinUtilISOLog " Partition $($p.PartitionNumber) Type=$($p.Type) Letter=$($p.DriveLetter) Size=$([math]::Round($p.Size/1MB))MB"
}
$winpePart = $partitions | Where-Object { $_.Type -eq "Basic" } | Select-Object -Last 1
if (-not $winpePart) {
throw "Could not find the Basic partition on Disk $diskNum after creation."
throw "Could not find the Basic partition on Disk $DiskNumber after creation."
}
# Format using Format-Volume (reliable on fresh drives; diskpart format fails
# with 'no volume selected' when the partition has never been formatted before)
Log "Formatting Partition $($winpePart.PartitionNumber) as FAT32 (label: $volLabel)..."
Get-Partition -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber |
Write-WinUtilISOLog "Formatting Partition $($winpePart.PartitionNumber) as FAT32 (label: $volLabel)..."
Get-Partition -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber |
Format-Volume -FileSystem FAT32 -NewFileSystemLabel $volLabel -Force -Confirm:$false
Log "Partition $($winpePart.PartitionNumber) formatted as FAT32."
Write-WinUtilISOLog "Partition $($winpePart.PartitionNumber) formatted as FAT32."
SetProgress "Assigning drive letters..." 30
Step-WinUtilJob -Status "Assigning drive letters..." -Percent 30
Start-Sleep -Seconds 2
Update-Disk -Number $diskNum
Update-Disk -Number $DiskNumber
try { Remove-PartitionAccessPath -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber -AccessPath "$($winpePart.DriveLetter):" } catch { Log "Warning: could not remove existing partition access path: $_" }
$usbLetter = Get-FreeDriveLetter
try { Remove-PartitionAccessPath -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber -AccessPath "$($winpePart.DriveLetter):" } catch { Write-WinUtilISOLog -Level "WARN" -Message "Could not remove existing partition access path: $_" }
$usbLetter = Get-WinUtilFreeDriveLetter
if (-not $usbLetter) { throw "No free drive letters (D-Z) available to assign to the USB data partition." }
Set-Partition -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber -NewDriveLetter $usbLetter
Log "Assigned drive letter $usbLetter to WINPE partition (Partition $($winpePart.PartitionNumber))."
Set-Partition -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber -NewDriveLetter $usbLetter
Write-WinUtilISOLog "Assigned drive letter $usbLetter to WINPE partition (Partition $($winpePart.PartitionNumber))."
Start-Sleep -Seconds 2
$usbDrive = "${usbLetter}:"
$retries = 0
while (-not (Test-Path $usbDrive) -and $retries -lt 6) {
$retries++
Log "Waiting for $usbDrive to become accessible (attempt $retries/6)..."
Write-WinUtilISOLog "Waiting for $usbDrive to become accessible (attempt $retries/6)..."
Start-Sleep -Seconds 2
}
if (-not (Test-Path $usbDrive)) { throw "Drive $usbDrive is not accessible after letter assignment." }
Log "USB data partition: $usbDrive"
Write-WinUtilISOLog "USB data partition: $usbDrive"
$contentSizeBytes = (Get-ChildItem -LiteralPath $contentsDir -File -Recurse -Force | Measure-Object -Property Length -Sum).Sum
if (-not $contentSizeBytes) { $contentSizeBytes = 0 }
@@ -223,7 +197,7 @@ function Invoke-WinUtilISOWriteUSB {
$partitionCapacityGB = [math]::Round($partitionCapacityBytes / 1GB, 2)
$partitionFreeGB = [math]::Round($partitionFreeBytes / 1GB, 2)
Log "Source content size: $contentSizeGB GB. USB partition capacity: $partitionCapacityGB GB, free: $partitionFreeGB GB."
Write-WinUtilISOLog "Source content size: $contentSizeGB GB. USB partition capacity: $partitionCapacityGB GB, free: $partitionFreeGB GB."
if ($contentSizeBytes -gt $partitionCapacityBytes) {
throw "ISO content ($contentSizeGB GB) is larger than the USB partition capacity ($partitionCapacityGB GB). Use a larger USB drive or reduce image size."
@@ -233,54 +207,56 @@ function Invoke-WinUtilISOWriteUSB {
throw "Insufficient free space on USB partition. Required: $contentSizeGB GB, available: $partitionFreeGB GB."
}
SetProgress "Copying Windows 11 files to USB..." 45
Step-WinUtilJob -Status "Copying Windows 11 files to USB..." -Percent 45
# Copy files; split install.wim if > 4 GB (FAT32 limit)
$installWim = Join-Path $contentsDir "sources\install.wim"
if (Test-Path $installWim) {
$wimSizeMB = [math]::Round((Get-Item $installWim).Length / 1MB)
if ($wimSizeMB -gt 3800) {
Log "install.wim is $wimSizeMB MB - splitting for FAT32 compatibility... This will take several minutes."
Write-WinUtilISOLog "install.wim is $wimSizeMB MB - splitting for FAT32 compatibility... This will take several minutes."
Set-ItemProperty -LiteralPath $installWim -Name IsReadOnly -Value $false
$splitDest = Join-Path $usbDrive "sources\install.swm"
New-Item -ItemType Directory -Path (Split-Path $splitDest) -Force
New-Item -ItemType Directory -Path (Split-Path $splitDest) -Force | Out-Null
Split-WindowsImage -ImagePath $installWim -SplitImagePath $splitDest -FileSize 3800 -CheckIntegrity
Log "install.wim split complete."
Log "Copying remaining files to USB..."
& robocopy $contentsDir $usbDrive /E /XF install.wim /NFL /NDL /NJH /NJS
Write-WinUtilISOLog "install.wim split complete."
Write-WinUtilISOLog "Copying remaining files to USB..."
Invoke-WinUtilRobocopy -Source $contentsDir -Destination $usbDrive -Arguments @("/E","/XF","install.wim","/NFL","/NDL","/NJH","/NJS")
} else {
& robocopy $contentsDir $usbDrive /E /NFL /NDL /NJH /NJS
Invoke-WinUtilRobocopy -Source $contentsDir -Destination $usbDrive -Arguments @("/E","/NFL","/NDL","/NJH","/NJS")
}
} else {
& robocopy $contentsDir $usbDrive /E /NFL /NDL /NJH /NJS
Invoke-WinUtilRobocopy -Source $contentsDir -Destination $usbDrive -Arguments @("/E","/NFL","/NDL","/NJH","/NJS")
}
SetProgress "Finalising USB drive..." 90
Log "Files copied to USB."
SetProgress "USB write complete" 100
Log "USB drive is ready for use."
Step-WinUtilJob -Status "Finalising USB drive..." -Percent 90
Write-WinUtilISOLog "Files copied to USB."
Step-WinUtilJob -Status "USB write complete" -Percent 100
Write-WinUtilISOLog "USB drive is ready for use."
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
[System.Windows.MessageBox]::Show(
"USB drive created successfully!`n`nYou can now boot from this drive to install Windows 11.",
"USB Ready", "OK", "Info")
})
Invoke-WPFUIThread -Parameters @{ DiskNumber = $DiskNumber } -ScriptBlock {
param($DiskNumber)
$sync["WPFWin11ISODoneLabel"].Text = "Disk $DiskNumber is ready to boot from."
$sync["WPFWin11ISODonePanel"].Visibility = "Visible"
}
Set-WinUtilISOStep -Step "Output"
Show-WinUtilMessage -Message "USB drive created successfully!`n`nYou can now boot from this drive to install Windows 11." -Title "USB Ready" -Button "OK" -Icon "Info" | Out-Null
} catch {
Log "ERROR during USB write: $_"
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
[System.Windows.MessageBox]::Show("USB write failed:`n`n$_", "USB Write Error", "OK", "Error")
})
Write-WinUtilISOLog -Level "ERROR" -Message "USB write failed: $_"
$_.Exception.Data["WinUtilErrorReported"] = $true
Set-WinUtilISOStep -Step "Output"
Show-WinUtilMessage -Message "USB write failed:`n`n$_" -Title "USB Write Error" -Button "OK" -Icon "Error" | Out-Null
throw
} finally {
Start-Sleep -Milliseconds 800
$sync["Win11ISOProcessRunning"] = $false
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
$sync["WPFTweaksProgressBar"].Visibility = "Collapsed"
$sync["WPFTweaksProgressLabel"].Text = ""
$sync["WPFTweaksProgressLabel"].ToolTip = ""
$sync["WPFTweaksProgressValue"].Value = 0
Invoke-WPFUIThread -ScriptBlock {
$sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $true
})
}
})
$script.BeginInvoke()
# Cancellation skips catch, so the working page can still be up here
if ($sync["WPFWin11ISOWorkingSection"].IsSelected) {
Set-WinUtilISOStep -Step "Output"
}
}
}
}
}
@@ -1,15 +1,68 @@
function Invoke-WinUtilInstallPSProfile {
if (-not (Get-Command wt)) {
Write-Host "Windows Terminal not found. Installing..."
Install-WinUtilWinget
winget install Microsoft.WindowsTerminal --source winget --silent
function Get-WinUtilPowerShell7Path {
$command = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue
if ($command) { return $command.Source }
foreach ($candidate in @(
"$env:ProgramFiles\PowerShell\7\pwsh.exe",
"$env:LOCALAPPDATA\Microsoft\WindowsApps\pwsh.exe")) {
if (Test-Path -LiteralPath $candidate) { return $candidate }
}
if (-not (Get-Command pwsh)) {
Write-Host "PowerShell 7 not found. Installing..."
return $null
}
function Invoke-WinUtilInstallPSProfile {
<#
.SYNOPSIS
Installs the CTT PowerShell profile
.DESCRIPTION
The profile targets PowerShell 7, so its setup script has to run under pwsh rather than
the runspace this job is on. It runs as a child process with its output captured, so the
job log records what happened instead of it scrolling past in a terminal nobody kept.
#>
$pwshPath = Get-WinUtilPowerShell7Path
if (-not $pwshPath) {
Step-WinUtilJob -Status "Installing PowerShell 7" -State "Indeterminate"
Write-WinUtilLog -Component "Feature" -Message "PowerShell 7 not found, installing it first."
Install-WinUtilWinget
winget install Microsoft.PowerShell --source winget --installer-type wix --silent
Install-WinUtilProgramWinget -Action Install -Programs @("Microsoft.PowerShell") | Out-Null
# WinGet updates the persisted PATH, not this already-running process. Resolve the
# standard install locations as well as the current PATH before deciding it failed.
$pwshPath = Get-WinUtilPowerShell7Path
if (-not $pwshPath) {
throw "PowerShell 7 could not be installed, so the profile cannot be set up."
}
}
Step-WinUtilJob -Status "Running the profile setup" -State "Indeterminate"
$setupUrl = "https://github.com/ChrisTitusTech/powershell-profile/raw/main/setup.ps1"
# Stop in the child, so a setup failure is a nonzero exit rather than a logged error and a
# exit code of zero
$output = & $pwshPath -NoProfile -NonInteractive -Command "`$ErrorActionPreference = 'Stop'; irm '$setupUrl' | iex" 2>&1
$exitCode = $LASTEXITCODE
$failures = 0
foreach ($line in @($output)) {
if ($line -is [System.Management.Automation.ErrorRecord]) {
$failures++
Write-WinUtilErrorRecord -ErrorRecord $line -Component "Feature" -Context "PowerShell profile setup"
} elseif (-not [string]::IsNullOrWhiteSpace($line)) {
Write-WinUtilLog -Component "Feature" -Message ([string]$line).Trim()
}
}
if ($exitCode -ne 0) {
throw "The profile setup script exited with code $exitCode."
}
if ($failures -gt 0) {
throw "The profile setup script reported $failures error(s); see the log."
}
wt new-tab pwsh -NoExit -Command "irm https://github.com/ChrisTitusTech/powershell-profile/raw/main/setup.ps1 | iex"
Write-WinUtilLog -Component "Feature" -Message "CTT PowerShell profile installed. Open a new PowerShell 7 session to use it."
}
+53 -19
View File
@@ -20,41 +20,75 @@ function Invoke-WinUtilSSHServer {
#Adding Firewall rule for port 22
Write-Host "Setting up firewall rules"
if (-not ((Get-NetFirewallRule -Name 'sshd').Enabled)) {
$firewallRule = Get-NetFirewallRule -Name 'sshd' -ErrorAction SilentlyContinue
if ($null -eq $firewallRule) {
New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22
Write-Host "Firewall rule for OpenSSH Server created and enabled."
} elseif ([int]$firewallRule.Enabled -eq 2) {
Set-NetFirewallRule -Name 'sshd' -Enabled True
Write-Host "Firewall rule for OpenSSH Server enabled."
}
# Check for the authorized_keys file
$sshFolderPath = "$Home\.ssh"
$authorizedKeysPath = "$sshFolderPath\authorized_keys"
# An SSH logon for a member of the administrators group gets a full token
# with no UAC prompt, so sshd reads administrator keys from a machine-wide
# file that only Administrators and SYSTEM may write. WinUtil always runs
# elevated, so the account being set up here is always an administrator.
$sshProgramDataPath = Join-Path $env:ProgramData "ssh"
$sshdConfigPath = Join-Path $sshProgramDataPath "sshd_config"
$authorizedKeysPath = Join-Path $sshProgramDataPath "administrators_authorized_keys"
$profileKeysPath = Join-Path $env:USERPROFILE ".ssh\authorized_keys"
if (-not (Test-Path -Path $sshFolderPath)) {
Write-Host "Creating ssh directory..."
New-Item -Path $sshFolderPath -ItemType Directory -Force
if (-not (Test-Path -Path $sshProgramDataPath)) {
New-Item -Path $sshProgramDataPath -ItemType Directory -Force | Out-Null
}
# Earlier WinUtil versions commented out the administrators block in
# sshd_config. Detect that state before restoring it, so administrator keys
# already in use are carried over instead of silently stopping working.
$configContent = if (Test-Path -Path $sshdConfigPath) { [string](Get-Content -Path $sshdConfigPath -Raw) } else { "" }
$restoredContent = $configContent -replace '(?m)^# (Match Group administrators)$', '$1'
$restoredContent = $restoredContent -replace '(?m)^# (\s+AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys)$', '$1'
$configWasOverridden = $restoredContent -ne $configContent
if (-not (Test-Path -Path $authorizedKeysPath)) {
Write-Host "Creating authorized_keys file..."
New-Item -Path $authorizedKeysPath -ItemType File -Force
Write-Host "authorized_keys file created at $authorizedKeysPath."
Write-Host "Creating administrators_authorized_keys file..."
New-Item -Path $authorizedKeysPath -ItemType File -Force | Out-Null
Write-Host "administrators_authorized_keys file created at $authorizedKeysPath."
}
Write-Host "Configuring sshd_config for standard authorized_keys behavior..."
$sshdConfigPath = "C:\ProgramData\ssh\sshd_config"
if ($configWasOverridden -and (Test-Path -Path $profileKeysPath)) {
$currentKeys = @(Get-Content -Path $authorizedKeysPath)
$keysToMove = @(Get-Content -Path $profileKeysPath | Where-Object {
$_.Trim() -and -not $_.TrimStart().StartsWith("#") -and $currentKeys -notcontains $_
})
$configContent = Get-Content -Path $sshdConfigPath -Raw
if ($keysToMove.Count -gt 0) {
Add-Content -Path $authorizedKeysPath -Value $keysToMove
Write-Host "Moved $($keysToMove.Count) key(s) from $profileKeysPath to $authorizedKeysPath."
}
}
$updatedContent = $configContent -replace '(?m)^(Match Group administrators)$', '# $1'
$updatedContent = $updatedContent -replace '(?m)^(\s+AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys)$', '# $1'
# sshd ignores the file unless inheritance is off and access is limited to
# Administrators (S-1-5-32-544) and SYSTEM (S-1-5-18). SIDs keep this
# working on localized installs, where the group names differ.
$acl = Get-Acl -Path $authorizedKeysPath
$acl.SetAccessRuleProtection($true, $false)
foreach ($rule in @($acl.Access)) {
[void]$acl.RemoveAccessRule($rule)
}
foreach ($sid in @("S-1-5-32-544", "S-1-5-18")) {
[void]$acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
[System.Security.Principal.SecurityIdentifier]::new($sid), "FullControl", "Allow"))
}
Set-Acl -Path $authorizedKeysPath -AclObject $acl
if ($updatedContent -ne $configContent) {
Set-Content -Path $sshdConfigPath -Value $updatedContent -Force
Write-Host "Commented out administrator-specific SSH key configuration in sshd_config"
if ($configWasOverridden) {
Set-Content -Path $sshdConfigPath -Value $restoredContent -Force
Write-Host "Restored the administrator key file setting in sshd_config."
Restart-Service -Name sshd -Force
}
Write-Host "OpenSSH server was successfully enabled."
Write-Host "The config file can be located at C:\ProgramData\ssh\sshd_config"
Write-Host "The config file can be located at $sshdConfigPath"
Write-Host "Add your public keys to this file -> $authorizedKeysPath"
}
+1 -1
View File
@@ -62,7 +62,7 @@ function Invoke-WinUtilTweaks {
}
}
if ($sync.configs.tweaks.$CheckBox.registry) {
$sync.configs.tweaks.$CheckBox.registry | ForEach-Object {
$sync.configs.tweaks.$CheckBox.registry | Where-Object { -not $psitem.Values } | ForEach-Object {
Set-WinUtilRegistry -Name $psitem.Name -Path $psitem.Path -Type $psitem.Type -Value $psitem.$($values.registry)
}
}
@@ -1,10 +1,36 @@
function Invoke-WinUtilUninstallPSProfile {
<#
.SYNOPSIS
Restores the PowerShell 7 profile the CTT profile replaced
if (Test-Path ($Profile + ".bak")) {
Move-Item -Path ($Profile + ".bak") -Destination $Profile
} else {
Remove-Item -Path $Profile
.DESCRIPTION
The profile path has to come from pwsh itself. $PROFILE inside this job is the worker's
own Windows PowerShell profile, which is not the file the install wrote.
#>
$pwshPath = Get-WinUtilPowerShell7Path
if (-not $pwshPath) {
throw "PowerShell 7 is not installed, so there is no CTT profile to remove."
}
Write-Host "Successfully uninstalled CTT PowerShell Profile." -ForegroundColor Green
$profilePath = (& $pwshPath -NoProfile -NonInteractive -Command '$PROFILE' | Select-Object -First 1)
if ([string]::IsNullOrWhiteSpace($profilePath)) {
throw "Could not determine the PowerShell 7 profile path."
}
$profilePath = $profilePath.Trim()
$backupPath = "$profilePath.bak"
if (Test-Path $backupPath) {
Move-Item -Path $backupPath -Destination $profilePath -Force
Write-WinUtilLog -Component "Feature" -Message "Restored the profile that was in place before: $profilePath"
return
}
if (Test-Path $profilePath) {
Remove-Item -Path $profilePath -Force
Write-WinUtilLog -Component "Feature" -Message "Removed the CTT PowerShell profile: $profilePath"
return
}
Write-WinUtilLog -Level "WARN" -Component "Feature" -Message "No PowerShell 7 profile found at $profilePath, nothing to remove."
}
+105
View File
@@ -0,0 +1,105 @@
function Measure-WinUtilStep {
<#
.SYNOPSIS
Times one step of a pipeline and records it for the timing summary
.DESCRIPTION
Output passes through untouched, so this can wrap an existing expression without
changing what the caller receives. Each step is logged as a "timing:" line and kept
in $sync.StepTimings for the summary to rank.
.PARAMETER Name
What the step is, as it should read in the log.
.PARAMETER ScriptBlock
The work to time.
.PARAMETER Scope
Groups steps that belong to the same run, normally a job name or "UI".
#>
param(
[Parameter(Mandatory, Position = 0)]
[string]$Name,
[Parameter(Mandatory, Position = 1)]
[scriptblock]$ScriptBlock,
[string]$Scope = "WinUtil"
)
$isUIDiagnostic = $Scope -in @("UI", "Tab")
$captureTiming = -not $isUIDiagnostic -or $sync.IsLocalCompile
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
try {
& $ScriptBlock
} finally {
$stopwatch.Stop()
if ($captureTiming -and $null -ne $sync.StepTimings) {
$null = $sync.StepTimings.Add([pscustomobject]@{
Scope = $Scope
Step = $Name
Milliseconds = $stopwatch.ElapsedMilliseconds
})
}
if ($captureTiming) {
$level = if ($isUIDiagnostic) { "DEBUG" } else { "INFO" }
Write-WinUtilLog -Level $level -Component $Scope -Message "timing: $Name took $($stopwatch.ElapsedMilliseconds) ms"
}
}
}
function Write-WinUtilTimingSummary {
<#
.SYNOPSIS
Logs the slowest steps of a scope, so the log answers "what took so long"
.PARAMETER Scope
Which group of steps to report on.
.PARAMETER Top
How many of the slowest steps to list.
.PARAMETER TotalMilliseconds
Wall clock total. Without it the summary sums the steps, missing whatever happened
between them.
#>
param(
[Parameter(Mandatory)]
[string]$Scope,
[int]$Top = 5,
[long]$TotalMilliseconds = -1,
[int]$StartIndex = 0
)
$isUIDiagnostic = $Scope -in @("UI", "Tab")
if (($isUIDiagnostic -and -not $sync.IsLocalCompile) -or $null -eq $sync.StepTimings) {
return
}
[System.Threading.Monitor]::Enter($sync.StepTimings.SyncRoot)
try {
$timingSnapshot = @($sync.StepTimings.ToArray())
} finally {
[System.Threading.Monitor]::Exit($sync.StepTimings.SyncRoot)
}
$steps = @($timingSnapshot | Select-Object -Skip $StartIndex | Where-Object { $_.Scope -eq $Scope })
if ($steps.Count -eq 0) {
return
}
$measured = ($steps | Measure-Object -Property Milliseconds -Sum).Sum
$total = if ($TotalMilliseconds -ge 0) { $TotalMilliseconds } else { $measured }
$level = if ($isUIDiagnostic) { "DEBUG" } else { "INFO" }
Write-WinUtilLog -Level $level -Component $Scope -Message "timing summary: $($steps.Count) step(s), $measured ms measured of $total ms total"
foreach ($step in ($steps | Sort-Object Milliseconds -Descending | Select-Object -First $Top)) {
$share = if ($total -gt 0) { [int](($step.Milliseconds / $total) * 100) } else { 0 }
Write-WinUtilLog -Level $level -Component $Scope -Message "timing summary: $($step.Milliseconds) ms ($share%) $($step.Step)"
}
}
@@ -0,0 +1,53 @@
function New-WinUtilFossBadge {
<#
.SYNOPSIS
Creates the FOSS marker: the open source keyhole on a green backdrop
.DESCRIPTION
Returns a fresh element on every call, because a WPF element can only have one parent.
The artwork is authored in a 22x22 box and scaled by the Viewbox, so callers only pick a size.
.PARAMETER Size
Edge length of the badge in pixels
.PARAMETER Round
Use a full circle instead of the corner triangle, for the legend rather than an app entry
#>
param(
[double]$Size = 24,
[switch]$Round
)
$artwork = New-Object Windows.Controls.Grid
$artwork.Width = 22
$artwork.Height = 22
$backdrop = New-Object Windows.Shapes.Path
$backdrop.Fill = [Windows.Media.SolidColorBrush]::new([Windows.Media.Color]::FromRgb(19, 143, 83))
$keyhole = New-Object Windows.Shapes.Path
$keyhole.Stroke = [Windows.Media.SolidColorBrush]::new([Windows.Media.Color]::FromRgb(247, 247, 247))
if ($Round) {
$backdrop.Data = [Windows.Media.EllipseGeometry]::new([Windows.Point]::new(11, 11), 11, 11)
# Keyhole centred in the circle, which has room for a larger ring than the triangle does
$keyhole.Data = [Windows.Media.Geometry]::Parse("M 7.673,15.751 A 5.8,5.8 0 1 1 14.327,15.751")
$keyhole.StrokeThickness = 3.4
} else {
# Triangle filling the top right corner, its outer corner rounded to match AppEntryBorderStyle
$backdrop.Data = [Windows.Media.Geometry]::Parse("M 0,0 L 17,0 A 5,5 0 0 1 22,5 L 22,22 Z")
# Keyhole centred on the triangle's incentre (15.56, 6.44) so it keeps the same
# 1.8 clearance from all three edges
$keyhole.Data = [Windows.Media.Geometry]::Parse("M 13.61,9.225 A 3.4,3.4 0 1 1 17.51,9.225")
$keyhole.StrokeThickness = 2.4
}
$keyhole.StrokeStartLineCap = [Windows.Media.PenLineCap]::Round
$keyhole.StrokeEndLineCap = [Windows.Media.PenLineCap]::Round
[void]$artwork.Children.Add($backdrop)
[void]$artwork.Children.Add($keyhole)
$badge = New-Object Windows.Controls.Viewbox
$badge.Width = $Size
$badge.Height = $Size
$badge.Child = $artwork
$badge.ToolTip = "Free and Open Source Software"
return $badge
}
@@ -0,0 +1,55 @@
function New-WinUtilSessionState {
<#
.SYNOPSIS
Builds the InitialSessionState every WinUtil runspace is created from
.DESCRIPTION
The interface runspace and the worker pool start from the same state: the shared
$sync hashtable, the compiled script's globals, and every WinUtil function. That is
what lets the interface build a tab and a job body call any helper without injecting
definitions by hand. PowerShell's own functions are skipped, the default session
state already carries them.
Cached: an InitialSessionState is a template any number of runspaces are created
from, and building it is not free.
#>
if ($sync.SessionState) {
return $sync.SessionState
}
$initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
$variables = @(
@{ Name = "sync"; Value = $sync },
@{ Name = "PARAM_OFFLINE"; Value = $PARAM_OFFLINE },
@{ Name = "inputXML"; Value = $inputXML },
@{ Name = "WinUtilAutounattendXml"; Value = $WinUtilAutounattendXml }
)
foreach ($variable in $variables) {
$initialSessionState.Variables.Add(
(New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList $variable.Name, $variable.Value, $null)
)
}
$builtInFunctions = [System.Collections.Generic.HashSet[string]]::new(
[string[]]@($initialSessionState.Commands |
Where-Object { $_ -is [System.Management.Automation.Runspaces.SessionStateFunctionEntry] } |
ForEach-Object { $_.Name }),
[StringComparer]::OrdinalIgnoreCase
)
foreach ($function in (Get-ChildItem function:\)) {
if ($builtInFunctions.Contains($function.Name)) {
continue
}
$initialSessionState.Commands.Add(
(New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function.Name, $function.Definition)
)
}
$sync.SessionState = $initialSessionState
return $initialSessionState
}
@@ -0,0 +1,131 @@
function Register-WinUtilRunspaceCleanup {
<#
.SYNOPSIS
Disposes a PowerShell instance and any owned runspace once its work has finished
.DESCRIPTION
Ends the invocation and disposes the instance from a thread pool callback, so
nothing has to wait for a fire-and-forget runspace to complete just to clean it up.
.PARAMETER PowerShell
The instance to dispose.
.PARAMETER Handle
The handle returned by its BeginInvoke.
.PARAMETER Runspace
A dedicated runspace owned by the invocation. Shared pool invocations omit it.
#>
param(
[Parameter(Mandatory)]
$PowerShell,
[Parameter(Mandatory)]
$Handle,
$Runspace
)
# Version the CLR helper because Add-Type definitions survive repeated in-memory WinUtil runs.
# Older sessions can already contain the V1 type, whose state object has no Runspace property.
if (-not ("WinUtilRunspaceCleanupV3" -as [type])) {
Add-Type @"
using System;
using System.Management.Automation;
using System.Management.Automation.Runspaces;
using System.Threading;
public sealed class WinUtilRunspaceCleanupStateV3
{
public PowerShell PowerShell { get; set; }
public IAsyncResult Handle { get; set; }
public Runspace Runspace { get; set; }
}
public static class WinUtilRunspaceCleanupV3
{
public static readonly System.Threading.WaitOrTimerCallback Callback = Cleanup;
public static bool Register(WinUtilRunspaceCleanupStateV3 state)
{
try
{
ThreadPool.RegisterWaitForSingleObject(state.Handle.AsyncWaitHandle, Callback, state, -1, true);
return true;
}
catch
{
// Registration is normally infallible, but work has already started. A background
// waiter preserves asynchronous cleanup without blocking the WPF dispatcher.
try
{
var thread = new Thread(() =>
{
try
{
state.Handle.AsyncWaitHandle.WaitOne();
}
catch
{
}
Cleanup(state, false);
});
thread.IsBackground = true;
thread.Name = "WinUtil runspace cleanup fallback";
thread.Start();
return true;
}
catch
{
return false;
}
}
}
public static void Cleanup(object state, bool timedOut)
{
var cleanupState = state as WinUtilRunspaceCleanupStateV3;
if (cleanupState == null || cleanupState.PowerShell == null || cleanupState.Handle == null)
{
return;
}
try
{
cleanupState.PowerShell.EndInvoke(cleanupState.Handle);
}
catch
{
}
finally
{
cleanupState.PowerShell.Dispose();
if (cleanupState.Runspace != null)
{
try
{
cleanupState.Runspace.Close();
}
catch
{
}
finally
{
cleanupState.Runspace.Dispose();
}
}
}
}
}
"@
}
$cleanupState = [WinUtilRunspaceCleanupStateV3]::new()
$cleanupState.PowerShell = $PowerShell
$cleanupState.Handle = $Handle
$cleanupState.Runspace = $Runspace
$registered = [WinUtilRunspaceCleanupV3]::Register($cleanupState)
if (-not $registered) {
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Could not register asynchronous cleanup for background work; it will be reclaimed when later work or shutdown inspects it."
}
}
@@ -55,7 +55,9 @@ function Remove-WinUtilProvisionedAPPX {
$failureDetails = ($removalOutput | Out-String).Trim()
$errorMessage = "AppX provisioned package removal failed: $failureDetails"
Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message $errorMessage
throw $errorMessage
$exception = [System.InvalidOperationException]::new($errorMessage)
$exception.Data["WinUtilErrorReported"] = $true
throw $exception
}
Write-WinUtilLog -Component "AppX" -Message "AppX provisioned package removal completed."
+26 -9
View File
@@ -2,7 +2,7 @@ function Reset-WPFCheckBoxes {
<#
.SYNOPSIS
Set winutil checkboxs to match $sync.selected values.
Set WinUtil checkboxes to match $sync.selected values.
Should only need to be run if $sync.selected updated outside of UI (i.e. presets or import)
.PARAMETER doToggles
@@ -22,19 +22,30 @@ function Reset-WPFCheckBoxes {
)
$selectedSet = [System.Collections.Generic.HashSet[string]]::new([string[]]@($sync.selectedApps + $sync.selectedTweaks + $sync.selectedFeatures + $sync.selectedAppx), [StringComparer]::OrdinalIgnoreCase)
foreach ($syncEntry in $sync.GetEnumerator()) {
# A synchronized Hashtable protects individual operations, not enumeration. Materialize the
# snapshot under its lock, then release it before handlers run and mutate $sync.
[System.Threading.Monitor]::Enter($sync.SyncRoot)
try {
$syncEntries = @($sync.GetEnumerator())
} finally {
[System.Threading.Monitor]::Exit($sync.SyncRoot)
}
foreach ($syncEntry in $syncEntries) {
if ($syncEntry.Value -is [System.Windows.Controls.CheckBox] -and $syncEntry.Name -notlike "WPFToggle*" -and $syncEntry.Name -like $checkboxfilterpattern) {
$checkboxName = $syncEntry.Key
$sync.$checkboxName.IsChecked = $selectedSet.Contains($checkboxName)
}
}
# Update Installs tab UI values
$count = $sync.SelectedApps.Count
$sync.WPFselectedAppsButton.Content = "Selected Apps: $count"
# On every change, remove all entries inside the Popup Menu. This is done, so we can keep the alphabetical order even if elements are selected in a random way
$sync.selectedAppsstackPanel.Children.Clear()
$sync.selectedApps | Foreach-Object { Add-SelectedAppsMenuItem -name $($sync.configs.applicationsHashtable.$_.Content) -key $_ }
# Update Installs tab UI values. These are built with the Install tab, and this runs for
# whichever tab is built first: offline starts on Tweaks, so they are not there yet.
if ($sync.selectedAppsstackPanel) {
$count = $sync.SelectedApps.Count
$sync.WPFselectedAppsButton.Content = "Selected Apps: $count"
# On every change, remove all entries inside the Popup Menu. This is done, so we can keep the alphabetical order even if elements are selected in a random way
$sync.selectedAppsstackPanel.Children.Clear()
$sync.selectedApps | Foreach-Object { Add-SelectedAppsMenuItem -name $($sync.configs.applicationsHashtable.$_.Content) -key $_ }
}
if($doToggles) {
# Restore toggle switch states from imported config.
@@ -42,7 +53,13 @@ function Reset-WPFCheckBoxes {
# from the export file were not part of the saved config and should keep whatever
# state the live system already has (set during UI initialisation via Get-WinUtilToggleStatus).
$importedToggles = [System.Collections.Generic.HashSet[string]]::new([string[]]@($sync.selectedToggles), [StringComparer]::OrdinalIgnoreCase)
foreach ($toggle in $sync.GetEnumerator()) {
[System.Threading.Monitor]::Enter($sync.SyncRoot)
try {
$toggleEntries = @($sync.GetEnumerator())
} finally {
[System.Threading.Monitor]::Exit($sync.SyncRoot)
}
foreach ($toggle in $toggleEntries) {
if ($toggle.Key -like "WPFToggle*" -and $toggle.Value -is [System.Windows.Controls.CheckBox] -and $importedToggles.Contains($toggle.Key)) {
$sync[$toggle.Key].IsChecked = $true
}
@@ -1,17 +1,50 @@
function Set-WinUtilAppCategoryFilter {
<#
.SYNOPSIS
Applies an exact application category filter from an Install tab search chip.
Applies the Install tab category filter and syncs the chip states to it
.DESCRIPTION
The selection lives in $sync.SelectedAppCategories. An empty selection means every
category is shown, which is what the All chip represents. The category filter and the
search box are independent: this only touches categories, and the current search text
is reapplied on top.
.PARAMETER Category
The application category to show. An empty value clears the filter.
The category to act on. An empty value clears the filter back to All.
.PARAMETER Additive
Toggles this category in or out of the current selection instead of replacing it.
Bound to ctrl click.
#>
param(
[Parameter(Mandatory = $false)]
[string]$Category = ""
[string]$Category = "",
[Parameter(Mandatory = $false)]
[switch]$Additive
)
$sync.SearchBar.Tag = $Category
$sync.SearchBar.Text = $Category
Find-AppsByNameOrDescription -SearchString $Category -Category $Category
if ($null -eq $sync.SelectedAppCategories) {
$sync.SelectedAppCategories = [System.Collections.Generic.List[string]]::new()
}
$selected = $sync.SelectedAppCategories
if ([string]::IsNullOrWhiteSpace($Category)) {
$selected.Clear()
} elseif ($Additive) {
if ($selected.Contains($Category)) {
[void]$selected.Remove($Category)
} else {
$selected.Add($Category)
}
} elseif ($selected.Count -eq 1 -and $selected.Contains($Category)) {
# Clicking the only active category again clears the filter
$selected.Clear()
} else {
$selected.Clear()
$selected.Add($Category)
}
Update-WinUtilAppCategoryChip
Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Categories $selected.ToArray()
}
+61 -21
View File
@@ -15,7 +15,22 @@ function Set-WinUtilDNS {
if($DNSProvider -eq "Default") {
Write-WinUtilLog -Component "DNS" -Message "DNS provider is Default; no DNS changes applied."
return
return $true
}
if($DNSProvider -eq "Fastest") {
Write-WinUtilLog -Component "DNS" -Message "Auto-detecting fastest DNS provider via latency benchmark..."
$benchmark = Get-WinUtilDNSBenchmark
$validFastest = $benchmark | Where-Object { $_.LatencyMs -lt 9999 } | Select-Object -First 1
if ($validFastest) {
$DNSProvider = $validFastest.Provider
Write-Host "Auto-selected fastest DNS provider: $DNSProvider ($($validFastest.LatencyMs) ms)"
Write-WinUtilLog -Component "DNS" -Message "Auto-selected fastest DNS provider: $DNSProvider ($($validFastest.LatencyMs) ms)"
} else {
Write-Warning "Could not measure DNS latency to any provider; keeping current network adapter DNS settings."
Write-WinUtilLog -Component "DNS" -Message "Benchmark timeout or all probes failed; aborting DNS change to preserve existing settings."
return $false
}
}
try {
@@ -29,11 +44,17 @@ function Set-WinUtilDNS {
if($null -eq $dns) {
Write-Warning "DNS provider $DNSProvider was not found in configuration."
Write-WinUtilLog -Level "ERROR" -Component "DNS" -Message "DNS provider $DNSProvider was not found in configuration."
return
return $false
}
}
$dohSupported = [bool](Get-Command Add-DnsClientDohServerAddress -ErrorAction SilentlyContinue)
if ($DNSProvider -ne "DHCP" -and $dns.DohOnly -and -not $dohSupported) {
Write-Warning "DNS provider $DNSProvider requires DNS over HTTPS, which is not supported on this system."
Write-WinUtilLog -Level "ERROR" -Component "DNS" -Message "DNS provider $DNSProvider requires DNS over HTTPS, which is not supported on this system."
return $false
}
$dnscacheBase = "HKLM:\System\CurrentControlSet\Services\Dnscache\InterfaceSpecificParameters"
Foreach ($Adapter in $Adapters) {
@@ -64,40 +85,59 @@ function Set-WinUtilDNS {
Remove-Item -Path $dohInterfaceSettings -Recurse -Force -ErrorAction SilentlyContinue
}
} else {
Write-WinUtilLog -Component "DNS" -Message "Setting IPv4 DNS on adapter $($Adapter.Name) (ifIndex: $($Adapter.ifIndex)) to $($dns.Primary), $($dns.Secondary)."
Set-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -ServerAddresses ($dns.Primary, $dns.Secondary)
Write-WinUtilLog -Component "DNS" -Message "Setting IPv6 DNS on adapter $($Adapter.Name) (ifIndex: $($Adapter.ifIndex)) to $($dns.Primary6), $($dns.Secondary6)."
Set-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -ServerAddresses ($dns.Primary6, $dns.Secondary6)
$ipv4Addresses = @(@($dns.Primary, $dns.Secondary) | Where-Object { $_ })
$ipv6Addresses = @(@($dns.Primary6, $dns.Secondary6) | Where-Object { $_ })
if ($dohSupported -and $dns.DohTemplate) {
$ips = @($dns.Primary, $dns.Secondary, $dns.Primary6, $dns.Secondary6) | Where-Object { $_ }
foreach ($ip in $ips) {
$existing = Get-DnsClientDohServerAddress -ServerAddress $ip -ErrorAction SilentlyContinue
if ($existing) {
Set-DnsClientDohServerAddress -ServerAddress $ip -DohTemplate $dns.DohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true -ErrorAction Stop
} else {
Write-WinUtilLog -Component "DNS" -Message "Registering DoH template for $ip."
Add-DnsClientDohServerAddress -ServerAddress $ip -DohTemplate $dns.DohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true -ErrorAction Stop
try {
$ips = @($dns.Primary, $dns.Secondary, $dns.Primary6, $dns.Secondary6) | Where-Object { $_ }
foreach ($ip in $ips) {
$dohTemplate = if ($dns.SecondaryDohTemplate -and @($dns.Secondary, $dns.Secondary6) -contains $ip) {
$dns.SecondaryDohTemplate
} else {
$dns.DohTemplate
}
$existing = Get-DnsClientDohServerAddress -ServerAddress $ip -ErrorAction SilentlyContinue
if ($existing) {
Set-DnsClientDohServerAddress -ServerAddress $ip -DohTemplate $dohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true -ErrorAction Stop
} else {
Write-WinUtilLog -Component "DNS" -Message "Registering DoH template for $ip."
Add-DnsClientDohServerAddress -ServerAddress $ip -DohTemplate $dohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true -ErrorAction Stop
}
$leaf = if ($ip.Contains(':')) { 'Doh6' } else { 'Doh' }
$regPath = "$interfaceParams\DohInterfaceSettings\$leaf\$ip"
if (-not (Test-Path $regPath)) {
New-Item -Path $regPath -Force -ErrorAction Stop | Out-Null
}
New-ItemProperty -Path $regPath -Name "DohFlags" -Value 1 -PropertyType QWord -Force -ErrorAction Stop | Out-Null
}
$leaf = if ($ip.Contains(':')) { 'Doh6' } else { 'Doh' }
$regPath = "$interfaceParams\DohInterfaceSettings\$leaf\$ip"
if (-not (Test-Path $regPath)) {
New-Item -Path $regPath -Force -ErrorAction Stop | Out-Null
} catch {
if ($dns.DohOnly) {
throw
}
New-ItemProperty -Path $regPath -Name "DohFlags" -Value 1 -PropertyType QWord -Force -ErrorAction Stop | Out-Null
Write-Warning "DNS over HTTPS setup for provider $DNSProvider failed; continuing with plain DNS."
Write-WinUtilLog -Level "WARN" -Component "DNS" -Message "DNS over HTTPS setup for provider $DNSProvider failed; continuing with plain DNS: $($psitem.Exception.Message)"
}
}
Write-WinUtilLog -Component "DNS" -Message "Setting IPv4 DNS on adapter $($Adapter.Name) (ifIndex: $($Adapter.ifIndex)) to $($dns.Primary), $($dns.Secondary)."
Set-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -ServerAddresses $ipv4Addresses -ErrorAction Stop
Write-WinUtilLog -Component "DNS" -Message "Setting IPv6 DNS on adapter $($Adapter.Name) (ifIndex: $($Adapter.ifIndex)) to $($dns.Primary6), $($dns.Secondary6)."
Set-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -ServerAddresses $ipv6Addresses -ErrorAction Stop
}
}
if ($DNSProvider -ne "DHCP" -and $dohSupported -and $dns.DohTemplate) {
Clear-DnsClientCache
}
Write-WinUtilLog -Component "DNS" -Message "DNS provider change completed: $DNSProvider"
return $true
} catch {
Write-Warning "DNS provider $DNSProvider was not completed because an error occurred."
Write-Warning $psitem.Exception.Message
Write-WinUtilLog -Level "ERROR" -Component "DNS" -Message "DNS provider $DNSProvider was not completed: $($psitem.Exception.Message)"
return $false
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ function Set-WinUtilRegistry {
)
try {
if(!(Test-Path 'HKU:\')) {New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS}
if(!(Test-Path 'HKU:\')) {New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS | Out-Null}
If (!(Test-Path $Path)) {
Write-Host "$Path was not found. Creating..."
@@ -0,0 +1,78 @@
function Set-WinUtilRegistryComboState {
<#
.SYNOPSIS
Applies and verifies a config-defined registry combo-box state.
.PARAMETER Registry
Registry settings containing a value mapping for each supported state.
.PARAMETER State
The state name to apply.
#>
param(
[Parameter(Mandatory)]
$Registry,
[Parameter(Mandatory)]
[string]$State
)
if ($Registry[0].Values.PSObject.Properties.Name -notcontains $State) {
throw "Unknown registry state '$State'."
}
# Preserve exact prior values so a partial update can be rolled back.
$previousValues = foreach ($setting in @($Registry)) {
$currentValue = Get-WinUtilRegistryComboValue -Setting $setting
[pscustomobject]@{ Setting = $setting; Exists = $currentValue.Exists; Value = $currentValue.Value }
}
try {
foreach ($setting in @($Registry)) {
$configuredValue = $setting.Values.PSObject.Properties[$State].Value
$previousValue = $previousValues | Where-Object Setting -EQ $setting
if ($configuredValue -ne "<RemoveEntry>" -or $previousValue.Exists) {
Set-WinUtilRegistry -Name $setting.Name -Path $setting.Path -Type $setting.Type -Value $configuredValue
}
}
# Set-WinUtilRegistry reports write errors without throwing, so verify each result explicitly.
foreach ($setting in @($Registry)) {
$configuredValue = $setting.Values.PSObject.Properties[$State].Value
$currentValue = Get-WinUtilRegistryComboValue -Setting $setting
$writeMatches = if ($configuredValue -eq "<RemoveEntry>") {
-not $currentValue.Exists
} else {
$currentValue.Exists -and [string]$currentValue.Value -eq [string]$configuredValue
}
if (-not $writeMatches) {
throw "The registry values did not match the requested state."
}
}
} catch {
$applyError = $_.Exception.Message
if ([string]::IsNullOrWhiteSpace($applyError)) {
$applyError = "The registry values did not match the requested state."
}
$rollbackFailed = $false
foreach ($previousValue in $previousValues) {
try {
$currentValue = Get-WinUtilRegistryComboValue -Setting $previousValue.Setting
if ($previousValue.Exists -or $currentValue.Exists) {
$rollbackValue = if ($previousValue.Exists) { $previousValue.Value } else { "<RemoveEntry>" }
Set-WinUtilRegistry -Name $previousValue.Setting.Name -Path $previousValue.Setting.Path -Type $previousValue.Setting.Type -Value $rollbackValue
}
$restoredValue = Get-WinUtilRegistryComboValue -Setting $previousValue.Setting
if ($restoredValue.Exists -ne $previousValue.Exists -or ($restoredValue.Exists -and [string]$restoredValue.Value -ne [string]$previousValue.Value)) {
$rollbackFailed = $true
}
} catch {
$rollbackFailed = $true
}
}
if ($rollbackFailed) {
throw "Unable to apply registry state '$State': $applyError. The previous registry state could not be restored."
}
throw "Unable to apply registry state '$State': $applyError"
}
}
+4 -1
View File
@@ -33,7 +33,10 @@ Function Set-WinUtilService {
# Service exists, proceed with changing properties -- while handling auto delayed start for PWSH 5
if (($PSVersionTable.PSVersion.Major -lt 7) -and ($StartupType -eq "AutomaticDelayedStart")) {
sc.exe config $Name start=delayed-auto
sc.exe config $Name start= delayed-auto
if ($LASTEXITCODE -ne 0) {
throw "sc.exe config failed with exit code $LASTEXITCODE"
}
} else {
$service | Set-Service -StartupType $StartupType -ErrorAction Stop
}

Some files were not shown because too many files have changed in this diff Show More