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>
This commit is contained in:
MyDrift
2026-09-04 09:55:39 -05:00
committed by GitHub
co-authored by Chris Titus
parent 69ff04b632
commit 283b9954f1
122 changed files with 8646 additions and 3345 deletions
+6 -1
View File
@@ -175,7 +175,12 @@ 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 `Set-WinUtilTweaksProgressIndicator` safe to call without a window; the `-Preset` and `-Config` paths run the workflows before the form is created and before PresentationCore is loaded.
- 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 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.
+4
View File
@@ -58,6 +58,8 @@
"ScrollBarHoverColor": "#5A5D62",
"ScrollBarDraggingColor": "#6A6D72",
"ProgressBarForegroundColor": "#2E77FF",
"ProgressBarErrorColor": "#D13438",
"ProgressBarWarningColor": "#B36A00",
"ProgressBarBackgroundColor": "Transparent",
"ButtonInstallBackgroundColor": "#F7F7F7",
"ButtonTweaksBackgroundColor": "#F7F7F7",
@@ -98,6 +100,8 @@
"ScrollBarHoverColor": "#3B4252",
"ScrollBarDraggingColor": "#5E81AC",
"ProgressBarForegroundColor": "#6EFF72",
"ProgressBarErrorColor": "#FF6B6B",
"ProgressBarWarningColor": "#FFC83D",
"ProgressBarBackgroundColor": "Transparent",
"ButtonInstallBackgroundColor": "#222222",
"ButtonTweaksBackgroundColor": "#333333",
+4 -2
View File
@@ -1121,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"
@@ -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.
@@ -390,23 +391,68 @@ Update UI
- `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
@@ -452,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
@@ -486,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
@@ -248,7 +248,7 @@ If you're reporting a problem, click the gear icon in the top-right corner and c
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.
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
+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
}
}
@@ -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
}
@@ -4,6 +4,8 @@ function Get-WinUtilEnvironmentReport {
Collects the allowlisted data used by the WinUtil environment report.
#>
$reportWarnings = [System.Collections.Generic.List[string]]::new()
$windows = [ordered]@{
edition = $null
version = $null
@@ -27,7 +29,9 @@ function Get-WinUtilEnvironmentReport {
$hardware.totalMemoryGB = [math]::Round(([double]$operatingSystem.TotalVisibleMemorySize / 1MB), 2)
}
} catch {
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to collect Windows/memory info from Win32_OperatingSystem: $($_.Exception.Message)"
$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 {
@@ -37,7 +41,9 @@ function Get-WinUtilEnvironmentReport {
$hardware.logicalProcessorCount = [int](($processors | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum)
}
} catch {
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to collect CPU info from Win32_Processor: $($_.Exception.Message)"
$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]@{
@@ -49,7 +55,9 @@ function Get-WinUtilEnvironmentReport {
try {
$powershell.executionPolicy = (Get-ExecutionPolicy).ToString()
} catch {
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to read PowerShell execution policy: $($_.Exception.Message)"
$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
@@ -57,14 +65,23 @@ function Get-WinUtilEnvironmentReport {
try {
$chocolatey.installed = (Test-WinUtilPackageManager -choco 6>$null) -eq "installed"
} catch {
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to check Chocolatey availability: $($_.Exception.Message)"
$message = "Failed to check Chocolatey availability: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
if ($chocolatey.installed) {
try {
$chocolatey.version = (choco -v 2>&1 | Select-Object -First 1).ToString().Trim()
$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 {
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to read Chocolatey version: $($_.Exception.Message)"
$message = "Failed to read Chocolatey version: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
}
@@ -72,18 +89,29 @@ function Get-WinUtilEnvironmentReport {
try {
$winget.installed = (Test-WinUtilPackageManager -winget 6>$null) -eq "installed"
} catch {
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to check WinGet availability: $($_.Exception.Message)"
$message = "Failed to check WinGet availability: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
if ($winget.installed) {
try {
$winget.version = (winget -v 2>&1 | Select-Object -First 1).ToString().Trim()
$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 {
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to read WinGet version: $($_.Exception.Message)"
$message = "Failed to read WinGet version: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
}
$system = [ordered]@{ pendingRebootRequired = $false }
# 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",
@@ -93,17 +121,30 @@ function Get-WinUtilEnvironmentReport {
# 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 -Path "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" `
-Name "PendingFileRenameOperations" -ErrorAction SilentlyContinue).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 $_ }).Count -gt 0 -or
$system.pendingRebootRequired = ($rebootPaths | Where-Object {
Test-Path -LiteralPath $_ -ErrorAction Stop
}).Count -gt 0 -or
$pendingFileRenameOperations.Count -gt 0
} catch {
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to check pending-reboot registry state: $($_.Exception.Message)"
$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"
+3 -3
View File
@@ -22,17 +22,17 @@ function Get-WinUtilRecentLogs {
$LogDirectory = Join-Path $sync.winutildir "logs"
}
if (-not (Test-Path $LogDirectory)) {
if (-not (Test-Path -LiteralPath $LogDirectory -ErrorAction Stop)) {
return ""
}
$cutoff = (Get-Date).AddDays(-$Days)
$logFiles = Get-ChildItem -Path $LogDirectory -Filter "winutil_*.log" -File -ErrorAction SilentlyContinue |
$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 -Path $logFile.FullName -Raw)"
"=== $($logFile.Name) ===`n$(Get-Content -LiteralPath $logFile.FullName -Raw -ErrorAction Stop)"
}
return ($sections -join "`n`n")
@@ -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()
@@ -13,35 +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 = Get-WinUtilEntryToolTip -Description $app.description -Key $appKey
$border.Add_MouseLeftButtonUp({
# Resolve through $sync because the border's child is a layout Grid for FOSS entries
$childCheckbox = $sync.$($this.Tag)
$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.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
@@ -49,18 +32,8 @@ function Initialize-InstallAppEntry {
# Store the original appKey in Tag
$checkBox.Tag = $appKey
$checkbox.Style = $sync.Form.Resources.AppEntryCheckboxStyle
# The checkbox sits inside the entry layout Grid, so the border is one level further up
$checkbox.Add_Checked({
Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName $this.Tag
$borderElement = $this.Parent.Parent
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallSelectedColor")
})
$checkbox.Add_Unchecked({
Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName $this.Tag
$borderElement = $this.Parent.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"
@@ -73,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,6 +64,8 @@ function Initialize-InstallAppEntry {
$appName = New-Object Windows.Controls.TextBlock
$appName.Style = $sync.Form.Resources.AppEntryNameStyle
$appName.Text = $app.content
# Add FOSS label after the name if FOSS
[void]$contentPanel.Children.Add($appName)
$checkBox.Content = $contentPanel
@@ -110,6 +86,7 @@ function Initialize-InstallAppEntry {
[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()
@@ -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,30 +16,42 @@ function Initialize-WinUtilTabContent {
return
}
switch ($TabName) {
"Install" {
Initialize-WPFUI -targetGridName "appscategory"
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
}
}
}
# 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
# Sync freshly built controls to any selections already in $sync.selected* (import/preset).
Reset-WPFCheckBoxes -doToggles $true
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
}
}
@@ -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
+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
}
@@ -117,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
@@ -125,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 @"
File diff suppressed because it is too large Load Diff
+84 -125
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 complete Steps 1-3 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,59 @@ 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 }
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 +127,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 +196,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,55 +206,41 @@ 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")
})
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
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
$sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $true
})
Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $true }
}
})
$script.BeginInvoke()
}
}
@@ -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."
}
@@ -20,9 +20,13 @@ 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 (-not $firewallRule.Enabled) {
Set-NetFirewallRule -Name 'sshd' -Enabled True
Write-Host "Firewall rule for OpenSSH Server enabled."
}
# An SSH logon for a member of the administrators group gets a full token
@@ -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."
}
+98
View File
@@ -0,0 +1,98 @@
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"
)
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
try {
& $ScriptBlock
} finally {
$stopwatch.Stop()
if ($null -ne $sync.StepTimings) {
$null = $sync.StepTimings.Add([pscustomobject]@{
Scope = $Scope
Step = $Name
Milliseconds = $stopwatch.ElapsedMilliseconds
})
}
Write-WinUtilLog -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
)
if ($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 }
Write-WinUtilLog -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 -Component $Scope -Message "timing summary: $($step.Milliseconds) ms ($share%) $($step.Step)"
}
}
@@ -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."
+25 -8
View File
@@ -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,39 +0,0 @@
function Set-WinUtilTweaksProgressIndicator {
<#
.SYNOPSIS
Shows, updates, or hides the window-level progress indicator used by long-running
workflows such as app management, Tweaks, AppX management, and Win11 Creator.
It lives outside the TabControl, so it stays visible no matter which tab is active.
.PARAMETER Visible
Whether the indicator should be shown or hidden.
.PARAMETER Label
The text to display above the progress bar.
.PARAMETER Percent
The percentage of the progress bar that should be filled (0-100).
#>
param(
[bool]$Visible,
[string]$Label,
[ValidateRange(0,100)]
[int]$Percent
)
if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) {
return
}
$indicatorVisible = if ($Visible) { [Windows.Visibility]::Visible } else { [Windows.Visibility]::Collapsed }
$indicatorLabel = $Label
$hasLabel = $PSBoundParameters.ContainsKey('Label')
$hasPercent = $PSBoundParameters.ContainsKey('Percent')
Invoke-WPFUIThread -ScriptBlock {
$sync.WPFTweaksProgressBar.Visibility = $indicatorVisible
if ($hasLabel) {
$sync.WPFTweaksProgressLabel.Text = $indicatorLabel
}
if ($hasPercent) {
$sync.WPFTweaksProgressValue.Value = $Percent
}
}
}
+28 -1
View File
@@ -2,6 +2,14 @@ function Show-WinUtilMessage {
<#
.SYNOPSIS
Shows a WinUtil message box and returns the selected result.
.DESCRIPTION
Message boxes need the interface thread, so this marshals onto it and can therefore be
called from a job body as well as from an event handler. Every prompt is also written to
the session log so the log shows what the user was asked and not just what happened next.
With no window there is nobody to click, so nothing is shown. A modal put up in that
state never returns and takes the worker with it.
#>
param (
[string]$Message,
@@ -10,5 +18,24 @@ function Show-WinUtilMessage {
$Icon = "Information"
)
[System.Windows.MessageBox]::Show($Message, $Title, $Button, $Icon)
Write-WinUtilLog -Component "Dialog" -Message "$Title : $($Message -replace '\r?\n', ' ')"
if (-not (Test-WinUtilUIAlive)) {
# Anything with a choice is answered with the one that does not go ahead, so a prompt
# nobody saw can never stand in for consent
$unattended = if ("$Button" -eq "OK") { "OK" } else { "No" }
Write-WinUtilLog -Level "WARN" -Component "Dialog" -Message "No window to ask on, answering '$unattended' for: $Title"
return $unattended
}
return Invoke-WPFUIThread -PassThru -Parameters @{
Message = $Message
Title = $Title
Button = $Button
Icon = $Icon
} -ScriptBlock {
param($Message, $Title, $Button, $Icon)
[System.Windows.MessageBox]::Show($Message, $Title, $Button, $Icon)
}
}
@@ -0,0 +1,40 @@
function Start-WinUtilAssetRendering {
<#
.SYNOPSIS
Renders the taskbar overlay bitmaps on a thread of their own
.DESCRIPTION
Rasterising the overlays costs the interface thread time it could spend getting the
window up. The bitmaps are frozen before publication, so they can be built anywhere.
Nothing waits on this: if the render has not finished when an overlay is asked for,
Set-WinUtilTaskbaritem renders it in place.
Needs STA for RenderTargetBitmap, which the shared worker pool is not.
#>
$runspace = [runspacefactory]::CreateRunspace((New-WinUtilSessionState))
$runspace.ApartmentState = "STA"
$runspace.ThreadOptions = "ReuseThread"
$runspace.Open()
$shell = [powershell]::Create()
$shell.Runspace = $runspace
[void]$shell.AddScript({
# A new runspace does not inherit assemblies loaded by the interface runspace. On a cold
# process these types otherwise fail before the off-thread render can do any work.
Add-Type -AssemblyName WindowsBase
Add-Type -AssemblyName PresentationCore
Add-Type -AssemblyName PresentationFramework
Measure-WinUtilStep -Scope "UI" -Name "render taskbar overlays (off thread)" -ScriptBlock {
Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $true
}
})
$handle = $shell.BeginInvoke()
Register-WinUtilRunspaceCleanup -PowerShell $shell -Handle $handle -Runspace $runspace
return $handle
}
@@ -0,0 +1,156 @@
function Start-WinUtilBackgroundQueue {
<#
.SYNOPSIS
Drains a queue of interface work one item at a time, between the things the user does
.DESCRIPTION
For work that must run on the interface thread but that nobody waits on: unopened
tabs, app list entries. One item per queued operation, so input is answered between
them instead of after the whole list.
Re-posted rather than looped: only returning to the dispatcher lets it service input.
Posted as a compiled action rather than through Invoke-WPFUIThread, whose body
crosses runspaces as text and would recompile on each of the hundreds of posts a full
app list costs.
.PARAMETER Name
Identifies the queue in $sync so a re-posted pump finds its state.
.PARAMETER Queue
The queue to drain. Items mean whatever Step says they mean.
.PARAMETER Step
Runs one item. Receives the dequeued item.
.PARAMETER OnComplete
Runs once on the interface thread after the last item.
.PARAMETER RequiresTab
Work drawing into this tab waits while another tab is shown.
.PARAMETER DeferWhile
Extra reason to hold off, tested each round. Lets a more urgent queue go first.
#>
param(
[Parameter(Mandatory)]
[string]$Name,
[Parameter(Mandatory)]
$Queue,
[Parameter(Mandatory)]
[scriptblock]$Step,
[scriptblock]$OnComplete,
[string]$RequiresTab,
[scriptblock]$DeferWhile
)
if ($null -eq $sync.BackgroundQueues) {
$sync.BackgroundQueues = [hashtable]::Synchronized(@{})
}
$sync.BackgroundQueues[$Name] = @{
Queue = $Queue
Step = $Step
OnComplete = $OnComplete
RequiresTab = $RequiresTab
DeferWhile = $DeferWhile
}
# No window means no dispatcher to spread over and nothing competing for the thread
if (-not (Test-WinUtilUIAlive)) {
while ($Queue.Count -gt 0) {
# One failing item must not abandon the rest or strand the state, matching the
# dispatcher path
try {
& $Step $Queue.Dequeue()
} catch {
Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Background queue '$Name'"
}
}
if ($OnComplete) { & $OnComplete }
$sync.BackgroundQueues.Remove($Name)
return
}
Request-WinUtilBackgroundQueueStep -Name $Name
}
function Request-WinUtilBackgroundQueueStep {
<#
.SYNOPSIS
Posts the next step of a queue at background priority
.PARAMETER Name
Which queue to advance.
#>
param(
[Parameter(Mandatory)]
[string]$Name
)
if (-not (Test-WinUtilUIAlive)) {
return
}
# The name travels as the dispatcher's argument, not captured: this function has returned by
# the time the block runs, and a closure would bind command lookup to a copied scope.
$null = $sync.Form.Dispatcher.BeginInvoke(
[System.Windows.Threading.DispatcherPriority]::Background,
[System.Windows.Threading.DispatcherOperationCallback]{
param($QueueName)
Invoke-WinUtilBackgroundQueueStep -Name $QueueName
return $null
},
$Name)
}
function Invoke-WinUtilBackgroundQueueStep {
<#
.SYNOPSIS
Runs one item of a queue and asks for the next, or finishes
.PARAMETER Name
Which queue to advance.
#>
param(
[Parameter(Mandatory)]
[string]$Name
)
$state = $sync.BackgroundQueues[$Name]
if ($null -eq $state) {
return
}
if ($state.Queue.Count -gt 0) {
$defer = (Test-WinUtilDeferBackgroundWork -RequiresTab $state.RequiresTab) -or
($state.DeferWhile -and (& $state.DeferWhile))
# Waits rather than competing with whatever the user is doing
if ($defer) {
Invoke-WinUtilWhenIdle -Argument $Name -Callback {
param($QueueName)
Invoke-WinUtilBackgroundQueueStep -Name $QueueName
}
return
}
try {
& $state.Step $state.Queue.Dequeue()
} catch {
Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Background queue '$Name'"
}
}
if ($state.Queue.Count -gt 0) {
Request-WinUtilBackgroundQueueStep -Name $Name
return
}
$sync.BackgroundQueues.Remove($Name)
if ($state.OnComplete) { & $state.OnComplete }
}
@@ -4,8 +4,30 @@ function Invoke-WinUtilInstallAppRenderBatch {
$CategoryBatch
)
foreach ($appKey in $CategoryBatch.AppKeys) {
# A count is not a unit of time. How long a fixed number of entries takes depends on the
# machine and on the category, so the pass runs to a deadline instead and hands back
# whatever it did not reach. That caps how long a click can be left waiting.
$budgetMs = 25
$keys = @($CategoryBatch.AppKeys)
# This runs on the dispatcher, so keep the slice free of logging and other disk I/O.
$clock = [System.Diagnostics.Stopwatch]::StartNew()
$rendered = 0
foreach ($appKey in $keys) {
$sync.$appKey = Initialize-InstallAppEntry -TargetElement $CategoryBatch.TargetElement -AppKey $appKey
$rendered++
# at least one per pass, or a slow machine would never finish the list
if ($clock.ElapsedMilliseconds -ge $budgetMs) {
break
}
}
if ($rendered -lt $keys.Count) {
$sync.InstallAppRenderQueue.Enqueue([pscustomobject]@{
Category = $CategoryBatch.Category
TargetElement = $CategoryBatch.TargetElement
AppKeys = @($keys[$rendered..($keys.Count - 1)])
})
}
# Entries render in batches, so a filter that is already active has to be applied to each new
@@ -21,23 +43,7 @@ function Invoke-WinUtilInstallAppRenderBatch {
function Complete-WinUtilInstallAppRendering {
$sync.InstallAppEntriesRendered = $true
}
function Invoke-WinUtilInstallAppRenderNextBatch {
if ($sync.InstallAppRenderQueue.Count -gt 0) {
$categoryBatch = $sync.InstallAppRenderQueue.Dequeue()
Invoke-WinUtilInstallAppRenderBatch -CategoryBatch $categoryBatch
}
if ($sync.InstallAppRenderQueue.Count -gt 0) {
$sync.Form.Dispatcher.BeginInvoke(
[System.Windows.Threading.DispatcherPriority]::Background,
[action]{ Invoke-WinUtilInstallAppRenderNextBatch }
) | Out-Null
return
}
Complete-WinUtilInstallAppRendering
}
function Start-WinUtilInstallAppRendering {
@@ -47,18 +53,15 @@ function Start-WinUtilInstallAppRendering {
$sync.InstallAppEntriesRendered = $false
if ($sync.Form -and $sync.Form.Dispatcher) {
$sync.Form.Dispatcher.BeginInvoke(
[System.Windows.Threading.DispatcherPriority]::Background,
[action]{ Invoke-WinUtilInstallAppRenderNextBatch }
) | Out-Null
return
}
while ($sync.InstallAppRenderQueue.Count -gt 0) {
$categoryBatch = $sync.InstallAppRenderQueue.Dequeue()
Invoke-WinUtilInstallAppRenderBatch -CategoryBatch $categoryBatch
}
Complete-WinUtilInstallAppRendering
Start-WinUtilBackgroundQueue -Name "InstallAppRender" -Queue $sync.InstallAppRenderQueue `
-RequiresTab "Install" `
-Step { param($CategoryBatch) Invoke-WinUtilInstallAppRenderBatch -CategoryBatch $CategoryBatch } `
-OnComplete { Complete-WinUtilInstallAppRendering } `
-DeferWhile {
# Tabs that have never been built come first. This list is already on screen and
# filling in, while another tab is empty until it is built, so a click on one costs
# the whole build. The list finishing a little later is not felt; a tab that takes
# half a second to open is.
$sync.TabWarmupQueue -and $sync.TabWarmupQueue.Count -gt 0
}
}
+258
View File
@@ -0,0 +1,258 @@
function Start-WinUtilJob {
<#
.SYNOPSIS
Runs a long operation off the UI thread with the shared progress, taskbar, log and
error handling applied around it
.DESCRIPTION
One job at a time. Owns the busy flag, the progress bar, the taskbar item, the
console banner and the log lines for the job's lifetime. The body does the work and
calls Step-WinUtilJob; it must not print a banner or set the busy flag itself. A body
that throws is caught and the interface restored in a finally, so a failure cannot
leave the UI stuck busy.
.PARAMETER Name
Log component and progress text, for example Install.
.PARAMETER ScriptBlock
The work. Receives Parameters as named parameters.
.PARAMETER Parameters
Values passed to the body by name.
.PARAMETER Description
Progress text shown while the job starts. Defaults to the job name.
.PARAMETER DisableAppList
Greys out the app list for the duration, for jobs that change what is installed.
.EXAMPLE
Start-WinUtilJob -Name "Install" -Parameters @{ Packages = $packages } -ScriptBlock {
param($Packages)
Step-WinUtilJob -Status "Installing" -Percent 10
}
#>
param(
[Parameter(Mandatory)]
[string]$Name,
[Parameter(Mandatory)]
[scriptblock]$ScriptBlock,
[hashtable]$Parameters = @{},
[string]$Description,
[switch]$DisableAppList
)
if ($sync.ShuttingDown -or $sync.FinishInConsole) {
Write-WinUtilLog -Level "WARN" -Component $Name -Message "Refused to start $Name, WinUtil is closing."
return $null
}
# A nested job runs inline: the outer already owns the slot and the reporting, so claiming
# again would refuse it and skip its work. Feature installs arrive twice, from feature.json
# and from Invoke-WPFFeatureInstall.
if ($global:WinUtilIsJobWorker) {
& $ScriptBlock @Parameters
return $null
}
# Locked: a headless or scheduled caller is not serialised by the dispatcher, where
# test-then-assign lets two jobs both own the slot. The token identifies the run, so a worker
# still unwinding cannot release a slot the next job holds.
$jobToken = [guid]::NewGuid().ToString()
$blockedBy = $null
[System.Threading.Monitor]::Enter($sync.SyncRoot)
try {
if ($sync.ActiveJob) {
$blockedBy = $sync.ActiveJob
} else {
$sync.ActiveJob = $Name
$sync.ActiveJobToken = $jobToken
$sync.LastJobResult = $null
}
} finally {
[System.Threading.Monitor]::Exit($sync.SyncRoot)
}
if ($blockedBy) {
Show-WinUtilMessage -Message "$blockedBy is still running. Wait for it to finish before starting another action." -Title "WinUtil" -Button "OK" -Icon "Warning" | Out-Null
return $null
}
$label = if ($Description) { $Description } else { $Name }
try {
$timingStartIndex = if ($sync.StepTimings) { $sync.StepTimings.Count } else { 0 }
Write-WinUtilLog -Component $Name -Message "$Name job started."
Write-WinUtilJobBanner -Message $label
Step-WinUtilJob -Status "$label..." -Percent 0 -State "Normal" -Overlay "logo"
if ($DisableAppList -and (Test-WinUtilUIAlive)) {
Invoke-WPFUIThread -ScriptBlock {
if ($null -ne $sync.ItemsControl) { $sync.ItemsControl.IsEnabled = $false }
}
}
# Rebuilt from its text inside the runspace: a scriptblock carries the session state it was
# defined in, and recreating it there binds it to the worker. The handle is discarded,
# printing it puts an IAsyncResult table on the console on every button press.
$null = Invoke-WPFRunspace -ParameterList @(
("JobName", $Name),
("JobLabel", $label),
("JobBody", $ScriptBlock.ToString()),
("JobParameters", $Parameters),
("JobRestoresAppList", [bool]$DisableAppList),
("JobToken", $jobToken),
("TimingStartIndex", $timingStartIndex)
) -ScriptBlock {
param($JobName, $JobLabel, $JobBody, $JobParameters, $JobRestoresAppList, $JobToken, $TimingStartIndex)
# Marks this runspace as the one doing the work, so a pause holds here and not in
# whoever asked for it
$global:WinUtilIsJobWorker = $true
$global:WinUtilJobErrorCount = 0
$global:WinUtilJobWarningCount = 0
$jobClock = [System.Diagnostics.Stopwatch]::StartNew()
try {
$body = [scriptblock]::Create($JobBody)
# A worker's warning and error streams buffer on a PowerShell object nobody reads.
# Merging them into the output stream is what gets them to the log.
& $body @JobParameters 2>&1 3>&1 | ForEach-Object {
if ($_ -is [System.Management.Automation.WarningRecord]) {
$global:WinUtilJobWarningCount++
Write-WinUtilLog -Level "WARN" -Component $JobName -Message $_.Message
} elseif ($_ -is [System.Management.Automation.ErrorRecord]) {
Write-WinUtilErrorRecord -ErrorRecord $_ -Component $JobName -Context "Non-terminating error"
}
}
$jobClock.Stop()
# A step can fail without throwing, for example a registry write refused by policy.
# The counter belongs to this worker runspace, so unrelated UI errors cannot change
# this job's result while it is running.
$newErrors = $global:WinUtilJobErrorCount
$newWarnings = $global:WinUtilJobWarningCount
if ($newErrors -gt 0) {
Write-WinUtilLog -Level "WARN" -Component $JobName -Message "$JobName job finished in $($jobClock.ElapsedMilliseconds) ms with $newErrors error(s)."
Write-WinUtilJobBanner -Message "$JobLabel finished with $newErrors error(s), see the log" -Level "ERROR"
Step-WinUtilJob -Status "$JobName finished with $newErrors error(s)" -Percent 100 -State "Paused" -Overlay "warning"
} elseif ($newWarnings -gt 0) {
Write-WinUtilLog -Level "WARN" -Component $JobName -Message "$JobName job finished in $($jobClock.ElapsedMilliseconds) ms with $newWarnings warning(s)."
Write-WinUtilJobBanner -Message "$JobLabel finished with $newWarnings warning(s), see the log"
Step-WinUtilJob -Status "$JobName finished with $newWarnings warning(s)" -Percent 100 -State "Paused" -Overlay "warning"
} else {
Write-WinUtilLog -Component $JobName -Message "$JobName job finished in $($jobClock.ElapsedMilliseconds) ms."
Write-WinUtilJobBanner -Message "$JobLabel finished"
Step-WinUtilJob -Status "$JobName finished" -Percent 100 -State "None" -Overlay "checkmark"
}
} catch {
$jobClock.Stop()
# A leaf that logs before rethrowing marks that exact exception. Preserve the outer
# context and stack without counting it twice; unrelated earlier errors do not qualify.
$errorAlreadyReported = $_.Exception.Data["WinUtilErrorReported"] -eq $true
Write-WinUtilErrorRecord -ErrorRecord $_ -Component $JobName -Context "$JobName failed after $($jobClock.ElapsedMilliseconds) ms" -DetailOnly:$errorAlreadyReported
Write-WinUtilJobBanner -Message "$JobLabel failed: $($_.Exception.Message)" -Level "ERROR"
Step-WinUtilJob -Status "$JobName failed" -Percent 100 -State "Error" -Overlay "warning"
} finally {
$jobResult = [pscustomobject]@{
Token = $JobToken
Errors = $global:WinUtilJobErrorCount
Warnings = $global:WinUtilJobWarningCount
}
# Pool runspaces are reused, so leaving this set would make the next piece of
# background work on this runspace believe it is a job worker
$global:WinUtilIsJobWorker = $false
$global:WinUtilJobErrorCount = 0
$global:WinUtilJobWarningCount = 0
Write-WinUtilTimingSummary -Scope $JobName -TotalMilliseconds $jobClock.ElapsedMilliseconds -StartIndex $TimingStartIndex
# A worker the watchdog cut off can reach here after the next job claimed the slot,
# and everything below releases shared state.
$stillOwns = $false
[System.Threading.Monitor]::Enter($sync.SyncRoot)
try {
$stillOwns = $sync.ActiveJobToken -eq $JobToken
if ($stillOwns) {
$sync.LastJobResult = $jobResult
}
} finally {
[System.Threading.Monitor]::Exit($sync.SyncRoot)
}
if ($stillOwns) {
try {
if ($JobRestoresAppList -and (Test-WinUtilUIAlive)) {
Invoke-WPFUIThread -ScriptBlock {
if ($null -ne $sync.ItemsControl) { $sync.ItemsControl.IsEnabled = $true }
}
}
} catch {
Write-WinUtilLog -Level "WARN" -Component $JobName -Message "Could not restore the app list after $JobName finished: $($_.Exception.Message)"
} finally {
# Dispatcher shutdown can race the alive check and abort the restore call.
# The worker is still finished, so its slot must always be released.
$null = Clear-WinUtilActiveJob -Token $JobToken
}
} else {
Write-WinUtilLog -Level "WARN" -Component $JobName -Message "$JobName unwound after another job had started; leaving its state alone."
}
}
}
} catch {
$scheduleError = $_
try {
Write-WinUtilErrorRecord -ErrorRecord $scheduleError -Component $Name -Context "Could not schedule $Name"
$sync.LastJobResult = [pscustomobject]@{ Token = $jobToken; Errors = 1; Warnings = 0 }
Write-WinUtilJobBanner -Message "$label could not start" -Level "ERROR"
Step-WinUtilJob -Status "$Name could not start" -Percent 100 -State "Error" -Overlay "warning"
} catch {
Write-WinUtilLog -Level "WARN" -Component $Name -Message "Could not report that $Name failed to start: $($_.Exception.Message)"
} finally {
try {
if ($DisableAppList -and (Test-WinUtilUIAlive)) {
Invoke-WPFUIThread -ScriptBlock {
if ($null -ne $sync.ItemsControl) { $sync.ItemsControl.IsEnabled = $true }
}
}
} catch {
Write-WinUtilLog -Level "WARN" -Component $Name -Message "Could not restore the app list after $Name failed to start: $($_.Exception.Message)"
} finally {
$null = Clear-WinUtilActiveJob -Token $jobToken
}
}
}
}
function Clear-WinUtilActiveJob {
<#
.SYNOPSIS
Releases the active job slot, clearing its name and its token together
.DESCRIPTION
A token left set still matches a later run and blocks new work.
.PARAMETER Token
Release only if this run still owns the slot. Omit to release unconditionally.
#>
param([string]$Token)
[System.Threading.Monitor]::Enter($sync.SyncRoot)
try {
if (-not $Token -or $sync.ActiveJobToken -eq $Token) {
$sync.ActiveJobToken = $null
$sync.ActiveJob = $null
return $true
}
return $false
} finally {
[System.Threading.Monitor]::Exit($sync.SyncRoot)
}
}
@@ -0,0 +1,38 @@
function Start-WinUtilTabWarmup {
<#
.SYNOPSIS
Builds the tabs the user has not opened yet, while the interface is idle
.DESCRIPTION
Tab content has to be built on the interface thread, so a tab that is still empty
when it is first clicked makes that click pay for the build. Queueing the builds
moves that cost to where nothing is waiting on it.
Queued at background priority rather than idle priority. At idle priority this never
ran until the app list had finished, which is the exact window in which a tab the
user clicks is still empty and costs a full build to open.
#>
# Win11ISO is left out: building it runs the existing work check, which raises the resume
# prompt while the user is on another tab. That check belongs to opening the tab, not warming
# it.
$pending = [System.Collections.Queue]::new()
foreach ($tab in @("Tweaks", "Config", "AppX")) {
if (-not $sync.InitializedTabs[$tab]) {
$pending.Enqueue($tab)
}
}
if ($pending.Count -eq 0) {
return
}
$sync.TabWarmupQueue = $pending
Start-WinUtilBackgroundQueue -Name "TabWarmup" -Queue $pending -Step {
param($Tab)
Measure-WinUtilStep -Scope "UI" -Name "warm $Tab tab" -ScriptBlock {
Initialize-WinUtilTabContent -TabName $Tab -Yield
}
}
}
@@ -0,0 +1,530 @@
function Start-WinUtilUserInterface {
<#
.SYNOPSIS
Builds the WinUtil window, wires its event handlers and runs it to completion
.DESCRIPTION
This is the whole interface. It runs on the dedicated STA interface runspace that
main.ps1 starts, so the thread that owns the window does nothing but paint and
dispatch: every long operation goes to the worker pool through Start-WinUtilJob.
The call blocks until the window is closed, and the interface runspace is the only
place that is allowed to touch controls directly.
#>
$buildClock = [System.Diagnostics.Stopwatch]::StartNew()
Measure-WinUtilStep -Scope "UI" -Name "load WPF assemblies" -ScriptBlock {
[void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')
}
[xml]$XAML = $inputXML
# Read the XAML file
$readerOperationSuccessful = $false # There's more cases of failure then success.
$readerFailure = $null
$reader = (New-Object System.Xml.XmlNodeReader $xaml)
try {
Measure-WinUtilStep -Scope "UI" -Name "parse XAML" -ScriptBlock {
$sync["Form"] = [Windows.Markup.XamlReader]::Load( $reader )
}
$readerOperationSuccessful = $true
} catch [System.Management.Automation.MethodInvocationException] {
$readerFailure = $_
Write-Host "We ran into a problem with the XAML code. Check the syntax for this control..." -ForegroundColor Red
Write-Host $error[0].Exception.Message -ForegroundColor Red
If ($error[0].Exception.Message -like "*button*") {
write-Host "Ensure your &lt;button in the `$inputXML does NOT have a Click=ButtonClick property. PS can't handle this`n`n`n`n" -ForegroundColor Red
}
} catch {
$readerFailure = $_
Write-Host "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed." -ForegroundColor Red
}
if (-NOT ($readerOperationSuccessful)) {
Write-Host "Failed to parse xaml content using Windows.Markup.XamlReader's Load Method." -ForegroundColor Red
Write-Host "Quitting WinUtil..." -ForegroundColor Red
Write-WinUtilLog -Level "ERROR" -Component "UI" -Message "Failed to parse the XAML content. WinUtil cannot start."
throw [System.InvalidOperationException]::new("Failed to parse the XAML content. WinUtil cannot start.", $readerFailure.Exception)
}
# Setup the Window to follow listen for windows Theme Change events and update the winutil theme
# throttle logic needed, because windows seems to send more than one theme change event per change
$themeState = @{ LastChange = [datetime]::MinValue }
$debounceInterval = [timespan]::FromSeconds(2)
$sync.Form.Add_Loaded({
$interopHelper = New-Object System.Windows.Interop.WindowInteropHelper $sync.Form
$hwndSource = [System.Windows.Interop.HwndSource]::FromHwnd($interopHelper.Handle)
$hwndSource.AddHook({
param (
[System.IntPtr]$hwnd,
[int]$msg,
[System.IntPtr]$wParam,
[System.IntPtr]$lParam,
[ref]$handled
)
$null = $hwnd, $wParam, $lParam
# Check for the Event WM_SETTINGCHANGE (0x1001A) and validate that Button shows the icon for "Auto" => [char]0xF08C
if (($msg -eq 0x001A) -and $sync.ThemeButton.Content -eq [char]0xF08C) {
$currentTime = [datetime]::Now
if ($currentTime - $themeState.LastChange -gt $debounceInterval) {
Invoke-WinutilThemeChange -theme "Auto"
$themeState.LastChange = $currentTime
# [ref] out-parameter: assigning to $handled would only replace the local
$handled.Value = $true
}
}
return 0
})
})
Measure-WinUtilStep -Scope "UI" -Name "apply theme" -ScriptBlock {
Invoke-WinutilThemeChange -theme $sync.preferences.theme
}
# No tab content is built before first paint. Invoke-WPFTab builds whichever tab it
# activates, and ContentRendered activates the default one.
$sync.InitializedTabs = @{}
#===========================================================================
# Store Form Objects In PowerShell
#===========================================================================
Measure-WinUtilStep -Scope "UI" -Name "map named controls" -ScriptBlock {
$xaml.SelectNodes("//*[@Name]") | ForEach-Object {$sync["$("$($psitem.Name)")"] = $sync["Form"].FindName($psitem.Name)}
}
# Built here so it carries this runspace's session state: posted work then runs as ordinary
# interface code, not a much slower cross-runspace nested pipeline. Invoke-WPFUIThread is the
# caller-facing side.
$sync.UIDispatchDelegate = [System.Func[object, object]]{
param($Work)
try {
$body = [scriptblock]::Create($Work.Body)
$parameters = $Work.Parameters
if ($parameters -and $parameters.Count -gt 0) {
& $body @parameters
} else {
& $body
}
} catch {
if ($Work.PropagateErrors) {
throw
}
# Fire-and-forget work has no waiting caller to report its failure.
Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Interface work"
}
}
Measure-WinUtilStep -Scope "UI" -Name "wire static button clicks" -ScriptBlock {
# CheckBox and RadioButton also derive from ButtonBase, so the exact type name is what
# decides, not -is
$clickableTypes = [System.Collections.Generic.HashSet[string]]::new([string[]]@("Button", "ToggleButton"), [StringComparer]::OrdinalIgnoreCase)
$alreadyWired = [System.Collections.Generic.HashSet[string]]::new([string[]]@($sync.Buttons), [StringComparer]::OrdinalIgnoreCase)
$clickHandler = {
[System.Object]$Sender = $args[0]
Invoke-WPFButton $Sender.name
}
foreach ($entry in @($sync.GetEnumerator())) {
$control = $entry.Value
if ($null -eq $control -or -not $clickableTypes.Contains($control.GetType().Name)) {
continue
}
if (-not $alreadyWired.Add([string]$entry.Key)) {
continue
}
$control.Add_Click($clickHandler)
$sync.Buttons.Add($entry.Key) | Out-Null
}
}
#===========================================================================
# Setup and Show the Form
#===========================================================================
# Progress bar in taskbaritem > Set-WinUtilProgressbar
$sync["Form"].TaskbarItemInfo = New-Object System.Windows.Shell.TaskbarItemInfo
Set-WinUtilTaskbaritem -state "None"
# Wired before the window is shown, so work queued during startup already knows to stand
# aside for anything the user does
Register-WinUtilInputWatch
# Set the titlebar
$sync["Form"].title = $sync["Form"].title + " " + $sync.version
# Set the commands that will run when the form is closed
$sync["Form"].Add_Closing({
param($eventSender, $closingArgs)
$null = $eventSender
# The pool cannot be torn down under work that is still running: the runspace error that
# follows is unhandled and ends the process
if ($sync.ActiveJob -and -not $sync.ForceClose) {
$closingArgs.Cancel = $true
Invoke-WinUtilCloseRequest -RunningJob $sync.ActiveJob
return
}
# Work that is meant to outlive the window needs the pool it is running on. main.ps1
# waits for it and shuts the pool down once it is done.
if ($sync.FinishInConsole) {
Write-WinUtilLog -Component "UI" -Message "Window closing, leaving $($sync.ActiveJob) to finish in the console."
return
}
# main.ps1 owns pool shutdown after the window has finished closing. Doing it from this
# dispatcher callback can deadlock with a worker that is in its UI-thread cleanup block.
Write-WinUtilLog -Component "UI" -Message "Window closing; the main thread will shut down the worker pool."
})
# Attach the event handler to the Click event
$sync.SearchBarClearButton.Add_Click({
$sync.SearchBar.Text = ""
$sync.SearchBarClearButton.Visibility = "Collapsed"
# Focus the search bar after clearing the text
$sync.SearchBar.Focus()
$sync.SearchBar.SelectAll()
})
# add some shortcuts for people that don't like clicking
function Invoke-WinUtilFontScaleStep([double]$Step) { $sync.FontScalingSlider.Value = [math]::Max(0.75, [math]::Min(2.0, $sync.FontScalingSlider.Value + $Step)); Invoke-WinUtilFontScaling -ScaleFactor $sync.FontScalingSlider.Value }
$commonKeyEvents = {
if ($sync.ActiveJob) {
return
}
# Handle key presses of single keys
switch ($_.Key) {
"Escape" { $sync.SearchBar.Text = "" }
}
# Handle Alt key combinations for navigation
if ($_.KeyboardDevice.Modifiers -eq "Alt") {
$keyEventArgs = $_
switch ($_.SystemKey) {
"I" { Invoke-WPFButton "WPFTab1BT"; $keyEventArgs.Handled = $true } # Navigate to Install tab and suppress Windows Warning Sound
"T" { Invoke-WPFButton "WPFTab2BT"; $keyEventArgs.Handled = $true } # Navigate to Tweaks tab
"C" { Invoke-WPFButton "WPFTab3BT"; $keyEventArgs.Handled = $true } # Navigate to Config tab
"U" { Invoke-WPFButton "WPFTab4BT"; $keyEventArgs.Handled = $true } # Navigate to Updates tab
"W" { Invoke-WPFButton "WPFTab5BT"; $keyEventArgs.Handled = $true } # Navigate to Win11ISO tab
}
}
# Handle Ctrl key combinations for specific actions
if ($_.KeyboardDevice.Modifiers -eq "Ctrl") {
$keyEventArgs = $_
switch ($_.Key) {
"F" { $sync.SearchBar.Focus() } # Focus on the search bar
"Q" { $this.Close() } # Close the application
}
}
$ctrlShiftModifiers = [Windows.Input.ModifierKeys]::Control -bor [Windows.Input.ModifierKeys]::Shift
if ($_.KeyboardDevice.Modifiers -eq "Ctrl" -or $_.KeyboardDevice.Modifiers -eq $ctrlShiftModifiers) {
$keyEventArgs = $_
switch ($_.Key) {
{ $_ -in "OemPlus", "Add" } { Invoke-WinUtilFontScaleStep 0.05; $keyEventArgs.Handled = $true }
{ $_ -in "OemMinus", "Subtract" } { Invoke-WinUtilFontScaleStep -0.05; $keyEventArgs.Handled = $true }
}
}
}
$sync["Form"].Add_PreViewKeyDown($commonKeyEvents)
$sync["Form"].Add_PreviewMouseWheel({
if ([Windows.Input.Keyboard]::Modifiers -eq "Ctrl") { Invoke-WinUtilFontScaleStep $(if ($_.Delta -gt 0) { 0.05 } else { -0.05 }); $_.Handled = $true }
})
$sync["Form"].Add_MouseLeftButtonDown({
Invoke-WPFPopup -Action "Hide" -Popups @("Settings", "Theme", "FontScaling")
$sync["Form"].DragMove()
})
$sync["Form"].Add_MouseDoubleClick({
if ($_.OriginalSource.Name -eq "NavDockPanel" -or
$_.OriginalSource.Name -eq "GridBesideNavDockPanel") {
if ($sync["Form"].WindowState -eq [Windows.WindowState]::Normal) {
[Windows.SystemCommands]::MaximizeWindow($sync.Form)
}
else{
[Windows.SystemCommands]::RestoreWindow($sync.Form)
}
}
})
$sync["Form"].Add_Deactivated({
Invoke-WPFPopup -Action "Hide" -Popups @("Settings", "Theme", "FontScaling")
})
$sync["Form"].Add_ContentRendered({
# Load the Windows Forms assembly
Add-Type -AssemblyName System.Windows.Forms
$primaryScreen = [System.Windows.Forms.Screen]::PrimaryScreen
# Check if the primary screen is found
if ($primaryScreen) {
# Extract screen width and height for the primary monitor
$screenWidth = $primaryScreen.Bounds.Width
$screenHeight = $primaryScreen.Bounds.Height
$sync.Form.MinWidth = [Math]::Min([double]$sync.Form.MinWidth, [double]$screenWidth)
# Compare with the primary monitor size
if ($sync.Form.ActualWidth -gt $screenWidth -or $sync.Form.ActualHeight -gt $screenHeight) {
$sync.Form.Left = 0
$sync.Form.Top = 0
$sync.Form.Width = $screenWidth
$sync.Form.Height = $screenHeight
}
}
if ($PARAM_OFFLINE) {
# Show offline banner
$sync.WPFOfflineBanner.Visibility = [System.Windows.Visibility]::Visible
# Disable the install tab
$sync.WPFTab1BT.IsEnabled = $false
$sync.WPFTab1BT.Opacity = 0.5
$sync.WPFTab1BT.ToolTip = "Internet connection required for installing applications."
# The install action buttons are generated with the Install tab, so
# Initialize-WinUtilInstallTabControls disables them when that tab is built
# Show offline indicator
Write-Host "Offline mode detected - Install tab disabled." -ForegroundColor Yellow
# Optionally switch to a different tab if install tab was going to be default
Invoke-WPFTab "WPFTab2BT" -Yield # Switch to Tweaks tab instead
}
else {
# Online - ensure install tab is enabled
$sync.WPFTab1BT.IsEnabled = $true
$sync.WPFTab1BT.Opacity = 1.0
$sync.WPFTab1BT.ToolTip = $null
Invoke-WPFTab "WPFTab1BT" -Yield # Default to install tab
}
$sync["Form"].Focus()
$sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Initialize-WinUtilRunspacePool | Out-Null }) | Out-Null
$sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{
Set-WinUtilTaskbaritem -overlay "logo"
}) | Out-Null
$sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Start-WinUtilTabWarmup }) | Out-Null
})
# The SearchBarTimer is used to delay the search operation until the user has stopped typing for a short period
# This prevents the ui from stuttering when the user types quickly as it dosnt need to update the ui for every keystroke
$searchBarTimer = New-Object System.Windows.Threading.DispatcherTimer
$searchBarTimer.Interval = [TimeSpan]::FromMilliseconds(300)
$searchBarTimer.IsEnabled = $false
$searchBarTimer.add_Tick({
$searchBarTimer.Stop()
switch ($sync.currentTab) {
"Install" {
Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Categories $sync.SelectedAppCategories.ToArray()
}
"Tweaks" {
Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text
}
"AppX" {
Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text
}
}
})
$sync["SearchBar"].Add_TextChanged({
if ($sync.SearchBar.Text -ne "") {
$sync.SearchBarClearButton.Visibility = "Visible"
$sync.SearchBarIcon.Visibility = "Collapsed"
} else {
$sync.SearchBarClearButton.Visibility = "Collapsed"
$sync.SearchBarIcon.Visibility = "Visible"
}
if ($searchBarTimer.IsEnabled) {
$searchBarTimer.Stop()
}
$searchBarTimer.Start()
})
# Category filter chips. The chip carries its category in Tag, so one handler covers all of them.
$sync.AppCategoryChips = @(
@{ Name = "WPFSearchChipAll"; Category = "" }
@{ Name = "WPFSearchChipBrowsers"; Category = "Browsers" }
@{ Name = "WPFSearchChipCommunications"; Category = "Communications" }
@{ Name = "WPFSearchChipDevelopment"; Category = "Development" }
@{ Name = "WPFSearchChipDocument"; Category = "Document" }
@{ Name = "WPFSearchChipGames"; Category = "Games" }
@{ Name = "WPFSearchChipMicrosoftTools"; Category = "Microsoft Tools" }
@{ Name = "WPFSearchChipMultimediaTools"; Category = "Multimedia Tools" }
@{ Name = "WPFSearchChipProTools"; Category = "Pro Tools" }
@{ Name = "WPFSearchChipSelfhostedTools"; Category = "Selfhosted Tools" }
@{ Name = "WPFSearchChipUtilities"; Category = "Utilities" }
)
$sync.SelectedAppCategories = [System.Collections.Generic.List[string]]::new()
foreach ($appCategoryChip in $sync.AppCategoryChips) {
$sync[$appCategoryChip.Name].Tag = $appCategoryChip.Category
$sync[$appCategoryChip.Name].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this })
}
$sync["Form"].Add_Loaded({
param($e)
$null = $e
$sync.Form.MinWidth = "1150"
$sync["Form"].MaxWidth = [Double]::PositiveInfinity
$sync["Form"].MaxHeight = [Double]::PositiveInfinity
})
Measure-WinUtilStep -Scope "UI" -Name "build nav logo" -ScriptBlock {
$NavLogoPanel = $sync["Form"].FindName("NavLogoPanel")
$NavLogoPanel.Children.Add((Invoke-WinUtilAssets -Type "logo" -Size 25)) | Out-Null
}
$sync["Form"].Add_Activated({
Set-WinUtilTaskbaritem -overlay "logo"
})
$sync["ThemeButton"].Add_Click({
Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Hide"; "Theme" = "Toggle"; "FontScaling" = "Hide" }
})
$sync["AutoThemeMenuItem"].Add_Click({
Invoke-WPFPopup -Action "Hide" -Popups @("Theme")
Invoke-WinutilThemeChange -theme "Auto"
})
$sync["DarkThemeMenuItem"].Add_Click({
Invoke-WPFPopup -Action "Hide" -Popups @("Theme")
Invoke-WinutilThemeChange -theme "Dark"
})
$sync["LightThemeMenuItem"].Add_Click({
Invoke-WPFPopup -Action "Hide" -Popups @("Theme")
Invoke-WinutilThemeChange -theme "Light"
})
$sync["SettingsButton"].Add_Click({
Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Toggle"; "Theme" = "Hide"; "FontScaling" = "Hide" }
})
$sync["ImportMenuItem"].Add_Click({
Invoke-WPFPopup -Action "Hide" -Popups @("Settings")
Invoke-WPFImpex -type "import"
})
$sync["ExportMenuItem"].Add_Click({
Invoke-WPFPopup -Action "Hide" -Popups @("Settings")
Invoke-WPFImpex -type "export"
})
$sync["ExportEnvironmentReportMenuItem"].Add_Click({
Invoke-WPFPopup -Action "Hide" -Popups @("Settings")
Invoke-WPFExportEnvironmentReport
})
$sync["AboutMenuItem"].Add_Click({
Invoke-WPFPopup -Action "Hide" -Popups @("Settings")
$authorInfo = @"
Author : <a href="https://github.com/ChrisTitusTech">@ChrisTitusTech</a>
UI : <a href="https://github.com/MyDrift-user">@MyDrift-user</a>, <a href="https://github.com/Marterich">@Marterich</a>
Runspace : <a href="https://github.com/DeveloperDurp">@DeveloperDurp</a>, <a href="https://github.com/Marterich">@Marterich</a>
GitHub : <a href="https://github.com/ChrisTitusTech/winutil">ChrisTitusTech/winutil</a>
Version : <a href="https://github.com/ChrisTitusTech/winutil/releases/tag/$($sync.version)">$($sync.version)</a>
"@
Show-CustomDialog -Title "About" -Message $authorInfo
})
$sync["DocumentationMenuItem"].Add_Click({
Invoke-WPFPopup -Action "Hide" -Popups @("Settings")
Start-Process "https://winutil.christitus.com/"
})
$sync["SponsorMenuItem"].Add_Click({
Invoke-WPFPopup -Action "Hide" -Popups @("Settings")
$authorInfo = @"
<a href="https://github.com/sponsors/ChrisTitusTech">Current sponsors for ChrisTitusTech:</a>
"@
$authorInfo += "`n"
try {
$sponsors = Invoke-WinUtilSponsors
foreach ($sponsor in $sponsors) {
$authorInfo += "<a href=`"https://github.com/sponsors/ChrisTitusTech`">$sponsor</a>`n"
}
} catch {
$authorInfo += "An error occurred while fetching or processing the sponsors: $_`n"
}
Show-CustomDialog -Title "Sponsors" -Message $authorInfo -EnableScroll $true
})
# Font Scaling Event Handlers
$sync["FontScalingButton"].Add_Click({
Invoke-WPFPopup -PopupActionTable @{ "Settings" = "Hide"; "Theme" = "Hide"; "FontScaling" = "Toggle" }
})
$sync["FontScalingSlider"].Add_ValueChanged({
param($slider)
$percentage = [math]::Round($slider.Value * 100)
$sync.FontScalingValue.Text = "$percentage%"
})
$sync["FontScalingResetButton"].Add_Click({
$sync.FontScalingSlider.Value = 1.0
$sync.FontScalingValue.Text = "100%"
})
$sync["FontScalingApplyButton"].Add_Click({
$scaleFactor = $sync.FontScalingSlider.Value
Invoke-WinUtilFontScaling -ScaleFactor $scaleFactor
Invoke-WPFPopup -Action "Hide" -Popups @("FontScaling")
})
# Win11ISO Tab button handlers
$sync["WPFWin11ISOBrowseButton"].Add_Click({
Invoke-WinUtilISOBrowse
})
$sync["WPFWin11ISODownloadLink"].Add_Click({
Start-Process "https://www.microsoft.com/software-download/windows11"
})
$sync["WPFWin11ISOMountButton"].Add_Click({
Invoke-WinUtilISOMountAndVerify
})
$sync["WPFWin11ISOModifyButton"].Add_Click({
Invoke-WinUtilISOModify
})
$sync["WPFWin11ISOChooseISOButton"].Add_Click({
$sync["WPFWin11ISOOptionUSB"].Visibility = "Collapsed"
Invoke-WinUtilISOExport
})
$sync["WPFWin11ISOChooseUSBButton"].Add_Click({
$sync["WPFWin11ISOOptionUSB"].Visibility = "Visible"
Invoke-WinUtilISORefreshUSBDrives
})
$sync["WPFWin11ISORefreshUSBButton"].Add_Click({
Invoke-WinUtilISORefreshUSBDrives
})
$sync["WPFWin11ISOWriteUSBButton"].Add_Click({
Invoke-WinUtilISOWriteUSB
})
$sync["WPFWin11ISOCleanResetButton"].Add_Click({
Invoke-WinUtilISOCleanAndReset
})
$buildClock.Stop()
Write-WinUtilLog -Component "UI" -Message "Interface built in $($buildClock.ElapsedMilliseconds) ms, showing the window."
Write-WinUtilTimingSummary -Scope "UI" -TotalMilliseconds $buildClock.ElapsedMilliseconds
# Input priority runs behind everything already queued, so this fires at the first moment
# the window could actually service a click
$sync["Form"].Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Input, [action]{
$sinceStart = [int]((Get-Date) - $sync.StartedAt).TotalMilliseconds
Write-WinUtilLog -Component "UI" -Message "timing: interface ready for input $sinceStart ms after start."
}) | Out-Null
$sync["Form"].ShowDialog() | Out-Null
# ShowDialog returns once the window is gone; stop the dispatcher so this runspace can close
[System.Windows.Threading.Dispatcher]::CurrentDispatcher.InvokeShutdown()
}
+104
View File
@@ -0,0 +1,104 @@
function Step-WinUtilJob {
<#
.SYNOPSIS
Advances a job to its next reportable point, honouring a pause or stop on the way
.DESCRIPTION
Every loop calls this, so it is the one point a run reliably passes between steps and
therefore the only place it can be held or ended without cutting into a command in
flight. It blocks while the run is paused and throws OperationCanceledException once
a stop is asked for, so calling it from a finally, or from a catch already reporting
a failure, re-raises that stop. The job layer clears the flags before its own finish
reporting for that reason.
Drives the progress bar and taskbar item together and does nothing without a window,
so job bodies need no UI checks. The update is posted rather than waited on: a job
reporting per item would otherwise stall on the interface thread each time.
.PARAMETER Status
Text for the progress label
.PARAMETER Percent
Completion between 0 and 100
.PARAMETER State
Taskbar state. Normal while working, Error on failure, None when finished.
.PARAMETER Overlay
Taskbar overlay icon: logo, checkmark, warning or None
.PARAMETER Hide
Clears and hides the progress bar. Used when leaving a finished job behind rather
than while one is running.
#>
param(
[string]$Status,
[int]$Percent = -1,
[ValidateSet("Normal", "Error", "Paused", "Indeterminate", "None")]
[string]$State,
[string]$Overlay,
[switch]$Hide
)
# With no window every update is thrown away, and a window closed over running work counts
# as none: its dispatcher accepts posts and discards them. The console is what is left.
if (-not (Test-WinUtilUIAlive)) {
if (-not $Hide) {
Write-WinUtilConsoleProgress -Status $Status -Percent $Percent
}
return
}
Invoke-WPFUIThread -Async -Parameters @{
Status = $Status
Percent = [Math]::Min([Math]::Max($Percent, -1), 100)
State = $State
Overlay = $Overlay
HideBar = [bool]$Hide
HasStatus = $PSBoundParameters.ContainsKey('Status')
HasState = $PSBoundParameters.ContainsKey('State')
HasOverlay = $PSBoundParameters.ContainsKey('Overlay')
} -ScriptBlock {
param($Status, $Percent, $State, $Overlay, $HideBar, $HasStatus, $HasState, $HasOverlay)
if ($HideBar) {
$sync.WPFTweaksProgressBar.Visibility = [Windows.Visibility]::Collapsed
$sync.WPFTweaksProgressLabel.Text = ""
$sync.WPFTweaksProgressLabel.ToolTip = $null
$sync.WPFTweaksProgressValue.Value = 0
return
}
$hasPercent = $Percent -ge 0
if ($HasStatus -or $hasPercent) {
$sync.WPFTweaksProgressBar.Visibility = [Windows.Visibility]::Visible
}
if ($HasStatus) {
$sync.WPFTweaksProgressLabel.Text = $Status
$sync.WPFTweaksProgressLabel.ToolTip = $Status
}
if ($hasPercent) {
$sync.WPFTweaksProgressValue.Value = $Percent
$sync.Form.TaskbarItemInfo.ProgressValue = $Percent / 100
}
if ($HasState) {
# Pulse in place at whatever progress has been reached. IsIndeterminate would make
# WPF discard Value and fill the whole bar, which reads as finished.
$sync.WPFTweaksProgressValue.Tag = if ($State -eq "Indeterminate") { "Pulse" } else { $null }
# By resource reference rather than a fixed brush, so switching theme repaints it
$barColor = switch ($State) {
"Error" { "ProgressBarErrorColor" }
"Paused" { "ProgressBarWarningColor" }
default { "ProgressBarForegroundColor" }
}
$sync.WPFTweaksProgressValue.SetResourceReference([Windows.Controls.Control]::ForegroundProperty, $barColor)
Set-WinUtilTaskbaritem -state $State
}
if ($HasOverlay) {
Set-WinUtilTaskbaritem -overlay $Overlay
}
}
}
@@ -0,0 +1,150 @@
function Test-WinUtilShellRunning {
<#
.SYNOPSIS
Whether one instance is still running, treating a disposed one as finished
#>
param($PowerShell)
try {
return $PowerShell.InvocationStateInfo.State -in @(
[System.Management.Automation.PSInvocationState]::Running,
[System.Management.Automation.PSInvocationState]::Stopping
)
} catch {
return $false
}
}
function Register-WinUtilActiveShell {
<#
.SYNOPSIS
Records a PowerShell instance that is running on the worker pool
.DESCRIPTION
An instance still queued when the pool closes starts on a closing runspace, throws on
a thread pool thread where nothing catches, and takes the process down with it.
Tracking what is in flight is what lets those be stopped first.
#>
param(
[Parameter(Mandatory)]
$PowerShell
)
# Synchronized protects one operation, not a test followed by an assignment, so the
# collection is created under the shared lock
[System.Threading.Monitor]::Enter($sync.SyncRoot)
try {
if ($null -eq $sync.ActiveShells) {
$sync.ActiveShells = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new())
}
} finally {
[System.Threading.Monitor]::Exit($sync.SyncRoot)
}
# Nothing disposes these on the way out, so finished ones are dropped here instead of
# accumulating for the life of the session
foreach ($finished in (Get-WinUtilActiveShell)) {
if (-not (Test-WinUtilShellRunning $finished)) {
try {
$sync.ActiveShells.Remove($finished)
} catch {
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Could not remove a completed worker from the active set: $_"
}
}
}
$null = $sync.ActiveShells.Add($PowerShell)
}
function Get-WinUtilActiveShell {
<#
.SYNOPSIS
A snapshot of the tracked instances, copied under the collection's own lock
.DESCRIPTION
Enumerating a synchronized ArrayList is not itself synchronized; a concurrent Add or
Remove throws mid-loop. SyncRoot is the documented fix.
#>
if ($null -eq $sync.ActiveShells) {
return @()
}
[System.Threading.Monitor]::Enter($sync.ActiveShells.SyncRoot)
try {
return @($sync.ActiveShells.ToArray())
} finally {
[System.Threading.Monitor]::Exit($sync.ActiveShells.SyncRoot)
}
}
function Stop-WinUtilActiveWork {
<#
.SYNOPSIS
Asks everything running on the worker pool to stop, and waits for it
.DESCRIPTION
Stop is a request, not a kill: a command already inside an installer runs until it
returns. The wait is bounded so a worker that never returns cannot hold the window
open.
.PARAMETER TimeoutSeconds
How long to wait before giving up on it.
#>
param(
[int]$TimeoutSeconds = 15,
# Issue the stop and return. The caller polls Test-WinUtilActiveWorkRunning instead of
# blocking here, which matters on the interface thread where a wait freezes the window.
[switch]$NoWait
)
$shells = Get-WinUtilActiveShell
if ($shells.Count -eq 0) {
return $true
}
Write-WinUtilLog -Component "UI" -Message "Stopping $($shells.Count) running item(s) before closing."
foreach ($shell in $shells) {
if (Test-WinUtilShellRunning $shell) {
try {
$null = $shell.BeginStop($null, $null)
} catch {
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Could not request that a worker stop: $_"
}
}
}
if ($NoWait) {
return $false
}
$clock = [System.Diagnostics.Stopwatch]::StartNew()
while ($clock.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
$stillRunning = @(Get-WinUtilActiveShell | Where-Object { Test-WinUtilShellRunning $_ }).Count
if ($stillRunning -eq 0) {
Write-WinUtilLog -Component "UI" -Message "Everything stopped after $($clock.ElapsedMilliseconds) ms."
return $true
}
Start-Sleep -Milliseconds 100
}
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Gave up waiting for work to stop after $TimeoutSeconds seconds, closing anyway."
return $false
}
function Test-WinUtilActiveWorkRunning {
<#
.SYNOPSIS
Whether any tracked instance is still running
#>
foreach ($shell in (Get-WinUtilActiveShell)) {
if (Test-WinUtilShellRunning $shell) { return $true }
}
return $false
}
@@ -0,0 +1,97 @@
function Register-WinUtilInputWatch {
<#
.SYNOPSIS
Records when the user last did something, so background work can step aside
.DESCRIPTION
Preview events run before the control handles the input, so the timestamp is set
even for a click the control then spends time on.
#>
$sync.LastInputAt = [datetime]::MinValue
$stamp = { $sync.LastInputAt = [datetime]::Now }
$sync.Form.Add_PreviewMouseDown($stamp)
$sync.Form.Add_PreviewKeyDown($stamp)
$sync.Form.Add_PreviewMouseWheel($stamp)
}
function Test-WinUtilDeferBackgroundWork {
<#
.SYNOPSIS
Whether speculative work should wait rather than run now
.DESCRIPTION
Background priority queues work behind input but does not make it interruptible:
whatever is running must finish before a click is looked at, which is why the pieces
are kept short. Waits while the user is active, or while the work draws into a tab
that is not on screen.
.PARAMETER RequiresTab
The tab this work draws into. Work for a hidden tab waits.
#>
param(
[string]$RequiresTab
)
if ($sync.LastInputAt) {
$sinceInput = ([datetime]::Now - $sync.LastInputAt).TotalMilliseconds
# long enough to cover a click and the work it starts, short enough not to be noticed
if ($sinceInput -lt 400) {
return $true
}
}
if ($RequiresTab -and $sync.currentTab -and $sync.currentTab -ne $RequiresTab) {
return $true
}
return $false
}
function Invoke-WinUtilWhenIdle {
<#
.SYNOPSIS
Runs a callback once the interface is not being used
.DESCRIPTION
A one shot timer, not a dispatcher post: a post at background priority runs straight
away and the point is to leave a gap.
.PARAMETER Callback
What to run once the wait is over.
.PARAMETER Argument
Passed to the callback. Carried on the timer rather than captured, so the callback
resolves commands where it was written, not in a copied scope.
.PARAMETER DelayMilliseconds
How long to wait before looking again.
#>
param(
[Parameter(Mandatory)]
[scriptblock]$Callback,
$Argument,
[int]$DelayMilliseconds = 150
)
if (-not (Test-WinUtilUIAlive)) {
return
}
# Bound to the interface dispatcher explicitly: the default picks up the calling thread's,
# which is only correct while every caller reaches here through a UI post
$timer = New-Object System.Windows.Threading.DispatcherTimer([System.Windows.Threading.DispatcherPriority]::Background, $sync.Form.Dispatcher)
$timer.Interval = [timespan]::FromMilliseconds($DelayMilliseconds)
$timer.Tag = @{ Callback = $Callback; Argument = $Argument }
# Sender taken from the argument, matching how the rest of this codebase handles timer ticks
$timer.Add_Tick({
param($eventSender)
$ticked = [System.Windows.Threading.DispatcherTimer]$eventSender
$ticked.Stop()
& $ticked.Tag.Callback $ticked.Tag.Argument
})
$timer.Start()
}
@@ -19,28 +19,16 @@ function Test-WinUtilPackageManager {
if ($winget) {
if (Get-Command winget -ErrorAction SilentlyContinue) {
Write-Host "===========================================" -ForegroundColor Green
Write-Host "--- WinGet is installed ---" -ForegroundColor Green
Write-Host "===========================================" -ForegroundColor Green
$status = "installed"
} else {
Write-Host "===========================================" -ForegroundColor Red
Write-Host "--- WinGet is not installed ---" -ForegroundColor Red
Write-Host "===========================================" -ForegroundColor Red
$status = "not-installed"
}
}
if ($choco) {
if (Get-Command choco -ErrorAction SilentlyContinue) {
Write-Host "===========================================" -ForegroundColor Green
Write-Host "--- Chocolatey is installed ---" -ForegroundColor Green
Write-Host "===========================================" -ForegroundColor Green
$status = "installed"
} else {
Write-Host "===========================================" -ForegroundColor Red
Write-Host "--- Chocolatey is not installed ---" -ForegroundColor Red
Write-Host "===========================================" -ForegroundColor Red
$status = "not-installed"
}
}
@@ -77,7 +77,11 @@ function Update-WinUtilSelections {
foreach ($listName in $nextSelections.Keys) {
foreach ($cbkey in $nextSelections[$listName]) {
$sync.$listName.Add($cbkey)
# Appending, so the same entry can already be there: a preset and a config that both
# name it would otherwise select it twice
if ($sync.$listName -notcontains $cbkey) {
$sync.$listName.Add($cbkey)
}
}
}
}
@@ -0,0 +1,91 @@
function Write-WinUtilConsoleProgress {
<#
.SYNOPSIS
Reports job progress on the console for runs that have no window
.DESCRIPTION
The progress bar is the only thing telling a user how far along a job is, so a
headless run needs the same information in the only place it has.
On a console the line is rewritten in place, because a package that reports every
few hundred milliseconds would otherwise scroll a screenful for one install. When
output is redirected there is no cursor to move, so each update is its own line and
they are throttled hard instead.
#>
param(
[string]$Status,
[int]$Percent = -1
)
if ([string]::IsNullOrWhiteSpace($Status) -and $Percent -lt 0) {
return
}
if ($null -eq $sync.ConsoleProgressState) {
$sync.ConsoleProgressState = [hashtable]::Synchronized(@{
LastText = ""
LastWrite = [datetime]::MinValue
LineLength = 0
LineOpen = $false
})
}
$state = $sync.ConsoleProgressState
$text = if ([string]::IsNullOrWhiteSpace($Status)) { $state.LastText } else { $Status }
if ([string]::IsNullOrWhiteSpace($text)) {
return
}
$redirected = [Console]::IsOutputRedirected
$throttleMs = if ($redirected) { 1000 } else { 150 }
$now = Get-Date
$sinceLast = ($now - $state.LastWrite).TotalMilliseconds
# Redirected output cannot be rewritten in place, so every update is its own line and the
# throttle holds even when the text changed. A job reporting per package would otherwise
# scroll a screenful.
if ($redirected) {
if ($sinceLast -lt $throttleMs) { return }
} elseif ($text -eq $state.LastText -and $sinceLast -lt $throttleMs) {
return
}
$state.LastText = $text
$state.LastWrite = $now
$prefix = if ($Percent -ge 0) { "[{0,3}%] " -f $Percent } else { "[ =] " }
$line = "$prefix$text"
if ($redirected) {
Write-Host $line -ForegroundColor DarkCyan
return
}
# Pad to the previous length so a shorter line does not leave the tail of the longer one
$padding = [Math]::Max(0, $state.LineLength - $line.Length)
Write-Host ("`r$line" + (" " * $padding)) -NoNewline -ForegroundColor DarkCyan
$state.LineLength = $line.Length
$state.LineOpen = $true
}
function Complete-WinUtilConsoleProgress {
<#
.SYNOPSIS
Ends the progress line so the next thing printed starts on its own
.DESCRIPTION
The line is rewritten in place and therefore left without a newline. Anything else
reaching the console has to close it first, or it lands on top of the progress.
#>
$state = $sync.ConsoleProgressState
if ($null -eq $state -or -not $state.LineOpen) {
return
}
Write-Host ""
$state.LineOpen = $false
$state.LineLength = 0
$state.LastText = ""
}
@@ -0,0 +1,132 @@
function Copy-WinUtilEnvironmentReportExportFile {
param(
[Parameter(Mandatory)]
[string]$SourcePath,
[Parameter(Mandatory)]
[string]$DestinationPath,
[switch]$Overwrite
)
[System.IO.File]::Copy($SourcePath, $DestinationPath, $Overwrite)
}
function Remove-WinUtilEnvironmentReportExportFile {
param(
[Parameter(Mandatory)]
[string]$Path
)
[System.IO.File]::Delete($Path)
}
function Write-WinUtilEnvironmentReportExport {
<#
.SYNOPSIS
Publishes an environment report and its optional log companion as one export.
#>
param(
[Parameter(Mandatory)]
[string]$JsonPath,
[Parameter(Mandatory)]
[string]$Json,
[Parameter(Mandatory)]
[string]$LogsPath,
[AllowNull()]
[string]$Logs,
[switch]$IncludeLogs
)
$suffix = [guid]::NewGuid().ToString("N")
$jsonTempPath = "$JsonPath.$suffix.tmp"
$logsTempPath = "$LogsPath.$suffix.tmp"
$jsonBackupPath = "$JsonPath.$suffix.bak"
$logsBackupPath = "$LogsPath.$suffix.bak"
$hadJson = [System.IO.File]::Exists($JsonPath)
$hadLogs = $IncludeLogs -and [System.IO.File]::Exists($LogsPath)
$encoding = [System.Text.UTF8Encoding]::new($false)
$retainJsonBackup = $false
$retainLogsBackup = $false
try {
# Prepare both outputs before replacing either destination. If collection or a temporary
# write fails, the previous export remains untouched.
[System.IO.File]::WriteAllText($jsonTempPath, $Json, $encoding)
if ($IncludeLogs) {
[System.IO.File]::WriteAllText($logsTempPath, $Logs, $encoding)
}
if ($hadJson) {
Copy-WinUtilEnvironmentReportExportFile -SourcePath $JsonPath -DestinationPath $jsonBackupPath
}
if ($hadLogs) {
Copy-WinUtilEnvironmentReportExportFile -SourcePath $LogsPath -DestinationPath $logsBackupPath
}
try {
Copy-WinUtilEnvironmentReportExportFile -SourcePath $jsonTempPath -DestinationPath $JsonPath -Overwrite
if ($IncludeLogs) {
Copy-WinUtilEnvironmentReportExportFile -SourcePath $logsTempPath -DestinationPath $LogsPath -Overwrite
}
} catch {
$publishError = $_
$rollbackErrors = [System.Collections.Generic.List[string]]::new()
# A two-file replacement is not atomic on Windows. Restore both destinations if the
# second publish fails so an old companion cannot appear to belong to new JSON.
try {
if ($hadJson) {
Copy-WinUtilEnvironmentReportExportFile -SourcePath $jsonBackupPath -DestinationPath $JsonPath -Overwrite
} elseif ([System.IO.File]::Exists($JsonPath)) {
Remove-WinUtilEnvironmentReportExportFile -Path $JsonPath
}
} catch {
$retainJsonBackup = $hadJson
$recoveryNote = if ($hadJson) { " Recovery copy retained at $jsonBackupPath." } else { "" }
$rollbackErrors.Add("JSON restore failed: $($_.Exception.Message).$recoveryNote")
}
if ($IncludeLogs) {
try {
if ($hadLogs) {
Copy-WinUtilEnvironmentReportExportFile -SourcePath $logsBackupPath -DestinationPath $LogsPath -Overwrite
} elseif ([System.IO.File]::Exists($LogsPath)) {
Remove-WinUtilEnvironmentReportExportFile -Path $LogsPath
}
} catch {
$retainLogsBackup = $hadLogs
$recoveryNote = if ($hadLogs) { " Recovery copy retained at $logsBackupPath." } else { "" }
$rollbackErrors.Add("logs restore failed: $($_.Exception.Message).$recoveryNote")
}
}
if ($rollbackErrors.Count -gt 0) {
$message = "Environment export publish failed: $($publishError.Exception.Message) Rollback also failed: $($rollbackErrors -join ' ')"
throw [System.IO.IOException]::new($message, $publishError.Exception)
}
throw $publishError
}
} finally {
$cleanupTargets = @(
[pscustomobject]@{ Path = $jsonTempPath; Retain = $false },
[pscustomobject]@{ Path = $logsTempPath; Retain = $false },
[pscustomobject]@{ Path = $jsonBackupPath; Retain = $retainJsonBackup },
[pscustomobject]@{ Path = $logsBackupPath; Retain = $retainLogsBackup }
)
foreach ($cleanupTarget in $cleanupTargets) {
if (-not $cleanupTarget.Retain -and [System.IO.File]::Exists($cleanupTarget.Path)) {
try {
Remove-WinUtilEnvironmentReportExportFile -Path $cleanupTarget.Path
} catch {
Write-Warning "Could not remove temporary environment-export file $($cleanupTarget.Path): $($_.Exception.Message)"
}
}
}
}
}
@@ -0,0 +1,58 @@
function Write-WinUtilErrorRecord {
<#
.SYNOPSIS
Logs a failure with enough context to act on it
.DESCRIPTION
A bare "You cannot call a method on a null-valued expression." says nothing about
where it came from. This records the message together with the exception type, the
command and line that raised it, and the script stack, under a component name.
.PARAMETER ErrorRecord
The error to report, normally $_ from a catch block.
.PARAMETER Component
Which part of WinUtil was running, for example Install or UI.
.PARAMETER Context
What was being attempted, for example the button name or the package.
#>
param(
[Parameter(Mandatory)]
$ErrorRecord,
[string]$Component = "WinUtil",
[string]$Context,
# The underlying failure was already logged and counted; retain this call's context and
# stack as diagnostic detail without adding a second headline error.
[switch]$DetailOnly
)
$headline = if ($Context) { "$Context : $($ErrorRecord.Exception.Message)" } else { $ErrorRecord.Exception.Message }
Write-WinUtilLog -Level "ERROR" -Component $Component -Message $headline -Detail:$DetailOnly
$invocation = $ErrorRecord.InvocationInfo
if ($invocation) {
$where = "$($invocation.ScriptName):$($invocation.ScriptLineNumber)"
if ([string]::IsNullOrWhiteSpace($invocation.ScriptName)) {
$where = "line $($invocation.ScriptLineNumber)"
}
Write-WinUtilLog -Level "ERROR" -Detail -Component $Component -Message " at $where in $($invocation.MyCommand): $($invocation.Line.Trim())"
}
Write-WinUtilLog -Level "ERROR" -Detail -Component $Component -Message " type $($ErrorRecord.Exception.GetType().FullName), category $($ErrorRecord.CategoryInfo.Category)"
if ($ErrorRecord.ScriptStackTrace) {
foreach ($frame in ($ErrorRecord.ScriptStackTrace -split "`r?`n")) {
if (-not [string]::IsNullOrWhiteSpace($frame)) {
Write-WinUtilLog -Level "ERROR" -Detail -Component $Component -Message " $($frame.Trim())"
}
}
}
if (-not $DetailOnly) {
Write-Host "$Component : $headline" -ForegroundColor Red
}
}
@@ -0,0 +1,54 @@
function Write-WinUtilJobBanner {
<#
.SYNOPSIS
Writes the boxed start, finish or failure line a job prints to the console
.DESCRIPTION
One place decides what a running operation looks like in the terminal, so every
workflow announces itself the same way instead of hand-drawing its own box. Called
by Start-WinUtilJob, not by job bodies.
.PARAMETER Message
The line to box, for example "Installing apps".
.PARAMETER Level
INFO for a normal banner, ERROR to colour it as a failure.
#>
param(
[Parameter(Mandatory)]
[string]$Message,
[ValidateSet("INFO", "ERROR")]
[string]$Level = "INFO"
)
# A progress line is rewritten in place and left open, so the box would be drawn on top of it
Complete-WinUtilConsoleProgress
# Wrapped, because a failure listing several packages would otherwise draw a box wider
# than the console
$width = 76
$words = $Message -split '\s+'
$lines = [System.Collections.Generic.List[string]]::new()
$current = ""
foreach ($word in $words) {
if ($current.Length -gt 0 -and ($current.Length + 1 + $word.Length) -gt $width) {
$lines.Add($current)
$current = $word
} else {
$current = if ($current.Length -eq 0) { $word } else { "$current $word" }
}
}
if ($current.Length -gt 0) { $lines.Add($current) }
$longest = ($lines | Measure-Object -Property Length -Maximum).Maximum
$border = "=" * ($longest + 6)
$colour = if ($Level -eq "ERROR") { "Red" } else { "Cyan" }
Write-Host ""
Write-Host $border -ForegroundColor $colour
foreach ($line in $lines) {
Write-Host ("-- {0}$(' ' * ($longest - $line.Length)) --" -f $line) -ForegroundColor $colour
}
Write-Host $border -ForegroundColor $colour
}
+36 -1
View File
@@ -4,6 +4,11 @@ function Write-WinUtilLog {
.SYNOPSIS
Writes a timestamped WinUtil log entry to the active session log.
.DESCRIPTION
Called from the interface thread and from every job body. When Start-Transcript owns the
active session log, entries go through the host so the transcript records them without a
competing file write. Standalone callers use a named mutex to serialize direct appends.
.PARAMETER Message
The message to write.
@@ -21,9 +26,20 @@ function Write-WinUtilLog {
[ValidateSet("INFO", "WARN", "ERROR", "DEBUG")]
[string]$Level = "INFO",
[string]$Component = "WinUtil"
[string]$Component = "WinUtil",
# Continuation of an error already counted, such as a stack frame
[switch]$Detail
)
if ($Level -eq "ERROR" -and -not $Detail -and $null -ne $sync.LoggedErrors) {
$null = $sync.LoggedErrors.Add("[$Component] $Message")
}
if ($Level -eq "ERROR" -and -not $Detail -and $global:WinUtilIsJobWorker) {
$global:WinUtilJobErrorCount++
}
try {
$logPath = $null
$transcriptPath = $null
@@ -70,10 +86,29 @@ function Write-WinUtilLog {
return
}
$mutex = [System.Threading.Mutex]::new($false, "WinUtilSessionLog")
$held = $false
try {
try {
$held = $mutex.WaitOne(2000)
} catch [System.Threading.AbandonedMutexException] {
# A thread died holding the mutex; ownership transfers to us either way
$held = $true
}
if (-not $held) {
# Writing anyway is what interleaves lines, and the wait only times out when
# contention is at its worst
Write-Host $line
return
}
Add-Content -Path $logPath -Value $line -Encoding UTF8 -ErrorAction Stop
} catch [System.IO.IOException] {
Write-Host $line
} finally {
if ($held) { $mutex.ReleaseMutex() }
$mutex.Dispose()
}
} catch {
Write-Warning "Unable to write WinUtil log entry: $($_.Exception.Message)"
+24 -55
View File
@@ -1,68 +1,37 @@
function Invoke-WPFAppxInstall {
if ($sync.ProcessRunning) {
Show-WinUtilMessage -Message "An AppX process is currently running." -Title "WinUtil" -Button "OK" -Icon "Warning"
return
}
if ($null -eq $sync.selectedAppx -or $sync.selectedAppx.Count -eq 0) {
Show-WinUtilMessage -Message "No AppX Package selected" -Title "Error" -Button "OK" -Icon "Error"
return
}
$selected = @($sync.selectedAppx)
$apps = $sync.configs.appxHashtable
Start-WinUtilJob -Name "AppX install" -Description "Installing AppX packages" -Parameters @{
Selected = @($sync.selectedAppx)
Apps = $sync.configs.appxHashtable
} -ScriptBlock {
param($Selected, $Apps)
$sync.ProcessRunning = $true
Invoke-WPFRunspace -ParameterList @(("selected", $selected), ("apps", $apps)) -ScriptBlock {
param($selected, $apps)
$totalPackages = @($Selected).Count
$results = @()
Write-WinUtilLog -Component "AppX" -Message "Starting AppX install for $totalPackages selected package(s)."
$totalPackages = @($selected).Count
$hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher
for ($index = 0; $index -lt $totalPackages; $index++) {
$app = $Apps[$Selected[$index]]
$position = $index + 1
try {
Write-WinUtilLog -Component "AppX" -Message "Starting AppX install for $totalPackages selected package(s)."
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing AppX install (0/$totalPackages)" -Percent 0
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" }
}
for ($index = 0; $index -lt $totalPackages; $index++) {
$key = $selected[$index]
$app = $apps[$key]
$position = $index + 1
$startPercent = [int](($index / $totalPackages) * 100)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installing $($app.Content) ($position/$totalPackages)" -Percent $startPercent
}
Write-Host "Installing $($app.Content)"
Install-WinUtilAPPX -Name $app.PackageId -StoreId $app.StoreId
$completedPercent = [int](($position / $totalPackages) * 100)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installed $($app.Content) ($position/$totalPackages)" -Percent $completedPercent
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) }
}
}
Write-Host "================================="
Write-Host "-- AppX Install Finished ---"
Write-Host "================================="
Write-WinUtilLog -Component "AppX" -Message "AppX install finished."
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX install finished" -Percent 100
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" }
Step-WinUtilJob -Status "Installing $($app.Content) ($position/$totalPackages)" -Percent ([int](($index / $totalPackages) * 100))
Write-Host "Installing $($app.Content)"
$appResults = @(Install-WinUtilAPPX -Name $app.PackageId -StoreId $app.StoreId)
$results += $appResults
$status = if (@($appResults | Where-Object Outcome -EQ "Failed").Count -gt 0) {
"Failed"
} elseif (@($appResults | Where-Object Outcome -EQ "Skipped").Count -gt 0) {
"Skipped"
} else {
"Installed"
}
Step-WinUtilJob -Status "$status $($app.Content) ($position/$totalPackages)" -Percent ([int](($position / $totalPackages) * 100))
}
catch {
Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX install failed: $($_.Exception.Message)"
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX install failed" -Percent 100
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" }
}
}
finally {
$sync.ProcessRunning = $false
}
Complete-WinUtilPackageRun -Action "Install" -Results $results
}
}
+45 -81
View File
@@ -1,100 +1,64 @@
function Invoke-WPFAppxRemoval {
if ($sync.ProcessRunning) {
Show-WinUtilMessage -Message "An AppX process is currently running." -Title "WinUtil" -Button "OK" -Icon "Warning"
return
}
<#
.SYNOPSIS
Removes the selected AppX packages
#>
if ($null -eq $sync.selectedAppx -or $sync.selectedAppx.Count -eq 0) {
Show-WinUtilMessage -Message "No AppX Package selected" -Title "Error" -Button "OK" -Icon "Error"
return
}
$selected = @($sync.selectedAppx)
$apps = $sync.configs.appxHashtable
Start-WinUtilJob -Name "AppX" -Description "Removing AppX packages" -Parameters @{
Selected = @($sync.selectedAppx)
Apps = $sync.configs.appxHashtable
} -ScriptBlock {
param($Selected, $Apps)
$sync.ProcessRunning = $true
Invoke-WPFRunspace -ParameterList @(("selected", $selected), ("apps", $apps)) -ScriptBlock {
param($selected, $apps)
$totalPackages = @($selected).Count
$hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher
$total = @($Selected).Count
$packageList = [System.Collections.Generic.List[string]]::new()
Write-WinUtilLog -Component "AppX" -Message "Starting AppX removal for $total selected package(s)."
try {
Write-WinUtilLog -Component "AppX" -Message "Starting AppX removal for $totalPackages selected package(s)."
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing AppX removal (0/$totalPackages)" -Percent 0
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" }
for ($index = 0; $index -lt $total; $index++) {
$key = $Selected[$index]
$app = $Apps[$key]
$position = $index + 1
Step-WinUtilJob -Status "Removing $($app.Content) ($position/$total)" -Percent ([int](($index / $total) * 90))
if ($key -eq "WPFAppxMicrosoft_XboxGamingOverlay") {
# Making sure Game Bar isn't running
Write-WinUtilLog -Component "AppX" -Message "Stopping GameBarFTServer before removing Xbox Gaming Overlay."
Stop-Process -Name GameBarFTServer -Force -Confirm:$false -ErrorAction SilentlyContinue
# This stops annoying ms-gamebar popup when launching games.
Write-WinUtilLog -Component "AppX" -Message "Disabling Game DVR capture before removing Xbox Gaming Overlay."
Set-ItemProperty -Path HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR -Name AppCaptureEnabled -Value 0
}
for ($index = 0; $index -lt $totalPackages; $index++) {
$key = $selected[$index]
$app = $apps[$key]
$position = $index + 1
$startPercent = [int](($index / $totalPackages) * 90)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removing $($app.Content) ($position/$totalPackages)" -Percent $startPercent
}
if ($key -eq "WPFAppxMicrosoft_XboxGamingOverlay") {
# Making sure Game Bar isn't running
Write-WinUtilLog -Component "AppX" -Message "Stopping GameBarFTServer before removing Xbox Gaming Overlay."
Stop-Process -Name GameBarFTServer -Force -Confirm:$false -ErrorAction SilentlyContinue
# This stops annoying ms-gamebar popup when launching games.
Write-WinUtilLog -Component "AppX" -Message "Disabling Game DVR capture before removing Xbox Gaming Overlay."
Set-ItemProperty -Path HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR -Name AppCaptureEnabled -Value 0
}
if ($key -eq "WPFAppxMicrosoft_WindowsNotepad") {
Write-WinUtilLog -Component "AppX" -Message "Stopping dllhost before removing Notepad."
Stop-Process -Name dllhost -Force -Confirm:$false -ErrorAction SilentlyContinue
}
Write-Host "Removing $($app.Content)"
Write-WinUtilLog -Component "AppX" -Message "Removing $($app.Content) ($($app.PackageId))."
Remove-WinUtilAPPX -Name $app.PackageId
$packageList.Add($app.PackageId)
if ($key -eq "WPFAppxMSTeams") {
# Uninstalls Microsoft Teams Meeting Add-in for Microsoft Office
Write-WinUtilLog -Component "AppX" -Message "Uninstalling Microsoft Teams meeting add-in package."
Get-Package -Name "Microsoft Teams*" -ErrorAction SilentlyContinue | Uninstall-Package -Force
}
$completedPercent = [int](($position / $totalPackages) * 90)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removed $($app.Content) ($position/$totalPackages)" -Percent $completedPercent
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) }
}
if ($key -eq "WPFAppxMicrosoft_WindowsNotepad") {
Write-WinUtilLog -Component "AppX" -Message "Stopping dllhost before removing Notepad."
Stop-Process -Name dllhost -Force -Confirm:$false -ErrorAction SilentlyContinue
}
if ($packageList.Count -gt 0) {
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removing provisioned AppX packages" -Percent 90
}
Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray()
Write-Host "Removing $($app.Content)"
Write-WinUtilLog -Component "AppX" -Message "Removing $($app.Content) ($($app.PackageId))."
Remove-WinUtilAPPX -Name $app.PackageId
$packageList.Add($app.PackageId)
if ($key -eq "WPFAppxMSTeams") {
# Uninstalls Microsoft Teams Meeting Add-in for Microsoft Office
Write-WinUtilLog -Component "AppX" -Message "Uninstalling Microsoft Teams meeting add-in package."
Get-Package -Name "Microsoft Teams*" -ErrorAction SilentlyContinue | Uninstall-Package -Force
}
Write-Host "================================="
Write-Host "-- AppX Removal Finished ---"
Write-Host "================================="
Write-WinUtilLog -Component "AppX" -Message "AppX removal finished."
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX removal finished" -Percent 100
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" }
}
}
catch {
Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message "AppX removal failed: $($_.Exception.Message)"
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "AppX removal failed" -Percent 100
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" }
}
}
finally {
$sync.ProcessRunning = $false
Step-WinUtilJob -Status "Removed $($app.Content) ($position/$total)" -Percent ([int](($position / $total) * 90))
}
} | Out-Null
if ($packageList.Count -gt 0) {
Step-WinUtilJob -Status "Removing provisioned AppX packages" -Percent 90
Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray()
}
}
}
+122 -10
View File
@@ -3,7 +3,14 @@ function Invoke-WPFButton {
<#
.SYNOPSIS
Invokes the function associated with the clicked button
Routes a button press, deciding whether it is interface work or a job
.DESCRIPTION
This is the one place that classifies a button. Anything that changes the system runs
through Start-WinUtilJob, which means it gets the busy flag, the progress bar, the
taskbar item, the console banner and the log lines without each workflow arranging that
for itself. Anything that only changes what the interface is showing runs here and now,
because pushing it onto a worker would just make it slower.
.PARAMETER Button
The name of the button that was clicked
@@ -12,12 +19,112 @@ function Invoke-WPFButton {
Param ([string]$Button)
# Use this to get the name of the button
#[System.Windows.MessageBox]::Show("$Button","Chris Titus Tech's Windows Utility","OK","Info")
if (-not $sync.ProcessRunning -and -not $sync.Win11ISOProcessRunning) {
Set-WinUtilTweaksProgressIndicator -Visible $false
# Clear the progress left behind by the previous job, but never while one is running
if (-not $sync.ActiveJob) {
Step-WinUtilJob -Hide
}
# Switch-driven buttons that change the system. Anything in feature.json counts too. The
# WPFPanel* entries are the exception only when they hand off to a Windows applet, which is
# what a missing function means; the two that carry one change the system themselves and
# would otherwise run their waits on the interface thread with nothing reporting them.
#
# This is a whitelist on purpose: window chrome and popup toggles also reach here, because
# every Button in $sync gets wired to this function, and they must not become jobs.
$workButtons = @(
"WPFInstallUpgrade", "WPFAddUltPerf", "WPFRemoveUltPerf",
"WPFUpdatesdefault", "WPFUpdatesdisable", "WPFUpdatessecurity",
"WPFGetInstalledAppx"
)
$featureEntry = $sync.configs.feature.$Button
# Feature installation owns its selection validation and snapshots the selection before it
# starts a job. Wrapping it here would turn its "nothing selected" return into job success.
$isConfigWork = $featureEntry -and $Button -ne "WPFFeatureInstall" -and
($Button -notlike "WPFPanel*" -or $featureEntry.function)
if ($isConfigWork -or $workButtons -contains $Button) {
$updatesDisableConfirmed = $false
if ($Button -eq "WPFUpdatesdisable") {
$updatesDisableConfirmed = Confirm-WPFUpdatesdisable
if (-not $updatesDisableConfirmed) {
return
}
}
Start-WinUtilJob -Name (Get-WinUtilButtonLabel -Button $Button) -Parameters @{
Button = $Button
UpdatesDisableConfirmed = $updatesDisableConfirmed
} -ScriptBlock {
param($Button, $UpdatesDisableConfirmed)
Invoke-WPFButtonAction -Button $Button -UpdatesDisableConfirmed:$UpdatesDisableConfirmed
}
return
}
# A handler that throws on the interface thread would otherwise reach the user as a bare
# message with no indication of which control produced it
try {
Invoke-WPFButtonAction -Button $Button
} catch {
Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Button '$Button'"
}
}
function Get-WinUtilButtonLabel {
<#
.SYNOPSIS
Returns the name a button's job should be reported under.
.DESCRIPTION
Whatever the button says is what the progress bar, the banner and the log say, so the
wording never drifts from the interface and there is no second list to maintain. Falls
back to the button name when there is nothing to read.
#>
param([string]$Button)
$content = $sync.configs.feature.$Button.Content
if (-not [string]::IsNullOrWhiteSpace($content)) {
return $content
}
$control = $sync.$Button
if ($control -and $control.Content) {
$text = if ($control.Content -is [string]) { $control.Content } else { $control.Content.Text }
if (-not [string]::IsNullOrWhiteSpace($text)) {
return ([string]$text).Trim()
}
}
$fallback = ($Button -replace '^WPF', '')
if ([string]::IsNullOrWhiteSpace($fallback)) {
return "WinUtil"
}
return $fallback
}
function Invoke-WPFButtonAction {
<#
.SYNOPSIS
Invokes the function associated with the clicked button
.DESCRIPTION
The work itself. Called by Invoke-WPFButton, either directly or from inside a job, so it
must not concern itself with progress, busy state or banners.
.PARAMETER Button
The name of the button that was clicked
#>
Param (
[string]$Button,
[switch]$UpdatesDisableConfirmed
)
# Check if button is defined in feature config with function or InvokeScript
if ($sync.configs.feature.$Button) {
$buttonConfig = $sync.configs.feature.$Button
@@ -61,7 +168,7 @@ function Invoke-WPFButton {
"WPFRemoveUltPerf" {Invoke-WPFUltimatePerformance}
"WPFundoall" {Invoke-WPFundoall}
"WPFUpdatesdefault" {Invoke-WPFUpdatesdefault}
"WPFUpdatesdisable" {Invoke-WPFUpdatesdisable}
"WPFUpdatesdisable" {Invoke-WPFUpdatesdisable -Confirmed:$UpdatesDisableConfirmed}
"WPFUpdatessecurity" {Invoke-WPFUpdatessecurity}
"WPFGetInstalled" {Invoke-WPFGetInstalled -CheckBox "winget"}
"WPFGetInstalledTweaks" {Invoke-WPFGetInstalled -CheckBox "tweaks"}
@@ -78,13 +185,18 @@ function Invoke-WPFButton {
}
"WPFGetInstalledAppx" {
$installedAppxPackages = Get-WinUtilInstalledAPPX
foreach ($appx in $sync.configs.appxHashtable.GetEnumerator()) {
if ($appx.Value.PackageId -in $installedAppxPackages) {
$sync.$($appx.Key).IsChecked = $true
Invoke-WPFUIThread -Parameters @{ Installed = $installedAppxPackages } -ScriptBlock {
param($Installed)
foreach ($appx in $sync.configs.appxHashtable.GetEnumerator()) {
if ($appx.Value.PackageId -in $Installed) {
$sync.$($appx.Key).IsChecked = $true
}
}
}
}
"WPFCloseButton" {$sync.Form.Close(); Write-Host "Bye bye!"}
# Closing may be declined, or leave a job running that outlives the window, so the
# goodbye belongs at the point the process actually ends rather than here
"WPFCloseButton" {$sync.Form.Close()}
"WPFMinimizeButton" {[Windows.SystemCommands]::MinimizeWindow($sync.Form)}
"WPFMaximizeButton" {
if ($sync.Form.WindowState -eq [Windows.WindowState]::Normal) {
@@ -6,10 +6,9 @@ function Invoke-WPFExportEnvironmentReport {
#>
try {
$includeLogs = [System.Windows.MessageBox]::Show(
$sync.Form,
"Also include the last 7 days of WinUtil logs? This can help maintainers troubleshoot an issue.",
"Environment Report", "YesNo", "Question") -eq "Yes"
$includeLogs = (Show-WinUtilMessage `
-Message "Also include the last 7 days of WinUtil logs? The companion file contains raw logs and may include local paths, commands, and error details. Review it before sharing." `
-Title "Environment Report" -Button "YesNo" -Icon "Question") -eq "Yes"
Add-Type -AssemblyName System.Windows.Forms
$dialog = [System.Windows.Forms.SaveFileDialog]::new()
@@ -25,63 +24,53 @@ function Invoke-WPFExportEnvironmentReport {
$jsonPath = $dialog.FileName
$logsPath = Get-WinUtilEnvironmentReportLogsPath -JsonPath $jsonPath
# SaveFileDialog's own overwrite prompt only covers $jsonPath. $logsPath is derived and never
# shown to the user, so a same-day re-export would otherwise silently replace it.
if ($includeLogs -and (Test-Path $logsPath)) {
$includeLogs = [System.Windows.MessageBox]::Show(
$sync.Form,
"A logs file already exists at:`n$logsPath`n`nReplace it?",
"Environment Report", "YesNo", "Warning") -eq "Yes"
}
Write-WinUtilLog -Component "EnvironmentReport" -Message "Environment report export started."
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Exporting environment report..." -Percent 0
# Registry reads across every tweak/toggle and the log bundle's file read add up to a
# couple of seconds. This handler runs directly on the WPF dispatcher thread (wired from
# the Settings menu), so the collection and write happen in a background runspace to avoid
# freezing the window.
Invoke-WPFRunspace -ParameterList @(("JsonPath", $jsonPath), ("LogsPath", $logsPath), ("IncludeLogs", $includeLogs)) -ScriptBlock {
param($JsonPath, $LogsPath, $IncludeLogs)
try {
$report = Get-WinUtilEnvironmentReport
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Writing environment report..." -Percent 60
$json = $report | ConvertTo-Json -Depth 6
[System.IO.File]::WriteAllText($JsonPath, $json, [System.Text.UTF8Encoding]::new($false))
if ($IncludeLogs) {
$logs = Get-WinUtilRecentLogs
[System.IO.File]::WriteAllText($LogsPath, $logs, [System.Text.UTF8Encoding]::new($false))
}
Write-WinUtilLog -Component "EnvironmentReport" -Message "Environment report exported to $JsonPath."
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Environment export completed" -Percent 100
Invoke-WPFUIThread { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" }
} catch {
# No MessageBox here: it would hop through Invoke-WPFUIThread/Dispatcher.Invoke from
# this background thread, the combination that can stall/freeze the UI.
# The progress label, taskbar overlay, and log line carry the failure instead.
Write-WinUtilLog -Component "EnvironmentReport" -Level "ERROR" -Message "Environment report export failed: $($_.Exception.Message)"
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Environment export failed: $($_.Exception.Message)" -Percent 100
Invoke-WPFUIThread { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" }
# SaveFileDialog's own overwrite prompt only covers $jsonPath. A derived companion from a
# previous export must neither be silently replaced nor left looking like it belongs to new JSON.
if (Test-Path -LiteralPath $logsPath) {
if (-not $includeLogs) {
Show-WinUtilMessage `
-Message "A logs file already exists at:`n$logsPath`n`nChoose another report filename or include and replace the existing logs." `
-Title "Environment Report" -Button "OK" -Icon "Warning" | Out-Null
return
}
# This is wired from a Settings-menu item, not a feature.json Button, so it never
# benefits from Invoke-WPFButton's implicit "clear the progress indicator on the next
# click" reset. Hide it explicitly instead, after a brief pause so the completed/failed
# label is actually visible.
Start-Sleep -Seconds 3
Set-WinUtilTweaksProgressIndicator -Visible $false
} | Out-Null
$replaceLogs = (Show-WinUtilMessage `
-Message "A logs file already exists at:`n$logsPath`n`nReplace it?" `
-Title "Environment Report" -Button "YesNo" -Icon "Warning") -eq "Yes"
if (-not $replaceLogs) {
return
}
}
# Registry reads across every tweak/toggle and the log bundle's file read add up to a
# couple of seconds, so the job layer keeps the Settings handler responsive and owns the
# progress, taskbar state, logging, and error reporting.
Start-WinUtilJob -Name "EnvironmentReport" -Description "Exporting environment report" -Parameters @{
JsonPath = $jsonPath
LogsPath = $logsPath
IncludeLogs = $includeLogs
} -ScriptBlock {
param($JsonPath, $LogsPath, $IncludeLogs)
Step-WinUtilJob -Status "Collecting environment report" -Percent 10
$report = Get-WinUtilEnvironmentReport
$json = $report | ConvertTo-Json -Depth 6
if ($IncludeLogs) {
Step-WinUtilJob -Status "Collecting recent WinUtil logs" -Percent 60
$logs = Get-WinUtilRecentLogs
}
Step-WinUtilJob -Status "Writing environment export" -Percent 80
Write-WinUtilEnvironmentReportExport -JsonPath $JsonPath -Json $json `
-LogsPath $LogsPath -Logs $logs -IncludeLogs:$IncludeLogs
Write-WinUtilLog -Component "EnvironmentReport" -Message "Environment report exported to $JsonPath."
Step-WinUtilJob -Status "Environment export completed" -Percent 100
}
} catch {
Write-WinUtilLog -Component "EnvironmentReport" -Level "ERROR" -Message "Environment report export failed: $($_.Exception.Message)"
[System.Windows.MessageBox]::Show(
$sync.Form,
"The environment report could not be exported. $($_.Exception.Message)",
"Environment Report",
"OK",
"None"
) | Out-Null
Show-WinUtilMessage -Message "The environment report could not be exported. $($_.Exception.Message)" `
-Title "Environment Report" -Button "OK" -Icon "None" | Out-Null
}
}
+19 -26
View File
@@ -6,35 +6,28 @@ function Invoke-WPFFeatureInstall {
#>
if($sync.ProcessRunning) {
$msg = "[Invoke-WPFFeatureInstall] Install process is currently running."
[System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
if ($null -eq $sync.selectedFeatures -or $sync.selectedFeatures.Count -eq 0) {
Show-WinUtilMessage -Message "No Windows Feature selected" -Title "WinUtil" -Button "OK" -Icon "Warning"
return
}
Invoke-WPFRunspace -ScriptBlock {
$Features = $sync.selectedFeatures
$sync.ProcessRunning = $true
if ($Features.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" }
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 - 1) / $total) * 100))
Measure-WinUtilStep -Scope "Features" -Name $feature -ScriptBlock {
Invoke-WinUtilFeatureInstall $feature
}
Step-WinUtilJob -Status "Installed $feature ($completed/$total)" -Percent ([int](($completed / $total) * 100))
}
$x = 0
$Features | ForEach-Object {
Invoke-WinUtilFeatureInstall $_
$X++
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($x/$Features.Count) }
}
$sync.ProcessRunning = $false
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" }
Write-Host "==================================="
Write-Host "--- Features are Installed ---"
Write-Host "--- A Reboot may be required ---"
Write-Host "==================================="
} | Out-Null
Write-Host "A reboot may be required."
}
}
@@ -14,7 +14,4 @@ function Invoke-WPFFixesNTPPool {
Restart-Service w32time
w32tm /resync
Write-Host "================================="
Write-Host "-- NTP Configuration Complete ---"
Write-Host "================================="
}
+3 -13
View File
@@ -30,7 +30,7 @@ function Invoke-WPFFixesUpdate {
param($Aggressive = $false)
Write-Progress -Id 0 -Activity "Repairing Windows Update" -PercentComplete 0
Set-WinUtilTaskbaritem -state "Indeterminate" -overlay "logo"
Step-WinUtilJob -State "Indeterminate"
Write-Host "Starting Windows Update Repair..."
# Wait for the first progress bar to show, otherwise the second one won't show
Start-Sleep -Milliseconds 200
@@ -192,24 +192,14 @@ function Invoke-WPFFixesUpdate {
try {
(New-Object -ComObject Microsoft.Update.AutoUpdate).DetectNow()
} catch {
Set-WinUtilTaskbaritem -state "Error" -overlay "warning"
Write-WinUtilLog -Level "ERROR" -Component "Updates" -Message "Failed to create Windows Update COM object: $_"
Write-Warning "Failed to create Windows Update COM object: $_"
}
Start-Process -NoNewWindow -FilePath "wuauclt" -ArgumentList "/resetauthorization", "/detectnow"
Write-Progress -Id 10 -ParentId 0 -Activity "Forcing discovery" -Status "Completed" -PercentComplete 100
Write-Progress -Id 0 -Activity "Repairing Windows Update" -Status "Completed" -PercentComplete 100
Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"
$ButtonType = [System.Windows.MessageBoxButton]::OK
$MessageboxTitle = "Reset Windows Update "
$Messageboxbody = ("Stock settings loaded.`n Please reboot your computer")
$MessageIcon = [System.Windows.MessageBoxImage]::Information
[System.Windows.MessageBox]::Show($Messageboxbody, $MessageboxTitle, $ButtonType, $MessageIcon)
Write-Host "==============================================="
Write-Host "-- Reset All Windows Update Settings to Stock -"
Write-Host "==============================================="
Show-WinUtilMessage -Message "Stock settings loaded.`n Please reboot your computer" -Title "Reset Windows Update" -Button "OK" -Icon "Information" | Out-Null
# Remove the progress bars
Write-Progress -Id 0 -Activity "Repairing Windows Update" -Completed
+2 -12
View File
@@ -7,17 +7,7 @@ function Invoke-WPFFixesWinget {
.DESCRIPTION
BravoNorris for the fantastic idea of a button to reinstall WinGet
#>
# Install Choco if not already present
try {
Set-WinUtilTaskbaritem -state "Indeterminate" -overlay "logo"
Write-Host "==> Starting WinGet Repair"
Install-WinUtilWinget
} catch {
Write-Error "Failed to install WinGet: $_"
Set-WinUtilTaskbaritem -state "Error" -overlay "warning"
} finally {
Write-Host "==> Finished WinGet Repair"
Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"
}
Step-WinUtilJob -Status "Repairing WinGet" -State "Indeterminate"
Install-WinUtilWinget -Force
}
+29 -66
View File
@@ -1,89 +1,52 @@
function Invoke-WPFGetInstalled {
<#
.SYNOPSIS
Invokes the function that gets the checkboxes to check in a new runspace
Detects what is already installed or applied and ticks the matching boxes
.PARAMETER checkbox
Indicates whether to check for installed 'winget' programs or applied 'tweaks'
#>
param($checkbox)
if ($sync.ProcessRunning) {
$msg = "[Invoke-WPFGetInstalled] Install process is currently running."
[System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
return
}
if (($sync.ChocoRadioButton.IsChecked -eq $false) -and ((Test-WinUtilPackageManager -winget) -eq "not-installed") -and $checkbox -eq "winget") {
return
}
$managerPreference = $sync.preferences.packagemanager
$operation = [Hashtable]::Synchronized(@{
Checkboxes = @()
Error = $null
})
$completeAction = [Action[hashtable, string]]{
param(
[hashtable]$completedOperation,
[string]$completedCheckbox
)
try {
if ($completedOperation.Error) {
Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "Get installed state failed: $($completedOperation.Error)"
Write-Warning "Unable to get installed state: $($completedOperation.Error)"
return
}
if ($completedCheckbox -eq "winget") {
foreach ($checkboxName in $completedOperation.Checkboxes) {
if (-not $sync.selectedApps.Contains($checkboxName)) {
$sync.selectedApps.Add($checkboxName)
Start-WinUtilJob -Name "Detect installed" -Description "Checking what is already installed" -Parameters @{
Checkbox = $checkbox
ManagerPreference = $sync.preferences.packagemanager
} -ScriptBlock {
param($Checkbox, $ManagerPreference)
Step-WinUtilJob -Status "Checking what is already installed" -State "Indeterminate"
$found = @()
if ($Checkbox -eq "winget") {
$source = if ($ManagerPreference -eq "Choco") { "choco" } else { $Checkbox }
$found = @(Invoke-WinUtilCurrentSystem -CheckBox $source)
} elseif ($Checkbox -eq "tweaks") {
$found = @(Invoke-WinUtilCurrentSystem -CheckBox $Checkbox)
}
Write-WinUtilLog -Component "Install" -Message "Detected $($found.Count) existing item(s) for $Checkbox."
# Ticking boxes touches the controls, so it happens on the interface thread
Invoke-WPFUIThread -Parameters @{ Checkbox = $Checkbox; Found = $found } -ScriptBlock {
param($Checkbox, $Found)
if ($Checkbox -eq "winget") {
foreach ($name in $Found) {
if (-not $sync.selectedApps.Contains($name)) {
$sync.selectedApps.Add($name)
}
}
Reset-WPFCheckBoxes -checkboxfilterpattern "WPFInstall*"
} else {
foreach ($checkboxName in $completedOperation.Checkboxes) {
$sync.$checkboxName.ischecked = $True
foreach ($name in $Found) {
$sync.$name.ischecked = $true
}
}
} finally {
$sync.ProcessRunning = $false
Set-WinUtilTaskbaritem -state "None"
}
}
$sync.ProcessRunning = $true
Set-WinUtilTaskbaritem -state "Indeterminate"
try {
Invoke-WPFRunspace -ParameterList @(
("managerPreference", $managerPreference),
("checkbox", $checkbox),
("operation", $operation),
("completeAction", $completeAction)
) -ScriptBlock {
param (
[string]$checkbox,
[string]$managerPreference,
[hashtable]$operation,
[Action[hashtable, string]]$completeAction
)
try {
if ($checkbox -eq "winget") {
switch ($managerPreference) {
"Choco" { $operation.Checkboxes = @(Invoke-WinUtilCurrentSystem -CheckBox "choco"); break }
"Winget" { $operation.Checkboxes = @(Invoke-WinUtilCurrentSystem -CheckBox $checkbox); break }
}
} elseif ($checkbox -eq "tweaks") {
$operation.Checkboxes = @(Invoke-WinUtilCurrentSystem -CheckBox $checkbox)
}
} catch {
$operation.Error = $_.Exception.Message
} finally {
$sync.Form.Dispatcher.BeginInvoke($completeAction, [object[]]@($operation, $checkbox)) | Out-Null
}
}
} catch {
$operation.Error = $_.Exception.Message
$completeAction.Invoke($operation, $checkbox)
}
}
+25 -12
View File
@@ -16,7 +16,14 @@ function Invoke-WPFImpex {
#>
param(
$type,
$Config = $null
$Config = $null,
# Add to the current selection instead of replacing it. Used when a preset has already
# set a baseline that the imported file is meant to extend.
[switch]$Merge,
# Headless runs cannot show a dialog and must fail rather than applying a partial request.
[switch]$ThrowOnError
)
function ConfigDialog {
@@ -46,9 +53,9 @@ function Invoke-WPFImpex {
if ($Config) {
$allConfs = ($sync.selectedApps + $sync.selectedTweaks + $sync.selectedToggles + $sync.selectedFeatures + $sync.selectedAppx) | ForEach-Object { [string]$_ }
if (-not $allConfs) {
[System.Windows.MessageBox]::Show(
"No settings are selected to export. Please select at least one app, tweak, toggle, feature, or AppX package before exporting.",
"Nothing to Export", "OK", "Warning")
Show-WinUtilMessage -Message (
"No settings are selected to export. Please select at least one app, tweak, toggle, feature, or AppX package before exporting."
) -Title "Nothing to Export" -Button "OK" -Icon "Warning" | Out-Null
return
}
$jsonFile = $allConfs | ConvertTo-Json
@@ -65,12 +72,14 @@ function Invoke-WPFImpex {
if ($Config) {
try {
if ($Config -match '^https?://') {
$jsonFile = (Invoke-WebRequest "$Config").Content | ConvertFrom-Json
$jsonFile = (Invoke-WebRequest "$Config" -ErrorAction Stop).Content | ConvertFrom-Json
} else {
$jsonFile = Get-Content $Config | ConvertFrom-Json
$jsonFile = Get-Content $Config -ErrorAction Stop | ConvertFrom-Json
}
} catch {
Write-Error "Failed to load the JSON file from the specified path or URL: $_"
$message = "Failed to load the JSON file from the specified path or URL: $_"
if ($ThrowOnError) { throw $message }
Write-Error $message
return
}
$isLegacyConfig = $jsonFile -is [System.Management.Automation.PSCustomObject] -and
@@ -100,16 +109,19 @@ function Invoke-WPFImpex {
}
if (-not $flattenedJson) {
[System.Windows.MessageBox]::Show(
"The selected file contains no settings to import. No changes have been made.",
"Empty Configuration", "OK", "Warning")
Show-WinUtilMessage -Message "The selected file contains no settings to import. No changes have been made." -Title "Empty Configuration" -Button "OK" -Icon "Warning" | Out-Null
return
}
# Replace unless this import is merging onto something already selected,
# which the headless path does when it is given a preset and a config
$replaceMode = @{}
if (-not $Merge) { $replaceMode["Replace"] = $true }
# Modern configs stay strict. Legacy configs can reference entries that no
# longer exist, so restore supported selections and report the retired keys.
if ($isLegacyConfig) {
$skippedSelections = @(Update-WinUtilSelections -flatJson $flattenedJson -Replace -SkipUnknown)
$skippedSelections = @(Update-WinUtilSelections -flatJson $flattenedJson @replaceMode -SkipUnknown)
if ($skippedSelections.Count -gt 0) {
$skippedSummary = $skippedSelections -join ", "
@@ -135,7 +147,7 @@ function Invoke-WPFImpex {
} else {
# Build and validate every imported selection before replacing the current
# state. This keeps a malformed config from leaving partial selections behind.
Update-WinUtilSelections -flatJson $flattenedJson -Replace
Update-WinUtilSelections -flatJson $flattenedJson @replaceMode
}
if ($sync.Form) {
@@ -143,6 +155,7 @@ function Invoke-WPFImpex {
}
}
} catch {
if ($ThrowOnError) { throw }
Write-Error "An error occurred while importing: $_"
}
}
+30 -77
View File
@@ -8,13 +8,6 @@ function Invoke-WPFInstall {
[PSObject[]]$PackagesToInstall = $($sync.selectedApps | Foreach-Object { $sync.configs.applicationsHashtable.$_ })
)
if($sync.ProcessRunning) {
$msg = "[Invoke-WPFInstall] An Install process is currently running."
Show-WinUtilMessage -Message $msg -Title "WinUtil" -Button "OK" -Icon "Warning"
return
}
if ($PackagesToInstall.Count -eq 0) {
$WarningMsg = "Please select the program(s) to install or upgrade."
Show-WinUtilMessage -Message $WarningMsg -Title "WinUtil" -Button "OK" -Icon "Warning"
@@ -26,89 +19,49 @@ function Invoke-WPFInstall {
$packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToInstall -Preference $ManagerPreference
Write-WinUtilLog -Component "Install" -Message "Install selected package(s): $($packageSummary -join '; ')"
Invoke-WPFRunspace -ParameterList @(("PackagesToInstall", $PackagesToInstall),("ManagerPreference", $ManagerPreference)) -ScriptBlock {
Start-WinUtilJob -Name "Install" -Description "Installing apps" -DisableAppList -Parameters @{
PackagesToInstall = $PackagesToInstall
ManagerPreference = $ManagerPreference
} -ScriptBlock {
param($PackagesToInstall, $ManagerPreference)
$packagesSorted = Get-WinUtilSelectedPackages -PackageList $PackagesToInstall -Preference $ManagerPreference
$packagesWinget = $packagesSorted['Winget']
$packagesChoco = $packagesSorted['Choco']
$totalPackages = @($packagesWinget).Count + @($packagesChoco).Count
$completedPackages = 0
$hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher
Write-WinUtilLog -Component "Install" -Message "Install package manager split: winget=$(@($packagesWinget).Count), choco=$(@($packagesChoco).Count)"
try {
$sync.ProcessRunning = $true
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing app install (0/$totalPackages)" -Percent 0
Invoke-WPFUIThread -ScriptBlock {
if ($null -ne $sync.ItemsControl) {
$sync.ItemsControl.IsEnabled = $false
}
}
}
$results = @()
if($packagesWinget.Count -gt 0 -and $packagesWinget -ne "0") {
Install-WinUtilWinget
foreach ($program in $packagesWinget) {
$position = $completedPackages + 1
$startPercent = [int](($completedPackages / $totalPackages) * 100)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installing $program ($position/$totalPackages)" -Percent $startPercent
}
Install-WinUtilProgramWinget -Action Install -Programs @($program)
$completedPackages++
$completedPercent = [int](($completedPackages / $totalPackages) * 100)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installed $program ($completedPackages/$totalPackages)" -Percent $completedPercent
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) }
}
}
}
if($packagesChoco.Count -gt 0) {
if ($packagesWinget.Count -gt 0 -and $packagesWinget -ne "0") {
Install-WinUtilWinget
foreach ($program in $packagesWinget) {
$position = $completedPackages + 1
$startPercent = [int](($completedPackages / $totalPackages) * 100)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installing Chocolatey packages ($position/$totalPackages)" -Percent $startPercent
}
Step-WinUtilJob -Status "Installing $program ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100))
Install-WinUtilChoco
Install-WinUtilProgramChoco -Action Install -Programs $packagesChoco
$completedPackages += @($packagesChoco).Count
$completedPercent = [int](($completedPackages / $totalPackages) * 100)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Installed Chocolatey packages ($completedPackages/$totalPackages)" -Percent $completedPercent
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) }
$results += Measure-WinUtilStep -Scope "Install" -Name "winget $program" -ScriptBlock {
Install-WinUtilProgramWinget -Action Install -Programs @($program)
}
$completedPackages++
Step-WinUtilJob -Status "Installed $program ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100))
}
Write-Host "==========================================="
Write-Host "-- Installs have finished ---"
Write-Host "==========================================="
Write-WinUtilLog -Component "Install" -Message "Install workflow completed."
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "App install finished" -Percent 100
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" }
}
} catch {
Write-Host "==========================================="
Write-Host "Error: $_"
Write-Host "==========================================="
Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "Install workflow failed: $($_.Exception.Message)"
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "App install failed" -Percent 100
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" }
}
} finally {
if ($hasUI) {
Invoke-WPFUIThread -ScriptBlock {
if ($null -ne $sync.ItemsControl) {
$sync.ItemsControl.IsEnabled = $true
}
}
}
$sync.ProcessRunning = $False
}
} | Out-Null
if ($packagesChoco.Count -gt 0) {
$position = $completedPackages + 1
Step-WinUtilJob -Status "Installing Chocolatey packages ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100))
Install-WinUtilChoco
$chocoBase = [int](($completedPackages / $totalPackages) * 100)
$chocoSpan = [int]((@($packagesChoco).Count / $totalPackages) * 100)
$results += Measure-WinUtilStep -Scope "Install" -Name "choco $($packagesChoco -join ', ')" -ScriptBlock {
Install-WinUtilProgramChoco -Action Install -Programs $packagesChoco -ProgressBase $chocoBase -ProgressSpan $chocoSpan
}
$completedPackages += @($packagesChoco).Count
Step-WinUtilJob -Status "Installed Chocolatey packages ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100))
}
Complete-WinUtilPackageRun -Action "Install" -Results $results
}
}
+35 -14
View File
@@ -1,21 +1,42 @@
function Invoke-WPFInstallUpgrade {
if ($sync.ChocoRadioButton.IsChecked) {
Install-WinUtilChoco # Ensure Chocolatey is installed before upgrading
<#
Write-Host "==========================================="
Write-Host "-- Updates started ---"
Write-Host "-- You can close this window if desired ---"
Write-Host "==========================================="
.SYNOPSIS
Upgrades every package that has an update available
Start-Process -FilePath powershell.exe -ArgumentList 'choco upgrade all -y'
} else {
Install-WinUtilWinget # Ensure WinGet is installed before upgrading
.DESCRIPTION
Runs on the worker like any other package work, so the progress bar, the taskbar item
and the log report it the same way an install does.
Write-Host "==========================================="
Write-Host "-- Updates started ---"
Write-Host "-- You can close this window if desired ---"
Write-Host "==========================================="
#>
Start-Process -FilePath powershell.exe -ArgumentList '-NoExit winget upgrade --all --include-unknown --silent --accept-source-agreements --accept-package-agreements'
# The radio button belongs to the interface thread; this body runs on a worker. The
# preference it maintains carries the same answer and is what every other workflow reads.
if ($sync.preferences.packagemanager -eq "Choco") {
Step-WinUtilJob -Status "Preparing Chocolatey" -State "Indeterminate"
Install-WinUtilChoco
Write-WinUtilLog -Component "Install" -Message "Upgrading all Chocolatey packages."
Step-WinUtilJob -Status "Upgrading all Chocolatey packages" -State "Indeterminate"
# "all" is choco's own name for every installed package, so this stays one call
$result = Measure-WinUtilStep -Scope "Install" -Name "choco upgrade all" -ScriptBlock {
Install-WinUtilProgramChoco -Action Upgrade -Programs @("all")
}
Complete-WinUtilPackageRun -Action "Upgrade" -Results @($result)
return
}
Step-WinUtilJob -Status "Preparing WinGet" -State "Indeterminate"
Install-WinUtilWinget
Write-WinUtilLog -Component "Install" -Message "Upgrading all WinGet packages."
Step-WinUtilJob -Status "Upgrading all WinGet packages" -State "Indeterminate"
# Let WinGet resolve every package against its recorded source. Parsing its localized,
# width-truncated table loses identifiers and source information.
$result = Measure-WinUtilStep -Scope "Install" -Name "winget upgrade --all" -ScriptBlock {
Install-WinUtilProgramWinget -Action Upgrade -Programs @("all")
}
Complete-WinUtilPackageRun -Action "Upgrade" -Results @($result)
}
+12 -44
View File
@@ -1,50 +1,18 @@
function Invoke-WPFOOSU {
if ($sync.ProcessRunning) {
Show-WinUtilMessage -Message "Another process is currently running." -Title "WinUtil" -Button "OK" -Icon "Warning"
return
}
Start-WinUtilJob -Name "OOSU" -Description "Downloading O&O ShutUp10++" -Parameters @{
DownloadPath = Join-Path $sync.winutildir "ooshutup10.exe"
} -ScriptBlock {
param($DownloadPath)
$downloadPath = Join-Path $sync.winutildir "ooshutup10.exe"
$sync.ProcessRunning = $true
Write-WinUtilLog -Component "OOSU" -Message "Downloading O&O ShutUp10++."
Invoke-WPFRunspace -ParameterList @(,("downloadPath", $downloadPath)) -ScriptBlock {
param($downloadPath)
$hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher
try {
Write-WinUtilLog -Component "OOSU" -Message "Downloading O&O ShutUp10++."
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Downloading O&O ShutUp10++ (0%)" -Percent 0
}
Save-WinUtilFile -Uri "https://dl5.oo-software.com/files/ooshutup10/OOSU10.exe" -DestinationPath $downloadPath -ProgressCallback {
param($percent)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Downloading O&O ShutUp10++ ($percent%)" -Percent $percent
}
}
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Launching O&O ShutUp10++" -Percent 100
}
Start-Process -FilePath $downloadPath
Write-WinUtilLog -Component "OOSU" -Message "O&O ShutUp10++ launched."
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "O&O ShutUp10++ launched" -Percent 100
}
}
catch {
Write-WinUtilLog -Level "ERROR" -Component "OOSU" -Message "O&O ShutUp10++ download failed: $($_.Exception.Message)"
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "O&O ShutUp10++ download failed" -Percent 100
}
Write-Error "Couldn't download O&O ShutUp10. Please make sure you have an active Internet connection."
}
finally {
$sync.ProcessRunning = $false
Save-WinUtilFile -Uri "https://dl5.oo-software.com/files/ooshutup10/OOSU10.exe" -DestinationPath $DownloadPath -ProgressCallback {
param($percent)
Step-WinUtilJob -Status "Downloading O&O ShutUp10++ ($percent%)" -Percent $percent
}
Step-WinUtilJob -Status "Launching O&O ShutUp10++" -Percent 100
Start-Process -FilePath $DownloadPath
Write-WinUtilLog -Component "OOSU" -Message "O&O ShutUp10++ launched."
}
}
@@ -1,4 +1,5 @@
function Invoke-WPFPanelAutologin {
Invoke-WebRequest -Uri https://live.sysinternals.com/Autologon.exe -OutFile "$winutildir\autologin.exe"
Start-Process -FilePath "$winutildir\autologin.exe" -ArgumentList /accepteula
$autologonPath = Join-Path $sync.winutildir "autologin.exe"
Invoke-WebRequest -Uri https://live.sysinternals.com/Autologon.exe -OutFile $autologonPath
Start-Process -FilePath $autologonPath -ArgumentList /accepteula
}
+56 -58
View File
@@ -31,68 +31,66 @@ function Invoke-WPFRunspace {
$ParameterList
)
if (-not ("WinUtilRunspaceCleanup" -as [type])) {
Add-Type @"
using System;
using System.Management.Automation;
public sealed class WinUtilRunspaceCleanupState
{
public PowerShell PowerShell { get; set; }
public IAsyncResult Handle { get; set; }
}
public static class WinUtilRunspaceCleanup
{
public static readonly System.Threading.WaitOrTimerCallback Callback = Cleanup;
public static void Cleanup(object state, bool timedOut)
{
var cleanupState = state as WinUtilRunspaceCleanupState;
if (cleanupState == null || cleanupState.PowerShell == null || cleanupState.Handle == null)
{
return;
$poolLock = Get-WinUtilRunspacePoolLock
[System.Threading.Monitor]::Enter($poolLock)
try {
# The lifecycle lock keeps the final shutdown check, invocation start, and registration
# atomic with pool closure. Otherwise shutdown can miss a newly started invocation.
if ($sync.ShuttingDown) {
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Refused to start background work, WinUtil is closing."
return $null
}
try
{
cleanupState.PowerShell.EndInvoke(cleanupState.Handle);
Initialize-WinUtilRunspacePool | Out-Null
# Create a PowerShell instance
$powershell = [powershell]::Create()
# Add Scriptblock and Arguments to runspace
[void]$powershell.AddScript($ScriptBlock)
[void]$powershell.AddArgument($ArgumentList)
foreach ($parameter in $ParameterList) {
# A single pair written as @(("Name", $value)) collapses to a two element array, and
# indexing it then yields the first two characters of the name
if ($parameter -is [string] -or $parameter.Count -ne 2) {
throw "ParameterList takes name and value pairs. Received '$parameter'. A single pair needs a leading comma: -ParameterList (,('Name', `$value))"
}
[void]$powershell.AddParameter($parameter[0], $parameter[1])
}
catch
{
$powershell.RunspacePool = $sync.runspace
# Track before starting so no exception after BeginInvoke can make the caller release its
# job token while work is actually running. Cleanup registration is best-effort and has a
# background-thread fallback; it must never synchronously wait on a worker from the WPF
# dispatcher because the worker's finalizer may be waiting to restore UI state.
try {
Register-WinUtilActiveShell -PowerShell $powershell
} catch {
$powershell.Dispose()
throw
}
finally
{
cleanupState.PowerShell.Dispose();
$handle = $null
try {
$handle = $powershell.BeginInvoke()
} catch {
$startError = $_
$sync.ActiveShells.Remove($powershell)
$powershell.Dispose()
throw $startError
}
try {
Register-WinUtilRunspaceCleanup -PowerShell $powershell -Handle $handle
} catch {
# The worker remains tracked and owns its job token. Treat it as scheduled so another
# system-changing job cannot start beside it; later work/shutdown can reclaim the shell.
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Could not register background cleanup: $($_.Exception.Message)"
}
return $handle
} finally {
[System.Threading.Monitor]::Exit($poolLock)
}
}
"@
}
Initialize-WinUtilRunspacePool | Out-Null
# Create a PowerShell instance
$powershell = [powershell]::Create()
# Add Scriptblock and Arguments to runspace
[void]$powershell.AddScript($ScriptBlock)
[void]$powershell.AddArgument($ArgumentList)
foreach ($parameter in $ParameterList) {
[void]$powershell.AddParameter($parameter[0], $parameter[1])
}
$powershell.RunspacePool = $sync.runspace
# Execute the RunspacePool
$handle = $powershell.BeginInvoke()
$cleanupState = [WinUtilRunspaceCleanupState]::new()
$cleanupState.PowerShell = $powershell
$cleanupState.Handle = $handle
[System.Threading.ThreadPool]::RegisterWaitForSingleObject($handle.AsyncWaitHandle, [WinUtilRunspaceCleanup]::Callback, $cleanupState, -1, $true) | Out-Null
# Return the handle
return $handle
}
+2 -9
View File
@@ -2,16 +2,9 @@ function Invoke-WPFSSHServer {
<#
.SYNOPSIS
Invokes the OpenSSH Server install in a runspace
Installs and starts the OpenSSH Server
#>
Invoke-WPFRunspace -ScriptBlock {
Invoke-WinUtilSSHServer
Write-Host "======================================="
Write-Host "-- OpenSSH Server installed! ---"
Write-Host "======================================="
}
Invoke-WinUtilSSHServer
}
+48 -5
View File
@@ -10,10 +10,53 @@ function Invoke-WPFSystemRepair {
3. DISM - Repair a corrupted Windows operating system image
#>
Start-Process cmd.exe -ArgumentList "/c chkdsk /scan /perf" -NoNewWindow -Wait
Start-Process cmd.exe -ArgumentList "/c sfc /scannow" -NoNewWindow -Wait
Start-Process cmd.exe -ArgumentList "/c dism /online /cleanup-image /restorehealth" -NoNewWindow -Wait
# SuccessCodes maps the non-zero exits a step treats as success to what they mean. The codes
# are per step because the same number means different things: 1 and 2 are ordinary chkdsk
# outcomes, while 1 from sfc is a failure, and 3010 is a repaired image from DISM only.
$steps = @(
@{
Label = "Checking the disk for errors"
Arguments = "/c chkdsk /scan /perf"
# 3 is left out: the disk could not be checked, or has errors an online scan cannot
# fix, and the steps after this one are not worth running on a disk in that state.
SuccessCodes = @{
1 = "errors were found and fixed"
2 = "cleanup was performed, or was skipped because /f was not given"
}
},
@{
Label = "Scanning protected system files"
Arguments = "/c sfc /scannow"
SuccessCodes = @{}
},
@{
Label = "Repairing the Windows image"
Arguments = "/c dism /online /cleanup-image /restorehealth"
SuccessCodes = @{
3010 = "a restart is needed for the repair to take effect"
}
}
)
Write-Host "==> Finished System Repair"
Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"
$completed = 0
foreach ($step in $steps) {
Step-WinUtilJob -Status "$($step.Label) ($($completed + 1)/$($steps.Count))" -Percent ([int](($completed / $steps.Count) * 100))
Write-WinUtilLog -Component "SystemRepair" -Message $step.Label
# Start-Process does not throw on a nonzero exit, so without this a failed chkdsk, sfc
# or dism run would still be reported as a completed repair
$process = Start-Process cmd.exe -ArgumentList $step.Arguments -NoNewWindow -Wait -PassThru
$exitCode = $process.ExitCode
if ($exitCode -ne 0) {
if ($step.SuccessCodes.ContainsKey($exitCode)) {
# Start-WinUtilJob records WarningRecord output in both the session log and the
# job result, so accepted nonzero outcomes cannot finish with a green checkmark.
Write-Warning "$($step.Label) finished: $($step.SuccessCodes[$exitCode])."
} else {
throw "$($step.Label) failed with exit code $exitCode."
}
}
$completed++
}
}
+44 -40
View File
@@ -8,55 +8,59 @@ function Invoke-WPFTab {
.PARAMETER ClickedTab
The name of the tab that was clicked
.PARAMETER Yield
Build the tab's content in slices, letting the interface answer between them. For the
tab opened at startup, where the window is already on screen and filling in gradually
reads better than holding the thread until it is complete.
#>
Param (
[Parameter(Mandatory,position=0)]
[string]$ClickedTab
[string]$ClickedTab,
[switch]$Yield
)
$tabNav = Get-WinUtilVariables | Where-Object {$psitem -like "WPFTabNav"}
$tabNumber = [int]($ClickedTab -replace "WPFTab","" -replace "BT","") - 1
$filter = Get-WinUtilVariables -Type ToggleButton | Where-Object {$psitem -like "WPFTab?BT"}
$sync.$tabNav.Items[$tabNumber].IsSelected = $true
($sync.GetEnumerator()).where{$psitem.Key -in $filter} | ForEach-Object {
if ($ClickedTab -ne $PSItem.name) {
$sync[$PSItem.Name].IsChecked = $false
Measure-WinUtilStep -Scope "Tab" -Name "$ClickedTab select" -ScriptBlock {
$filter = Get-WinUtilVariables -Type ToggleButton | Where-Object {$psitem -like "WPFTab?BT"}
$sync.WPFTabNav.Items[$tabNumber].IsSelected = $true
($sync.GetEnumerator()).where{$psitem.Key -in $filter} | ForEach-Object {
if ($ClickedTab -ne $PSItem.name) {
$sync[$PSItem.Name].IsChecked = $false
} else {
$sync["$ClickedTab"].IsChecked = $true
}
}
$sync.currentTab = $sync.WPFTabNav.Items[$tabNumber].Header
}
Measure-WinUtilStep -Scope "Tab" -Name "$ClickedTab content" -ScriptBlock {
Initialize-WinUtilTabContent -TabName $sync.currentTab -Yield:$Yield
}
Measure-WinUtilStep -Scope "Tab" -Name "$ClickedTab filter reset" -ScriptBlock {
$searchText = if ($null -ne $sync.SearchBar) { $sync.SearchBar.Text } else { "" }
if ($sync.currentTab -eq "Install") {
$selectedCategories = if ($sync.SelectedAppCategories) { $sync.SelectedAppCategories.ToArray() } else { @() }
Find-AppsByNameOrDescription -SearchString $searchText -Categories $selectedCategories
} elseif ($sync.currentTab -eq "Tweaks" -or $sync.currentTab -eq "AppX") {
Find-TweaksByNameOrDescription -SearchString $searchText
}
}
Measure-WinUtilStep -Scope "Tab" -Name "$ClickedTab search bar" -ScriptBlock {
# Show search bar in Install, Tweaks, and AppX tabs
$searchIcon = ($sync.Form.FindName("SearchBar").Parent.Children | Where-Object { $_ -is [System.Windows.Controls.TextBlock] -and $_.Text -eq [char]0xE721 })[0]
if ($tabNumber -eq 0 -or $tabNumber -eq 1 -or $tabNumber -eq 5) {
$sync.SearchBar.Visibility = "Visible"
if ($searchIcon) { $searchIcon.Visibility = "Visible" }
} else {
$sync["$ClickedTab"].IsChecked = $true
$sync.SearchBar.Visibility = "Collapsed"
if ($searchIcon) { $searchIcon.Visibility = "Collapsed" }
$sync.SearchBarClearButton.Visibility = "Collapsed"
}
}
$sync.currentTab = $sync.$tabNav.Items[$tabNumber].Header
Initialize-WinUtilTabContent -TabName $sync.currentTab
# Always reset the filter for the current tab
if ($sync.currentTab -eq "Install") {
# Reset the search text, but keep the categories the chips are still showing as selected
$selectedCategories = if ($sync.SelectedAppCategories) { $sync.SelectedAppCategories.ToArray() } else { @() }
Find-AppsByNameOrDescription -SearchString "" -Categories $selectedCategories
} elseif ($sync.currentTab -eq "Tweaks") {
# Reset Tweaks tab filter
Find-TweaksByNameOrDescription -SearchString ""
} elseif ($sync.currentTab -eq "AppX") {
# Reset AppX tab filter
Find-TweaksByNameOrDescription -SearchString ""
}
# Show search bar in Install, Tweaks, and AppX tabs
if ($tabNumber -eq 0 -or $tabNumber -eq 1 -or $tabNumber -eq 5) {
$sync.SearchBar.Visibility = "Visible"
$searchIcon = ($sync.Form.FindName("SearchBar").Parent.Children | Where-Object { $_ -is [System.Windows.Controls.TextBlock] -and $_.Text -eq [char]0xE721 })[0]
if ($searchIcon) {
$searchIcon.Visibility = "Visible"
}
} else {
$sync.SearchBar.Visibility = "Collapsed"
$searchIcon = ($sync.Form.FindName("SearchBar").Parent.Children | Where-Object { $_ -is [System.Windows.Controls.TextBlock] -and $_.Text -eq [char]0xE721 })[0]
if ($searchIcon) {
$searchIcon.Visibility = "Collapsed"
}
# Hide the clear button if it's visible
$sync.SearchBarClearButton.Visibility = "Collapsed"
}
}
@@ -0,0 +1,40 @@
function Invoke-WPFToggleSelections {
<#
.SYNOPSIS
Applies every selected toggle
.DESCRIPTION
In the window a toggle applies itself the moment it is switched, so nothing ever had to
apply a list of them. An imported configuration carries toggles the same way it carries
tweaks, and without this they would be read and then ignored.
#>
$toggles = @($sync.selectedToggles)
if ($toggles.Count -eq 0) {
Show-WinUtilMessage -Message "No toggles are selected." -Title "WinUtil" -Button "OK" -Icon "Warning" | Out-Null
return
}
Write-WinUtilLog -Component "Toggles" -Message "Toggles requested: $($toggles.Count) selected."
Start-WinUtilJob -Name "Toggles" -Description "Applying toggles" -Parameters @{
Toggles = $toggles
} -ScriptBlock {
param($Toggles)
$total = [Math]::Max(@($Toggles).Count, 1)
$completed = 0
foreach ($toggle in $Toggles) {
Step-WinUtilJob -Status "Applying $toggle ($($completed + 1)/$total)" -Percent ([int](($completed / $total) * 100))
Measure-WinUtilStep -Scope "Toggles" -Name $toggle -ScriptBlock {
Invoke-WinUtilTweaks $toggle
}
$completed++
Step-WinUtilJob -Percent ([int](($completed / $total) * 100))
}
}
}
+34 -4
View File
@@ -22,7 +22,11 @@ function Invoke-WPFUIElements {
[string]$targetGridName,
[Parameter(Mandatory, Position = 2)]
[int]$columncount
[int]$columncount,
# Let the interface answer between batches of entries. Only for content nobody is
# waiting on: a user who just clicked the tab is better served by finishing at once.
[switch]$Yield
)
$window = $sync.form
@@ -100,7 +104,7 @@ function Invoke-WPFUIElements {
$panelcount = 0
# Iterate through 'organizedData' by panel, category, and application
$count = 0
$yieldClock = [System.Diagnostics.Stopwatch]::StartNew()
foreach ($panelKey in ($organizedData.Keys | Sort-Object)) {
# Create a Border for each column
$border = New-Object Windows.Controls.Border
@@ -150,7 +154,6 @@ function Invoke-WPFUIElements {
# Now proceed with adding category labels and entries to $stackPanelContainer
foreach ($category in ($organizedData[$panelKey].Keys | Sort-Object)) {
$count++
$label = New-Object Windows.Controls.Label
$categoryCleanName = $category -replace ".*__", ""
@@ -174,7 +177,24 @@ function Invoke-WPFUIElements {
}
}}, Content
foreach ($entryInfo in $entries) {
$count++
# Constructing a panel's worth of controls in one go holds the interface for
# hundreds of milliseconds. Draining the queue on a deadline rather than every
# nth entry keeps the wait bounded whatever the entries cost to build.
if ($Yield -and $yieldClock.ElapsedMilliseconds -ge 25 -and (Test-WinUtilUIAlive)) {
$yieldClock.Restart()
$frame = New-Object Windows.Threading.DispatcherFrame
$null = $sync.Form.Dispatcher.BeginInvoke(
[Windows.Threading.DispatcherPriority]::Background,
[Windows.Threading.DispatcherOperationCallback]{
param($dispatcherFrame)
$dispatcherFrame.Continue = $false
return $null
},
$frame)
[Windows.Threading.Dispatcher]::PushFrame($frame)
}
# Create the UI elements based on the entry type
switch ($entryInfo.Type) {
"Toggle" {
@@ -555,4 +575,14 @@ function Invoke-WPFUIElements {
}
}
}
# A search event can run inside one of the yielded dispatcher frames above. Controls added
# after that event start visible, so reapply the live filter once this tab is complete.
$filterPanel = switch ($sync.currentTab) {
"Tweaks" { "tweakspanel" }
"AppX" { "appxpanel" }
}
if ($filterPanel -eq $targetGridName -and $null -ne $sync.SearchBar) {
Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text
}
}
+144 -3
View File
@@ -1,7 +1,148 @@
function Invoke-WPFUIThread ($ScriptBlock) {
if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) {
function Test-WinUtilUIAlive {
<#
.SYNOPSIS
Whether there is a window that can still be posted to
.DESCRIPTION
False for a headless run, and for a window closed over running work: a shut down
dispatcher accepts posts and discards them.
#>
return $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher -and -not $sync.Form.Dispatcher.HasShutdownStarted
}
function Test-WinUtilDispatcherShutdownException {
param([System.Exception]$Exception)
while ($null -ne $Exception) {
if ($Exception -is [System.OperationCanceledException] -or
$Exception -is [System.InvalidOperationException]) {
return $true
}
$Exception = $Exception.InnerException
}
return $false
}
function Invoke-WPFUIThread {
<#
.SYNOPSIS
Runs a scriptblock on the interface thread
.DESCRIPTION
Controls may only be touched from the thread that owns the window.
The body is handed over as text and rebuilt in the interface runspace rather than
marshalled as a scriptblock: a scriptblock keeps the session state it was written in,
and running one across runspaces costs roughly twenty times as much per command. So
values come in through Parameters rather than captured from the caller's scope.
A no-op once the window is gone, so a job outliving the interface finishes quietly.
.PARAMETER ScriptBlock
The work to run on the interface thread. Declare a param block for anything it needs.
.PARAMETER Parameters
Values passed to the body by name.
.PARAMETER Async
Post and return instead of waiting. For progress and log updates, which must never
stall the caller.
.PARAMETER PassThru
Return what the body produced. Off by default so a caller that only wanted a control
updated gets no stray output.
#>
param(
[Parameter(Mandatory, Position = 0)]
[scriptblock]$ScriptBlock,
[hashtable]$Parameters = @{},
[switch]$Async,
[switch]$PassThru
)
if (-not (Test-WinUtilUIAlive)) {
return
}
$dispatcher = $sync.Form.Dispatcher
if (-not $Async -and $dispatcher.CheckAccess()) {
$inlineResult = & $ScriptBlock @Parameters
if ($PassThru) { return $inlineResult }
return
}
$sync.form.Dispatcher.Invoke([action]$ScriptBlock)
$executor = $sync.UIDispatchDelegate
if ($null -eq $executor) {
# No interface runspace to hand the work to, so the block itself is marshalled. It has to
# receive its parameters and return what it produced, and [action] carries neither.
if ($Async) {
# The values travel as the dispatcher's argument, since this call returns before the
# block runs and anything captured from here would be gone by then
try {
$null = $dispatcher.BeginInvoke(
[Windows.Threading.DispatcherPriority]::Background,
[System.Windows.Threading.DispatcherOperationCallback]{
param($Work)
$body = $Work.Body
$arguments = $Work.Parameters
if ($arguments -and $arguments.Count -gt 0) {
$null = & $body @arguments
} else {
$null = & $body
}
return $null
},
@{ Body = $ScriptBlock; Parameters = $Parameters })
} catch {
$shutdownFailure = Test-WinUtilDispatcherShutdownException -Exception $_.Exception
if ($shutdownFailure -and -not (Test-WinUtilUIAlive)) { return }
throw
}
return
}
# Synchronous, so this frame is still alive while the block runs and can be captured from
try {
$fallbackResult = $dispatcher.Invoke([System.Func[object]]{ & $ScriptBlock @Parameters })
} catch {
$shutdownFailure = Test-WinUtilDispatcherShutdownException -Exception $_.Exception
if ($shutdownFailure -and -not (Test-WinUtilUIAlive)) { return }
throw
}
if ($PassThru) { return $fallbackResult }
return
}
$work = @{
Body = $ScriptBlock.ToString()
Parameters = $Parameters
# A synchronous caller is waiting for a required UI handoff and must see its failure.
# Fire-and-forget posts have nobody to receive an exception, so the delegate only logs it.
PropagateErrors = -not $Async
}
if ($Async) {
try {
$null = $dispatcher.BeginInvoke([Windows.Threading.DispatcherPriority]::Background, $executor, $work)
} catch {
$shutdownFailure = Test-WinUtilDispatcherShutdownException -Exception $_.Exception
if ($shutdownFailure -and -not (Test-WinUtilUIAlive)) { return }
throw
}
return
}
try {
$result = $dispatcher.Invoke($executor, @($work))
} catch {
$shutdownFailure = Test-WinUtilDispatcherShutdownException -Exception $_.Exception
if ($shutdownFailure -and -not (Test-WinUtilUIAlive)) { return }
throw
}
if ($PassThru) { return $result }
}
@@ -1,9 +1,40 @@
function Invoke-WPFUltimatePerformance ([switch]$Enable) {
<#
.SYNOPSIS
Adds or removes the Ultimate Performance power plan
#>
if ($Enable) {
powercfg /setactive (powercfg /duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61 | Select-String -Pattern '[A-Fa-f0-9-]{36}').Matches.Value
[System.Windows.MessageBox]::Show("Ultimate Power Plan installed and activated.","Success","OK","Information")
Step-WinUtilJob -Status "Adding the Ultimate Performance power plan" -State "Indeterminate"
Write-WinUtilLog -Component "Power" -Message "Duplicating and activating the Ultimate Performance power plan."
$duplicated = powercfg /duplicatescheme e9a42b02-d5df-448d-aa00-03f14749eb61
if ($LASTEXITCODE -ne 0) {
throw "powercfg could not duplicate the Ultimate Performance scheme (exit code $LASTEXITCODE)."
}
$guid = ($duplicated | Select-String -Pattern '[A-Fa-f0-9-]{36}').Matches.Value
if (-not $guid) {
throw "powercfg did not report a scheme GUID to activate."
}
powercfg /setactive $guid
if ($LASTEXITCODE -ne 0) {
throw "powercfg could not activate the Ultimate Performance scheme (exit code $LASTEXITCODE)."
}
Write-WinUtilLog -Component "Power" -Message "Ultimate Performance power plan installed and activated."
} else {
Step-WinUtilJob -Status "Restoring the default power plans" -State "Indeterminate"
Write-WinUtilLog -Component "Power" -Message "Restoring the default power schemes."
powercfg /restoredefaultschemes
[System.Windows.MessageBox]::Show("Power Plan was reset to defaults.","Success","OK","Information")
if ($LASTEXITCODE -ne 0) {
throw "powercfg could not restore the default power schemes (exit code $LASTEXITCODE)."
}
Write-WinUtilLog -Component "Power" -Message "Power plans were reset to defaults."
}
}
+36 -84
View File
@@ -9,12 +9,6 @@ function Invoke-WPFUnInstall {
Uninstalls the selected programs
#>
if($sync.ProcessRunning) {
$msg = "[Invoke-WPFUnInstall] Install process is currently running"
Show-WinUtilMessage -Message $msg -Title "WinUtil" -Button "OK" -Icon "Warning"
return
}
if ($PackagesToUninstall.Count -eq 0) {
$WarningMsg = "Please select the program(s) to uninstall"
Show-WinUtilMessage -Message $WarningMsg -Title "WinUtil" -Button "OK" -Icon "Warning"
@@ -28,100 +22,58 @@ function Invoke-WPFUnInstall {
$confirm = Show-WinUtilMessage -Message $Messageboxbody -Title $MessageboxTitle -Button $ButtonType -Icon $MessageIcon
if($confirm -eq "No") {return}
if ($confirm -ne "Yes") { return }
$ManagerPreference = $sync.preferences.packagemanager
Write-WinUtilLog -Component "Uninstall" -Message "Uninstall requested for $(@($PackagesToUninstall).Count) selected package(s) using preference: $ManagerPreference"
$packageSummary = Get-WinUtilPackageLogSummary -Packages $PackagesToUninstall -Preference $ManagerPreference
Write-WinUtilLog -Component "Uninstall" -Message "Uninstall selected package(s): $($packageSummary -join '; ')"
Invoke-WPFRunspace -ParameterList @(("PackagesToUninstall", $PackagesToUninstall),("ManagerPreference", $ManagerPreference)) -ScriptBlock {
Start-WinUtilJob -Name "Uninstall" -Description "Uninstalling apps" -DisableAppList -Parameters @{
PackagesToUninstall = $PackagesToUninstall
ManagerPreference = $ManagerPreference
} -ScriptBlock {
param($PackagesToUninstall, $ManagerPreference)
$packagesSorted = Get-WinUtilSelectedPackages -PackageList $PackagesToUninstall -Preference $ManagerPreference
$packagesWinget = $packagesSorted['Winget']
$packagesChoco = $packagesSorted['Choco']
$totalPackages = @($packagesWinget).Count + @($packagesChoco).Count
$completedPackages = 0
$hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher
Write-WinUtilLog -Component "Uninstall" -Message "Uninstall package manager split: winget=$(@($packagesWinget).Count), choco=$(@($packagesChoco).Count)"
try {
$sync.ProcessRunning = $true
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Preparing app uninstall (0/$totalPackages)" -Percent 0
Invoke-WPFUIThread -ScriptBlock {
if ($null -ne $sync.ItemsControl) {
$sync.ItemsControl.IsEnabled = $false
}
}
}
if ($packagesWinget -contains "Microsoft.Edge") {
New-Item -Path "$Env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe\MicrosoftEdge.exe" -Force
}
# Uninstall all selected programs in new window
if($packagesWinget.Count -gt 0) {
foreach ($program in $packagesWinget) {
$position = $completedPackages + 1
$startPercent = [int](($completedPackages / $totalPackages) * 100)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Uninstalling $program ($position/$totalPackages)" -Percent $startPercent
}
Install-WinUtilProgramWinget -Action Uninstall -Programs @($program)
$completedPackages++
$completedPercent = [int](($completedPackages / $totalPackages) * 100)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Uninstalled $program ($completedPackages/$totalPackages)" -Percent $completedPercent
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) }
}
}
}
if($packagesChoco.Count -gt 0) {
$position = $completedPackages + 1
$startPercent = [int](($completedPackages / $totalPackages) * 100)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Uninstalling Chocolatey packages ($position/$totalPackages)" -Percent $startPercent
}
Install-WinUtilProgramChoco -Action Uninstall -Programs $packagesChoco
$completedPackages += @($packagesChoco).Count
$completedPercent = [int](($completedPackages / $totalPackages) * 100)
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Uninstalled Chocolatey packages ($completedPackages/$totalPackages)" -Percent $completedPercent
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($completedPercent / 100) }
}
}
Write-Host "==========================================="
Write-Host "-- Uninstalls have finished ---"
Write-Host "==========================================="
Write-WinUtilLog -Component "Uninstall" -Message "Uninstall workflow completed."
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "App uninstall finished" -Percent 100
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" }
}
} catch {
Write-Host "==========================================="
Write-Host "Error: $_"
Write-Host "==========================================="
Write-WinUtilLog -Level "ERROR" -Component "Uninstall" -Message "Uninstall workflow failed: $($_.Exception.Message)"
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "App uninstall failed" -Percent 100
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" }
}
} finally {
if ($hasUI) {
Invoke-WPFUIThread -ScriptBlock {
if ($null -ne $sync.ItemsControl) {
$sync.ItemsControl.IsEnabled = $true
}
}
}
$sync.ProcessRunning = $False
if ($packagesWinget -contains "Microsoft.Edge") {
New-Item -Path "$Env:SystemRoot\SystemApps\Microsoft.MicrosoftEdge_8wekyb3d8bbwe\MicrosoftEdge.exe" -Force | Out-Null
}
$results = @()
if ($packagesWinget.Count -gt 0) {
foreach ($program in $packagesWinget) {
$position = $completedPackages + 1
Step-WinUtilJob -Status "Uninstalling $program ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100))
$results += Measure-WinUtilStep -Scope "Uninstall" -Name "winget $program" -ScriptBlock {
Install-WinUtilProgramWinget -Action Uninstall -Programs @($program)
}
$completedPackages++
Step-WinUtilJob -Status "Uninstalled $program ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100))
}
}
if ($packagesChoco.Count -gt 0) {
$position = $completedPackages + 1
Step-WinUtilJob -Status "Uninstalling Chocolatey packages ($position/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100))
$chocoBase = [int](($completedPackages / $totalPackages) * 100)
$chocoSpan = [int]((@($packagesChoco).Count / $totalPackages) * 100)
$results += Measure-WinUtilStep -Scope "Uninstall" -Name "choco $($packagesChoco -join ', ')" -ScriptBlock {
Install-WinUtilProgramChoco -Action Uninstall -Programs $packagesChoco -ProgressBase $chocoBase -ProgressSpan $chocoSpan
}
$completedPackages += @($packagesChoco).Count
Step-WinUtilJob -Status "Uninstalled Chocolatey packages ($completedPackages/$totalPackages)" -Percent ([int](($completedPackages / $totalPackages) * 100))
}
Complete-WinUtilPackageRun -Action "Uninstall" -Results $results
}
}
@@ -82,9 +82,6 @@ function Invoke-WPFUpdatesdefault {
Get-ScheduledTask -TaskPath $Task -ErrorAction SilentlyContinue | Enable-ScheduledTask -ErrorAction SilentlyContinue
}
Write-Host "===================================================" -ForegroundColor Green
Write-Host "--- Windows Update Settings Reset to Default ---" -ForegroundColor Green
Write-Host "===================================================" -ForegroundColor Green
Write-Host "Note: You must restart your system in order for all changes to take effect." -ForegroundColor Yellow
Write-WinUtilLog -Component "Updates" -Message "Windows Update default workflow completed. Restart required."
+16 -10
View File
@@ -8,14 +8,9 @@ function Invoke-WPFUpdatesdisable {
Disabling Windows Update is not recommended. This is only for advanced users who know what they are doing.
#>
$confirmation = Show-WinUtilMessage `
-Message "Disabling Windows Update stops update services, disables scheduled tasks, and clears downloaded update files. Security updates will not be installed until defaults are restored. Continue?" `
-Title "Disable Windows Update?" `
-Button "YesNo" `
-Icon "Warning"
param([switch]$Confirmed)
if ($confirmation -ne "Yes") {
Write-WinUtilLog -Component "Updates" -Message "Windows Update disable workflow cancelled."
if (-not $Confirmed -and -not (Confirm-WPFUpdatesdisable)) {
return
}
@@ -57,10 +52,21 @@ function Invoke-WPFUpdatesdisable {
Get-ScheduledTask -TaskPath $Task -ErrorAction SilentlyContinue | Disable-ScheduledTask -ErrorAction SilentlyContinue
}
Write-Host "=================================" -ForegroundColor Green
Write-Host "--- Windows Update Is Disabled ---" -ForegroundColor Green
Write-Host "=================================" -ForegroundColor Green
Write-Host "Note: You must restart your system in order for all changes to take effect." -ForegroundColor Yellow
Write-WinUtilLog -Component "Updates" -Message "Windows Update disable workflow completed. Restart required."
}
function Confirm-WPFUpdatesdisable {
$confirmation = Show-WinUtilMessage `
-Message "Disabling Windows Update stops update services, disables scheduled tasks, and clears downloaded update files. Security updates will not be installed until defaults are restored. Continue?" `
-Title "Disable Windows Update?" `
-Button "YesNo" `
-Icon "Warning"
if ($confirmation -ne "Yes") {
Write-WinUtilLog -Component "Updates" -Message "Windows Update disable workflow cancelled."
return $false
}
return $true
}
@@ -76,8 +76,5 @@ function Invoke-WPFUpdatessecurity {
Set-ItemProperty -Path $automaticUpdatePolicyPath -Name "NoAutoRebootWithLoggedOnUsers" -Type DWord -Value 1
Set-ItemProperty -Path $automaticUpdatePolicyPath -Name "AUPowerManagement" -Type DWord -Value 0
Write-Host "================================="
Write-Host "-- Updates Set to Recommended ---"
Write-Host "================================="
Write-WinUtilLog -Component "Updates" -Message "Recommended Windows Update settings workflow completed."
}
+40 -75
View File
@@ -6,94 +6,59 @@ function Invoke-WPFtweaksbutton {
#>
if($sync.ProcessRunning) {
$msg = "[Invoke-WPFtweaksbutton] Install process is currently running."
[System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
return
}
$Tweaks = $sync.selectedTweaks
$dnsProvider = $sync["WPFchangedns"].text
if (-not ($dnsProvider)) {
$dnsProvider = "Default"
}
$restorePointTweak = "WPFTweaksRestorePoint"
$restorePointSelected = $Tweaks -contains $restorePointTweak
$tweaksToRun = @($Tweaks | Where-Object { $_ -ne $restorePointTweak })
$totalSteps = [Math]::Max($Tweaks.Count, 1)
$completedSteps = 0
Write-WinUtilLog -Component "Tweaks" -Message "Tweaks requested: $(@($Tweaks).Count) selected tweak(s), DNS provider: $dnsProvider"
if ($tweaks.count -eq 0 -and $dnsProvider -eq "Default") {
$msg = "Please check the tweaks you wish to perform."
[System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
if ($Tweaks.count -eq 0 -and $dnsProvider -eq "Default") {
Show-WinUtilMessage -Message "Please check the tweaks you wish to perform." -Title "WinUtil" -Button "OK" -Icon "Warning"
return
}
if ($restorePointSelected) {
$sync.ProcessRunning = $true
Write-WinUtilLog -Component "Tweaks" -Message "Tweaks requested: $(@($Tweaks).Count) selected tweak(s), DNS provider: $dnsProvider"
if ($Tweaks.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" }
Start-WinUtilJob -Name "Tweaks" -Description "Applying tweaks" -Parameters @{
Tweaks = @($Tweaks)
DnsProvider = $dnsProvider
} -ScriptBlock {
param($Tweaks, $DnsProvider)
# The restore point has to be taken before anything else changes
$restorePointTweak = "WPFTweaksRestorePoint"
$tweaksToRun = @($Tweaks | Where-Object { $_ -ne $restorePointTweak })
$totalSteps = [Math]::Max(@($Tweaks).Count, 1)
$completedSteps = 0
if ($Tweaks -contains $restorePointTweak) {
Step-WinUtilJob -Status "Creating restore point" -Percent 0
Write-WinUtilLog -Component "Tweaks" -Message "Creating restore point before applying selected tweaks."
Measure-WinUtilStep -Scope "Tweaks" -Name $restorePointTweak -ScriptBlock {
Invoke-WinUtilTweaks $restorePointTweak
}
$completedSteps = 1
}
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Creating restore point" -Percent 0
Write-WinUtilLog -Component "Tweaks" -Message "Creating restore point before applying selected tweaks."
Invoke-WinUtilTweaks $restorePointTweak
$completedSteps = 1
if ($DnsProvider -ne "Default") {
$dnsResult = Measure-WinUtilStep -Scope "Tweaks" -Name "Set DNS to $DnsProvider" -ScriptBlock {
@(Set-WinUtilDNS -DNSProvider $DnsProvider)
}
if ($tweaksToRun.Count -eq 0 -and $dnsProvider -eq "Default") {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Tweaks finished" -Percent 100
$sync.ProcessRunning = $false
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" }
Write-Host "================================="
Write-Host "-- Tweaks are Finished ---"
Write-Host "================================="
Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed after restore point."
return
# Carrying on after the DNS change failed leaves the machine half configured, so the run
# ends here and the job layer reports it
if (@($dnsResult)[-1] -ne $true) {
throw "The DNS change to $DnsProvider failed, so the remaining tweaks were not applied."
}
}
foreach ($tweak in $tweaksToRun) {
Step-WinUtilJob -Status "Applying $tweak ($($completedSteps + 1)/$totalSteps)" -Percent ([int](($completedSteps / $totalSteps) * 100))
Measure-WinUtilStep -Scope "Tweaks" -Name $tweak -ScriptBlock {
Invoke-WinUtilTweaks $tweak
}
$completedSteps++
Step-WinUtilJob -Percent ([int](($completedSteps / $totalSteps) * 100))
}
}
# The leading "," in the ParameterList is necessary because we only provide one argument and powershell cannot be convinced that we want a nested loop with only one argument otherwise
Invoke-WPFRunspace -ParameterList @(("tweaks", $tweaksToRun), ("dnsProvider", $dnsProvider), ("completedSteps", $completedSteps), ("totalSteps", $totalSteps)) -ScriptBlock {
param($tweaks, $dnsProvider, $completedSteps, $totalSteps)
$sync.ProcessRunning = $true
if ($completedSteps -eq 0) {
if ($Tweaks.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" }
}
}
if ($dnsProvider -ne "Default") {
$dnsResult = @(Set-WinUtilDNS -DNSProvider $dnsProvider)
if ($dnsResult[-1] -ne $true) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "DNS change failed" -Percent 100
$sync.ProcessRunning = $false
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" }
Write-WinUtilLog -Level "ERROR" -Component "Tweaks" -Message "Tweaks workflow stopped because the DNS change failed."
return
}
}
for ($i = 0; $i -lt $tweaks.Count; $i++) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Applying $($tweaks[$i]) ($($completedSteps + 1)/$totalSteps)" -Percent ($completedSteps / $totalSteps * 100)
Invoke-WinUtilTweaks $tweaks[$i]
$completedSteps++
$progress = $completedSteps / $totalSteps
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value $progress }
}
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Tweaks finished" -Percent 100
$sync.ProcessRunning = $false
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" }
Write-Host "================================="
Write-Host "-- Tweaks are Finished ---"
Write-Host "================================="
Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed."
} | Out-Null
}
+14 -32
View File
@@ -6,45 +6,27 @@ function Invoke-WPFundoall {
#>
if($sync.ProcessRunning) {
$msg = "[Invoke-WPFundoall] Install process is currently running."
[System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
return
}
$tweaks = $sync.selectedTweaks
if ($tweaks.count -eq 0) {
$msg = "Please check the tweaks you wish to undo."
[System.Windows.MessageBox]::Show($msg, "Winutil", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning)
Show-WinUtilMessage -Message "Please check the tweaks you wish to undo." -Title "WinUtil" -Button "OK" -Icon "Warning"
return
}
Invoke-WPFRunspace -ArgumentList $tweaks -ScriptBlock {
param($tweaks)
Start-WinUtilJob -Name "Undo tweaks" -Description "Undoing tweaks" -Parameters @{
Tweaks = @($tweaks)
} -ScriptBlock {
param($Tweaks)
$sync.ProcessRunning = $true
Write-WinUtilLog -Component "Tweaks" -Message "Undo tweaks requested: $(@($tweaks).Count) selected tweak(s)."
if ($tweaks.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" }
$total = @($Tweaks).Count
Write-WinUtilLog -Component "Tweaks" -Message "Undo tweaks requested: $total selected tweak(s)."
for ($i = 0; $i -lt $total; $i++) {
Step-WinUtilJob -Status "Undoing $($Tweaks[$i]) ($($i + 1)/$total)" -Percent ([int](($i / $total) * 100))
Measure-WinUtilStep -Scope "Undo tweaks" -Name $Tweaks[$i] -ScriptBlock {
Invoke-WinUtiltweaks $Tweaks[$i] -undo $true
}
Step-WinUtilJob -Percent ([int]((($i + 1) / $total) * 100))
}
for ($i = 0; $i -lt $tweaks.Count; $i++) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Undoing $($tweaks[$i]) ($($i + 1)/$($tweaks.Count))" -Percent ($i / $tweaks.Count * 100)
Invoke-WinUtiltweaks $tweaks[$i] -undo $true
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -value ($i/$tweaks.Count) }
}
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Undo Tweaks Finished" -Percent 100
$sync.ProcessRunning = $false
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "None" -overlay "checkmark" }
Write-Host "=================================="
Write-Host "--- Undo Tweaks are Finished ---"
Write-Host "=================================="
Write-WinUtilLog -Component "Tweaks" -Message "Undo tweaks workflow completed."
}
}
+147 -24
View File
@@ -2,39 +2,162 @@ function Invoke-WinUtilAutoRun {
<#
.SYNOPSIS
Runs Install, Tweaks, and Features with optional UI invocation.
#>
Runs every selected action to completion without a window
function BusyWait {
Start-Sleep -Milliseconds 100
while ($sync.ProcessRunning) {
Start-Sleep -Milliseconds 100
.DESCRIPTION
The headless path. Each action is the same job the button would start, run one at a time
because the job layer allows one at a time, and waited on until the worker clears the
busy flag.
Returns a summary of what ran so the caller can decide the exit code. Nothing here
touches the interface, so it behaves the same whether a window exists or not.
.PARAMETER StopTimeoutSeconds
How long a step that timed out is given to stop before the run gives up on the rest.
.PARAMETER StepTimeoutSeconds
How long a single action may take before the run gives up on it. Without a ceiling an
installer waiting on something that will never arrive hangs the run for good.
#>
param(
[int]$StepTimeoutSeconds = 3600,
[int]$StopTimeoutSeconds = 30
)
$steps = @(
[pscustomobject]@{ Name = "Tweaks"; Count = @($sync.selectedTweaks).Count; Action = { Invoke-WPFtweaksbutton } }
[pscustomobject]@{ Name = "Toggles"; Count = @($sync.selectedToggles).Count; Action = { Invoke-WPFToggleSelections } }
[pscustomobject]@{ Name = "Features"; Count = @($sync.selectedFeatures).Count; Action = { Invoke-WPFFeatureInstall } }
[pscustomobject]@{ Name = "Applications"; Count = @($sync.selectedApps).Count; Action = { Invoke-WPFInstall } }
[pscustomobject]@{ Name = "AppX removal"; Count = @($sync.selectedAppx).Count; Action = { Invoke-WPFAppxRemoval } }
)
$planned = @($steps | Where-Object { $_.Count -gt 0 })
if ($planned.Count -eq 0) {
Write-WinUtilLog -Level "WARN" -Component "AutoRun" -Message "Nothing was selected, so there is nothing to do."
return [pscustomobject]@{ Steps = @(); Failed = 0; TimedOut = 0; Errors = 0; Warnings = 0 }
}
Write-WinUtilLog -Component "AutoRun" -Message "Headless run starting: $(($planned | ForEach-Object { "$($_.Name) ($($_.Count))" }) -join ', ')"
$results = New-Object System.Collections.ArrayList
$runClock = [System.Diagnostics.Stopwatch]::StartNew()
foreach ($step in $planned) {
$errorsBefore = if ($sync.LoggedErrors) { $sync.LoggedErrors.Count } else { 0 }
$sync.LastJobResult = $null
$stepClock = [System.Diagnostics.Stopwatch]::StartNew()
$timedOut = $false
Write-WinUtilLog -Component "AutoRun" -Message "$($step.Name): starting $($step.Count) item(s)."
try {
& $step.Action
} catch {
Write-WinUtilErrorRecord -ErrorRecord $_ -Component "AutoRun" -Context "Starting $($step.Name)"
}
# The action starts a job and returns; the run is over when the worker clears the flag
while ($sync.ActiveJob) {
if ($stepClock.Elapsed.TotalSeconds -ge $StepTimeoutSeconds) {
$timedOut = $true
Write-WinUtilLog -Level "ERROR" -Component "AutoRun" -Message "$($step.Name) did not finish within $StepTimeoutSeconds seconds, moving on."
break
}
Start-Sleep -Milliseconds 200
}
$stepClock.Stop()
$newErrors = if ($sync.LoggedErrors) { $sync.LoggedErrors.Count - $errorsBefore } else { 0 }
$newWarnings = if ($null -ne $sync.LastJobResult) { [int]$sync.LastJobResult.Warnings } else { 0 }
$null = $results.Add([pscustomobject]@{
Name = $step.Name
Items = $step.Count
Seconds = [int]$stepClock.Elapsed.TotalSeconds
Errors = $newErrors
Warnings = $newWarnings
TimedOut = $timedOut
})
$outcome = if ($timedOut) { "timed out" } elseif ($newErrors -gt 0) { "finished with $newErrors error(s)" } elseif ($newWarnings -gt 0) { "finished with $newWarnings warning(s)" } else { "finished" }
Write-WinUtilLog -Component "AutoRun" -Message "$($step.Name): $outcome after $([int]$stepClock.Elapsed.TotalSeconds)s."
if ($timedOut) {
# The worker is still on the pool. Clearing the slot on its own would let the next
# step start beside it, so two runs would be changing the machine at once.
Stop-WinUtilActiveWork -NoWait | Out-Null
$stopDeadline = (Get-Date).AddSeconds($StopTimeoutSeconds)
while ((Test-WinUtilActiveWorkRunning) -and (Get-Date) -lt $stopDeadline) {
Start-Sleep -Milliseconds 200
}
# A job that never cleared the flag would make every later step refuse to start
$null = Clear-WinUtilActiveJob
if (Test-WinUtilActiveWorkRunning) {
Write-WinUtilLog -Level "ERROR" -Component "AutoRun" -Message "$($step.Name) could not be stopped, so the remaining steps are abandoned rather than run beside it."
break
}
}
}
if ($sync.selectedTweaks.Count -gt 0) {
Write-Host "Applying tweaks..."
Invoke-WPFtweaksbutton
BusyWait
}
$runClock.Stop()
Write-WinUtilTimingSummary -Scope "AutoRun" -TotalMilliseconds $runClock.ElapsedMilliseconds
if ($sync.selectedFeatures.Count -gt 0) {
Write-Host "Applying features..."
Invoke-WPFFeatureInstall
BusyWait
return [pscustomobject]@{
Steps = @($results)
Failed = @($results | Where-Object { $_.Errors -gt 0 }).Count
TimedOut = @($results | Where-Object { $_.TimedOut }).Count
Errors = (@($results | Measure-Object -Property Errors -Sum).Sum)
Warnings = (@($results | Measure-Object -Property Warnings -Sum).Sum)
}
}
if ($sync.selectedApps.Count -gt 0) {
Write-Host "Installing applications..."
Invoke-WPFInstall
BusyWait
function Write-WinUtilAutoRunSummary {
<#
.SYNOPSIS
Prints what a headless run did and returns the exit code it should end with
#>
param(
[Parameter(Mandatory)]
$Summary
)
Write-Host ""
Write-Host "=== WinUtil headless run ===" -ForegroundColor Cyan
foreach ($step in @($Summary.Steps)) {
$state = if ($step.TimedOut) { "TIMED OUT" } elseif ($step.Errors -gt 0) { "$($step.Errors) error(s)" } elseif ($step.Warnings -gt 0) { "$($step.Warnings) warning(s)" } else { "ok" }
$colour = if ($step.TimedOut -or $step.Errors -gt 0 -or $step.Warnings -gt 0) { "Yellow" } else { "Green" }
Write-Host (" {0,-14} {1,3} item(s) {2,5}s {3}" -f $step.Name, $step.Items, $step.Seconds, $state) -ForegroundColor $colour
}
if (@($Summary.Steps).Count -eq 0) {
Write-Host " nothing was selected" -ForegroundColor Yellow
Write-Host ""
return 2
}
if ($Summary.TimedOut -gt 0 -or $Summary.Failed -gt 0) {
Write-Host ""
Write-Host "Finished with problems. See $($sync.logPath)" -ForegroundColor Yellow
Write-Host ""
return 1
}
if ($sync.selectedAppx.Count -gt 0) {
Write-Host "Removing AppX packages..."
Invoke-WPFAppxRemoval
BusyWait
if ($Summary.Warnings -gt 0) {
Write-Host ""
Write-Host "Completed with warnings. See $($sync.logPath)" -ForegroundColor Yellow
Write-Host ""
return 0
}
Write-Host "Done."
Write-Host ""
Write-Host "All steps completed. Log: $($sync.logPath)" -ForegroundColor Green
Write-Host ""
return 0
}
+132 -196
View File
@@ -6,6 +6,7 @@ BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilInstalledAPPX.ps1")
. (Join-Path $script:repoRoot "functions\private\Install-WinUtilAPPX.ps1")
. (Join-Path $script:repoRoot "functions\private\Complete-WinUtilPackageRun.ps1")
. (Join-Path $script:repoRoot "functions\private\Remove-WinUtilAPPX.ps1")
. (Join-Path $script:repoRoot "functions\private\Remove-WinUtilProvisionedAPPX.ps1")
. (Join-Path $script:repoRoot "functions\public\Invoke-WPFAppxInstall.ps1")
@@ -37,6 +38,12 @@ BeforeAll {
function Write-WinUtilLog {
param($Message, $Level, $Component)
}
function Start-WinUtilJob {
param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters, [string]$Description, [switch]$DisableAppList)
}
function Step-WinUtilJob {
param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay)
}
function Show-WinUtilMessage {
param($Message, $Title, $Button, $Icon)
}
@@ -44,10 +51,7 @@ BeforeAll {
param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock)
}
function Invoke-WPFUIThread {
param([scriptblock]$ScriptBlock)
}
function Set-WinUtilTweaksProgressIndicator {
param($Visible, $Label, $Percent)
param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async)
}
function powershell.exe { }
function Get-AppxPackage {
@@ -147,8 +151,10 @@ Describe "Install-WinUtilAPPX" {
}
It "uses a local manifest without contacting the Microsoft Store" {
Install-WinUtilAPPX -Name "Example.Package" -StoreId "9EXAMPLE1234"
$result = Install-WinUtilAPPX -Name "Example.Package" -StoreId "9EXAMPLE1234"
$result.Outcome | Should -Be "Succeeded"
$result.Manager | Should -Be "appx"
Should -Invoke -CommandName Install-WinUtilWinget -Times 0 -Exactly
Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 0 -Exactly
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
@@ -168,6 +174,20 @@ Describe "Install-WinUtilAPPX" {
}
}
It "does not return WinGet bootstrap output as a package result" {
Mock powershell.exe { $global:LASTEXITCODE = 0 }
Mock Install-WinUtilWinget { [pscustomobject]@{ Provider = "NuGet" } }
Mock Install-WinUtilProgramWinget {
[pscustomobject]@{ Package = "msstore:9EXAMPLE1234"; Outcome = "Skipped" }
}
$result = @(Install-WinUtilAPPX -Name "Example.Package" -StoreId "9EXAMPLE1234")
$result.Count | Should -Be 1
$result[0].Package | Should -Be "msstore:9EXAMPLE1234"
$result[0].Outcome | Should -Be "Skipped"
}
It "logs local registration failures before using the Microsoft Store" {
Mock powershell.exe {
$global:LASTEXITCODE = 1
@@ -220,7 +240,7 @@ Describe "Install-WinUtilAPPX" {
Describe "Get installed AppX selection" {
BeforeEach {
$script:sync = [Hashtable]::Synchronized(@{
ProcessRunning = $false
ActiveJob = $null
configs = @{
feature = @{}
appxHashtable = @{
@@ -232,9 +252,14 @@ Describe "Get installed AppX selection" {
WPFAppxMissing = [pscustomobject]@{ IsChecked = $false }
})
Mock Set-WinUtilTweaksProgressIndicator { }
Mock Get-WinUtilInstalledAPPX { @("Example.Package") }
Mock Invoke-WPFAppxInstall { }
Mock Step-WinUtilJob { }
Mock Invoke-WPFUIThread { $uiParameters = $Parameters; & $ScriptBlock @uiParameters }
Mock Start-WinUtilJob {
$jobParameters = $Parameters
& $ScriptBlock @jobParameters
}
}
AfterEach {
@@ -244,6 +269,9 @@ Describe "Get installed AppX selection" {
It "selects configured packages returned by the compatibility-safe query" {
Invoke-WPFButton -Button "WPFGetInstalledAppx"
Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Name -eq "GetInstalledAppx"
}
Should -Invoke -CommandName Get-WinUtilInstalledAPPX -Times 1 -Exactly
$script:sync.WPFAppxExample.IsChecked | Should -BeTrue
$script:sync.WPFAppxMissing.IsChecked | Should -BeFalse
@@ -343,14 +371,14 @@ Describe "Remove-WinUtilProvisionedAPPX" {
$source | Should -Match '\$removalOutput = powershell\.exe .* 2>&1'
$source | Should -Match 'Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message \$errorMessage'
$source | Should -Match '(?s)AppX provisioned package removal failed:.*throw \$errorMessage.*AppX provisioned package removal completed\.'
$source | Should -Match '(?s)AppX provisioned package removal failed:.*WinUtilErrorReported.*throw \$exception.*AppX provisioned package removal completed\.'
}
}
Describe "Invoke-WPFAppxInstall" {
BeforeEach {
$script:sync = [Hashtable]::Synchronized(@{
ProcessRunning = $false
ActiveJob = $null
Form = [pscustomobject]@{ Dispatcher = [pscustomobject]@{} }
selectedAppx = [System.Collections.Generic.List[string]]::new()
configs = @{
@@ -365,19 +393,17 @@ Describe "Invoke-WPFAppxInstall" {
})
$script:capturedAppxInstallScriptBlock = $null
$script:capturedAppxInstallParameterList = $null
$script:appxInstallProcessRunningAtLaunch = $null
Mock Show-WinUtilMessage { "OK" }
Mock Write-Host { }
Mock Write-WinUtilLog { }
Mock Set-WinUtilTweaksProgressIndicator { }
Mock Invoke-WPFUIThread { }
Mock Install-WinUtilAPPX { }
Mock Invoke-WPFRunspace {
$script:appxInstallProcessRunningAtLaunch = $script:sync.ProcessRunning
Mock Complete-WinUtilPackageRun { }
Mock Step-WinUtilJob { }
Mock Start-WinUtilJob {
$script:capturedAppxInstallScriptBlock = $ScriptBlock
$script:capturedAppxInstallParameterList = $ParameterList
[pscustomobject]@{ MockHandle = $true }
$script:capturedAppxInstallParameters = $Parameters
}
}
@@ -385,7 +411,6 @@ Describe "Invoke-WPFAppxInstall" {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedAppxInstallScriptBlock -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedAppxInstallParameterList -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name appxInstallProcessRunningAtLaunch -Scope Script -ErrorAction SilentlyContinue
}
It "prompts and exits when no AppX packages are selected for install" {
@@ -397,158 +422,86 @@ Describe "Invoke-WPFAppxInstall" {
$Button -eq "OK" -and
$Icon -eq "Error"
}
Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly
Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly
}
It "prevents overlapping AppX install operations" {
$script:sync.ProcessRunning = $true
It "queues an install job for the selected AppX packages" {
$script:sync.selectedAppx.Add("WPFAppxExample")
Invoke-WPFAppxInstall
Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter {
$Message -eq "An AppX process is currently running." -and
$Title -eq "WinUtil" -and
$Button -eq "OK" -and
$Icon -eq "Warning"
Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Name -eq "AppX install"
}
Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly
$script:capturedAppxInstallParameters.Selected[0] | Should -Be "WPFAppxExample"
}
It "installs selected AppX packages with their Store IDs" {
$script:sync.selectedAppx.Add("WPFAppxExample")
Invoke-WPFAppxInstall
$script:appxInstallProcessRunningAtLaunch | Should -BeTrue
& $script:capturedAppxInstallScriptBlock -selected @("WPFAppxExample") -apps $script:sync.configs.appxHashtable
$jobParameters = $script:capturedAppxInstallParameters
& $script:capturedAppxInstallScriptBlock @jobParameters
$script:capturedAppxInstallParameterList[0][1][0] | Should -Be "WPFAppxExample"
Should -Invoke -CommandName Install-WinUtilAPPX -Times 1 -Exactly -ParameterFilter {
$Name -eq "Example.Package" -and $StoreId -eq "9EXAMPLE1234"
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "Installing Example App (1/1)" -and $Percent -eq 0
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Installing Example App (1/1)" -and $Percent -eq 0
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "Installed Example App (1/1)" -and $Percent -eq 100
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Installed Example App (1/1)" -and $Percent -eq 100
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "AppX install finished" -and $Percent -eq 100
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*'
}
$script:sync.ProcessRunning | Should -BeFalse
}
It "shows failure feedback and clears ProcessRunning when install fails" {
It "lets an install failure surface so the job layer can handle it" {
$script:sync.selectedAppx.Add("WPFAppxExample")
Mock Install-WinUtilAPPX { throw "Install failed" }
Invoke-WPFAppxInstall
& $script:capturedAppxInstallScriptBlock -selected @("WPFAppxExample") -apps $script:sync.configs.appxHashtable
$jobParameters = $script:capturedAppxInstallParameters
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "AppX install failed" -and $Percent -eq 100
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*'
}
$script:sync.ProcessRunning | Should -BeFalse
{ & $script:capturedAppxInstallScriptBlock @jobParameters } | Should -Throw "Install failed"
}
}
Describe "Invoke-WPFAppxRemoval entrypoint" {
BeforeEach {
$script:sync = [Hashtable]::Synchronized(@{
ProcessRunning = $false
selectedAppx = [System.Collections.Generic.List[string]]::new()
configs = @{
appxHashtable = @{}
It "passes a skipped Store result to package completion and reports it as skipped" {
$script:sync.selectedAppx.Add("WPFAppxExample")
Mock Install-WinUtilAPPX {
[pscustomobject]@{
Package = "9EXAMPLE1234"
Manager = "winget"
Action = "Install"
ExitCode = -1978335107
Outcome = "Skipped"
Detail = "elevated context"
}
})
$script:capturedAppxScriptBlock = $null
$script:capturedAppxParameterList = $null
$script:appxRemovalProcessRunningAtLaunch = $null
}
Mock Show-WinUtilMessage { "OK" }
Mock Invoke-WPFRunspace {
$script:appxRemovalProcessRunningAtLaunch = $script:sync.ProcessRunning
$script:capturedAppxScriptBlock = $ScriptBlock
$script:capturedAppxParameterList = $ParameterList
[pscustomobject]@{ MockHandle = $true }
Invoke-WPFAppxInstall
$jobParameters = $script:capturedAppxInstallParameters
& $script:capturedAppxInstallScriptBlock @jobParameters
Should -Invoke Complete-WinUtilPackageRun -Times 1 -Exactly -ParameterFilter {
$Action -eq "Install" -and $Results.Count -eq 1 -and $Results[0].Outcome -eq "Skipped"
}
Should -Invoke Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Skipped Example App (1/1)" -and $Percent -eq 100
}
}
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedAppxScriptBlock -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedAppxParameterList -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name appxRemovalProcessRunningAtLaunch -Scope Script -ErrorAction SilentlyContinue
}
It "prompts and exits when no AppX packages are selected" {
Invoke-WPFAppxRemoval
Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter {
$Message -eq "No AppX Package selected" -and
$Title -eq "Error" -and
$Button -eq "OK" -and
$Icon -eq "Error"
}
Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly
}
It "prevents overlapping AppX removal operations" {
$script:sync.ProcessRunning = $true
$script:sync.selectedAppx.Add("WPFAppxExample")
Invoke-WPFAppxRemoval
Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter {
$Message -eq "An AppX process is currently running." -and
$Title -eq "WinUtil" -and
$Button -eq "OK" -and
$Icon -eq "Warning"
}
Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly
}
It "passes selected AppX keys and app metadata to the removal runspace" {
$script:sync.selectedAppx.Add("WPFAppxExample")
$script:sync.configs.appxHashtable["WPFAppxExample"] = [pscustomobject]@{
Content = "Example App"
PackageId = "Example.Package"
}
Invoke-WPFAppxRemoval
$script:sync.selectedAppx.Add("WPFAppxChangedAfterLaunch")
$script:appxRemovalProcessRunningAtLaunch | Should -BeTrue
$script:capturedAppxParameterList[0][1] | Should -HaveCount 1
Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly
Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter {
$ScriptBlock -is [scriptblock] -and
$ParameterList.Count -eq 2 -and
$ParameterList[0][0] -eq "selected" -and
$ParameterList[0][1][0] -eq "WPFAppxExample" -and
$ParameterList[1][0] -eq "apps" -and
$ParameterList[1][1]["WPFAppxExample"].PackageId -eq "Example.Package"
}
}
}
Describe "Invoke-WPFAppxRemoval runspace body" {
Describe "Invoke-WPFAppxRemoval" {
BeforeEach {
$script:sync = [Hashtable]::Synchronized(@{
ProcessRunning = $false
ActiveJob = $null
Form = [pscustomobject]@{ Dispatcher = [pscustomobject]@{} }
selectedAppx = [System.Collections.Generic.List[string]]::new()
configs = @{
appxHashtable = @{}
}
})
$script:capturedAppxScriptBlock = $null
$script:capturedAppxRemovalScriptBlock = $null
$script:capturedAppxRemovalParameters = $null
$script:apps = @{
WPFAppxExample = [pscustomobject]@{
Content = "Example App"
@@ -568,15 +521,10 @@ Describe "Invoke-WPFAppxRemoval runspace body" {
}
}
Mock Invoke-WPFRunspace {
$script:capturedAppxScriptBlock = $ScriptBlock
[pscustomobject]@{ MockHandle = $true }
}
Mock Show-WinUtilMessage { "OK" }
Mock Write-Host { }
Mock Write-WinUtilLog { }
Mock Set-WinUtilTweaksProgressIndicator { }
Mock Invoke-WPFUIThread { }
Mock Step-WinUtilJob { }
Mock Stop-Process { }
Mock Set-ItemProperty { }
Mock Get-AppxPackage {
@@ -593,21 +541,54 @@ Describe "Invoke-WPFAppxRemoval runspace body" {
}
}
Mock Uninstall-Package { }
Mock Start-WinUtilJob {
$script:capturedAppxRemovalScriptBlock = $ScriptBlock
$script:capturedAppxRemovalParameters = $Parameters
}
}
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedAppxScriptBlock -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedAppxRemovalScriptBlock -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedAppxRemovalParameters -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name apps -Scope Script -ErrorAction SilentlyContinue
}
It "removes selected AppX packages and clears ProcessRunning when finished" {
$selected = @("WPFAppxExample")
It "prompts and exits when no AppX packages are selected" {
Invoke-WPFAppxRemoval
Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter {
$Message -eq "No AppX Package selected" -and
$Title -eq "Error" -and
$Button -eq "OK" -and
$Icon -eq "Error"
}
Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly
}
It "queues a removal job with a snapshot of the selection and app metadata" {
$script:sync.selectedAppx.Add("WPFAppxExample")
$script:sync.configs.appxHashtable = $script:apps
Invoke-WPFAppxRemoval
& $script:capturedAppxScriptBlock -selected $selected -apps $script:apps
$script:sync.selectedAppx.Add("WPFAppxChangedAfterLaunch")
Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Name -eq "AppX" -and $ScriptBlock -is [scriptblock]
}
Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly
$script:capturedAppxRemovalParameters.Selected | Should -HaveCount 1
$script:capturedAppxRemovalParameters.Selected[0] | Should -Be "WPFAppxExample"
$script:capturedAppxRemovalParameters.Apps["WPFAppxExample"].PackageId | Should -Be "Example.Package"
}
It "removes the selected packages and reports progress for each one" {
$script:sync.selectedAppx.Add("WPFAppxExample")
$script:sync.configs.appxHashtable = $script:apps
Invoke-WPFAppxRemoval
$jobParameters = $script:capturedAppxRemovalParameters
& $script:capturedAppxRemovalScriptBlock @jobParameters
Should -Invoke -CommandName Get-AppxPackage -Times 1 -Exactly -ParameterFilter {
$Name -eq "*Example.Package*" -and $AllUsers -eq $true
@@ -618,93 +599,49 @@ Describe "Invoke-WPFAppxRemoval runspace body" {
Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly -ParameterFilter {
$PackageList.Count -eq 1 -and $PackageList[0] -eq "Example.Package"
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "Removing Example App (1/1)" -and $Percent -eq 0
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Removing Example App (1/1)" -and $Percent -eq 0
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "Removed Example App (1/1)" -and $Percent -eq 90
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Removed Example App (1/1)" -and $Percent -eq 90
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "AppX removal finished" -and $Percent -eq 100
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Removing provisioned AppX packages" -and $Percent -eq 90
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*'
}
$script:sync.ProcessRunning | Should -BeFalse
}
It "shows failure feedback and clears ProcessRunning when removal fails" {
$selected = @("WPFAppxExample")
It "lets a removal failure surface so the job layer can handle it" {
$script:sync.selectedAppx.Add("WPFAppxExample")
$script:sync.configs.appxHashtable = $script:apps
Mock Remove-WinUtilAPPX { throw "Removal failed" }
Invoke-WPFAppxRemoval
& $script:capturedAppxScriptBlock -selected $selected -apps $script:apps
$jobParameters = $script:capturedAppxRemovalParameters
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "AppX removal failed" -and $Percent -eq 100
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*'
}
{ & $script:capturedAppxRemovalScriptBlock @jobParameters } | Should -Throw "Removal failed"
Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 0 -Exactly
$script:sync.ProcessRunning | Should -BeFalse
}
It "removes packages without UI progress during headless autorun" {
$selected = @("WPFAppxExample")
$script:sync.Remove("Form")
$script:sync.selectedAppx.Add("WPFAppxExample")
$script:sync.configs.appxHashtable = $script:apps
Invoke-WPFAppxRemoval
& $script:capturedAppxScriptBlock -selected $selected -apps $script:apps
Should -Invoke -CommandName Remove-AppxPackage -Times 1 -Exactly
Should -Invoke -CommandName Remove-WinUtilProvisionedAPPX -Times 1 -Exactly
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 0 -Exactly
Should -Invoke -CommandName Invoke-WPFUIThread -Times 0 -Exactly
$script:sync.ProcessRunning | Should -BeFalse
}
It "shows failure feedback when provisioned package removal fails" {
$selected = @("WPFAppxExample")
It "lets a provisioned removal failure surface so the job layer can handle it" {
$script:sync.selectedAppx.Add("WPFAppxExample")
$script:sync.configs.appxHashtable = $script:apps
Mock Remove-WinUtilProvisionedAPPX { throw "Provisioned removal failed" }
Invoke-WPFAppxRemoval
& $script:capturedAppxScriptBlock -selected $selected -apps $script:apps
$jobParameters = $script:capturedAppxRemovalParameters
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "AppX removal failed" -and $Percent -eq 100
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 0 -Exactly -ParameterFilter {
$Label -eq "AppX removal finished"
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*'
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 0 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*'
}
$script:sync.ProcessRunning | Should -BeFalse
{ & $script:capturedAppxRemovalScriptBlock @jobParameters } | Should -Throw "Provisioned removal failed"
}
It "applies special cleanup for Xbox overlay, Notepad, and Teams selections" {
$selected = @(
"WPFAppxMicrosoft_XboxGamingOverlay",
"WPFAppxMicrosoft_WindowsNotepad",
"WPFAppxMSTeams"
)
foreach ($key in $selected) {
foreach ($key in @("WPFAppxMicrosoft_XboxGamingOverlay", "WPFAppxMicrosoft_WindowsNotepad", "WPFAppxMSTeams")) {
$script:sync.selectedAppx.Add($key)
}
$script:sync.configs.appxHashtable = $script:apps
Invoke-WPFAppxRemoval
& $script:capturedAppxScriptBlock -selected $selected -apps $script:apps
$jobParameters = $script:capturedAppxRemovalParameters
& $script:capturedAppxRemovalScriptBlock @jobParameters
Should -Invoke -CommandName Stop-Process -Times 1 -Exactly -ParameterFilter {
$Name -eq "GameBarFTServer" -and
@@ -736,6 +673,5 @@ Describe "Invoke-WPFAppxRemoval runspace body" {
$PackageList[1] -eq "Microsoft.WindowsNotepad" -and
$PackageList[2] -eq "MSTeams"
}
$script:sync.ProcessRunning | Should -BeFalse
}
}
+59 -26
View File
@@ -1,35 +1,68 @@
#===========================================================================
# Tests - Asset rendering
#===========================================================================
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
. (Join-Path $script:repoRoot "functions\private\Register-WinUtilRunspaceCleanup.ps1")
}
Describe "Rendered asset caching" {
It "caches rendered bitmap assets by type and size" {
$assetScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilAssets.ps1") -Raw
$assetScript | Should -Match 'RenderedAssetCache'
$assetScript | Should -Match '\$cacheKey = "\$\(\(\[string\]\$type\)\.ToLowerInvariant\(\)\)\|\$Size"'
$assetScript | Should -Match 'return \$sync\.RenderedAssetCache\[\$cacheKey\]'
$assetScript | Should -Match '\$sync\.RenderedAssetCache\[\$cacheKey\] = \$bitmapImage'
}
It "renders only the logo overlay before first paint and defers status overlays" {
$mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw
$mainScript | Should -Match 'Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$true -IncludeStatusAssets \$false'
$mainScript | Should -Match 'Dispatcher\.BeginInvoke\(\[System\.Windows\.Threading\.DispatcherPriority\]::Background, \[action\]\{ Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$false -IncludeStatusAssets \$true \}'
$mainScript | Should -Not -Match '\$sync\["checkmarkrender"\] = \(Invoke-WinUtilAssets -Type "checkmark"'
$mainScript | Should -Not -Match '\$sync\["warningrender"\] = \(Invoke-WinUtilAssets -Type "warning"'
}
It "lazily creates taskbar overlays before assigning them" {
$taskbarScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Set-WinUtilTaskbarItem.ps1") -Raw
$taskbarScript | Should -Match 'Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$true -IncludeStatusAssets \$false'
$taskbarScript | Should -Match 'Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo \$false -IncludeStatusAssets \$true'
}
Describe "Taskbar overlay rendering" {
It "loads the versioned cleanup helper beside a previous WinUtil helper type" {
if (-not ("WinUtilRunspaceCleanup" -as [type])) {
Add-Type @"
using System;
using System.Management.Automation;
public sealed class WinUtilRunspaceCleanupState
{
public PowerShell PowerShell { get; set; }
public IAsyncResult Handle { get; set; }
}
public static class WinUtilRunspaceCleanup
{
public static readonly System.Threading.WaitOrTimerCallback Callback =
delegate(object state, bool timedOut) { };
}
"@
}
$runspace = [runspacefactory]::CreateRunspace()
$runspace.Open()
$shell = [powershell]::Create()
$shell.Runspace = $runspace
[void]$shell.AddScript({ $null = 1 })
$handle = $shell.BeginInvoke()
{ Register-WinUtilRunspaceCleanup -PowerShell $shell -Handle $handle -Runspace $runspace } |
Should -Not -Throw
}
It "serializes speculative and fallback rendering through one shared lock" {
$startSource = Get-Content -Path (Join-Path $script:repoRoot "scripts\start.ps1") -Raw
$initializerSource = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTaskbarOverlayAssets.ps1") -Raw
$startSource | Should -Match '\$sync\.AssetRenderLock = \[object\]::new\(\)'
$startSource | Should -Match '\$sync\.RenderedAssetCache = \[Hashtable\]::Synchronized'
$initializerSource | Should -Match '\[System\.Threading\.Monitor\]::Enter\(\$assetRenderLock\)'
$initializerSource | Should -Match '\[System\.Threading\.Monitor\]::Exit\(\$assetRenderLock\)'
}
It "closes a dedicated runspace after its invocation completes" {
$runspace = [runspacefactory]::CreateRunspace()
$runspace.Open()
$shell = [powershell]::Create()
$shell.Runspace = $runspace
[void]$shell.AddScript({ $null = 1 })
$handle = $shell.BeginInvoke()
Register-WinUtilRunspaceCleanup -PowerShell $shell -Handle $handle -Runspace $runspace
$deadline = (Get-Date).AddSeconds(5)
while ($runspace.RunspaceStateInfo.State -eq 'Opened' -and (Get-Date) -lt $deadline) {
Start-Sleep -Milliseconds 25
}
$runspace.RunspaceStateInfo.State | Should -Not -Be 'Opened'
}
}
+114
View File
@@ -0,0 +1,114 @@
#===========================================================================
# Tests - Speculative work stands aside for the user
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$script:functionRoot = Join-Path $script:repoRoot "functions"
. (Join-Path $script:functionRoot "private\Test-WinUtilDeferBackgroundWork.ps1")
function Test-WinUtilUIAlive { $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher }
}
Describe "Test-WinUtilDeferBackgroundWork" {
BeforeEach {
$global:sync = [hashtable]::Synchronized(@{})
$sync.currentTab = "Install"
$sync.LastInputAt = [datetime]::MinValue
}
It "runs work when the user is idle and the tab is the one being drawn" {
Test-WinUtilDeferBackgroundWork -RequiresTab "Install" | Should -BeFalse
}
It "waits while the user is interacting" {
# background priority puts work behind input in the queue but does not make the piece
# already running interruptible
$sync.LastInputAt = [datetime]::Now
Test-WinUtilDeferBackgroundWork | Should -BeTrue
}
It "runs again once the interaction has passed" {
$sync.LastInputAt = [datetime]::Now.AddSeconds(-2)
Test-WinUtilDeferBackgroundWork | Should -BeFalse
}
It "waits while the work is for a tab that is not on screen" {
$sync.currentTab = "Tweaks"
Test-WinUtilDeferBackgroundWork -RequiresTab "Install" | Should -BeTrue
}
It "does not care which tab is open for work that belongs to no tab" {
$sync.currentTab = "Tweaks"
Test-WinUtilDeferBackgroundWork | Should -BeFalse
}
It "copes with the timestamp never having been set" {
$sync.Remove("LastInputAt")
{ Test-WinUtilDeferBackgroundWork } | Should -Not -Throw
Test-WinUtilDeferBackgroundWork | Should -BeFalse
}
}
Describe "Invoke-WinUtilWhenIdle" {
BeforeEach {
Add-Type -AssemblyName WindowsBase
$global:sync = [hashtable]::Synchronized(@{})
$global:sync.Form = [pscustomobject]@{ Dispatcher = [System.Windows.Threading.Dispatcher]::CurrentDispatcher }
}
It "actually runs the callback" {
# a retry that never fires abandons whatever was deferred, and nothing reports it
$global:ranCount = 0
Invoke-WinUtilWhenIdle -Callback { $global:ranCount++ } -DelayMilliseconds 20
$frame = New-Object System.Windows.Threading.DispatcherFrame
$guard = New-Object System.Windows.Threading.DispatcherTimer
$guard.Interval = [timespan]::FromMilliseconds(20)
$guard.Tag = @{ Frame = $frame; Clock = [Diagnostics.Stopwatch]::StartNew() }
$guard.Add_Tick({
param($eventSender)
$t = [System.Windows.Threading.DispatcherTimer]$eventSender
if ($global:ranCount -gt 0 -or $t.Tag.Clock.Elapsed.TotalSeconds -gt 3) {
$t.Stop()
$t.Tag.Frame.Continue = $false
}
})
$guard.Start()
[System.Windows.Threading.Dispatcher]::PushFrame($frame)
$global:ranCount | Should -Be 1
}
It "runs the callback exactly once, not on every tick" {
$global:ranCount = 0
Invoke-WinUtilWhenIdle -Callback { $global:ranCount++ } -DelayMilliseconds 20
$frame = New-Object System.Windows.Threading.DispatcherFrame
$stop = New-Object System.Windows.Threading.DispatcherTimer
$stop.Interval = [timespan]::FromMilliseconds(300)
$stop.Tag = $frame
$stop.Add_Tick({
param($eventSender)
$t = [System.Windows.Threading.DispatcherTimer]$eventSender
$t.Stop()
$t.Tag.Continue = $false
})
$stop.Start()
[System.Windows.Threading.Dispatcher]::PushFrame($frame)
$global:ranCount | Should -Be 1
}
It "does nothing when the window has gone" {
$global:sync.Form = $null
{ Invoke-WinUtilWhenIdle -Callback { throw "should not run" } } | Should -Not -Throw
}
}
+89
View File
@@ -0,0 +1,89 @@
#===========================================================================
# Tests - Work button routing
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
. (Join-Path $script:repoRoot "functions\public\Invoke-WPFUpdatesdisable.ps1")
. (Join-Path $script:repoRoot "functions\public\Invoke-WPFButton.ps1")
function Start-WinUtilJob {
param([string]$Name, [scriptblock]$ScriptBlock, [hashtable]$Parameters)
}
function Step-WinUtilJob {
param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide)
}
function Write-WinUtilLog {
param($Message, $Level, $Component)
}
function Write-WinUtilErrorRecord {
param($ErrorRecord, $Component, $Context)
}
function Invoke-WPFFeatureInstall { }
}
Describe "Work button preflight" {
BeforeEach {
$script:sync = [Hashtable]::Synchronized(@{
ActiveJob = $null
configs = @{
feature = @{
WPFFeatureInstall = [pscustomobject]@{
Content = "Install Features"
function = "Invoke-WPFFeatureInstall"
}
}
}
WPFUpdatesdisable = [pscustomobject]@{ Content = "Disable Updates" }
})
$script:capturedJob = $null
Mock Step-WinUtilJob { }
Mock Write-WinUtilLog { }
Mock Write-WinUtilErrorRecord { }
Mock Invoke-WPFFeatureInstall { }
Mock Invoke-WPFUpdatesdisable { }
Mock Confirm-WPFUpdatesdisable { $true }
Mock Start-WinUtilJob {
$script:capturedJob = [pscustomobject]@{
Name = $Name
ScriptBlock = $ScriptBlock
Parameters = $Parameters
}
}
}
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedJob -Scope Script -ErrorAction SilentlyContinue
}
It "lets feature installation validate its selection before starting a job" {
Invoke-WPFButton -Button "WPFFeatureInstall"
Should -Invoke Invoke-WPFFeatureInstall -Times 1 -Exactly
Should -Invoke Start-WinUtilJob -Times 0 -Exactly
}
It "does not start the update-disable job when confirmation is declined" {
Mock Confirm-WPFUpdatesdisable { $false }
Invoke-WPFButton -Button "WPFUpdatesdisable"
Should -Invoke Confirm-WPFUpdatesdisable -Times 1 -Exactly
Should -Invoke Start-WinUtilJob -Times 0 -Exactly
}
It "passes completed confirmation into the update-disable job" {
Invoke-WPFButton -Button "WPFUpdatesdisable"
Should -Invoke Start-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Name -eq "Disable Updates" -and $Parameters.UpdatesDisableConfirmed -eq $true
}
$jobParameters = $script:capturedJob.Parameters
& $script:capturedJob.ScriptBlock @jobParameters
Should -Invoke Invoke-WPFUpdatesdisable -Times 1 -Exactly -ParameterFilter { $Confirmed }
Should -Invoke Confirm-WPFUpdatesdisable -Times 1 -Exactly
}
}
+26
View File
@@ -43,4 +43,30 @@ Describe "Get-WinUtilRecentLogs" {
$result | Should -BeNullOrEmpty
}
It "reads an exact log directory whose name contains wildcard characters" {
$literalDirectory = Join-Path $TestDrive "logs[1]"
[System.IO.Directory]::CreateDirectory($literalDirectory) | Out-Null
$literalLog = Join-Path $literalDirectory "winutil_2026-08-20_11-00-00.log"
"literal path session log" | Set-Content -LiteralPath $literalLog
(Get-Item -LiteralPath $literalLog).LastWriteTime = (Get-Date).AddDays(-1)
$result = Get-WinUtilRecentLogs -Days 7 -LogDirectory $literalDirectory
$result | Should -Match "literal path session log"
}
It "fails the bundle when the log directory cannot be enumerated" {
Mock Get-ChildItem { throw "log directory access denied" }
{ Get-WinUtilRecentLogs -Days 7 -LogDirectory $script:logDir } |
Should -Throw "*log directory access denied*"
}
It "fails the bundle when a selected log disappears before it is read" {
Mock Get-Content { throw "log disappeared" }
{ Get-WinUtilRecentLogs -Days 7 -LogDirectory $script:logDir } |
Should -Throw "*log disappeared*"
}
}
+160 -2
View File
@@ -11,6 +11,7 @@ BeforeAll {
. (Join-Path $script:repoRoot "functions\private\Test-WinUtilPackageManager.ps1")
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilEnvironmentReport.ps1")
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilEnvironmentReportLogsPath.ps1")
. (Join-Path $script:repoRoot "functions\private\Write-WinUtilEnvironmentReportExport.ps1")
}
Describe "Get-WinUtilEnvironmentReport" {
@@ -52,6 +53,7 @@ Describe "Get-WinUtilEnvironmentReport" {
Mock Test-Path { $false }
Mock Get-ItemProperty { $null }
Mock Write-WinUtilLog { }
Mock Write-Warning { }
}
It "returns the versioned allowlisted report schema" {
@@ -110,10 +112,41 @@ Describe "Get-WinUtilEnvironmentReport" {
$report.packageManagers.winget.version | Should -Be "v1.29.290"
}
It "does not treat a failed package-manager probe message as a version" {
Mock Test-WinUtilPackageManager { "installed" } -ParameterFilter { $winget }
Mock winget {
$global:LASTEXITCODE = 7
"WinGet failed to initialize"
}
$report = Get-WinUtilEnvironmentReport
$report.packageManagers.winget.installed | Should -BeTrue
$report.packageManagers.winget.version | Should -BeNullOrEmpty
Should -Invoke Write-WinUtilLog -ParameterFilter {
$Level -eq "WARN" -and $Message -like "Failed to read WinGet version:*code 7*"
}
Should -Invoke Write-Warning -ParameterFilter {
$Message -like "Failed to read WinGet version:*code 7*"
}
}
It "surfaces an incomplete tweak-state report as a warning" {
Mock Get-WinUtilTweaksStateReport {
[pscustomobject]@{ collectionStatus = "unavailable" }
}
Get-WinUtilEnvironmentReport | Out-Null
Should -Invoke Write-Warning -ParameterFilter {
$Message -eq "Failed to collect the complete tweak state for the environment report."
}
}
It "flags a pending reboot from PendingFileRenameOperations" {
Mock Get-ItemProperty {
[pscustomobject]@{ PendingFileRenameOperations = @("a", "b") }
} -ParameterFilter { $Name -eq "PendingFileRenameOperations" }
}
$report = Get-WinUtilEnvironmentReport
@@ -123,13 +156,24 @@ Describe "Get-WinUtilEnvironmentReport" {
It "does not flag a pending reboot for a present but empty PendingFileRenameOperations value" {
Mock Get-ItemProperty {
[pscustomobject]@{ PendingFileRenameOperations = @() }
} -ParameterFilter { $Name -eq "PendingFileRenameOperations" }
}
$report = Get-WinUtilEnvironmentReport
$report.system.pendingRebootRequired | Should -BeFalse
}
It "reports pending reboot state as unknown when a registry read fails" {
Mock Get-ItemProperty { throw "registry access denied" }
$report = Get-WinUtilEnvironmentReport
$report.system.pendingRebootRequired | Should -BeNullOrEmpty
Should -Invoke Write-Warning -ParameterFilter {
$Message -like "Failed to check pending-reboot registry state:*registry access denied*"
}
}
}
Describe "Get-WinUtilEnvironmentReportLogsPath" {
@@ -144,6 +188,90 @@ Describe "Get-WinUtilEnvironmentReportLogsPath" {
}
}
Describe "Invoke-WPFExportEnvironmentReport overwrite protection" {
It "checks the derived logs path literally" {
$source = Get-Content -LiteralPath (Join-Path $script:repoRoot "functions\public\Invoke-WPFExportEnvironmentReport.ps1") -Raw
$source | Should -Match 'Test-Path\s+-LiteralPath\s+\$logsPath'
}
It "aborts rather than retaining a stale companion when logs are excluded" {
$source = Get-Content -LiteralPath (Join-Path $script:repoRoot "functions\public\Invoke-WPFExportEnvironmentReport.ps1") -Raw
$source | Should -Match 'if \(-not \$includeLogs\) \{[\s\S]*Choose another report filename[\s\S]*return'
$source | Should -Match 'if \(-not \$replaceLogs\) \{\s*return\s*\}'
}
}
Describe "Write-WinUtilEnvironmentReportExport" {
BeforeEach {
$script:rollbackJsonPath = $null
$script:removeAttempts = [System.Collections.Generic.List[string]]::new()
}
It "restores the previous JSON when publishing the log companion fails" {
$jsonPath = Join-Path $TestDrive "report.json"
$logsPath = Join-Path $TestDrive "report_logs.txt"
[System.IO.File]::WriteAllText($jsonPath, "old report")
$null = New-Item -ItemType Directory -Path $logsPath
{
Write-WinUtilEnvironmentReportExport -JsonPath $jsonPath -Json "new report" `
-LogsPath $logsPath -Logs "new logs" -IncludeLogs
} | Should -Throw
[System.IO.File]::ReadAllText($jsonPath) | Should -Be "old report"
[System.IO.Directory]::Exists($logsPath) | Should -BeTrue
@(Get-ChildItem -LiteralPath $TestDrive -File).Name | Should -Be @("report.json")
}
It "retains the recovery copy and still attempts log rollback when JSON restore fails" {
$jsonPath = Join-Path $TestDrive "rollback-report.json"
$logsPath = Join-Path $TestDrive "rollback-report_logs.txt"
[System.IO.File]::WriteAllText($jsonPath, "old report")
$null = New-Item -ItemType Directory -Path $logsPath
$script:rollbackJsonPath = $jsonPath
Mock Copy-WinUtilEnvironmentReportExportFile {
if ($SourcePath -like "*.bak" -and $DestinationPath -eq $script:rollbackJsonPath) {
throw "JSON restore blocked"
}
[System.IO.File]::Copy($SourcePath, $DestinationPath, [bool]$Overwrite)
}
{
Write-WinUtilEnvironmentReportExport -JsonPath $jsonPath -Json "new report" `
-LogsPath $logsPath -Logs "new logs" -IncludeLogs
} | Should -Throw "*Recovery copy retained*"
[System.IO.File]::ReadAllText($jsonPath) | Should -Be "new report"
$backup = @(Get-ChildItem -LiteralPath $TestDrive -Filter "rollback-report.json.*.bak" -File)
$backup.Count | Should -Be 1
[System.IO.File]::ReadAllText($backup[0].FullName) | Should -Be "old report"
[System.IO.Directory]::Exists($logsPath) | Should -BeTrue
}
It "does not let one temporary-file cleanup failure mask a successful export" {
$jsonPath = Join-Path $TestDrive "cleanup-report.json"
$logsPath = Join-Path $TestDrive "cleanup-report_logs.txt"
Mock Remove-WinUtilEnvironmentReportExportFile {
$script:removeAttempts.Add($Path)
if ($Path -like "$jsonPath.*.tmp") {
throw "temporary file is locked"
}
[System.IO.File]::Delete($Path)
}
{
Write-WinUtilEnvironmentReportExport -JsonPath $jsonPath -Json "new report" `
-LogsPath $logsPath -Logs "new logs" -IncludeLogs
} | Should -Not -Throw
[System.IO.File]::ReadAllText($jsonPath) | Should -Be "new report"
[System.IO.File]::ReadAllText($logsPath) | Should -Be "new logs"
@($script:removeAttempts | Where-Object { $_ -like "*.tmp" }).Count | Should -Be 2
}
}
Describe "Get-WinUtilTweaksStateReport" {
BeforeEach {
$script:sync = [Hashtable]::Synchronized(@{
@@ -231,6 +359,36 @@ Describe "Get-WinUtilTweaksStateReport" {
@($result.customizePreferences.PSObject.Properties).Count | Should -Be 0
}
It "reports collection as unavailable when a configured service cannot be read" {
$script:sync.configs.tweaks | Add-Member -MemberType NoteProperty -Name WPFTweaksMissingService -Value ([pscustomobject]@{
category = "Essential Tweaks"
service = @([pscustomobject]@{ Name = "MissingService"; StartupType = "Disabled" })
})
Mock Get-Service { throw "Service access failed" } -ParameterFilter { $Name -eq "MissingService" }
$result = Get-WinUtilTweaksStateReport
$result.collectionStatus | Should -Be "unavailable"
@($result.essentialTweaks.PSObject.Properties).Count | Should -Be 0
}
It "marks only the affected tweak false when a configured service is absent" {
$script:sync.configs.tweaks | Add-Member -MemberType NoteProperty -Name WPFTweaksMissingService -Value ([pscustomobject]@{
category = "Essential Tweaks"
service = @([pscustomobject]@{ Name = "MissingService"; StartupType = "Disabled" })
})
Mock Get-Service {
Write-Error -Message "Cannot find any service with service name 'MissingService'." `
-Category ObjectNotFound -ErrorId NoServiceFoundForGivenName -ErrorAction Stop
} -ParameterFilter { $Name -eq "MissingService" }
$result = Get-WinUtilTweaksStateReport
$result.collectionStatus | Should -Be "collected"
$result.essentialTweaks.WPFTweaksApplied | Should -BeTrue
$result.essentialTweaks.WPFTweaksMissingService | Should -BeFalse
}
It "lists combobox and script-only tweaks as not evaluable instead of silently dropping them" {
$result = Get-WinUtilTweaksStateReport
+39
View File
@@ -0,0 +1,39 @@
#===========================================================================
# Tests - Generated control lifetime
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$script:xamlText = Get-Content -Path (Join-Path $script:repoRoot "xaml\inputXML.xaml") -Raw
# Names that exist only once a tab has been built from config
$script:generatedNames = @(
Get-ChildItem -Path (Join-Path $script:repoRoot "config") -Filter *.json | ForEach-Object {
$config = Get-Content -Path $_.FullName -Raw | ConvertFrom-Json
$config.PSObject.Properties.Name
}
) | Sort-Object -Unique | Where-Object { $script:xamlText -notmatch "Name=`"$([regex]::Escape($_))`"" }
}
Describe "Generated controls" {
# Tab content is built after first paint, so anything referencing a generated control from
# the interface build runs while that control is still $null. That produced three silent
# "You cannot call a method on a null-valued expression" errors.
It "are not touched while the interface is being built" {
$uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw
$referenced = @(
[regex]::Matches($uiScript, '\$sync(?:\["([A-Za-z_][A-Za-z0-9_]*)"\]|\.([A-Za-z_][A-Za-z0-9_]*))') |
ForEach-Object { if ($_.Groups[1].Success) { $_.Groups[1].Value } else { $_.Groups[2].Value } }
) | Sort-Object -Unique
$tooEarly = @($referenced | Where-Object { $script:generatedNames -contains $_ })
if ($tooEarly.Count -gt 0) {
throw "Start-WinUtilUserInterface touches generated control(s) that do not exist yet: $($tooEarly -join ', '). Wire them from Initialize-WinUtilInstallTabControls or the tab that creates them."
}
}
}
+2 -31
View File
@@ -19,11 +19,12 @@ namespace Windows
}
. (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIThread.ps1")
. (Join-Path $script:repoRoot "functions\private\Set-WinUtilTweaksProgressIndicator.ps1")
function script:New-WinUtilFakeForm {
$dispatcher = New-Object psobject
$dispatcher | Add-Member -MemberType NoteProperty -Name InvokeCount -Value 0
$dispatcher | Add-Member -MemberType NoteProperty -Name HasShutdownStarted -Value $false
$dispatcher | Add-Member -MemberType ScriptMethod -Name CheckAccess -Value { return $false }
$dispatcher | Add-Member -MemberType ScriptMethod -Name Invoke -Value {
param($Action)
@@ -75,33 +76,3 @@ Describe "Invoke-WPFUIThread without a window" {
$form.Dispatcher.InvokeCount | Should -Be 1
}
}
Describe "Set-WinUtilTweaksProgressIndicator without a window" {
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
}
It "returns before resolving WPF types when the form is missing" {
$script:sync = [Hashtable]::Synchronized(@{})
{ Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Creating restore point" -Percent 0 } | Should -Not -Throw
}
It "still updates the indicator controls when a window exists" {
$controls = New-WinUtilFakeIndicatorControlSet
$script:sync = [Hashtable]::Synchronized(@{
Form = New-WinUtilFakeForm
WPFTweaksProgressBar = $controls.Bar
WPFTweaksProgressLabel = $controls.Label
WPFTweaksProgressValue = $controls.Value
})
Mock Invoke-WPFUIThread { & $ScriptBlock }
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Applying WPFTweaksTelemetry (1/17)" -Percent 42
$controls.Bar.Visibility | Should -Be ([Windows.Visibility]::Visible)
$controls.Label.Text | Should -Be "Applying WPFTweaksTelemetry (1/17)"
$controls.Value.Value | Should -Be 42
}
}
+412
View File
@@ -0,0 +1,412 @@
#===========================================================================
# Tests - Headless runs never need a window
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$script:functionRoot = Join-Path $script:repoRoot "functions"
$script:mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw
$script:startScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\start.ps1") -Raw
$startAst = [System.Management.Automation.Language.Parser]::ParseInput($script:startScript, [ref]$null, [ref]$null)
$script:fileProcessFunction = $startAst.Find({
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -eq "Test-WinUtilOwnsFileProcess"
}, $true).Extent.Text
$script:elevationCommandFunction = $startAst.Find({
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -eq "New-WinUtilElevationCommand"
}, $true).Extent.Text
. (Join-Path $script:functionRoot "private\Update-WinUtilSelections.ps1")
. (Join-Path $script:functionRoot "public\Invoke-WPFImpex.ps1")
# Stubs so the mocks below have something to replace; the real ones live in other files
function Write-WinUtilLog { param($Level, $Component, $Message, [switch]$Detail) }
function Write-WinUtilTimingSummary { param($Scope, $TotalMilliseconds) }
function Clear-WinUtilActiveJob { param([string]$Token) $sync.ActiveJobToken = $null; $sync.ActiveJob = $null; return $true }
# A timed out step stops its worker before the next one starts, so the run needs both of these
function Stop-WinUtilActiveWork { param([switch]$NoWait) }
function Test-WinUtilActiveWorkRunning { return $false }
function Write-WinUtilErrorRecord { param($ErrorRecord, $Component, $Context) }
function Invoke-WPFtweaksbutton { }
function Invoke-WPFToggleSelections { }
function Invoke-WPFFeatureInstall { }
function Invoke-WPFInstall { }
function Invoke-WPFAppxRemoval { }
}
Describe "Headless entry point" {
It "handles preset and config through one path" {
$script:mainScript | Should -Match 'if \(\$Preset -or \$Config\) \{'
}
It "returns an outcome without exiting an in-memory caller" {
$script:mainScript | Should -Match 'if \(\$env:WINUTIL_HEADLESS_CHILD -eq "1" -or \$script:WinUtilIsFileProcess\) \{[\s\S]*exit \$headlessCode[\s\S]*\$global:LASTEXITCODE = \$headlessCode[\s\S]*return \$headlessCode'
$script:mainScript | Should -Match 'Write-WinUtilAutoRunSummary'
}
It "exits with the outcome when headless mode owns a file-backed process" {
$script:startScript | Should -Match '\$script:WinUtilIsFileProcess = Test-WinUtilOwnsFileProcess'
$script:mainScript | Should -Match '-or \$script:WinUtilIsFileProcess\) \{[\s\S]*exit \$headlessCode'
}
It "recognizes explicit and positional direct file targets as process-owning" {
. ([scriptblock]::Create($script:fileProcessFunction))
$winUtilPath = Join-Path $script:repoRoot "winutil.ps1"
$wrapperPath = Join-Path $TestDrive "wrapper.ps1"
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-File", $winUtilPath) | Should -BeTrue
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-f", $winUtilPath) | Should -BeTrue
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-EP", "Bypass", "-File", $winUtilPath) | Should -BeTrue
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("powershell.exe", "-Version", "5.1", "-File", $winUtilPath) | Should -BeTrue
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("powershell.exe", "-PSConsoleFile", "profile.psc1", "-File", $winUtilPath) | Should -BeTrue
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-config", "profile.pssc", "-File", $winUtilPath) | Should -BeTrue
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-W", "Hidden", $winUtilPath) | Should -BeTrue
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-WorkingD", "C:\", $winUtilPath) | Should -BeTrue
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-Execu", "Bypass", $winUtilPath) | Should -BeTrue
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-i", $winUtilPath) | Should -BeTrue
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", $winUtilPath) | Should -BeTrue
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-NoProfile", $winUtilPath) | Should -BeTrue
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-NoExit", $winUtilPath) | Should -BeFalse
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-noe", $winUtilPath) | Should -BeFalse
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-File", $wrapperPath) | Should -BeFalse
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", $wrapperPath, "-HarnessPath", $winUtilPath) | Should -BeFalse
Test-WinUtilOwnsFileProcess -ScriptPath $winUtilPath -CommandLineArgs @("pwsh.dll", "-Command", "& '$winUtilPath'") | Should -BeFalse
}
It "fails process-owning restricted-language launches without terminating in-memory callers" {
$script:startScript | Should -Match 'LanguageMode -ne ''FullLanguage''[\s\S]*\$global:LASTEXITCODE = 1[\s\S]*if \(\$env:WINUTIL_HEADLESS_CHILD -eq "1" -or \$script:WinUtilIsFileProcess\) \{ exit 1 \}[\s\S]*return 1'
}
It "passes hostile elevation parameter values as data instead of executable code" {
. ([scriptblock]::Create($script:elevationCommandFunction))
$targetDirectory = Join-Path $TestDrive "O'Brien"
$targetScript = Join-Path $targetDirectory "target.ps1"
$markerPath = Join-Path $TestDrive "injected.txt"
New-Item -ItemType Directory -Path $targetDirectory | Out-Null
'param([string]$Config) Write-Output $Config' | Set-Content -LiteralPath $targetScript
$hostileConfig = "'; Set-Content -LiteralPath '$markerPath' -Value injected; #"
$encodedCommand = New-WinUtilElevationCommand -ScriptPath $targetScript -Parameters @{ Config = $hostileConfig } -Headless
$bootstrap = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encodedCommand))
$previousChildFlag = $env:WINUTIL_HEADLESS_CHILD
try {
$output = & ([scriptblock]::Create($bootstrap))
} finally {
$env:WINUTIL_HEADLESS_CHILD = $previousChildFlag
}
$bootstrap | Should -Not -Match ([regex]::Escape($hostileConfig))
$output | Should -Be $hostileConfig
Test-Path -LiteralPath $markerPath | Should -BeFalse
}
It "propagates direct -File codes without terminating a file-backed wrapper" {
$harnessPath = Join-Path $TestDrive "headless-exit-harness.ps1"
$wrapperPath = Join-Path $TestDrive "headless-exit-wrapper.ps1"
@"
param([int]`$Code)
$($script:fileProcessFunction)
`$ownsProcess = Test-WinUtilOwnsFileProcess -ScriptPath `$PSCommandPath -CommandLineArgs ([Environment]::GetCommandLineArgs())
if (`$ownsProcess) { exit `$Code }
return `$Code
"@ | Set-Content -LiteralPath $harnessPath
@'
param([string]$HarnessPath)
& $HarnessPath -Code 7 | Out-Null
exit 23
'@ | Set-Content -LiteralPath $wrapperPath
$powerShellHosts = @(Get-Command pwsh.exe, powershell.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -Unique)
$powerShellHosts.Count | Should -BeGreaterThan 0
foreach ($powerShellHost in $powerShellHosts) {
& $powerShellHost -ExecutionPolicy Bypass -NoProfile -File $harnessPath -Code 0 | Out-Null
$LASTEXITCODE | Should -Be 0 -Because "$powerShellHost should propagate success"
& $powerShellHost -ExecutionPolicy Bypass -NoProfile -File $harnessPath -Code 7 | Out-Null
$LASTEXITCODE | Should -Be 7 -Because "$powerShellHost should propagate failure"
& $powerShellHost -ExecutionPolicy Bypass -NoProfile -f $harnessPath -Code 7 | Out-Null
$LASTEXITCODE | Should -Be 7 -Because "$powerShellHost should support the -f alias"
if ((Split-Path -Leaf $powerShellHost) -like "pwsh*") {
& $powerShellHost -NoProfile $harnessPath -Code 7 | Out-Null
$LASTEXITCODE | Should -Be 7 -Because "$powerShellHost should support a positional file target"
}
& $powerShellHost -ExecutionPolicy Bypass -NoProfile -File $wrapperPath -HarnessPath $harnessPath | Out-Null
$LASTEXITCODE | Should -Be 23 -Because "$powerShellHost should let the wrapper continue"
}
}
It "propagates a compiled WinUtil headless failure without terminating its wrapper" {
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator
)
if (-not $isAdmin) {
Set-ItResult -Skipped -Because "the compiled WinUtil process would open a UAC prompt"
return
}
& (Join-Path $script:repoRoot "Compile.ps1")
$compiledPath = Join-Path $script:repoRoot "winutil.ps1"
$missingConfig = Join-Path $TestDrive "missing-config.json"
$wrapperPath = Join-Path $TestDrive "compiled-winutil-wrapper.ps1"
@'
param([string]$WinUtilPath, [string]$MissingConfig)
$source = Get-Content -LiteralPath $WinUtilPath -Raw
& ([scriptblock]::Create($source)) -Config $MissingConfig | Out-Null
Write-Output "wrapper-continued"
exit 23
'@ | Set-Content -LiteralPath $wrapperPath
$powerShellHosts = @(Get-Command pwsh.exe, powershell.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -Unique)
foreach ($powerShellHost in $powerShellHosts) {
& $powerShellHost -ExecutionPolicy Bypass -NoProfile -File $compiledPath -Config $missingConfig | Out-Null
$LASTEXITCODE | Should -Be 1 -Because "$powerShellHost should propagate WinUtil's failed config import"
$wrapperOutput = @(& $powerShellHost -ExecutionPolicy Bypass -NoProfile -File $wrapperPath -WinUtilPath $compiledPath -MissingConfig $missingConfig)
$LASTEXITCODE | Should -Be 23 -Because "$powerShellHost should let the file-backed wrapper continue"
$wrapperOutput | Should -Contain "wrapper-continued"
}
}
It "cleans up even when the run throws" {
# Without a finally a failed run leaves the worker pool open and the transcript running
$script:mainScript | Should -Match '\} finally \{[\s\S]*Close-WinUtilRunspacePool[\s\S]*Stop-Transcript'
}
It "names the presets that exist when given one that does not" {
$script:mainScript | Should -Match "There is no preset called"
}
It "waits for the elevated run and passes its code back" {
# Start-Process without -Wait returns immediately, so the caller would see success
# regardless of what the run did
$script:startScript | Should -Match '\$elevated = Start-Process[^\r\n]*-Wait -PassThru'
$script:startScript | Should -Match '\$env:WINUTIL_HEADLESS_CHILD = ''1'''
$script:startScript | Should -Match '\$global:LASTEXITCODE = \$elevated\.ExitCode[\s\S]*return \$elevated\.ExitCode'
$script:startScript | Should -Match 'if \(\$script:WinUtilIsFileProcess\) \{ exit \$elevated\.ExitCode \}'
}
}
Describe "Headless config import" {
BeforeEach {
$global:sync = [hashtable]::Synchronized(@{})
}
It "throws when a requested config cannot be loaded" {
$missingConfig = Join-Path $TestDrive "missing.json"
{ Invoke-WPFImpex -type "import" -Config $missingConfig -ThrowOnError } | Should -Throw
}
}
Describe "Invoke-WinUtilAutoRun" {
BeforeAll {
. (Join-Path $script:functionRoot "public\Invoke-WinUtilAutoRun.ps1")
}
BeforeEach {
$global:sync = [hashtable]::Synchronized(@{})
$sync.selectedTweaks = [System.Collections.Generic.List[string]]::new()
$sync.selectedToggles = [System.Collections.Generic.List[string]]::new()
$sync.selectedApps = [System.Collections.Generic.List[string]]::new()
$sync.selectedFeatures = [System.Collections.Generic.List[string]]::new()
$sync.selectedAppx = [System.Collections.Generic.List[string]]::new()
$sync.LoggedErrors = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new())
$sync.ActiveJob = $null
Mock Write-WinUtilLog { }
Mock Write-WinUtilTimingSummary { }
Mock Write-WinUtilErrorRecord { }
}
It "does nothing and says so when nothing is selected" {
$summary = Invoke-WinUtilAutoRun
@($summary.Steps).Count | Should -Be 0
Should -Invoke -CommandName Write-WinUtilLog -ParameterFilter { $Level -eq "WARN" -and $Message -like "*nothing to do*" }
}
It "applies toggles, which nothing outside the window ever did" {
$sync.selectedToggles.Add("WPFToggleDarkMode")
Mock Invoke-WPFToggleSelections { }
$summary = Invoke-WinUtilAutoRun
Should -Invoke -CommandName Invoke-WPFToggleSelections -Times 1 -Exactly
@($summary.Steps | Where-Object { $_.Name -eq "Toggles" }).Count | Should -Be 1
}
It "runs only the steps that have something selected" {
$sync.selectedApps.Add("WPFInstall7zip")
Mock Invoke-WPFInstall { }
Mock Invoke-WPFtweaksbutton { }
$summary = Invoke-WinUtilAutoRun
@($summary.Steps).Count | Should -Be 1
$summary.Steps[0].Name | Should -Be "Applications"
Should -Invoke -CommandName Invoke-WPFtweaksbutton -Times 0 -Exactly
}
It "gives up on a step that never finishes instead of hanging for good" {
$sync.selectedApps.Add("WPFInstall7zip")
# a job that sets the flag and never clears it
Mock Invoke-WPFInstall { $sync.ActiveJob = "Install" }
$summary = Invoke-WinUtilAutoRun -StepTimeoutSeconds 1
$summary.TimedOut | Should -Be 1
# and the flag must be released, or every later step would be refused
$sync.ActiveJob | Should -BeNullOrEmpty
}
It "abandons the remaining steps when a timed out worker will not stop" {
$sync.selectedTweaks.Add("WPFTweaksAH")
$sync.selectedApps.Add("WPFInstall7zip")
# the first step times out, and its worker is still running afterwards
Mock Invoke-WPFtweaksbutton { $sync.ActiveJob = "Tweaks" }
Mock Invoke-WPFInstall { }
Mock Stop-WinUtilActiveWork { }
Mock Test-WinUtilActiveWorkRunning { return $true }
$summary = Invoke-WinUtilAutoRun -StepTimeoutSeconds 1 -StopTimeoutSeconds 1
$summary.TimedOut | Should -Be 1
# the later step must not run beside work that is still changing the machine
Should -Invoke -CommandName Invoke-WPFInstall -Times 0 -Exactly
Should -Invoke -CommandName Stop-WinUtilActiveWork -Times 1 -Exactly
}
It "counts a step's errors against the run" {
$sync.selectedApps.Add("WPFInstall7zip")
Mock Invoke-WPFInstall { $null = $sync.LoggedErrors.Add("boom") }
$summary = Invoke-WinUtilAutoRun
$summary.Failed | Should -Be 1
$summary.Errors | Should -Be 1
}
It "reports warnings produced by a completed job" {
$sync.selectedApps.Add("WPFInstall7zip")
Mock Invoke-WPFInstall {
$sync.LastJobResult = [pscustomobject]@{ Errors = 0; Warnings = 1 }
}
$summary = Invoke-WinUtilAutoRun
$summary.Failed | Should -Be 0
$summary.Warnings | Should -Be 1
$summary.Steps[0].Warnings | Should -Be 1
}
It "keeps going after a step throws rather than abandoning the run" {
$sync.selectedTweaks.Add("WPFTweaksDiskCleanup")
$sync.selectedApps.Add("WPFInstall7zip")
Mock Invoke-WPFtweaksbutton { throw "tweaks blew up" }
Mock Invoke-WPFInstall { }
$summary = Invoke-WinUtilAutoRun
Should -Invoke -CommandName Invoke-WPFInstall -Times 1 -Exactly
@($summary.Steps).Count | Should -Be 2
}
}
Describe "Write-WinUtilAutoRunSummary" {
BeforeAll {
. (Join-Path $script:functionRoot "public\Invoke-WinUtilAutoRun.ps1")
}
BeforeEach {
$global:sync = @{ logPath = "C:\temp\winutil.log" }
Mock Write-Host { }
}
It "returns 0 when every step was clean" {
$summary = [pscustomobject]@{
Steps = @([pscustomobject]@{ Name = "Tweaks"; Items = 1; Seconds = 1; Errors = 0; TimedOut = $false })
Failed = 0; TimedOut = 0; Errors = 0
}
Write-WinUtilAutoRunSummary -Summary $summary | Should -Be 0
}
It "returns 1 when a step failed or timed out" {
$failed = [pscustomobject]@{
Steps = @([pscustomobject]@{ Name = "Tweaks"; Items = 1; Seconds = 1; Errors = 2; TimedOut = $false })
Failed = 1; TimedOut = 0; Errors = 2
}
Write-WinUtilAutoRunSummary -Summary $failed | Should -Be 1
$timedOut = [pscustomobject]@{
Steps = @([pscustomobject]@{ Name = "Apps"; Items = 1; Seconds = 60; Errors = 0; TimedOut = $true })
Failed = 0; TimedOut = 1; Errors = 0
}
Write-WinUtilAutoRunSummary -Summary $timedOut | Should -Be 1
}
It "returns 2 when nothing was selected, which is not the same as success" {
$summary = [pscustomobject]@{ Steps = @(); Failed = 0; TimedOut = 0; Errors = 0 }
Write-WinUtilAutoRunSummary -Summary $summary | Should -Be 2
}
It "reports warnings without turning an expected package skip into failure" {
$summary = [pscustomobject]@{
Steps = @([pscustomobject]@{ Name = "Applications"; Items = 1; Seconds = 1; Errors = 0; Warnings = 1; TimedOut = $false })
Failed = 0; TimedOut = 0; Errors = 0; Warnings = 1
}
Write-WinUtilAutoRunSummary -Summary $summary | Should -Be 0
Should -Invoke Write-Host -ParameterFilter { $Object -like "Completed with warnings*" }
}
}
Describe "Update-WinUtilSelections" {
BeforeEach {
$global:sync = [hashtable]::Synchronized(@{})
foreach ($list in @("selectedApps","selectedTweaks","selectedToggles","selectedFeatures","selectedAppx")) {
$sync.$list = [System.Collections.Generic.List[string]]::new()
}
# The selection sorter now checks each key against the real catalogue before taking it,
# so the fixture has to carry the configs it reads
$sync.configs = @{
applicationsHashtable = @{ "WPFInstall7zip" = @{} }
appxHashtable = @{ "WPFAppxBing" = @{} }
tweaks = [pscustomobject]@{ "WPFTweaksDiskCleanup" = @{}; "WPFToggleDarkMode" = @{} }
feature = [pscustomobject]@{ "WPFFeaturesdotnet" = @{} }
}
Mock Write-WinUtilLog { }
}
It "sorts each prefix into its own list" {
Update-WinUtilSelections -flatJson @("WPFInstall7zip","WPFTweaksDiskCleanup","WPFToggleDarkMode","WPFFeaturesdotnet","WPFAppxBing")
$sync.selectedApps | Should -Contain "WPFInstall7zip"
$sync.selectedTweaks | Should -Contain "WPFTweaksDiskCleanup"
$sync.selectedToggles | Should -Contain "WPFToggleDarkMode"
$sync.selectedFeatures | Should -Contain "WPFFeaturesdotnet"
$sync.selectedAppx | Should -Contain "WPFAppxBing"
}
It "hands back an unrecognised entry rather than throwing, which is what the headless run relies on" {
# The headless path passes SkipUnknown so a retired entry names itself and the run goes
# on. Without it the call is strict, which is what the window wants.
$skipped = @(Update-WinUtilSelections -flatJson @("NotAWinUtilEntry") -SkipUnknown)
$skipped | Should -Contain "NotAWinUtilEntry"
{ Update-WinUtilSelections -flatJson @("NotAWinUtilEntry") } | Should -Throw
}
It "does not select the same entry twice when a preset and a config both list it" {
Update-WinUtilSelections -flatJson @("WPFInstall7zip")
Update-WinUtilSelections -flatJson @("WPFInstall7zip")
@($sync.selectedApps).Count | Should -Be 1
}
}
+12 -35
View File
@@ -1,44 +1,21 @@
#===========================================================================
# Tests - Install tab rendering
#===========================================================================
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
}
Describe "Install app rendering startup contract" {
It "queues app entries after creating category containers" {
$categoryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallCategoryAppList.ps1") -Raw
$categoryScript | Should -Match '\$sync\.InstallAppRenderQueue = \[System\.Collections\.Queue\]::new\(\)'
$categoryScript | Should -Match 'Start-WinUtilInstallAppRendering'
$categoryScript | Should -Match 'Pre-group apps by category before creating WPF controls'
}
It "renders queued apps through dispatcher callbacks when a form dispatcher exists" {
$renderScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1") -Raw
$renderScript | Should -Match 'Dispatcher\.BeginInvoke'
$renderScript | Should -Match 'Invoke-WinUtilInstallAppRenderNextBatch'
$renderScript | Should -Match 'Initialize-InstallAppEntry'
$renderScript | Should -Match 'Find-AppsByNameOrDescription -SearchString \$sync\.SearchBar\.Text -Categories \$selectedCategories'
# A batch has to be filtered when either filter is on, not only when there is search text
$renderScript | Should -Match '\$selectedCategories\.Count -gt 0'
$renderScript | Should -Match '\$sync\.InstallAppEntriesRendered = \$true'
}
It "does not use dispatcher timers for deferred install rendering" {
$renderScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1") -Raw
$renderScript | Should -Not -Match 'DispatcherTimer'
$renderScript | Should -Not -Match '\$timer'
$renderScript | Should -Not -Match '\$dispatcherTimer'
$renderScript | Should -Not -Match '\$timer\.Stop\(\)'
$renderScript | Should -Not -Match '& \$renderCategory'
}
It "drains queued app batches on the WPF dispatcher without timer scope errors" {
Add-Type -AssemblyName WindowsBase
function global:Test-WinUtilUIAlive { $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher }
. (Join-Path $script:repoRoot "functions\private\Start-WinUtilBackgroundQueue.ps1")
. (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1")
$previousSync = Get-Variable -Name sync -Scope Global -ErrorAction SilentlyContinue
@@ -61,6 +38,10 @@ Describe "Install app rendering startup contract" {
return "entry:$AppKey"
}
function global:Test-WinUtilDeferBackgroundWork { param($RequiresTab) $false }
function global:Invoke-WinUtilWhenIdle { param($Callback, $DelayMilliseconds) }
function global:Find-AppsByNameOrDescription {
param($SearchString, $Category)
throw "Search should not run for an empty search box in this test."
@@ -92,6 +73,8 @@ Describe "Install app rendering startup contract" {
$global:sync.InstallAppRenderQueue.Count | Should -Be 0
@($renderedApps) | Should -Be @("AppA", "AppB", "AppC")
$global:Error.Count | Should -Be $errorCountBefore
(Get-Content (Join-Path $script:repoRoot "functions\private\Start-WinUtilInstallAppRendering.ps1") -Raw) |
Should -Not -Match 'Measure-WinUtilStep'
} finally {
if ($previousSync) {
Set-Variable -Name sync -Value $previousSync.Value -Scope Global
@@ -112,17 +95,11 @@ Describe "Install app rendering startup contract" {
}
}
It "keeps app-entry metadata lookup independent from the old caller scope" {
$entryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallAppEntry.ps1") -Raw
$entryScript | Should -Match '\$app = \$sync\.configs\.applicationsHashtable\.\$appKey'
$entryScript | Should -Not -Match '\$Apps\.\$appKey'
}
It "restores delayed app checkbox state from selected apps" {
$entryScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Initialize-InstallAppEntry.ps1") -Raw
$entryScript | Should -Match '\$sync\.selectedApps -contains \$appKey'
$entryScript | Should -Match '\$checkBox\.IsChecked = \$true'
}
}
+117 -194
View File
@@ -4,6 +4,7 @@
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
. (Join-Path $script:repoRoot "functions\private\Measure-WinUtilStep.ps1")
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilPackageLogSummary.ps1")
. (Join-Path $script:repoRoot "functions\public\Invoke-WPFInstall.ps1")
@@ -12,15 +13,24 @@ BeforeAll {
function Show-WinUtilMessage {
param($Message, $Title, $Button, $Icon)
}
function Start-WinUtilJob {
param(
[string]$Name,
[scriptblock]$ScriptBlock,
[hashtable]$Parameters,
[string]$Description,
[switch]$DisableAppList
)
}
function Step-WinUtilJob {
param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay)
}
function Invoke-WPFRunspace {
param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock)
}
function Get-WinUtilSelectedPackages {
param($PackageList, [string]$Preference)
}
function Set-WinUtilTweaksProgressIndicator {
param($Visible, $Label, $Percent)
}
function Install-WinUtilWinget { }
function Install-WinUtilChoco { }
function Install-WinUtilProgramWinget {
@@ -29,8 +39,11 @@ BeforeAll {
function Install-WinUtilProgramChoco {
param($Action, $Programs)
}
function Complete-WinUtilPackageRun {
param([string]$Action, [object[]]$Results)
}
function Invoke-WPFUIThread {
param([scriptblock]$ScriptBlock)
param([scriptblock]$ScriptBlock, [hashtable]$Parameters, [switch]$Async)
}
function Write-WinUtilLog {
param($Message, $Level, $Component)
@@ -107,37 +120,43 @@ Describe "Invoke-WPFInstall entrypoint" {
BeforeEach {
$script:package = New-WinUtilPackage
New-WinUtilInstallTestContext -Packages @($script:package)
$script:capturedInstallScriptBlock = $null
$script:capturedInstallParameterList = $null
$script:capturedInstallJob = $null
Mock Show-WinUtilMessage { "OK" }
Mock Invoke-WPFRunspace {
$script:capturedInstallScriptBlock = $ScriptBlock
$script:capturedInstallParameterList = $ParameterList
[pscustomobject]@{ MockHandle = $true }
Mock Start-WinUtilJob {
$script:capturedInstallJob = [pscustomobject]@{
Name = $Name
ScriptBlock = $ScriptBlock
Parameters = $Parameters
Description = $Description
DisableAppList = [bool]$DisableAppList
}
}
Mock Write-WinUtilLog { }
}
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedInstallScriptBlock -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedInstallParameterList -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedInstallJob -Scope Script -ErrorAction SilentlyContinue
}
It "queues selected packages with the configured package manager preference" {
It "queues an install job with the configured package manager preference" {
Invoke-WPFInstall
Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter {
$ScriptBlock -is [scriptblock] -and
$ParameterList.Count -eq 2 -and
$ParameterList[0][0] -eq "PackagesToInstall" -and
@($ParameterList[0][1]).Count -eq 1 -and
@($ParameterList[0][1])[0].winget -eq "Git.Git" -and
$ParameterList[1][0] -eq "ManagerPreference" -and
$ParameterList[1][1] -eq "Winget"
Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Name -eq "Install" -and
$ScriptBlock -is [scriptblock] -and
$DisableAppList -and
@($Parameters.PackagesToInstall).Count -eq 1 -and
@($Parameters.PackagesToInstall)[0].winget -eq "Git.Git" -and
$Parameters.ManagerPreference -eq "Winget"
}
Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly
}
It "logs package identities before the job is queued" {
Invoke-WPFInstall
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Component -eq "Install" -and
$Message -eq "Install selected package(s): Git (winget: Git.Git)"
@@ -149,14 +168,9 @@ Describe "Invoke-WPFInstall entrypoint" {
Invoke-WPFInstall -PackagesToInstall $explicitPackage
Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter {
$ScriptBlock -is [scriptblock] -and
$ParameterList.Count -eq 2 -and
$ParameterList[0][0] -eq "PackagesToInstall" -and
@($ParameterList[0][1]).Count -eq 1 -and
@($ParameterList[0][1])[0].winget -eq "VideoLAN.VLC" -and
$ParameterList[1][0] -eq "ManagerPreference" -and
$ParameterList[1][1] -eq "Winget"
Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter {
@($Parameters.PackagesToInstall).Count -eq 1 -and
@($Parameters.PackagesToInstall)[0].winget -eq "VideoLAN.VLC"
}
Should -Invoke -CommandName Show-WinUtilMessage -Times 0 -Exactly
}
@@ -172,73 +186,49 @@ Describe "Invoke-WPFInstall entrypoint" {
$Button -eq "OK" -and
$Icon -eq "Warning"
}
Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly
}
It "prompts and exits when another install process is running" {
New-WinUtilInstallTestContext -ProcessRunning $true -Packages @($script:package)
Invoke-WPFInstall
Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter {
$Message -eq "[Invoke-WPFInstall] An Install process is currently running." -and
$Title -eq "WinUtil" -and
$Button -eq "OK" -and
$Icon -eq "Warning"
}
Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly
Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly
}
}
Describe "Invoke-WPFInstall runspace body" {
Describe "Invoke-WPFInstall job body" {
BeforeEach {
$script:package = New-WinUtilPackage
New-WinUtilInstallTestContext -Packages @($script:package)
$script:capturedInstallScriptBlock = $null
$script:capturedInstallJob = $null
Mock Show-WinUtilMessage { "OK" }
Mock Invoke-WPFRunspace {
$script:capturedInstallScriptBlock = $ScriptBlock
[pscustomobject]@{ MockHandle = $true }
Mock Start-WinUtilJob {
$script:capturedInstallJob = [pscustomobject]@{
ScriptBlock = $ScriptBlock
Parameters = $Parameters
}
}
Mock Get-WinUtilSelectedPackages {
New-WinUtilPackageSplit -Winget @("Git.Git") -Choco @("vlc")
}
Mock Set-WinUtilTweaksProgressIndicator { }
Mock Step-WinUtilJob { }
Mock Install-WinUtilWinget { }
Mock Install-WinUtilChoco { }
Mock Install-WinUtilProgramWinget { }
Mock Install-WinUtilProgramChoco { }
Mock Invoke-WPFUIThread { }
Mock Write-WinUtilLog { }
Mock Write-Host { }
}
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedInstallScriptBlock -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedInstallJob -Scope Script -ErrorAction SilentlyContinue
}
It "installs split winget and choco packages and cleans up on success" {
It "installs split winget and choco packages and reports progress" {
Invoke-WPFInstall
& $script:capturedInstallScriptBlock -PackagesToInstall @($script:package) -ManagerPreference "Winget"
$jobParameters = $script:capturedInstallJob.Parameters
& $script:capturedInstallJob.ScriptBlock @jobParameters
Should -Invoke -CommandName Get-WinUtilSelectedPackages -Times 1 -Exactly -ParameterFilter {
@($PackageList).Count -eq 1 -and $Preference -eq "Winget"
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "Preparing app install (0/2)" -and $Percent -eq 0
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "Installed Git.Git (1/2)" -and $Percent -eq 50
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "Installed Chocolatey packages (2/2)" -and $Percent -eq 100
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "App install finished" -and $Percent -eq 100
}
Should -Invoke -CommandName Install-WinUtilWinget -Times 1 -Exactly
Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 1 -Exactly -ParameterFilter {
$Action -eq "Install" -and @($Programs)[0] -eq "Git.Git"
@@ -247,41 +237,22 @@ Describe "Invoke-WPFInstall runspace body" {
Should -Invoke -CommandName Install-WinUtilProgramChoco -Times 1 -Exactly -ParameterFilter {
$Action -eq "Install" -and @($Programs)[0] -eq "vlc"
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $false*'
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Installed Git.Git (1/2)" -and $Percent -eq 50
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $true*'
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Installed Chocolatey packages (2/2)" -and $Percent -eq 100
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*'
}
$script:sync.ProcessRunning | Should -BeFalse
}
It "shows failure progress, sets taskbar error state, and clears ProcessRunning on failure" {
It "lets a failure surface so the job layer can handle it" {
Mock Install-WinUtilProgramWinget { throw "winget failed" }
Invoke-WPFInstall
& $script:capturedInstallScriptBlock -PackagesToInstall @($script:package) -ManagerPreference "Winget"
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "App install failed" -and $Percent -eq 100
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $false*'
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $true*'
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*'
}
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Level -eq "ERROR" -and $Component -eq "Install" -and $Message -like "Install workflow failed:*"
}
$script:sync.ProcessRunning | Should -BeFalse
{ $jobParameters = $script:capturedInstallJob.Parameters
& $script:capturedInstallJob.ScriptBlock @jobParameters } |
Should -Throw "winget failed"
}
}
@@ -289,43 +260,44 @@ Describe "Invoke-WPFUnInstall entrypoint" {
BeforeEach {
$script:package = New-WinUtilPackage
New-WinUtilInstallTestContext -Packages @($script:package)
$script:capturedUninstallScriptBlock = $null
$script:capturedUninstallParameterList = $null
$script:capturedUninstallJob = $null
Mock Show-WinUtilMessage { "Yes" }
Mock Invoke-WPFRunspace {
$script:capturedUninstallScriptBlock = $ScriptBlock
$script:capturedUninstallParameterList = $ParameterList
[pscustomobject]@{ MockHandle = $true }
Mock Start-WinUtilJob {
$script:capturedUninstallJob = [pscustomobject]@{
Name = $Name
ScriptBlock = $ScriptBlock
Parameters = $Parameters
DisableAppList = [bool]$DisableAppList
}
}
Mock Write-WinUtilLog { }
}
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedUninstallScriptBlock -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedUninstallParameterList -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedUninstallJob -Scope Script -ErrorAction SilentlyContinue
}
It "confirms and queues selected packages with the configured package manager preference" {
Invoke-WPFUnInstall -PackagesToUninstall @($script:package)
It "confirms and queues an uninstall job with the configured package manager preference" {
Invoke-WPFUnInstall
Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter {
$Message -like "*This will uninstall the following applications:*" -and
$Message -like "*Git*" -and
$Title -eq "Are you sure?" -and
"$Button" -eq "YesNo" -and
"$Icon" -eq "Information"
$Title -eq "Are you sure?" -and $Button -eq "YesNo"
}
Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly -ParameterFilter {
$ScriptBlock -is [scriptblock] -and
$ParameterList.Count -eq 2 -and
$ParameterList[0][0] -eq "PackagesToUninstall" -and
@($ParameterList[0][1]).Count -eq 1 -and
@($ParameterList[0][1])[0].winget -eq "Git.Git" -and
$ParameterList[1][0] -eq "ManagerPreference" -and
$ParameterList[1][1] -eq "Winget"
Should -Invoke -CommandName Start-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Name -eq "Uninstall" -and
$ScriptBlock -is [scriptblock] -and
$DisableAppList -and
@($Parameters.PackagesToUninstall).Count -eq 1 -and
@($Parameters.PackagesToUninstall)[0].winget -eq "Git.Git" -and
$Parameters.ManagerPreference -eq "Winget"
}
}
It "logs package identities before the uninstall job is queued" {
Invoke-WPFUnInstall
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Component -eq "Uninstall" -and
$Message -eq "Uninstall selected package(s): Git (winget: Git.Git)"
@@ -333,128 +305,79 @@ Describe "Invoke-WPFUnInstall entrypoint" {
}
It "prompts and exits when no packages are selected" {
Invoke-WPFUnInstall -PackagesToUninstall @()
New-WinUtilInstallTestContext
Invoke-WPFUnInstall
Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter {
$Message -eq "Please select the program(s) to uninstall" -and
$Title -eq "WinUtil" -and
$Button -eq "OK" -and
$Icon -eq "Warning"
$Message -eq "Please select the program(s) to uninstall"
}
Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly
}
It "prompts and exits when another install process is running" {
$script:sync.ProcessRunning = $true
Invoke-WPFUnInstall -PackagesToUninstall @($script:package)
Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter {
$Message -eq "[Invoke-WPFUnInstall] Install process is currently running" -and
$Title -eq "WinUtil" -and
$Button -eq "OK" -and
$Icon -eq "Warning"
}
Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly
Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly
}
It "exits without queueing uninstall when confirmation is declined" {
Mock Show-WinUtilMessage { "No" } -ParameterFilter { $Title -eq "Are you sure?" }
Mock Show-WinUtilMessage { "No" }
Invoke-WPFUnInstall -PackagesToUninstall @($script:package)
Invoke-WPFUnInstall
Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly
Should -Invoke -CommandName Start-WinUtilJob -Times 0 -Exactly
}
}
Describe "Invoke-WPFUnInstall runspace body" {
Describe "Invoke-WPFUnInstall job body" {
BeforeEach {
$script:package = New-WinUtilPackage
New-WinUtilInstallTestContext -Packages @($script:package)
$script:capturedUninstallScriptBlock = $null
$script:capturedUninstallJob = $null
Mock Show-WinUtilMessage { "Yes" }
Mock Invoke-WPFRunspace {
$script:capturedUninstallScriptBlock = $ScriptBlock
[pscustomobject]@{ MockHandle = $true }
Mock Start-WinUtilJob {
$script:capturedUninstallJob = [pscustomobject]@{
ScriptBlock = $ScriptBlock
Parameters = $Parameters
}
}
Mock Get-WinUtilSelectedPackages {
New-WinUtilPackageSplit -Winget @("Git.Git") -Choco @("vlc")
}
Mock Set-WinUtilTweaksProgressIndicator { }
Mock Step-WinUtilJob { }
Mock Install-WinUtilProgramWinget { }
Mock Install-WinUtilProgramChoco { }
Mock Invoke-WPFUIThread { }
Mock Write-WinUtilLog { }
Mock Write-Host { }
Mock New-Item { }
}
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedUninstallScriptBlock -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedUninstallJob -Scope Script -ErrorAction SilentlyContinue
}
It "uninstalls split winget and choco packages and cleans up on success" {
Invoke-WPFUnInstall -PackagesToUninstall @($script:package)
It "uninstalls split winget and choco packages and reports progress" {
Invoke-WPFUnInstall
& $script:capturedUninstallScriptBlock -PackagesToUninstall @($script:package) -ManagerPreference "Winget"
$jobParameters = $script:capturedUninstallJob.Parameters
& $script:capturedUninstallJob.ScriptBlock @jobParameters
Should -Invoke -CommandName Get-WinUtilSelectedPackages -Times 1 -Exactly -ParameterFilter {
@($PackageList).Count -eq 1 -and $Preference -eq "Winget"
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "Preparing app uninstall (0/2)" -and $Percent -eq 0
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "Uninstalled Git.Git (1/2)" -and $Percent -eq 50
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "Uninstalled Chocolatey packages (2/2)" -and $Percent -eq 100
}
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "App uninstall finished" -and $Percent -eq 100
}
Should -Invoke -CommandName Install-WinUtilProgramWinget -Times 1 -Exactly -ParameterFilter {
$Action -eq "Uninstall" -and @($Programs)[0] -eq "Git.Git"
}
Should -Invoke -CommandName Install-WinUtilProgramChoco -Times 1 -Exactly -ParameterFilter {
$Action -eq "Uninstall" -and @($Programs)[0] -eq "vlc"
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $false*'
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Uninstalled Git.Git (1/2)" -and $Percent -eq 50
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $true*'
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Uninstalled Chocolatey packages (2/2)" -and $Percent -eq 100
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"*'
}
$script:sync.ProcessRunning | Should -BeFalse
}
It "shows failure progress, sets taskbar error state, and clears ProcessRunning on failure" {
It "lets a failure surface so the job layer can handle it" {
Mock Install-WinUtilProgramWinget { throw "winget failed" }
Invoke-WPFUnInstall -PackagesToUninstall @($script:package)
Invoke-WPFUnInstall
& $script:capturedUninstallScriptBlock -PackagesToUninstall @($script:package) -ManagerPreference "Winget"
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "App uninstall failed" -and $Percent -eq 100
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $false*'
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*$sync.ItemsControl.IsEnabled = $true*'
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*'
}
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Level -eq "ERROR" -and $Component -eq "Uninstall" -and $Message -like "Uninstall workflow failed:*"
}
$script:sync.ProcessRunning | Should -BeFalse
$jobParameters = $script:capturedUninstallJob.Parameters
{ & $script:capturedUninstallJob.ScriptBlock @jobParameters } | Should -Throw "winget failed"
}
}
+515
View File
@@ -0,0 +1,515 @@
#===========================================================================
# Tests - Job layer
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
. (Join-Path $script:repoRoot "functions\private\Measure-WinUtilStep.ps1")
. (Join-Path $script:repoRoot "functions\private\Write-WinUtilErrorRecord.ps1")
. (Join-Path $script:repoRoot "functions\private\Complete-WinUtilPackageRun.ps1")
. (Join-Path $script:repoRoot "functions\private\Start-WinUtilJob.ps1")
. (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilCloseRequest.ps1")
. (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIThread.ps1")
function Invoke-WPFRunspace {
param($ArgumentList, $ParameterList, [scriptblock]$ScriptBlock)
}
function Write-WinUtilJobBanner {
param([string]$Message, [string]$Level)
}
function Write-WinUtilLog {
param($Message, $Level, $Component, [switch]$Detail)
}
function Step-WinUtilJob {
param([string]$Status, [int]$Percent, [string]$State, [string]$Overlay, [switch]$Hide)
}
function Show-WinUtilMessage {
param($Message, $Title, $Button, $Icon)
}
function script:Get-WinUtilJobRunspaceBody {
param([hashtable]$ParameterList)
$named = @{}
foreach ($parameter in $ParameterList) {
$named[$parameter[0]] = $parameter[1]
}
return $named
}
}
Describe "Interface thread dispatch" {
# Work handed to the interface thread must arrive as body text plus parameters. A scriptblock
# marshalled from a worker runspace keeps that runspace's session state, so every command it
# invokes is resolved back through the originating runspace. Measured on 400 invocations:
# 5354 ms marshalled against 3 ms rebuilt from text.
It "passes deferred values as parameters rather than capturing them" {
foreach ($path in @(
"functions\private\Step-WinUtilJob.ps1",
"functions\private\Invoke-WinUtilISO.ps1"
)) {
$source = Get-Content -Path (Join-Path $script:repoRoot $path) -Raw
$source | Should -Match ([regex]::Escape('Invoke-WPFUIThread -Async -Parameters @{'))
$source | Should -Not -Match ([regex]::Escape('GetNewClosure'))
}
}
}
Describe "Invoke-WPFUIThread output" {
BeforeEach {
$dispatcher = [pscustomobject]@{ HasShutdownStarted = $false }
$dispatcher | Add-Member -MemberType ScriptMethod -Name CheckAccess -Value { $true }
$script:sync = [Hashtable]::Synchronized(@{
Form = [pscustomobject]@{ Dispatcher = $dispatcher }
})
}
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
}
# Output from the body must not join the caller's own return value: a caller that only
# wanted a control updated would otherwise return an array and every index read wrong.
It "swallows the body's output by default" {
@(Invoke-WPFUIThread -ScriptBlock { "stray" }).Count | Should -Be 0
}
It "returns the body's output when asked" {
Invoke-WPFUIThread -PassThru -ScriptBlock { "wanted" } | Should -Be "wanted"
}
It "passes values in rather than relying on the caller's scope" {
Invoke-WPFUIThread -PassThru -Parameters @{ Value = 7 } -ScriptBlock {
param($Value)
$Value * 2
} | Should -Be 14
}
It "ignores a synchronous dispatch canceled by window shutdown" {
$dispatcher = [pscustomobject]@{ HasShutdownStarted = $false }
$dispatcher | Add-Member -MemberType ScriptMethod -Name CheckAccess -Value { $false }
$dispatcher | Add-Member -MemberType ScriptMethod -Name Invoke -Value {
param($Executor, $Work)
$null = $Executor, $Work
$this.HasShutdownStarted = $true
throw [System.OperationCanceledException]::new("dispatcher stopped")
}
$script:sync = [Hashtable]::Synchronized(@{
Form = [pscustomobject]@{ Dispatcher = $dispatcher }
UIDispatchDelegate = [System.Func[object, object]]{ param($Work) $Work }
})
{ Invoke-WPFUIThread -ScriptBlock { } } | Should -Not -Throw
}
It "does not hide a dispatch failure while the window is still alive" {
$dispatcher = [pscustomobject]@{ HasShutdownStarted = $false }
$dispatcher | Add-Member -MemberType ScriptMethod -Name CheckAccess -Value { $false }
$dispatcher | Add-Member -MemberType ScriptMethod -Name Invoke -Value {
throw [System.InvalidOperationException]::new("not a shutdown")
}
$script:sync = [Hashtable]::Synchronized(@{
Form = [pscustomobject]@{ Dispatcher = $dispatcher }
UIDispatchDelegate = [System.Func[object, object]]{ param($Work) $Work }
})
{ Invoke-WPFUIThread -ScriptBlock { } } | Should -Throw "*not a shutdown*"
}
It "rethrows synchronous callback failures for the waiting caller to log" {
$tokens = $null
$errors = $null
$uiAst = [System.Management.Automation.Language.Parser]::ParseFile(
(Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1"),
[ref]$tokens,
[ref]$errors
)
$delegateAssignment = $uiAst.Find({
param($node)
$node -is [System.Management.Automation.Language.AssignmentStatementAst] -and
$node.Left.Extent.Text -eq '$sync.UIDispatchDelegate'
}, $true)
$delegateBody = $delegateAssignment.Right.Expression.Child.ScriptBlock.GetScriptBlock()
$delegate = [System.Func[object, object]]$delegateBody
Mock Write-WinUtilErrorRecord { }
{ $delegate.Invoke(@{ Body = 'throw "callback failed"'; Parameters = @{}; PropagateErrors = $true }) } |
Should -Throw "*callback failed*"
Should -Invoke Write-WinUtilErrorRecord -Times 0 -Exactly
}
It "logs asynchronous callback failures without throwing on the dispatcher" {
$tokens = $null
$errors = $null
$uiAst = [System.Management.Automation.Language.Parser]::ParseFile(
(Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1"),
[ref]$tokens,
[ref]$errors
)
$delegateAssignment = $uiAst.Find({
param($node)
$node -is [System.Management.Automation.Language.AssignmentStatementAst] -and
$node.Left.Extent.Text -eq '$sync.UIDispatchDelegate'
}, $true)
$delegateBody = $delegateAssignment.Right.Expression.Child.ScriptBlock.GetScriptBlock()
$delegate = [System.Func[object, object]]$delegateBody
Mock Write-WinUtilErrorRecord { }
{ $delegate.Invoke(@{ Body = 'throw "callback failed"'; Parameters = @{}; PropagateErrors = $false }) } |
Should -Not -Throw
Should -Invoke Write-WinUtilErrorRecord -Times 1 -Exactly
}
}
Describe "Start-WinUtilJob" {
BeforeEach {
$script:sync = [Hashtable]::Synchronized(@{
ActiveJob = $null
Form = [pscustomobject]@{ Dispatcher = [pscustomobject]@{} }
ItemsControl = [pscustomobject]@{ IsEnabled = $true }
})
$script:capturedRunspaceBody = $null
$script:capturedRunspaceArgs = $null
$script:activeJobAtQueueTime = $null
Mock Show-WinUtilMessage { "OK" }
Mock Write-WinUtilLog { }
Mock Step-WinUtilJob { }
Mock Invoke-WPFUIThread { & $ScriptBlock }
Mock Invoke-WPFRunspace {
$script:activeJobAtQueueTime = $script:sync.ActiveJob
$script:capturedRunspaceBody = $ScriptBlock
$script:capturedRunspaceArgs = @{}
foreach ($parameter in $ParameterList) {
$script:capturedRunspaceArgs[$parameter[0]] = $parameter[1]
}
[pscustomobject]@{ MockHandle = $true }
}
}
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedRunspaceBody -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedRunspaceArgs -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name activeJobAtQueueTime -Scope Script -ErrorAction SilentlyContinue
}
It "claims the busy state before the work is queued" {
Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null
$script:activeJobAtQueueTime | Should -Be "Example"
Should -Invoke -CommandName Invoke-WPFRunspace -Times 1 -Exactly
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Component -eq "Example" -and $Message -eq "Example job started."
}
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Example..." -and $Percent -eq 0
}
}
It "uses the description for the initial progress text" {
Start-WinUtilJob -Name "Example" -Description "Doing the thing" -ScriptBlock { } | Out-Null
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Doing the thing..." -and $Percent -eq 0 -and $State -eq "Normal" -and $Overlay -eq "logo"
}
}
It "refuses a second job while one is running" {
$script:sync.ActiveJob = "Install"
$result = Start-WinUtilJob -Name "Tweaks" -ScriptBlock { }
$result | Should -BeNullOrEmpty
Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly
Should -Invoke -CommandName Show-WinUtilMessage -Times 1 -Exactly -ParameterFilter {
$Message -like "Install is still running*"
}
$script:sync.ActiveJob | Should -Be "Install"
}
It "claims the slot with a token that identifies the run, not just its name" {
Start-WinUtilJob -Name "Install" -ScriptBlock { } | Out-Null
$first = $script:sync.ActiveJobToken
$first | Should -Not -BeNullOrEmpty
$script:capturedRunspaceArgs["JobToken"] | Should -Be $first
# a second run of the same name gets its own token, so a late worker cannot release it
$script:sync.ActiveJob = $null
$script:sync.ActiveJobToken = $null
Start-WinUtilJob -Name "Install" -ScriptBlock { } | Out-Null
$script:sync.ActiveJobToken | Should -Not -Be $first
}
It "passes the body text and its parameters to the worker" {
Start-WinUtilJob -Name "Example" -Parameters @{ Value = 42 } -ScriptBlock { param($Value) $Value } | Out-Null
$script:capturedRunspaceArgs["JobName"] | Should -Be "Example"
$script:capturedRunspaceArgs["JobBody"] | Should -BeOfType [string]
$script:capturedRunspaceArgs["JobBody"] | Should -Match 'param\(\$Value\)'
$script:capturedRunspaceArgs["JobParameters"].Value | Should -Be 42
$script:capturedRunspaceArgs["JobRestoresAppList"] | Should -BeFalse
}
It "greys out the app list only when asked to" {
Start-WinUtilJob -Name "Install" -DisableAppList -ScriptBlock { } | Out-Null
$script:sync.ItemsControl.IsEnabled | Should -BeFalse
$script:capturedRunspaceArgs["JobRestoresAppList"] | Should -BeTrue
}
It "releases the job and restores the app list when scheduling fails" {
Mock Invoke-WPFRunspace { throw "pool failed" }
Mock Write-Host { }
{ Start-WinUtilJob -Name "Install" -DisableAppList -ScriptBlock { } } | Should -Not -Throw
$script:sync.ActiveJob | Should -BeNullOrEmpty
$script:sync.ItemsControl.IsEnabled | Should -BeTrue
}
It "releases the job when progress setup and failure reporting both throw" {
Mock Step-WinUtilJob { throw "progress unavailable" }
Mock Write-Host { }
{ Start-WinUtilJob -Name "Install" -DisableAppList -ScriptBlock { } } | Should -Not -Throw
$script:sync.ActiveJob | Should -BeNullOrEmpty
$script:sync.ActiveJobToken | Should -BeNullOrEmpty
$script:sync.ItemsControl.IsEnabled | Should -BeTrue
Should -Invoke -CommandName Invoke-WPFRunspace -Times 0 -Exactly
}
It "reports completion and releases the busy state when the body succeeds" {
Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null
& $script:capturedRunspaceBody `
-JobName "Example" `
-JobBody '$null = $true' `
-JobParameters @{} `
-JobRestoresAppList $false `
-JobToken $script:capturedRunspaceArgs["JobToken"]
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Component -eq "Example" -and $Message -like "Example job finished in * ms."
}
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Example finished" -and $Percent -eq 100 -and $State -eq "None" -and $Overlay -eq "checkmark"
}
$script:sync.ActiveJob | Should -BeNullOrEmpty
}
It "does not let a stale worker replace the current job result" {
Start-WinUtilJob -Name "Old" -ScriptBlock { } | Out-Null
$oldToken = $script:capturedRunspaceArgs["JobToken"]
$script:sync.ActiveJob = "Current"
$script:sync.ActiveJobToken = "current-token"
$script:sync.LastJobResult = [pscustomobject]@{ Token = "current-token"; Errors = 0; Warnings = 0 }
& $script:capturedRunspaceBody `
-JobName "Old" `
-JobLabel "Old" `
-JobBody '$global:WinUtilJobErrorCount = 1' `
-JobParameters @{} `
-JobRestoresAppList $false `
-JobToken $oldToken
$script:sync.LastJobResult.Token | Should -Be "current-token"
$script:sync.ActiveJob | Should -Be "Current"
$script:sync.ActiveJobToken | Should -Be "current-token"
}
It "logs the failure and releases the busy state when the body throws" {
Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null
$script:sync.ActiveJob = "Example"
Mock Write-Host { }
{
& $script:capturedRunspaceBody `
-JobName "Example" `
-JobBody 'throw "boom"' `
-JobParameters @{} `
-JobRestoresAppList $false `
-JobToken $script:capturedRunspaceArgs["JobToken"]
} | Should -Not -Throw
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Level -eq "ERROR" -and $Component -eq "Example" -and $Message -like "*failed after * ms : boom"
}
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Example failed" -and $State -eq "Error" -and $Overlay -eq "warning"
}
$script:sync.ActiveJob | Should -BeNullOrEmpty
}
It "does not count an error again when the body logs it before throwing" {
Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null
& $script:capturedRunspaceBody `
-JobName "Example" `
-JobLabel "Example" `
-JobBody 'Write-WinUtilLog -Level "ERROR" -Component "Example" -Message "operation failed"; $exception = [System.InvalidOperationException]::new("operation failed"); $exception.Data["WinUtilErrorReported"] = $true; throw $exception' `
-JobParameters @{} `
-JobRestoresAppList $false `
-JobToken $script:capturedRunspaceArgs["JobToken"]
Should -Invoke Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Level -eq "ERROR" -and -not $Detail
}
}
It "counts a distinct terminating error after an earlier reported error" {
Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null
& $script:capturedRunspaceBody `
-JobName "Example" `
-JobLabel "Example" `
-JobBody 'Write-WinUtilLog -Level "ERROR" -Component "Example" -Message "first failure"; throw "second failure"' `
-JobParameters @{} `
-JobRestoresAppList $false `
-JobToken $script:capturedRunspaceArgs["JobToken"]
Should -Invoke Write-WinUtilLog -Times 2 -Exactly -ParameterFilter {
$Level -eq "ERROR" -and -not $Detail
}
}
It "reports a job that logged errors without throwing" {
Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null
& $script:capturedRunspaceBody `
-JobName "Example" `
-JobLabel "Example" `
-JobBody '$global:WinUtilJobErrorCount = 1' `
-JobParameters @{} `
-JobRestoresAppList $false `
-JobToken $script:capturedRunspaceArgs["JobToken"]
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Example finished with 1 error(s)" -and $State -eq "Paused" -and $Overlay -eq "warning"
}
$script:sync.ActiveJob | Should -BeNullOrEmpty
}
It "ignores errors logged outside the active job worker" {
$script:sync.LoggedErrors = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new())
$null = $script:sync.LoggedErrors.Add("[UI] unrelated rendering failure")
Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null
& $script:capturedRunspaceBody `
-JobName "Example" `
-JobLabel "Example" `
-JobBody '$null = $sync.LoggedErrors.Add("[UI] another unrelated failure")' `
-JobParameters @{} `
-JobRestoresAppList $false `
-JobToken $script:capturedRunspaceArgs["JobToken"]
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Example finished" -and $State -eq "None" -and $Overlay -eq "checkmark"
}
}
It "surfaces warnings and non-terminating errors raised by the body" {
$script:sync.LoggedErrors = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new())
Start-WinUtilJob -Name "Example" -ScriptBlock { } | Out-Null
& $script:capturedRunspaceBody `
-JobName "Example" `
-JobLabel "Example" `
-JobBody 'Write-Warning "a warning"' `
-JobParameters @{} `
-JobRestoresAppList $false `
-JobToken $script:capturedRunspaceArgs["JobToken"]
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Level -eq "WARN" -and $Message -eq "a warning"
}
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Example finished with 1 warning(s)" -and $State -eq "Paused" -and $Overlay -eq "warning"
}
$script:sync.LastJobResult.Warnings | Should -Be 1
$script:sync.LastJobResult.Errors | Should -Be 0
}
It "finishes with a warning when elevated install skips a user-scoped package" {
Start-WinUtilJob -Name "Install" -ScriptBlock { } | Out-Null
& $script:capturedRunspaceBody `
-JobName "Install" `
-JobLabel "Installing apps" `
-JobBody 'Complete-WinUtilPackageRun -Action "Install" -Results @([pscustomobject]@{ Package = "Microsoft.Sysinternals.ProcessExplorer"; Action = "Install"; ExitCode = -1978335107; Outcome = "Skipped"; Detail = "already installed for the current user; elevated WinUtil cannot update it" })' `
-JobParameters @{} `
-JobRestoresAppList $false `
-JobToken $script:capturedRunspaceArgs["JobToken"]
Should -Invoke -CommandName Step-WinUtilJob -Times 1 -Exactly -ParameterFilter {
$Status -eq "Install finished with 1 warning(s)" -and $State -eq "Paused" -and $Overlay -eq "warning"
}
}
It "restores the app list after a failing job that disabled it" {
Start-WinUtilJob -Name "Install" -DisableAppList -ScriptBlock { } | Out-Null
$script:sync.ActiveJob = "Install"
Mock Write-Host { }
& $script:capturedRunspaceBody `
-JobName "Install" `
-JobBody 'throw "boom"' `
-JobParameters @{} `
-JobRestoresAppList $true `
-JobToken $script:capturedRunspaceArgs["JobToken"]
$script:sync.ItemsControl.IsEnabled | Should -BeTrue
$script:sync.ActiveJob | Should -BeNullOrEmpty
}
It "releases the job when dispatcher shutdown aborts the app-list restore" {
$script:dispatchCount = 0
Mock Invoke-WPFUIThread {
$script:dispatchCount++
if ($script:dispatchCount -gt 1) {
throw [System.Threading.Tasks.TaskCanceledException]::new("dispatcher stopped")
}
& $ScriptBlock
}
Start-WinUtilJob -Name "Install" -DisableAppList -ScriptBlock { } | Out-Null
{
& $script:capturedRunspaceBody `
-JobName "Install" `
-JobBody '$null = $true' `
-JobParameters @{} `
-JobRestoresAppList $true `
-JobToken $script:capturedRunspaceArgs["JobToken"]
} | Should -Not -Throw
$script:sync.ActiveJob | Should -BeNullOrEmpty
$script:sync.ActiveJobToken | Should -BeNullOrEmpty
Should -Invoke Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Level -eq "WARN" -and $Message -like "Could not restore the app list*"
}
}
}
Describe "Job timing summaries" {
BeforeEach {
$script:sync = [Hashtable]::Synchronized(@{
StepTimings = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new())
})
$null = $sync.StepTimings.Add([pscustomobject]@{ Scope = "Install"; Step = "old"; Milliseconds = 100 })
$null = $sync.StepTimings.Add([pscustomobject]@{ Scope = "Install"; Step = "current"; Milliseconds = 25 })
Mock Write-WinUtilLog { }
}
It "excludes entries recorded before the current run" {
Write-WinUtilTimingSummary -Scope "Install" -TotalMilliseconds 30 -StartIndex 1
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Message -eq "timing summary: 1 step(s), 25 ms measured of 30 ms total"
}
}
}
+70
View File
@@ -0,0 +1,70 @@
#===========================================================================
# Tests - Everything that changes the system goes through the job layer
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$script:functionRoot = Join-Path $script:repoRoot "functions"
$script:configRoot = Join-Path $script:repoRoot "config"
}
Describe "Work routing" {
It "never hands work to a console window of its own" {
# A separate console takes the work outside the job layer, so it gets no progress bar,
# no taskbar state and no log lines, and the window outlives WinUtil
$offenders = @()
foreach ($file in (Get-ChildItem -Path $script:functionRoot -Filter *.ps1 -Recurse)) {
$text = Get-Content -Path $file.FullName -Raw
foreach ($line in ($text -split "`r?`n")) {
if ($line -match '^\s*#') { continue }
if ($line -match 'wt\s+new-tab') { $offenders += "$($file.Name): $($line.Trim())" }
if ($line -match 'Start-Process\s+.*(powershell|pwsh)\.exe' -and $line -notmatch '-NoNewWindow') {
$offenders += "$($file.Name): $($line.Trim())"
}
}
}
if ($offenders.Count -gt 0) { throw ($offenders -join "`n") }
}
It "probes for optional commands without throwing when they are absent" {
$offenders = @()
foreach ($file in (Get-ChildItem -Path $script:functionRoot -Filter *.ps1 -Recurse)) {
$text = Get-Content -Path $file.FullName -Raw
foreach ($line in ($text -split "`r?`n")) {
if ($line -match 'if\s*\(\s*-not\s*\(Get-Command\s+[^\)]+\)\s*\)' -and $line -notmatch 'ErrorAction') {
$offenders += "$($file.Name): $($line.Trim())"
}
}
}
if ($offenders.Count -gt 0) { throw ($offenders -join "`n") }
}
It "never leaves the runspace handle in the pipeline" {
# Invoke-WPFRunspace returns an IAsyncResult. An unassigned call prints it to the
# console, or folds it into whatever the calling workflow returns.
$offenders = @()
foreach ($file in (Get-ChildItem -Path $script:functionRoot -Filter *.ps1 -Recurse)) {
if ($file.Name -eq "Invoke-WPFRunspace.ps1") { continue }
foreach ($line in ((Get-Content -Path $file.FullName -Raw) -split "`r?`n")) {
if ($line -match '^\s*Invoke-WPFRunspace\b') { $offenders += "$($file.Name): $($line.Trim())" }
}
}
if ($offenders.Count -gt 0) { throw ($offenders -join "`n") }
}
}
+57 -20
View File
@@ -1,14 +1,20 @@
#===========================================================================
# Tests - Lazy tab initialization
#===========================================================================
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
. (Join-Path $script:repoRoot "functions\private\Measure-WinUtilStep.ps1")
function Test-WinUtilUIAlive { $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher }
function Write-WinUtilLog {
param($Message, $Level, $Component)
}
function Invoke-WPFUIElements {
param($configVariable, [string]$targetGridName, [int]$columncount)
}
function Invoke-WinUtilISOCheckExistingWork { }
function Initialize-WinUtilInstallTabControls { }
function Reset-WPFCheckBoxes { param([bool]$doToggles) }
. (Join-Path $script:repoRoot "functions\public\Initialize-WPFUI.ps1")
@@ -49,21 +55,18 @@ Describe "Initialize-WinUtilTabContent" {
$script:sync.InitializedTabs["Install"] | Should -BeTrue
}
It "re-applies checkbox selections after building a tab's controls" {
Initialize-WinUtilTabContent -TabName "Tweaks"
It "leaves the app navigation to Initialize-WPFUI so its buttons keep their handlers" {
# Rendering it here as well built the navigation twice. The second pass cleared the
# first one's controls, and because the "already wired" guard goes by name the
# replacements counted as wired and never got a click handler, so Install and
# Uninstall did nothing at all.
Initialize-WinUtilTabContent -TabName "Install"
Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly -ParameterFilter {
$doToggles -eq $true
Should -Invoke -CommandName Invoke-WPFUIElements -Times 0 -Exactly -ParameterFilter {
$targetGridName -eq "appscategory"
}
}
It "does not re-apply checkbox selections on a tab that's already built" {
Initialize-WinUtilTabContent -TabName "Tweaks"
Initialize-WinUtilTabContent -TabName "Tweaks"
Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly
}
It "initializes deferred config-backed tabs once" {
Initialize-WinUtilTabContent -TabName "Tweaks"
Initialize-WinUtilTabContent -TabName "Config"
@@ -83,6 +86,22 @@ Describe "Initialize-WinUtilTabContent" {
}
}
It "re-applies checkbox selections after building a tab's controls" {
# controls built just now start unchecked, so an import or a preset has to reach them
Initialize-WinUtilTabContent -TabName "Tweaks"
Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly -ParameterFilter {
$doToggles -eq $true
}
}
It "does not re-apply checkbox selections on a tab that is already built" {
Initialize-WinUtilTabContent -TabName "Tweaks"
Initialize-WinUtilTabContent -TabName "Tweaks"
Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly
}
It "checks for existing Win11ISO work when the tab is initialized" {
Add-Type -AssemblyName WindowsBase
$dispatcher = [pscustomobject]@{}
@@ -128,14 +147,15 @@ Describe "Initialize-WPFUI" {
}
Describe "Startup lazy tab wiring" {
It "builds only install tab content before first paint" {
$mainScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\main.ps1") -Raw
$startupRegion = $mainScript.Substring(0, $mainScript.IndexOf("# Store Form Objects In PowerShell"))
It "builds no tab content before first paint" {
# Startup moved out of main.ps1 and onto the interface thread. Nothing is built up front
# at all now: Invoke-WPFTab builds whichever tab it selects, and the warmup does the rest.
$uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw
$startupRegion | Should -Match 'Initialize-WinUtilTabContent -TabName "Install"'
$startupRegion | Should -Not -Match 'targetGridName "tweakspanel"'
$startupRegion | Should -Not -Match 'targetGridName "featurespanel"'
$startupRegion | Should -Not -Match 'targetGridName "appxpanel"'
$uiScript | Should -Match 'Invoke-WPFTab "WPFTab1BT"'
$uiScript | Should -Not -Match 'targetGridName "tweakspanel"'
$uiScript | Should -Not -Match 'targetGridName "featurespanel"'
$uiScript | Should -Not -Match 'targetGridName "appxpanel"'
}
It "initializes tab content when a tab is selected" {
@@ -150,7 +170,9 @@ Describe "Startup lazy tab wiring" {
$rendererScript | Should -Match '(?s)"Button"\s*\{.*\$button\.Add_Click\(\{.*Invoke-WPFButton \$Sender\.name'
$rendererScript | Should -Match '\$sync\.Buttons\.Add\(\$button\.Name\)'
$mainScript | Should -Match '\$sync\.Buttons -notcontains \$psitem'
# The "already wired" gate moved to the interface thread with the rest of startup
$uiScript = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Start-WinUtilUserInterface.ps1") -Raw
$uiScript | Should -Match '\$alreadyWired'
}
It "binds generated documentation links when lazy panels are rendered" {
@@ -167,4 +189,19 @@ Describe "Startup lazy tab wiring" {
$rendererScript | Should -Match '\$hasOuterScrollViewer'
$rendererScript | Should -Match 'if\s*\(\$hasOuterScrollViewer\)'
}
It "reapplies the live search after yielded tab controls finish rendering" {
$rendererScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIElements.ps1") -Raw
$tabScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFTab.ps1") -Raw
$rendererScript | Should -Match 'Find-TweaksByNameOrDescription -SearchString \$sync\.SearchBar\.Text'
$tabScript | Should -Match 'Find-TweaksByNameOrDescription -SearchString \$searchText'
$tabScript | Should -Match 'Find-AppsByNameOrDescription -SearchString \$searchText'
}
It "passes the yield frame into the dispatcher callback" {
$rendererScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIElements.ps1") -Raw
$rendererScript | Should -Match '(?s)DispatcherOperationCallback\]\s*\{\s*param\(\$dispatcherFrame\).*\$dispatcherFrame\.Continue = \$false.*\},\s*\$frame\)'
}
}
+68 -25
View File
@@ -1,6 +1,5 @@
#===========================================================================
# Tests - WinUtil Logging
#===========================================================================
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
@@ -18,6 +17,8 @@ Describe "Write-WinUtilLog" {
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name WinUtilLogPath -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name WinUtilIsJobWorker -Scope Global -ErrorAction SilentlyContinue
Remove-Variable -Name WinUtilJobErrorCount -Scope Global -ErrorAction SilentlyContinue
Remove-Item -Path $script:testRoot -Recurse -Force -ErrorAction SilentlyContinue
}
@@ -35,22 +36,60 @@ Describe "Write-WinUtilLog" {
Get-Content -Path $logPath -Raw | Should -Match "\[INFO\] \[Test\] same session log"
}
It "uses the transcript stream when logPath is not set" {
$transcriptPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log"
It "writes through the host when the transcript owns the active session log" {
$logPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log"
$script:sync = [hashtable]::Synchronized(@{
logPath = $logPath
transcriptPath = $logPath
})
Mock Write-Host { }
Mock Add-Content { }
Write-WinUtilLog -Component "Test" -Message "transcript entry"
Should -Invoke Write-Host -Times 1 -Exactly -ParameterFilter {
$Object -match "\[INFO\] \[Test\] transcript entry"
}
Should -Invoke Add-Content -Times 0 -Exactly
}
It "writes entries produced concurrently by several threads" {
$logPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log"
$script:sync = [hashtable]::Synchronized(@{
winutildir = $script:testRoot
transcriptPath = $transcriptPath
logPath = $logPath
})
Mock Add-Content { }
Mock Write-Host { }
Write-WinUtilLog -Component "Test" -Message "transcript fallback"
$logFunction = Get-Content -Path (Join-Path $script:repoRoot "functions\private\Write-WinUtilLog.ps1") -Raw
$initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
$initialSessionState.Variables.Add(
(New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList "sync", $script:sync, $null)
)
$pool = [runspacefactory]::CreateRunspacePool(1, 4, $initialSessionState, $Host)
$pool.Open()
Should -Invoke -CommandName Add-Content -Times 0 -Exactly
Should -Invoke -CommandName Write-Host -Times 1 -Exactly -ParameterFilter {
$Object -match "\[INFO\] \[Test\] transcript fallback"
try {
$handles = foreach ($index in 1..12) {
$shell = [powershell]::Create()
$shell.RunspacePool = $pool
[void]$shell.AddScript($logFunction)
[void]$shell.AddScript("Write-WinUtilLog -Component 'Test' -Message 'entry $index'")
[pscustomobject]@{ Shell = $shell; Handle = $shell.BeginInvoke() }
}
foreach ($item in $handles) {
$item.Shell.EndInvoke($item.Handle)
$item.Shell.Dispose()
}
} finally {
$pool.Close()
$pool.Dispose()
}
$content = Get-Content -Path $logPath
foreach ($index in 1..12) {
@($content | Where-Object { $_ -match "\[Test\] entry $index$" }).Count | Should -Be 1
}
Test-Path -Path (Join-Path $script:testRoot "winutil.log") | Should -BeFalse
}
It "creates one fallback log under logs when only winutildir is available" {
@@ -70,38 +109,42 @@ Describe "Write-WinUtilLog" {
$content | Should -Match "second fallback entry"
}
It "does not append directly when the active log file is the transcript" {
It "falls back to host output when the log file cannot be opened" {
$logPath = Join-Path $script:testRoot "logs\winutil_2026-07-01_12-00-00.log"
$script:sync = [hashtable]::Synchronized(@{
winutildir = $script:testRoot
logPath = $logPath
transcriptPath = $logPath
})
Mock Add-Content { throw [System.IO.IOException]::new("locked by transcript") } -ParameterFilter {
Mock Add-Content { throw [System.IO.IOException]::new("file is locked") } -ParameterFilter {
$Path -eq $logPath -and $ErrorAction -eq "Stop"
}
Mock Write-Host { }
Mock Write-Warning { }
Write-WinUtilLog -Component "Test" -Message "transcript stream fallback"
Write-WinUtilLog -Component "Test" -Message "locked file fallback"
Should -Invoke -CommandName Add-Content -Times 0 -Exactly
Should -Invoke -CommandName Write-Host -Times 1 -Exactly -ParameterFilter {
$Object -match "\[INFO\] \[Test\] transcript stream fallback"
$Object -match "\[INFO\] \[Test\] locked file fallback"
}
Should -Invoke -CommandName Write-Warning -Times 0 -Exactly
}
}
It "counts only headline errors written by the active job worker" {
$script:sync = [hashtable]::Synchronized(@{
winutildir = $script:testRoot
LoggedErrors = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new())
})
$global:WinUtilIsJobWorker = $true
$global:WinUtilJobErrorCount = 0
Describe "WinUtil startup logging path" {
It "uses one timestamped log file under the logs directory" {
$startScript = Get-Content -Path (Join-Path $script:repoRoot "scripts\start.ps1") -Raw
Write-WinUtilLog -Level "ERROR" -Component "Test" -Message "job error"
Write-WinUtilLog -Level "ERROR" -Detail -Component "Test" -Message "error detail"
$global:WinUtilIsJobWorker = $false
Write-WinUtilLog -Level "ERROR" -Component "UI" -Message "unrelated error"
$startScript | Should -Match '\$sync\.logPath = "\$logdir\\winutil_\$dateTime\.log"'
$startScript | Should -Match '\$sync\.transcriptPath = \$sync\.logPath'
$startScript | Should -Match 'Start-Transcript -Path \$sync\.logPath'
$startScript | Should -Not -Match '\$sync\.logPath = "\$winutildir\\winutil\.log"'
$global:WinUtilJobErrorCount | Should -Be 1
$script:sync.LoggedErrors.Count | Should -Be 2
}
}
-7
View File
@@ -1,6 +1,5 @@
#===========================================================================
# Tests - Multiplane Overlay
#===========================================================================
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
@@ -27,13 +26,7 @@ Describe "Multiplane Overlay configuration" {
$script:states[1].Values.PSObject.Properties.Value | Should -Be @("<RemoveEntry>", "<RemoveEntry>", "1")
}
It "uses the generic combo registry handler" {
$renderer = Get-Content (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIElements.ps1") -Raw
$renderer | Should -Match 'Get-WinUtilRegistryComboState'
$renderer | Should -Match 'Set-WinUtilRegistryComboState'
$renderer | Should -Not -Match 'WPFMultiplaneOverlay'
}
}
Describe "Get-WinUtilRegistryComboState" {

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