Compare commits

..
Author SHA1 Message Date
Chris Titus e912126b74 Update support section with new product link 2026-08-14 08:43:55 -05:00
181 changed files with 4256 additions and 14491 deletions
+1 -4
View File
@@ -3,7 +3,4 @@
# Seanh1917 (ANGRYxScotsmans) is the docs guy
docs/ @ChrisTitusTech @seanh1995
tools/devdocs-generator.* @ChrisTitusTech @seanh1995
# Title screen generation
tools/title-screen/ @ChrisTitusTech @mewclouds
tools/devdocs-generator.* @ChrisTitusTech @seanh1995
+1 -1
View File
@@ -91,7 +91,7 @@ graph TD
* Run the following command to compile and run WinUtil:
* `.\Compile.ps1 -run`
![Compile](/docs/src/assets/contributing/Compile.png)
![Compile](/docs/src/assets/contributing/Complie.png)
Open PowerShell as Administrator.
+3 -6
View File
@@ -43,16 +43,14 @@ body:
- type: textarea
id: issue_description
attributes:
label: "Description of the issue"
description: Provide a clear and concise description of the issue.
label: Provide a clear and concise description of the issue.
validations:
required: true
- type: textarea
id: tweaks_applied
attributes:
label: "List of Applied Tweaks"
description: List the tweaks you applied before the issue occurred.
label: List the tweaks you applied before the issue occurred.
validations:
required: false
@@ -60,5 +58,4 @@ body:
id: error_output
attributes:
label: Paste the full error output (if available) or Screenshot or Video.
description: "If you're willing to share the whole log, please provide an external link to text-sharing service, i.e. Pastebin"
placeholder: "Include any relevant logs or error messages. Keep any sensitive info out of the presented log"
placeholder: "Include any relevant logs or error messages."
@@ -1,207 +0,0 @@
name: Generate WinUtil title screen
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: winutil-title-screen
cancel-in-progress: true
jobs:
generate:
runs-on: windows-latest
timeout-minutes: 15
env:
WINUTIL_CAPTURE_WIDTH: "1920"
WINUTIL_CAPTURE_HEIGHT: "1080"
defaults:
run:
shell: pwsh
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
ref: main
persist-credentials: false
- name: Install uv and Python
uses: astral-sh/setup-uv@v10.2.0
with:
version: "0.12.0"
python-version: "3.13"
enable-cache: false
- name: Set display resolution
run: |
# Set-DisplayResolution is provided by Windows PowerShell's ServerCore
# module, so this step intentionally calls powershell.exe from pwsh.
& powershell.exe -NoLogo -NoProfile -Command @'
Set-DisplayResolution `
-Width $env:WINUTIL_CAPTURE_WIDTH `
-Height $env:WINUTIL_CAPTURE_HEIGHT `
-Force
'@
if ($LASTEXITCODE -ne 0) {
throw "Failed to set the runner display resolution."
}
Add-Type @"
using System.Runtime.InteropServices;
public static class ResolutionCheck {
[DllImport("user32.dll")]
public static extern int GetSystemMetrics(int index);
}
"@
$actualWidth = [ResolutionCheck]::GetSystemMetrics(0)
$actualHeight = [ResolutionCheck]::GetSystemMetrics(1)
if (
$actualWidth -ne [int]$env:WINUTIL_CAPTURE_WIDTH -or
$actualHeight -ne [int]$env:WINUTIL_CAPTURE_HEIGHT
) {
throw (
"The hosted runner rejected the requested resolution. " +
"Requested: $env:WINUTIL_CAPTURE_WIDTH" +
"x$env:WINUTIL_CAPTURE_HEIGHT; " +
"actual: ${actualWidth}x${actualHeight}."
)
}
- name: Report display environment
run: |
Add-Type @"
using System.Runtime.InteropServices;
public static class DisplayInfo {
[DllImport("user32.dll")]
public static extern int GetSystemMetrics(int index);
}
"@
$width = [DisplayInfo]::GetSystemMetrics(0)
$height = [DisplayInfo]::GetSystemMetrics(1)
$sessionId = [Diagnostics.Process]::GetCurrentProcess().SessionId
Write-Host "Resolution: ${width}x${height}"
Write-Host "Session ID: $sessionId"
Write-Host "User: $env:USERNAME"
- name: Compile WinUtil
run: |
Set-ExecutionPolicy Bypass -Scope Process -Force
./Compile.ps1
- name: Launch WinUtil
run: |
$scriptPath = Join-Path $env:GITHUB_WORKSPACE "winutil.ps1"
$command = "& '$scriptPath'"
$bytes = [Text.Encoding]::Unicode.GetBytes($command)
$encodedCommand = [Convert]::ToBase64String($bytes)
# Hide the console host while leaving the WPF window available.
$process = Start-Process powershell.exe -ArgumentList @(
"-NoLogo",
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-EncodedCommand", $encodedCommand
) -WindowStyle Hidden -PassThru
"WINUTIL_HOST_PID=$($process.Id)" |
Out-File $env:GITHUB_ENV -Append -Encoding utf8
$deadline = (Get-Date).AddSeconds(90)
do {
Start-Sleep -Seconds 2
# A versioned title excludes the unversioned console host.
$window = Get-Process |
Where-Object { $_.MainWindowTitle -match '^WinUtil\s+\d' } |
Select-Object -First 1
} until ($window -or (Get-Date) -ge $deadline)
if (-not $window) {
throw "WinUtil did not expose a window within 90 seconds."
}
Write-Host "WinUtil HWND: $($window.MainWindowHandle)"
Write-Host "WinUtil title: $($window.MainWindowTitle)"
# The exact HWND avoids desktop-enumeration ambiguity. Python validates
# the title and WPF class before using it.
"WINUTIL_HWND=$($window.MainWindowHandle)" |
Out-File $env:GITHUB_ENV -Append -Encoding utf8
- name: Generate title screen
working-directory: tools/title-screen
run: |
& uv run --locked python automate_title_screen.py `
--output ..\..\docs\src\assets\branding\title-screen.png 2>&1 |
Tee-Object -FilePath "$env:RUNNER_TEMP\title-screen-capture.log"
if ($LASTEXITCODE -ne 0) {
throw "Title-screen automation exited with code $LASTEXITCODE."
}
- name: Create title-screen update pull request
id: cpr
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.AUTO_MERGE }}
add-paths: docs/src/assets/branding/title-screen.png
base: main
branch: title-screen-update
delete-branch: true
commit-message: "docs: update WinUtil title screen"
title: "docs: update WinUtil title screen"
body: |
Regenerates the WinUtil Light and Dark title-screen composite from the current main branch.
Source workflow: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
labels: |
automated
documentation
skip-changelog
- name: Report pull request
if: steps.cpr.outputs.pull-request-url
env:
PR_OPERATION: ${{ steps.cpr.outputs.pull-request-operation }}
PR_URL: ${{ steps.cpr.outputs.pull-request-url }}
run: |
Write-Host "Pull request $env:PR_OPERATION`: $env:PR_URL"
- name: Inspect UI Automation on failure
if: failure()
continue-on-error: true
working-directory: tools/title-screen
run: |
uv run --locked python inspect_winutil.py `
"$env:RUNNER_TEMP\winutil-title-screen-inspect.txt"
- name: Upload failure diagnostics
if: failure()
uses: actions/upload-artifact@v7
with:
name: winutil-title-screen-failure-${{ github.run_number }}
path: |
docs/src/assets/branding/title-screen.png
${{ runner.temp }}/title-screen-capture.log
${{ runner.temp }}/winutil-title-screen-inspect.txt
if-no-files-found: warn
retention-days: 14
- name: Close WinUtil
if: always()
run: |
if ($env:WINUTIL_HOST_PID) {
Stop-Process `
-Id ([int]$env:WINUTIL_HOST_PID) `
-Force `
-ErrorAction SilentlyContinue
}
-212
View File
@@ -1,212 +0,0 @@
name: Repo Contributors' Issue Triage Tools
on:
issue_comment:
types: [created, edited]
jobs:
triage-tools:
if: ${{ !github.event.issue.pull_request }}
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: none
contents: none
steps:
- name: Process issue command
uses: actions/github-script@v9
with:
script: |
const trustedUsers = [
7896101, // ChrisTitusTech
57459428, // FluffyPunk
125669256, // FallenGME
90123670, // mewclouds
121827219, // MyDrift-user
17331812, // Callum
101426328, // CodingWonders
70659536 // og-mrk
];
if (context.payload.issue.pull_request) {
console.log("Pull request comments are not supported. Exiting.");
return;
}
const comment = context.payload.comment;
const commentAuthor = comment.user;
if (!trustedUsers.includes(commentAuthor.id)) {
console.log(`Comment author ${commentAuthor.login} is not a trusted user. Exiting.`);
return;
}
const {owner, repo} = context.repo;
const issueNumber = context.issue.number;
const command = comment.body.trim().toLowerCase();
const triageMatch = command.match(/^\/triage$/);
const triageOffMatch = command.match(/^\/triageoff$/);
const notPlannedMatch = command.match(/^\/np(?:\s+(\w+))?$/);
const duplicateMatch = command.match(/^\/duplicate\s+#?(\d+)$/);
if (triageMatch) {
const issue = await github.rest.issues.get({
owner,
repo,
issue_number: issueNumber
});
if (issue.data.labels.some(label => label.name === "needs-triage")) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: "This issue already has the 'needs-triage' label. Use /triageoff to remove it."
});
} else {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: ["needs-triage"]
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `${commentAuthor.login} requested maintainer triage for this issue.`
});
}
} else if (triageOffMatch) {
const issue = await github.rest.issues.get({
owner,
repo,
issue_number: issueNumber
});
if (issue.data.labels.some(label => label.name === "needs-triage")) {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: issueNumber,
name: "needs-triage"
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `${commentAuthor.login} removed the triage request from this issue.`
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: "This issue is not currently marked for triage."
});
}
} else if (notPlannedMatch) {
const reason = notPlannedMatch[1];
if (reason === "wontfix") {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: ["wontfix"]
});
} else if (reason === "notrelated") {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: ["not-related"]
});
}
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: "closed",
state_reason: "not_planned"
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `${commentAuthor.login} closed this issue as not planned.`
});
} else if (duplicateMatch) {
const duplicateIssueNumber = Number(duplicateMatch[1]);
if (!Number.isSafeInteger(duplicateIssueNumber) || duplicateIssueNumber < 1) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: "The duplicate target must be a positive issue number."
});
} else if (duplicateIssueNumber === issueNumber) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: "An issue cannot be marked as a duplicate of itself."
});
} else {
let duplicateIssue;
try {
const response = await github.rest.issues.get({
owner,
repo,
issue_number: duplicateIssueNumber
});
duplicateIssue = response.data;
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
if (duplicateIssue?.pull_request) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `#${duplicateIssueNumber} is a pull request, not an issue.`
});
} else if (duplicateIssue) {
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: "closed",
state_reason: "duplicate",
duplicate_issue_id: duplicateIssue.id
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `${commentAuthor.login} closed this issue as a duplicate of #${duplicateIssueNumber}.`
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: `Issue #${duplicateIssueNumber} does not exist.`
});
}
}
} else {
console.log("Comment does not contain a supported issue command. Exiting.");
return;
}
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id
});
-3
View File
@@ -10,9 +10,6 @@ testResults.xml
# general software/os specific
desktop.ini
.DS_Store
__pycache__/
*.pyc
.venv/
.vscode/
.idea/
+3 -11
View File
@@ -63,7 +63,7 @@ Given the current wave of npm/pnpm/yarn supply-chain worms (malicious postinstal
- Treat any `postinstall`/`preinstall` lifecycle script in a new dependency as worth flagging to the user before installing — summarize what it does.
- Don't put real secrets anywhere under `docs/`. `docs/.dockerignore` only trims what `docker build` copies into the image — it does not affect the `docker compose` bind mount, which exposes the entire `docs/` directory (including any `.env` file) inside the container for every dev/build/preview command (see the next bullet). There is no "keep it out unless mounted" middle ground here.
- The container mounts `docs/` as a volume, so file edits on the host are reflected inside the container immediately — no rebuild needed for normal code changes, only when `docs/package.json`/`docs/package-lock.json` change (see the rebuild-and-drop-volume steps above).
- This Docker requirement is specific to `docs/`. PowerShell tooling runs directly on the host per Section 1. The Python project under `tools/title-screen/` runs with uv as documented in its README.
- This Docker requirement is specific to `docs/`. The rest of the repo is PowerShell (`Compile.ps1`, Pester, Script Analyzer) and runs directly on the host per Section 1.
## 3. Source Of Truth
@@ -127,7 +127,6 @@ If a check cannot be run, say exactly why and what residual risk remains. See SP
- Treat local `winutil.ps1` changes as disposable compile output.
- Never stage or commit `winutil.ps1`, `binary/`, or anything else ignored by the root `.gitignore` or `docs/.gitignore` — read those files rather than assuming. `docs/public/` is tracked source for static assets, not generated output.
- `docs/src/assets/branding/title-screen.png` is a tracked generated asset. Do not edit it manually. Update `tools/title-screen/` or run the title-screen workflow.
- Do not remove `.gitignore` rules that keep generated artifacts out of Git.
- Before finishing, check `git status --short` and separate your changes from pre-existing user changes.
- Do not revert user changes unless explicitly asked.
@@ -175,16 +174,9 @@ When the user corrects an agent approach, add or tighten one concrete rule here
- Import Pester 5.8.0 before running tests so `Invoke-Pester -Output Detailed -CI` does not resolve to Windows' inbox Pester 3.4.0.
- Keep package install/uninstall process launches simple unless explicitly requested; do not add a separate stdout/stderr process logging helper for winget or Chocolatey.
- When the active log file is owned by `Start-Transcript`, do not call `Add-Content` against that file; write to host output so the transcript captures the line in the same log file without recording a terminating-error diagnostic.
- Keep UI helpers such as `Invoke-WPFUIThread` and `Step-WinUtilJob` safe to call without a window; the `-Preset` and `-Config` paths run the workflows before the form is created and before PresentationCore is loaded. Ask `Test-WinUtilUIAlive` rather than writing the `$sync.Form` / dispatcher / `HasShutdownStarted` check out by hand.
- Put long operations on the job layer with `Start-WinUtilJob` and report from them with `Step-WinUtilJob`; a job body must not set the busy flag, print its own banner, or carry its own try/catch/finally around the interface. `Invoke-WPFRunspace` directly is for fire-and-forget work that is not a job.
- Drain interface work that nobody is waiting for through `Start-WinUtilBackgroundQueue`; do not hand-roll another dequeue-and-re-post pump.
- Values a posted scriptblock needs travel as the dispatcher's argument or through `-Parameters`, never captured from the caller: a plain block resolves them when the dispatcher gets to it, and `GetNewClosure` binds command lookup to a copied scope. For the same reason, prefer a compiled `[action]` over `Invoke-WPFUIThread -Async` on hot re-posting paths, which marshals its body as text and recompiles it per post.
- Diagnostic scaffolding does not ship. Measure with it, then delete it.
- Have each Pester file load the assemblies and dot-source the functions it needs; several passed only because an earlier file in alphabetical order happened to load them.
- 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.
- 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, inject storage controllers (`SCSIAdapter` / `HDC`) into `boot.wim` index 2 with DISM, not `$WinpeDriver$`. Keep `install.wim` on its own per-package `/Add-Driver` retry loop. For each image, add each root package folder with its own `/Add-Driver /Recurse` so a single bad driver cannot fail the rest, and skip any folder whose ancestor is already in the set, since that ancestor's `/Recurse` covers it. Warn per failure and commit only when at least one package was added; when none were, warn and discard rather than throwing, so the run still produces an ISO. The discard in the cleanup block carries both orphaned mounts and that intentional zero-added case; keep it. Do not export editions or run unrelated WIM cleanup, and reject damaged metadata before ISO export. Use `-LiteralPath` for driver export paths, since `%TEMP%` can contain wildcard characters.
- For Win11 Creator driver injection, keep offline WIM servicing to one mount, one `/Add-Driver`, and one commit; do not export editions or run unrelated WIM cleanup, and reject damaged metadata before ISO export.
- For Script Analyzer cleanup, fix actionable source warnings first and do not globally suppress accepted convention warnings such as plural names, `ShouldProcess` on UI helpers, `$global:sync`, or compile-time cross-file false positives.
- For DNS DHCP reset, keep the cmdlet reset and explicitly set IPv4 and IPv6 DNS source to DHCP.
- Public pull-request diffs may be sent to configured external review services without a separate privacy approval; do not block the review loop on upload authorization for this public repository.
- Keep install-tab favicon loading overlapped with app-entry rendering; do not replace native WPF loading with a deferred second phase unless visible completion time is proven no slower than `main`.
-2
View File
@@ -9,8 +9,6 @@ $sync = [Hashtable]::Synchronized(@{})
$sync.configs = @{}
$script = (Get-Content -Path scripts\start.ps1) -replace '#{replaceme}', (Get-Date -Format 'yy.MM.dd')
$isLocalCompile = -not [string]::Equals($env:GITHUB_ACTIONS, "true", [StringComparison]::OrdinalIgnoreCase)
$script = $script -replace '#{islocalcompile}', $isLocalCompile.ToString().ToLowerInvariant()
$script += Get-ChildItem -Path functions -Recurse -File | ForEach-Object {
Get-Content -Path $_.FullName -Raw
+2 -4
View File
@@ -13,7 +13,7 @@ A curated compilation of Windows system tasks streamline **installs**, debloat w
## Quick Start
> **WinUtil must be run as Administrator** because it performs system-wide changes.
> **WinUtil must be run as Administrator** Because it performs system-wide changes.
Open PowerShell or Terminal as admin, then run:
@@ -78,9 +78,7 @@ See https://github.com/ChrisTitusTech/winutil/blob/main/.github/CONTRIBUTING.md
These are the sponsors that help keep this project alive with monthly contributions.
<!-- sponsors --><a href="https://github.com/dwelfusius"><img src="https:&#x2F;&#x2F;github.com&#x2F;dwelfusius.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/mews-se"><img src="https:&#x2F;&#x2F;github.com&#x2F;mews-se.png" width="60px" alt="User avatar: Martin" /></a><a href="https://github.com/jdiegmueller"><img src="https:&#x2F;&#x2F;github.com&#x2F;jdiegmueller.png" width="60px" alt="User avatar: Jason A. Diegmueller" /></a><a href="https://github.com/robertsandrock"><img src="https:&#x2F;&#x2F;github.com&#x2F;robertsandrock.png" width="60px" alt="User avatar: RMS" /></a><a href="https://github.com/paulsheets"><img src="https:&#x2F;&#x2F;github.com&#x2F;paulsheets.png" width="60px" alt="User avatar: Paul" /></a><a href="https://github.com/djones369"><img src="https:&#x2F;&#x2F;github.com&#x2F;djones369.png" width="60px" alt="User avatar: Dave J (WhamGeek)" /></a><a href="https://github.com/anthonymendez"><img src="https:&#x2F;&#x2F;github.com&#x2F;anthonymendez.png" width="60px" alt="User avatar: Anthony Mendez" /></a><a href="https://github.com/FatBastard0"><img src="https:&#x2F;&#x2F;github.com&#x2F;FatBastard0.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/DursleyGuy"><img src="https:&#x2F;&#x2F;github.com&#x2F;DursleyGuy.png" width="60px" alt="User avatar: DursleyGuy" /></a><a href="https://github.com/DwayneTheRockLobster1"><img src="https:&#x2F;&#x2F;github.com&#x2F;DwayneTheRockLobster1.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/KieraKujisawa"><img src="https:&#x2F;&#x2F;github.com&#x2F;KieraKujisawa.png" width="60px" alt="User avatar: Kiera Meredith" /></a><a href="https://github.com/seanh1995"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanh1995.png" width="60px" alt="User avatar: Sean (ANGRYxScotsman)" /></a><a href="https://github.com/F-L-Perez"><img src="https:&#x2F;&#x2F;github.com&#x2F;F-L-Perez.png" width="60px" alt="User avatar: Fra · ppe" /></a><a href="https://github.com/josencarnacao"><img src="https:&#x2F;&#x2F;github.com&#x2F;josencarnacao.png" width="60px" alt="User avatar: José Encarnação" /></a><!-- sponsors -->
*<sub>Sponsors with a recurring subscription also get access to the .NET alternative.</sub>
<!-- sponsors --><a href="https://github.com/ysaito8015"><img src="https:&#x2F;&#x2F;github.com&#x2F;ysaito8015.png" width="60px" alt="User avatar: Yusuke Saito" /></a><a href="https://github.com/dwelfusius"><img src="https:&#x2F;&#x2F;github.com&#x2F;dwelfusius.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/mews-se"><img src="https:&#x2F;&#x2F;github.com&#x2F;mews-se.png" width="60px" alt="User avatar: Martin" /></a><a href="https://github.com/jdiegmueller"><img src="https:&#x2F;&#x2F;github.com&#x2F;jdiegmueller.png" width="60px" alt="User avatar: Jason A. Diegmueller" /></a><a href="https://github.com/robertsandrock"><img src="https:&#x2F;&#x2F;github.com&#x2F;robertsandrock.png" width="60px" alt="User avatar: RMS" /></a><a href="https://github.com/paulsheets"><img src="https:&#x2F;&#x2F;github.com&#x2F;paulsheets.png" width="60px" alt="User avatar: Paul" /></a><a href="https://github.com/djones369"><img src="https:&#x2F;&#x2F;github.com&#x2F;djones369.png" width="60px" alt="User avatar: Dave J (WhamGeek)" /></a><a href="https://github.com/anthonymendez"><img src="https:&#x2F;&#x2F;github.com&#x2F;anthonymendez.png" width="60px" alt="User avatar: Anthony Mendez" /></a><a href="https://github.com/FatBastard0"><img src="https:&#x2F;&#x2F;github.com&#x2F;FatBastard0.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/DursleyGuy"><img src="https:&#x2F;&#x2F;github.com&#x2F;DursleyGuy.png" width="60px" alt="User avatar: DursleyGuy" /></a><a href="https://github.com/DwayneTheRockLobster1"><img src="https:&#x2F;&#x2F;github.com&#x2F;DwayneTheRockLobster1.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/KieraKujisawa"><img src="https:&#x2F;&#x2F;github.com&#x2F;KieraKujisawa.png" width="60px" alt="User avatar: Kiera Meredith" /></a><a href="https://github.com/andrewpayne68"><img src="https:&#x2F;&#x2F;github.com&#x2F;andrewpayne68.png" width="60px" alt="User avatar: Andrew P" /></a><a href="https://github.com/seanh1995"><img src="https:&#x2F;&#x2F;github.com&#x2F;seanh1995.png" width="60px" alt="User avatar: Sean (ANGRYxScotsman)" /></a><a href="https://github.com/Abs313a"><img src="https:&#x2F;&#x2F;github.com&#x2F;Abs313a.png" width="60px" alt="User avatar: Abs" /></a><!-- sponsors -->
---
-4
View File
@@ -30,7 +30,6 @@ WinUtil is a Windows PowerShell utility with a WPF interface. The repository is
- `tools/autounattend.xml`: unattended setup XML embedded for Windows ISO workflows.
- `pester/`: Pester tests for config and function checks.
- `lint/PSScriptAnalyser.ps1`: PowerShell Script Analyzer settings.
- `tools/title-screen/`: uv project that captures WinUtil's Light and Dark themes and generates the title-screen composite.
- `docs/`: Astro + Starlight documentation site, with its own `package.json` and build independent of `Compile.ps1`.
- `winutil.ps1`: ignored generated build artifact.
@@ -81,7 +80,6 @@ Because the final script is concatenated, code cannot rely on runtime module imp
## Configuration Contract
- Config files must remain valid JSON and compile cleanly through `ConvertFrom-Json`.
- `config/dns.json` opts unfiltered providers into Fastest selection with `BenchmarkEligible: true`; missing or false values exclude a provider from the TCP latency benchmark.
- `config/applications.json` defines installable applications; each entry includes the fields expected by tests and UI code, such as package manager IDs, category, display content, description, and link.
- `config/tweaks.json` defines Windows tweaks; registry and service changes include original values or original states when applicable so undo workflows can restore user systems.
- Preset and navigation files reference valid config keys. Renaming a config key requires updating all presets, UI references, docs, and code paths together.
@@ -99,7 +97,6 @@ Because the final script is concatenated, code cannot rely on runtime module imp
- `docs/src/content/docs/code-reference/tweaks/` and `.../features/` are auto-generated by `tools/devdocs-generator.ps1` from `config/tweaks.json`/`config/feature.json` and the relevant PowerShell function files. Other pages under `code-reference/` (e.g. `architecture.mdx`) are hand-written and untouched by the generator.
- Sidebar entries in `docs/astro.config.mjs` must match actual page slugs under `docs/src/content/docs/`.
- `docs/public/` is tracked source for static assets (favicons, etc.), not generated output. Generated/ignored paths are listed in `docs/.gitignore` (`dist/`, `.astro/`, `node_modules/`, local env files).
- `docs/src/assets/branding/title-screen.png` is a tracked generated image used by the repository README and docs homepage. Its raw Light and Dark captures are temporary.
- `docs/Dockerfile` and `docs/docker-compose.yml` (service `winutil-astro`) containerize the site's npm tooling; see AGENTS.md's Dependency Installs, Builds, And Dev Servers for why and how agents must use them instead of running npm on the host.
## Testing And CI
@@ -109,7 +106,6 @@ Because the final script is concatenated, code cannot rely on runtime module imp
- Pester 5.8.0 runs the suite under `pester/*.Tests.ps1`. GitHub Actions (`unittests.yaml`) installs Pester 5.8.0 fresh and runs with `-CI`, which produces `testResults.xml` and exits non-zero on failure.
- GitHub Actions also runs PowerShell Script Analyzer with `lint/PSScriptAnalyser.ps1` on every push.
- The generated `winutil.ps1` may appear locally after compile. It remains ignored build output (see root `.gitignore`) and must not be committed.
- The manually triggered title-screen workflow compiles WinUtil from `main` and opens an image-only pull request when the generated composite changes. These pull requests require manual review. Failed runs retain diagnostics for 14 days.
## Release Artifact
+20 -101
View File
@@ -17,15 +17,6 @@
"winget": "7zip.7zip",
"foss": true
},
"abdownloadmanager": {
"category": "Utilities",
"choco": "ab-download-manager",
"content": "AB Download Manager",
"description": "AB Download Manager is an open-source download accelerator and manager with multi-threaded downloads, queue scheduling, speed limiting, and browser integration.",
"link": "https://abdownloadmanager.com/",
"winget": "amir1376.ABDownloadManager",
"foss": true
},
"adobe": {
"category": "Document",
"choco": "adobereader",
@@ -107,15 +98,6 @@
"winget": "AutoHotkey.AutoHotkey",
"foss": true
},
"battlenet": {
"category": "Games",
"choco": "na",
"winget": "Blizzard.BattleNet",
"content": "Battle.net",
"description": "Battle.net is a launcher for games created and developed by Activision Blizzard",
"link": "https://battle.net",
"foss": false
},
"bitwarden": {
"category": "Utilities",
"choco": "bitwarden",
@@ -193,7 +175,7 @@
"choco": "na",
"content": "ChatGPT Desktop",
"description": "The official ChatGPT desktop app for Windows, distributed through the Microsoft Store.",
"link": "https://openai.com/chatgpt/download/",
"link": "https://apps.microsoft.com/detail/9nt1r1c2hh7j",
"winget": "msstore:9NT1R1C2HH7J",
"foss": false
},
@@ -220,7 +202,7 @@
"choco": "chromium",
"content": "Chromium",
"description": "Chromium is the open-source project that serves as the foundation for various web browsers, including Chrome.",
"link": "https://www.chromium.org/",
"link": "https://github.com/Hibbiki/chromium-win64",
"winget": "Hibbiki.Chromium",
"foss": true
},
@@ -346,7 +328,7 @@
"choco": "dorion",
"content": "Dorion",
"description": "Tiny alternative Discord client with a smaller footprint, snappier startup, themes, plugins and more!",
"link": "https://spikehd.dev/projects/dorion/",
"link": "https://github.com/SpikeHD/Dorion",
"winget": "SpikeHD.Dorion",
"foss": true
},
@@ -431,16 +413,6 @@
"winget": "Microsoft.Edge",
"foss": false
},
"es-de": {
"category": "Games",
"choco": "",
"content": "EmulationStation Desktop Edition",
"_comment": "This and emulationstation are two completely different things. ES-DE is your frontend for everything and has its own set of emulators. Emulationstation is a graphical frontend for RetroArch.",
"description": "EmulationStation Desktop Edition is a frontend for browsing and launching games from your multi-platform game collection.",
"link": "https://es-de.org/",
"winget": "ES-DE.EmulationStation-DE",
"foss": true
},
"enteauth": {
"category": "Utilities",
"choco": "ente-auth",
@@ -464,19 +436,10 @@
"choco": "files",
"content": "Files",
"description": "Alternative file explorer.",
"link": "https://files.community",
"link": "https://github.com/files-community/Files",
"winget": "FilesCommunity.Files",
"foss": true
},
"fileconverter": {
"category": "Multimedia Tools",
"choco": "file-converter",
"content": "File Converter",
"description": "File Converter converts and compresses files from the Windows Explorer context menu.",
"link": "https://file-converter.io/",
"winget": "AdrienAllard.FileConverter",
"foss": true
},
"firefox": {
"category": "Browsers",
"choco": "firefox",
@@ -644,7 +607,7 @@
"choco": "helium",
"content": "Helium",
"description": "Private, fast, and honest web browser.",
"link": "https://helium.computer",
"link": "https://github.com/imputnet/helium/",
"winget": "ImputNet.Helium",
"foss": true
},
@@ -653,7 +616,7 @@
"choco": "hugo-extended",
"content": "Hugo",
"description": "The world's fastest framework for building websites.",
"link": "https://gohugo.io",
"link": "https://github.com/gohugoio/hugo/",
"winget": "Hugo.Hugo.Extended",
"foss": true
},
@@ -770,7 +733,7 @@
"choco": "jellyfin-media-player",
"content": "Jellyfin Media Player",
"description": "Jellyfin Media Player is a client application for the Jellyfin media server, providing access to your media library.",
"link": "https://jellyfin.org/",
"link": "https://github.com/jellyfin/jellyfin-media-player",
"winget": "Jellyfin.JellyfinMediaPlayer",
"foss": true
},
@@ -860,7 +823,7 @@
"choco": "librewolf",
"content": "LibreWolf",
"description": "LibreWolf is a privacy-focused web browser based on Firefox, with additional privacy and security enhancements.",
"link": "https://librewolf.net/",
"link": "https://librewolf-community.gitlab.io/",
"winget": "LibreWolf.LibreWolf",
"foss": true
},
@@ -878,7 +841,7 @@
"choco": "mediainfo",
"content": "mpc-qt",
"description": "Media Player Classic Qute Theater",
"link": "https://mpc-qt.github.io",
"link": "https://github.com/mpc-qt/mpc-qt",
"winget": "mpc-qt.mpc-qt",
"foss": true
},
@@ -931,7 +894,7 @@
"choco": "mpc-hc-clsid2",
"content": "Media Player Classic - Home Cinema",
"description": "Media Player Classic - Home Cinema (MPC-HC) is a free and open-source video and audio player for Windows. MPC-HC is based on the original Guliverkli project and contains many additional features and bug fixes.",
"link": "https://mpc-hc.org/",
"link": "https://github.com/clsid2/mpc-hc/",
"winget": "clsid2.mpc-hc",
"foss": true
},
@@ -958,7 +921,7 @@
"choco": "mullvad-app",
"content": "Mullvad VPN",
"description": "This is the VPN client software for the Mullvad VPN service.",
"link": "https://mullvad.net/",
"link": "https://github.com/mullvad/mullvadvpn-app",
"winget": "MullvadVPN.MullvadVPN",
"foss": true
},
@@ -985,7 +948,7 @@
"choco": "nanazip",
"content": "NanaZip",
"description": "NanaZip is a fast and efficient file compression and decompression tool.",
"link": "https://nanazip.org",
"link": "https://github.com/M2Team/NanaZip",
"winget": "M2Team.NanaZip",
"foss": true
},
@@ -998,15 +961,6 @@
"winget": "Netbird.Netbird",
"foss": true
},
"tailscale": {
"category": "Utilities",
"choco": "tailscale",
"content": "Tailscale",
"description": "The Tailscale client allows you to connect all your devices using WireGuard®, without the hassle. Tailscale makes it as easy as installing an app and signing in.",
"link": "https://tailscale.com/",
"winget": "Tailscale.Tailscale",
"foss": false
},
"naps2": {
"category": "Document",
"choco": "naps2",
@@ -1420,15 +1374,6 @@
"winget": "qBittorrent.qBittorrent",
"foss": true
},
"qownnotes": {
"category": "Document",
"choco": "qownnotes",
"content": "QOwnNotes",
"description": "QOwnNotes is a free open-source note-taking app with Nextcloud/ownCloud integration.",
"link": "https://www.qownnotes.org/",
"winget": "pbek.QOwnNotes",
"foss": true
},
"qtox": {
"category": "Communications",
"choco": "qtox",
@@ -1614,27 +1559,10 @@
"choco": "sunshine",
"content": "Sunshine/GameStream Server",
"description": "Sunshine is a GameStream server that allows you to remotely play PC games on Android devices, offering low-latency streaming.",
"link": "https://app.lizardbyte.dev/Sunshine/",
"link": "https://github.com/LizardByte/Sunshine",
"winget": "LizardByte.Sunshine",
"foss": true
},
"synctrayzor": {
"category": "Selfhosted Tools",
"choco": "synctrayzor",
"content": "SyncTrayzor",
"description": "SyncTrayzor is a Windows tray utility that bundles and wraps Syncthing, making it behave like a native application to easily manage and monitor file synchronization.",
"link": "https://github.com/GermanCoding/SyncTrayzor",
"winget": "GermanCoding.SyncTrayzor"
},
"syncthing": {
"category": "Selfhosted Tools",
"choco": "syncthing",
"content": "Syncthing (CLI / Web UI)",
"description": "Syncthing is a decentralized, peer-to-peer file synchronization tool that securely syncs files directly across devices without cloud servers, managed via a local web interface.",
"link": "https://syncthing.net/",
"winget": "Syncthing.Syncthing",
"foss": true
},
"tcpview": {
"category": "Microsoft Tools",
"choco": "tcpview",
@@ -1748,7 +1676,7 @@
"choco": "translucenttb",
"content": "TranslucentTB",
"description": "TranslucentTB is a tool that allows you to customize the transparency of the Windows Taskbar.",
"link": "https://translucenttb.github.io",
"link": "https://github.com/TranslucentTB/TranslucentTB",
"winget": "CharlesMilette.TranslucentTB",
"foss": true
},
@@ -1828,8 +1756,8 @@
"category": "Communications",
"choco": "na",
"content": "Vesktop",
"description": "A cross-platform electron-based desktop app aiming to give you a snappier Discord experience with Vencord pre-installed.",
"link": "https://vesktop.dev",
"description": "A cross platform electron-based desktop app aiming to give you a snappier Discord experience with Vencord pre-installed.",
"link": "https://github.com/Vencord/Vesktop",
"winget": "Vencord.Vesktop",
"foss": true
},
@@ -1919,7 +1847,7 @@
"choco": "na",
"content": "WhatsApp Desktop",
"description": "WhatsApp Desktop is the official Windows desktop messaging app from Meta, distributed through the Microsoft Store.",
"link": "https://www.whatsapp.com/download",
"link": "https://apps.microsoft.com/detail/9nksqgp7f2nh",
"winget": "msstore:9NKSQGP7F2NH",
"foss": false
},
@@ -2042,8 +1970,9 @@
},
"Overwolf": {
"category": "Games",
"content": "CurseForge",
"description": "CurseForge is a desktop application for managing mods and modpacks across multiple games, powered by Overwolf.",
"choco": "overwolf",
"content": "Overwolf",
"description": "Popular platform for game overlays and companion apps (mod managers, trackers, etc.), widely used by gamers.",
"link": "https://www.overwolf.com/app/overwolf-curseforge",
"winget": "Overwolf.CurseForge",
"foss": false
@@ -2110,15 +2039,5 @@
"content": "Lua",
"link": "https://github.com/rjpcomputing/luaforwindows",
"foss": true
},
"CloudflareWARP": {
"category": "Utilities",
"choco": "warp",
"winget": "Cloudflare.Warp",
"description": "WARP is a freemium VPN service provided by Cloudflare. Includes usage of Cloudflare's DNS",
"content": "Cloudflare WARP",
"link": "https://one.one.one.one",
"foss": false
}
}
-8
View File
@@ -259,13 +259,5 @@
"Description": "Bundles built-in card game modes including Klondike, Spider, FreeCell, Pyramid, and TriPeaks alongside daily challenges.",
"Panel": "1",
"PackageId": "Microsoft.MicrosoftSolitaireCollection"
},
"WPFAppxMicrosoft_ZuneVideo": {
"Category": "Utilities & Productivity",
"Content": "Movies & TV",
"Description": "The default video player and storefront for purchasing or renting media.",
"Panel": "0",
"PackageId": "Microsoft.ZuneVideo",
"StoreId": "9WZDNCRFJ3P2"
}
}
+54 -2
View File
@@ -1,6 +1,5 @@
{
"Google":{
"BenchmarkEligible": true,
"Primary": "8.8.8.8",
"Secondary": "8.8.4.4",
"Primary6": "2001:4860:4860::8888",
@@ -8,7 +7,6 @@
"DohTemplate": "https://dns.google/dns-query"
},
"Cloudflare":{
"BenchmarkEligible": true,
"Primary": "1.1.1.1",
"Secondary": "1.0.0.1",
"Primary6": "2606:4700:4700::1111",
@@ -56,5 +54,59 @@
"Primary6": "2a10:50c0::bad1:ff",
"Secondary6": "2a10:50c0::bad2:ff",
"DohTemplate": "https://family.adguard-dns.com/dns-query"
},
"Mullvad":{
"Primary": "194.242.2.2",
"Secondary": "194.242.2.3",
"Primary6": "2a07:e340::2",
"Secondary6": "2a07:e340::3",
"DohOnly": true,
"DohTemplate": "https://dns.mullvad.net/dns-query",
"SecondaryDohTemplate": "https://adblock.dns.mullvad.net/dns-query"
},
"Mullvad_Ads_Trackers":{
"Primary": "194.242.2.3",
"Secondary": "194.242.2.2",
"Primary6": "2a07:e340::3",
"Secondary6": "2a07:e340::2",
"DohOnly": true,
"DohTemplate": "https://adblock.dns.mullvad.net/dns-query",
"SecondaryDohTemplate": "https://dns.mullvad.net/dns-query"
},
"Mullvad_Ads_Trackers_Malware":{
"Primary": "194.242.2.4",
"Secondary": "194.242.2.3",
"Primary6": "2a07:e340::4",
"Secondary6": "2a07:e340::3",
"DohOnly": true,
"DohTemplate": "https://base.dns.mullvad.net/dns-query",
"SecondaryDohTemplate": "https://adblock.dns.mullvad.net/dns-query"
},
"Mullvad_Ads_Trackers_Malware_Social":{
"Primary": "194.242.2.5",
"Secondary": "194.242.2.4",
"Primary6": "2a07:e340::5",
"Secondary6": "2a07:e340::4",
"DohOnly": true,
"DohTemplate": "https://extended.dns.mullvad.net/dns-query",
"SecondaryDohTemplate": "https://base.dns.mullvad.net/dns-query"
},
"Mullvad_Ads_Trackers_Malware_Adult_Gambling":{
"Primary": "194.242.2.6",
"Secondary": "194.242.2.5",
"Primary6": "2a07:e340::6",
"Secondary6": "2a07:e340::5",
"DohOnly": true,
"DohTemplate": "https://family.dns.mullvad.net/dns-query",
"SecondaryDohTemplate": "https://extended.dns.mullvad.net/dns-query"
},
"Mullvad_Ads_Trackers_Malware_Adult_Gambling_Social":{
"Primary": "194.242.2.9",
"Secondary": "194.242.2.6",
"Primary6": "2a07:e340::9",
"Secondary6": "2a07:e340::6",
"DohOnly": true,
"DohTemplate": "https://all.dns.mullvad.net/dns-query",
"SecondaryDohTemplate": "https://family.dns.mullvad.net/dns-query"
}
}
-7
View File
@@ -13,9 +13,6 @@
"FontSize": "12",
"FontFamily": "Arial",
"HeaderFontSize": "16",
"Win11StepTitleFontSize": "22",
"Win11StepHeroFontSize": "40",
"Win11LogFontFamily": "Consolas, Monaco",
"HeaderFontFamily": "Consolas, Monaco",
"CheckBoxBulletDecoratorSize": "14",
"CheckBoxMargin": "15,0,0,2",
@@ -61,8 +58,6 @@
"ScrollBarHoverColor": "#5A5D62",
"ScrollBarDraggingColor": "#6A6D72",
"ProgressBarForegroundColor": "#2E77FF",
"ProgressBarErrorColor": "#D13438",
"ProgressBarWarningColor": "#B36A00",
"ProgressBarBackgroundColor": "Transparent",
"ButtonInstallBackgroundColor": "#F7F7F7",
"ButtonTweaksBackgroundColor": "#F7F7F7",
@@ -103,8 +98,6 @@
"ScrollBarHoverColor": "#3B4252",
"ScrollBarDraggingColor": "#5E81AC",
"ProgressBarForegroundColor": "#6EFF72",
"ProgressBarErrorColor": "#FF6B6B",
"ProgressBarWarningColor": "#FFC83D",
"ProgressBarBackgroundColor": "Transparent",
"ButtonInstallBackgroundColor": "#222222",
"ButtonTweaksBackgroundColor": "#333333",
+31 -52
View File
@@ -1,14 +1,14 @@
{
"WPFTweaksActivity": {
"Content": "Activity History - Disable",
"Description": "Stops Windows from publishing or uploading user activities while preserving clipboard history.",
"Description": "Erases recent docs, clipboard, and run history.",
"category": "Essential Tweaks",
"panel": "1",
"registry": [
{
"Path": "HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows\\System",
"Name": "EnableActivityFeed",
"Value": "1",
"Value": "0",
"Type": "DWord",
"OriginalValue": "<RemoveEntry>"
},
@@ -99,10 +99,10 @@
"category": "Essential Tweaks",
"panel": "1",
"InvokeScript": [
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /deny *S-1-1-0:F"
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /deny Everyone:F"
],
"UndoScript": [
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /grant *S-1-1-0:F"
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /grant Everyone:F"
],
"link": "https://winutil.christitus.com/code-reference/tweaks/essential-tweaks/disablestoresearch"
},
@@ -114,7 +114,7 @@
"service": [
{
"Name": "lfsvc",
"StartupType": "Disabled",
"StartupType": "Disable",
"OriginalType": "Manual"
}
],
@@ -647,7 +647,7 @@
"InvokeScript": [
"
# Deny permission to remove OneDrive folder
icacls $Env:OneDrive /deny \"*S-1-5-32-544:(D,DC)\"
icacls $Env:OneDrive /deny \"Administrators:(D,DC)\"
Write-Host \"Uninstalling OneDrive...\"
Start-Process -FilePath (Join-Path $Env:SystemRoot \"System32\\OneDriveSetup.exe\") -ArgumentList '/uninstall' -Wait
@@ -661,7 +661,7 @@
Remove-Item \"$Env:ProgramData\\Microsoft OneDrive\" -Recurse -Force
# Grant back permission to access OneDrive folder
icacls $Env:OneDrive /grant \"*S-1-5-32-544:(D,DC)\"
icacls $Env:OneDrive /grant \"Administrators:(D,DC)\"
if (-not (Get-ChildItem -Path $Env:OneDrive)) {
Remove-Item -Path $Env:OneDrive -Recurse
@@ -854,7 +854,7 @@
},
"WPFTweaksEndTaskOnTaskbar": {
"Content": "End Task With Right Click - Enable",
"Description": "Enables option to end task when right-clicking a program in the taskbar.",
"Description": "Enables option to end task when right clicking a program in the taskbar.",
"category": "Essential Tweaks",
"panel": "1",
"registry": [
@@ -991,51 +991,16 @@
New-Item -Path $RazerPath -ItemType Directory
}
icacls $RazerPath /deny \"*S-1-1-0:(W)\"
icacls $RazerPath /deny \"Everyone:(W)\"
"
],
"UndoScript": [
"
icacls \"$Env:SystemRoot\\Installer\\Razer\" /remove:d *S-1-1-0
icacls \"$Env:SystemRoot\\Installer\\Razer\" /remove:d Everyone
"
],
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/razerblock"
},
"WPFTweaksLogiBlock": {
"Content": "Logitech Download Assistant Auto-Install - Disable",
"Description": "Blocks the Logi Download Assistant that Windows Update keeps reinstalling with Logitech device drivers. Logitech hardware keeps working without it.",
"category": "z__Advanced Tweaks - CAUTION",
"panel": "1",
"InvokeScript": [
"
Stop-Process -Name \"logi_download_assistant\" -Force -ErrorAction SilentlyContinue
$ProgramFiles64 = if ($Env:ProgramW6432) { $Env:ProgramW6432 } else { $Env:ProgramFiles }
$LogiPath = \"$ProgramFiles64\\LogiDownloadAssistant\"
if (Test-Path $LogiPath) {
Remove-Item $LogiPath\\* -Recurse -Force
} else {
New-Item -Path $LogiPath -ItemType Directory
}
icacls $LogiPath /deny \"*S-1-1-0:(W)\"
if ($LASTEXITCODE -ne 0) { throw \"icacls failed to deny write access on $LogiPath (exit code $LASTEXITCODE)\" }
"
],
"UndoScript": [
"
$ProgramFiles64 = if ($Env:ProgramW6432) { $Env:ProgramW6432 } else { $Env:ProgramFiles }
$LogiPath = \"$ProgramFiles64\\LogiDownloadAssistant\"
if (Test-Path $LogiPath) {
icacls $LogiPath /remove:d \"*S-1-1-0\"
if ($LASTEXITCODE -ne 0) { throw \"icacls failed to remove the write-deny rule on $LogiPath (exit code $LASTEXITCODE)\" }
}
"
],
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/logiblock"
},
"WPFTweaksDisableNotifications": {
"Content": "System Tray Notifications & Calendar - Disable",
"Description": "Disables all Notifications INCLUDING Calendar.",
@@ -1121,10 +1086,8 @@
"panel": "1",
"InvokeScript": [
"
# 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
Remove-Item -Path \"$Env:Temp\\*\" -Recurse -Force
Remove-Item -Path \"$Env:SystemRoot\\Temp\\*\" -Recurse -Force
"
],
"link": "https://winutil.christitus.com/code-reference/tweaks/essential-tweaks/deletetempfiles"
@@ -1205,6 +1168,22 @@
],
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/disablebgapps"
},
"WPFTweaksDisableFSO": {
"Content": "Fullscreen Optimizations - Disable",
"Description": "Disables FSO in all applications. NOTE: This will disable Color Management in Exclusive Fullscreen.",
"category": "z__Advanced Tweaks - CAUTION",
"panel": "1",
"registry": [
{
"Path": "HKCU:\\System\\GameConfigStore",
"Name": "GameDVR_DXGIHonorFSEWindowsCompatible",
"Value": "1",
"Type": "DWord",
"OriginalValue": "0"
}
],
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/disablefso"
},
"WPFTweaksDisableExplorerAutoDiscovery": {
"Content": "File Explorer Automatic Folder Discovery - Disable",
"Description": "Windows Explorer automatically tries to guess the type of the folder based on its contents, slowing down the browsing experience. WARNING! Will disable File Explorer grouping.",
@@ -1422,7 +1401,7 @@
},
"WPFToggleNewOutlook": {
"Content": "Microsoft Outlook New Version",
"Description": "This will ensure the new Outlook application is used.",
"Description": "This will ensures the classic Outlook application is used.",
"category": "Customize Preferences",
"panel": "2",
"Type": "Toggle",
@@ -1686,7 +1665,7 @@
],
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/loginblur"
},
"WPFToggleDisableLockscreen": {
"WPFTweaksDisableLockscreen": {
"Content": "Lock Screen - Disable",
"Description": "Skips the lock screen entirely and goes directly to the sign-in screen on boot and wake.",
"category": "Customize Preferences",
@@ -1885,7 +1864,7 @@
"category": "z__Advanced Tweaks - CAUTION",
"panel": "1",
"Type": "Combobox",
"ComboItems": "Default DHCP Fastest Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult",
"ComboItems": "Default DHCP Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult Mullvad Mullvad_Ads_Trackers Mullvad_Ads_Trackers_Malware Mullvad_Ads_Trackers_Malware_Social Mullvad_Ads_Trackers_Malware_Adult_Gambling Mullvad_Ads_Trackers_Malware_Adult_Gambling_Social",
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/changedns"
},
"WPFAddUltPerf": {
-1
View File
@@ -56,7 +56,6 @@ export default defineConfig({
label: 'Code Reference',
items: [
{ label: 'Architecture & Design', slug: 'code-reference/architecture' },
{ label: 'Issue Triage Commands', slug: 'code-reference/issue-triage' },
{ label: 'Tweaks Reference', items: [{ autogenerate: { directory: 'code-reference/tweaks' } }] },
{ label: 'Features Reference', items: [{ autogenerate: { directory: 'code-reference/features' } }] },
],
+269 -540
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -13,7 +13,7 @@
"@astrojs/starlight": "^0.41.5",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@fontsource/geist-sans": "^5.3.0",
"astro": "^7.3.2",
"sharp": "^0.35.4"
"astro": "^7.0.2",
"sharp": "^0.35.3"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -79,15 +79,14 @@ winutil/
**Why**: Makes distribution easier (single file) and improves load time.
#### 2. scripts/main.ps1
**Purpose**: Entry point that manages the run.
**Purpose**: Entry point that initializes the GUI and event system.
**Responsibilities**:
- Load XAML and create WPF window
- Initialize form elements
- Set up event handlers
- Load configurations
- 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).
- Display the GUI
#### 3. functions/public/
**Purpose**: User-facing functions that implement main features.
@@ -130,36 +129,26 @@ The interface itself lives in `Start-WinUtilUserInterface`, not here. See [Threa
## Win11 Creator Architecture
The **Win11 Creator** is a specialized subsystem within Winutil that creates customized Windows 11 ISOs. It operates independently of the main package installation and tweak system.
The **Win11 Creator** is a specialized subsystem within Winutil that creates customized Windows 11 ISOs. It operates independently from the main package installation and tweak system.
### Win11 Creator Components
**Core Functions** (`functions/private/`):
- `Invoke-WinUtilISO.ps1`: Main orchestrator containing ISO workflow and helper functions
- `Invoke-WinUtilISO.ps1`: Main orchestrator containing all Win11 Creator functions
- `Invoke-WinUtilISOBrowse`: ISO file selection dialog
- `Invoke-WinUtilISOMountAndVerify`: Validates and mounts ISO, verifies it is an official Windows 11 ISO
- `Invoke-WinUtilISOMountAndVerify`: Validates and mounts ISO, verifies it's official Windows 11
- `Invoke-WinUtilISOModify`: Launches modification in background runspace
- `Invoke-WinUtilISOExport`: Builds bootable ISO via `oscdimg.exe`
- `Invoke-WinUtilISOExport`: Handles ISO and USB export
- `Invoke-WinUtilISOCheckExistingWork`: Recovers incomplete work sessions
- `Invoke-WinUtilISOCleanAndReset`: Cleans up temp directories and resets UI
- `Write-WinUtilISOLog`: Posts log messages to UI status box and session log
- `Set-WinUtilISOStep`: Controls wizard navigation state
- `Get-WinUtilEditionIdFromName`: Maps edition display names to setup edition IDs
- `Invoke-WinUtilRobocopy`: Copies files with exit code verification
- `Find-WinUtilOscdimg` / `Get-WinUtilOscdimgPath`: Locates or installs `oscdimg.exe`
- `Invoke-WinUtilISOUSB.ps1`: USB drive detection and formatting
- `Invoke-WinUtilISORefreshUSBDrives`: Enumerates USB drives
- `Get-WinUtilFreeDriveLetter`: Finds available drive letters
- `Invoke-WinUtilISOWriteUSB`: Formats USB drive as GPT/FAT32, splits WIM if needed, and copies files
- `Invoke-WinUtilISOScript.ps1`: Prepares setup media and customizations
- Stages AppX removal (19 bloatware packages) into `autounattend.xml` for first logon
- Applies 50+ registry tweaks during Windows Setup and first logon; removes scheduled tasks at first logon
- Triggers OneDrive uninstall during first logon
- Stages setup script fallbacks to `sources\$OEM$\$$\Setup\Scripts\`
- Pins selected edition in `autounattend.xml` and writes `sources\ei.cfg`
- Injects eligible current system drivers into WIM images if enabled (WIM mount is only used for driver servicing)
- `Invoke-WinUtilISOScript.ps1`: Applies modifications to mounted install.wim
- Removes provisioned AppX packages (40+ bloatware apps)
- Injects drivers (optional) from the current system
- Removes OneDrive setup files
- Applies offline registry tweaks (hardware bypass, privacy, telemetry, OOBE)
- Deletes telemetry scheduled task definitions
- Pre-stages setup scripts from autounattend.xml
### Win11 Creator Data Flow
@@ -179,19 +168,18 @@ User optionally enables the Driver Injection checkbox
↓
Invoke-WinUtilISOModify (runs in background runspace)
├─ Create work directory: ~WinUtil_Win11ISO_[timestamp]
├─ Copy ISO contents to disk via robocopy (~5-6 GB)
├─ Copy ISO contents to disk (~5-6 GB)
├─ Mount install.wim at selected edition/index
├─ Invoke-WinUtilISOScript:
│ ├─ Generate autounattend.xml with Windows PE and specialize safeguards
│ ├─ Add first-logon script (19 AppX removals, registry tweaks, OneDrive uninstall)
│ ├─ Pin selected edition index in autounattend.xml (/IMAGE/INDEX)
│ ├─ Stage setup script fallbacks to sources\$OEM$\$$\Setup\Scripts\
│ ├─ Write sources\ei.cfg and remove stale sources\PID.txt
│ └─ If driver injection enabled:
│ ├─ Require install.wim; install.esd cannot accept driver injection
│ ├─ Export current system drivers via DISM
│ ├─ Exclude stale duplicate packages
│ ├─ Inject storage drivers into boot.wim index 2 and eligible drivers into install.wim
│ └─ Validate WIM metadata before and after injection
│ ├─ Remove 40+ bloat AppX packages
│ ├─ Export and inject drivers (if enabled)
│ ├─ Remove OneDrive setup
│ ├─ Load offline registry hives
│ ├─ Apply 50+ registry tweaks (hardware bypass, privacy, telemetry, OOBE, etc.)
│ ├─ Delete telemetry scheduled task files
│ ├─ Pre-stage setup scripts from autounattend.xml to C:\Windows\Setup\Scripts\
│ └─ Unload registry hives
├─ Dismount and save the modified install.wim (~10+ minutes, slowest step)
├─ Dismount source ISO
└─ Report completion, enable export options
↓
@@ -202,15 +190,12 @@ Invoke-WinUtilISOExport (user chooses output)
│
└─ Option 2: Write to USB
├─ Format USB as GPT
├─ Create single FAT32 partition (capped at 32 GB)
├─ Split install.wim into .swm files if > 3.8 GB
├─ Reject install.esd files of 4 GB or more
├─ Copy files via robocopy
├─ Create 512 MB EFI partition
├─ Copy modified ISO contents
└─ Output: Bootable USB (minimum 8 GB)
↓
Invoke-WinUtilISOCleanAndReset (optional)
├─ Dismount any open WIM mounts with discard
├─ Delete temp working directory (~10-15 GB)
└─ Delete temp working directory (~10-15 GB)
└─ Reset UI to initial state
```
@@ -222,30 +207,20 @@ Invoke-WinUtilISOCleanAndReset (optional)
- Checks image metadata for "Windows 11" string
- Rejects custom, modified, or non-Windows 11 ISOs
**WIM Metadata Validation**:
- During driver injection, `Assert-WinUtilISOWimMetadata` validates critical fields (`Languages`, `Installation`, `Edition`, `ProductSuite`, `ProductType`) before and after WIM servicing
- Driver injection stops before export if the required metadata is missing or changes
- Without driver injection, WinUtil preserves the original installation image and skips WIM metadata validation
**Edition Pinning & Setup Fallback**:
- Writes `sources\ei.cfg` and removes `sources\PID.txt` so setup does not use mismatched OEM product keys
- Pins the selected edition index in `autounattend.xml` (`/IMAGE/INDEX`)
- Stages fallback setup scripts under `sources\$OEM$\$$\Setup\Scripts\` with `UseConfigurationSet` enabled
**Work Session Recovery**:
- Auto-detects incomplete work from previous sessions
- Allows resuming the export step without re-running selection and modification
- Allows resuming Step 4 (export) without re-running Steps 1-3
- Prevents redundant modifications
**Modification Safety**:
- Windows PE and specialize set hardware bypass and setup safeguards; `WinUtil-PostInstall.ps1` applies additional registry changes at first logon
- All registry changes are documented in a script (reversible)
- Original ISO never modified; only working copy
- Logged to the WinUtil session log and the live UI status panel
- DISM handles image dismount with automatic cleanup and discard on error
- Logged to `WinUtil_Win11ISO.log` for debugging
- DISM handles image dismount with automatic cleanup on error
### Win11 Creator Registry Tweaks
The `Invoke-WinUtilISOScript` function applies **50+ registry tweaks** during setup and first logon:
The `Invoke-WinUtilISOScript` function applies **50+ offline registry tweaks**:
**Hardware Bypass**:
- TPM 2.0 check bypass
@@ -283,9 +258,9 @@ The `Invoke-WinUtilISOScript` function applies **50+ registry tweaks** during se
### Driver Injection Feature
**Optional Enhancement**: When enabled, exports all drivers from the running system and injects them into:
- `install.wim` (main OS image), excluding stale duplicates
- `boot.wim` index 2 (Windows Setup PE environment), storage controllers only (`SCSIAdapter` / `HDC`)
**Optional Enhancement**: When enabled, exports all drivers from the running system and injects them into both:
- `install.wim` (main OS image)
- `boot.wim` index 2 (Windows Setup PE environment)
**Use Case**: Enables offline installation on systems with missing drivers.
@@ -415,68 +390,23 @@ Update UI
- `service`: Services to change
- `OriginalValue/State`: For undo functionality
## Threading Model
## PowerShell Runspace
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.
Winutil uses PowerShell runspaces for the GUI to remain responsive:
```powershell
# main.ps1 - the interface gets its own thread
$sync.UIRunspace = [runspacefactory]::CreateRunspace($Host, (New-WinUtilSessionState))
$sync.UIRunspace.ApartmentState = "STA"
$sync.UIRunspace.Open()
# Create runspace
$sync.runspace = [runspacefactory]::CreateRunspace()
$sync.runspace.Open()
$sync.runspace.SessionStateProxy.SetVariable("sync", $sync)
$uiShell = [powershell]::Create()
$uiShell.Runspace = $sync.UIRunspace
[void]$uiShell.AddScript({ Start-WinUtilUserInterface })
$uiHandle = $uiShell.BeginInvoke()
$uiHandle.AsyncWaitHandle.WaitOne()
# Run code in background
$powershell = [powershell]::Create().AddScript($scriptblock)
$powershell.Runspace = $sync.runspace
$handle = $powershell.BeginInvoke()
```
**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.
**Why**: Prevents UI freezing during long-running operations.
## WPF Event Handling
@@ -522,21 +452,23 @@ if (!(Get-Command choco -ErrorAction SilentlyContinue)) {
choco install $app.choco -y
```
## Error Handling And Logging
## 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.
Winutil uses PowerShell error handling:
```powershell
Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "winget install failed: $($_.Exception.Message)"
try {
# Attempt operation
Invoke-SomeOperation
}
catch {
Write-Host "Error: $_" -ForegroundColor Red
# Log error
Add-Content -Path $logfile -Value "ERROR: $_"
}
```
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.
**Logging**: Errors and operations are logged for debugging.
## Configuration Loading
@@ -554,23 +486,15 @@ $sync.configs.features = Get-Content "config/feature.json" | ConvertFrom-Json
## UI Update Pattern
Controls may only be touched from the thread that owns the window, so background work reaches
them through `Invoke-WPFUIThread`:
UI updates must happen on the UI thread:
```powershell
Invoke-WPFUIThread -Parameters @{ Count = $installed.Count } -ScriptBlock {
param($Count)
$sync.WPFselectedAppsButton.Content = "Selected Apps: $Count"
}
$sync.form.Dispatcher.Invoke([action]{
$sync.WPFStatusLabel.Content = "Installing..."
}, "Normal")
```
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.
**Why**: WPF requires UI updates on the main thread.
## Adding New Features
@@ -125,9 +125,9 @@ function Invoke-WPFFixesUpdate {
if (Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate") {
Write-Progress -Id 0 -Activity "Repairing Windows Update" -Status "Removing WSUS client settings..." -PercentComplete 60
Write-Progress -Id 6 -ParentId 0 -Activity "Removing WSUS client settings" -PercentComplete 0
Remove-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate" -Name "AccountDomainSid" -ErrorAction SilentlyContinue
Remove-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate" -Name "PingID" -ErrorAction SilentlyContinue
Remove-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate" -Name "SusClientId" -ErrorAction SilentlyContinue
Start-Process -NoNewWindow -FilePath "REG" -ArgumentList "DELETE", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate", "/v", "AccountDomainSid", "/f" -RedirectStandardError "NUL"
Start-Process -NoNewWindow -FilePath "REG" -ArgumentList "DELETE", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate", "/v", "PingID", "/f" -RedirectStandardError "NUL"
Start-Process -NoNewWindow -FilePath "REG" -ArgumentList "DELETE", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate", "/v", "SusClientId", "/f" -RedirectStandardError "NUL"
Write-Progress -Id 6 -ParentId 0 -Activity "Removing WSUS client settings" -Status "Completed" -PercentComplete 100
}
@@ -1,26 +0,0 @@
---
title: Issue Triage Commands
description: Commands trusted maintainers can use to label and close GitHub issues.
---
Trusted repository members whose numeric GitHub user IDs are listed in the issue triage workflow can run the commands below. These commands work only on issues. Comments on pull requests are ignored, and the workflow token has no pull-request write permission.
## Commands
- `/triage` adds the `needs-triage` label.
- `/triageoff` removes the `needs-triage` label when it is present.
- `/np` closes the issue as not planned.
- `/np wontfix` adds the `wontfix` label without replacing existing labels, then closes the issue as not planned.
- `/np notrelated` adds the `not-related` label without replacing existing labels, then closes the issue as not planned.
- `/np <reason>` closes the issue as not planned without adding a label for unrecognized reasons.
- `/duplicate <issue number>` closes the issue as a duplicate of another issue. The positive issue number may optionally start with `#`; pull request numbers are rejected.
## Command handling
Put exactly one command in the comment. Leading and trailing whitespace is allowed, but text before or after the command is not. Command matching is case-insensitive, and malformed issue numbers are rejected.
After a command is handled, the workflow deletes the command comment and leaves an audit comment describing the result. A missing duplicate target or a request to duplicate an issue into itself is reported without closing the issue. Unexpected GitHub API failures stop the workflow and leave the command comment available for retry.
## Access
Access is granted by adding the trusted member's immutable numeric GitHub user ID to `.github/workflows/triage-tools.yaml` through a pull request. Abuse can result in removal from the allowlist or other repository moderation action.
@@ -1,6 +1,6 @@
---
title: "Multiplane Overlay"
description: "Multiplane Overlay composes multiple image layers, which can sometimes cause issues with graphics cards. Changes to this preference are applied immediately."
description: "Multiplane Overlay compose multiple image layers, which can sometimes cause issues with graphics cards."
editUrl: false
---
@@ -9,40 +9,28 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
:::
```json title="config/tweaks.json"
"WPFMultiplaneOverlay": {
"WPFToggleMultiplaneOverlay": {
"Content": "Multiplane Overlay",
"Description": "Multiplane Overlay composes multiple image layers, which can sometimes cause issues with graphics cards. Changes to this preference are applied immediately.",
"Description": "Multiplane Overlay compose multiple image layers, which can sometimes cause issues with graphics cards.",
"category": "Customize Preferences",
"panel": "2",
"Type": "Combobox",
"ComboItems": "Enabled|Disabled (Compatibility)|Fully Disabled",
"ComboDescriptions": {
"Enabled": "Uses Windows' default overlay behavior.",
"Disabled (Compatibility)": "Disables MPO using OverlayTestMode=5, the less aggressive compatibility method.",
"Fully Disabled": "Disables MPO using OverlayTestMode=5 and DisableOverlays=1, the more aggressive method."
},
"Type": "Toggle",
"registry": [
{
"Path": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\Dwm",
"Name": "OverlayTestMode",
"Value": "0",
"Type": "DWord",
"DefaultValue": "0",
"Values": {
"Enabled": "<RemoveEntry>",
"Disabled (Compatibility)": "5",
"Fully Disabled": "5"
}
"OriginalValue": "5",
"DefaultState": "true"
},
{
"Path": "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\GraphicsDrivers",
"Name": "DisableOverlays",
"Value": "0",
"Type": "DWord",
"DefaultValue": "0",
"Values": {
"Enabled": "<RemoveEntry>",
"Disabled (Compatibility)": "<RemoveEntry>",
"Fully Disabled": "1"
}
"OriginalValue": "1",
"DefaultState": "true"
}
],
}
@@ -17,7 +17,7 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
"service": [
{
"Name": "lfsvc",
"StartupType": "Disabled",
"StartupType": "Disable",
"OriginalType": "Manual"
}
],
@@ -0,0 +1,33 @@
---
title: "Fullscreen Optimizations - Disable"
description: "Disables FSO in all applications. NOTE: This will disable Color Management in Exclusive Fullscreen."
editUrl: false
---
:::note
This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitusTech/winutil/blob/main/config/tweaks.json). Do not edit this page directly.
:::
```json title="config/tweaks.json"
"WPFTweaksDisableFSO": {
"Content": "Fullscreen Optimizations - Disable",
"Description": "Disables FSO in all applications. NOTE: This will disable Color Management in Exclusive Fullscreen.",
"category": "z__Advanced Tweaks - CAUTION",
"panel": "1",
"registry": [
{
"Path": "HKCU:\\System\\GameConfigStore",
"Name": "GameDVR_DXGIHonorFSEWindowsCompatible",
"Value": "1",
"Type": "DWord",
"OriginalValue": "0"
}
],
}
```
## Registry Changes
Applications and System Components store and retrieve configuration data to modify Windows settings, so we can use the registry to change many settings in one place.
You can find information about the registry on [Wikipedia](https://en.wikipedia.org/wiki/Windows_Registry) and [Microsoft's Website](https://learn.microsoft.com/en-us/windows/win32/sysinfo/registry).
@@ -14,6 +14,6 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
"category": "z__Advanced Tweaks - CAUTION",
"panel": "1",
"Type": "Combobox",
"ComboItems": "Default DHCP Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult Mullvad Mullvad_Ads_Trackers Mullvad_Ads_Trackers_Malware Mullvad_Ads_Trackers_Malware_Social Mullvad_Ads_Trackers_Malware_Adult_Gambling Mullvad_Ads_Trackers_Malware_Adult_Gambling_Social",
"ComboItems": "Default DHCP Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult",
}
```
+1 -1
View File
@@ -101,7 +101,7 @@ While you can make your changes directly through the Web, we recommend cloning t
* Run the following command to compile and run WinUtil:
* `.\Compile.ps1 -run`
![Compile](../../assets/contributing/Compile.png)
![Compile](../../assets/contributing/Complie.png)
* After seeing that your changes work properly, feel free to commit the changes to the repository and make a PR. For help on that, follow the documentation below.
@@ -22,8 +22,6 @@ Example:
To view exactly what each preset does, see:
https://github.com/ChrisTitusTech/winutil/blob/main/config/preset.json
To find a key for a custom configuration, hover over a supported application, tweak, feature, or AppX entry in WinUtil. Its tooltip shows `Preset key: <key>`. Application keys are searchable on the Install tab, while tweak and AppX keys are searchable on their respective tabs. Feature keys are available from their tooltips because the Config/Features tab does not have search. Controls that cannot be applied from a preset, such as toggle switches, drop-downs, and package-manager choices, intentionally do not advertise a preset key.
To create your own config file:
1. Open WinUtil.
@@ -42,14 +40,6 @@ This is useful for:
- Reusing a known-good baseline after reinstalling Windows
- Standardizing deployments for labs, workstations, or personal setups
:::caution[Keep exported configurations current]
Exported configurations contain the WinUtil catalog keys that existed when the file was created. Current exports use a flat JSON format: one string when a single setting is selected, or an array of strings when several settings are selected. If a later WinUtil version removes or renames any imported key, the entire import is rejected before current selections are changed. PowerShell reports the stale entry as `Unknown selection key '<key>'`.
Older WinUtil versions exported a JSON object with `Install` package metadata and grouped `WPFInstall`, `WPFTweaks`, `WPFToggle`, and `WPFFeature` selections. When importing one of these legacy files, WinUtil restores the keys that still exist and skips retired keys, recording them as a warning in the WinUtil log. If the file contains no supported selections, the import makes no changes.
To recover, compare the reported key with the current files in the [WinUtil configuration catalog](https://github.com/ChrisTitusTech/winutil/tree/main/config). Remove or replace the stale key in your JSON file, or create and export a new configuration with the current WinUtil version, then run the import again. Re-export long-lived baselines after catalog changes so they remain compatible.
:::
:::note
Run the command in an elevated PowerShell session so WinUtil can apply system-level changes.
:::
@@ -235,21 +235,6 @@ Now that you're set up, explore these guides:
- [Tweaks Guide](/guides/tweaks/) — Understand system optimizations
- [FAQ](/faq/) — Common questions and answers
## Exporting a diagnostics report
If you're reporting a problem, click the gear icon in the top-right corner and choose **Export Environment Report**. It saves a read-only JSON file with:
- Windows edition, version, build, and architecture
- CPU model, logical processor count, and total memory
- PowerShell edition, version, and execution policy
- Whether WinGet and Chocolatey are installed, and their versions
- Whether a reboot is pending
- The current applied/not-applied state of every tweak and toggle
It does not include computer or user names, paths, IP or MAC addresses, serial numbers, installed-app inventories, services, raw registry paths or values, secrets, or logs. The tweak/toggle state is derived from a registry comparison, but only the resulting true/false per tweak is included, not any registry content itself. WinUtil never uploads the report — you choose where to save it and who to send it to.
You'll be asked whether to also bundle the last 7 days of WinUtil logs into a companion `.txt` file, which maintainers often need alongside the report to diagnose an issue. This companion is raw log output and does not use the JSON report's privacy allowlist: it may contain local paths, commands, and error details. Review it before sharing.
## Getting help
If you need assistance:
+8 -1
View File
@@ -65,7 +65,6 @@ Use the DNS section to switch both IPv4 and IPv6 DNS providers without editing a
* **Default**: Uses the default DNS settings configured by your ISP or network.
* **DHCP**: Automatically acquires DNS settings from the DHCP server.
* **Fastest**: Compares TCP port 53 connection times to Google and Cloudflare, then applies the quicker provider. Filtering providers are excluded. If both probes fail, existing DNS settings are preserved. The scan takes up to about three seconds; connection time is only a rough latency estimate, not a DNS lookup or DNS-over-HTTPS performance test.
* [**Google**](https://developers.google.com/speed/public-dns?hl=en): A reliable and fast DNS service provided by Google.
* [**Cloudflare**](https://developers.cloudflare.com/1.1.1.1/): Known for speed and privacy, Cloudflare DNS is a popular choice for enhancing internet performance.
* [**Cloudflare_Malware**](https://developers.cloudflare.com/1.1.1.1/setup/#:~:text=Use%20the%20following%20DNS%20resolvers%20to%20block%20malicious%20content%3A): Provides additional protection by blocking malware sites.
@@ -74,6 +73,14 @@ Use the DNS section to switch both IPv4 and IPv6 DNS providers without editing a
* [**Quad9**](https://quad9.net/): Focuses on security by blocking known malicious domains.
* [**AdGuard_Ads_Trackers**](https://adguard-dns.io/en/welcome.html): AdGuard DNS blocks ads, trackers, and other unwanted DNS requests. Visit the website and sign in for a dashboard, statistics, and additional server-side customization.
* [**AdGuard_Ads_Trackers_Malware_Adult**](https://adguard-dns.io/en/welcome.html): AdGuard DNS blocks ads, trackers, malware, and adult content, and enables Safe Search and Safe Mode where possible.
* [**Mullvad**](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls): Mullvad DNS without content blocking.
* [**Mullvad_Ads_Trackers**](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls): Blocks ads and trackers.
* [**Mullvad_Ads_Trackers_Malware**](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls): Blocks ads, trackers, and malware.
* [**Mullvad_Ads_Trackers_Malware_Social**](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls): Blocks ads, trackers, malware, and social media.
* [**Mullvad_Ads_Trackers_Malware_Adult_Gambling**](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls): Blocks ads, trackers, malware, adult content, and gambling.
* [**Mullvad_Ads_Trackers_Malware_Adult_Gambling_Social**](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls): Applies all available Mullvad filters.
Mullvad profiles require DNS over HTTPS support in Windows. If the selected primary resolver is unavailable, WinUtil uses the closest Mullvad secondary resolver to preserve connectivity; that fallback may use a different filtering level and can be less restrictive.
### Customize Preferences
+21 -27
View File
@@ -23,30 +23,33 @@ This workflow is intended for fresh Windows installs, not in-place upgrades of a
---
The tab is a three-step wizard. The step you are on is shown in the header row, and you can click an earlier step to go back to it until the image has been modified. The status log on the right stays visible throughout.
### Step 1 — Select Your Official Windows 11 ISO
1. Open WinUtil and go to the **Win11 Creator** tab.
2. Click **Browse** and select your **official Windows 11 ISO file** from Microsoft (must be 4 GB or larger). Custom or modified ISOs are not supported.
3. The file path and size will appear on screen once selected.
4. Click **Mount & Verify ISO**. WinUtil mounts the ISO, checks for a valid `install.wim` or `install.esd`, and reads the available editions (Home, Pro, Enterprise, etc.).
---
### Step 2 — Mount & Verify
1. Click **Mount & Verify ISO**.
2. WinUtil mounts the ISO, checks for a valid `install.wim` or `install.esd`, and reads the available editions (Home, Pro, Enterprise, etc.).
3. Once verified, select your desired **edition** from the dropdown — Pro is selected by default if available.
:::note
Mounting and verification takes around 10–30 seconds, depending on your drive speed.
This step takes around 10–30 seconds, depending on your drive speed.
:::
---
### Step 2 — Modify the Image
### Step 3 — Run the Modification
Once the ISO is verified, WinUtil moves to this step and shows the mounted drive and image file. Choose your **edition** from the dropdown — Pro is selected by default if available — and tick **Inject current system drivers** if you want them.
Then click **Run Windows ISO Modification and Creator** to start the customization process. WinUtil will:
Click **Run Windows ISO Modification and Creator** to start the customization process. WinUtil will:
**App & Component Removal:**
- **Remove 19 bloat apps** — Clipchamp, Teams, Dev Home, new Outlook, Bing apps, Solitaire, and more
- **Uninstall OneDrive** during first logon
- **Remove 40+ bloat apps** — Clipchamp, Teams, Copilot, Dev Home, new Outlook, Bing apps, Solitaire, and more
- **Delete OneDrive setup** from the image
**System Customization:**
- **Bypass hardware checks** — removes TPM, Secure Boot, CPU, RAM, and storage requirement enforcement so the ISO installs on unsupported hardware
@@ -67,13 +70,9 @@ Then click **Run Windows ISO Modification and Creator** to start the customizati
- **Disable Copilot and search box suggestions**
**Optional: Driver Injection**
- If enabled, WinUtil exports the drivers from your current system, injects boot-storage drivers into `boot.wim` (Windows Setup, index 2), and injects eligible packages into the selected `install.wim` image. Stale duplicate packages are excluded before injection. Each remaining package is added separately, so one incompatible package does not stop the others. If a package fails, WinUtil discards the partial mount and retries the remaining packages against the original image. Check the live log for warnings: if every package fails, WinUtil keeps the original `install.wim` and still produces the ISO.
- If enabled, it injects all drivers from your current system into the install.wim and boot.wim — useful for offline installations on machines with missing drivers. This is an optional checkbox in Step 3.
:::note
Driver injection requires `sources\install.wim`. If the ISO uses `sources\install.esd`, leave this option off.
:::
A live log shows progress as each step completes. Without driver injection, WinUtil writes the setup changes in a few seconds after the ISO copy. With driver injection, this stage usually takes **10–20 minutes**, depending on hardware. Keep WinUtil open until the log reports completion.
A live log shows progress as each step completes. This stage usually takes **10–30 minutes** depending on disk speed. The WIM dismount near the end is the slowest part, so do not close WinUtil while it is running.
:::note
The resulting ISO is close to the size of the source ISO. WinUtil does not remove the unused editions from `install.wim`; it selects your edition through `sources\ei.cfg` and `autounattend.xml` so Windows Setup installs the right one.
@@ -81,7 +80,7 @@ The resulting ISO is close to the size of the source ISO. WinUtil does not remov
---
### Step 3 — Export Your Result
### Step 4 — Export Your Result
Once the modification is complete, choose how to save your image:
@@ -99,11 +98,7 @@ Once the modification is complete, choose how to save your image:
1. Click **Write Directly to a USB Drive**.
2. Select your USB drive from the dropdown (click **Refresh** if it doesn't appear).
3. Click **Erase & Write to USB** and confirm the warning — **all data on the drive will be permanently erased**.
4. WinUtil formats the drive as GPT with a single FAT32 partition (capped at 32 GB, splitting `install.wim` into `.swm` files if larger than 3.8 GB) and copies the modified Windows files.
:::note
WinUtil cannot split `install.esd`. If that file is 4 GB or larger, save an ISO instead of writing a FAT32 USB drive.
:::
4. WinUtil formats the drive as GPT with a 512 MB EFI partition and copies the modified Windows files.
:::danger
Double-check you have selected the correct drive before confirming. This operation cannot be undone.
@@ -115,9 +110,9 @@ Double-check you have selected the correct drive before confirming. This operati
---
### Start Over (Optional)
### Step 5 — Clean Up (Optional)
Click **Start Over** to delete the temporary working directory (~10–15 GB) and return the tool to its initial state, ready for a new ISO. You will be asked to confirm before anything is deleted.
Click **Clean & Reset** to delete the temporary working directory (~10–15 GB) and return the tool to its initial state, ready for a new ISO. You will be asked to confirm before anything is deleted.
---
@@ -138,12 +133,11 @@ When you install Windows 11 from your modified ISO:
| Problem | Fix |
|---------|-----|
| "install.wim / install.esd was not found" | The ISO has no Windows installation image — download a fresh official ISO from Microsoft |
| "install.wim not found" | Not a valid Windows 11 ISO — download a fresh one from Microsoft |
| "oscdimg.exe not found" | Run `winget install -e --id Microsoft.OSCDIMG` then retry |
| USB drive not showing up | Plug it in, wait a few seconds, then click **Refresh** |
| Driver injection seems stuck | WIM servicing can pause at a mount or commit. Allow 10–20 minutes depending on hardware, and check the live log before closing WinUtil |
| Modification seems stuck | The WIM dismount step is slow — wait at least 10 minutes before assuming it's frozen |
| "Access Denied" error | Make sure WinUtil is running as Administrator |
| Driver injection warning | Review the live log for the named package. Other compatible packages continue; if none succeed, the ISO is created with the original `install.wim` |
| "Setup has failed to validate the product key" | Recreate the ISO with the latest WinUtil. The creator now removes stale `PID.txt`, writes `sources\ei.cfg`, and pins the selected image in `autounattend.xml` so setup does not use an embedded OEM key for a different edition |
---
+4 -4
View File
@@ -9,7 +9,7 @@ If you run WinUtil and get an error like:
`< : The term '<' is not recognized as the name of a cmdlet, function, script file, or operable program.`
try using a **VPN** and if that doesn't work then report the issue to https://github.com/ChrisTitusTech/winutil/issues
try using a **VPN** and if that doesn't work than report the issue to https://github.com/ChrisTitusTech/winutil/issues
### Script Won't Run
@@ -17,13 +17,13 @@ If you run WinUtil and get the error:
`"WinUtil is unable to run on your system. PowerShell execution is restricted by security policies"`
This means that your PowerShell session is in **Constrained Language Mode**, which prevents WinUtil from running.
this means that your PowerShell session is in **Constrained Language Mode**, which prevents WinUtil from running.
### Ultimate Performance Plan Not Working
The Ultimate Performance power plan may not work on some laptops that do not fully support this power plan.
In these cases, the power plan may fail to apply. This is expected behavior on unsupported hardware.
In these cases, the power plan may fail to apply, This is expected behavior on unsupported hardware.
### Revert start menu tweak not working
@@ -34,7 +34,7 @@ In this update, Microsoft completely removed the old Start Menu code from Window
### Issues with PowerShell 7 or Class not registered Error
Installing PowerShell 7 from the Microsoft Store (MSIX package) is known to cause issues with DISM cmdlets such as `Get-WindowsOptionalFeature` and `Enable-WindowsOptionalFeature`, resulting in a `Class not registered` COM error.
This might also make it so running the "pre-installed app removal" will take an indefinite amount of time
This might also make it so running the "pre-installed app removal" will take a indefinite amount of time
Instead, install PowerShell 7 using one of the following methods:
@@ -41,7 +41,7 @@ function Add-SelectedAppsMenuItem {
$selectedAppRemoveButton.Add_MouseEnter({ $this.Foreground = "Red" })
$selectedAppRemoveButton.Add_MouseLeave({ $this.SetResourceReference([Windows.Controls.Control]::ForegroundProperty, "MainForegroundColor") })
$selectedAppRemoveButton.Add_Click({
$sync.($this.Tag).isChecked = $false # On click of the remove button, we only have to uncheck the corresponding checkbox. This will kick off all necessary changes to update the UI
$sync.($this.Tag).isChecked = $false # On click of the remove button, we only have to uncheck the corresponding checkbox. This will kick of all necessary changes to update the UI
})
[System.Windows.Controls.Grid]::SetColumn($selectedAppRemoveButton, 1)
$selectedAppGrid.Children.Add($selectedAppRemoveButton)
+9 -138
View File
@@ -1,147 +1,18 @@
function Close-WinUtilRunspacePool {
<#
.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) {
if ($null -eq $sync -or -not $sync.ContainsKey("runspace") -or $null -eq $sync.runspace) {
return
}
$poolLock = Get-WinUtilRunspacePoolLock
[System.Threading.Monitor]::Enter($poolLock)
try {
# 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() }
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()
}
} finally {
[System.Threading.Monitor]::Exit($poolLock)
$sync.runspace.Dispose()
$sync.Remove("runspace")
}
}
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
}
@@ -1,58 +0,0 @@
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
}
}
@@ -5,9 +5,9 @@ function Find-AppsByNameOrDescription {
.DESCRIPTION
Search text and categories are independent filters that both have to pass. An entry is
shown when its name, description, or application preset key matches the search text, and
when its category is in the selected set. An empty search matches everything, and an empty
category set matches every category.
shown when its name or description matches the search text, and when its category is in
the selected set. An empty search matches everything, and an empty category set matches
every category.
While either filter is active the matching categories are expanded, since a collapsed
category would otherwise hide the very results that were asked for. With no filter at
@@ -115,8 +115,7 @@ function Find-AppsByNameOrDescription {
$categoryMatch = -not $hasCategories -or $activeCategories -contains $appEntry.Category
$textMatch = -not $hasSearch -or
$appEntry.Content -like "*$escapedSearchString*" -or
$appEntry.Description -like "*$escapedSearchString*" -or
$appTag -like "*$escapedSearchString*"
$appEntry.Description -like "*$escapedSearchString*"
if ($categoryMatch -and $textMatch) {
$appControl.Visibility = [Windows.Visibility]::Visible
@@ -1,60 +0,0 @@
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
}
@@ -1,84 +0,0 @@
function Get-WinUtilDNSBenchmark {
<#
.SYNOPSIS
Benchmarks neutral DNS providers by measuring TCP port 53 latency (RTT in ms) to determine the fastest DNS server.
.PARAMETER TimeoutMs
Maximum timeout in milliseconds for each connection test. Default is 1500ms.
.OUTPUTS
Array of PSCustomObjects containing Provider, PrimaryIP, and LatencyMs sorted by lowest latency.
.EXAMPLE
$results = Get-WinUtilDNSBenchmark
$fastest = $results[0]
#>
[CmdletBinding()]
param(
[ValidateRange(1, 9998)]
[int]$TimeoutMs = 1500
)
Write-WinUtilLog -Component "DNS" -Message "Starting DNS latency benchmark scan (TCP port 53)..."
$dnsConfigs = $sync.configs.dns
if ($null -eq $dnsConfigs) {
Write-Warning "DNS configurations not found in `$sync.configs.dns."
Write-WinUtilLog -Level "ERROR" -Component "DNS" -Message "DNS configurations not found in `$sync.configs.dns."
return @()
}
$results = [System.Collections.Generic.List[PSObject]]::new()
foreach ($prop in $dnsConfigs.PSObject.Properties) {
$providerName = $prop.Name
$primaryIp = $prop.Value.Primary
if (-not $primaryIp) { continue }
# Providers must explicitly opt in so new filtering services are never auto-selected.
if ($prop.Value.BenchmarkEligible -ne $true) {
continue
}
$latency = 9999
$client = $null
try {
$client = New-Object System.Net.Sockets.TcpClient
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$asyncResult = $client.BeginConnect($primaryIp, 53, $null, $null)
$success = $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)
$stopwatch.Stop()
if ($success) {
$client.EndConnect($asyncResult)
$latency = [int]$stopwatch.ElapsedMilliseconds
} else {
$latency = 9999
}
} catch {
$latency = 9999
} finally {
if ($null -ne $client) {
$client.Dispose()
}
}
$results.Add([PSCustomObject]@{
Provider = $providerName
PrimaryIP = $primaryIp
LatencyMs = $latency
})
}
$sortedResults = @($results | Sort-Object LatencyMs)
if ($sortedResults.Count -gt 0 -and $sortedResults[0].LatencyMs -lt 9999) {
$fastest = $sortedResults[0]
Write-WinUtilLog -Component "DNS" -Message "DNS Benchmark completed. Fastest neutral provider: $($fastest.Provider) ($($fastest.LatencyMs) ms)"
} else {
Write-WinUtilLog -Component "DNS" -Message "DNS Benchmark completed. Could not determine latency for providers."
}
return $sortedResults
}
@@ -1,25 +0,0 @@
function Get-WinUtilEntryToolTip {
<#
.SYNOPSIS
Builds the tooltip string for an app/tweak/feature entry: its description plus its preset JSON key
.PARAMETER Description
The entry's description from the config JSON. May be null or empty.
.PARAMETER Key
The entry's JSON key as used in preset files (e.g. WPFInstallbrave, WPFTweaksTele).
#>
param(
[Parameter(Mandatory = $false)]
[string]$Description,
[Parameter(Mandatory = $true)]
[string]$Key
)
if ([string]::IsNullOrWhiteSpace($Description)) {
return "Preset key: $Key"
}
return "$Description`n`nPreset key: $Key"
}
@@ -1,162 +0,0 @@
function Get-WinUtilEnvironmentReport {
<#
.SYNOPSIS
Collects the allowlisted data used by the WinUtil environment report.
#>
$reportWarnings = [System.Collections.Generic.List[string]]::new()
$windows = [ordered]@{
edition = $null
version = $null
buildNumber = $null
architecture = $null
}
$hardware = [ordered]@{
cpuModel = $null
logicalProcessorCount = $null
totalMemoryGB = $null
}
try {
$operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
$windows.edition = $operatingSystem.Caption
$windows.version = $operatingSystem.Version
$windows.buildNumber = $operatingSystem.BuildNumber
$windows.architecture = $operatingSystem.OSArchitecture
if ($null -ne $operatingSystem.TotalVisibleMemorySize) {
$hardware.totalMemoryGB = [math]::Round(([double]$operatingSystem.TotalVisibleMemorySize / 1MB), 2)
}
} catch {
$message = "Failed to collect Windows/memory info from Win32_OperatingSystem: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
try {
$processors = @(Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop)
if ($processors.Count -gt 0) {
$hardware.cpuModel = $processors[0].Name
$hardware.logicalProcessorCount = [int](($processors | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum)
}
} catch {
$message = "Failed to collect CPU info from Win32_Processor: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
$powershell = [ordered]@{
edition = $PSVersionTable.PSEdition
version = $PSVersionTable.PSVersion.ToString()
executionPolicy = $null
}
try {
$powershell.executionPolicy = (Get-ExecutionPolicy).ToString()
} catch {
$message = "Failed to read PowerShell execution policy: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
# Re-use built-in functionality
$chocolatey = [ordered]@{ installed = $false; version = $null }
try {
$chocolatey.installed = (Test-WinUtilPackageManager -choco 6>$null) -eq "installed"
} catch {
$message = "Failed to check Chocolatey availability: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
if ($chocolatey.installed) {
try {
$global:LASTEXITCODE = 0
$versionOutput = @(choco -v 2>&1)
if ($LASTEXITCODE -ne 0) {
throw "Chocolatey version probe exited with code $LASTEXITCODE."
}
$chocolatey.version = ($versionOutput | Select-Object -First 1).ToString().Trim()
} catch {
$message = "Failed to read Chocolatey version: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
}
$winget = [ordered]@{ installed = $false; version = $null }
try {
$winget.installed = (Test-WinUtilPackageManager -winget 6>$null) -eq "installed"
} catch {
$message = "Failed to check WinGet availability: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
if ($winget.installed) {
try {
$global:LASTEXITCODE = 0
$versionOutput = @(winget -v 2>&1)
if ($LASTEXITCODE -ne 0) {
throw "WinGet version probe exited with code $LASTEXITCODE."
}
$winget.version = ($versionOutput | Select-Object -First 1).ToString().Trim()
} catch {
$message = "Failed to read WinGet version: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
}
# Null means the registry state could not be read. Do not turn an access/provider failure into
# a misleading "no reboot required" result.
$system = [ordered]@{ pendingRebootRequired = $null }
try {
$rebootPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending",
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"
)
# A present-but-empty PendingFileRenameOperations value still returns a non-null object, so
# check the actual entries rather than just whether the property exists.
$pendingFileRenameOperations = @(
(Get-ItemProperty -LiteralPath "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" `
-ErrorAction Stop).PendingFileRenameOperations |
Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }
)
$system.pendingRebootRequired = ($rebootPaths | Where-Object {
Test-Path -LiteralPath $_ -ErrorAction Stop
}).Count -gt 0 -or
$pendingFileRenameOperations.Count -gt 0
} catch {
$message = "Failed to check pending-reboot registry state: $($_.Exception.Message)"
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
$tweaksState = Get-WinUtilTweaksStateReport
if ($tweaksState.collectionStatus -ne "collected") {
$message = "Failed to collect the complete tweak state for the environment report."
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
[void]$reportWarnings.Add($message)
}
foreach ($message in $reportWarnings) {
Write-Warning $message
}
return [pscustomobject][ordered]@{
schemaVersion = "1.0"
generatedAtUtc = [DateTime]::UtcNow.ToString("o")
windows = [pscustomobject]$windows
hardware = [pscustomobject]$hardware
powershell = [pscustomobject]$powershell
packageManagers = [pscustomobject][ordered]@{
winget = [pscustomobject]$winget
chocolatey = [pscustomobject]$chocolatey
}
system = [pscustomobject]$system
tweaksState = $tweaksState
}
}
@@ -1,16 +0,0 @@
function Get-WinUtilEnvironmentReportLogsPath {
<#
.SYNOPSIS
Derives the companion logs .txt path from the environment report's JSON save path.
#>
param(
[Parameter(Mandatory = $true)]
[string]$JsonPath
)
# ChangeExtension($JsonPath, $null) leaves a trailing dot instead of stripping it, so build the
# name from its parts instead.
$directory = [System.IO.Path]::GetDirectoryName($JsonPath)
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($JsonPath)
return [System.IO.Path]::Combine($directory, "${baseName}_logs.txt")
}
@@ -1,39 +0,0 @@
function Get-WinUtilRecentLogs {
<#
.SYNOPSIS
Concatenates WinUtil session logs from the last N days into a single text blob.
.PARAMETER Days
How many days back to include. Defaults to 7, matching what the support forum/server
typically asks users for.
.PARAMETER LogDirectory
Overrides the log directory (normally $sync.winutildir\logs). Mainly for testing.
#>
param(
[int]$Days = 7,
[string]$LogDirectory
)
if ([string]::IsNullOrWhiteSpace($LogDirectory)) {
if ($null -eq $sync -or -not $sync.ContainsKey("winutildir") -or [string]::IsNullOrWhiteSpace($sync.winutildir)) {
return ""
}
$LogDirectory = Join-Path $sync.winutildir "logs"
}
if (-not (Test-Path -LiteralPath $LogDirectory -ErrorAction Stop)) {
return ""
}
$cutoff = (Get-Date).AddDays(-$Days)
$logFiles = Get-ChildItem -LiteralPath $LogDirectory -Filter "winutil_*.log" -File -ErrorAction Stop |
Where-Object { $_.LastWriteTime -ge $cutoff } |
Sort-Object LastWriteTime
$sections = foreach ($logFile in $logFiles) {
"=== $($logFile.Name) ===`n$(Get-Content -LiteralPath $logFile.FullName -Raw -ErrorAction Stop)"
}
return ($sections -join "`n`n")
}
@@ -1,17 +0,0 @@
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,9 +8,10 @@ function Get-WinUtilSelectedPackages {
[string] $Preference
)
# A single package has no meaningful percentage to show
if ($PackageList.count -eq 1) {
Step-WinUtilJob -State "Indeterminate"
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" }
}
$packagesWinget = [System.Collections.ArrayList]::new()
+11 -24
View File
@@ -1,32 +1,23 @@
Function Get-WinUtilToggleStatus {
param(
$ToggleSwitch,
[switch]$BypassCache,
[switch]$StopOnReadError
)
Function Get-WinUtilToggleStatus ($ToggleSwitch) {
$ToggleSwitchReg = $sync.configs.tweaks.$ToggleSwitch.registry
if (-not $BypassCache) {
if ($null -eq $sync.ToggleStatusCache) {
$sync.ToggleStatusCache = @{}
}
if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) {
return [bool]$sync.ToggleStatusCache[$ToggleSwitch]
}
if ($null -eq $sync.ToggleStatusCache) {
$sync.ToggleStatusCache = @{}
}
$readErrorAction = if ($StopOnReadError) { "Stop" } else { "Continue" }
if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) {
return [bool]$sync.ToggleStatusCache[$ToggleSwitch]
}
if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) {
New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS -ErrorAction $readErrorAction | Out-Null
New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS | Out-Null
}
foreach ($regentry in $ToggleSwitchReg) {
if (Test-Path $regentry.Path -ErrorAction $readErrorAction) {
$regstate = (Get-ItemProperty -Path $regentry.Path -ErrorAction $readErrorAction).$($regentry.Name)
if (Test-Path $regentry.Path) {
$regstate = (Get-ItemProperty -Path $regentry.Path).$($regentry.Name)
} else {
$regstate = $null
}
@@ -39,15 +30,11 @@ Function Get-WinUtilToggleStatus {
}
if ($regstate -ne $regentry.Value) {
if (-not $BypassCache) {
$sync.ToggleStatusCache[$ToggleSwitch] = $false
}
$sync.ToggleStatusCache[$ToggleSwitch] = $false
return $false
}
}
if (-not $BypassCache) {
$sync.ToggleStatusCache[$ToggleSwitch] = $true
}
$sync.ToggleStatusCache[$ToggleSwitch] = $true
return $true
}
@@ -1,62 +0,0 @@
function Get-WinUtilTweaksStateReport {
<#
.SYNOPSIS
Groups every config/tweaks.json entry's live applied state by category, reusing the same
detection Invoke-WPFGetInstalled uses to check the "Get Installed Tweaks" checkboxes.
#>
$categoryFieldNames = [ordered]@{
"Essential Tweaks" = "essentialTweaks"
"Customize Preferences" = "customizePreferences"
"z__Advanced Tweaks - CAUTION" = "advancedTweaks"
"Performance Plans - NOT FOR LAPTOPS" = "performancePlans"
}
$grouped = [ordered]@{}
foreach ($fieldName in $categoryFieldNames.Values) {
$grouped[$fieldName] = [ordered]@{}
}
$notEvaluable = [System.Collections.Generic.List[string]]::new()
$collectionStatus = "collected"
try {
$appliedTweaks = [System.Collections.Generic.HashSet[string]]::new(
[string[]]@(Invoke-WinUtilCurrentSystem -CheckBox "tweaks" `
-BypassToggleStatusCache -StopOnReadError)
)
foreach ($property in $sync.configs.tweaks.PSObject.Properties) {
$tweakKey = $property.Name
$entry = $property.Value
$fieldName = $categoryFieldNames[[string]$entry.category]
# Buttons embedded in the tweaks panel (e.g. the OOSU/Ultimate Performance launchers)
# are actions, not stateful tweaks, so they're outside this report's scope entirely.
if (-not $fieldName -or $entry.Type -eq "Button") {
continue
}
# Combobox tweaks and script-only tweaks with no registry/service schema have no
# detectable current state. List them so the report doesn't silently drop them.
if ($entry.Type -eq "Combobox" -or (-not $entry.registry -and -not $entry.service)) {
$notEvaluable.Add($tweakKey)
continue
}
$grouped[$fieldName][$tweakKey] = $appliedTweaks.Contains($tweakKey)
}
} catch {
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message "Failed to collect tweaks/toggle state: $($_.Exception.Message)"
$collectionStatus = "unavailable"
}
# Empty groups from a failed collection would otherwise be indistinguishable in the JSON from a
# successful scan that found nothing notable, so record whether collection actually ran.
$result = [ordered]@{ collectionStatus = $collectionStatus }
foreach ($fieldName in $categoryFieldNames.Values) {
$result[$fieldName] = [pscustomobject]$grouped[$fieldName]
}
$result.notEvaluable = @($notEvaluable)
return [pscustomobject]$result
}
@@ -3,7 +3,7 @@
.SYNOPSIS
Creates a [Windows.Controls.ScrollViewer] containing a [Windows.Controls.ItemsControl] which is setup to use Virtualization to only load the visible elements for performance reasons.
This is used as the parent object for all category and app entries on the install tab
Used as part of the Install Tab UI generation
Used to as part of the Install Tab UI generation
.PARAMETER TargetElement
The element to which the AppArea should be added
@@ -13,7 +13,7 @@
$targetGrid = $sync.Form.FindName($TargetElement)
$null = $targetGrid.Children.Clear()
# Create the outer Border for the area where the apps will be placed
# Create the outer Border for the aren where the apps will be placed
$Border = New-Object Windows.Controls.Border
$Border.VerticalAlignment = "Stretch"
$Border.SetResourceReference([Windows.Controls.Control]::StyleProperty, "BorderStyle")
@@ -13,18 +13,35 @@ function Initialize-InstallAppEntry {
$appKey
)
$app = $sync.configs.applicationsHashtable[$appKey]
$handlers = Get-WinUtilAppEntryHandlers
$app = $sync.configs.applicationsHashtable.$appKey
# 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($handlers.BorderClick)
$border.Add_MouseEnter($handlers.MouseEnter)
$border.Add_MouseLeave($handlers.MouseLeave)
$border.Add_MouseRightButtonUp($handlers.RightClick)
$border.ToolTip = $app.description
$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
})
$checkBox = New-Object Windows.Controls.CheckBox
# Sanitize the name for WPF
@@ -32,8 +49,18 @@ function Initialize-InstallAppEntry {
# Store the original appKey in Tag
$checkBox.Tag = $appKey
$checkbox.Style = $sync.Form.Resources.AppEntryCheckboxStyle
$checkbox.Add_Checked($handlers.Checked)
$checkbox.Add_Unchecked($handlers.Unchecked)
# 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")
})
$contentPanel = New-Object Windows.Controls.StackPanel
$contentPanel.Orientation = "Horizontal"
@@ -46,16 +73,15 @@ 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($handlers.ImageFailed)
$logo.Add_ImageFailed({ $this.Visibility = "Collapsed"; $this.Parent.Children[0].Visibility = "Visible" })
[void]$icon.Children.Add($logo)
}
[void]$contentPanel.Children.Add($icon)
@@ -64,8 +90,6 @@ 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
@@ -86,7 +110,6 @@ function Initialize-InstallAppEntry {
[void]$entryLayout.Children.Add($fossBadge)
}
$border.Child = $entryLayout
if ($sync.selectedApps -contains $appKey) {
$checkBox.IsChecked = $true
@@ -16,17 +16,14 @@ function Initialize-InstallCategoryAppList {
$Apps
)
# Pre-group apps by category before creating WPF controls. Lists, because appending to
# an array copies it and there are several hundred apps.
# Pre-group apps by category before creating WPF controls.
$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] = [System.Collections.Generic.List[string]]::new()
$appsByCategory[$category] = @()
}
$appsByCategory[$category].Add($appKey)
$appsByCategory[$category] += $appKey
}
$sync.InstallAppRenderQueue = [System.Collections.Queue]::new()
@@ -1,35 +0,0 @@
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,34 +1,38 @@
function Initialize-WinUtilRunspacePool {
<#
.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()
if ($sync.runspace -and $sync.runspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) {
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,11 +1,7 @@
function Initialize-WinUtilTabContent {
param(
[Parameter(Mandatory = $true)]
[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
[string]$TabName
)
if ($null -eq $sync.InitializedTabs) {
@@ -16,42 +12,31 @@ function Initialize-WinUtilTabContent {
return
}
# 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
switch ($TabName) {
"Install" {
Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1
Initialize-WPFUI -targetGridName "appscategory"
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
}
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
}
}
# Controls built just now start unchecked, so anything already chosen by an import or a
# preset has to be applied to them once they exist
Reset-WPFCheckBoxes -doToggles $true
} catch {
# A half built tab must be allowed to rebuild rather than staying empty forever
$sync.InitializedTabs[$TabName] = $false
throw
}
$sync.InitializedTabs[$TabName] = $true
# Sync freshly built controls to any selections already in $sync.selected* (import/preset).
Reset-WPFCheckBoxes -doToggles $true
}
@@ -4,30 +4,15 @@ function Initialize-WinUtilTaskbarOverlayAssets {
[bool]$IncludeStatusAssets = $true
)
[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 ($IncludeLogo -and -not $sync["logorender"]) {
$sync["logorender"] = (Invoke-WinUtilAssets -Type "Logo" -Size 90 -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["checkmarkrender"]) {
$sync["checkmarkrender"] = (Invoke-WinUtilAssets -Type "checkmark" -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)
if ($IncludeStatusAssets -and -not $sync["warningrender"]) {
$sync["warningrender"] = (Invoke-WinUtilAssets -Type "warning" -Size 512 -Render)
}
}
+3 -12
View File
@@ -58,14 +58,7 @@ 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 [pscustomobject]@{
Package = $Name
Manager = "appx"
Action = "Install"
ExitCode = 0
Outcome = "Succeeded"
Detail = "registered local manifest"
}
return
}
}
@@ -77,12 +70,10 @@ 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
$exception = [System.InvalidOperationException]::new($errorMessage)
$exception.Data["WinUtilErrorReported"] = $true
throw $exception
throw $errorMessage
}
Write-WinUtilLog -Component "AppX" -Message "No usable local manifest found for $Name. Installing Microsoft Store product $StoreId."
$null = Install-WinUtilWinget
Install-WinUtilWinget
Install-WinUtilProgramWinget -Action Install -Programs @("msstore:$StoreId")
}
+4 -36
View File
@@ -1,39 +1,7 @@
function Install-WinUtilChoco {
<#
.SYNOPSIS
Installs Chocolatey if it is not already present
#>
if (Get-Command -Name choco -ErrorAction SilentlyContinue) {
return
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))
}
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,116 +1,20 @@
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", "Upgrade")]
[ValidateSet("Install", "Uninstall")]
[string]$Action,
[Parameter(Mandatory=$true)]
[string[]]$Programs,
[int]$ProgressBase = 0,
[int]$ProgressSpan = 0
[string[]]$Programs
)
# 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"
if ($Action -eq 'Install') {
$arguments = "install $Programs -y"
} else {
$arguments = "uninstall $Programs -y"
}
$nothingToDo = @{
2 = "nothing to do"
}
$verb = $Action.ToLowerInvariant()
$chocoAvailable = $null -ne (Get-Command choco -ErrorAction SilentlyContinue)
$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
}
}
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))"
}
@@ -1,116 +1,32 @@
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", "Upgrade")]
[ValidateSet("Install", "Uninstall")]
[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
}
$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 = "winget"
if ($program.StartsWith("msstore:", [System.StringComparison]::OrdinalIgnoreCase)) {
$source = "msstore"
$program = $program.Substring("msstore:".Length)
}
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") }
}
$process = Start-Process -FilePath winget -ArgumentList $arguments -NoNewWindow -Wait -PassThru
$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" }
}
if ($Action -eq 'Install') {
$arguments = @("install", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--silent")
} 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
$arguments = @("uninstall", "--id", $program, "--source", $source, "--silent")
}
$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
}
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))"
}
}
+2 -12
View File
@@ -7,21 +7,11 @@ function Install-WinUtilWinget {
.DESCRIPTION
installs winGet if needed
#>
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") {
if ((Test-WinUtilPackageManager -winget) -eq "installed") {
return
}
if ($Force) {
Write-Host "Repairing the WinGet installation..." -ForegroundColor Yellow
} else {
Write-Host "WinGet is not installed. Installing now..." -ForegroundColor Red
}
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 = [Hashtable]::Synchronized(@{})
$sync.RenderedAssetCache = @{}
}
$cacheKey = "$(([string]$type).ToLowerInvariant())|$Size"
@@ -1,116 +0,0 @@
function Invoke-WinUtilCloseRequest {
<#
.SYNOPSIS
Asks what to do about work that is still running when the window is closed
.DESCRIPTION
A half finished install or tweak run is not ended without asking. Either it finishes
without the window, reporting to the console and then exiting, or it is stopped and
everything closes now.
.PARAMETER RunningJob
The name of the job in flight, so the question names what is at stake.
#>
param(
[Parameter(Mandatory)]
[string]$RunningJob
)
# The question carries the meaning rather than naming buttons: Windows labels them in its own
# language, so "Yes" in the text would not match a button reading "Ja".
$answer = Show-WinUtilMessage -Button "YesNoCancel" -Icon "Warning" -Title "$RunningJob is still running" -Message @"
$RunningJob has not finished yet.
Close the window and let it finish in the console?
WinUtil will exit on its own once it is done. If you do not, it will be
stopped and everything closes now. Cancel keeps WinUtil open.
"@
switch ("$answer") {
"Yes" {
Write-WinUtilLog -Component "UI" -Message "Close requested: closing the window, $RunningJob continues in the console."
$sync.FinishInConsole = $true
$sync.ForceClose = $true
Write-Host ""
Write-Host "WinUtil's window is closed. $RunningJob is still running here, and this window will close when it finishes." -ForegroundColor Cyan
Write-Host ""
# Posted rather than closed from inside the handler that is already unwinding
Request-WinUtilWindowClose
}
"No" {
Write-WinUtilLog -Component "UI" -Message "Close requested: stopping $RunningJob."
Step-WinUtilJob -Status "Stopping $RunningJob" -State "Indeterminate"
$sync.ForceClose = $true
# Close the window first. The main thread owns pool shutdown after ShowDialog
# returns, so the worker can finish its UI-dispatching finally block before the UI
# runspace is disposed. Waiting for it here would deadlock the dispatcher.
Request-WinUtilWindowClose
}
default {
Write-WinUtilLog -Component "UI" -Message "Close cancelled, $RunningJob is still running."
}
}
}
function Request-WinUtilWindowClose {
<#
.SYNOPSIS
Closes the window from outside the handler that is currently cancelling the close
#>
if (-not (Test-WinUtilUIAlive)) {
return
}
$sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{
$sync.Form.Close()
}) | Out-Null
}
function Wait-WinUtilRemainingWork {
<#
.SYNOPSIS
Waits for work that outlived the window, reporting to the console
.DESCRIPTION
Runs on the main thread once the interface has gone. The job is still on the worker
pool and keeps logging, so this only waits and keeps the wait visible.
.PARAMETER TimeoutMinutes
Upper bound, so a worker that never returns cannot keep the process alive.
#>
param(
# Double rather than int: an int silently truncates a fractional value to zero, which
# turns the bound into "do not wait at all"
[double]$TimeoutMinutes = 120
)
if (-not $sync.FinishInConsole -or -not $sync.ActiveJob) {
return
}
$job = $sync.ActiveJob
Write-WinUtilLog -Component "UI" -Message "Window closed, waiting for $job to finish."
Write-Host "Waiting for $job to finish..." -ForegroundColor Cyan
$clock = [System.Diagnostics.Stopwatch]::StartNew()
while ($sync.ActiveJob -and $clock.Elapsed.TotalMinutes -lt $TimeoutMinutes) {
Start-Sleep -Milliseconds 250
}
# The job's last progress line is still open, so anything after it needs a fresh line
Complete-WinUtilConsoleProgress
if ($sync.ActiveJob) {
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "$job did not finish within $TimeoutMinutes minutes, exiting anyway."
Write-Host "$job is taking longer than $TimeoutMinutes minutes. Exiting." -ForegroundColor Yellow
return
}
Write-WinUtilLog -Component "UI" -Message "$job finished after the window closed, in $([int]$clock.Elapsed.TotalSeconds)s."
Write-Host "$job finished. Closing." -ForegroundColor Green
}
@@ -11,9 +11,7 @@ Function Invoke-WinUtilCurrentSystem {
#>
param(
$CheckBox,
[switch]$BypassToggleStatusCache,
[switch]$StopOnReadError
$CheckBox
)
if ($CheckBox -eq "choco") {
$apps = (choco list | Select-String -Pattern "^\S+").Matches.Value
@@ -54,7 +52,6 @@ Function Invoke-WinUtilCurrentSystem {
if ($CheckBox -eq "tweaks") {
if (!(Test-Path 'HKU:\')) {$null = (New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS)}
$readErrorAction = if ($StopOnReadError) { "Stop" } else { "SilentlyContinue" }
$sync.configs.tweaks | Get-Member -MemberType NoteProperty | ForEach-Object {
@@ -68,9 +65,7 @@ Function Invoke-WinUtilCurrentSystem {
$Values = @()
if ($entryType -eq "Toggle") {
if (-not (Get-WinUtilToggleStatus $Config `
-BypassCache:$BypassToggleStatusCache `
-StopOnReadError:$StopOnReadError)) {
if (-not (Get-WinUtilToggleStatus $Config)) {
$values += $False
}
} else {
@@ -82,12 +77,8 @@ Function Invoke-WinUtilCurrentSystem {
$registryTotal++
$regstate = $null
if (Test-Path $tweak.Path -ErrorAction $readErrorAction) {
if ($StopOnReadError) {
$regstate = (Get-ItemProperty -Path $tweak.Path -ErrorAction Stop).$($tweak.Name)
} else {
$regstate = Get-ItemProperty -Name $tweak.Name -Path $tweak.Path -ErrorAction SilentlyContinue | Select-Object -ExpandProperty $($tweak.Name)
}
if (Test-Path $tweak.Path) {
$regstate = Get-ItemProperty -Name $tweak.Name -Path $tweak.Path -ErrorAction SilentlyContinue | Select-Object -ExpandProperty $($tweak.Name)
}
if ($null -eq $regstate) {
@@ -117,17 +108,7 @@ Function Invoke-WinUtilCurrentSystem {
Foreach ($tweaks in $serviceKeys) {
Foreach ($tweak in $tweaks) {
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
}
$Service = Get-Service -Name $tweak.Name
if ($Service) {
$actualValue = $Service.StartType
@@ -135,8 +116,6 @@ Function Invoke-WinUtilCurrentSystem {
if ($expectedValue -ne $actualValue) {
$values += $False
}
} elseif ($StopOnReadError) {
$values += $False
}
}
}
@@ -8,9 +8,7 @@ function Invoke-WinUtilExplorerUpdate {
)
if ($action -eq "refresh") {
# 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 {
Invoke-WPFRunspace -ScriptBlock {
# Define the Win32 type only if it doesn't exist
if (-not ([System.Management.Automation.PSTypeName]'Win32').Type) {
Add-Type -TypeDefinition @"
@@ -29,8 +29,6 @@ function Invoke-WinUtilFontScaling {
"FontSize",
"ButtonFontSize",
"HeaderFontSize",
"Win11StepTitleFontSize",
"Win11StepHeroFontSize",
"TabButtonFontSize",
"ConfigTabButtonFontSize",
"IconFontSize",
File diff suppressed because it is too large Load Diff
+70 -282
View File
@@ -6,8 +6,7 @@ function Invoke-WinUtilISOScript {
.DESCRIPTION
Stages WinUtil's AppX removal, registry tweaks, and scheduled-task cleanup
in the answer file for first logon, writes sources\ei.cfg for the selected
edition, and optionally adds current-system drivers to boot.wim index 2 and
one install.wim index.
edition, and optionally adds current-system drivers to one install.wim index.
.PARAMETER ISOContentsDir
Root directory of the copied ISO contents.
@@ -26,10 +25,6 @@ function Invoke-WinUtilISOScript {
.PARAMETER Log
Optional ScriptBlock for progress/status logging. Receives a single [string] argument.
.PARAMETER DriversInjected
Optional [ref] set to $true only if driver injection actually mounted and committed
install.wim; stays $false if injection was skipped or no package was added successfully.
#>
param (
[Parameter(Mandatory)][string]$ISOContentsDir,
@@ -38,8 +33,7 @@ function Invoke-WinUtilISOScript {
[string]$InstallEditionId = "",
[string]$InstallImagePath = "",
[int]$InstallImageIndex = 1,
[scriptblock]$Log = { param($m) Write-Output $m },
[ref]$DriversInjected = [ref]$false
[scriptblock]$Log = { param($m) Write-Output $m }
)
function Add-WinUtilISOStagedDrivers {
@@ -47,14 +41,34 @@ function Invoke-WinUtilISOScript {
[Parameter(Mandatory)][string]$ContentRoot,
[Parameter(Mandatory)][string]$InstallImagePath,
[Parameter(Mandatory)][int]$InstallImageIndex,
[scriptblock]$Logger,
[ref]$DriversInjected = [ref]$false
[scriptblock]$Logger
)
$DriversInjected.Value = $false
function Copy-WinUtilISODriverFolder {
param (
[Parameter(Mandatory)][string]$Source,
[Parameter(Mandatory)][string]$Destination
)
$folderName = Split-Path $Source -Leaf
$targetPath = Join-Path $Destination $folderName
$suffix = 1
while (Test-Path -LiteralPath $targetPath) {
$targetPath = Join-Path $Destination "${folderName}_$suffix"
$suffix++
}
Copy-Item -LiteralPath $Source -Destination $targetPath -Recurse -Force -ErrorAction Stop
return $targetPath
}
function Test-WinUtilISOStorageDriver {
param ([Parameter(Mandatory)][System.IO.FileInfo]$InfFile)
if ($InfFile.BaseName -match '(?i)(iaahci|iastor|vmd|irst|rst)') {
return $true
}
try {
return (Get-Content -LiteralPath $InfFile.FullName -Raw -ErrorAction Stop) -match '(?im)^\s*Class\s*=\s*(SCSIAdapter|HDC)\s*(?:;.*)?$'
} catch {
@@ -63,128 +77,6 @@ function Invoke-WinUtilISOScript {
}
}
function Get-WinUtilISODriverPackageVersion {
param ([Parameter(Mandatory)][System.IO.FileInfo]$InfFile)
try {
$infText = Get-Content -LiteralPath $InfFile.FullName -Raw -ErrorAction Stop
} catch {
$null = & $Logger "Warning: could not read '$($InfFile.FullName)' to determine its driver version: $_"
return $null
}
# The version component of DriverVer is optional per the INF spec (date-only entries
# are valid); treat a missing version as 0.0 so date-only entries still rank correctly
# instead of being discarded as unparseable.
$match = [regex]::Match($infText, '(?im)^\s*DriverVer\s*=\s*(?<date>\d{1,2}/\d{1,2}/\d{4})\s*(?:,\s*(?<version>\d+(?:\.\d+){0,3}))?\s*(?:;.*)?$')
if (-not $match.Success) {
return $null
}
try {
$date = [datetime]::ParseExact($match.Groups['date'].Value, 'M/d/yyyy', [System.Globalization.CultureInfo]::InvariantCulture)
$versionText = if ($match.Groups['version'].Success) { $match.Groups['version'].Value } else { '0' }
if (($versionText.Split('.')).Count -lt 2) {
$versionText = "$versionText.0"
}
$version = [version]$versionText
} catch {
$null = & $Logger "Warning: could not parse DriverVer '$($match.Value.Trim())' in '$($InfFile.FullName)': $_"
return $null
}
return [pscustomobject]@{
Date = $date
Version = $version
Raw = if ($match.Groups['version'].Success) { "$($match.Groups['date'].Value),$($match.Groups['version'].Value)" } else { $match.Groups['date'].Value }
}
}
function Get-WinUtilISODriverProvider {
param ([Parameter(Mandatory)][System.IO.FileInfo]$InfFile)
try {
$infText = Get-Content -LiteralPath $InfFile.FullName -Raw -ErrorAction Stop
} catch {
$null = & $Logger "Warning: could not read '$($InfFile.FullName)' to determine its provider: $_"
return ''
}
$match = [regex]::Match($infText, '(?im)^\s*Provider\s*=\s*(?<provider>.+?)\s*(?:;.*)?$')
if (-not $match.Success) {
return ''
}
return $match.Groups['provider'].Value.ToLowerInvariant()
}
function Select-WinUtilISOStagedDriverPackages {
param (
[Parameter(Mandatory)][AllowEmptyCollection()][object[]]$DriverFolderGroups,
[scriptblock]$Logger
)
$survivingFolders = [System.Collections.Generic.List[string]]::new()
$dedupGroups = @{}
foreach ($driverFolderGroup in $DriverFolderGroups) {
$driverFolder = [string]$driverFolderGroup.Name
# DISM names exported package folders <infname>_<arch>_<hash>; grouping on infname+arch
# (dropping the hash) is what lets us recognize two exports of the same driver. When a
# folder doesn't match that pattern, fall back to the full path rather than the leaf name:
# two unrelated folders at different depths (e.g. group_a\duplicate and group_b\duplicate)
# can share a leaf name, and the full path is guaranteed unique per group.
$leafName = Split-Path -Path $driverFolder -Leaf
$dedupKey = $driverFolder
$nameMatch = [regex]::Match($leafName, '(?i)^(?<infname>.+)_(?<arch>x86|amd64|arm64|arm|wow)_[0-9a-f]{16}$')
if ($nameMatch.Success) {
$provider = Get-WinUtilISODriverProvider -InfFile $driverFolderGroup.Group[0]
$dedupKey = "$($nameMatch.Groups['infname'].Value.ToLowerInvariant())_$($nameMatch.Groups['arch'].Value.ToLowerInvariant())_$provider"
}
if (-not $dedupGroups.ContainsKey($dedupKey)) {
$dedupGroups[$dedupKey] = [System.Collections.Generic.List[object]]::new()
}
$dedupGroups[$dedupKey].Add($driverFolderGroup)
}
foreach ($dedupKey in $dedupGroups.Keys) {
$candidates = $dedupGroups[$dedupKey]
if ($candidates.Count -eq 1) {
$survivingFolders.Add([string]$candidates[0].Name)
continue
}
$ranked = @($candidates | ForEach-Object {
$primaryVersion = ($_.Group | ForEach-Object { Get-WinUtilISODriverPackageVersion -InfFile $_ } | Where-Object { $_ }) |
Sort-Object -Property Date, Version -Descending | Select-Object -First 1
[pscustomobject]@{ Folder = [string]$_.Name; Version = $primaryVersion }
})
$withVersion = @($ranked | Where-Object { $_.Version })
if ($withVersion.Count -eq 0) {
$null = & $Logger "Warning: could not determine DriverVer for any duplicate of '$dedupKey'; keeping all $($ranked.Count) package(s) rather than guessing."
foreach ($candidate in $ranked) {
$survivingFolders.Add($candidate.Folder)
}
continue
}
$kept = $withVersion | Sort-Object -Property @{ Expression = { $_.Version.Date } }, @{ Expression = { $_.Version.Version } } -Descending | Select-Object -First 1
$survivingFolders.Add($kept.Folder)
foreach ($candidate in $ranked) {
if ($candidate.Folder -eq $kept.Folder) {
continue
}
$droppedVersion = if ($candidate.Version) { $candidate.Version.Raw } else { 'unknown' }
$null = & $Logger "Excluding stale duplicate driver package '$($candidate.Folder)' (DriverVer $droppedVersion) superseded by '$($kept.Folder)' (DriverVer $($kept.Version.Raw))."
}
}
return @($survivingFolders)
}
function Invoke-WinUtilISODism {
param (
[Parameter(Mandatory)][string[]]$Arguments,
@@ -246,93 +138,6 @@ function Invoke-WinUtilISOScript {
return @(& dism.exe /English /Get-MountedImageInfo 2>$null) -match [regex]::Escape($Path)
}
function Get-WinUtilISODriverFolderName {
param ([Parameter(Mandatory)][string]$DriverFolder)
if ($DriverFolder.StartsWith($driverExportRoot, [System.StringComparison]::OrdinalIgnoreCase)) {
return $DriverFolder.Substring($driverExportRoot.Length).TrimStart('\')
}
return $DriverFolder
}
function Get-WinUtilISORootDriverFolders {
param ([Parameter(Mandatory)][AllowEmptyCollection()][string[]]$DriverFolders)
return @($DriverFolders | Where-Object {
$candidate = $_
-not ($DriverFolders | Where-Object { $candidate.StartsWith("$_\", [System.StringComparison]::OrdinalIgnoreCase) })
})
}
function Add-WinUtilISODriversToImage {
param (
[Parameter(Mandatory)][string]$ImagePath,
[Parameter(Mandatory)][int]$ImageIndex,
[Parameter(Mandatory)][string]$MountDir,
[Parameter(Mandatory)][AllowEmptyCollection()][string[]]$DriverFolders,
[Parameter(Mandatory)][string]$ImageLabel,
[Parameter(Mandatory)][ref]$ImageMounted
)
if ($DriverFolders.Count -eq 0) {
& $Logger "No driver packages to add to ${ImageLabel}."
return 0
}
Set-ItemProperty -LiteralPath $ImagePath -Name IsReadOnly -Value $false
New-Item -Path $MountDir -ItemType Directory -Force | Out-Null
$remainingDriverFolders = @($DriverFolders)
while ($remainingDriverFolders.Count -gt 0) {
& $Logger "Mounting $ImageLabel index $ImageIndex for driver injection..."
Invoke-WinUtilISODism -Arguments @('/English', '/Mount-Image', "/ImageFile:$ImagePath", "/Index:$ImageIndex", "/MountDir:$MountDir") -Operation 'mount' | Out-Null
$ImageMounted.Value = $true
$failedDriverFolder = $null
$driverName = $null
foreach ($driverFolder in $remainingDriverFolders) {
$driverName = Get-WinUtilISODriverFolderName -DriverFolder $driverFolder
try {
Invoke-WinUtilISODism -Arguments @('/English', "/Image:$MountDir", '/Add-Driver', "/Driver:$driverFolder", '/Recurse') -Operation "add-driver:$driverName" | Out-Null
} catch {
& $Logger "Warning: failed to add driver package '$driverName': $_"
$failedDriverFolder = $driverFolder
break
}
}
if (-not $failedDriverFolder) {
break
}
& $Logger "Discarding the potentially partial $ImageLabel mount before continuing without '$driverName'."
try {
Invoke-WinUtilISODism -Arguments @('/English', '/Unmount-Image', "/MountDir:$MountDir", '/Discard') -Operation 'discard' | Out-Null
$ImageMounted.Value = $false
} catch {
throw "Failed to discard the potentially partial $ImageLabel mount after driver package '$driverName' failed: $_"
}
$remainingDriverFolders = @($remainingDriverFolders | Where-Object { $_ -ne $failedDriverFolder })
}
$addedCount = $remainingDriverFolders.Count
if ($addedCount -eq 0) {
if ($ImageLabel -eq 'install.wim') {
& $Logger "Warning: none of the $($DriverFolders.Count) exported driver packages could be added; continuing with an unmodified install.wim."
} else {
& $Logger "Warning: none of the $($DriverFolders.Count) driver packages could be added to ${ImageLabel}."
}
return 0
}
& $Logger "Added $addedCount of $($DriverFolders.Count) driver packages to ${ImageLabel}."
& $Logger "Committing the driver-only $ImageLabel change..."
Invoke-WinUtilISODism -Arguments @('/English', '/Unmount-Image', "/MountDir:$MountDir", '/Commit') -Operation 'commit' | Out-Null
$ImageMounted.Value = $false
return $addedCount
}
if ([IO.Path]::GetExtension($InstallImagePath) -ne '.wim') {
throw 'Current-system driver injection requires install.wim; install.esd cannot be serviced in place.'
}
@@ -346,94 +151,77 @@ function Invoke-WinUtilISOScript {
$driverExportRoot = Join-Path $env:TEMP "WinUtil_DriverExport_$(Get-Date -Format 'yyyyMMdd_HHmmss')_$(([guid]::NewGuid()).ToString('N').Substring(0, 8))"
$mountDir = Join-Path (Split-Path -Path $ContentRoot -Parent) 'wim_mount'
New-Item -Path $driverExportRoot -ItemType Directory -Force | Out-Null
# %TEMP% can be an 8.3 alias, but Get-ChildItem below reports long paths, so the
# exported folders would not share this prefix unless it is expanded first.
$driverExportRoot = (Get-Item -LiteralPath $driverExportRoot).FullName
$imageMounted = $false
try {
& $Logger "Exporting current system drivers before WIM driver injection..."
& $Logger "Exporting current system drivers before modifying install.wim..."
$dismLog = Join-Path $env:TEMP "WinUtil_DismDriverExport_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
Invoke-WinUtilISODism -Arguments @('/English', '/Online', '/Export-Driver', "/Destination:$driverExportRoot", "/LogPath:$dismLog") -Operation 'export-driver' | Out-Null
$dismProcess = Start-Process -FilePath "dism.exe" -ArgumentList "/online /export-driver /destination:`"$driverExportRoot`" /LogPath:`"$dismLog`"" -Wait -NoNewWindow -PassThru
if ($dismProcess.ExitCode -ne 0) {
throw "dism.exe driver export failed with exit code $($dismProcess.ExitCode)."
}
$driverInfs = @(Get-ChildItem -LiteralPath $driverExportRoot -Filter '*.inf' -Recurse -File)
$driverInfs = @(Get-ChildItem -Path $driverExportRoot -Filter '*.inf' -Recurse -File)
if ($driverInfs.Count -eq 0) {
throw 'DISM exported no driver INF files.'
}
$driverFolders = @($driverInfs | Group-Object { $_.Directory.FullName })
$stagedDriverFolders = @(Select-WinUtilISOStagedDriverPackages -DriverFolderGroups $driverFolders -Logger $Logger)
$metadataBefore = Get-WinUtilISOWimMetadata -ImagePath $InstallImagePath -Index $InstallImageIndex
Assert-WinUtilISOWimMetadata -Before $metadataBefore
$winpeDriverDir = Join-Path $ContentRoot '$WinpeDriver$'
$storageCount = 0
$copyFailures = 0
$excludedDriverFolderGroups = @($driverFolders | Where-Object { $_.Name -notin $stagedDriverFolders })
foreach ($excludedDriverFolderGroup in $excludedDriverFolderGroups) {
$excludedFolder = [string]$excludedDriverFolderGroup.Name
$hasRetainedDescendant = [bool]@($stagedDriverFolders | Where-Object {
$_.StartsWith("$excludedFolder\", [System.StringComparison]::OrdinalIgnoreCase)
}).Count
if ($hasRetainedDescendant) {
try {
foreach ($excludedInf in $excludedDriverFolderGroup.Group) {
Remove-Item -LiteralPath $excludedInf.FullName -Force -ErrorAction Stop
}
} catch {
throw "Failed to remove excluded driver INF files from package '$excludedFolder' before injection: $_"
}
& $Logger "Keeping excluded driver package directory '$excludedFolder' because it contains a retained nested package, after removing its excluded INF files."
foreach ($driverFolderGroup in $driverFolders) {
$driverFolder = [string]$driverFolderGroup.Name
$storageInfs = @($driverFolderGroup.Group | Where-Object { Test-WinUtilISOStorageDriver -InfFile $_ })
if ($storageInfs.Count -eq 0) {
continue
}
try {
Remove-Item -LiteralPath $excludedFolder -Recurse -Force -ErrorAction Stop
New-Item -Path $winpeDriverDir -ItemType Directory -Force | Out-Null
$winpeTarget = Copy-WinUtilISODriverFolder -Source $driverFolder -Destination $winpeDriverDir
$storageCount++
& $Logger "Staged boot-storage package '$driverFolder' for WinPE as '$winpeTarget'."
} catch {
throw "Failed to remove excluded driver package '$excludedFolder' before injection: $_"
$copyFailures++
& $Logger "Warning: failed to stage boot-storage package '$driverFolder': $_"
}
}
& $Logger "Exported $($stagedDriverFolders.Count) of $($driverFolders.Count) driver packages ($($excludedDriverFolderGroups.Count) excluded)."
# Storage for Setup comes from the same surviving set as install.wim, so stale
# duplicates never reach boot.wim.
$storageFolders = @(
$driverFolders |
Where-Object { $_.Name -in $stagedDriverFolders } |
Where-Object { @($_.Group | Where-Object { Test-WinUtilISOStorageDriver -InfFile $_ }).Count -gt 0 } |
ForEach-Object { [string]$_.Name }
)
$storageRootFolders = @(Get-WinUtilISORootDriverFolders -DriverFolders $storageFolders)
$rootPackageFolders = @(Get-WinUtilISORootDriverFolders -DriverFolders $stagedDriverFolders)
$imageMountedRef = [ref]$imageMounted
$bootWim = Join-Path $ContentRoot 'sources\boot.wim'
if ($storageRootFolders.Count -gt 0) {
if (Test-Path -LiteralPath $bootWim) {
& $Logger "Adding $($storageRootFolders.Count) root storage driver packages to boot.wim."
$null = Add-WinUtilISODriversToImage -ImagePath $bootWim -ImageIndex 2 -MountDir $mountDir -DriverFolders $storageRootFolders -ImageLabel 'boot.wim' -ImageMounted $imageMountedRef
} else {
& $Logger 'Warning: boot.wim was not found; Windows Setup will not have injected storage drivers.'
}
if ($copyFailures -gt 0) {
throw "Failed to stage $copyFailures boot-storage driver package folders."
}
& $Logger "Adding $($rootPackageFolders.Count) root driver packages to install.wim."
$addedCount = Add-WinUtilISODriversToImage -ImagePath $InstallImagePath -ImageIndex $InstallImageIndex -MountDir $mountDir -DriverFolders $rootPackageFolders -ImageLabel 'install.wim' -ImageMounted $imageMountedRef
if ($addedCount -gt 0) {
$metadataAfter = Get-WinUtilISOWimMetadata -ImagePath $InstallImagePath -Index $InstallImageIndex
Assert-WinUtilISOWimMetadata -Before $metadataBefore -After $metadataAfter
& $Logger 'Driver injection complete; install.wim metadata validation passed.'
$DriversInjected.Value = $true
}
& $Logger "Exported $($driverInfs.Count) driver INF files across $($driverFolders.Count) package folders; staged $storageCount boot-storage packages for WinPE."
$metadataBefore = Get-WinUtilISOWimMetadata -ImagePath $InstallImagePath -Index $InstallImageIndex
Assert-WinUtilISOWimMetadata -Before $metadataBefore
Set-ItemProperty -LiteralPath $InstallImagePath -Name IsReadOnly -Value $false
New-Item -Path $mountDir -ItemType Directory -Force | Out-Null
& $Logger "Mounting install.wim index $InstallImageIndex once for driver injection..."
Invoke-WinUtilISODism -Arguments @('/English', '/Mount-Image', "/ImageFile:$InstallImagePath", "/Index:$InstallImageIndex", "/MountDir:$mountDir") -Operation 'mount' | Out-Null
$imageMounted = $true
& $Logger "Adding all exported drivers to the selected Windows image in one DISM operation..."
Invoke-WinUtilISODism -Arguments @('/English', "/Image:$mountDir", '/Add-Driver', "/Driver:$driverExportRoot", '/Recurse') -Operation 'add-driver' | Out-Null
& $Logger 'Committing the driver-only install.wim change...'
Invoke-WinUtilISODism -Arguments @('/English', '/Unmount-Image', "/MountDir:$mountDir", '/Commit') -Operation 'commit' | Out-Null
$imageMounted = $false
$metadataAfter = Get-WinUtilISOWimMetadata -ImagePath $InstallImagePath -Index $InstallImageIndex
Assert-WinUtilISOWimMetadata -Before $metadataBefore -After $metadataAfter
& $Logger 'Driver injection complete; install.wim metadata validation passed.'
} finally {
if ($imageMounted -or (Test-WinUtilISOMountedImage -Path $mountDir)) {
try {
Invoke-WinUtilISODism -Arguments @('/English', '/Unmount-Image', "/MountDir:$mountDir", '/Discard') -Operation 'discard' | Out-Null
} catch {
& $Logger "Warning: could not discard the failed WIM mount: $_"
& $Logger "Warning: could not discard the failed install.wim mount: $_"
}
}
Remove-Item -LiteralPath $mountDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $driverExportRoot -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path $mountDir -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path $driverExportRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
@@ -747,6 +535,6 @@ $appxList
Write-WinUtilISOEditionConfig -ContentRoot $ISOContentsDir -EditionId $InstallEditionId -Logger $Log
if ($InjectCurrentSystemDrivers) {
Add-WinUtilISOStagedDrivers -ContentRoot $ISOContentsDir -Logger $Log -InstallImagePath $InstallImagePath -InstallImageIndex $InstallImageIndex -DriversInjected $DriversInjected
Add-WinUtilISOStagedDrivers -ContentRoot $ISOContentsDir -Logger $Log -InstallImagePath $InstallImagePath -InstallImageIndex $InstallImageIndex
}
}
+124 -99
View File
@@ -21,34 +21,25 @@ 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)) {
Show-WinUtilMessage -Message "No modified ISO content found. Please run the modification step first." -Title "Not Ready" -Button "OK" -Icon "Warning" | Out-Null
[System.Windows.MessageBox]::Show("No modified ISO content found. Please complete Steps 1-3 first.", "Not Ready", "OK", "Warning")
return
}
$installWim = Join-Path $contentsDir "sources\install.wim"
$installEsd = Join-Path $contentsDir "sources\install.esd"
if (Test-Path $installEsd) {
$esdSizeBytes = (Get-Item $installEsd).Length
$installEsdFile = Get-Item $installEsd
$esdSizeBytes = $installEsdFile.Length
$esdSizeMB = [math]::Ceiling($esdSizeBytes / 1MB)
if ($esdSizeBytes -ge 4GB) {
$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
[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")
return
}
}
@@ -67,60 +58,95 @@ function Invoke-WinUtilISOWriteUSB {
}
if (-not $targetDisk) {
Show-WinUtilMessage -Message "Please select a USB drive from the dropdown." -Title "No Drive Selected" -Button "OK" -Icon "Warning" | Out-Null
[System.Windows.MessageBox]::Show("Please select a USB drive from the dropdown.", "No Drive Selected", "OK", "Warning")
return
}
$diskNum = $targetDisk.Number
$sizeGB = [math]::Round($targetDisk.Size / 1GB, 1)
$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")
$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
}
Start-WinUtilJob -Name "USB write" -Description "Writing USB drive" -Parameters @{
DiskNumber = $diskNum
ContentsDir = $contentsDir
} -ScriptBlock {
param($DiskNumber, $contentsDir)
$sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $false
$sync["Win11ISOProcessRunning"] = $true
Write-WinUtilISOLog "Starting USB write to Disk $diskNum..."
Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $false }
Set-WinUtilISOStep -Step "Working" -Label "Writing the USB drive"
$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
}
try {
Write-WinUtilISOLog "Starting USB write to Disk $DiskNumber..."
Step-WinUtilJob -Status "Formatting USB drive..." -Percent 10
SetProgress "Formatting USB drive..." 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 $DiskNumber`nclean`nexit" | Set-Content -Path $dpFile1 -Encoding ASCII
Write-WinUtilISOLog "Running diskpart clean on Disk $DiskNumber..."
"select disk $diskNum`nclean`nexit" | Set-Content -Path $dpFile1 -Encoding ASCII
Log "Running diskpart clean on Disk $diskNum..."
$dpCleanOut = diskpart /s $dpFile1
$dpCleanOut | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" }
$dpCleanOut | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" }
Remove-Item $dpFile1 -Force
if (($dpCleanOut -join ' ') -match 'device is not ready') {
Write-WinUtilISOLog "Disk $DiskNumber was not ready; waiting 5 seconds and retrying clean..."
Log "Disk $diskNum was not ready; waiting 5 seconds and retrying clean..."
Start-Sleep -Seconds 5
Update-Disk -Number $DiskNumber
Update-Disk -Number $diskNum
$dpFile1b = Join-Path $env:TEMP "winutil_diskpart_$(Get-Random).txt"
"select disk $DiskNumber`nclean`nexit" | Set-Content -Path $dpFile1b -Encoding ASCII
diskpart /s $dpFile1b | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" }
"select disk $diskNum`nclean`nexit" | Set-Content -Path $dpFile1b -Encoding ASCII
diskpart /s $dpFile1b | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" }
Remove-Item $dpFile1b -Force
}
# Phase 2: Initialize as GPT
Start-Sleep -Seconds 2
Update-Disk -Number $DiskNumber
$diskObj = Get-Disk -Number $DiskNumber
Update-Disk -Number $diskNum
$diskObj = Get-Disk -Number $diskNum
if ($diskObj.PartitionStyle -eq 'RAW') {
Initialize-Disk -Number $DiskNumber -PartitionStyle GPT
Write-WinUtilISOLog "Disk $DiskNumber initialized as GPT."
Initialize-Disk -Number $diskNum -PartitionStyle GPT
Log "Disk $diskNum initialized as GPT."
} else {
Set-Disk -Number $DiskNumber -PartitionStyle GPT
Write-WinUtilISOLog "Disk $DiskNumber converted to GPT (was $($diskObj.PartitionStyle))."
Set-Disk -Number $diskNum -PartitionStyle GPT
Log "Disk $diskNum converted to GPT (was $($diskObj.PartitionStyle))."
}
# Phase 3: Create FAT32 partition via diskpart, then format with Format-Volume
@@ -128,64 +154,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 $DiskNumber).Size / 1MB)
$diskSizeMB = [int][Math]::Floor((Get-Disk -Number $diskNum).Size / 1MB)
$createPartitionCommand = "create partition primary"
if ($diskSizeMB -gt $maxFat32PartitionMB) {
$createPartitionCommand = "create partition primary size=$maxFat32PartitionMB"
Write-WinUtilISOLog "Disk $DiskNumber is $diskSizeMB MB; creating FAT32 partition capped at $maxFat32PartitionMB MB (32 GB)."
Log "Disk $diskNum is $diskSizeMB MB; creating FAT32 partition capped at $maxFat32PartitionMB MB (32 GB)."
}
@(
"select disk $DiskNumber"
"select disk $diskNum"
$createPartitionCommand
"exit"
) | Set-Content -Path $dpFile2 -Encoding ASCII
Write-WinUtilISOLog "Creating partitions on Disk $DiskNumber..."
diskpart /s $dpFile2 | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" }
Log "Creating partitions on Disk $diskNum..."
diskpart /s $dpFile2 | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" }
Remove-Item $dpFile2 -Force
Step-WinUtilJob -Status "Formatting USB partition..." -Percent 25
SetProgress "Formatting USB partition..." 25
Start-Sleep -Seconds 3
Update-Disk -Number $DiskNumber
Update-Disk -Number $diskNum
$partitions = Get-Partition -DiskNumber $DiskNumber
Write-WinUtilISOLog "Partitions on Disk $DiskNumber after creation: $($partitions.Count)"
$partitions = Get-Partition -DiskNumber $diskNum
Log "Partitions on Disk $diskNum after creation: $($partitions.Count)"
foreach ($p in $partitions) {
Write-WinUtilISOLog " Partition $($p.PartitionNumber) Type=$($p.Type) Letter=$($p.DriveLetter) Size=$([math]::Round($p.Size/1MB))MB"
Log " 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 $DiskNumber after creation."
throw "Could not find the Basic partition on Disk $diskNum 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)
Write-WinUtilISOLog "Formatting Partition $($winpePart.PartitionNumber) as FAT32 (label: $volLabel)..."
Get-Partition -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber |
Log "Formatting Partition $($winpePart.PartitionNumber) as FAT32 (label: $volLabel)..."
Get-Partition -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber |
Format-Volume -FileSystem FAT32 -NewFileSystemLabel $volLabel -Force -Confirm:$false
Write-WinUtilISOLog "Partition $($winpePart.PartitionNumber) formatted as FAT32."
Log "Partition $($winpePart.PartitionNumber) formatted as FAT32."
Step-WinUtilJob -Status "Assigning drive letters..." -Percent 30
SetProgress "Assigning drive letters..." 30
Start-Sleep -Seconds 2
Update-Disk -Number $DiskNumber
Update-Disk -Number $diskNum
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
try { Remove-PartitionAccessPath -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber -AccessPath "$($winpePart.DriveLetter):" } catch { Log "Warning: could not remove existing partition access path: $_" }
$usbLetter = Get-FreeDriveLetter
if (-not $usbLetter) { throw "No free drive letters (D-Z) available to assign to the USB data partition." }
Set-Partition -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber -NewDriveLetter $usbLetter
Write-WinUtilISOLog "Assigned drive letter $usbLetter to WINPE partition (Partition $($winpePart.PartitionNumber))."
Set-Partition -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber -NewDriveLetter $usbLetter
Log "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++
Write-WinUtilISOLog "Waiting for $usbDrive to become accessible (attempt $retries/6)..."
Log "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." }
Write-WinUtilISOLog "USB data partition: $usbDrive"
Log "USB data partition: $usbDrive"
$contentSizeBytes = (Get-ChildItem -LiteralPath $contentsDir -File -Recurse -Force | Measure-Object -Property Length -Sum).Sum
if (-not $contentSizeBytes) { $contentSizeBytes = 0 }
@@ -197,7 +223,7 @@ function Invoke-WinUtilISOWriteUSB {
$partitionCapacityGB = [math]::Round($partitionCapacityBytes / 1GB, 2)
$partitionFreeGB = [math]::Round($partitionFreeBytes / 1GB, 2)
Write-WinUtilISOLog "Source content size: $contentSizeGB GB. USB partition capacity: $partitionCapacityGB GB, free: $partitionFreeGB GB."
Log "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."
@@ -207,56 +233,55 @@ function Invoke-WinUtilISOWriteUSB {
throw "Insufficient free space on USB partition. Required: $contentSizeGB GB, available: $partitionFreeGB GB."
}
Step-WinUtilJob -Status "Copying Windows 11 files to USB..." -Percent 45
SetProgress "Copying Windows 11 files to USB..." 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) {
Write-WinUtilISOLog "install.wim is $wimSizeMB MB - splitting for FAT32 compatibility... This will take several minutes."
Log "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 | Out-Null
New-Item -ItemType Directory -Path (Split-Path $splitDest) -Force
Split-WindowsImage -ImagePath $installWim -SplitImagePath $splitDest -FileSize 3800 -CheckIntegrity
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")
Log "install.wim split complete."
Log "Copying remaining files to USB..."
& robocopy $contentsDir $usbDrive /E /XF install.wim /NFL /NDL /NJH /NJS
} else {
Invoke-WinUtilRobocopy -Source $contentsDir -Destination $usbDrive -Arguments @("/E","/NFL","/NDL","/NJH","/NJS")
& robocopy $contentsDir $usbDrive /E /NFL /NDL /NJH /NJS
}
} else {
Invoke-WinUtilRobocopy -Source $contentsDir -Destination $usbDrive -Arguments @("/E","/NFL","/NDL","/NJH","/NJS")
& robocopy $contentsDir $usbDrive /E /NFL /NDL /NJH /NJS
}
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."
SetProgress "Finalising USB drive..." 90
Log "Files copied to USB."
SetProgress "USB write complete" 100
Log "USB drive is ready for use."
Invoke-WPFUIThread -Parameters @{ DiskNumber = $DiskNumber } -ScriptBlock {
param($DiskNumber)
$sync["WPFWin11ISODoneLabel"].Text = "Disk $DiskNumber is ready to boot from."
$sync["WPFWin11ISODonePanel"].Visibility = "Visible"
}
Set-WinUtilISOStep -Step "Output"
Show-WinUtilMessage -Message "USB drive created successfully!`n`nYou can now boot from this drive to install Windows 11." -Title "USB Ready" -Button "OK" -Icon "Info" | Out-Null
$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")
})
} catch {
Write-WinUtilISOLog -Level "ERROR" -Message "USB write failed: $_"
$_.Exception.Data["WinUtilErrorReported"] = $true
Set-WinUtilISOStep -Step "Output"
Show-WinUtilMessage -Message "USB write failed:`n`n$_" -Title "USB Write Error" -Button "OK" -Icon "Error" | Out-Null
throw
Log "ERROR during USB write: $_"
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
[System.Windows.MessageBox]::Show("USB write failed:`n`n$_", "USB Write Error", "OK", "Error")
})
} finally {
Invoke-WPFUIThread -ScriptBlock {
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
# Cancellation skips catch, so the working page can still be up here
if ($sync["WPFWin11ISOWorkingSection"].IsSelected) {
Set-WinUtilISOStep -Step "Output"
}
}
})
}
}
})
$script.BeginInvoke()
}
@@ -1,68 +1,15 @@
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 }
}
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."
if (-not (Get-Command wt)) {
Write-Host "Windows Terminal not found. Installing..."
Install-WinUtilWinget
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."
}
winget install Microsoft.WindowsTerminal --source winget --silent
}
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 (-not (Get-Command pwsh)) {
Write-Host "PowerShell 7 not found. Installing..."
Install-WinUtilWinget
winget install Microsoft.PowerShell --source winget --installer-type wix --silent
}
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."
}
Write-WinUtilLog -Component "Feature" -Message "CTT PowerShell profile installed. Open a new PowerShell 7 session to use it."
wt new-tab pwsh -NoExit -Command "irm https://github.com/ChrisTitusTech/powershell-profile/raw/main/setup.ps1 | iex"
}
@@ -20,13 +20,9 @@ function Invoke-WinUtilSSHServer {
#Adding Firewall rule for port 22
Write-Host "Setting up firewall rules"
$firewallRule = Get-NetFirewallRule -Name 'sshd' -ErrorAction SilentlyContinue
if ($null -eq $firewallRule) {
if (-not ((Get-NetFirewallRule -Name 'sshd').Enabled)) {
New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22
Write-Host "Firewall rule for OpenSSH Server created and enabled."
} elseif ([int]$firewallRule.Enabled -eq 2) {
Set-NetFirewallRule -Name 'sshd' -Enabled True
Write-Host "Firewall rule for OpenSSH Server enabled."
}
# An SSH logon for a member of the administrators group gets a full token
@@ -1,36 +1,10 @@
function Invoke-WinUtilUninstallPSProfile {
<#
.SYNOPSIS
Restores the PowerShell 7 profile the CTT profile replaced
.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."
if (Test-Path ($Profile + ".bak")) {
Move-Item -Path ($Profile + ".bak") -Destination $Profile
} else {
Remove-Item -Path $Profile
}
$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."
Write-Host "Successfully uninstalled CTT PowerShell Profile." -ForegroundColor Green
}
-105
View File
@@ -1,105 +0,0 @@
function Measure-WinUtilStep {
<#
.SYNOPSIS
Times one step of a pipeline and records it for the timing summary
.DESCRIPTION
Output passes through untouched, so this can wrap an existing expression without
changing what the caller receives. Each step is logged as a "timing:" line and kept
in $sync.StepTimings for the summary to rank.
.PARAMETER Name
What the step is, as it should read in the log.
.PARAMETER ScriptBlock
The work to time.
.PARAMETER Scope
Groups steps that belong to the same run, normally a job name or "UI".
#>
param(
[Parameter(Mandatory, Position = 0)]
[string]$Name,
[Parameter(Mandatory, Position = 1)]
[scriptblock]$ScriptBlock,
[string]$Scope = "WinUtil"
)
$isUIDiagnostic = $Scope -in @("UI", "Tab")
$captureTiming = -not $isUIDiagnostic -or $sync.IsLocalCompile
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
try {
& $ScriptBlock
} finally {
$stopwatch.Stop()
if ($captureTiming -and $null -ne $sync.StepTimings) {
$null = $sync.StepTimings.Add([pscustomobject]@{
Scope = $Scope
Step = $Name
Milliseconds = $stopwatch.ElapsedMilliseconds
})
}
if ($captureTiming) {
$level = if ($isUIDiagnostic) { "DEBUG" } else { "INFO" }
Write-WinUtilLog -Level $level -Component $Scope -Message "timing: $Name took $($stopwatch.ElapsedMilliseconds) ms"
}
}
}
function Write-WinUtilTimingSummary {
<#
.SYNOPSIS
Logs the slowest steps of a scope, so the log answers "what took so long"
.PARAMETER Scope
Which group of steps to report on.
.PARAMETER Top
How many of the slowest steps to list.
.PARAMETER TotalMilliseconds
Wall clock total. Without it the summary sums the steps, missing whatever happened
between them.
#>
param(
[Parameter(Mandatory)]
[string]$Scope,
[int]$Top = 5,
[long]$TotalMilliseconds = -1,
[int]$StartIndex = 0
)
$isUIDiagnostic = $Scope -in @("UI", "Tab")
if (($isUIDiagnostic -and -not $sync.IsLocalCompile) -or $null -eq $sync.StepTimings) {
return
}
[System.Threading.Monitor]::Enter($sync.StepTimings.SyncRoot)
try {
$timingSnapshot = @($sync.StepTimings.ToArray())
} finally {
[System.Threading.Monitor]::Exit($sync.StepTimings.SyncRoot)
}
$steps = @($timingSnapshot | Select-Object -Skip $StartIndex | Where-Object { $_.Scope -eq $Scope })
if ($steps.Count -eq 0) {
return
}
$measured = ($steps | Measure-Object -Property Milliseconds -Sum).Sum
$total = if ($TotalMilliseconds -ge 0) { $TotalMilliseconds } else { $measured }
$level = if ($isUIDiagnostic) { "DEBUG" } else { "INFO" }
Write-WinUtilLog -Level $level -Component $Scope -Message "timing summary: $($steps.Count) step(s), $measured ms measured of $total ms total"
foreach ($step in ($steps | Sort-Object Milliseconds -Descending | Select-Object -First $Top)) {
$share = if ($total -gt 0) { [int](($step.Milliseconds / $total) * 100) } else { 0 }
Write-WinUtilLog -Level $level -Component $Scope -Message "timing summary: $($step.Milliseconds) ms ($share%) $($step.Step)"
}
}
@@ -1,55 +0,0 @@
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
}
@@ -1,131 +0,0 @@
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,9 +55,7 @@ function Remove-WinUtilProvisionedAPPX {
$failureDetails = ($removalOutput | Out-String).Trim()
$errorMessage = "AppX provisioned package removal failed: $failureDetails"
Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message $errorMessage
$exception = [System.InvalidOperationException]::new($errorMessage)
$exception.Data["WinUtilErrorReported"] = $true
throw $exception
throw $errorMessage
}
Write-WinUtilLog -Component "AppX" -Message "AppX provisioned package removal completed."
+9 -26
View File
@@ -2,7 +2,7 @@ function Reset-WPFCheckBoxes {
<#
.SYNOPSIS
Set WinUtil checkboxes to match $sync.selected values.
Set winutil checkboxs to match $sync.selected values.
Should only need to be run if $sync.selected updated outside of UI (i.e. presets or import)
.PARAMETER doToggles
@@ -22,30 +22,19 @@ function Reset-WPFCheckBoxes {
)
$selectedSet = [System.Collections.Generic.HashSet[string]]::new([string[]]@($sync.selectedApps + $sync.selectedTweaks + $sync.selectedFeatures + $sync.selectedAppx), [StringComparer]::OrdinalIgnoreCase)
# 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) {
foreach ($syncEntry in $sync.GetEnumerator()) {
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. 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 $_ }
}
# 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 $_ }
if($doToggles) {
# Restore toggle switch states from imported config.
@@ -53,13 +42,7 @@ 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)
[System.Threading.Monitor]::Enter($sync.SyncRoot)
try {
$toggleEntries = @($sync.GetEnumerator())
} finally {
[System.Threading.Monitor]::Exit($sync.SyncRoot)
}
foreach ($toggle in $toggleEntries) {
foreach ($toggle in $sync.GetEnumerator()) {
if ($toggle.Key -like "WPFToggle*" -and $toggle.Value -is [System.Windows.Controls.CheckBox] -and $importedToggles.Contains($toggle.Key)) {
$sync[$toggle.Key].IsChecked = $true
}
-15
View File
@@ -18,21 +18,6 @@ function Set-WinUtilDNS {
return $true
}
if($DNSProvider -eq "Fastest") {
Write-WinUtilLog -Component "DNS" -Message "Auto-detecting fastest DNS provider via latency benchmark..."
$benchmark = Get-WinUtilDNSBenchmark
$validFastest = $benchmark | Where-Object { $_.LatencyMs -lt 9999 } | Select-Object -First 1
if ($validFastest) {
$DNSProvider = $validFastest.Provider
Write-Host "Auto-selected fastest DNS provider: $DNSProvider ($($validFastest.LatencyMs) ms)"
Write-WinUtilLog -Component "DNS" -Message "Auto-selected fastest DNS provider: $DNSProvider ($($validFastest.LatencyMs) ms)"
} else {
Write-Warning "Could not measure DNS latency to any provider; keeping current network adapter DNS settings."
Write-WinUtilLog -Component "DNS" -Message "Benchmark timeout or all probes failed; aborting DNS change to preserve existing settings."
return $false
}
}
try {
$Adapters = Get-NetAdapter | Where-Object {$_.Status -eq "Up"}
Write-Host "Ensuring DNS is set to $DNSProvider on the following interfaces:"
+1 -4
View File
@@ -33,10 +33,7 @@ Function Set-WinUtilService {
# Service exists, proceed with changing properties -- while handling auto delayed start for PWSH 5
if (($PSVersionTable.PSVersion.Major -lt 7) -and ($StartupType -eq "AutomaticDelayedStart")) {
sc.exe config $Name start= delayed-auto
if ($LASTEXITCODE -ne 0) {
throw "sc.exe config failed with exit code $LASTEXITCODE"
}
sc.exe config $Name start=delayed-auto
} else {
$service | Set-Service -StartupType $StartupType -ErrorAction Stop
}
@@ -0,0 +1,39 @@
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
}
}
}
+1 -28
View File
@@ -2,14 +2,6 @@ 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,
@@ -18,24 +10,5 @@ function Show-WinUtilMessage {
$Icon = "Information"
)
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)
}
[System.Windows.MessageBox]::Show($Message, $Title, $Button, $Icon)
}
@@ -1,40 +0,0 @@
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
}
@@ -1,156 +0,0 @@
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,30 +4,8 @@ function Invoke-WinUtilInstallAppRenderBatch {
$CategoryBatch
)
# 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) {
foreach ($appKey in $CategoryBatch.AppKeys) {
$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
@@ -43,7 +21,23 @@ 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 {
@@ -53,15 +47,18 @@ function Start-WinUtilInstallAppRendering {
$sync.InstallAppEntriesRendered = $false
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
}
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
}
-258
View File
@@ -1,258 +0,0 @@
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)
}
}
@@ -1,38 +0,0 @@
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
}
}
}
@@ -1,538 +0,0 @@
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
})
$sync["WPFWin11ISOBackButton"].Add_Click({
$sync["WPFWin11ISOSelectSection"].IsSelected = $true
})
$sync["WPFWin11ISOForwardButton"].Add_Click({
$sync["WPFWin11ISOModifySection"].IsSelected = $true
})
$buildClock.Stop()
Write-WinUtilLog -Level "DEBUG" -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 -Level "DEBUG" -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
@@ -1,104 +0,0 @@
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
}
}
}
@@ -1,150 +0,0 @@
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
}
@@ -1,97 +0,0 @@
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,16 +19,28 @@ 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"
}
}
+2 -75
View File
@@ -1,21 +1,4 @@
function Update-WinUtilSelections {
param(
[Parameter(Mandatory)]
[string[]]$flatJson,
[switch]$Replace,
[switch]$SkipUnknown
)
$nextSelections = @{
selectedApps = [System.Collections.Generic.List[string]]::new()
selectedTweaks = [System.Collections.Generic.List[string]]::new()
selectedToggles = [System.Collections.Generic.List[string]]::new()
selectedFeatures = [System.Collections.Generic.List[string]]::new()
selectedAppx = [System.Collections.Generic.List[string]]::new()
}
function Update-WinUtilSelections ($flatJson) {
foreach ($cbkey in $flatJson) {
$listName = switch -Regex ($cbkey) {
@@ -26,62 +9,6 @@ function Update-WinUtilSelections {
'^WPFAppx' { 'selectedAppx' }
}
if (-not $listName) {
if ($SkipUnknown) {
$cbkey
continue
}
throw "Unsupported selection key '$cbkey'."
}
$isKnownSelection = switch ($listName) {
'selectedApps' {
$sync.configs.applicationsHashtable.ContainsKey($cbkey)
}
'selectedTweaks' {
$null -ne $sync.configs.tweaks.PSObject.Properties[$cbkey]
}
'selectedToggles' {
$null -ne $sync.configs.tweaks.PSObject.Properties[$cbkey]
}
'selectedFeatures' {
$null -ne $sync.configs.feature.PSObject.Properties[$cbkey]
}
'selectedAppx' {
$sync.configs.appxHashtable.ContainsKey($cbkey)
}
}
if (-not $isKnownSelection) {
if ($SkipUnknown) {
$cbkey
continue
}
throw "Unknown selection key '$cbkey'."
}
$nextSelections[$listName].Add($cbkey)
}
$validSelectionCount = ($nextSelections.Values | ForEach-Object { $_.Count } | Measure-Object -Sum).Sum
if ($SkipUnknown -and $validSelectionCount -eq 0) {
return
}
if ($Replace) {
foreach ($listName in $nextSelections.Keys) {
$sync[$listName] = $nextSelections[$listName]
}
return
}
foreach ($listName in $nextSelections.Keys) {
foreach ($cbkey in $nextSelections[$listName]) {
# 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)
}
}
$sync.$listName.Add($cbkey)
}
}
@@ -1,91 +0,0 @@
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 = ""
}
@@ -1,132 +0,0 @@
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)"
}
}
}
}
}
@@ -1,58 +0,0 @@
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
}
}
@@ -1,54 +0,0 @@
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
}
+1 -42
View File
@@ -4,11 +4,6 @@ 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.
@@ -26,26 +21,9 @@ function Write-WinUtilLog {
[ValidateSet("INFO", "WARN", "ERROR", "DEBUG")]
[string]$Level = "INFO",
[string]$Component = "WinUtil",
# Continuation of an error already counted, such as a stack frame
[switch]$Detail
[string]$Component = "WinUtil"
)
# UI performance diagnostics are useful to developers but are too noisy for the release
# transcript. Compile.ps1 stamps local builds so DEBUG output cannot leak into CI artifacts.
if ($Level -eq "DEBUG" -and ($null -eq $sync -or -not $sync.IsLocalCompile)) {
return
}
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
@@ -92,29 +70,10 @@ 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)"
+4 -1
View File
@@ -7,7 +7,10 @@ function Initialize-WPFUI {
switch ($TargetGridName) {
"appscategory"{
Invoke-WPFUIElements -configVariable $sync.configs.appnavigation -targetGridName "appscategory" -columncount 1
# TODO
# Switch UI generation of the sidebar to this function
# $sync.ItemsControl = Initialize-InstallAppArea -TargetElement $TargetGridName
# ...
# Create and configure a popup for displaying selected apps
$selectedAppsPopup = New-Object Windows.Controls.Primitives.Popup
+55 -24
View File
@@ -1,37 +1,68 @@
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
}
Start-WinUtilJob -Name "AppX install" -Description "Installing AppX packages" -Parameters @{
Selected = @($sync.selectedAppx)
Apps = $sync.configs.appxHashtable
} -ScriptBlock {
param($Selected, $Apps)
$selected = @($sync.selectedAppx)
$apps = $sync.configs.appxHashtable
$totalPackages = @($Selected).Count
$results = @()
Write-WinUtilLog -Component "AppX" -Message "Starting AppX install for $totalPackages selected package(s)."
$sync.ProcessRunning = $true
Invoke-WPFRunspace -ParameterList @(("selected", $selected), ("apps", $apps)) -ScriptBlock {
param($selected, $apps)
for ($index = 0; $index -lt $totalPackages; $index++) {
$app = $Apps[$Selected[$index]]
$position = $index + 1
$totalPackages = @($selected).Count
$hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher
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"
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" }
}
Step-WinUtilJob -Status "$status $($app.Content) ($position/$totalPackages)" -Percent ([int](($position / $totalPackages) * 100))
}
Complete-WinUtilPackageRun -Action "Install" -Results $results
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" }
}
}
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
}
}
}
+81 -45
View File
@@ -1,64 +1,100 @@
function Invoke-WPFAppxRemoval {
<#
.SYNOPSIS
Removes the selected AppX packages
#>
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
}
Start-WinUtilJob -Name "AppX" -Description "Removing AppX packages" -Parameters @{
Selected = @($sync.selectedAppx)
Apps = $sync.configs.appxHashtable
} -ScriptBlock {
param($Selected, $Apps)
$selected = @($sync.selectedAppx)
$apps = $sync.configs.appxHashtable
$total = @($Selected).Count
$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
$packageList = [System.Collections.Generic.List[string]]::new()
Write-WinUtilLog -Component "AppX" -Message "Starting AppX removal for $total selected package(s)."
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
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" }
}
if ($key -eq "WPFAppxMicrosoft_WindowsNotepad") {
Write-WinUtilLog -Component "AppX" -Message "Stopping dllhost before removing Notepad."
Stop-Process -Name dllhost -Force -Confirm:$false -ErrorAction SilentlyContinue
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) }
}
}
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
if ($packageList.Count -gt 0) {
if ($hasUI) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Removing provisioned AppX packages" -Percent 90
}
Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray()
}
Step-WinUtilJob -Status "Removed $($app.Content) ($position/$total)" -Percent ([int](($position / $total) * 90))
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
}
if ($packageList.Count -gt 0) {
Step-WinUtilJob -Status "Removing provisioned AppX packages" -Percent 90
Remove-WinUtilProvisionedAPPX -PackageList $packageList.ToArray()
}
}
} | Out-Null
}
+10 -122
View File
@@ -3,14 +3,7 @@ function Invoke-WPFButton {
<#
.SYNOPSIS
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.
Invokes the function associated with the clicked button
.PARAMETER Button
The name of the button that was clicked
@@ -19,112 +12,12 @@ function Invoke-WPFButton {
Param ([string]$Button)
# Clear the progress left behind by the previous job, but never while one is running
if (-not $sync.ActiveJob) {
Step-WinUtilJob -Hide
# 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
}
# 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
@@ -168,7 +61,7 @@ function Invoke-WPFButtonAction {
"WPFRemoveUltPerf" {Invoke-WPFUltimatePerformance}
"WPFundoall" {Invoke-WPFundoall}
"WPFUpdatesdefault" {Invoke-WPFUpdatesdefault}
"WPFUpdatesdisable" {Invoke-WPFUpdatesdisable -Confirmed:$UpdatesDisableConfirmed}
"WPFUpdatesdisable" {Invoke-WPFUpdatesdisable}
"WPFUpdatessecurity" {Invoke-WPFUpdatessecurity}
"WPFGetInstalled" {Invoke-WPFGetInstalled -CheckBox "winget"}
"WPFGetInstalledTweaks" {Invoke-WPFGetInstalled -CheckBox "tweaks"}
@@ -185,18 +78,13 @@ function Invoke-WPFButtonAction {
}
"WPFGetInstalledAppx" {
$installedAppxPackages = Get-WinUtilInstalledAPPX
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
}
foreach ($appx in $sync.configs.appxHashtable.GetEnumerator()) {
if ($appx.Value.PackageId -in $installedAppxPackages) {
$sync.$($appx.Key).IsChecked = $true
}
}
}
# 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()}
"WPFCloseButton" {$sync.Form.Close(); Write-Host "Bye bye!"}
"WPFMinimizeButton" {[Windows.SystemCommands]::MinimizeWindow($sync.Form)}
"WPFMaximizeButton" {
if ($sync.Form.WindowState -eq [Windows.WindowState]::Normal) {
@@ -1,76 +0,0 @@
function Invoke-WPFExportEnvironmentReport {
<#
.SYNOPSIS
Exports an allowlisted, read-only environment report as JSON, with an optional bundle of
recent WinUtil logs.
#>
try {
$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()
$dialog.Title = "Export Environment Report"
$dialog.Filter = "JSON files (*.json)|*.json"
$dialog.FileName = "WinUtilEnvironmentReport_$(Get-Date -Format 'yyyyMMdd').json"
$dialog.InitialDirectory = [Environment]::GetFolderPath("Desktop")
if ($dialog.ShowDialog() -ne [System.Windows.Forms.DialogResult]::OK) {
return
}
$jsonPath = $dialog.FileName
$logsPath = Get-WinUtilEnvironmentReportLogsPath -JsonPath $jsonPath
# 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
}
$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)"
Show-WinUtilMessage -Message "The environment report could not be exported. $($_.Exception.Message)" `
-Title "Environment Report" -Button "OK" -Icon "None" | Out-Null
}
}

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