mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-10-08 11:39:10 +11:00
Compare commits
51
Commits
26.08.19
...
9419b2803e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9419b2803e | ||
|
|
92d5a08d49 | ||
|
|
a3a7c500d4 | ||
|
|
40666e1d31 | ||
|
|
4d4e219562 | ||
|
|
fc03af421b | ||
|
|
8939ecf4b4 | ||
|
|
735b59b82f | ||
|
|
d0d39d6478 | ||
|
|
6f1266eb86 | ||
|
|
48885d7c1e | ||
|
|
abcbc23144 | ||
|
|
311fde6f31 | ||
|
|
ad81e0a7af | ||
|
|
8e3998d9fd | ||
|
|
f65d2631a0 | ||
|
|
5f39d3b42d | ||
|
|
448543870a | ||
|
|
91a4f62068 | ||
|
|
2a73455872 | ||
|
|
52ebc931fd | ||
|
|
7aff652d4c | ||
|
|
be394d2ce0 | ||
|
|
b4181bd008 | ||
|
|
da7354bfc0 | ||
|
|
9de91c3999 | ||
|
|
c2d65b6f99 | ||
|
|
700725a878 | ||
|
|
f9139c75ff | ||
|
|
9ac45d6c31 | ||
|
|
2260df6365 | ||
|
|
fc60cbde09 | ||
|
|
dbe1c365f5 | ||
|
|
a0d3c719a6 | ||
|
|
283b9954f1 | ||
|
|
69ff04b632 | ||
|
|
7ee5be3d2f | ||
|
|
4a1db91e3f | ||
|
|
41e4f35897 | ||
|
|
d1a61790a1 | ||
|
|
87dd643176 | ||
|
|
8f7b12bc0d | ||
|
|
88071f2849 | ||
|
|
df858c4cd7 | ||
|
|
cc5e31460b | ||
|
|
d0f7689504 | ||
|
|
22e1dc9b09 | ||
|
|
c00f1eb7b3 | ||
|
|
f54b22da2c | ||
|
|
e15e2be1c4 | ||
|
|
34f7ce5a0e |
+4
-1
@@ -3,4 +3,7 @@
|
|||||||
|
|
||||||
# Seanh1917 (ANGRYxScotsmans) is the docs guy
|
# Seanh1917 (ANGRYxScotsmans) is the docs guy
|
||||||
docs/ @ChrisTitusTech @seanh1995
|
docs/ @ChrisTitusTech @seanh1995
|
||||||
tools/devdocs-generator.* @ChrisTitusTech @seanh1995
|
tools/devdocs-generator.* @ChrisTitusTech @seanh1995
|
||||||
|
|
||||||
|
# Title screen generation
|
||||||
|
tools/title-screen/ @ChrisTitusTech @mewclouds
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ graph TD
|
|||||||
* Run the following command to compile and run WinUtil:
|
* Run the following command to compile and run WinUtil:
|
||||||
* `.\Compile.ps1 -run`
|
* `.\Compile.ps1 -run`
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|
||||||
Open PowerShell as Administrator.
|
Open PowerShell as Administrator.
|
||||||
|
|||||||
@@ -43,14 +43,16 @@ body:
|
|||||||
- type: textarea
|
- type: textarea
|
||||||
id: issue_description
|
id: issue_description
|
||||||
attributes:
|
attributes:
|
||||||
label: Provide a clear and concise description of the issue.
|
label: "Description of the issue"
|
||||||
|
description: Provide a clear and concise description of the issue.
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: tweaks_applied
|
id: tweaks_applied
|
||||||
attributes:
|
attributes:
|
||||||
label: List the tweaks you applied before the issue occurred.
|
label: "List of Applied Tweaks"
|
||||||
|
description: List the tweaks you applied before the issue occurred.
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
@@ -58,4 +60,5 @@ body:
|
|||||||
id: error_output
|
id: error_output
|
||||||
attributes:
|
attributes:
|
||||||
label: Paste the full error output (if available) or Screenshot or Video.
|
label: Paste the full error output (if available) or Screenshot or Video.
|
||||||
placeholder: "Include any relevant logs or error messages."
|
description: "If you're willing to share the whole log, please provide an external link to text-sharing service, i.e. Pastebin"
|
||||||
|
placeholder: "Include any relevant logs or error messages. Keep any sensitive info out of the presented log"
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
name: Generate WinUtil title screen
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: winutil-title-screen
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
generate:
|
||||||
|
runs-on: windows-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
WINUTIL_CAPTURE_WIDTH: "1920"
|
||||||
|
WINUTIL_CAPTURE_HEIGHT: "1080"
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: pwsh
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install uv and Python
|
||||||
|
uses: astral-sh/setup-uv@v10.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
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ testResults.xml
|
|||||||
# general software/os specific
|
# general software/os specific
|
||||||
desktop.ini
|
desktop.ini
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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).
|
- 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/`. The rest of the repo is PowerShell (`Compile.ps1`, Pester, Script Analyzer) and runs directly on the host per Section 1.
|
- 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.
|
||||||
|
|
||||||
## 3. Source Of Truth
|
## 3. Source Of Truth
|
||||||
|
|
||||||
@@ -127,6 +127,7 @@ 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.
|
- 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.
|
- 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.
|
- 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.
|
- Before finishing, check `git status --short` and separate your changes from pre-existing user changes.
|
||||||
- Do not revert user changes unless explicitly asked.
|
- Do not revert user changes unless explicitly asked.
|
||||||
@@ -174,9 +175,16 @@ When the user corrects an agent approach, add or tighten one concrete rule here
|
|||||||
- Import Pester 5.8.0 before running tests so `Invoke-Pester -Output Detailed -CI` does not resolve to Windows' inbox Pester 3.4.0.
|
- 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.
|
- 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.
|
- When the active log file is owned by `Start-Transcript`, do not call `Add-Content` against that file; write to host output so the transcript captures the line in the same log file without recording a terminating-error diagnostic.
|
||||||
- Keep UI helpers such as `Invoke-WPFUIThread` and `Set-WinUtilTweaksProgressIndicator` safe to call without a window; the `-Preset` and `-Config` paths run the workflows before the form is created and before PresentationCore is loaded.
|
- Keep UI helpers such as `Invoke-WPFUIThread` and `Step-WinUtilJob` safe to call without a window; the `-Preset` and `-Config` paths run the workflows before the form is created and before PresentationCore is loaded. Ask `Test-WinUtilUIAlive` rather than writing the `$sync.Form` / dispatcher / `HasShutdownStarted` check out by hand.
|
||||||
|
- Put long operations on the job layer with `Start-WinUtilJob` and report from them with `Step-WinUtilJob`; a job body must not set the busy flag, print its own banner, or carry its own try/catch/finally around the interface. `Invoke-WPFRunspace` directly is for fire-and-forget work that is not a job.
|
||||||
|
- Drain interface work that nobody is waiting for through `Start-WinUtilBackgroundQueue`; do not hand-roll another dequeue-and-re-post pump.
|
||||||
|
- Values a posted scriptblock needs travel as the dispatcher's argument or through `-Parameters`, never captured from the caller: a plain block resolves them when the dispatcher gets to it, and `GetNewClosure` binds command lookup to a copied scope. For the same reason, prefer a compiled `[action]` over `Invoke-WPFUIThread -Async` on hot re-posting paths, which marshals its body as text and recompiles it per post.
|
||||||
|
- Diagnostic scaffolding does not ship. Measure with it, then delete it.
|
||||||
|
- Have each Pester file load the assemblies and dot-source the functions it needs; several passed only because an earlier file in alphabetical order happened to load them.
|
||||||
- Log install/uninstall package names and package-manager IDs before queuing background runspace work; do not rely on runspace host output for the package identity.
|
- 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, start each new ISO modification in a fresh `WinUtil_Win11ISO_*` temp directory; existing-work detection is only for resuming/exporting already modified media.
|
||||||
- For Win11 Creator driver injection, keep offline WIM servicing to one mount, one `/Add-Driver`, and one commit; do not export editions or run unrelated WIM cleanup, and reject damaged metadata before ISO export.
|
- For Win11 Creator driver injection, 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 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 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.
|
- 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`.
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ $sync = [Hashtable]::Synchronized(@{})
|
|||||||
$sync.configs = @{}
|
$sync.configs = @{}
|
||||||
|
|
||||||
$script = (Get-Content -Path scripts\start.ps1) -replace '#{replaceme}', (Get-Date -Format 'yy.MM.dd')
|
$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 {
|
$script += Get-ChildItem -Path functions -Recurse -File | ForEach-Object {
|
||||||
Get-Content -Path $_.FullName -Raw
|
Get-Content -Path $_.FullName -Raw
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ A curated compilation of Windows system tasks streamline **installs**, debloat w
|
|||||||
|
|
||||||
## Quick Start
|
## 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:
|
Open PowerShell or Terminal as admin, then run:
|
||||||
|
|
||||||
@@ -78,7 +78,9 @@ See https://github.com/ChrisTitusTech/winutil/blob/main/.github/CONTRIBUTING.md
|
|||||||
|
|
||||||
These are the sponsors that help keep this project alive with monthly contributions.
|
These are the sponsors that help keep this project alive with monthly contributions.
|
||||||
|
|
||||||
<!-- sponsors --><a href="https://github.com/ysaito8015"><img src="https://github.com/ysaito8015.png" width="60px" alt="User avatar: Yusuke Saito" /></a><a href="https://github.com/dwelfusius"><img src="https://github.com/dwelfusius.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/mews-se"><img src="https://github.com/mews-se.png" width="60px" alt="User avatar: Martin" /></a><a href="https://github.com/jdiegmueller"><img src="https://github.com/jdiegmueller.png" width="60px" alt="User avatar: Jason A. Diegmueller" /></a><a href="https://github.com/robertsandrock"><img src="https://github.com/robertsandrock.png" width="60px" alt="User avatar: RMS" /></a><a href="https://github.com/paulsheets"><img src="https://github.com/paulsheets.png" width="60px" alt="User avatar: Paul" /></a><a href="https://github.com/djones369"><img src="https://github.com/djones369.png" width="60px" alt="User avatar: Dave J (WhamGeek)" /></a><a href="https://github.com/anthonymendez"><img src="https://github.com/anthonymendez.png" width="60px" alt="User avatar: Anthony Mendez" /></a><a href="https://github.com/FatBastard0"><img src="https://github.com/FatBastard0.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/DursleyGuy"><img src="https://github.com/DursleyGuy.png" width="60px" alt="User avatar: DursleyGuy" /></a><a href="https://github.com/DwayneTheRockLobster1"><img src="https://github.com/DwayneTheRockLobster1.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/KieraKujisawa"><img src="https://github.com/KieraKujisawa.png" width="60px" alt="User avatar: Kiera Meredith" /></a><a href="https://github.com/seanh1995"><img src="https://github.com/seanh1995.png" width="60px" alt="User avatar: Sean (ANGRYxScotsman)" /></a><a href="https://github.com/Abs313a"><img src="https://github.com/Abs313a.png" width="60px" alt="User avatar: Abs" /></a><a href="https://github.com/kydoa"><img src="https://github.com/kydoa.png" width="60px" alt="User avatar: Daniel Scheuermann" /></a><a href="https://github.com/hafiz-muhammad"><img src="https://github.com/hafiz-muhammad.png" width="60px" alt="User avatar: Abdul Hafiz Muhammad" /></a><!-- sponsors -->
|
<!-- sponsors --><a href="https://github.com/dwelfusius"><img src="https://github.com/dwelfusius.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/mews-se"><img src="https://github.com/mews-se.png" width="60px" alt="User avatar: Martin" /></a><a href="https://github.com/jdiegmueller"><img src="https://github.com/jdiegmueller.png" width="60px" alt="User avatar: Jason A. Diegmueller" /></a><a href="https://github.com/robertsandrock"><img src="https://github.com/robertsandrock.png" width="60px" alt="User avatar: RMS" /></a><a href="https://github.com/paulsheets"><img src="https://github.com/paulsheets.png" width="60px" alt="User avatar: Paul" /></a><a href="https://github.com/djones369"><img src="https://github.com/djones369.png" width="60px" alt="User avatar: Dave J (WhamGeek)" /></a><a href="https://github.com/anthonymendez"><img src="https://github.com/anthonymendez.png" width="60px" alt="User avatar: Anthony Mendez" /></a><a href="https://github.com/FatBastard0"><img src="https://github.com/FatBastard0.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/DursleyGuy"><img src="https://github.com/DursleyGuy.png" width="60px" alt="User avatar: DursleyGuy" /></a><a href="https://github.com/DwayneTheRockLobster1"><img src="https://github.com/DwayneTheRockLobster1.png" width="60px" alt="User avatar: " /></a><a href="https://github.com/KieraKujisawa"><img src="https://github.com/KieraKujisawa.png" width="60px" alt="User avatar: Kiera Meredith" /></a><a href="https://github.com/seanh1995"><img src="https://github.com/seanh1995.png" width="60px" alt="User avatar: Sean (ANGRYxScotsman)" /></a><a href="https://github.com/F-L-Perez"><img src="https://github.com/F-L-Perez.png" width="60px" alt="User avatar: Fra · ppe" /></a><a href="https://github.com/josencarnacao"><img src="https://github.com/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>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ WinUtil is a Windows PowerShell utility with a WPF interface. The repository is
|
|||||||
- `tools/autounattend.xml`: unattended setup XML embedded for Windows ISO workflows.
|
- `tools/autounattend.xml`: unattended setup XML embedded for Windows ISO workflows.
|
||||||
- `pester/`: Pester tests for config and function checks.
|
- `pester/`: Pester tests for config and function checks.
|
||||||
- `lint/PSScriptAnalyser.ps1`: PowerShell Script Analyzer settings.
|
- `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`.
|
- `docs/`: Astro + Starlight documentation site, with its own `package.json` and build independent of `Compile.ps1`.
|
||||||
- `winutil.ps1`: ignored generated build artifact.
|
- `winutil.ps1`: ignored generated build artifact.
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ Because the final script is concatenated, code cannot rely on runtime module imp
|
|||||||
## Configuration Contract
|
## Configuration Contract
|
||||||
|
|
||||||
- Config files must remain valid JSON and compile cleanly through `ConvertFrom-Json`.
|
- 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/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.
|
- `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.
|
- Preset and navigation files reference valid config keys. Renaming a config key requires updating all presets, UI references, docs, and code paths together.
|
||||||
@@ -97,6 +99,7 @@ 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.
|
- `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/`.
|
- 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/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.
|
- `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
|
## Testing And CI
|
||||||
@@ -106,6 +109,7 @@ 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.
|
- 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.
|
- 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 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
|
## Release Artifact
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,15 @@
|
|||||||
"winget": "7zip.7zip",
|
"winget": "7zip.7zip",
|
||||||
"foss": true
|
"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": {
|
"adobe": {
|
||||||
"category": "Document",
|
"category": "Document",
|
||||||
"choco": "adobereader",
|
"choco": "adobereader",
|
||||||
@@ -459,6 +468,15 @@
|
|||||||
"winget": "FilesCommunity.Files",
|
"winget": "FilesCommunity.Files",
|
||||||
"foss": true
|
"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": {
|
"firefox": {
|
||||||
"category": "Browsers",
|
"category": "Browsers",
|
||||||
"choco": "firefox",
|
"choco": "firefox",
|
||||||
@@ -1406,7 +1424,7 @@
|
|||||||
"category": "Document",
|
"category": "Document",
|
||||||
"choco": "qownnotes",
|
"choco": "qownnotes",
|
||||||
"content": "QOwnNotes",
|
"content": "QOwnNotes",
|
||||||
"description": "QOwnNotes is a free open-source note taking app with Nextcloud/ownCloud integration.",
|
"description": "QOwnNotes is a free open-source note-taking app with Nextcloud/ownCloud integration.",
|
||||||
"link": "https://www.qownnotes.org/",
|
"link": "https://www.qownnotes.org/",
|
||||||
"winget": "pbek.QOwnNotes",
|
"winget": "pbek.QOwnNotes",
|
||||||
"foss": true
|
"foss": true
|
||||||
@@ -1600,6 +1618,23 @@
|
|||||||
"winget": "LizardByte.Sunshine",
|
"winget": "LizardByte.Sunshine",
|
||||||
"foss": true
|
"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": {
|
"tcpview": {
|
||||||
"category": "Microsoft Tools",
|
"category": "Microsoft Tools",
|
||||||
"choco": "tcpview",
|
"choco": "tcpview",
|
||||||
@@ -1793,7 +1828,7 @@
|
|||||||
"category": "Communications",
|
"category": "Communications",
|
||||||
"choco": "na",
|
"choco": "na",
|
||||||
"content": "Vesktop",
|
"content": "Vesktop",
|
||||||
"description": "A cross platform electron-based desktop app aiming to give you a snappier Discord experience with Vencord pre-installed.",
|
"description": "A cross-platform electron-based desktop app aiming to give you a snappier Discord experience with Vencord pre-installed.",
|
||||||
"link": "https://vesktop.dev",
|
"link": "https://vesktop.dev",
|
||||||
"winget": "Vencord.Vesktop",
|
"winget": "Vencord.Vesktop",
|
||||||
"foss": true
|
"foss": true
|
||||||
@@ -2007,9 +2042,8 @@
|
|||||||
},
|
},
|
||||||
"Overwolf": {
|
"Overwolf": {
|
||||||
"category": "Games",
|
"category": "Games",
|
||||||
"choco": "overwolf",
|
"content": "CurseForge",
|
||||||
"content": "Overwolf",
|
"description": "CurseForge is a desktop application for managing mods and modpacks across multiple games, powered by 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",
|
"link": "https://www.overwolf.com/app/overwolf-curseforge",
|
||||||
"winget": "Overwolf.CurseForge",
|
"winget": "Overwolf.CurseForge",
|
||||||
"foss": false
|
"foss": false
|
||||||
@@ -2087,3 +2121,4 @@
|
|||||||
"foss": false
|
"foss": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -259,5 +259,13 @@
|
|||||||
"Description": "Bundles built-in card game modes including Klondike, Spider, FreeCell, Pyramid, and TriPeaks alongside daily challenges.",
|
"Description": "Bundles built-in card game modes including Klondike, Spider, FreeCell, Pyramid, and TriPeaks alongside daily challenges.",
|
||||||
"Panel": "1",
|
"Panel": "1",
|
||||||
"PackageId": "Microsoft.MicrosoftSolitaireCollection"
|
"PackageId": "Microsoft.MicrosoftSolitaireCollection"
|
||||||
|
},
|
||||||
|
"WPFAppxMicrosoft_ZuneVideo": {
|
||||||
|
"Category": "Utilities & Productivity",
|
||||||
|
"Content": "Movies & TV",
|
||||||
|
"Description": "The default video player and storefront for purchasing or renting media.",
|
||||||
|
"Panel": "0",
|
||||||
|
"PackageId": "Microsoft.ZuneVideo",
|
||||||
|
"StoreId": "9WZDNCRFJ3P2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-54
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"Google":{
|
"Google":{
|
||||||
|
"BenchmarkEligible": true,
|
||||||
"Primary": "8.8.8.8",
|
"Primary": "8.8.8.8",
|
||||||
"Secondary": "8.8.4.4",
|
"Secondary": "8.8.4.4",
|
||||||
"Primary6": "2001:4860:4860::8888",
|
"Primary6": "2001:4860:4860::8888",
|
||||||
@@ -7,6 +8,7 @@
|
|||||||
"DohTemplate": "https://dns.google/dns-query"
|
"DohTemplate": "https://dns.google/dns-query"
|
||||||
},
|
},
|
||||||
"Cloudflare":{
|
"Cloudflare":{
|
||||||
|
"BenchmarkEligible": true,
|
||||||
"Primary": "1.1.1.1",
|
"Primary": "1.1.1.1",
|
||||||
"Secondary": "1.0.0.1",
|
"Secondary": "1.0.0.1",
|
||||||
"Primary6": "2606:4700:4700::1111",
|
"Primary6": "2606:4700:4700::1111",
|
||||||
@@ -54,59 +56,5 @@
|
|||||||
"Primary6": "2a10:50c0::bad1:ff",
|
"Primary6": "2a10:50c0::bad1:ff",
|
||||||
"Secondary6": "2a10:50c0::bad2:ff",
|
"Secondary6": "2a10:50c0::bad2:ff",
|
||||||
"DohTemplate": "https://family.adguard-dns.com/dns-query"
|
"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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@
|
|||||||
"FontSize": "12",
|
"FontSize": "12",
|
||||||
"FontFamily": "Arial",
|
"FontFamily": "Arial",
|
||||||
"HeaderFontSize": "16",
|
"HeaderFontSize": "16",
|
||||||
|
"Win11StepTitleFontSize": "22",
|
||||||
|
"Win11StepHeroFontSize": "40",
|
||||||
|
"Win11LogFontFamily": "Consolas, Monaco",
|
||||||
"HeaderFontFamily": "Consolas, Monaco",
|
"HeaderFontFamily": "Consolas, Monaco",
|
||||||
"CheckBoxBulletDecoratorSize": "14",
|
"CheckBoxBulletDecoratorSize": "14",
|
||||||
"CheckBoxMargin": "15,0,0,2",
|
"CheckBoxMargin": "15,0,0,2",
|
||||||
@@ -58,6 +61,8 @@
|
|||||||
"ScrollBarHoverColor": "#5A5D62",
|
"ScrollBarHoverColor": "#5A5D62",
|
||||||
"ScrollBarDraggingColor": "#6A6D72",
|
"ScrollBarDraggingColor": "#6A6D72",
|
||||||
"ProgressBarForegroundColor": "#2E77FF",
|
"ProgressBarForegroundColor": "#2E77FF",
|
||||||
|
"ProgressBarErrorColor": "#D13438",
|
||||||
|
"ProgressBarWarningColor": "#B36A00",
|
||||||
"ProgressBarBackgroundColor": "Transparent",
|
"ProgressBarBackgroundColor": "Transparent",
|
||||||
"ButtonInstallBackgroundColor": "#F7F7F7",
|
"ButtonInstallBackgroundColor": "#F7F7F7",
|
||||||
"ButtonTweaksBackgroundColor": "#F7F7F7",
|
"ButtonTweaksBackgroundColor": "#F7F7F7",
|
||||||
@@ -98,6 +103,8 @@
|
|||||||
"ScrollBarHoverColor": "#3B4252",
|
"ScrollBarHoverColor": "#3B4252",
|
||||||
"ScrollBarDraggingColor": "#5E81AC",
|
"ScrollBarDraggingColor": "#5E81AC",
|
||||||
"ProgressBarForegroundColor": "#6EFF72",
|
"ProgressBarForegroundColor": "#6EFF72",
|
||||||
|
"ProgressBarErrorColor": "#FF6B6B",
|
||||||
|
"ProgressBarWarningColor": "#FFC83D",
|
||||||
"ProgressBarBackgroundColor": "Transparent",
|
"ProgressBarBackgroundColor": "Transparent",
|
||||||
"ButtonInstallBackgroundColor": "#222222",
|
"ButtonInstallBackgroundColor": "#222222",
|
||||||
"ButtonTweaksBackgroundColor": "#333333",
|
"ButtonTweaksBackgroundColor": "#333333",
|
||||||
|
|||||||
+51
-14
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"WPFTweaksActivity": {
|
"WPFTweaksActivity": {
|
||||||
"Content": "Activity History - Disable",
|
"Content": "Activity History - Disable",
|
||||||
"Description": "Erases recent docs, clipboard, and run history.",
|
"Description": "Stops Windows from publishing or uploading user activities while preserving clipboard history.",
|
||||||
"category": "Essential Tweaks",
|
"category": "Essential Tweaks",
|
||||||
"panel": "1",
|
"panel": "1",
|
||||||
"registry": [
|
"registry": [
|
||||||
{
|
{
|
||||||
"Path": "HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows\\System",
|
"Path": "HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows\\System",
|
||||||
"Name": "EnableActivityFeed",
|
"Name": "EnableActivityFeed",
|
||||||
"Value": "0",
|
"Value": "1",
|
||||||
"Type": "DWord",
|
"Type": "DWord",
|
||||||
"OriginalValue": "<RemoveEntry>"
|
"OriginalValue": "<RemoveEntry>"
|
||||||
},
|
},
|
||||||
@@ -99,10 +99,10 @@
|
|||||||
"category": "Essential Tweaks",
|
"category": "Essential Tweaks",
|
||||||
"panel": "1",
|
"panel": "1",
|
||||||
"InvokeScript": [
|
"InvokeScript": [
|
||||||
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /deny Everyone:F"
|
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /deny *S-1-1-0:F"
|
||||||
],
|
],
|
||||||
"UndoScript": [
|
"UndoScript": [
|
||||||
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /grant Everyone:F"
|
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /grant *S-1-1-0:F"
|
||||||
],
|
],
|
||||||
"link": "https://winutil.christitus.com/code-reference/tweaks/essential-tweaks/disablestoresearch"
|
"link": "https://winutil.christitus.com/code-reference/tweaks/essential-tweaks/disablestoresearch"
|
||||||
},
|
},
|
||||||
@@ -647,7 +647,7 @@
|
|||||||
"InvokeScript": [
|
"InvokeScript": [
|
||||||
"
|
"
|
||||||
# Deny permission to remove OneDrive folder
|
# Deny permission to remove OneDrive folder
|
||||||
icacls $Env:OneDrive /deny \"Administrators:(D,DC)\"
|
icacls $Env:OneDrive /deny \"*S-1-5-32-544:(D,DC)\"
|
||||||
|
|
||||||
Write-Host \"Uninstalling OneDrive...\"
|
Write-Host \"Uninstalling OneDrive...\"
|
||||||
Start-Process -FilePath (Join-Path $Env:SystemRoot \"System32\\OneDriveSetup.exe\") -ArgumentList '/uninstall' -Wait
|
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
|
Remove-Item \"$Env:ProgramData\\Microsoft OneDrive\" -Recurse -Force
|
||||||
|
|
||||||
# Grant back permission to access OneDrive folder
|
# Grant back permission to access OneDrive folder
|
||||||
icacls $Env:OneDrive /grant \"Administrators:(D,DC)\"
|
icacls $Env:OneDrive /grant \"*S-1-5-32-544:(D,DC)\"
|
||||||
|
|
||||||
if (-not (Get-ChildItem -Path $Env:OneDrive)) {
|
if (-not (Get-ChildItem -Path $Env:OneDrive)) {
|
||||||
Remove-Item -Path $Env:OneDrive -Recurse
|
Remove-Item -Path $Env:OneDrive -Recurse
|
||||||
@@ -854,7 +854,7 @@
|
|||||||
},
|
},
|
||||||
"WPFTweaksEndTaskOnTaskbar": {
|
"WPFTweaksEndTaskOnTaskbar": {
|
||||||
"Content": "End Task With Right Click - Enable",
|
"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",
|
"category": "Essential Tweaks",
|
||||||
"panel": "1",
|
"panel": "1",
|
||||||
"registry": [
|
"registry": [
|
||||||
@@ -991,16 +991,51 @@
|
|||||||
New-Item -Path $RazerPath -ItemType Directory
|
New-Item -Path $RazerPath -ItemType Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
icacls $RazerPath /deny \"Everyone:(W)\"
|
icacls $RazerPath /deny \"*S-1-1-0:(W)\"
|
||||||
"
|
"
|
||||||
],
|
],
|
||||||
"UndoScript": [
|
"UndoScript": [
|
||||||
"
|
"
|
||||||
icacls \"$Env:SystemRoot\\Installer\\Razer\" /remove:d Everyone
|
icacls \"$Env:SystemRoot\\Installer\\Razer\" /remove:d *S-1-1-0
|
||||||
"
|
"
|
||||||
],
|
],
|
||||||
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/razerblock"
|
"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": {
|
"WPFTweaksDisableNotifications": {
|
||||||
"Content": "System Tray Notifications & Calendar - Disable",
|
"Content": "System Tray Notifications & Calendar - Disable",
|
||||||
"Description": "Disables all Notifications INCLUDING Calendar.",
|
"Description": "Disables all Notifications INCLUDING Calendar.",
|
||||||
@@ -1086,8 +1121,10 @@
|
|||||||
"panel": "1",
|
"panel": "1",
|
||||||
"InvokeScript": [
|
"InvokeScript": [
|
||||||
"
|
"
|
||||||
Remove-Item -Path \"$Env:Temp\\*\" -Recurse -Force
|
# A temp folder always holds files something has open, including this run's own, and
|
||||||
Remove-Item -Path \"$Env:SystemRoot\\Temp\\*\" -Recurse -Force
|
# the job layer counts a logged error as a failed step
|
||||||
|
Remove-Item -Path \"$Env:Temp\\*\" -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item -Path \"$Env:SystemRoot\\Temp\\*\" -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
"
|
"
|
||||||
],
|
],
|
||||||
"link": "https://winutil.christitus.com/code-reference/tweaks/essential-tweaks/deletetempfiles"
|
"link": "https://winutil.christitus.com/code-reference/tweaks/essential-tweaks/deletetempfiles"
|
||||||
@@ -1385,7 +1422,7 @@
|
|||||||
},
|
},
|
||||||
"WPFToggleNewOutlook": {
|
"WPFToggleNewOutlook": {
|
||||||
"Content": "Microsoft Outlook New Version",
|
"Content": "Microsoft Outlook New Version",
|
||||||
"Description": "This will ensures the classic Outlook application is used.",
|
"Description": "This will ensure the new Outlook application is used.",
|
||||||
"category": "Customize Preferences",
|
"category": "Customize Preferences",
|
||||||
"panel": "2",
|
"panel": "2",
|
||||||
"Type": "Toggle",
|
"Type": "Toggle",
|
||||||
@@ -1649,7 +1686,7 @@
|
|||||||
],
|
],
|
||||||
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/loginblur"
|
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/loginblur"
|
||||||
},
|
},
|
||||||
"WPFTweaksDisableLockscreen": {
|
"WPFToggleDisableLockscreen": {
|
||||||
"Content": "Lock Screen - Disable",
|
"Content": "Lock Screen - Disable",
|
||||||
"Description": "Skips the lock screen entirely and goes directly to the sign-in screen on boot and wake.",
|
"Description": "Skips the lock screen entirely and goes directly to the sign-in screen on boot and wake.",
|
||||||
"category": "Customize Preferences",
|
"category": "Customize Preferences",
|
||||||
@@ -1848,7 +1885,7 @@
|
|||||||
"category": "z__Advanced Tweaks - CAUTION",
|
"category": "z__Advanced Tweaks - CAUTION",
|
||||||
"panel": "1",
|
"panel": "1",
|
||||||
"Type": "Combobox",
|
"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 Fastest Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult",
|
||||||
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/changedns"
|
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/changedns"
|
||||||
},
|
},
|
||||||
"WPFAddUltPerf": {
|
"WPFAddUltPerf": {
|
||||||
|
|||||||
Generated
+540
-269
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -13,7 +13,7 @@
|
|||||||
"@astrojs/starlight": "^0.41.5",
|
"@astrojs/starlight": "^0.41.5",
|
||||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||||
"@fontsource/geist-sans": "^5.3.0",
|
"@fontsource/geist-sans": "^5.3.0",
|
||||||
"astro": "^7.0.2",
|
"astro": "^7.3.2",
|
||||||
"sharp": "^0.35.3"
|
"sharp": "^0.35.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 78 KiB |
@@ -79,14 +79,15 @@ winutil/
|
|||||||
**Why**: Makes distribution easier (single file) and improves load time.
|
**Why**: Makes distribution easier (single file) and improves load time.
|
||||||
|
|
||||||
#### 2. scripts/main.ps1
|
#### 2. scripts/main.ps1
|
||||||
**Purpose**: Entry point that initializes the GUI and event system.
|
**Purpose**: Entry point that manages the run.
|
||||||
|
|
||||||
**Responsibilities**:
|
**Responsibilities**:
|
||||||
- Load XAML and create WPF window
|
|
||||||
- Initialize form elements
|
|
||||||
- Set up event handlers
|
|
||||||
- Load configurations
|
- Load configurations
|
||||||
- Display the GUI
|
- Run the headless `-Preset` and `-Config` paths
|
||||||
|
- Start the interface on a dedicated STA runspace and wait for it
|
||||||
|
- Report anything the interface thread failed with, then clean up
|
||||||
|
|
||||||
|
The interface itself lives in `Start-WinUtilUserInterface`, not here. See [Threading Model](#threading-model).
|
||||||
|
|
||||||
#### 3. functions/public/
|
#### 3. functions/public/
|
||||||
**Purpose**: User-facing functions that implement main features.
|
**Purpose**: User-facing functions that implement main features.
|
||||||
@@ -129,26 +130,36 @@ winutil/
|
|||||||
|
|
||||||
## Win11 Creator Architecture
|
## Win11 Creator Architecture
|
||||||
|
|
||||||
The **Win11 Creator** is a specialized subsystem within Winutil that creates customized Windows 11 ISOs. It operates independently from the main package installation and tweak system.
|
The **Win11 Creator** is a specialized subsystem within Winutil that creates customized Windows 11 ISOs. It operates independently of the main package installation and tweak system.
|
||||||
|
|
||||||
### Win11 Creator Components
|
### Win11 Creator Components
|
||||||
|
|
||||||
**Core Functions** (`functions/private/`):
|
**Core Functions** (`functions/private/`):
|
||||||
- `Invoke-WinUtilISO.ps1`: Main orchestrator containing all Win11 Creator functions
|
- `Invoke-WinUtilISO.ps1`: Main orchestrator containing ISO workflow and helper functions
|
||||||
- `Invoke-WinUtilISOBrowse`: ISO file selection dialog
|
- `Invoke-WinUtilISOBrowse`: ISO file selection dialog
|
||||||
- `Invoke-WinUtilISOMountAndVerify`: Validates and mounts ISO, verifies it's official Windows 11
|
- `Invoke-WinUtilISOMountAndVerify`: Validates and mounts ISO, verifies it is an official Windows 11 ISO
|
||||||
- `Invoke-WinUtilISOModify`: Launches modification in background runspace
|
- `Invoke-WinUtilISOModify`: Launches modification in background runspace
|
||||||
- `Invoke-WinUtilISOExport`: Handles ISO and USB export
|
- `Invoke-WinUtilISOExport`: Builds bootable ISO via `oscdimg.exe`
|
||||||
- `Invoke-WinUtilISOCheckExistingWork`: Recovers incomplete work sessions
|
- `Invoke-WinUtilISOCheckExistingWork`: Recovers incomplete work sessions
|
||||||
- `Invoke-WinUtilISOCleanAndReset`: Cleans up temp directories and resets UI
|
- `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-WinUtilISOScript.ps1`: Applies modifications to mounted install.wim
|
- `Invoke-WinUtilISOUSB.ps1`: USB drive detection and formatting
|
||||||
- Removes provisioned AppX packages (40+ bloatware apps)
|
- `Invoke-WinUtilISORefreshUSBDrives`: Enumerates USB drives
|
||||||
- Injects drivers (optional) from the current system
|
- `Get-WinUtilFreeDriveLetter`: Finds available drive letters
|
||||||
- Removes OneDrive setup files
|
- `Invoke-WinUtilISOWriteUSB`: Formats USB drive as GPT/FAT32, splits WIM if needed, and copies files
|
||||||
- Applies offline registry tweaks (hardware bypass, privacy, telemetry, OOBE)
|
|
||||||
- Deletes telemetry scheduled task definitions
|
- `Invoke-WinUtilISOScript.ps1`: Prepares setup media and customizations
|
||||||
- Pre-stages setup scripts from autounattend.xml
|
- 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)
|
||||||
|
|
||||||
### Win11 Creator Data Flow
|
### Win11 Creator Data Flow
|
||||||
|
|
||||||
@@ -168,18 +179,19 @@ User optionally enables the Driver Injection checkbox
|
|||||||
↓
|
↓
|
||||||
Invoke-WinUtilISOModify (runs in background runspace)
|
Invoke-WinUtilISOModify (runs in background runspace)
|
||||||
├─ Create work directory: ~WinUtil_Win11ISO_[timestamp]
|
├─ Create work directory: ~WinUtil_Win11ISO_[timestamp]
|
||||||
├─ Copy ISO contents to disk (~5-6 GB)
|
├─ Copy ISO contents to disk via robocopy (~5-6 GB)
|
||||||
├─ Mount install.wim at selected edition/index
|
|
||||||
├─ Invoke-WinUtilISOScript:
|
├─ Invoke-WinUtilISOScript:
|
||||||
│ ├─ Remove 40+ bloat AppX packages
|
│ ├─ Generate autounattend.xml with Windows PE and specialize safeguards
|
||||||
│ ├─ Export and inject drivers (if enabled)
|
│ ├─ Add first-logon script (19 AppX removals, registry tweaks, OneDrive uninstall)
|
||||||
│ ├─ Remove OneDrive setup
|
│ ├─ Pin selected edition index in autounattend.xml (/IMAGE/INDEX)
|
||||||
│ ├─ Load offline registry hives
|
│ ├─ Stage setup script fallbacks to sources\$OEM$\$$\Setup\Scripts\
|
||||||
│ ├─ Apply 50+ registry tweaks (hardware bypass, privacy, telemetry, OOBE, etc.)
|
│ ├─ Write sources\ei.cfg and remove stale sources\PID.txt
|
||||||
│ ├─ Delete telemetry scheduled task files
|
│ └─ If driver injection enabled:
|
||||||
│ ├─ Pre-stage setup scripts from autounattend.xml to C:\Windows\Setup\Scripts\
|
│ ├─ Require install.wim; install.esd cannot accept driver injection
|
||||||
│ └─ Unload registry hives
|
│ ├─ Export current system drivers via DISM
|
||||||
├─ Dismount and save the modified install.wim (~10+ minutes, slowest step)
|
│ ├─ 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
|
||||||
├─ Dismount source ISO
|
├─ Dismount source ISO
|
||||||
└─ Report completion, enable export options
|
└─ Report completion, enable export options
|
||||||
↓
|
↓
|
||||||
@@ -190,12 +202,15 @@ Invoke-WinUtilISOExport (user chooses output)
|
|||||||
│
|
│
|
||||||
└─ Option 2: Write to USB
|
└─ Option 2: Write to USB
|
||||||
├─ Format USB as GPT
|
├─ Format USB as GPT
|
||||||
├─ Create 512 MB EFI partition
|
├─ Create single FAT32 partition (capped at 32 GB)
|
||||||
├─ Copy modified ISO contents
|
├─ Split install.wim into .swm files if > 3.8 GB
|
||||||
|
├─ Reject install.esd files of 4 GB or more
|
||||||
|
├─ Copy files via robocopy
|
||||||
└─ Output: Bootable USB (minimum 8 GB)
|
└─ Output: Bootable USB (minimum 8 GB)
|
||||||
↓
|
↓
|
||||||
Invoke-WinUtilISOCleanAndReset (optional)
|
Invoke-WinUtilISOCleanAndReset (optional)
|
||||||
└─ Delete temp working directory (~10-15 GB)
|
├─ Dismount any open WIM mounts with discard
|
||||||
|
├─ Delete temp working directory (~10-15 GB)
|
||||||
└─ Reset UI to initial state
|
└─ Reset UI to initial state
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -207,20 +222,30 @@ Invoke-WinUtilISOCleanAndReset (optional)
|
|||||||
- Checks image metadata for "Windows 11" string
|
- Checks image metadata for "Windows 11" string
|
||||||
- Rejects custom, modified, or non-Windows 11 ISOs
|
- 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**:
|
**Work Session Recovery**:
|
||||||
- Auto-detects incomplete work from previous sessions
|
- Auto-detects incomplete work from previous sessions
|
||||||
- Allows resuming Step 4 (export) without re-running Steps 1-3
|
- Allows resuming the export step without re-running selection and modification
|
||||||
- Prevents redundant modifications
|
- Prevents redundant modifications
|
||||||
|
|
||||||
**Modification Safety**:
|
**Modification Safety**:
|
||||||
- All registry changes are documented in a script (reversible)
|
- Windows PE and specialize set hardware bypass and setup safeguards; `WinUtil-PostInstall.ps1` applies additional registry changes at first logon
|
||||||
- Original ISO never modified; only working copy
|
- Original ISO never modified; only working copy
|
||||||
- Logged to `WinUtil_Win11ISO.log` for debugging
|
- Logged to the WinUtil session log and the live UI status panel
|
||||||
- DISM handles image dismount with automatic cleanup on error
|
- DISM handles image dismount with automatic cleanup and discard on error
|
||||||
|
|
||||||
### Win11 Creator Registry Tweaks
|
### Win11 Creator Registry Tweaks
|
||||||
|
|
||||||
The `Invoke-WinUtilISOScript` function applies **50+ offline registry tweaks**:
|
The `Invoke-WinUtilISOScript` function applies **50+ registry tweaks** during setup and first logon:
|
||||||
|
|
||||||
**Hardware Bypass**:
|
**Hardware Bypass**:
|
||||||
- TPM 2.0 check bypass
|
- TPM 2.0 check bypass
|
||||||
@@ -258,9 +283,9 @@ The `Invoke-WinUtilISOScript` function applies **50+ offline registry tweaks**:
|
|||||||
|
|
||||||
### Driver Injection Feature
|
### Driver Injection Feature
|
||||||
|
|
||||||
**Optional Enhancement**: When enabled, exports all drivers from the running system and injects them into both:
|
**Optional Enhancement**: When enabled, exports all drivers from the running system and injects them into:
|
||||||
- `install.wim` (main OS image)
|
- `install.wim` (main OS image), excluding stale duplicates
|
||||||
- `boot.wim` index 2 (Windows Setup PE environment)
|
- `boot.wim` index 2 (Windows Setup PE environment), storage controllers only (`SCSIAdapter` / `HDC`)
|
||||||
|
|
||||||
**Use Case**: Enables offline installation on systems with missing drivers.
|
**Use Case**: Enables offline installation on systems with missing drivers.
|
||||||
|
|
||||||
@@ -390,23 +415,68 @@ Update UI
|
|||||||
- `service`: Services to change
|
- `service`: Services to change
|
||||||
- `OriginalValue/State`: For undo functionality
|
- `OriginalValue/State`: For undo functionality
|
||||||
|
|
||||||
## PowerShell Runspace
|
## Threading Model
|
||||||
|
|
||||||
Winutil uses PowerShell runspaces for the GUI to remain responsive:
|
WinUtil runs on three kinds of thread, and each has one job:
|
||||||
|
|
||||||
|
| Thread | Runspace | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Main | The one the script started in | Start the interface, wait for it, surface its errors, clean up |
|
||||||
|
| Interface | `$sync.UIRunspace`, a dedicated STA runspace | Own the window. Paint and dispatch, nothing else |
|
||||||
|
| Workers | `$sync.runspace`, a shared pool | Run everything long: installs, tweaks, features, AppX, Win11 Creator |
|
||||||
|
|
||||||
|
All three are created from the same starting point, `New-WinUtilSessionState`, which carries
|
||||||
|
`$sync`, the compiled script's globals, and every WinUtil function. That is what lets any
|
||||||
|
thread call any helper without the caller injecting function definitions.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Create runspace
|
# main.ps1 - the interface gets its own thread
|
||||||
$sync.runspace = [runspacefactory]::CreateRunspace()
|
$sync.UIRunspace = [runspacefactory]::CreateRunspace($Host, (New-WinUtilSessionState))
|
||||||
$sync.runspace.Open()
|
$sync.UIRunspace.ApartmentState = "STA"
|
||||||
$sync.runspace.SessionStateProxy.SetVariable("sync", $sync)
|
$sync.UIRunspace.Open()
|
||||||
|
|
||||||
# Run code in background
|
$uiShell = [powershell]::Create()
|
||||||
$powershell = [powershell]::Create().AddScript($scriptblock)
|
$uiShell.Runspace = $sync.UIRunspace
|
||||||
$powershell.Runspace = $sync.runspace
|
[void]$uiShell.AddScript({ Start-WinUtilUserInterface })
|
||||||
$handle = $powershell.BeginInvoke()
|
$uiHandle = $uiShell.BeginInvoke()
|
||||||
|
$uiHandle.AsyncWaitHandle.WaitOne()
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why**: Prevents UI freezing during long-running operations.
|
**Why**: the window never blocks on work, and a failure on the interface thread is reported
|
||||||
|
instead of disappearing.
|
||||||
|
|
||||||
|
## Long-Running Work
|
||||||
|
|
||||||
|
Every long action goes through `Start-WinUtilJob`, which owns everything a running operation
|
||||||
|
needs: refusing to start while another job runs, the busy flag (`$sync.ActiveJob`), the progress
|
||||||
|
bar and taskbar item, the boxed console banner, a start/finish/failure line in the log, and
|
||||||
|
restoring the interface in a `finally` whatever happens.
|
||||||
|
|
||||||
|
`Invoke-WPFButton` decides what counts as a long action. Anything that changes the system gets a
|
||||||
|
job; anything that only changes what the interface is showing runs on the interface thread.
|
||||||
|
That single classification is why no workflow arranges its own progress, banner or busy state.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Start-WinUtilJob -Name "Features" -Description "Installing Windows Features" -Parameters @{
|
||||||
|
Features = @($sync.selectedFeatures)
|
||||||
|
} -ScriptBlock {
|
||||||
|
param($Features)
|
||||||
|
|
||||||
|
$total = @($Features).Count
|
||||||
|
$completed = 0
|
||||||
|
foreach ($feature in $Features) {
|
||||||
|
$completed++
|
||||||
|
Step-WinUtilJob -Status "Installing $feature ($completed/$total)" -Percent ([int](($completed / $total) * 100))
|
||||||
|
Invoke-WinUtilFeatureInstall $feature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The body only has to do the work and call `Step-WinUtilJob`. Anything it throws is
|
||||||
|
caught, logged, and shown on the taskbar as a failure.
|
||||||
|
|
||||||
|
**Values, not closures**: the body is rebuilt inside the worker from its text, so it receives
|
||||||
|
what it needs through `-Parameters` rather than capturing the caller's variables.
|
||||||
|
|
||||||
## WPF Event Handling
|
## WPF Event Handling
|
||||||
|
|
||||||
@@ -452,23 +522,21 @@ if (!(Get-Command choco -ErrorAction SilentlyContinue)) {
|
|||||||
choco install $app.choco -y
|
choco install $app.choco -y
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling And Logging
|
||||||
|
|
||||||
Winutil uses PowerShell error handling:
|
A job body does not need its own error handling. `Start-WinUtilJob` catches whatever the body
|
||||||
|
throws, logs it, and marks the run as failed on the taskbar, so a failure can never leave the
|
||||||
|
interface stuck busy. Only catch inside a body when you have something specific to do first,
|
||||||
|
such as cleaning up a mounted image, and then rethrow.
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
try {
|
Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "winget install failed: $($_.Exception.Message)"
|
||||||
# Attempt operation
|
|
||||||
Invoke-SomeOperation
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Host "Error: $_" -ForegroundColor Red
|
|
||||||
# Log error
|
|
||||||
Add-Content -Path $logfile -Value "ERROR: $_"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Logging**: Errors and operations are logged for debugging.
|
Entries and console diagnostics go to the same
|
||||||
|
`%LocalAppData%\winutil\logs\winutil_<timestamp>.log` session file. While `Start-Transcript`
|
||||||
|
owns that file, `Write-WinUtilLog` writes through the host so the transcript captures the entry
|
||||||
|
without a competing direct file write.
|
||||||
|
|
||||||
## Configuration Loading
|
## Configuration Loading
|
||||||
|
|
||||||
@@ -486,15 +554,23 @@ $sync.configs.features = Get-Content "config/feature.json" | ConvertFrom-Json
|
|||||||
|
|
||||||
## UI Update Pattern
|
## UI Update Pattern
|
||||||
|
|
||||||
UI updates must happen on the UI thread:
|
Controls may only be touched from the thread that owns the window, so background work reaches
|
||||||
|
them through `Invoke-WPFUIThread`:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$sync.form.Dispatcher.Invoke([action]{
|
Invoke-WPFUIThread -Parameters @{ Count = $installed.Count } -ScriptBlock {
|
||||||
$sync.WPFStatusLabel.Content = "Installing..."
|
param($Count)
|
||||||
}, "Normal")
|
$sync.WPFselectedAppsButton.Content = "Selected Apps: $Count"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why**: WPF requires UI updates on the main thread.
|
Add `-Async` to post the update instead of waiting for it. `Step-WinUtilJob` and the
|
||||||
|
Win11 Creator status log use that so a per-item update never stalls the worker.
|
||||||
|
|
||||||
|
**Values, not closures**: the body is rebuilt inside the interface runspace, so it takes what it
|
||||||
|
needs through `-Parameters`. Handing over a scriptblock from a worker instead would keep that
|
||||||
|
worker's session state, which loses the caller's variables on an async post and costs roughly
|
||||||
|
twenty times as much per command - enough to turn a checkbox refresh into a visible freeze.
|
||||||
|
|
||||||
## Adding New Features
|
## Adding New Features
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ This page is generated from [`functions/public/Invoke-WPFPanelAutologin.ps1`](ht
|
|||||||
|
|
||||||
```powershell title="functions/public/Invoke-WPFPanelAutologin.ps1"
|
```powershell title="functions/public/Invoke-WPFPanelAutologin.ps1"
|
||||||
function Invoke-WPFPanelAutologin {
|
function Invoke-WPFPanelAutologin {
|
||||||
Invoke-WebRequest -Uri https://live.sysinternals.com/Autologon.exe -OutFile "$winutildir\autologin.exe"
|
$autologonPath = Join-Path $sync.winutildir "autologin.exe"
|
||||||
Start-Process -FilePath "$winutildir\autologin.exe" -ArgumentList /accepteula
|
Invoke-WebRequest -Uri https://live.sysinternals.com/Autologon.exe -OutFile $autologonPath
|
||||||
|
Start-Process -FilePath $autologonPath -ArgumentList /accepteula
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -21,11 +21,54 @@ function Invoke-WPFSystemRepair {
|
|||||||
3. DISM - Repair a corrupted Windows operating system image
|
3. DISM - Repair a corrupted Windows operating system image
|
||||||
#>
|
#>
|
||||||
|
|
||||||
Start-Process cmd.exe -ArgumentList "/c chkdsk /scan /perf" -NoNewWindow -Wait
|
# SuccessCodes maps the non-zero exits a step treats as success to what they mean. The codes
|
||||||
Start-Process cmd.exe -ArgumentList "/c sfc /scannow" -NoNewWindow -Wait
|
# are per step because the same number means different things: 1 and 2 are ordinary chkdsk
|
||||||
Start-Process cmd.exe -ArgumentList "/c dism /online /cleanup-image /restorehealth" -NoNewWindow -Wait
|
# outcomes, while 1 from sfc is a failure, and 3010 is a repaired image from DISM only.
|
||||||
|
$steps = @(
|
||||||
|
@{
|
||||||
|
Label = "Checking the disk for errors"
|
||||||
|
Arguments = "/c chkdsk /scan /perf"
|
||||||
|
# 3 is left out: the disk could not be checked, or has errors an online scan cannot
|
||||||
|
# fix, and the steps after this one are not worth running on a disk in that state.
|
||||||
|
SuccessCodes = @{
|
||||||
|
1 = "errors were found and fixed"
|
||||||
|
2 = "cleanup was performed, or was skipped because /f was not given"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
Label = "Scanning protected system files"
|
||||||
|
Arguments = "/c sfc /scannow"
|
||||||
|
SuccessCodes = @{}
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
Label = "Repairing the Windows image"
|
||||||
|
Arguments = "/c dism /online /cleanup-image /restorehealth"
|
||||||
|
SuccessCodes = @{
|
||||||
|
3010 = "a restart is needed for the repair to take effect"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
Write-Host "==> Finished System Repair"
|
$completed = 0
|
||||||
Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"
|
foreach ($step in $steps) {
|
||||||
|
Step-WinUtilJob -Status "$($step.Label) ($($completed + 1)/$($steps.Count))" -Percent ([int](($completed / $steps.Count) * 100))
|
||||||
|
Write-WinUtilLog -Component "SystemRepair" -Message $step.Label
|
||||||
|
# Start-Process does not throw on a nonzero exit, so without this a failed chkdsk, sfc
|
||||||
|
# or dism run would still be reported as a completed repair
|
||||||
|
$process = Start-Process cmd.exe -ArgumentList $step.Arguments -NoNewWindow -Wait -PassThru
|
||||||
|
$exitCode = $process.ExitCode
|
||||||
|
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
if ($step.SuccessCodes.ContainsKey($exitCode)) {
|
||||||
|
# Start-WinUtilJob records WarningRecord output in both the session log and the
|
||||||
|
# job result, so accepted nonzero outcomes cannot finish with a green checkmark.
|
||||||
|
Write-Warning "$($step.Label) finished: $($step.SuccessCodes[$exitCode])."
|
||||||
|
} else {
|
||||||
|
throw "$($step.Label) failed with exit code $exitCode."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$completed++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -25,8 +25,5 @@ function Invoke-WPFFixesNTPPool {
|
|||||||
Restart-Service w32time
|
Restart-Service w32time
|
||||||
w32tm /resync
|
w32tm /resync
|
||||||
|
|
||||||
Write-Host "================================="
|
|
||||||
Write-Host "-- NTP Configuration Complete ---"
|
|
||||||
Write-Host "================================="
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function Invoke-WPFFixesUpdate {
|
|||||||
param($Aggressive = $false)
|
param($Aggressive = $false)
|
||||||
|
|
||||||
Write-Progress -Id 0 -Activity "Repairing Windows Update" -PercentComplete 0
|
Write-Progress -Id 0 -Activity "Repairing Windows Update" -PercentComplete 0
|
||||||
Set-WinUtilTaskbaritem -state "Indeterminate" -overlay "logo"
|
Step-WinUtilJob -State "Indeterminate"
|
||||||
Write-Host "Starting Windows Update Repair..."
|
Write-Host "Starting Windows Update Repair..."
|
||||||
# Wait for the first progress bar to show, otherwise the second one won't show
|
# Wait for the first progress bar to show, otherwise the second one won't show
|
||||||
Start-Sleep -Milliseconds 200
|
Start-Sleep -Milliseconds 200
|
||||||
@@ -203,24 +203,14 @@ function Invoke-WPFFixesUpdate {
|
|||||||
try {
|
try {
|
||||||
(New-Object -ComObject Microsoft.Update.AutoUpdate).DetectNow()
|
(New-Object -ComObject Microsoft.Update.AutoUpdate).DetectNow()
|
||||||
} catch {
|
} catch {
|
||||||
Set-WinUtilTaskbaritem -state "Error" -overlay "warning"
|
Write-WinUtilLog -Level "ERROR" -Component "Updates" -Message "Failed to create Windows Update COM object: $_"
|
||||||
Write-Warning "Failed to create Windows Update COM object: $_"
|
Write-Warning "Failed to create Windows Update COM object: $_"
|
||||||
}
|
}
|
||||||
Start-Process -NoNewWindow -FilePath "wuauclt" -ArgumentList "/resetauthorization", "/detectnow"
|
Start-Process -NoNewWindow -FilePath "wuauclt" -ArgumentList "/resetauthorization", "/detectnow"
|
||||||
Write-Progress -Id 10 -ParentId 0 -Activity "Forcing discovery" -Status "Completed" -PercentComplete 100
|
Write-Progress -Id 10 -ParentId 0 -Activity "Forcing discovery" -Status "Completed" -PercentComplete 100
|
||||||
Write-Progress -Id 0 -Activity "Repairing Windows Update" -Status "Completed" -PercentComplete 100
|
Write-Progress -Id 0 -Activity "Repairing Windows Update" -Status "Completed" -PercentComplete 100
|
||||||
|
|
||||||
Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"
|
Show-WinUtilMessage -Message "Stock settings loaded.`n Please reboot your computer" -Title "Reset Windows Update" -Button "OK" -Icon "Information" | Out-Null
|
||||||
|
|
||||||
$ButtonType = [System.Windows.MessageBoxButton]::OK
|
|
||||||
$MessageboxTitle = "Reset Windows Update "
|
|
||||||
$Messageboxbody = ("Stock settings loaded.`n Please reboot your computer")
|
|
||||||
$MessageIcon = [System.Windows.MessageBoxImage]::Information
|
|
||||||
|
|
||||||
[System.Windows.MessageBox]::Show($Messageboxbody, $MessageboxTitle, $ButtonType, $MessageIcon)
|
|
||||||
Write-Host "==============================================="
|
|
||||||
Write-Host "-- Reset All Windows Update Settings to Stock -"
|
|
||||||
Write-Host "==============================================="
|
|
||||||
|
|
||||||
# Remove the progress bars
|
# Remove the progress bars
|
||||||
Write-Progress -Id 0 -Activity "Repairing Windows Update" -Completed
|
Write-Progress -Id 0 -Activity "Repairing Windows Update" -Completed
|
||||||
|
|||||||
@@ -18,18 +18,8 @@ function Invoke-WPFFixesWinget {
|
|||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
BravoNorris for the fantastic idea of a button to reinstall WinGet
|
BravoNorris for the fantastic idea of a button to reinstall WinGet
|
||||||
#>
|
#>
|
||||||
# Install Choco if not already present
|
|
||||||
try {
|
|
||||||
Set-WinUtilTaskbaritem -state "Indeterminate" -overlay "logo"
|
|
||||||
Write-Host "==> Starting WinGet Repair"
|
|
||||||
Install-WinUtilWinget
|
|
||||||
} catch {
|
|
||||||
Write-Error "Failed to install WinGet: $_"
|
|
||||||
Set-WinUtilTaskbaritem -state "Error" -overlay "warning"
|
|
||||||
} finally {
|
|
||||||
Write-Host "==> Finished WinGet Repair"
|
|
||||||
Set-WinUtilTaskbaritem -state "None" -overlay "checkmark"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Step-WinUtilJob -Status "Repairing WinGet" -State "Indeterminate"
|
||||||
|
Install-WinUtilWinget -Force
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+61
-8
@@ -9,19 +9,72 @@ This page is generated from [`functions/private/Invoke-WinUtilInstallPSProfile.p
|
|||||||
:::
|
:::
|
||||||
|
|
||||||
```powershell title="functions/private/Invoke-WinUtilInstallPSProfile.ps1"
|
```powershell title="functions/private/Invoke-WinUtilInstallPSProfile.ps1"
|
||||||
|
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 {
|
function Invoke-WinUtilInstallPSProfile {
|
||||||
if (-not (Get-Command wt)) {
|
<#
|
||||||
Write-Host "Windows Terminal not found. Installing..."
|
.SYNOPSIS
|
||||||
|
Installs the CTT PowerShell profile
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
The profile targets PowerShell 7, so its setup script has to run under pwsh rather than
|
||||||
|
the runspace this job is on. It runs as a child process with its output captured, so the
|
||||||
|
job log records what happened instead of it scrolling past in a terminal nobody kept.
|
||||||
|
#>
|
||||||
|
|
||||||
|
$pwshPath = Get-WinUtilPowerShell7Path
|
||||||
|
if (-not $pwshPath) {
|
||||||
|
Step-WinUtilJob -Status "Installing PowerShell 7" -State "Indeterminate"
|
||||||
|
Write-WinUtilLog -Component "Feature" -Message "PowerShell 7 not found, installing it first."
|
||||||
|
|
||||||
Install-WinUtilWinget
|
Install-WinUtilWinget
|
||||||
winget install Microsoft.WindowsTerminal --source winget --silent
|
Install-WinUtilProgramWinget -Action Install -Programs @("Microsoft.PowerShell") | Out-Null
|
||||||
|
|
||||||
|
# WinGet updates the persisted PATH, not this already-running process. Resolve the
|
||||||
|
# standard install locations as well as the current PATH before deciding it failed.
|
||||||
|
$pwshPath = Get-WinUtilPowerShell7Path
|
||||||
|
if (-not $pwshPath) {
|
||||||
|
throw "PowerShell 7 could not be installed, so the profile cannot be set up."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Get-Command pwsh)) {
|
Step-WinUtilJob -Status "Running the profile setup" -State "Indeterminate"
|
||||||
Write-Host "PowerShell 7 not found. Installing..."
|
|
||||||
Install-WinUtilWinget
|
$setupUrl = "https://github.com/ChrisTitusTech/powershell-profile/raw/main/setup.ps1"
|
||||||
winget install Microsoft.PowerShell --source winget --installer-type wix --silent
|
# 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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wt new-tab pwsh -NoExit -Command "irm https://github.com/ChrisTitusTech/powershell-profile/raw/main/setup.ps1 | iex"
|
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."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+31
-5
@@ -10,13 +10,39 @@ This page is generated from [`functions/private/Invoke-WinUtilUninstallPSProfile
|
|||||||
|
|
||||||
```powershell title="functions/private/Invoke-WinUtilUninstallPSProfile.ps1"
|
```powershell title="functions/private/Invoke-WinUtilUninstallPSProfile.ps1"
|
||||||
function Invoke-WinUtilUninstallPSProfile {
|
function Invoke-WinUtilUninstallPSProfile {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Restores the PowerShell 7 profile the CTT profile replaced
|
||||||
|
|
||||||
if (Test-Path ($Profile + ".bak")) {
|
.DESCRIPTION
|
||||||
Move-Item -Path ($Profile + ".bak") -Destination $Profile
|
The profile path has to come from pwsh itself. $PROFILE inside this job is the worker's
|
||||||
} else {
|
own Windows PowerShell profile, which is not the file the install wrote.
|
||||||
Remove-Item -Path $Profile
|
#>
|
||||||
|
|
||||||
|
$pwshPath = Get-WinUtilPowerShell7Path
|
||||||
|
if (-not $pwshPath) {
|
||||||
|
throw "PowerShell 7 is not installed, so there is no CTT profile to remove."
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Successfully uninstalled CTT PowerShell Profile." -ForegroundColor Green
|
$profilePath = (& $pwshPath -NoProfile -NonInteractive -Command '$PROFILE' | Select-Object -First 1)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($profilePath)) {
|
||||||
|
throw "Could not determine the PowerShell 7 profile path."
|
||||||
|
}
|
||||||
|
$profilePath = $profilePath.Trim()
|
||||||
|
$backupPath = "$profilePath.bak"
|
||||||
|
|
||||||
|
if (Test-Path $backupPath) {
|
||||||
|
Move-Item -Path $backupPath -Destination $profilePath -Force
|
||||||
|
Write-WinUtilLog -Component "Feature" -Message "Restored the profile that was in place before: $profilePath"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path $profilePath) {
|
||||||
|
Remove-Item -Path $profilePath -Force
|
||||||
|
Write-WinUtilLog -Component "Feature" -Message "Removed the CTT PowerShell profile: $profilePath"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component "Feature" -Message "No PowerShell 7 profile found at $profilePath, nothing to remove."
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,17 +13,10 @@ function Invoke-WPFSSHServer {
|
|||||||
<#
|
<#
|
||||||
|
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Invokes the OpenSSH Server install in a runspace
|
Installs and starts the OpenSSH Server
|
||||||
|
|
||||||
#>
|
#>
|
||||||
|
|
||||||
Invoke-WPFRunspace -ScriptBlock {
|
Invoke-WinUtilSSHServer
|
||||||
|
|
||||||
Invoke-WinUtilSSHServer
|
|
||||||
|
|
||||||
Write-Host "======================================="
|
|
||||||
Write-Host "-- OpenSSH Server installed! ---"
|
|
||||||
Write-Host "======================================="
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
|||||||
:::
|
:::
|
||||||
|
|
||||||
```json title="config/tweaks.json"
|
```json title="config/tweaks.json"
|
||||||
"WPFTweaksDisableLockscreen": {
|
"WPFToggleDisableLockscreen": {
|
||||||
"Content": "Lock Screen - Disable",
|
"Content": "Lock Screen - Disable",
|
||||||
"Description": "Skips the lock screen entirely and goes directly to the sign-in screen on boot and wake.",
|
"Description": "Skips the lock screen entirely and goes directly to the sign-in screen on boot and wake.",
|
||||||
"category": "Customize Preferences",
|
"category": "Customize Preferences",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Microsoft Outlook New Version"
|
title: "Microsoft Outlook New Version"
|
||||||
description: "This will ensures the classic Outlook application is used."
|
description: "This will ensure the new Outlook application is used."
|
||||||
editUrl: false
|
editUrl: false
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
|||||||
```json title="config/tweaks.json"
|
```json title="config/tweaks.json"
|
||||||
"WPFToggleNewOutlook": {
|
"WPFToggleNewOutlook": {
|
||||||
"Content": "Microsoft Outlook New Version",
|
"Content": "Microsoft Outlook New Version",
|
||||||
"Description": "This will ensures the classic Outlook application is used.",
|
"Description": "This will ensure the new Outlook application is used.",
|
||||||
"category": "Customize Preferences",
|
"category": "Customize Preferences",
|
||||||
"panel": "2",
|
"panel": "2",
|
||||||
"Type": "Toggle",
|
"Type": "Toggle",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "Activity History - Disable"
|
title: "Activity History - Disable"
|
||||||
description: "Erases recent docs, clipboard, and run history."
|
description: "Stops Windows from publishing or uploading user activities while preserving clipboard history."
|
||||||
editUrl: false
|
editUrl: false
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,14 +11,14 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
|||||||
```json title="config/tweaks.json"
|
```json title="config/tweaks.json"
|
||||||
"WPFTweaksActivity": {
|
"WPFTweaksActivity": {
|
||||||
"Content": "Activity History - Disable",
|
"Content": "Activity History - Disable",
|
||||||
"Description": "Erases recent docs, clipboard, and run history.",
|
"Description": "Stops Windows from publishing or uploading user activities while preserving clipboard history.",
|
||||||
"category": "Essential Tweaks",
|
"category": "Essential Tweaks",
|
||||||
"panel": "1",
|
"panel": "1",
|
||||||
"registry": [
|
"registry": [
|
||||||
{
|
{
|
||||||
"Path": "HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows\\System",
|
"Path": "HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows\\System",
|
||||||
"Name": "EnableActivityFeed",
|
"Name": "EnableActivityFeed",
|
||||||
"Value": "0",
|
"Value": "1",
|
||||||
"Type": "DWord",
|
"Type": "DWord",
|
||||||
"OriginalValue": "<RemoveEntry>"
|
"OriginalValue": "<RemoveEntry>"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
|||||||
"panel": "1",
|
"panel": "1",
|
||||||
"InvokeScript": [
|
"InvokeScript": [
|
||||||
"
|
"
|
||||||
Remove-Item -Path \"$Env:Temp\\*\" -Recurse -Force
|
# A temp folder always holds files something has open, including this run's own, and
|
||||||
Remove-Item -Path \"$Env:SystemRoot\\Temp\\*\" -Recurse -Force
|
# 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
|
||||||
"
|
"
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
|||||||
"category": "Essential Tweaks",
|
"category": "Essential Tweaks",
|
||||||
"panel": "1",
|
"panel": "1",
|
||||||
"InvokeScript": [
|
"InvokeScript": [
|
||||||
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /deny Everyone:F"
|
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /deny *S-1-1-0:F"
|
||||||
],
|
],
|
||||||
"UndoScript": [
|
"UndoScript": [
|
||||||
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /grant Everyone:F"
|
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /grant *S-1-1-0:F"
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: "End Task With Right Click - Enable"
|
title: "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."
|
||||||
editUrl: false
|
editUrl: false
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
|||||||
```json title="config/tweaks.json"
|
```json title="config/tweaks.json"
|
||||||
"WPFTweaksEndTaskOnTaskbar": {
|
"WPFTweaksEndTaskOnTaskbar": {
|
||||||
"Content": "End Task With Right Click - Enable",
|
"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",
|
"category": "Essential Tweaks",
|
||||||
"panel": "1",
|
"panel": "1",
|
||||||
"registry": [
|
"registry": [
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
title: "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."
|
||||||
|
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"
|
||||||
|
"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)\" }
|
||||||
|
}
|
||||||
|
"
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
+12
-44
@@ -10,53 +10,21 @@ This page is generated from [`functions/public/Invoke-WPFOOSU.ps1`](https://gith
|
|||||||
|
|
||||||
```powershell title="functions/public/Invoke-WPFOOSU.ps1"
|
```powershell title="functions/public/Invoke-WPFOOSU.ps1"
|
||||||
function Invoke-WPFOOSU {
|
function Invoke-WPFOOSU {
|
||||||
if ($sync.ProcessRunning) {
|
Start-WinUtilJob -Name "OOSU" -Description "Downloading O&O ShutUp10++" -Parameters @{
|
||||||
Show-WinUtilMessage -Message "Another process is currently running." -Title "WinUtil" -Button "OK" -Icon "Warning"
|
DownloadPath = Join-Path $sync.winutildir "ooshutup10.exe"
|
||||||
return
|
} -ScriptBlock {
|
||||||
}
|
param($DownloadPath)
|
||||||
|
|
||||||
$downloadPath = Join-Path $sync.winutildir "ooshutup10.exe"
|
Write-WinUtilLog -Component "OOSU" -Message "Downloading O&O ShutUp10++."
|
||||||
$sync.ProcessRunning = $true
|
|
||||||
|
|
||||||
Invoke-WPFRunspace -ParameterList @(,("downloadPath", $downloadPath)) -ScriptBlock {
|
Save-WinUtilFile -Uri "https://dl5.oo-software.com/files/ooshutup10/OOSU10.exe" -DestinationPath $DownloadPath -ProgressCallback {
|
||||||
param($downloadPath)
|
param($percent)
|
||||||
|
Step-WinUtilJob -Status "Downloading O&O ShutUp10++ ($percent%)" -Percent $percent
|
||||||
$hasUI = $null -ne $sync.Form -and $null -ne $sync.Form.Dispatcher
|
|
||||||
|
|
||||||
try {
|
|
||||||
Write-WinUtilLog -Component "OOSU" -Message "Downloading O&O ShutUp10++."
|
|
||||||
if ($hasUI) {
|
|
||||||
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Downloading O&O ShutUp10++ (0%)" -Percent 0
|
|
||||||
}
|
|
||||||
|
|
||||||
Save-WinUtilFile -Uri "https://dl5.oo-software.com/files/ooshutup10/OOSU10.exe" -DestinationPath $downloadPath -ProgressCallback {
|
|
||||||
param($percent)
|
|
||||||
|
|
||||||
if ($hasUI) {
|
|
||||||
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Downloading O&O ShutUp10++ ($percent%)" -Percent $percent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($hasUI) {
|
|
||||||
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Launching O&O ShutUp10++" -Percent 100
|
|
||||||
}
|
|
||||||
Start-Process -FilePath $downloadPath
|
|
||||||
|
|
||||||
Write-WinUtilLog -Component "OOSU" -Message "O&O ShutUp10++ launched."
|
|
||||||
if ($hasUI) {
|
|
||||||
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "O&O ShutUp10++ launched" -Percent 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-WinUtilLog -Level "ERROR" -Component "OOSU" -Message "O&O ShutUp10++ download failed: $($_.Exception.Message)"
|
|
||||||
if ($hasUI) {
|
|
||||||
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "O&O ShutUp10++ download failed" -Percent 100
|
|
||||||
}
|
|
||||||
Write-Error "Couldn't download O&O ShutUp10. Please make sure you have an active Internet connection."
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
$sync.ProcessRunning = $false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Step-WinUtilJob -Status "Launching O&O ShutUp10++" -Percent 100
|
||||||
|
Start-Process -FilePath $DownloadPath
|
||||||
|
Write-WinUtilLog -Component "OOSU" -Message "O&O ShutUp10++ launched."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+2
-2
@@ -40,12 +40,12 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
|||||||
New-Item -Path $RazerPath -ItemType Directory
|
New-Item -Path $RazerPath -ItemType Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
icacls $RazerPath /deny \"Everyone:(W)\"
|
icacls $RazerPath /deny \"*S-1-1-0:(W)\"
|
||||||
"
|
"
|
||||||
],
|
],
|
||||||
"UndoScript": [
|
"UndoScript": [
|
||||||
"
|
"
|
||||||
icacls \"$Env:SystemRoot\\Installer\\Razer\" /remove:d Everyone
|
icacls \"$Env:SystemRoot\\Installer\\Razer\" /remove:d *S-1-1-0
|
||||||
"
|
"
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
|||||||
"InvokeScript": [
|
"InvokeScript": [
|
||||||
"
|
"
|
||||||
# Deny permission to remove OneDrive folder
|
# Deny permission to remove OneDrive folder
|
||||||
icacls $Env:OneDrive /deny \"Administrators:(D,DC)\"
|
icacls $Env:OneDrive /deny \"*S-1-5-32-544:(D,DC)\"
|
||||||
|
|
||||||
Write-Host \"Uninstalling OneDrive...\"
|
Write-Host \"Uninstalling OneDrive...\"
|
||||||
Start-Process -FilePath (Join-Path $Env:SystemRoot \"System32\\OneDriveSetup.exe\") -ArgumentList '/uninstall' -Wait
|
Start-Process -FilePath (Join-Path $Env:SystemRoot \"System32\\OneDriveSetup.exe\") -ArgumentList '/uninstall' -Wait
|
||||||
@@ -31,7 +31,7 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
|||||||
Remove-Item \"$Env:ProgramData\\Microsoft OneDrive\" -Recurse -Force
|
Remove-Item \"$Env:ProgramData\\Microsoft OneDrive\" -Recurse -Force
|
||||||
|
|
||||||
# Grant back permission to access OneDrive folder
|
# Grant back permission to access OneDrive folder
|
||||||
icacls $Env:OneDrive /grant \"Administrators:(D,DC)\"
|
icacls $Env:OneDrive /grant \"*S-1-5-32-544:(D,DC)\"
|
||||||
|
|
||||||
if (-not (Get-ChildItem -Path $Env:OneDrive)) {
|
if (-not (Get-ChildItem -Path $Env:OneDrive)) {
|
||||||
Remove-Item -Path $Env:OneDrive -Recurse
|
Remove-Item -Path $Env:OneDrive -Recurse
|
||||||
|
|||||||
+1
-1
@@ -14,6 +14,6 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
|||||||
"category": "z__Advanced Tweaks - CAUTION",
|
"category": "z__Advanced Tweaks - CAUTION",
|
||||||
"panel": "1",
|
"panel": "1",
|
||||||
"Type": "Combobox",
|
"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 Fastest Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult",
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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:
|
* Run the following command to compile and run WinUtil:
|
||||||
* `.\Compile.ps1 -run`
|
* `.\Compile.ps1 -run`
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
* 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.
|
* 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.
|
||||||
|
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ Yes, WinUtil works on Windows Server editions, though some features may not be a
|
|||||||
|
|
||||||
### Does WinUtil work with Windows LTSC?
|
### Does WinUtil work with Windows LTSC?
|
||||||
|
|
||||||
Yes, WinUtil works with Windows 10/11 LTSC editions. Some applications may not be available depending on your configuration.
|
WinUtil currently targets Windows 11. Windows 10 LTSC is not supported, consistent with the Windows 10 support policy above. Some features and applications may vary by Windows 11 edition.
|
||||||
|
|
||||||
### Can I use WinUtil in a corporate/enterprise environment?
|
### Can I use WinUtil in a corporate/enterprise environment?
|
||||||
|
|
||||||
|
|||||||
@@ -235,6 +235,21 @@ Now that you're set up, explore these guides:
|
|||||||
- [Tweaks Guide](/guides/tweaks/) — Understand system optimizations
|
- [Tweaks Guide](/guides/tweaks/) — Understand system optimizations
|
||||||
- [FAQ](/faq/) — Common questions and answers
|
- [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
|
## Getting help
|
||||||
|
|
||||||
If you need assistance:
|
If you need assistance:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Use the quick-selection buttons at the top of the Tweaks tab to speed up setup:
|
|||||||
* **Minimal**: Selects a smaller, lower-impact set of common tweaks.
|
* **Minimal**: Selects a smaller, lower-impact set of common tweaks.
|
||||||
* **Advanced**: Selects a focused set of safer advanced tweaks. This preset intentionally skips restore point creation and cleanup tasks to avoid a long runtime.
|
* **Advanced**: Selects a focused set of safer advanced tweaks. This preset intentionally skips restore point creation and cleanup tasks to avoid a long runtime.
|
||||||
* **Clear**: Clears all currently selected tweaks.
|
* **Clear**: Clears all currently selected tweaks.
|
||||||
* **Get Installed Tweaks**: Best-effort detection for tweaks already applied to your system.
|
* **Select Installed Tweaks**: Best-effort detection for tweaks already applied to your system.
|
||||||
|
|
||||||
### Run Tweaks
|
### Run Tweaks
|
||||||
* **Open the Tweaks tab**: Navigate to the **Tweaks** tab in the application.
|
* **Open the Tweaks tab**: Navigate to the **Tweaks** tab in the application.
|
||||||
@@ -65,6 +65,7 @@ Use the DNS section to switch both IPv4 and IPv6 DNS providers without editing a
|
|||||||
|
|
||||||
* **Default**: Uses the default DNS settings configured by your ISP or network.
|
* **Default**: Uses the default DNS settings configured by your ISP or network.
|
||||||
* **DHCP**: Automatically acquires DNS settings from the DHCP server.
|
* **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.
|
* [**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**](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.
|
* [**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.
|
||||||
@@ -73,14 +74,6 @@ 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.
|
* [**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**](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.
|
* [**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
|
### Customize Preferences
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Changing modes adjusts system-wide Windows Update behavior. After switching mode
|
|||||||
- **Feature updates**: Delayed by **365 days** to reduce the chance of disruption from major Windows changes.
|
- **Feature updates**: Delayed by **365 days** to reduce the chance of disruption from major Windows changes.
|
||||||
- **Quality updates**: Delayed by **4 days** to allow time for early issues to surface while still keeping the system protected.
|
- **Quality updates**: Delayed by **4 days** to allow time for early issues to surface while still keeping the system protected.
|
||||||
- **Drivers**: Excluded from Windows quality updates.
|
- **Drivers**: Excluded from Windows quality updates.
|
||||||
- **Restarts**: Scheduled updates do not automatically restart Windows while a user is signed in. A restart explicitly scheduled by a user still takes precedence.
|
- **Installation**: Updates download automatically and notify you when they are ready to install. This setting does not control restarts after installation.
|
||||||
- **Availability**: Update deferral policies apply to Windows Pro, Enterprise, and Education editions.
|
- **Availability**: Update deferral policies apply to Windows Pro, Enterprise, and Education editions.
|
||||||
- **Why use it**: This mode offers the best balance between security and stability, which is why it is the recommended option for most PCs.
|
- **Why use it**: This mode offers the best balance between security and stability, which is why it is the recommended option for most PCs.
|
||||||
|
|
||||||
|
|||||||
@@ -23,33 +23,30 @@ 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
|
### Step 1 — Select Your Official Windows 11 ISO
|
||||||
|
|
||||||
1. Open WinUtil and go to the **Win11 Creator** tab.
|
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.
|
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.
|
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
|
:::note
|
||||||
This step takes around 10–30 seconds, depending on your drive speed.
|
Mounting and verification takes around 10–30 seconds, depending on your drive speed.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Step 3 — Run the Modification
|
### Step 2 — Modify the Image
|
||||||
|
|
||||||
Click **Run Windows ISO Modification and Creator** to start the customization process. WinUtil will:
|
Once the ISO is verified, WinUtil moves to this step and shows the mounted drive and image file. Choose your **edition** from the dropdown — Pro is selected by default if available — and tick **Inject current system drivers** if you want them.
|
||||||
|
|
||||||
|
Then click **Run Windows ISO Modification and Creator** to start the customization process. WinUtil will:
|
||||||
|
|
||||||
**App & Component Removal:**
|
**App & Component Removal:**
|
||||||
- **Remove 40+ bloat apps** — Clipchamp, Teams, Copilot, Dev Home, new Outlook, Bing apps, Solitaire, and more
|
- **Remove 19 bloat apps** — Clipchamp, Teams, Dev Home, new Outlook, Bing apps, Solitaire, and more
|
||||||
- **Delete OneDrive setup** from the image
|
- **Uninstall OneDrive** during first logon
|
||||||
|
|
||||||
**System Customization:**
|
**System Customization:**
|
||||||
- **Bypass hardware checks** — removes TPM, Secure Boot, CPU, RAM, and storage requirement enforcement so the ISO installs on unsupported hardware
|
- **Bypass hardware checks** — removes TPM, Secure Boot, CPU, RAM, and storage requirement enforcement so the ISO installs on unsupported hardware
|
||||||
@@ -70,9 +67,13 @@ Click **Run Windows ISO Modification and Creator** to start the customization pr
|
|||||||
- **Disable Copilot and search box suggestions**
|
- **Disable Copilot and search box suggestions**
|
||||||
|
|
||||||
**Optional: Driver Injection**
|
**Optional: Driver Injection**
|
||||||
- If enabled, it injects all drivers from your current system into the install.wim and boot.wim — useful for offline installations on machines with missing drivers. This is an optional checkbox in Step 3.
|
- If enabled, WinUtil exports the drivers from your current system, 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.
|
||||||
|
|
||||||
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
|
||||||
|
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.
|
||||||
|
|
||||||
:::note
|
:::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.
|
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.
|
||||||
@@ -80,7 +81,7 @@ The resulting ISO is close to the size of the source ISO. WinUtil does not remov
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Step 4 — Export Your Result
|
### Step 3 — Export Your Result
|
||||||
|
|
||||||
Once the modification is complete, choose how to save your image:
|
Once the modification is complete, choose how to save your image:
|
||||||
|
|
||||||
@@ -98,7 +99,11 @@ Once the modification is complete, choose how to save your image:
|
|||||||
1. Click **Write Directly to a USB Drive**.
|
1. Click **Write Directly to a USB Drive**.
|
||||||
2. Select your USB drive from the dropdown (click **Refresh** if it doesn't appear).
|
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**.
|
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 512 MB EFI partition and copies the modified Windows files.
|
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.
|
||||||
|
:::
|
||||||
|
|
||||||
:::danger
|
:::danger
|
||||||
Double-check you have selected the correct drive before confirming. This operation cannot be undone.
|
Double-check you have selected the correct drive before confirming. This operation cannot be undone.
|
||||||
@@ -110,9 +115,9 @@ Double-check you have selected the correct drive before confirming. This operati
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Step 5 — Clean Up (Optional)
|
### Start Over (Optional)
|
||||||
|
|
||||||
Click **Clean & Reset** to delete the temporary working directory (~10–15 GB) and return the tool to its initial state, ready for a new ISO. You will be asked to confirm before anything is deleted.
|
Click **Start Over** to delete the temporary working directory (~10–15 GB) and return the tool to its initial state, ready for a new ISO. You will be asked to confirm before anything is deleted.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -133,11 +138,13 @@ When you install Windows 11 from your modified ISO:
|
|||||||
|
|
||||||
| Problem | Fix |
|
| Problem | Fix |
|
||||||
|---------|-----|
|
|---------|-----|
|
||||||
| "install.wim not found" | Not a valid Windows 11 ISO — download a fresh one from Microsoft |
|
| "install.wim / install.esd was not found" | The ISO has no Windows installation image — download a fresh official ISO from Microsoft |
|
||||||
| "oscdimg.exe not found" | Run `winget install -e --id Microsoft.OSCDIMG` then retry |
|
| "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** |
|
| USB drive not showing up | Plug it in, wait a few seconds, then click **Refresh** |
|
||||||
| Modification seems stuck | The WIM dismount step is slow — wait at least 10 minutes before assuming it's frozen |
|
| 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 |
|
||||||
| "Access Denied" error | Make sure WinUtil is running as Administrator |
|
| "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` |
|
||||||
|
| DISM error with an explanation in parentheses, e.g. "exit code 112 (Disk is full)" | WinUtil now explains common DISM failures in plain language. Free up disk space for a "Disk is full" error, or run WinUtil as Administrator for an "Access denied" error. If the code has no explanation, search the number on Microsoft's [DISM error code reference](https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes) |
|
||||||
| "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 |
|
| "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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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.`
|
`< : The term '<' is not recognized as the name of a cmdlet, function, script file, or operable program.`
|
||||||
|
|
||||||
try using a **VPN** and if that doesn't work than report the issue to https://github.com/ChrisTitusTech/winutil/issues
|
try using a **VPN** and if that doesn't work then report the issue to https://github.com/ChrisTitusTech/winutil/issues
|
||||||
|
|
||||||
### Script Won't Run
|
### 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"`
|
`"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
|
### Ultimate Performance Plan Not Working
|
||||||
|
|
||||||
The Ultimate Performance power plan may not work on some laptops that do not fully support this power plan.
|
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
|
### 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
|
### 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.
|
Installing PowerShell 7 from the Microsoft Store (MSIX package) is known to cause issues with DISM cmdlets such as `Get-WindowsOptionalFeature` and `Enable-WindowsOptionalFeature`, resulting in a `Class not registered` COM error.
|
||||||
|
|
||||||
This might also make it so running the "pre-installed app removal" will take a indefinite amount of time
|
This might also make it so running the "pre-installed app removal" will take an indefinite amount of time
|
||||||
|
|
||||||
Instead, install PowerShell 7 using one of the following methods:
|
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_MouseEnter({ $this.Foreground = "Red" })
|
||||||
$selectedAppRemoveButton.Add_MouseLeave({ $this.SetResourceReference([Windows.Controls.Control]::ForegroundProperty, "MainForegroundColor") })
|
$selectedAppRemoveButton.Add_MouseLeave({ $this.SetResourceReference([Windows.Controls.Control]::ForegroundProperty, "MainForegroundColor") })
|
||||||
$selectedAppRemoveButton.Add_Click({
|
$selectedAppRemoveButton.Add_Click({
|
||||||
$sync.($this.Tag).isChecked = $false # On click of the remove button, we only have to uncheck the corresponding checkbox. This will kick of all necessary changes to update the UI
|
$sync.($this.Tag).isChecked = $false # On click of the remove button, we only have to uncheck the corresponding checkbox. This will kick off all necessary changes to update the UI
|
||||||
})
|
})
|
||||||
[System.Windows.Controls.Grid]::SetColumn($selectedAppRemoveButton, 1)
|
[System.Windows.Controls.Grid]::SetColumn($selectedAppRemoveButton, 1)
|
||||||
$selectedAppGrid.Children.Add($selectedAppRemoveButton)
|
$selectedAppGrid.Children.Add($selectedAppRemoveButton)
|
||||||
|
|||||||
@@ -1,18 +1,147 @@
|
|||||||
function Close-WinUtilRunspacePool {
|
function Close-WinUtilRunspacePool {
|
||||||
if ($null -eq $sync -or -not $sync.ContainsKey("runspace") -or $null -eq $sync.runspace) {
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Stops anything still running and closes the worker pool
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Closing the pool with work still in it is what produced an unhandled
|
||||||
|
InvalidRunspaceStateException: a queued instance starts on a runspace that is already
|
||||||
|
closing, throws on a thread pool thread, and takes the process down. Whatever is in
|
||||||
|
flight is therefore asked to stop, and waited for, before the pool is closed.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[int]$StopTimeoutSeconds = 15,
|
||||||
|
|
||||||
|
# Leaves ShuttingDown clear: nothing resets it, so setting it here would refuse every
|
||||||
|
# later action for the rest of the session
|
||||||
|
[switch]$Recycle
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($null -eq $sync) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$poolLock = Get-WinUtilRunspacePoolLock
|
||||||
|
[System.Threading.Monitor]::Enter($poolLock)
|
||||||
try {
|
try {
|
||||||
if ($sync.runspace.RunspacePoolStateInfo.State -notin @(
|
# Set before stopping, so nothing that is winding down queues fresh work behind us
|
||||||
[System.Management.Automation.Runspaces.RunspacePoolState]::Closed,
|
if (-not $Recycle) {
|
||||||
[System.Management.Automation.Runspaces.RunspacePoolState]::Closing,
|
$sync.ShuttingDown = $true
|
||||||
[System.Management.Automation.Runspaces.RunspacePoolState]::Broken
|
}
|
||||||
)) {
|
|
||||||
$sync.runspace.Close()
|
if (-not $sync.ContainsKey("runspace") -or $null -eq $sync.runspace) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$stopped = $true
|
||||||
|
try {
|
||||||
|
$stopped = Stop-WinUtilActiveWork -TimeoutSeconds $StopTimeoutSeconds
|
||||||
|
} catch {
|
||||||
|
$stopped = $false
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Could not stop running work cleanly: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$pool = $sync.runspace
|
||||||
|
$cleanupDeferred = $false
|
||||||
|
try {
|
||||||
|
$poolState = $pool.RunspacePoolStateInfo.State
|
||||||
|
$terminalStates = @(
|
||||||
|
[System.Management.Automation.Runspaces.RunspacePoolState]::Closed,
|
||||||
|
[System.Management.Automation.Runspaces.RunspacePoolState]::Broken
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not $stopped -and $poolState -notin $terminalStates) {
|
||||||
|
# Close and Dispose both wait for an invocation that ignored BeginStop. Hand
|
||||||
|
# cleanup to the thread pool so the timeout above remains a real upper bound.
|
||||||
|
$cleanupDeferred = $true
|
||||||
|
if ($poolState -ne [System.Management.Automation.Runspaces.RunspacePoolState]::Closing) {
|
||||||
|
Register-WinUtilRunspacePoolCleanup -RunspacePool $pool
|
||||||
|
}
|
||||||
|
} elseif ($poolState -notin ($terminalStates + [System.Management.Automation.Runspaces.RunspacePoolState]::Closing)) {
|
||||||
|
$pool.Close()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
# A pool that will not close cleanly must not stop the window from closing
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Worker pool did not close cleanly: $($_.Exception.Message)"
|
||||||
|
} finally {
|
||||||
|
if (-not $cleanupDeferred) {
|
||||||
|
try {
|
||||||
|
$pool.Dispose()
|
||||||
|
} catch {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Worker pool did not dispose cleanly: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$sync.Remove("runspace")
|
||||||
|
if ($sync.ActiveShells) { $sync.ActiveShells.Clear() }
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
$sync.runspace.Dispose()
|
[System.Threading.Monitor]::Exit($poolLock)
|
||||||
$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
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
function Complete-WinUtilPackageRun {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Reports what a package run actually did and fails the job on unexpected errors
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Package managers report failure through an exit code, which is easy to walk past.
|
||||||
|
Without this the job layer would show a green checkmark for a run in which nothing
|
||||||
|
changed. Unexpected failures terminate the job; expected elevated-context skips
|
||||||
|
raise a warning so the job cannot claim that every requested action completed.
|
||||||
|
|
||||||
|
.PARAMETER Action
|
||||||
|
Install or Uninstall, used in the summary text.
|
||||||
|
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Action,
|
||||||
|
|
||||||
|
[object[]]$Results = @()
|
||||||
|
)
|
||||||
|
|
||||||
|
$succeeded = @($Results | Where-Object { $_.Outcome -eq "Succeeded" })
|
||||||
|
$skipped = @($Results | Where-Object { $_.Outcome -eq "Skipped" })
|
||||||
|
$failed = @($Results | Where-Object { $_.Outcome -eq "Failed" })
|
||||||
|
|
||||||
|
$summary = "$($succeeded.Count) succeeded, $($skipped.Count) skipped, $($failed.Count) failed"
|
||||||
|
Write-WinUtilLog -Component "Package" -Message "$Action summary: $summary"
|
||||||
|
Write-Host "$Action summary: $summary"
|
||||||
|
|
||||||
|
foreach ($result in $skipped) {
|
||||||
|
Write-Host " skipped $($result.Package) - $($result.Detail)"
|
||||||
|
}
|
||||||
|
foreach ($result in $failed) {
|
||||||
|
Write-Host " failed $($result.Package) - $($result.Detail)" -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminContextSkipped = @($skipped | Where-Object { $_.ExitCode -eq -1978335107 })
|
||||||
|
if ($adminContextSkipped.Count -gt 0) {
|
||||||
|
Write-Warning "$($adminContextSkipped.Count) package action(s) were skipped because elevated WinUtil cannot modify user-scoped installations."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failed.Count -gt 0) {
|
||||||
|
$names = ($failed | ForEach-Object { $_.Package }) -join ', '
|
||||||
|
$reasons = @($failed | ForEach-Object { $_.Detail } | Sort-Object -Unique)
|
||||||
|
|
||||||
|
$message = if ($reasons.Count -eq 1) {
|
||||||
|
"$($failed.Count) of $($Results.Count) package(s) failed: $names. $($reasons[0])"
|
||||||
|
} else {
|
||||||
|
"$($failed.Count) of $($Results.Count) package(s) failed: $names. See the lines above for each reason."
|
||||||
|
}
|
||||||
|
# Each failed package was already logged by its package-manager adapter. Carry that fact
|
||||||
|
# with the summary exception so the job wrapper adds context without another error count.
|
||||||
|
$exception = [System.InvalidOperationException]::new($message)
|
||||||
|
$exception.Data["WinUtilErrorReported"] = $true
|
||||||
|
throw $exception
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
function Get-WinUtilAppEntryHandlers {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
The event handlers shared by every app entry on the Install tab
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
A scriptblock literal inside a loop is a new scriptblock every time round, and
|
||||||
|
building six of them per app is the single largest cost of drawing the app list:
|
||||||
|
measured at 2.13 ms per entry against 0.62 ms when they are made once and reused.
|
||||||
|
|
||||||
|
None of them close over anything per entry. They read the sender through $this, so
|
||||||
|
one instance serves every app.
|
||||||
|
#>
|
||||||
|
|
||||||
|
if ($null -ne $script:WinUtilAppEntryHandlers) {
|
||||||
|
return $script:WinUtilAppEntryHandlers
|
||||||
|
}
|
||||||
|
|
||||||
|
$script:WinUtilAppEntryHandlers = @{
|
||||||
|
BorderClick = {
|
||||||
|
# Resolve through $sync because the border's child is a layout Grid for FOSS entries
|
||||||
|
$childCheckbox = $sync.$($this.Tag)
|
||||||
|
$childCheckbox.IsChecked = -not $childCheckbox.IsChecked
|
||||||
|
}
|
||||||
|
MouseEnter = {
|
||||||
|
if (($sync.$($this.Tag).IsChecked) -eq $false) {
|
||||||
|
$this.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallHighlightedColor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MouseLeave = {
|
||||||
|
if (($sync.$($this.Tag).IsChecked) -eq $false) {
|
||||||
|
$this.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RightClick = {
|
||||||
|
# Store the selected app in a global variable so it can be used in the popup
|
||||||
|
$sync.appPopupSelectedApp = $this.Tag
|
||||||
|
# Set the popup position to the current mouse position
|
||||||
|
$sync.appPopup.PlacementTarget = $this
|
||||||
|
$sync.appPopup.IsOpen = $true
|
||||||
|
}
|
||||||
|
# The checkbox sits inside the entry layout Grid, so the border is one level further up
|
||||||
|
Checked = {
|
||||||
|
Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName $this.Tag
|
||||||
|
$borderElement = $this.Parent.Parent
|
||||||
|
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallSelectedColor")
|
||||||
|
}
|
||||||
|
Unchecked = {
|
||||||
|
Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName $this.Tag
|
||||||
|
$borderElement = $this.Parent.Parent
|
||||||
|
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor")
|
||||||
|
}
|
||||||
|
ImageFailed = {
|
||||||
|
$this.Visibility = "Collapsed"
|
||||||
|
$this.Parent.Children[0].Visibility = "Visible"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $script:WinUtilAppEntryHandlers
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
function Get-WinUtilDNSBenchmark {
|
||||||
|
<#
|
||||||
|
|
||||||
|
.SYNOPSIS
|
||||||
|
Benchmarks neutral DNS providers by measuring TCP port 53 latency (RTT in ms) to determine the fastest DNS server.
|
||||||
|
|
||||||
|
.PARAMETER TimeoutMs
|
||||||
|
Maximum timeout in milliseconds for each connection test. Default is 1500ms.
|
||||||
|
|
||||||
|
.OUTPUTS
|
||||||
|
Array of PSCustomObjects containing Provider, PrimaryIP, and LatencyMs sorted by lowest latency.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
$results = Get-WinUtilDNSBenchmark
|
||||||
|
$fastest = $results[0]
|
||||||
|
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[ValidateRange(1, 9998)]
|
||||||
|
[int]$TimeoutMs = 1500
|
||||||
|
)
|
||||||
|
|
||||||
|
Write-WinUtilLog -Component "DNS" -Message "Starting DNS latency benchmark scan (TCP port 53)..."
|
||||||
|
|
||||||
|
$dnsConfigs = $sync.configs.dns
|
||||||
|
if ($null -eq $dnsConfigs) {
|
||||||
|
Write-Warning "DNS configurations not found in `$sync.configs.dns."
|
||||||
|
Write-WinUtilLog -Level "ERROR" -Component "DNS" -Message "DNS configurations not found in `$sync.configs.dns."
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = [System.Collections.Generic.List[PSObject]]::new()
|
||||||
|
|
||||||
|
foreach ($prop in $dnsConfigs.PSObject.Properties) {
|
||||||
|
$providerName = $prop.Name
|
||||||
|
$primaryIp = $prop.Value.Primary
|
||||||
|
if (-not $primaryIp) { continue }
|
||||||
|
|
||||||
|
# Providers must explicitly opt in so new filtering services are never auto-selected.
|
||||||
|
if ($prop.Value.BenchmarkEligible -ne $true) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$latency = 9999
|
||||||
|
$client = $null
|
||||||
|
try {
|
||||||
|
$client = New-Object System.Net.Sockets.TcpClient
|
||||||
|
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$asyncResult = $client.BeginConnect($primaryIp, 53, $null, $null)
|
||||||
|
$success = $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)
|
||||||
|
$stopwatch.Stop()
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
$client.EndConnect($asyncResult)
|
||||||
|
$latency = [int]$stopwatch.ElapsedMilliseconds
|
||||||
|
} else {
|
||||||
|
$latency = 9999
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$latency = 9999
|
||||||
|
} finally {
|
||||||
|
if ($null -ne $client) {
|
||||||
|
$client.Dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$results.Add([PSCustomObject]@{
|
||||||
|
Provider = $providerName
|
||||||
|
PrimaryIP = $primaryIp
|
||||||
|
LatencyMs = $latency
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortedResults = @($results | Sort-Object LatencyMs)
|
||||||
|
if ($sortedResults.Count -gt 0 -and $sortedResults[0].LatencyMs -lt 9999) {
|
||||||
|
$fastest = $sortedResults[0]
|
||||||
|
Write-WinUtilLog -Component "DNS" -Message "DNS Benchmark completed. Fastest neutral provider: $($fastest.Provider) ($($fastest.LatencyMs) ms)"
|
||||||
|
} else {
|
||||||
|
Write-WinUtilLog -Component "DNS" -Message "DNS Benchmark completed. Could not determine latency for providers."
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sortedResults
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
function Get-WinUtilEnvironmentReport {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Collects the allowlisted data used by the WinUtil environment report.
|
||||||
|
#>
|
||||||
|
|
||||||
|
$reportWarnings = [System.Collections.Generic.List[string]]::new()
|
||||||
|
|
||||||
|
$windows = [ordered]@{
|
||||||
|
edition = $null
|
||||||
|
version = $null
|
||||||
|
buildNumber = $null
|
||||||
|
architecture = $null
|
||||||
|
}
|
||||||
|
$hardware = [ordered]@{
|
||||||
|
cpuModel = $null
|
||||||
|
logicalProcessorCount = $null
|
||||||
|
totalMemoryGB = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$operatingSystem = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
|
||||||
|
$windows.edition = $operatingSystem.Caption
|
||||||
|
$windows.version = $operatingSystem.Version
|
||||||
|
$windows.buildNumber = $operatingSystem.BuildNumber
|
||||||
|
$windows.architecture = $operatingSystem.OSArchitecture
|
||||||
|
|
||||||
|
if ($null -ne $operatingSystem.TotalVisibleMemorySize) {
|
||||||
|
$hardware.totalMemoryGB = [math]::Round(([double]$operatingSystem.TotalVisibleMemorySize / 1MB), 2)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$message = "Failed to collect Windows/memory info from Win32_OperatingSystem: $($_.Exception.Message)"
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
|
||||||
|
[void]$reportWarnings.Add($message)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$processors = @(Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop)
|
||||||
|
if ($processors.Count -gt 0) {
|
||||||
|
$hardware.cpuModel = $processors[0].Name
|
||||||
|
$hardware.logicalProcessorCount = [int](($processors | Measure-Object -Property NumberOfLogicalProcessors -Sum).Sum)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$message = "Failed to collect CPU info from Win32_Processor: $($_.Exception.Message)"
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
|
||||||
|
[void]$reportWarnings.Add($message)
|
||||||
|
}
|
||||||
|
|
||||||
|
$powershell = [ordered]@{
|
||||||
|
edition = $PSVersionTable.PSEdition
|
||||||
|
version = $PSVersionTable.PSVersion.ToString()
|
||||||
|
executionPolicy = $null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$powershell.executionPolicy = (Get-ExecutionPolicy).ToString()
|
||||||
|
} catch {
|
||||||
|
$message = "Failed to read PowerShell execution policy: $($_.Exception.Message)"
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
|
||||||
|
[void]$reportWarnings.Add($message)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Re-use built-in functionality
|
||||||
|
$chocolatey = [ordered]@{ installed = $false; version = $null }
|
||||||
|
try {
|
||||||
|
$chocolatey.installed = (Test-WinUtilPackageManager -choco 6>$null) -eq "installed"
|
||||||
|
} catch {
|
||||||
|
$message = "Failed to check Chocolatey availability: $($_.Exception.Message)"
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
|
||||||
|
[void]$reportWarnings.Add($message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($chocolatey.installed) {
|
||||||
|
try {
|
||||||
|
$global:LASTEXITCODE = 0
|
||||||
|
$versionOutput = @(choco -v 2>&1)
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Chocolatey version probe exited with code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
$chocolatey.version = ($versionOutput | Select-Object -First 1).ToString().Trim()
|
||||||
|
} catch {
|
||||||
|
$message = "Failed to read Chocolatey version: $($_.Exception.Message)"
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
|
||||||
|
[void]$reportWarnings.Add($message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$winget = [ordered]@{ installed = $false; version = $null }
|
||||||
|
try {
|
||||||
|
$winget.installed = (Test-WinUtilPackageManager -winget 6>$null) -eq "installed"
|
||||||
|
} catch {
|
||||||
|
$message = "Failed to check WinGet availability: $($_.Exception.Message)"
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
|
||||||
|
[void]$reportWarnings.Add($message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($winget.installed) {
|
||||||
|
try {
|
||||||
|
$global:LASTEXITCODE = 0
|
||||||
|
$versionOutput = @(winget -v 2>&1)
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "WinGet version probe exited with code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
$winget.version = ($versionOutput | Select-Object -First 1).ToString().Trim()
|
||||||
|
} catch {
|
||||||
|
$message = "Failed to read WinGet version: $($_.Exception.Message)"
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
|
||||||
|
[void]$reportWarnings.Add($message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Null means the registry state could not be read. Do not turn an access/provider failure into
|
||||||
|
# a misleading "no reboot required" result.
|
||||||
|
$system = [ordered]@{ pendingRebootRequired = $null }
|
||||||
|
try {
|
||||||
|
$rebootPaths = @(
|
||||||
|
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending",
|
||||||
|
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired"
|
||||||
|
)
|
||||||
|
|
||||||
|
# A present-but-empty PendingFileRenameOperations value still returns a non-null object, so
|
||||||
|
# check the actual entries rather than just whether the property exists.
|
||||||
|
$pendingFileRenameOperations = @(
|
||||||
|
(Get-ItemProperty -LiteralPath "HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager" `
|
||||||
|
-ErrorAction Stop).PendingFileRenameOperations |
|
||||||
|
Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }
|
||||||
|
)
|
||||||
|
$system.pendingRebootRequired = ($rebootPaths | Where-Object {
|
||||||
|
Test-Path -LiteralPath $_ -ErrorAction Stop
|
||||||
|
}).Count -gt 0 -or
|
||||||
|
$pendingFileRenameOperations.Count -gt 0
|
||||||
|
} catch {
|
||||||
|
$message = "Failed to check pending-reboot registry state: $($_.Exception.Message)"
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
|
||||||
|
[void]$reportWarnings.Add($message)
|
||||||
|
}
|
||||||
|
|
||||||
|
$tweaksState = Get-WinUtilTweaksStateReport
|
||||||
|
if ($tweaksState.collectionStatus -ne "collected") {
|
||||||
|
$message = "Failed to collect the complete tweak state for the environment report."
|
||||||
|
Write-WinUtilLog -Component "EnvironmentReport" -Level "WARN" -Message $message
|
||||||
|
[void]$reportWarnings.Add($message)
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($message in $reportWarnings) {
|
||||||
|
Write-Warning $message
|
||||||
|
}
|
||||||
|
|
||||||
|
return [pscustomobject][ordered]@{
|
||||||
|
schemaVersion = "1.0"
|
||||||
|
generatedAtUtc = [DateTime]::UtcNow.ToString("o")
|
||||||
|
windows = [pscustomobject]$windows
|
||||||
|
hardware = [pscustomobject]$hardware
|
||||||
|
powershell = [pscustomobject]$powershell
|
||||||
|
packageManagers = [pscustomobject][ordered]@{
|
||||||
|
winget = [pscustomobject]$winget
|
||||||
|
chocolatey = [pscustomobject]$chocolatey
|
||||||
|
}
|
||||||
|
system = [pscustomobject]$system
|
||||||
|
tweaksState = $tweaksState
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
function Get-WinUtilEnvironmentReportLogsPath {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Derives the companion logs .txt path from the environment report's JSON save path.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$JsonPath
|
||||||
|
)
|
||||||
|
|
||||||
|
# ChangeExtension($JsonPath, $null) leaves a trailing dot instead of stripping it, so build the
|
||||||
|
# name from its parts instead.
|
||||||
|
$directory = [System.IO.Path]::GetDirectoryName($JsonPath)
|
||||||
|
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($JsonPath)
|
||||||
|
return [System.IO.Path]::Combine($directory, "${baseName}_logs.txt")
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
function Get-WinUtilRecentLogs {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Concatenates WinUtil session logs from the last N days into a single text blob.
|
||||||
|
|
||||||
|
.PARAMETER Days
|
||||||
|
How many days back to include. Defaults to 7, matching what the support forum/server
|
||||||
|
typically asks users for.
|
||||||
|
|
||||||
|
.PARAMETER LogDirectory
|
||||||
|
Overrides the log directory (normally $sync.winutildir\logs). Mainly for testing.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[int]$Days = 7,
|
||||||
|
[string]$LogDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
if ([string]::IsNullOrWhiteSpace($LogDirectory)) {
|
||||||
|
if ($null -eq $sync -or -not $sync.ContainsKey("winutildir") -or [string]::IsNullOrWhiteSpace($sync.winutildir)) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
$LogDirectory = Join-Path $sync.winutildir "logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $LogDirectory -ErrorAction Stop)) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
$cutoff = (Get-Date).AddDays(-$Days)
|
||||||
|
$logFiles = Get-ChildItem -LiteralPath $LogDirectory -Filter "winutil_*.log" -File -ErrorAction Stop |
|
||||||
|
Where-Object { $_.LastWriteTime -ge $cutoff } |
|
||||||
|
Sort-Object LastWriteTime
|
||||||
|
|
||||||
|
$sections = foreach ($logFile in $logFiles) {
|
||||||
|
"=== $($logFile.Name) ===`n$(Get-Content -LiteralPath $logFile.FullName -Raw -ErrorAction Stop)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($sections -join "`n`n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
function Get-WinUtilRunspacePoolLock {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Returns the lock that serializes worker-pool startup and shutdown
|
||||||
|
#>
|
||||||
|
|
||||||
|
[System.Threading.Monitor]::Enter($sync.SyncRoot)
|
||||||
|
try {
|
||||||
|
if ($null -eq $sync.RunspacePoolLock) {
|
||||||
|
$sync.RunspacePoolLock = [object]::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sync.RunspacePoolLock
|
||||||
|
} finally {
|
||||||
|
[System.Threading.Monitor]::Exit($sync.SyncRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,9 @@ function Get-WinUtilSelectedPackages {
|
|||||||
[string] $Preference
|
[string] $Preference
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A single package has no meaningful percentage to show
|
||||||
if ($PackageList.count -eq 1) {
|
if ($PackageList.count -eq 1) {
|
||||||
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Indeterminate" -value 0.01 -overlay "logo" }
|
Step-WinUtilJob -State "Indeterminate"
|
||||||
} else {
|
|
||||||
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Normal" -value 0.01 -overlay "logo" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$packagesWinget = [System.Collections.ArrayList]::new()
|
$packagesWinget = [System.Collections.ArrayList]::new()
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
Function Get-WinUtilToggleStatus ($ToggleSwitch) {
|
Function Get-WinUtilToggleStatus {
|
||||||
|
param(
|
||||||
|
$ToggleSwitch,
|
||||||
|
[switch]$BypassCache,
|
||||||
|
[switch]$StopOnReadError
|
||||||
|
)
|
||||||
|
|
||||||
$ToggleSwitchReg = $sync.configs.tweaks.$ToggleSwitch.registry
|
$ToggleSwitchReg = $sync.configs.tweaks.$ToggleSwitch.registry
|
||||||
|
|
||||||
if ($null -eq $sync.ToggleStatusCache) {
|
if (-not $BypassCache) {
|
||||||
$sync.ToggleStatusCache = @{}
|
if ($null -eq $sync.ToggleStatusCache) {
|
||||||
|
$sync.ToggleStatusCache = @{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) {
|
||||||
|
return [bool]$sync.ToggleStatusCache[$ToggleSwitch]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($sync.ToggleStatusCache.ContainsKey($ToggleSwitch)) {
|
$readErrorAction = if ($StopOnReadError) { "Stop" } else { "Continue" }
|
||||||
return [bool]$sync.ToggleStatusCache[$ToggleSwitch]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) {
|
if (-not (Get-PSDrive -Name HKU -ErrorAction SilentlyContinue)) {
|
||||||
New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS | Out-Null
|
New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS -ErrorAction $readErrorAction | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($regentry in $ToggleSwitchReg) {
|
foreach ($regentry in $ToggleSwitchReg) {
|
||||||
|
|
||||||
if (Test-Path $regentry.Path) {
|
if (Test-Path $regentry.Path -ErrorAction $readErrorAction) {
|
||||||
$regstate = (Get-ItemProperty -Path $regentry.Path).$($regentry.Name)
|
$regstate = (Get-ItemProperty -Path $regentry.Path -ErrorAction $readErrorAction).$($regentry.Name)
|
||||||
} else {
|
} else {
|
||||||
$regstate = $null
|
$regstate = $null
|
||||||
}
|
}
|
||||||
@@ -30,11 +39,15 @@ Function Get-WinUtilToggleStatus ($ToggleSwitch) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($regstate -ne $regentry.Value) {
|
if ($regstate -ne $regentry.Value) {
|
||||||
$sync.ToggleStatusCache[$ToggleSwitch] = $false
|
if (-not $BypassCache) {
|
||||||
|
$sync.ToggleStatusCache[$ToggleSwitch] = $false
|
||||||
|
}
|
||||||
return $false
|
return $false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$sync.ToggleStatusCache[$ToggleSwitch] = $true
|
if (-not $BypassCache) {
|
||||||
|
$sync.ToggleStatusCache[$ToggleSwitch] = $true
|
||||||
|
}
|
||||||
return $true
|
return $true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
function Get-WinUtilTweaksStateReport {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Groups every config/tweaks.json entry's live applied state by category, reusing the same
|
||||||
|
detection Invoke-WPFGetInstalled uses to check the "Select 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
|
.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.
|
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
|
This is used as the parent object for all category and app entries on the install tab
|
||||||
Used to as part of the Install Tab UI generation
|
Used as part of the Install Tab UI generation
|
||||||
|
|
||||||
.PARAMETER TargetElement
|
.PARAMETER TargetElement
|
||||||
The element to which the AppArea should be added
|
The element to which the AppArea should be added
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
$targetGrid = $sync.Form.FindName($TargetElement)
|
$targetGrid = $sync.Form.FindName($TargetElement)
|
||||||
$null = $targetGrid.Children.Clear()
|
$null = $targetGrid.Children.Clear()
|
||||||
|
|
||||||
# Create the outer Border for the aren where the apps will be placed
|
# Create the outer Border for the area where the apps will be placed
|
||||||
$Border = New-Object Windows.Controls.Border
|
$Border = New-Object Windows.Controls.Border
|
||||||
$Border.VerticalAlignment = "Stretch"
|
$Border.VerticalAlignment = "Stretch"
|
||||||
$Border.SetResourceReference([Windows.Controls.Control]::StyleProperty, "BorderStyle")
|
$Border.SetResourceReference([Windows.Controls.Control]::StyleProperty, "BorderStyle")
|
||||||
|
|||||||
@@ -13,35 +13,18 @@ function Initialize-InstallAppEntry {
|
|||||||
$appKey
|
$appKey
|
||||||
)
|
)
|
||||||
|
|
||||||
$app = $sync.configs.applicationsHashtable.$appKey
|
$app = $sync.configs.applicationsHashtable[$appKey]
|
||||||
|
$handlers = Get-WinUtilAppEntryHandlers
|
||||||
|
|
||||||
# Create the outer Border for the application type
|
# Create the outer Border for the application type
|
||||||
$border = New-Object Windows.Controls.Border
|
$border = New-Object Windows.Controls.Border
|
||||||
$border.Style = $sync.Form.Resources.AppEntryBorderStyle
|
$border.Style = $sync.Form.Resources.AppEntryBorderStyle
|
||||||
$border.Tag = $appKey
|
$border.Tag = $appKey
|
||||||
$border.ToolTip = Get-WinUtilEntryToolTip -Description $app.description -Key $appKey
|
$border.ToolTip = Get-WinUtilEntryToolTip -Description $app.description -Key $appKey
|
||||||
$border.Add_MouseLeftButtonUp({
|
$border.Add_MouseLeftButtonUp($handlers.BorderClick)
|
||||||
# Resolve through $sync because the border's child is a layout Grid for FOSS entries
|
$border.Add_MouseEnter($handlers.MouseEnter)
|
||||||
$childCheckbox = $sync.$($this.Tag)
|
$border.Add_MouseLeave($handlers.MouseLeave)
|
||||||
$childCheckbox.IsChecked = -not $childCheckbox.IsChecked
|
$border.Add_MouseRightButtonUp($handlers.RightClick)
|
||||||
})
|
|
||||||
$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
|
$checkBox = New-Object Windows.Controls.CheckBox
|
||||||
# Sanitize the name for WPF
|
# Sanitize the name for WPF
|
||||||
@@ -49,18 +32,8 @@ function Initialize-InstallAppEntry {
|
|||||||
# Store the original appKey in Tag
|
# Store the original appKey in Tag
|
||||||
$checkBox.Tag = $appKey
|
$checkBox.Tag = $appKey
|
||||||
$checkbox.Style = $sync.Form.Resources.AppEntryCheckboxStyle
|
$checkbox.Style = $sync.Form.Resources.AppEntryCheckboxStyle
|
||||||
# The checkbox sits inside the entry layout Grid, so the border is one level further up
|
$checkbox.Add_Checked($handlers.Checked)
|
||||||
$checkbox.Add_Checked({
|
$checkbox.Add_Unchecked($handlers.Unchecked)
|
||||||
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 = New-Object Windows.Controls.StackPanel
|
||||||
$contentPanel.Orientation = "Horizontal"
|
$contentPanel.Orientation = "Horizontal"
|
||||||
@@ -73,15 +46,16 @@ function Initialize-InstallAppEntry {
|
|||||||
$fallback = New-Object Windows.Controls.TextBlock
|
$fallback = New-Object Windows.Controls.TextBlock
|
||||||
$fallback.Text = $app.content.TrimStart(".").Substring(0, 1).ToUpper()
|
$fallback.Text = $app.content.TrimStart(".").Substring(0, 1).ToUpper()
|
||||||
$fallback.FontWeight = "Bold"; $fallback.HorizontalAlignment = "Center"; $fallback.VerticalAlignment = "Center"
|
$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]::FontSizeProperty, "AppEntryFontSize")
|
||||||
$fallback.SetResourceReference([Windows.Controls.TextBlock]::ForegroundProperty, "ToggleButtonOnColor")
|
$fallback.SetResourceReference([Windows.Controls.TextBlock]::ForegroundProperty, "ToggleButtonOnColor")
|
||||||
[void]$icon.Children.Add($fallback)
|
[void]$icon.Children.Add($fallback)
|
||||||
if ($app.link) {
|
if ($app.link) {
|
||||||
|
$fallback.Visibility = "Collapsed"
|
||||||
$logo = New-Object Windows.Controls.Image
|
$logo = New-Object Windows.Controls.Image
|
||||||
$logo.Stretch = [Windows.Media.Stretch]::Uniform
|
$logo.Stretch = [Windows.Media.Stretch]::Uniform
|
||||||
$logo.Source = "https://www.google.com/s2/favicons?sz=64&domain_url=$([uri]::EscapeDataString($app.link))"
|
$logo.Source = "https://www.google.com/s2/favicons?sz=64&domain_url=$([uri]::EscapeDataString($app.link))"
|
||||||
$logo.Add_ImageFailed({ $this.Visibility = "Collapsed"; $this.Parent.Children[0].Visibility = "Visible" })
|
$logo.Add_ImageFailed($handlers.ImageFailed)
|
||||||
|
|
||||||
[void]$icon.Children.Add($logo)
|
[void]$icon.Children.Add($logo)
|
||||||
}
|
}
|
||||||
[void]$contentPanel.Children.Add($icon)
|
[void]$contentPanel.Children.Add($icon)
|
||||||
@@ -90,6 +64,8 @@ function Initialize-InstallAppEntry {
|
|||||||
$appName = New-Object Windows.Controls.TextBlock
|
$appName = New-Object Windows.Controls.TextBlock
|
||||||
$appName.Style = $sync.Form.Resources.AppEntryNameStyle
|
$appName.Style = $sync.Form.Resources.AppEntryNameStyle
|
||||||
$appName.Text = $app.content
|
$appName.Text = $app.content
|
||||||
|
|
||||||
|
# Add FOSS label after the name if FOSS
|
||||||
[void]$contentPanel.Children.Add($appName)
|
[void]$contentPanel.Children.Add($appName)
|
||||||
$checkBox.Content = $contentPanel
|
$checkBox.Content = $contentPanel
|
||||||
|
|
||||||
@@ -110,6 +86,7 @@ function Initialize-InstallAppEntry {
|
|||||||
|
|
||||||
[void]$entryLayout.Children.Add($fossBadge)
|
[void]$entryLayout.Children.Add($fossBadge)
|
||||||
}
|
}
|
||||||
|
|
||||||
$border.Child = $entryLayout
|
$border.Child = $entryLayout
|
||||||
if ($sync.selectedApps -contains $appKey) {
|
if ($sync.selectedApps -contains $appKey) {
|
||||||
$checkBox.IsChecked = $true
|
$checkBox.IsChecked = $true
|
||||||
|
|||||||
@@ -16,14 +16,17 @@ function Initialize-InstallCategoryAppList {
|
|||||||
$Apps
|
$Apps
|
||||||
)
|
)
|
||||||
|
|
||||||
# Pre-group apps by category before creating WPF controls.
|
# Pre-group apps by category before creating WPF controls. Lists, because appending to
|
||||||
|
# an array copies it and there are several hundred apps.
|
||||||
$appsByCategory = @{}
|
$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) {
|
foreach ($appKey in $Apps.Keys) {
|
||||||
$category = $Apps.$appKey.Category
|
$category = $Apps[$appKey].Category
|
||||||
if (-not $appsByCategory.ContainsKey($category)) {
|
if (-not $appsByCategory.ContainsKey($category)) {
|
||||||
$appsByCategory[$category] = @()
|
$appsByCategory[$category] = [System.Collections.Generic.List[string]]::new()
|
||||||
}
|
}
|
||||||
$appsByCategory[$category] += $appKey
|
$appsByCategory[$category].Add($appKey)
|
||||||
}
|
}
|
||||||
$sync.InstallAppRenderQueue = [System.Collections.Queue]::new()
|
$sync.InstallAppRenderQueue = [System.Collections.Queue]::new()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
function Initialize-WinUtilInstallTabControls {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Wires the Install tab controls that are generated from config rather than declared
|
||||||
|
in XAML
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
The package manager radio buttons and the install action buttons are created by
|
||||||
|
Invoke-WPFUIElements, so they do not exist until the Install tab is built. Setting
|
||||||
|
them up anywhere other than immediately after that build makes the code depend on
|
||||||
|
when the tab happens to be created.
|
||||||
|
#>
|
||||||
|
|
||||||
|
if ($sync.ChocoRadioButton) {
|
||||||
|
$sync.ChocoRadioButton.Add_Checked({
|
||||||
|
$sync.preferences.packagemanager = "Choco"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if ($sync.WingetRadioButton) {
|
||||||
|
$sync.WingetRadioButton.Add_Checked({
|
||||||
|
$sync.preferences.packagemanager = "Winget"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($sync.preferences.packagemanager) {
|
||||||
|
"Choco" { if ($sync.ChocoRadioButton) { $sync.ChocoRadioButton.IsChecked = $true }; break }
|
||||||
|
"Winget" { if ($sync.WingetRadioButton) { $sync.WingetRadioButton.IsChecked = $true }; break }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($PARAM_OFFLINE) {
|
||||||
|
foreach ($name in "WPFInstall", "WPFUninstall", "WPFInstallUpgrade", "WPFGetInstalled") {
|
||||||
|
if ($sync.$name) { $sync.$name.IsEnabled = $false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +1,34 @@
|
|||||||
function Initialize-WinUtilRunspacePool {
|
function Initialize-WinUtilRunspacePool {
|
||||||
if ($sync.runspace -and $sync.runspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) {
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Opens the shared worker pool that Start-WinUtilJob runs job bodies in
|
||||||
|
#>
|
||||||
|
|
||||||
|
$poolLock = Get-WinUtilRunspacePoolLock
|
||||||
|
[System.Threading.Monitor]::Enter($poolLock)
|
||||||
|
try {
|
||||||
|
if ($sync.runspace -and $sync.runspace.RunspacePoolStateInfo.State -eq [System.Management.Automation.Runspaces.RunspacePoolState]::Opened) {
|
||||||
|
return $sync.runspace
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sync.runspace) {
|
||||||
|
# A replacement, not a shutdown
|
||||||
|
Close-WinUtilRunspacePool -Recycle
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set the maximum number of threads for the RunspacePool to the number of threads on the machine.
|
||||||
|
$maxthreads = [Math]::Max([int]$env:NUMBER_OF_PROCESSORS, 1)
|
||||||
|
|
||||||
|
$sync.runspace = [runspacefactory]::CreateRunspacePool(
|
||||||
|
1, # Minimum thread count
|
||||||
|
$maxthreads, # Maximum thread count
|
||||||
|
(New-WinUtilSessionState), # Initial session state
|
||||||
|
$Host # Machine to create runspaces on
|
||||||
|
)
|
||||||
|
|
||||||
|
$sync.runspace.Open()
|
||||||
return $sync.runspace
|
return $sync.runspace
|
||||||
|
} finally {
|
||||||
|
[System.Threading.Monitor]::Exit($poolLock)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($sync.runspace) {
|
|
||||||
Close-WinUtilRunspacePool
|
|
||||||
}
|
|
||||||
|
|
||||||
# Set the maximum number of threads for the RunspacePool to the number of threads on the machine.
|
|
||||||
$maxthreads = [Math]::Max([int]$env:NUMBER_OF_PROCESSORS, 1)
|
|
||||||
|
|
||||||
# Create a new session state for parsing variables into our runspace.
|
|
||||||
$hashVars = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'sync', $sync, $null
|
|
||||||
$offlineVar = New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList 'PARAM_OFFLINE', $PARAM_OFFLINE, $null
|
|
||||||
$initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
|
|
||||||
|
|
||||||
$initialSessionState.Variables.Add($hashVars)
|
|
||||||
$initialSessionState.Variables.Add($offlineVar)
|
|
||||||
|
|
||||||
# Get every WinUtil/WPF function and add it to the session state.
|
|
||||||
$functions = Get-ChildItem function:\ | Where-Object { $_.Name -imatch 'winutil|WPF' }
|
|
||||||
foreach ($function in $functions) {
|
|
||||||
$functionDefinition = Get-Content function:\$($function.Name)
|
|
||||||
$functionEntry = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function.Name, $functionDefinition
|
|
||||||
$initialSessionState.Commands.Add($functionEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
$sync.runspace = [runspacefactory]::CreateRunspacePool(
|
|
||||||
1, # Minimum thread count
|
|
||||||
$maxthreads, # Maximum thread count
|
|
||||||
$initialSessionState, # Initial session state
|
|
||||||
$Host # Machine to create runspaces on
|
|
||||||
)
|
|
||||||
|
|
||||||
$sync.runspace.Open()
|
|
||||||
return $sync.runspace
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
function Initialize-WinUtilTabContent {
|
function Initialize-WinUtilTabContent {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory = $true)]
|
[Parameter(Mandatory = $true)]
|
||||||
[string]$TabName
|
[string]$TabName,
|
||||||
|
|
||||||
|
# Build in batches, letting the interface answer in between. Used by the warmup, which
|
||||||
|
# nobody is waiting on. A tab the user just clicked is built in one go.
|
||||||
|
[switch]$Yield
|
||||||
)
|
)
|
||||||
|
|
||||||
if ($null -eq $sync.InitializedTabs) {
|
if ($null -eq $sync.InitializedTabs) {
|
||||||
@@ -12,30 +16,42 @@ function Initialize-WinUtilTabContent {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch ($TabName) {
|
# Claimed before building, not after: a yielding build lets a click through, and that click
|
||||||
"Install" {
|
# would otherwise start building the same tab a second time.
|
||||||
Initialize-WPFUI -targetGridName "appscategory"
|
|
||||||
|
|
||||||
Initialize-WPFUI -targetGridName "appspanel"
|
|
||||||
}
|
|
||||||
"Tweaks" {
|
|
||||||
Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2
|
|
||||||
}
|
|
||||||
"Config" {
|
|
||||||
Invoke-WPFUIElements -configVariable $sync.configs.feature -targetGridName "featurespanel" -columncount 2
|
|
||||||
}
|
|
||||||
"AppX" {
|
|
||||||
Invoke-WPFUIElements -configVariable $sync.configs.appx -targetGridName "appxpanel" -columncount 2
|
|
||||||
}
|
|
||||||
"Win11ISO" {
|
|
||||||
if ($sync.Form -and $sync.Form.Dispatcher) {
|
|
||||||
$sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$sync.InitializedTabs[$TabName] = $true
|
$sync.InitializedTabs[$TabName] = $true
|
||||||
|
|
||||||
# Sync freshly built controls to any selections already in $sync.selected* (import/preset).
|
try {
|
||||||
Reset-WPFCheckBoxes -doToggles $true
|
switch ($TabName) {
|
||||||
|
"Install" {
|
||||||
|
Measure-WinUtilStep -Scope "UI" -Name "Install tab: category area" -ScriptBlock {
|
||||||
|
Initialize-WPFUI -targetGridName "appscategory"
|
||||||
|
}
|
||||||
|
Measure-WinUtilStep -Scope "UI" -Name "Install tab: app area" -ScriptBlock {
|
||||||
|
Initialize-WPFUI -targetGridName "appspanel"
|
||||||
|
}
|
||||||
|
Initialize-WinUtilInstallTabControls
|
||||||
|
}
|
||||||
|
"Tweaks" {
|
||||||
|
Invoke-WPFUIElements -configVariable $sync.configs.tweaks -targetGridName "tweakspanel" -columncount 2 -Yield:$Yield
|
||||||
|
}
|
||||||
|
"Config" {
|
||||||
|
Invoke-WPFUIElements -configVariable $sync.configs.feature -targetGridName "featurespanel" -columncount 2 -Yield:$Yield
|
||||||
|
}
|
||||||
|
"AppX" {
|
||||||
|
Invoke-WPFUIElements -configVariable $sync.configs.appx -targetGridName "appxpanel" -columncount 2 -Yield:$Yield
|
||||||
|
}
|
||||||
|
"Win11ISO" {
|
||||||
|
if (Test-WinUtilUIAlive) {
|
||||||
|
$sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{ Invoke-WinUtilISOCheckExistingWork }) | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Controls built just now start unchecked, so anything already chosen by an import or a
|
||||||
|
# preset has to be applied to them once they exist
|
||||||
|
Reset-WPFCheckBoxes -doToggles $true
|
||||||
|
} catch {
|
||||||
|
# A half built tab must be allowed to rebuild rather than staying empty forever
|
||||||
|
$sync.InitializedTabs[$TabName] = $false
|
||||||
|
throw
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,30 @@ function Initialize-WinUtilTaskbarOverlayAssets {
|
|||||||
[bool]$IncludeStatusAssets = $true
|
[bool]$IncludeStatusAssets = $true
|
||||||
)
|
)
|
||||||
|
|
||||||
if ($IncludeLogo -and -not $sync["logorender"]) {
|
[System.Threading.Monitor]::Enter($sync.SyncRoot)
|
||||||
$sync["logorender"] = (Invoke-WinUtilAssets -Type "Logo" -Size 90 -Render)
|
try {
|
||||||
|
if ($null -eq $sync.AssetRenderLock) {
|
||||||
|
$sync.AssetRenderLock = [object]::new()
|
||||||
|
}
|
||||||
|
$assetRenderLock = $sync.AssetRenderLock
|
||||||
|
} finally {
|
||||||
|
[System.Threading.Monitor]::Exit($sync.SyncRoot)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($IncludeStatusAssets -and -not $sync["checkmarkrender"]) {
|
[System.Threading.Monitor]::Enter($assetRenderLock)
|
||||||
$sync["checkmarkrender"] = (Invoke-WinUtilAssets -Type "checkmark" -Size 512 -Render)
|
try {
|
||||||
}
|
if ($IncludeLogo -and -not $sync["logorender"]) {
|
||||||
|
$sync["logorender"] = (Invoke-WinUtilAssets -Type "Logo" -Size 90 -Render)
|
||||||
|
}
|
||||||
|
|
||||||
if ($IncludeStatusAssets -and -not $sync["warningrender"]) {
|
if ($IncludeStatusAssets -and -not $sync["checkmarkrender"]) {
|
||||||
$sync["warningrender"] = (Invoke-WinUtilAssets -Type "warning" -Size 512 -Render)
|
$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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,14 @@ function Install-WinUtilAPPX {
|
|||||||
$manifestPath = ($manifestOutput | Select-Object -Last 1).ToString().Trim()
|
$manifestPath = ($manifestOutput | Select-Object -Last 1).ToString().Trim()
|
||||||
if (-not [string]::IsNullOrWhiteSpace($manifestPath)) {
|
if (-not [string]::IsNullOrWhiteSpace($manifestPath)) {
|
||||||
Write-WinUtilLog -Component "AppX" -Message "Registered local AppX manifest for $Name`: $manifestPath"
|
Write-WinUtilLog -Component "AppX" -Message "Registered local AppX manifest for $Name`: $manifestPath"
|
||||||
return
|
return [pscustomobject]@{
|
||||||
|
Package = $Name
|
||||||
|
Manager = "appx"
|
||||||
|
Action = "Install"
|
||||||
|
ExitCode = 0
|
||||||
|
Outcome = "Succeeded"
|
||||||
|
Detail = "registered local manifest"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,10 +77,12 @@ function Install-WinUtilAPPX {
|
|||||||
if ([string]::IsNullOrWhiteSpace($StoreId)) {
|
if ([string]::IsNullOrWhiteSpace($StoreId)) {
|
||||||
$errorMessage = "Unable to install $Name because no local manifest or Microsoft Store ID is available."
|
$errorMessage = "Unable to install $Name because no local manifest or Microsoft Store ID is available."
|
||||||
Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message $errorMessage
|
Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message $errorMessage
|
||||||
throw $errorMessage
|
$exception = [System.InvalidOperationException]::new($errorMessage)
|
||||||
|
$exception.Data["WinUtilErrorReported"] = $true
|
||||||
|
throw $exception
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-WinUtilLog -Component "AppX" -Message "No usable local manifest found for $Name. Installing Microsoft Store product $StoreId."
|
Write-WinUtilLog -Component "AppX" -Message "No usable local manifest found for $Name. Installing Microsoft Store product $StoreId."
|
||||||
Install-WinUtilWinget
|
$null = Install-WinUtilWinget
|
||||||
Install-WinUtilProgramWinget -Action Install -Programs @("msstore:$StoreId")
|
Install-WinUtilProgramWinget -Action Install -Programs @("msstore:$StoreId")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,39 @@
|
|||||||
function Install-WinUtilChoco {
|
function Install-WinUtilChoco {
|
||||||
if (-not (Get-Command -Name choco)) {
|
<#
|
||||||
Write-Host "Chocolatey is not installed. Installing now..."
|
.SYNOPSIS
|
||||||
$installScript = Invoke-WebRequest -Uri https://community.chocolatey.org/install.ps1 -UseBasicParsing
|
Installs Chocolatey if it is not already present
|
||||||
Invoke-Command -ScriptBlock ([scriptblock]::Create($installScript.Content))
|
#>
|
||||||
|
|
||||||
|
if (Get-Command -Name choco -ErrorAction SilentlyContinue) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Write-WinUtilLog -Component "Package" -Message "Chocolatey is not installed, installing it now."
|
||||||
|
Step-WinUtilJob -Status "Installing Chocolatey" -State "Indeterminate"
|
||||||
|
|
||||||
|
# Windows PowerShell 5.1 can negotiate a protocol the site refuses, which the official
|
||||||
|
# bootstrap sets explicitly for the same reason
|
||||||
|
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::Tls12
|
||||||
|
$installScript = Invoke-WebRequest -Uri https://community.chocolatey.org/install.ps1 -UseBasicParsing -TimeoutSec 60
|
||||||
|
Invoke-Command -ScriptBlock ([scriptblock]::Create($installScript.Content))
|
||||||
|
|
||||||
|
# The installer extends PATH for new processes, which this one is not. Appended rather than
|
||||||
|
# replaced: overwriting drops whatever this process added earlier in the session, and a
|
||||||
|
# later step looking for that tool would no longer find it.
|
||||||
|
$existing = $env:PATH -split ';' | Where-Object { $_ }
|
||||||
|
$persisted = @(
|
||||||
|
[System.Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||||
|
[System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||||
|
) -join ';' -split ';' | Where-Object { $_ }
|
||||||
|
|
||||||
|
$missing = $persisted | Where-Object { $existing -notcontains $_ }
|
||||||
|
if ($missing) {
|
||||||
|
$env:PATH = (@($existing) + @($missing)) -join ';'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Get-Command -Name choco -ErrorAction SilentlyContinue)) {
|
||||||
|
throw "Chocolatey was installed but choco is still not on PATH."
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-WinUtilLog -Component "Package" -Message "Chocolatey installed."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,116 @@
|
|||||||
function Install-WinUtilProgramChoco {
|
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 (
|
param (
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[ValidateSet("Install", "Uninstall")]
|
[ValidateSet("Install", "Uninstall", "Upgrade")]
|
||||||
[string]$Action,
|
[string]$Action,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string[]]$Programs
|
[string[]]$Programs,
|
||||||
|
|
||||||
|
[int]$ProgressBase = 0,
|
||||||
|
|
||||||
|
[int]$ProgressSpan = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
if ($Action -eq 'Install') {
|
# Chocolatey reports "nothing needed doing" and "it worked, now reboot" through exit codes
|
||||||
$arguments = "install $Programs -y"
|
# rather than as failures
|
||||||
} else {
|
$rebootCodes = @{
|
||||||
$arguments = "uninstall $Programs -y"
|
1641 = "installed, the installer started a restart"
|
||||||
|
3010 = "installed, a restart is needed to finish"
|
||||||
}
|
}
|
||||||
|
$nothingToDo = @{
|
||||||
|
2 = "nothing to do"
|
||||||
|
}
|
||||||
|
$verb = $Action.ToLowerInvariant()
|
||||||
|
$chocoAvailable = $null -ne (Get-Command choco -ErrorAction SilentlyContinue)
|
||||||
|
|
||||||
Write-WinUtilLog -Component "Package" -Message "$Action choco package(s): $($Programs -join ', ')"
|
$packages = @($Programs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||||
$process = Start-Process -FilePath choco -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$total = $packages.Count
|
||||||
Write-WinUtilLog -Component "Package" -Message "$Action choco package(s) completed: $($Programs -join ', ') (exit code: $($process.ExitCode))"
|
$index = 0
|
||||||
|
|
||||||
|
foreach ($program in $packages) {
|
||||||
|
$index++
|
||||||
|
if ($ProgressSpan -gt 0 -and $total -gt 0) {
|
||||||
|
$percent = $ProgressBase + [int]((($index - 1) / $total) * $ProgressSpan)
|
||||||
|
Step-WinUtilJob -Status "$Action $program ($index/$total)" -Percent $percent
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-WinUtilLog -Component "Package" -Message "$Action choco package: $program"
|
||||||
|
|
||||||
|
# --no-progress stops choco redrawing a percentage line that only makes sense on a
|
||||||
|
# console nobody is watching
|
||||||
|
$arguments = @($verb, $program, "-y", "--no-progress")
|
||||||
|
# Each worker runspace has its own global scope. Reset the native-command result there so
|
||||||
|
# command-not-found cannot inherit a successful code from earlier work in the same pool.
|
||||||
|
$global:LASTEXITCODE = $null
|
||||||
|
if (-not $chocoAvailable) {
|
||||||
|
$output = "Chocolatey is not installed or is not available on PATH."
|
||||||
|
$exitCode = -1
|
||||||
|
} else {
|
||||||
|
$output = & choco @arguments 2>&1
|
||||||
|
$exitCode = if ($null -eq $global:LASTEXITCODE) { -1 } else { [int]$global:LASTEXITCODE }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($exitCode -eq 0) {
|
||||||
|
$outcome = "Succeeded"
|
||||||
|
$detail = "exit code 0"
|
||||||
|
} elseif ($rebootCodes.ContainsKey($exitCode)) {
|
||||||
|
$outcome = "Succeeded"
|
||||||
|
$detail = $rebootCodes[$exitCode]
|
||||||
|
} elseif ($nothingToDo.ContainsKey($exitCode)) {
|
||||||
|
$outcome = "Skipped"
|
||||||
|
$detail = $nothingToDo[$exitCode]
|
||||||
|
} else {
|
||||||
|
$outcome = "Failed"
|
||||||
|
$detail = if ($exitCode -eq -1) { "Chocolatey command did not start" } else { "exit code $exitCode" }
|
||||||
|
}
|
||||||
|
|
||||||
|
$level = if ($outcome -eq "Failed") { "ERROR" } else { "INFO" }
|
||||||
|
Write-WinUtilLog -Level $level -Component "Package" -Message "$Action choco package $($outcome.ToLowerInvariant()): $program ($detail)"
|
||||||
|
|
||||||
|
if ($outcome -eq "Failed") {
|
||||||
|
# The reason is somewhere in choco's output, and without it the log says only that
|
||||||
|
# a number came back
|
||||||
|
foreach ($line in @($output | Select-Object -Last 15)) {
|
||||||
|
$text = ([string]$line).Trim()
|
||||||
|
if ($text) { Write-WinUtilLog -Level "WARN" -Component "Package" -Detail -Message $text }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($ProgressSpan -gt 0 -and $total -gt 0) {
|
||||||
|
Step-WinUtilJob -Status "$Action $program ($index/$total)" -Percent ($ProgressBase + [int](($index / $total) * $ProgressSpan))
|
||||||
|
}
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
Package = $program
|
||||||
|
Manager = "choco"
|
||||||
|
Action = $Action
|
||||||
|
ExitCode = $exitCode
|
||||||
|
Outcome = $outcome
|
||||||
|
Detail = $detail
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,116 @@
|
|||||||
Function Install-WinUtilProgramWinget {
|
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 (
|
param (
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[ValidateSet("Install", "Uninstall")]
|
[ValidateSet("Install", "Uninstall", "Upgrade")]
|
||||||
[string]$Action,
|
[string]$Action,
|
||||||
|
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string[]]$Programs
|
[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) {
|
foreach ($program in $Programs) {
|
||||||
if ([string]::IsNullOrWhiteSpace($program) -or $program -eq "na") {
|
if ([string]::IsNullOrWhiteSpace($program) -or $program -eq "na") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
$source = "winget"
|
$upgradeAll = $Action -eq "Upgrade" -and $program -eq "all"
|
||||||
if ($program.StartsWith("msstore:", [System.StringComparison]::OrdinalIgnoreCase)) {
|
$source = if ($upgradeAll) { "all configured sources" } else { "winget" }
|
||||||
|
if (-not $upgradeAll -and $program.StartsWith("msstore:", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
$source = "msstore"
|
$source = "msstore"
|
||||||
$program = $program.Substring("msstore:".Length)
|
$program = $program.Substring("msstore:".Length)
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($Action -eq 'Install') {
|
Write-WinUtilLog -Component "Package" -Message "$Action winget package: $program (source: $source)"
|
||||||
$arguments = @("install", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--silent")
|
|
||||||
} else {
|
$outcome = "Failed"
|
||||||
$arguments = @("uninstall", "--id", $program, "--source", $source, "--silent")
|
$detail = "no result"
|
||||||
|
$exitCode = -1
|
||||||
|
|
||||||
|
$arguments = switch ($Action) {
|
||||||
|
"Uninstall" { @("uninstall", "--id", $program, "--source", $source, "--silent") }
|
||||||
|
# --include-unknown because the scan that found these ran with it: without it winget
|
||||||
|
# refuses every package whose installed version it could not read
|
||||||
|
"Upgrade" {
|
||||||
|
if ($upgradeAll) {
|
||||||
|
@("upgrade", "--all", "--accept-package-agreements", "--accept-source-agreements", "--include-unknown", "--silent")
|
||||||
|
} else {
|
||||||
|
@("upgrade", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--include-unknown", "--silent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default { @("install", "--id", $program, "--accept-package-agreements", "--accept-source-agreements", "--source", $source, "--silent") }
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-WinUtilLog -Component "Package" -Message "$Action winget package: $program (source: $source)"
|
|
||||||
$process = Start-Process -FilePath winget -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
$process = Start-Process -FilePath winget -ArgumentList $arguments -NoNewWindow -Wait -PassThru
|
||||||
Write-WinUtilLog -Component "Package" -Message "$Action winget package completed: $program (exit code: $($process.ExitCode))"
|
$exitCode = $process.ExitCode
|
||||||
|
|
||||||
|
if ($exitCode -eq 0) {
|
||||||
|
$outcome = "Succeeded"
|
||||||
|
$detail = "exit code 0"
|
||||||
|
} elseif ($rebootExitCodes.ContainsKey($exitCode)) {
|
||||||
|
$outcome = "Succeeded"
|
||||||
|
$detail = $rebootExitCodes[$exitCode]
|
||||||
|
} elseif ($nothingToDo.ContainsKey($exitCode)) {
|
||||||
|
$outcome = "Skipped"
|
||||||
|
$detail = $nothingToDo[$exitCode]
|
||||||
|
} elseif ($exitCode -eq $adminContextProhibited) {
|
||||||
|
$outcome = "Skipped"
|
||||||
|
$detail = switch ($Action) {
|
||||||
|
"Install" { "already installed for the current user; elevated WinUtil cannot update it" }
|
||||||
|
"Upgrade" { "not upgraded; installed for the current user and elevated WinUtil cannot modify it" }
|
||||||
|
"Uninstall" { "remains installed for the current user; elevated WinUtil cannot uninstall it" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$outcome = "Failed"
|
||||||
|
# The client module reports the same failure as a bare HRESULT, so the hex form and
|
||||||
|
# Microsoft's own list serve both paths
|
||||||
|
$detail = "WinGet reported 0x{0:X8}. See https://learn.microsoft.com/windows/package-manager/winget/returnCodes" -f $exitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
$level = if ($outcome -eq "Failed") { "ERROR" } else { "INFO" }
|
||||||
|
Write-WinUtilLog -Level $level -Component "Package" -Message "$Action winget package $($outcome.ToLowerInvariant()): $program ($detail)"
|
||||||
|
|
||||||
|
[pscustomobject]@{
|
||||||
|
Package = $program
|
||||||
|
Manager = "winget"
|
||||||
|
Action = $Action
|
||||||
|
ExitCode = $exitCode
|
||||||
|
Outcome = $outcome
|
||||||
|
Detail = $detail
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,21 @@ function Install-WinUtilWinget {
|
|||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
installs winGet if needed
|
installs winGet if needed
|
||||||
#>
|
#>
|
||||||
if ((Test-WinUtilPackageManager -winget) -eq "installed") {
|
param(
|
||||||
|
[switch]$Force
|
||||||
|
)
|
||||||
|
|
||||||
|
# The repair action needs Repair-WinGetPackageManager to run even when winget is detected,
|
||||||
|
# which is the case a broken installation presents
|
||||||
|
if (-not $Force -and (Test-WinUtilPackageManager -winget) -eq "installed") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "WinGet is not installed. Installing now..." -ForegroundColor Red
|
if ($Force) {
|
||||||
|
Write-Host "Repairing the WinGet installation..." -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Write-Host "WinGet is not installed. Installing now..." -ForegroundColor Red
|
||||||
|
}
|
||||||
|
|
||||||
Install-PackageProvider -Name NuGet -Force
|
Install-PackageProvider -Name NuGet -Force
|
||||||
Install-Module -Name Microsoft.WinGet.Client -Force
|
Install-Module -Name Microsoft.WinGet.Client -Force
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Invoke-WinUtilAssets {
|
|||||||
|
|
||||||
if ($render -and $null -ne $sync) {
|
if ($render -and $null -ne $sync) {
|
||||||
if ($null -eq $sync.RenderedAssetCache) {
|
if ($null -eq $sync.RenderedAssetCache) {
|
||||||
$sync.RenderedAssetCache = @{}
|
$sync.RenderedAssetCache = [Hashtable]::Synchronized(@{})
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheKey = "$(([string]$type).ToLowerInvariant())|$Size"
|
$cacheKey = "$(([string]$type).ToLowerInvariant())|$Size"
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
function Invoke-WinUtilCloseRequest {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Asks what to do about work that is still running when the window is closed
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
A half finished install or tweak run is not ended without asking. Either it finishes
|
||||||
|
without the window, reporting to the console and then exiting, or it is stopped and
|
||||||
|
everything closes now.
|
||||||
|
|
||||||
|
.PARAMETER RunningJob
|
||||||
|
The name of the job in flight, so the question names what is at stake.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$RunningJob
|
||||||
|
)
|
||||||
|
|
||||||
|
# The question carries the meaning rather than naming buttons: Windows labels them in its own
|
||||||
|
# language, so "Yes" in the text would not match a button reading "Ja".
|
||||||
|
$answer = Show-WinUtilMessage -Button "YesNoCancel" -Icon "Warning" -Title "$RunningJob is still running" -Message @"
|
||||||
|
$RunningJob has not finished yet.
|
||||||
|
|
||||||
|
Close the window and let it finish in the console?
|
||||||
|
|
||||||
|
WinUtil will exit on its own once it is done. If you do not, it will be
|
||||||
|
stopped and everything closes now. Cancel keeps WinUtil open.
|
||||||
|
"@
|
||||||
|
|
||||||
|
switch ("$answer") {
|
||||||
|
"Yes" {
|
||||||
|
Write-WinUtilLog -Component "UI" -Message "Close requested: closing the window, $RunningJob continues in the console."
|
||||||
|
$sync.FinishInConsole = $true
|
||||||
|
$sync.ForceClose = $true
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "WinUtil's window is closed. $RunningJob is still running here, and this window will close when it finishes." -ForegroundColor Cyan
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Posted rather than closed from inside the handler that is already unwinding
|
||||||
|
Request-WinUtilWindowClose
|
||||||
|
}
|
||||||
|
"No" {
|
||||||
|
Write-WinUtilLog -Component "UI" -Message "Close requested: stopping $RunningJob."
|
||||||
|
Step-WinUtilJob -Status "Stopping $RunningJob" -State "Indeterminate"
|
||||||
|
$sync.ForceClose = $true
|
||||||
|
|
||||||
|
# Close the window first. The main thread owns pool shutdown after ShowDialog
|
||||||
|
# returns, so the worker can finish its UI-dispatching finally block before the UI
|
||||||
|
# runspace is disposed. Waiting for it here would deadlock the dispatcher.
|
||||||
|
Request-WinUtilWindowClose
|
||||||
|
}
|
||||||
|
default {
|
||||||
|
Write-WinUtilLog -Component "UI" -Message "Close cancelled, $RunningJob is still running."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Request-WinUtilWindowClose {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Closes the window from outside the handler that is currently cancelling the close
|
||||||
|
|
||||||
|
#>
|
||||||
|
if (-not (Test-WinUtilUIAlive)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$sync.Form.Dispatcher.BeginInvoke([System.Windows.Threading.DispatcherPriority]::Background, [action]{
|
||||||
|
$sync.Form.Close()
|
||||||
|
}) | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-WinUtilRemainingWork {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Waits for work that outlived the window, reporting to the console
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Runs on the main thread once the interface has gone. The job is still on the worker
|
||||||
|
pool and keeps logging, so this only waits and keeps the wait visible.
|
||||||
|
|
||||||
|
.PARAMETER TimeoutMinutes
|
||||||
|
Upper bound, so a worker that never returns cannot keep the process alive.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
# Double rather than int: an int silently truncates a fractional value to zero, which
|
||||||
|
# turns the bound into "do not wait at all"
|
||||||
|
[double]$TimeoutMinutes = 120
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not $sync.FinishInConsole -or -not $sync.ActiveJob) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = $sync.ActiveJob
|
||||||
|
Write-WinUtilLog -Component "UI" -Message "Window closed, waiting for $job to finish."
|
||||||
|
Write-Host "Waiting for $job to finish..." -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$clock = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while ($sync.ActiveJob -and $clock.Elapsed.TotalMinutes -lt $TimeoutMinutes) {
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
|
||||||
|
# The job's last progress line is still open, so anything after it needs a fresh line
|
||||||
|
Complete-WinUtilConsoleProgress
|
||||||
|
|
||||||
|
if ($sync.ActiveJob) {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "$job did not finish within $TimeoutMinutes minutes, exiting anyway."
|
||||||
|
Write-Host "$job is taking longer than $TimeoutMinutes minutes. Exiting." -ForegroundColor Yellow
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-WinUtilLog -Component "UI" -Message "$job finished after the window closed, in $([int]$clock.Elapsed.TotalSeconds)s."
|
||||||
|
Write-Host "$job finished. Closing." -ForegroundColor Green
|
||||||
|
}
|
||||||
@@ -11,7 +11,9 @@ Function Invoke-WinUtilCurrentSystem {
|
|||||||
#>
|
#>
|
||||||
|
|
||||||
param(
|
param(
|
||||||
$CheckBox
|
$CheckBox,
|
||||||
|
[switch]$BypassToggleStatusCache,
|
||||||
|
[switch]$StopOnReadError
|
||||||
)
|
)
|
||||||
if ($CheckBox -eq "choco") {
|
if ($CheckBox -eq "choco") {
|
||||||
$apps = (choco list | Select-String -Pattern "^\S+").Matches.Value
|
$apps = (choco list | Select-String -Pattern "^\S+").Matches.Value
|
||||||
@@ -52,6 +54,7 @@ Function Invoke-WinUtilCurrentSystem {
|
|||||||
if ($CheckBox -eq "tweaks") {
|
if ($CheckBox -eq "tweaks") {
|
||||||
|
|
||||||
if (!(Test-Path 'HKU:\')) {$null = (New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS)}
|
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 {
|
$sync.configs.tweaks | Get-Member -MemberType NoteProperty | ForEach-Object {
|
||||||
|
|
||||||
@@ -65,7 +68,9 @@ Function Invoke-WinUtilCurrentSystem {
|
|||||||
$Values = @()
|
$Values = @()
|
||||||
|
|
||||||
if ($entryType -eq "Toggle") {
|
if ($entryType -eq "Toggle") {
|
||||||
if (-not (Get-WinUtilToggleStatus $Config)) {
|
if (-not (Get-WinUtilToggleStatus $Config `
|
||||||
|
-BypassCache:$BypassToggleStatusCache `
|
||||||
|
-StopOnReadError:$StopOnReadError)) {
|
||||||
$values += $False
|
$values += $False
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -77,8 +82,12 @@ Function Invoke-WinUtilCurrentSystem {
|
|||||||
$registryTotal++
|
$registryTotal++
|
||||||
$regstate = $null
|
$regstate = $null
|
||||||
|
|
||||||
if (Test-Path $tweak.Path) {
|
if (Test-Path $tweak.Path -ErrorAction $readErrorAction) {
|
||||||
$regstate = Get-ItemProperty -Name $tweak.Name -Path $tweak.Path -ErrorAction SilentlyContinue | Select-Object -ExpandProperty $($tweak.Name)
|
if ($StopOnReadError) {
|
||||||
|
$regstate = (Get-ItemProperty -Path $tweak.Path -ErrorAction Stop).$($tweak.Name)
|
||||||
|
} else {
|
||||||
|
$regstate = Get-ItemProperty -Name $tweak.Name -Path $tweak.Path -ErrorAction SilentlyContinue | Select-Object -ExpandProperty $($tweak.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($null -eq $regstate) {
|
if ($null -eq $regstate) {
|
||||||
@@ -108,7 +117,17 @@ Function Invoke-WinUtilCurrentSystem {
|
|||||||
|
|
||||||
Foreach ($tweaks in $serviceKeys) {
|
Foreach ($tweaks in $serviceKeys) {
|
||||||
Foreach ($tweak in $tweaks) {
|
Foreach ($tweak in $tweaks) {
|
||||||
$Service = Get-Service -Name $tweak.Name
|
try {
|
||||||
|
$Service = Get-Service -Name $tweak.Name -ErrorAction $readErrorAction
|
||||||
|
} catch {
|
||||||
|
if ($StopOnReadError -and $_.FullyQualifiedErrorId -like "NoServiceFoundForGivenName*") {
|
||||||
|
# A removed optional service means this tweak is not applied; it does
|
||||||
|
# not make the registry and service state for every other tweak unknown.
|
||||||
|
$values += $False
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
|
||||||
if ($Service) {
|
if ($Service) {
|
||||||
$actualValue = $Service.StartType
|
$actualValue = $Service.StartType
|
||||||
@@ -116,6 +135,8 @@ Function Invoke-WinUtilCurrentSystem {
|
|||||||
if ($expectedValue -ne $actualValue) {
|
if ($expectedValue -ne $actualValue) {
|
||||||
$values += $False
|
$values += $False
|
||||||
}
|
}
|
||||||
|
} elseif ($StopOnReadError) {
|
||||||
|
$values += $False
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ function Invoke-WinUtilExplorerUpdate {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ($action -eq "refresh") {
|
if ($action -eq "refresh") {
|
||||||
Invoke-WPFRunspace -ScriptBlock {
|
# The handle is of no use to the caller, and leaving it in the pipeline puts it into
|
||||||
|
# whatever result the calling workflow returns
|
||||||
|
$null = Invoke-WPFRunspace -ScriptBlock {
|
||||||
# Define the Win32 type only if it doesn't exist
|
# Define the Win32 type only if it doesn't exist
|
||||||
if (-not ([System.Management.Automation.PSTypeName]'Win32').Type) {
|
if (-not ([System.Management.Automation.PSTypeName]'Win32').Type) {
|
||||||
Add-Type -TypeDefinition @"
|
Add-Type -TypeDefinition @"
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ function Invoke-WinUtilFontScaling {
|
|||||||
"FontSize",
|
"FontSize",
|
||||||
"ButtonFontSize",
|
"ButtonFontSize",
|
||||||
"HeaderFontSize",
|
"HeaderFontSize",
|
||||||
|
"Win11StepTitleFontSize",
|
||||||
|
"Win11StepHeroFontSize",
|
||||||
"TabButtonFontSize",
|
"TabButtonFontSize",
|
||||||
"ConfigTabButtonFontSize",
|
"ConfigTabButtonFontSize",
|
||||||
"IconFontSize",
|
"IconFontSize",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,8 @@ function Invoke-WinUtilISOScript {
|
|||||||
.DESCRIPTION
|
.DESCRIPTION
|
||||||
Stages WinUtil's AppX removal, registry tweaks, and scheduled-task cleanup
|
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
|
in the answer file for first logon, writes sources\ei.cfg for the selected
|
||||||
edition, and optionally adds current-system drivers to one install.wim index.
|
edition, and optionally adds current-system drivers to boot.wim index 2 and
|
||||||
|
one install.wim index.
|
||||||
|
|
||||||
.PARAMETER ISOContentsDir
|
.PARAMETER ISOContentsDir
|
||||||
Root directory of the copied ISO contents.
|
Root directory of the copied ISO contents.
|
||||||
@@ -25,6 +26,10 @@ function Invoke-WinUtilISOScript {
|
|||||||
|
|
||||||
.PARAMETER Log
|
.PARAMETER Log
|
||||||
Optional ScriptBlock for progress/status logging. Receives a single [string] argument.
|
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 (
|
param (
|
||||||
[Parameter(Mandatory)][string]$ISOContentsDir,
|
[Parameter(Mandatory)][string]$ISOContentsDir,
|
||||||
@@ -33,7 +38,8 @@ function Invoke-WinUtilISOScript {
|
|||||||
[string]$InstallEditionId = "",
|
[string]$InstallEditionId = "",
|
||||||
[string]$InstallImagePath = "",
|
[string]$InstallImagePath = "",
|
||||||
[int]$InstallImageIndex = 1,
|
[int]$InstallImageIndex = 1,
|
||||||
[scriptblock]$Log = { param($m) Write-Output $m }
|
[scriptblock]$Log = { param($m) Write-Output $m },
|
||||||
|
[ref]$DriversInjected = [ref]$false
|
||||||
)
|
)
|
||||||
|
|
||||||
function Add-WinUtilISOStagedDrivers {
|
function Add-WinUtilISOStagedDrivers {
|
||||||
@@ -41,34 +47,14 @@ function Invoke-WinUtilISOScript {
|
|||||||
[Parameter(Mandatory)][string]$ContentRoot,
|
[Parameter(Mandatory)][string]$ContentRoot,
|
||||||
[Parameter(Mandatory)][string]$InstallImagePath,
|
[Parameter(Mandatory)][string]$InstallImagePath,
|
||||||
[Parameter(Mandatory)][int]$InstallImageIndex,
|
[Parameter(Mandatory)][int]$InstallImageIndex,
|
||||||
[scriptblock]$Logger
|
[scriptblock]$Logger,
|
||||||
|
[ref]$DriversInjected = [ref]$false
|
||||||
)
|
)
|
||||||
|
$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 {
|
function Test-WinUtilISOStorageDriver {
|
||||||
param ([Parameter(Mandatory)][System.IO.FileInfo]$InfFile)
|
param ([Parameter(Mandatory)][System.IO.FileInfo]$InfFile)
|
||||||
|
|
||||||
if ($InfFile.BaseName -match '(?i)(iaahci|iastor|vmd|irst|rst)') {
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return (Get-Content -LiteralPath $InfFile.FullName -Raw -ErrorAction Stop) -match '(?im)^\s*Class\s*=\s*(SCSIAdapter|HDC)\s*(?:;.*)?$'
|
return (Get-Content -LiteralPath $InfFile.FullName -Raw -ErrorAction Stop) -match '(?im)^\s*Class\s*=\s*(SCSIAdapter|HDC)\s*(?:;.*)?$'
|
||||||
} catch {
|
} catch {
|
||||||
@@ -77,6 +63,143 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
$knownExitCode = @{
|
||||||
|
112 = "Disk is full"
|
||||||
|
5 = "Access denied"
|
||||||
|
2 = "File not found"
|
||||||
|
3 = "Path not found"
|
||||||
|
87 = "Invalid parameter"
|
||||||
|
1168 = "Element not found"
|
||||||
|
1392 = "File or directory is corrupted"
|
||||||
|
32 = "File in use / sharing violation"
|
||||||
|
21 = "Device not ready"
|
||||||
|
1460 = "Operation timed out"
|
||||||
|
1223 = "Operation cancelled by user"
|
||||||
|
50 = "Request not supported"
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-WinUtilISODism {
|
function Invoke-WinUtilISODism {
|
||||||
param (
|
param (
|
||||||
[Parameter(Mandatory)][string[]]$Arguments,
|
[Parameter(Mandatory)][string[]]$Arguments,
|
||||||
@@ -91,7 +214,11 @@ function Invoke-WinUtilISOScript {
|
|||||||
& $Logger " dism[$Operation]: $line"
|
& $Logger " dism[$Operation]: $line"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw "DISM $Operation failed with exit code $exitCode."
|
if ($knownExitCode.ContainsKey($exitCode)) {
|
||||||
|
throw "DISM $Operation failed with exit code $exitCode ($($knownExitCode[$exitCode]))."
|
||||||
|
} else {
|
||||||
|
throw "DISM $Operation failed with exit code $exitCode."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ($Operation -ne 'metadata') {
|
if ($Operation -ne 'metadata') {
|
||||||
& $Logger "DISM $Operation completed."
|
& $Logger "DISM $Operation completed."
|
||||||
@@ -138,6 +265,93 @@ function Invoke-WinUtilISOScript {
|
|||||||
return @(& dism.exe /English /Get-MountedImageInfo 2>$null) -match [regex]::Escape($Path)
|
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') {
|
if ([IO.Path]::GetExtension($InstallImagePath) -ne '.wim') {
|
||||||
throw 'Current-system driver injection requires install.wim; install.esd cannot be serviced in place.'
|
throw 'Current-system driver injection requires install.wim; install.esd cannot be serviced in place.'
|
||||||
}
|
}
|
||||||
@@ -151,77 +365,94 @@ function Invoke-WinUtilISOScript {
|
|||||||
$driverExportRoot = Join-Path $env:TEMP "WinUtil_DriverExport_$(Get-Date -Format 'yyyyMMdd_HHmmss')_$(([guid]::NewGuid()).ToString('N').Substring(0, 8))"
|
$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'
|
$mountDir = Join-Path (Split-Path -Path $ContentRoot -Parent) 'wim_mount'
|
||||||
New-Item -Path $driverExportRoot -ItemType Directory -Force | Out-Null
|
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
|
$imageMounted = $false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
& $Logger "Exporting current system drivers before modifying install.wim..."
|
& $Logger "Exporting current system drivers before WIM driver injection..."
|
||||||
$dismLog = Join-Path $env:TEMP "WinUtil_DismDriverExport_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
|
$dismLog = Join-Path $env:TEMP "WinUtil_DismDriverExport_$(Get-Date -Format 'yyyyMMdd_HHmmss').log"
|
||||||
$dismProcess = Start-Process -FilePath "dism.exe" -ArgumentList "/online /export-driver /destination:`"$driverExportRoot`" /LogPath:`"$dismLog`"" -Wait -NoNewWindow -PassThru
|
Invoke-WinUtilISODism -Arguments @('/English', '/Online', '/Export-Driver', "/Destination:$driverExportRoot", "/LogPath:$dismLog") -Operation 'export-driver' | Out-Null
|
||||||
if ($dismProcess.ExitCode -ne 0) {
|
|
||||||
throw "dism.exe driver export failed with exit code $($dismProcess.ExitCode)."
|
|
||||||
}
|
|
||||||
|
|
||||||
$driverInfs = @(Get-ChildItem -Path $driverExportRoot -Filter '*.inf' -Recurse -File)
|
$driverInfs = @(Get-ChildItem -LiteralPath $driverExportRoot -Filter '*.inf' -Recurse -File)
|
||||||
if ($driverInfs.Count -eq 0) {
|
if ($driverInfs.Count -eq 0) {
|
||||||
throw 'DISM exported no driver INF files.'
|
throw 'DISM exported no driver INF files.'
|
||||||
}
|
}
|
||||||
$driverFolders = @($driverInfs | Group-Object { $_.Directory.FullName })
|
$driverFolders = @($driverInfs | Group-Object { $_.Directory.FullName })
|
||||||
$winpeDriverDir = Join-Path $ContentRoot '$WinpeDriver$'
|
$stagedDriverFolders = @(Select-WinUtilISOStagedDriverPackages -DriverFolderGroups $driverFolders -Logger $Logger)
|
||||||
$storageCount = 0
|
$metadataBefore = Get-WinUtilISOWimMetadata -ImagePath $InstallImagePath -Index $InstallImageIndex
|
||||||
$copyFailures = 0
|
Assert-WinUtilISOWimMetadata -Before $metadataBefore
|
||||||
|
|
||||||
foreach ($driverFolderGroup in $driverFolders) {
|
$excludedDriverFolderGroups = @($driverFolders | Where-Object { $_.Name -notin $stagedDriverFolders })
|
||||||
$driverFolder = [string]$driverFolderGroup.Name
|
foreach ($excludedDriverFolderGroup in $excludedDriverFolderGroups) {
|
||||||
$storageInfs = @($driverFolderGroup.Group | Where-Object { Test-WinUtilISOStorageDriver -InfFile $_ })
|
$excludedFolder = [string]$excludedDriverFolderGroup.Name
|
||||||
if ($storageInfs.Count -eq 0) {
|
$hasRetainedDescendant = [bool]@($stagedDriverFolders | Where-Object {
|
||||||
|
$_.StartsWith("$excludedFolder\", [System.StringComparison]::OrdinalIgnoreCase)
|
||||||
|
}).Count
|
||||||
|
if ($hasRetainedDescendant) {
|
||||||
|
try {
|
||||||
|
foreach ($excludedInf in $excludedDriverFolderGroup.Group) {
|
||||||
|
Remove-Item -LiteralPath $excludedInf.FullName -Force -ErrorAction Stop
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw "Failed to remove excluded driver INF files from package '$excludedFolder' before injection: $_"
|
||||||
|
}
|
||||||
|
|
||||||
|
& $Logger "Keeping excluded driver package directory '$excludedFolder' because it contains a retained nested package, after removing its excluded INF files."
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
New-Item -Path $winpeDriverDir -ItemType Directory -Force | Out-Null
|
Remove-Item -LiteralPath $excludedFolder -Recurse -Force -ErrorAction Stop
|
||||||
$winpeTarget = Copy-WinUtilISODriverFolder -Source $driverFolder -Destination $winpeDriverDir
|
|
||||||
$storageCount++
|
|
||||||
& $Logger "Staged boot-storage package '$driverFolder' for WinPE as '$winpeTarget'."
|
|
||||||
} catch {
|
} catch {
|
||||||
$copyFailures++
|
throw "Failed to remove excluded driver package '$excludedFolder' before injection: $_"
|
||||||
& $Logger "Warning: failed to stage boot-storage package '$driverFolder': $_"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($copyFailures -gt 0) {
|
& $Logger "Exported $($stagedDriverFolders.Count) of $($driverFolders.Count) driver packages ($($excludedDriverFolderGroups.Count) excluded)."
|
||||||
throw "Failed to stage $copyFailures boot-storage driver package folders."
|
|
||||||
|
# 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.'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& $Logger "Exported $($driverInfs.Count) driver INF files across $($driverFolders.Count) package folders; staged $storageCount boot-storage packages for WinPE."
|
& $Logger "Adding $($rootPackageFolders.Count) root driver packages to install.wim."
|
||||||
$metadataBefore = Get-WinUtilISOWimMetadata -ImagePath $InstallImagePath -Index $InstallImageIndex
|
$addedCount = Add-WinUtilISODriversToImage -ImagePath $InstallImagePath -ImageIndex $InstallImageIndex -MountDir $mountDir -DriverFolders $rootPackageFolders -ImageLabel 'install.wim' -ImageMounted $imageMountedRef
|
||||||
Assert-WinUtilISOWimMetadata -Before $metadataBefore
|
if ($addedCount -gt 0) {
|
||||||
|
$metadataAfter = Get-WinUtilISOWimMetadata -ImagePath $InstallImagePath -Index $InstallImageIndex
|
||||||
Set-ItemProperty -LiteralPath $InstallImagePath -Name IsReadOnly -Value $false
|
Assert-WinUtilISOWimMetadata -Before $metadataBefore -After $metadataAfter
|
||||||
New-Item -Path $mountDir -ItemType Directory -Force | Out-Null
|
& $Logger 'Driver injection complete; install.wim metadata validation passed.'
|
||||||
& $Logger "Mounting install.wim index $InstallImageIndex once for driver injection..."
|
$DriversInjected.Value = $true
|
||||||
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 {
|
} finally {
|
||||||
if ($imageMounted -or (Test-WinUtilISOMountedImage -Path $mountDir)) {
|
if ($imageMounted -or (Test-WinUtilISOMountedImage -Path $mountDir)) {
|
||||||
try {
|
try {
|
||||||
Invoke-WinUtilISODism -Arguments @('/English', '/Unmount-Image', "/MountDir:$mountDir", '/Discard') -Operation 'discard' | Out-Null
|
Invoke-WinUtilISODism -Arguments @('/English', '/Unmount-Image', "/MountDir:$mountDir", '/Discard') -Operation 'discard' | Out-Null
|
||||||
} catch {
|
} catch {
|
||||||
& $Logger "Warning: could not discard the failed install.wim mount: $_"
|
& $Logger "Warning: could not discard the failed WIM mount: $_"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Remove-Item -Path $mountDir -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $mountDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
Remove-Item -Path $driverExportRoot -Recurse -Force -ErrorAction SilentlyContinue
|
Remove-Item -LiteralPath $driverExportRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +557,6 @@ $appxList
|
|||||||
Set-WinUtilRegistryValue 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\ReserveManager' 'ShippedWithReserves' 'REG_DWORD' '0'
|
Set-WinUtilRegistryValue 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\ReserveManager' 'ShippedWithReserves' 'REG_DWORD' '0'
|
||||||
Set-WinUtilRegistryValue 'HKLM\SYSTEM\CurrentControlSet\Control\BitLocker' 'PreventDeviceEncryption' 'REG_DWORD' '1'
|
Set-WinUtilRegistryValue 'HKLM\SYSTEM\CurrentControlSet\Control\BitLocker' 'PreventDeviceEncryption' 'REG_DWORD' '1'
|
||||||
Set-WinUtilRegistryValue 'HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Chat' 'ChatIcon' 'REG_DWORD' '3'
|
Set-WinUtilRegistryValue 'HKLM\SOFTWARE\Policies\Microsoft\Windows\Windows Chat' 'ChatIcon' 'REG_DWORD' '3'
|
||||||
Set-WinUtilRegistryValue 'HKLM\SOFTWARE\Policies\Microsoft\Windows\OneDrive' 'DisableFileSyncNGSC' 'REG_DWORD' '1'
|
|
||||||
Set-WinUtilRegistryValue 'HKLM\SOFTWARE\Policies\Microsoft\Windows\DataCollection' 'AllowTelemetry' 'REG_DWORD' '0'
|
Set-WinUtilRegistryValue 'HKLM\SOFTWARE\Policies\Microsoft\Windows\DataCollection' 'AllowTelemetry' 'REG_DWORD' '0'
|
||||||
Set-WinUtilRegistryValue 'HKLM\SYSTEM\CurrentControlSet\Services\dmwappushservice' 'Start' 'REG_DWORD' '4'
|
Set-WinUtilRegistryValue 'HKLM\SYSTEM\CurrentControlSet\Services\dmwappushservice' 'Start' 'REG_DWORD' '4'
|
||||||
Set-WinUtilRegistryValue 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsCopilot' 'TurnOffWindowsCopilot' 'REG_DWORD' '1'
|
Set-WinUtilRegistryValue 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsCopilot' 'TurnOffWindowsCopilot' 'REG_DWORD' '1'
|
||||||
@@ -535,6 +765,6 @@ $appxList
|
|||||||
Write-WinUtilISOEditionConfig -ContentRoot $ISOContentsDir -EditionId $InstallEditionId -Logger $Log
|
Write-WinUtilISOEditionConfig -ContentRoot $ISOContentsDir -EditionId $InstallEditionId -Logger $Log
|
||||||
|
|
||||||
if ($InjectCurrentSystemDrivers) {
|
if ($InjectCurrentSystemDrivers) {
|
||||||
Add-WinUtilISOStagedDrivers -ContentRoot $ISOContentsDir -Logger $Log -InstallImagePath $InstallImagePath -InstallImageIndex $InstallImageIndex
|
Add-WinUtilISOStagedDrivers -ContentRoot $ISOContentsDir -Logger $Log -InstallImagePath $InstallImagePath -InstallImageIndex $InstallImageIndex -DriversInjected $DriversInjected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,25 +21,34 @@ function Invoke-WinUtilISORefreshUSBDrives {
|
|||||||
$sync["Win11ISOUSBDisks"] = $removable
|
$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 {
|
function Invoke-WinUtilISOWriteUSB {
|
||||||
$contentsDir = $sync["Win11ISOContentsDir"]
|
$contentsDir = $sync["Win11ISOContentsDir"]
|
||||||
$usbDisks = $sync["Win11ISOUSBDisks"]
|
$usbDisks = $sync["Win11ISOUSBDisks"]
|
||||||
|
|
||||||
if (-not $contentsDir -or -not (Test-Path $contentsDir)) {
|
if (-not $contentsDir -or -not (Test-Path $contentsDir)) {
|
||||||
[System.Windows.MessageBox]::Show("No modified ISO content found. Please complete Steps 1-3 first.", "Not Ready", "OK", "Warning")
|
Show-WinUtilMessage -Message "No modified ISO content found. Please run the modification step first." -Title "Not Ready" -Button "OK" -Icon "Warning" | Out-Null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
$installWim = Join-Path $contentsDir "sources\install.wim"
|
|
||||||
$installEsd = Join-Path $contentsDir "sources\install.esd"
|
$installEsd = Join-Path $contentsDir "sources\install.esd"
|
||||||
if (Test-Path $installEsd) {
|
if (Test-Path $installEsd) {
|
||||||
$installEsdFile = Get-Item $installEsd
|
$esdSizeBytes = (Get-Item $installEsd).Length
|
||||||
$esdSizeBytes = $installEsdFile.Length
|
|
||||||
$esdSizeMB = [math]::Ceiling($esdSizeBytes / 1MB)
|
|
||||||
if ($esdSizeBytes -ge 4GB) {
|
if ($esdSizeBytes -ge 4GB) {
|
||||||
[System.Windows.MessageBox]::Show(
|
$esdSizeMB = [math]::Ceiling($esdSizeBytes / 1MB)
|
||||||
"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.",
|
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
|
||||||
"USB Creation Not Supported", "OK", "Warning")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,95 +67,60 @@ function Invoke-WinUtilISOWriteUSB {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (-not $targetDisk) {
|
if (-not $targetDisk) {
|
||||||
[System.Windows.MessageBox]::Show("Please select a USB drive from the dropdown.", "No Drive Selected", "OK", "Warning")
|
Show-WinUtilMessage -Message "Please select a USB drive from the dropdown." -Title "No Drive Selected" -Button "OK" -Icon "Warning" | Out-Null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
$diskNum = $targetDisk.Number
|
$diskNum = $targetDisk.Number
|
||||||
$sizeGB = [math]::Round($targetDisk.Size / 1GB, 1)
|
$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") {
|
if ($confirm -ne "Yes") {
|
||||||
Write-WinUtilISOLog "USB write cancelled by user."
|
Write-WinUtilISOLog "USB write cancelled by user."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
$sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $false
|
Start-WinUtilJob -Name "USB write" -Description "Writing USB drive" -Parameters @{
|
||||||
$sync["Win11ISOProcessRunning"] = $true
|
DiskNumber = $diskNum
|
||||||
Write-WinUtilISOLog "Starting USB write to Disk $diskNum..."
|
ContentsDir = $contentsDir
|
||||||
|
} -ScriptBlock {
|
||||||
|
param($DiskNumber, $contentsDir)
|
||||||
|
|
||||||
$runspace = [Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
|
Invoke-WPFUIThread -ScriptBlock { $sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $false }
|
||||||
$runspace.ApartmentState = "STA"
|
Set-WinUtilISOStep -Step "Working" -Label "Writing the USB drive"
|
||||||
$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 {
|
try {
|
||||||
SetProgress "Formatting USB drive..." 10
|
Write-WinUtilISOLog "Starting USB write to Disk $DiskNumber..."
|
||||||
|
Step-WinUtilJob -Status "Formatting USB drive..." -Percent 10
|
||||||
|
|
||||||
# Phase 1: Clean disk via diskpart (retry once if the drive is not yet ready)
|
# 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"
|
$dpFile1 = Join-Path $env:TEMP "winutil_diskpart_$(Get-Random).txt"
|
||||||
"select disk $diskNum`nclean`nexit" | Set-Content -Path $dpFile1 -Encoding ASCII
|
"select disk $DiskNumber`nclean`nexit" | Set-Content -Path $dpFile1 -Encoding ASCII
|
||||||
Log "Running diskpart clean on Disk $diskNum..."
|
Write-WinUtilISOLog "Running diskpart clean on Disk $DiskNumber..."
|
||||||
$dpCleanOut = diskpart /s $dpFile1
|
$dpCleanOut = diskpart /s $dpFile1
|
||||||
$dpCleanOut | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" }
|
$dpCleanOut | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" }
|
||||||
Remove-Item $dpFile1 -Force
|
Remove-Item $dpFile1 -Force
|
||||||
|
|
||||||
if (($dpCleanOut -join ' ') -match 'device is not ready') {
|
if (($dpCleanOut -join ' ') -match 'device is not ready') {
|
||||||
Log "Disk $diskNum was not ready; waiting 5 seconds and retrying clean..."
|
Write-WinUtilISOLog "Disk $DiskNumber was not ready; waiting 5 seconds and retrying clean..."
|
||||||
Start-Sleep -Seconds 5
|
Start-Sleep -Seconds 5
|
||||||
Update-Disk -Number $diskNum
|
Update-Disk -Number $DiskNumber
|
||||||
$dpFile1b = Join-Path $env:TEMP "winutil_diskpart_$(Get-Random).txt"
|
$dpFile1b = Join-Path $env:TEMP "winutil_diskpart_$(Get-Random).txt"
|
||||||
"select disk $diskNum`nclean`nexit" | Set-Content -Path $dpFile1b -Encoding ASCII
|
"select disk $DiskNumber`nclean`nexit" | Set-Content -Path $dpFile1b -Encoding ASCII
|
||||||
diskpart /s $dpFile1b | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" }
|
diskpart /s $dpFile1b | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" }
|
||||||
Remove-Item $dpFile1b -Force
|
Remove-Item $dpFile1b -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
# Phase 2: Initialize as GPT
|
# Phase 2: Initialize as GPT
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
Update-Disk -Number $diskNum
|
Update-Disk -Number $DiskNumber
|
||||||
$diskObj = Get-Disk -Number $diskNum
|
$diskObj = Get-Disk -Number $DiskNumber
|
||||||
if ($diskObj.PartitionStyle -eq 'RAW') {
|
if ($diskObj.PartitionStyle -eq 'RAW') {
|
||||||
Initialize-Disk -Number $diskNum -PartitionStyle GPT
|
Initialize-Disk -Number $DiskNumber -PartitionStyle GPT
|
||||||
Log "Disk $diskNum initialized as GPT."
|
Write-WinUtilISOLog "Disk $DiskNumber initialized as GPT."
|
||||||
} else {
|
} else {
|
||||||
Set-Disk -Number $diskNum -PartitionStyle GPT
|
Set-Disk -Number $DiskNumber -PartitionStyle GPT
|
||||||
Log "Disk $diskNum converted to GPT (was $($diskObj.PartitionStyle))."
|
Write-WinUtilISOLog "Disk $DiskNumber converted to GPT (was $($diskObj.PartitionStyle))."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Phase 3: Create FAT32 partition via diskpart, then format with Format-Volume
|
# Phase 3: Create FAT32 partition via diskpart, then format with Format-Volume
|
||||||
@@ -154,64 +128,64 @@ function Invoke-WinUtilISOWriteUSB {
|
|||||||
$volLabel = "W11-" + (Get-Date).ToString('yyMMdd')
|
$volLabel = "W11-" + (Get-Date).ToString('yyMMdd')
|
||||||
$dpFile2 = Join-Path $env:TEMP "winutil_diskpart2_$(Get-Random).txt"
|
$dpFile2 = Join-Path $env:TEMP "winutil_diskpart2_$(Get-Random).txt"
|
||||||
$maxFat32PartitionMB = 32768
|
$maxFat32PartitionMB = 32768
|
||||||
$diskSizeMB = [int][Math]::Floor((Get-Disk -Number $diskNum).Size / 1MB)
|
$diskSizeMB = [int][Math]::Floor((Get-Disk -Number $DiskNumber).Size / 1MB)
|
||||||
$createPartitionCommand = "create partition primary"
|
$createPartitionCommand = "create partition primary"
|
||||||
if ($diskSizeMB -gt $maxFat32PartitionMB) {
|
if ($diskSizeMB -gt $maxFat32PartitionMB) {
|
||||||
$createPartitionCommand = "create partition primary size=$maxFat32PartitionMB"
|
$createPartitionCommand = "create partition primary size=$maxFat32PartitionMB"
|
||||||
Log "Disk $diskNum is $diskSizeMB MB; creating FAT32 partition capped at $maxFat32PartitionMB MB (32 GB)."
|
Write-WinUtilISOLog "Disk $DiskNumber is $diskSizeMB MB; creating FAT32 partition capped at $maxFat32PartitionMB MB (32 GB)."
|
||||||
}
|
}
|
||||||
|
|
||||||
@(
|
@(
|
||||||
"select disk $diskNum"
|
"select disk $DiskNumber"
|
||||||
$createPartitionCommand
|
$createPartitionCommand
|
||||||
"exit"
|
"exit"
|
||||||
) | Set-Content -Path $dpFile2 -Encoding ASCII
|
) | Set-Content -Path $dpFile2 -Encoding ASCII
|
||||||
Log "Creating partitions on Disk $diskNum..."
|
Write-WinUtilISOLog "Creating partitions on Disk $DiskNumber..."
|
||||||
diskpart /s $dpFile2 | Where-Object { $_ -match '\S' } | ForEach-Object { Log " diskpart: $_" }
|
diskpart /s $dpFile2 | Where-Object { $_ -match '\S' } | ForEach-Object { Write-WinUtilISOLog " diskpart: $_" }
|
||||||
Remove-Item $dpFile2 -Force
|
Remove-Item $dpFile2 -Force
|
||||||
|
|
||||||
SetProgress "Formatting USB partition..." 25
|
Step-WinUtilJob -Status "Formatting USB partition..." -Percent 25
|
||||||
Start-Sleep -Seconds 3
|
Start-Sleep -Seconds 3
|
||||||
Update-Disk -Number $diskNum
|
Update-Disk -Number $DiskNumber
|
||||||
|
|
||||||
$partitions = Get-Partition -DiskNumber $diskNum
|
$partitions = Get-Partition -DiskNumber $DiskNumber
|
||||||
Log "Partitions on Disk $diskNum after creation: $($partitions.Count)"
|
Write-WinUtilISOLog "Partitions on Disk $DiskNumber after creation: $($partitions.Count)"
|
||||||
foreach ($p in $partitions) {
|
foreach ($p in $partitions) {
|
||||||
Log " Partition $($p.PartitionNumber) Type=$($p.Type) Letter=$($p.DriveLetter) Size=$([math]::Round($p.Size/1MB))MB"
|
Write-WinUtilISOLog " Partition $($p.PartitionNumber) Type=$($p.Type) Letter=$($p.DriveLetter) Size=$([math]::Round($p.Size/1MB))MB"
|
||||||
}
|
}
|
||||||
|
|
||||||
$winpePart = $partitions | Where-Object { $_.Type -eq "Basic" } | Select-Object -Last 1
|
$winpePart = $partitions | Where-Object { $_.Type -eq "Basic" } | Select-Object -Last 1
|
||||||
if (-not $winpePart) {
|
if (-not $winpePart) {
|
||||||
throw "Could not find the Basic partition on Disk $diskNum after creation."
|
throw "Could not find the Basic partition on Disk $DiskNumber after creation."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Format using Format-Volume (reliable on fresh drives; diskpart format fails
|
# Format using Format-Volume (reliable on fresh drives; diskpart format fails
|
||||||
# with 'no volume selected' when the partition has never been formatted before)
|
# with 'no volume selected' when the partition has never been formatted before)
|
||||||
Log "Formatting Partition $($winpePart.PartitionNumber) as FAT32 (label: $volLabel)..."
|
Write-WinUtilISOLog "Formatting Partition $($winpePart.PartitionNumber) as FAT32 (label: $volLabel)..."
|
||||||
Get-Partition -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber |
|
Get-Partition -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber |
|
||||||
Format-Volume -FileSystem FAT32 -NewFileSystemLabel $volLabel -Force -Confirm:$false
|
Format-Volume -FileSystem FAT32 -NewFileSystemLabel $volLabel -Force -Confirm:$false
|
||||||
Log "Partition $($winpePart.PartitionNumber) formatted as FAT32."
|
Write-WinUtilISOLog "Partition $($winpePart.PartitionNumber) formatted as FAT32."
|
||||||
|
|
||||||
SetProgress "Assigning drive letters..." 30
|
Step-WinUtilJob -Status "Assigning drive letters..." -Percent 30
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
Update-Disk -Number $diskNum
|
Update-Disk -Number $DiskNumber
|
||||||
|
|
||||||
try { Remove-PartitionAccessPath -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber -AccessPath "$($winpePart.DriveLetter):" } catch { Log "Warning: could not remove existing partition access path: $_" }
|
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-FreeDriveLetter
|
$usbLetter = Get-WinUtilFreeDriveLetter
|
||||||
if (-not $usbLetter) { throw "No free drive letters (D-Z) available to assign to the USB data partition." }
|
if (-not $usbLetter) { throw "No free drive letters (D-Z) available to assign to the USB data partition." }
|
||||||
Set-Partition -DiskNumber $diskNum -PartitionNumber $winpePart.PartitionNumber -NewDriveLetter $usbLetter
|
Set-Partition -DiskNumber $DiskNumber -PartitionNumber $winpePart.PartitionNumber -NewDriveLetter $usbLetter
|
||||||
Log "Assigned drive letter $usbLetter to WINPE partition (Partition $($winpePart.PartitionNumber))."
|
Write-WinUtilISOLog "Assigned drive letter $usbLetter to WINPE partition (Partition $($winpePart.PartitionNumber))."
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
|
|
||||||
$usbDrive = "${usbLetter}:"
|
$usbDrive = "${usbLetter}:"
|
||||||
$retries = 0
|
$retries = 0
|
||||||
while (-not (Test-Path $usbDrive) -and $retries -lt 6) {
|
while (-not (Test-Path $usbDrive) -and $retries -lt 6) {
|
||||||
$retries++
|
$retries++
|
||||||
Log "Waiting for $usbDrive to become accessible (attempt $retries/6)..."
|
Write-WinUtilISOLog "Waiting for $usbDrive to become accessible (attempt $retries/6)..."
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
}
|
}
|
||||||
if (-not (Test-Path $usbDrive)) { throw "Drive $usbDrive is not accessible after letter assignment." }
|
if (-not (Test-Path $usbDrive)) { throw "Drive $usbDrive is not accessible after letter assignment." }
|
||||||
Log "USB data partition: $usbDrive"
|
Write-WinUtilISOLog "USB data partition: $usbDrive"
|
||||||
|
|
||||||
$contentSizeBytes = (Get-ChildItem -LiteralPath $contentsDir -File -Recurse -Force | Measure-Object -Property Length -Sum).Sum
|
$contentSizeBytes = (Get-ChildItem -LiteralPath $contentsDir -File -Recurse -Force | Measure-Object -Property Length -Sum).Sum
|
||||||
if (-not $contentSizeBytes) { $contentSizeBytes = 0 }
|
if (-not $contentSizeBytes) { $contentSizeBytes = 0 }
|
||||||
@@ -223,7 +197,7 @@ function Invoke-WinUtilISOWriteUSB {
|
|||||||
$partitionCapacityGB = [math]::Round($partitionCapacityBytes / 1GB, 2)
|
$partitionCapacityGB = [math]::Round($partitionCapacityBytes / 1GB, 2)
|
||||||
$partitionFreeGB = [math]::Round($partitionFreeBytes / 1GB, 2)
|
$partitionFreeGB = [math]::Round($partitionFreeBytes / 1GB, 2)
|
||||||
|
|
||||||
Log "Source content size: $contentSizeGB GB. USB partition capacity: $partitionCapacityGB GB, free: $partitionFreeGB GB."
|
Write-WinUtilISOLog "Source content size: $contentSizeGB GB. USB partition capacity: $partitionCapacityGB GB, free: $partitionFreeGB GB."
|
||||||
|
|
||||||
if ($contentSizeBytes -gt $partitionCapacityBytes) {
|
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."
|
throw "ISO content ($contentSizeGB GB) is larger than the USB partition capacity ($partitionCapacityGB GB). Use a larger USB drive or reduce image size."
|
||||||
@@ -233,55 +207,56 @@ function Invoke-WinUtilISOWriteUSB {
|
|||||||
throw "Insufficient free space on USB partition. Required: $contentSizeGB GB, available: $partitionFreeGB GB."
|
throw "Insufficient free space on USB partition. Required: $contentSizeGB GB, available: $partitionFreeGB GB."
|
||||||
}
|
}
|
||||||
|
|
||||||
SetProgress "Copying Windows 11 files to USB..." 45
|
Step-WinUtilJob -Status "Copying Windows 11 files to USB..." -Percent 45
|
||||||
|
|
||||||
# Copy files; split install.wim if > 4 GB (FAT32 limit)
|
# Copy files; split install.wim if > 4 GB (FAT32 limit)
|
||||||
$installWim = Join-Path $contentsDir "sources\install.wim"
|
$installWim = Join-Path $contentsDir "sources\install.wim"
|
||||||
if (Test-Path $installWim) {
|
if (Test-Path $installWim) {
|
||||||
$wimSizeMB = [math]::Round((Get-Item $installWim).Length / 1MB)
|
$wimSizeMB = [math]::Round((Get-Item $installWim).Length / 1MB)
|
||||||
if ($wimSizeMB -gt 3800) {
|
if ($wimSizeMB -gt 3800) {
|
||||||
Log "install.wim is $wimSizeMB MB - splitting for FAT32 compatibility... This will take several minutes."
|
Write-WinUtilISOLog "install.wim is $wimSizeMB MB - splitting for FAT32 compatibility... This will take several minutes."
|
||||||
Set-ItemProperty -LiteralPath $installWim -Name IsReadOnly -Value $false
|
Set-ItemProperty -LiteralPath $installWim -Name IsReadOnly -Value $false
|
||||||
$splitDest = Join-Path $usbDrive "sources\install.swm"
|
$splitDest = Join-Path $usbDrive "sources\install.swm"
|
||||||
New-Item -ItemType Directory -Path (Split-Path $splitDest) -Force
|
New-Item -ItemType Directory -Path (Split-Path $splitDest) -Force | Out-Null
|
||||||
Split-WindowsImage -ImagePath $installWim -SplitImagePath $splitDest -FileSize 3800 -CheckIntegrity
|
Split-WindowsImage -ImagePath $installWim -SplitImagePath $splitDest -FileSize 3800 -CheckIntegrity
|
||||||
Log "install.wim split complete."
|
Write-WinUtilISOLog "install.wim split complete."
|
||||||
Log "Copying remaining files to USB..."
|
Write-WinUtilISOLog "Copying remaining files to USB..."
|
||||||
& robocopy $contentsDir $usbDrive /E /XF install.wim /NFL /NDL /NJH /NJS
|
Invoke-WinUtilRobocopy -Source $contentsDir -Destination $usbDrive -Arguments @("/E","/XF","install.wim","/NFL","/NDL","/NJH","/NJS")
|
||||||
} else {
|
} else {
|
||||||
& robocopy $contentsDir $usbDrive /E /NFL /NDL /NJH /NJS
|
Invoke-WinUtilRobocopy -Source $contentsDir -Destination $usbDrive -Arguments @("/E","/NFL","/NDL","/NJH","/NJS")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
& robocopy $contentsDir $usbDrive /E /NFL /NDL /NJH /NJS
|
Invoke-WinUtilRobocopy -Source $contentsDir -Destination $usbDrive -Arguments @("/E","/NFL","/NDL","/NJH","/NJS")
|
||||||
}
|
}
|
||||||
|
|
||||||
SetProgress "Finalising USB drive..." 90
|
Step-WinUtilJob -Status "Finalising USB drive..." -Percent 90
|
||||||
Log "Files copied to USB."
|
Write-WinUtilISOLog "Files copied to USB."
|
||||||
SetProgress "USB write complete" 100
|
Step-WinUtilJob -Status "USB write complete" -Percent 100
|
||||||
Log "USB drive is ready for use."
|
Write-WinUtilISOLog "USB drive is ready for use."
|
||||||
|
|
||||||
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
|
Invoke-WPFUIThread -Parameters @{ DiskNumber = $DiskNumber } -ScriptBlock {
|
||||||
[System.Windows.MessageBox]::Show(
|
param($DiskNumber)
|
||||||
"USB drive created successfully!`n`nYou can now boot from this drive to install Windows 11.",
|
|
||||||
"USB Ready", "OK", "Info")
|
$sync["WPFWin11ISODoneLabel"].Text = "Disk $DiskNumber is ready to boot from."
|
||||||
})
|
$sync["WPFWin11ISODonePanel"].Visibility = "Visible"
|
||||||
|
}
|
||||||
|
Set-WinUtilISOStep -Step "Output"
|
||||||
|
Show-WinUtilMessage -Message "USB drive created successfully!`n`nYou can now boot from this drive to install Windows 11." -Title "USB Ready" -Button "OK" -Icon "Info" | Out-Null
|
||||||
} catch {
|
} catch {
|
||||||
Log "ERROR during USB write: $_"
|
Write-WinUtilISOLog -Level "ERROR" -Message "USB write failed: $_"
|
||||||
$sync["WPFWin11ISOStatusLog"].Dispatcher.Invoke([action]{
|
$_.Exception.Data["WinUtilErrorReported"] = $true
|
||||||
[System.Windows.MessageBox]::Show("USB write failed:`n`n$_", "USB Write Error", "OK", "Error")
|
Set-WinUtilISOStep -Step "Output"
|
||||||
})
|
Show-WinUtilMessage -Message "USB write failed:`n`n$_" -Title "USB Write Error" -Button "OK" -Icon "Error" | Out-Null
|
||||||
|
throw
|
||||||
} finally {
|
} finally {
|
||||||
Start-Sleep -Milliseconds 800
|
Invoke-WPFUIThread -ScriptBlock {
|
||||||
$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
|
$sync["WPFWin11ISOWriteUSBButton"].IsEnabled = $true
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
$script.BeginInvoke()
|
# Cancellation skips catch, so the working page can still be up here
|
||||||
|
if ($sync["WPFWin11ISOWorkingSection"].IsSelected) {
|
||||||
|
Set-WinUtilISOStep -Step "Output"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,68 @@
|
|||||||
function Invoke-WinUtilInstallPSProfile {
|
function Get-WinUtilPowerShell7Path {
|
||||||
if (-not (Get-Command wt)) {
|
$command = Get-Command pwsh -CommandType Application -ErrorAction SilentlyContinue
|
||||||
Write-Host "Windows Terminal not found. Installing..."
|
if ($command) { return $command.Source }
|
||||||
Install-WinUtilWinget
|
|
||||||
winget install Microsoft.WindowsTerminal --source winget --silent
|
foreach ($candidate in @(
|
||||||
|
"$env:ProgramFiles\PowerShell\7\pwsh.exe",
|
||||||
|
"$env:LOCALAPPDATA\Microsoft\WindowsApps\pwsh.exe")) {
|
||||||
|
if (Test-Path -LiteralPath $candidate) { return $candidate }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (-not (Get-Command pwsh)) {
|
return $null
|
||||||
Write-Host "PowerShell 7 not found. Installing..."
|
}
|
||||||
|
|
||||||
|
function Invoke-WinUtilInstallPSProfile {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Installs the CTT PowerShell profile
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
The profile targets PowerShell 7, so its setup script has to run under pwsh rather than
|
||||||
|
the runspace this job is on. It runs as a child process with its output captured, so the
|
||||||
|
job log records what happened instead of it scrolling past in a terminal nobody kept.
|
||||||
|
#>
|
||||||
|
|
||||||
|
$pwshPath = Get-WinUtilPowerShell7Path
|
||||||
|
if (-not $pwshPath) {
|
||||||
|
Step-WinUtilJob -Status "Installing PowerShell 7" -State "Indeterminate"
|
||||||
|
Write-WinUtilLog -Component "Feature" -Message "PowerShell 7 not found, installing it first."
|
||||||
|
|
||||||
Install-WinUtilWinget
|
Install-WinUtilWinget
|
||||||
winget install Microsoft.PowerShell --source winget --installer-type wix --silent
|
Install-WinUtilProgramWinget -Action Install -Programs @("Microsoft.PowerShell") | Out-Null
|
||||||
|
|
||||||
|
# WinGet updates the persisted PATH, not this already-running process. Resolve the
|
||||||
|
# standard install locations as well as the current PATH before deciding it failed.
|
||||||
|
$pwshPath = Get-WinUtilPowerShell7Path
|
||||||
|
if (-not $pwshPath) {
|
||||||
|
throw "PowerShell 7 could not be installed, so the profile cannot be set up."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Step-WinUtilJob -Status "Running the profile setup" -State "Indeterminate"
|
||||||
|
|
||||||
|
$setupUrl = "https://github.com/ChrisTitusTech/powershell-profile/raw/main/setup.ps1"
|
||||||
|
# Stop in the child, so a setup failure is a nonzero exit rather than a logged error and a
|
||||||
|
# exit code of zero
|
||||||
|
$output = & $pwshPath -NoProfile -NonInteractive -Command "`$ErrorActionPreference = 'Stop'; irm '$setupUrl' | iex" 2>&1
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
|
||||||
|
$failures = 0
|
||||||
|
foreach ($line in @($output)) {
|
||||||
|
if ($line -is [System.Management.Automation.ErrorRecord]) {
|
||||||
|
$failures++
|
||||||
|
Write-WinUtilErrorRecord -ErrorRecord $line -Component "Feature" -Context "PowerShell profile setup"
|
||||||
|
} elseif (-not [string]::IsNullOrWhiteSpace($line)) {
|
||||||
|
Write-WinUtilLog -Component "Feature" -Message ([string]$line).Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
throw "The profile setup script exited with code $exitCode."
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failures -gt 0) {
|
||||||
|
throw "The profile setup script reported $failures error(s); see the log."
|
||||||
}
|
}
|
||||||
|
|
||||||
wt new-tab pwsh -NoExit -Command "irm https://github.com/ChrisTitusTech/powershell-profile/raw/main/setup.ps1 | iex"
|
Write-WinUtilLog -Component "Feature" -Message "CTT PowerShell profile installed. Open a new PowerShell 7 session to use it."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,13 @@ function Invoke-WinUtilSSHServer {
|
|||||||
|
|
||||||
#Adding Firewall rule for port 22
|
#Adding Firewall rule for port 22
|
||||||
Write-Host "Setting up firewall rules"
|
Write-Host "Setting up firewall rules"
|
||||||
if (-not ((Get-NetFirewallRule -Name 'sshd').Enabled)) {
|
$firewallRule = Get-NetFirewallRule -Name 'sshd' -ErrorAction SilentlyContinue
|
||||||
|
if ($null -eq $firewallRule) {
|
||||||
New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22
|
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."
|
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
|
# An SSH logon for a member of the administrators group gets a full token
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ function Invoke-WinUtilTweaks {
|
|||||||
|
|
||||||
$action = if ($undo) { "Undo" } else { "Apply" }
|
$action = if ($undo) { "Undo" } else { "Apply" }
|
||||||
Write-WinUtilLog -Component "Tweaks" -Message "$action tweak: $CheckBox"
|
Write-WinUtilLog -Component "Tweaks" -Message "$action tweak: $CheckBox"
|
||||||
|
# The counter lives in this runspace, so an error a concurrent job logs from its own
|
||||||
|
# runspace cannot be charged to a toggle flipped on the UI thread
|
||||||
|
$errorsBefore = [int]$global:WinUtilJobErrorCount
|
||||||
|
|
||||||
if ($undo) {
|
if ($undo) {
|
||||||
$Values = @{
|
$Values = @{
|
||||||
@@ -81,5 +84,10 @@ function Invoke-WinUtilTweaks {
|
|||||||
Remove-WinUtilProvisionedAPPX -PackageList $sync.configs.tweaks.$CheckBox.appx
|
Remove-WinUtilProvisionedAPPX -PackageList $sync.configs.tweaks.$CheckBox.appx
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Write-WinUtilLog -Component "Tweaks" -Message "$action tweak completed: $CheckBox"
|
$errorCount = [int]$global:WinUtilJobErrorCount - $errorsBefore
|
||||||
|
if ($errorCount -gt 0) {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component "Tweaks" -Message "$action tweak finished with $errorCount error(s): $CheckBox"
|
||||||
|
} else {
|
||||||
|
Write-WinUtilLog -Component "Tweaks" -Message "$action tweak completed: $CheckBox"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,36 @@
|
|||||||
function Invoke-WinUtilUninstallPSProfile {
|
function Invoke-WinUtilUninstallPSProfile {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Restores the PowerShell 7 profile the CTT profile replaced
|
||||||
|
|
||||||
if (Test-Path ($Profile + ".bak")) {
|
.DESCRIPTION
|
||||||
Move-Item -Path ($Profile + ".bak") -Destination $Profile
|
The profile path has to come from pwsh itself. $PROFILE inside this job is the worker's
|
||||||
} else {
|
own Windows PowerShell profile, which is not the file the install wrote.
|
||||||
Remove-Item -Path $Profile
|
#>
|
||||||
|
|
||||||
|
$pwshPath = Get-WinUtilPowerShell7Path
|
||||||
|
if (-not $pwshPath) {
|
||||||
|
throw "PowerShell 7 is not installed, so there is no CTT profile to remove."
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Successfully uninstalled CTT PowerShell Profile." -ForegroundColor Green
|
$profilePath = (& $pwshPath -NoProfile -NonInteractive -Command '$PROFILE' | Select-Object -First 1)
|
||||||
|
if ([string]::IsNullOrWhiteSpace($profilePath)) {
|
||||||
|
throw "Could not determine the PowerShell 7 profile path."
|
||||||
|
}
|
||||||
|
$profilePath = $profilePath.Trim()
|
||||||
|
$backupPath = "$profilePath.bak"
|
||||||
|
|
||||||
|
if (Test-Path $backupPath) {
|
||||||
|
Move-Item -Path $backupPath -Destination $profilePath -Force
|
||||||
|
Write-WinUtilLog -Component "Feature" -Message "Restored the profile that was in place before: $profilePath"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-Path $profilePath) {
|
||||||
|
Remove-Item -Path $profilePath -Force
|
||||||
|
Write-WinUtilLog -Component "Feature" -Message "Removed the CTT PowerShell profile: $profilePath"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component "Feature" -Message "No PowerShell 7 profile found at $profilePath, nothing to remove."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
function Measure-WinUtilStep {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Times one step of a pipeline and records it for the timing summary
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Output passes through untouched, so this can wrap an existing expression without
|
||||||
|
changing what the caller receives. Each step is logged as a "timing:" line and kept
|
||||||
|
in $sync.StepTimings for the summary to rank.
|
||||||
|
|
||||||
|
.PARAMETER Name
|
||||||
|
What the step is, as it should read in the log.
|
||||||
|
|
||||||
|
.PARAMETER ScriptBlock
|
||||||
|
The work to time.
|
||||||
|
|
||||||
|
.PARAMETER Scope
|
||||||
|
Groups steps that belong to the same run, normally a job name or "UI".
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory, Position = 0)]
|
||||||
|
[string]$Name,
|
||||||
|
|
||||||
|
[Parameter(Mandatory, Position = 1)]
|
||||||
|
[scriptblock]$ScriptBlock,
|
||||||
|
|
||||||
|
[string]$Scope = "WinUtil"
|
||||||
|
)
|
||||||
|
|
||||||
|
$isUIDiagnostic = $Scope -in @("UI", "Tab")
|
||||||
|
$captureTiming = -not $isUIDiagnostic -or $sync.IsLocalCompile
|
||||||
|
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
try {
|
||||||
|
& $ScriptBlock
|
||||||
|
} finally {
|
||||||
|
$stopwatch.Stop()
|
||||||
|
|
||||||
|
if ($captureTiming -and $null -ne $sync.StepTimings) {
|
||||||
|
$null = $sync.StepTimings.Add([pscustomobject]@{
|
||||||
|
Scope = $Scope
|
||||||
|
Step = $Name
|
||||||
|
Milliseconds = $stopwatch.ElapsedMilliseconds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($captureTiming) {
|
||||||
|
$level = if ($isUIDiagnostic) { "DEBUG" } else { "INFO" }
|
||||||
|
Write-WinUtilLog -Level $level -Component $Scope -Message "timing: $Name took $($stopwatch.ElapsedMilliseconds) ms"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-WinUtilTimingSummary {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Logs the slowest steps of a scope, so the log answers "what took so long"
|
||||||
|
|
||||||
|
.PARAMETER Scope
|
||||||
|
Which group of steps to report on.
|
||||||
|
|
||||||
|
.PARAMETER Top
|
||||||
|
How many of the slowest steps to list.
|
||||||
|
|
||||||
|
.PARAMETER TotalMilliseconds
|
||||||
|
Wall clock total. Without it the summary sums the steps, missing whatever happened
|
||||||
|
between them.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Scope,
|
||||||
|
|
||||||
|
[int]$Top = 5,
|
||||||
|
|
||||||
|
[long]$TotalMilliseconds = -1,
|
||||||
|
|
||||||
|
[int]$StartIndex = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
$isUIDiagnostic = $Scope -in @("UI", "Tab")
|
||||||
|
if (($isUIDiagnostic -and -not $sync.IsLocalCompile) -or $null -eq $sync.StepTimings) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Threading.Monitor]::Enter($sync.StepTimings.SyncRoot)
|
||||||
|
try {
|
||||||
|
$timingSnapshot = @($sync.StepTimings.ToArray())
|
||||||
|
} finally {
|
||||||
|
[System.Threading.Monitor]::Exit($sync.StepTimings.SyncRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
$steps = @($timingSnapshot | Select-Object -Skip $StartIndex | Where-Object { $_.Scope -eq $Scope })
|
||||||
|
if ($steps.Count -eq 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$measured = ($steps | Measure-Object -Property Milliseconds -Sum).Sum
|
||||||
|
$total = if ($TotalMilliseconds -ge 0) { $TotalMilliseconds } else { $measured }
|
||||||
|
|
||||||
|
$level = if ($isUIDiagnostic) { "DEBUG" } else { "INFO" }
|
||||||
|
Write-WinUtilLog -Level $level -Component $Scope -Message "timing summary: $($steps.Count) step(s), $measured ms measured of $total ms total"
|
||||||
|
foreach ($step in ($steps | Sort-Object Milliseconds -Descending | Select-Object -First $Top)) {
|
||||||
|
$share = if ($total -gt 0) { [int](($step.Milliseconds / $total) * 100) } else { 0 }
|
||||||
|
Write-WinUtilLog -Level $level -Component $Scope -Message "timing summary: $($step.Milliseconds) ms ($share%) $($step.Step)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
function New-WinUtilSessionState {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Builds the InitialSessionState every WinUtil runspace is created from
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
The interface runspace and the worker pool start from the same state: the shared
|
||||||
|
$sync hashtable, the compiled script's globals, and every WinUtil function. That is
|
||||||
|
what lets the interface build a tab and a job body call any helper without injecting
|
||||||
|
definitions by hand. PowerShell's own functions are skipped, the default session
|
||||||
|
state already carries them.
|
||||||
|
|
||||||
|
Cached: an InitialSessionState is a template any number of runspaces are created
|
||||||
|
from, and building it is not free.
|
||||||
|
#>
|
||||||
|
|
||||||
|
if ($sync.SessionState) {
|
||||||
|
return $sync.SessionState
|
||||||
|
}
|
||||||
|
|
||||||
|
$initialSessionState = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
|
||||||
|
|
||||||
|
$variables = @(
|
||||||
|
@{ Name = "sync"; Value = $sync },
|
||||||
|
@{ Name = "PARAM_OFFLINE"; Value = $PARAM_OFFLINE },
|
||||||
|
@{ Name = "inputXML"; Value = $inputXML },
|
||||||
|
@{ Name = "WinUtilAutounattendXml"; Value = $WinUtilAutounattendXml }
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($variable in $variables) {
|
||||||
|
$initialSessionState.Variables.Add(
|
||||||
|
(New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry -ArgumentList $variable.Name, $variable.Value, $null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
$builtInFunctions = [System.Collections.Generic.HashSet[string]]::new(
|
||||||
|
[string[]]@($initialSessionState.Commands |
|
||||||
|
Where-Object { $_ -is [System.Management.Automation.Runspaces.SessionStateFunctionEntry] } |
|
||||||
|
ForEach-Object { $_.Name }),
|
||||||
|
[StringComparer]::OrdinalIgnoreCase
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($function in (Get-ChildItem function:\)) {
|
||||||
|
if ($builtInFunctions.Contains($function.Name)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$initialSessionState.Commands.Add(
|
||||||
|
(New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry -ArgumentList $function.Name, $function.Definition)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
$sync.SessionState = $initialSessionState
|
||||||
|
return $initialSessionState
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
function Register-WinUtilRunspaceCleanup {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Disposes a PowerShell instance and any owned runspace once its work has finished
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Ends the invocation and disposes the instance from a thread pool callback, so
|
||||||
|
nothing has to wait for a fire-and-forget runspace to complete just to clean it up.
|
||||||
|
|
||||||
|
.PARAMETER PowerShell
|
||||||
|
The instance to dispose.
|
||||||
|
|
||||||
|
.PARAMETER Handle
|
||||||
|
The handle returned by its BeginInvoke.
|
||||||
|
|
||||||
|
.PARAMETER Runspace
|
||||||
|
A dedicated runspace owned by the invocation. Shared pool invocations omit it.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
$PowerShell,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
$Handle,
|
||||||
|
|
||||||
|
$Runspace
|
||||||
|
)
|
||||||
|
|
||||||
|
# Version the CLR helper because Add-Type definitions survive repeated in-memory WinUtil runs.
|
||||||
|
# Older sessions can already contain the V1 type, whose state object has no Runspace property.
|
||||||
|
if (-not ("WinUtilRunspaceCleanupV3" -as [type])) {
|
||||||
|
Add-Type @"
|
||||||
|
using System;
|
||||||
|
using System.Management.Automation;
|
||||||
|
using System.Management.Automation.Runspaces;
|
||||||
|
using System.Threading;
|
||||||
|
|
||||||
|
public sealed class WinUtilRunspaceCleanupStateV3
|
||||||
|
{
|
||||||
|
public PowerShell PowerShell { get; set; }
|
||||||
|
public IAsyncResult Handle { get; set; }
|
||||||
|
public Runspace Runspace { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class WinUtilRunspaceCleanupV3
|
||||||
|
{
|
||||||
|
public static readonly System.Threading.WaitOrTimerCallback Callback = Cleanup;
|
||||||
|
|
||||||
|
public static bool Register(WinUtilRunspaceCleanupStateV3 state)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ThreadPool.RegisterWaitForSingleObject(state.Handle.AsyncWaitHandle, Callback, state, -1, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Registration is normally infallible, but work has already started. A background
|
||||||
|
// waiter preserves asynchronous cleanup without blocking the WPF dispatcher.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var thread = new Thread(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
state.Handle.AsyncWaitHandle.WaitOne();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
Cleanup(state, false);
|
||||||
|
});
|
||||||
|
thread.IsBackground = true;
|
||||||
|
thread.Name = "WinUtil runspace cleanup fallback";
|
||||||
|
thread.Start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Cleanup(object state, bool timedOut)
|
||||||
|
{
|
||||||
|
var cleanupState = state as WinUtilRunspaceCleanupStateV3;
|
||||||
|
if (cleanupState == null || cleanupState.PowerShell == null || cleanupState.Handle == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cleanupState.PowerShell.EndInvoke(cleanupState.Handle);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
cleanupState.PowerShell.Dispose();
|
||||||
|
if (cleanupState.Runspace != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cleanupState.Runspace.Close();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
cleanupState.Runspace.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"@
|
||||||
|
}
|
||||||
|
|
||||||
|
$cleanupState = [WinUtilRunspaceCleanupStateV3]::new()
|
||||||
|
$cleanupState.PowerShell = $PowerShell
|
||||||
|
$cleanupState.Handle = $Handle
|
||||||
|
$cleanupState.Runspace = $Runspace
|
||||||
|
$registered = [WinUtilRunspaceCleanupV3]::Register($cleanupState)
|
||||||
|
if (-not $registered) {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Could not register asynchronous cleanup for background work; it will be reclaimed when later work or shutdown inspects it."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,9 @@ function Remove-WinUtilProvisionedAPPX {
|
|||||||
$failureDetails = ($removalOutput | Out-String).Trim()
|
$failureDetails = ($removalOutput | Out-String).Trim()
|
||||||
$errorMessage = "AppX provisioned package removal failed: $failureDetails"
|
$errorMessage = "AppX provisioned package removal failed: $failureDetails"
|
||||||
Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message $errorMessage
|
Write-WinUtilLog -Level "ERROR" -Component "AppX" -Message $errorMessage
|
||||||
throw $errorMessage
|
$exception = [System.InvalidOperationException]::new($errorMessage)
|
||||||
|
$exception.Data["WinUtilErrorReported"] = $true
|
||||||
|
throw $exception
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-WinUtilLog -Component "AppX" -Message "AppX provisioned package removal completed."
|
Write-WinUtilLog -Component "AppX" -Message "AppX provisioned package removal completed."
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ function Reset-WPFCheckBoxes {
|
|||||||
<#
|
<#
|
||||||
|
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Set winutil checkboxs to match $sync.selected values.
|
Set WinUtil checkboxes to match $sync.selected values.
|
||||||
Should only need to be run if $sync.selected updated outside of UI (i.e. presets or import)
|
Should only need to be run if $sync.selected updated outside of UI (i.e. presets or import)
|
||||||
|
|
||||||
.PARAMETER doToggles
|
.PARAMETER doToggles
|
||||||
@@ -22,19 +22,30 @@ function Reset-WPFCheckBoxes {
|
|||||||
)
|
)
|
||||||
$selectedSet = [System.Collections.Generic.HashSet[string]]::new([string[]]@($sync.selectedApps + $sync.selectedTweaks + $sync.selectedFeatures + $sync.selectedAppx), [StringComparer]::OrdinalIgnoreCase)
|
$selectedSet = [System.Collections.Generic.HashSet[string]]::new([string[]]@($sync.selectedApps + $sync.selectedTweaks + $sync.selectedFeatures + $sync.selectedAppx), [StringComparer]::OrdinalIgnoreCase)
|
||||||
|
|
||||||
foreach ($syncEntry in $sync.GetEnumerator()) {
|
# A synchronized Hashtable protects individual operations, not enumeration. Materialize the
|
||||||
|
# snapshot under its lock, then release it before handlers run and mutate $sync.
|
||||||
|
[System.Threading.Monitor]::Enter($sync.SyncRoot)
|
||||||
|
try {
|
||||||
|
$syncEntries = @($sync.GetEnumerator())
|
||||||
|
} finally {
|
||||||
|
[System.Threading.Monitor]::Exit($sync.SyncRoot)
|
||||||
|
}
|
||||||
|
foreach ($syncEntry in $syncEntries) {
|
||||||
if ($syncEntry.Value -is [System.Windows.Controls.CheckBox] -and $syncEntry.Name -notlike "WPFToggle*" -and $syncEntry.Name -like $checkboxfilterpattern) {
|
if ($syncEntry.Value -is [System.Windows.Controls.CheckBox] -and $syncEntry.Name -notlike "WPFToggle*" -and $syncEntry.Name -like $checkboxfilterpattern) {
|
||||||
$checkboxName = $syncEntry.Key
|
$checkboxName = $syncEntry.Key
|
||||||
$sync.$checkboxName.IsChecked = $selectedSet.Contains($checkboxName)
|
$sync.$checkboxName.IsChecked = $selectedSet.Contains($checkboxName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Update Installs tab UI values
|
# Update Installs tab UI values. These are built with the Install tab, and this runs for
|
||||||
$count = $sync.SelectedApps.Count
|
# whichever tab is built first: offline starts on Tweaks, so they are not there yet.
|
||||||
$sync.WPFselectedAppsButton.Content = "Selected Apps: $count"
|
if ($sync.selectedAppsstackPanel) {
|
||||||
# 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
|
$count = $sync.SelectedApps.Count
|
||||||
$sync.selectedAppsstackPanel.Children.Clear()
|
$sync.WPFselectedAppsButton.Content = "Selected Apps: $count"
|
||||||
$sync.selectedApps | Foreach-Object { Add-SelectedAppsMenuItem -name $($sync.configs.applicationsHashtable.$_.Content) -key $_ }
|
# 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) {
|
if($doToggles) {
|
||||||
# Restore toggle switch states from imported config.
|
# Restore toggle switch states from imported config.
|
||||||
@@ -42,7 +53,13 @@ function Reset-WPFCheckBoxes {
|
|||||||
# from the export file were not part of the saved config and should keep whatever
|
# 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).
|
# 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)
|
$importedToggles = [System.Collections.Generic.HashSet[string]]::new([string[]]@($sync.selectedToggles), [StringComparer]::OrdinalIgnoreCase)
|
||||||
foreach ($toggle in $sync.GetEnumerator()) {
|
[System.Threading.Monitor]::Enter($sync.SyncRoot)
|
||||||
|
try {
|
||||||
|
$toggleEntries = @($sync.GetEnumerator())
|
||||||
|
} finally {
|
||||||
|
[System.Threading.Monitor]::Exit($sync.SyncRoot)
|
||||||
|
}
|
||||||
|
foreach ($toggle in $toggleEntries) {
|
||||||
if ($toggle.Key -like "WPFToggle*" -and $toggle.Value -is [System.Windows.Controls.CheckBox] -and $importedToggles.Contains($toggle.Key)) {
|
if ($toggle.Key -like "WPFToggle*" -and $toggle.Value -is [System.Windows.Controls.CheckBox] -and $importedToggles.Contains($toggle.Key)) {
|
||||||
$sync[$toggle.Key].IsChecked = $true
|
$sync[$toggle.Key].IsChecked = $true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,21 @@ function Set-WinUtilDNS {
|
|||||||
return $true
|
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 {
|
try {
|
||||||
$Adapters = Get-NetAdapter | Where-Object {$_.Status -eq "Up"}
|
$Adapters = Get-NetAdapter | Where-Object {$_.Status -eq "Up"}
|
||||||
Write-Host "Ensuring DNS is set to $DNSProvider on the following interfaces:"
|
Write-Host "Ensuring DNS is set to $DNSProvider on the following interfaces:"
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
function Set-WinUtilTweaksProgressIndicator {
|
|
||||||
<#
|
|
||||||
.SYNOPSIS
|
|
||||||
Shows, updates, or hides the window-level progress indicator used by long-running
|
|
||||||
workflows such as app management, Tweaks, AppX management, and Win11 Creator.
|
|
||||||
It lives outside the TabControl, so it stays visible no matter which tab is active.
|
|
||||||
.PARAMETER Visible
|
|
||||||
Whether the indicator should be shown or hidden.
|
|
||||||
.PARAMETER Label
|
|
||||||
The text to display above the progress bar.
|
|
||||||
.PARAMETER Percent
|
|
||||||
The percentage of the progress bar that should be filled (0-100).
|
|
||||||
#>
|
|
||||||
param(
|
|
||||||
[bool]$Visible,
|
|
||||||
[string]$Label,
|
|
||||||
[ValidateRange(0,100)]
|
|
||||||
[int]$Percent
|
|
||||||
)
|
|
||||||
|
|
||||||
if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
$indicatorVisible = if ($Visible) { [Windows.Visibility]::Visible } else { [Windows.Visibility]::Collapsed }
|
|
||||||
$indicatorLabel = $Label
|
|
||||||
$hasLabel = $PSBoundParameters.ContainsKey('Label')
|
|
||||||
$hasPercent = $PSBoundParameters.ContainsKey('Percent')
|
|
||||||
|
|
||||||
Invoke-WPFUIThread -ScriptBlock {
|
|
||||||
$sync.WPFTweaksProgressBar.Visibility = $indicatorVisible
|
|
||||||
if ($hasLabel) {
|
|
||||||
$sync.WPFTweaksProgressLabel.Text = $indicatorLabel
|
|
||||||
}
|
|
||||||
if ($hasPercent) {
|
|
||||||
$sync.WPFTweaksProgressValue.Value = $Percent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,14 @@ function Show-WinUtilMessage {
|
|||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.SYNOPSIS
|
||||||
Shows a WinUtil message box and returns the selected result.
|
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 (
|
param (
|
||||||
[string]$Message,
|
[string]$Message,
|
||||||
@@ -10,5 +18,24 @@ function Show-WinUtilMessage {
|
|||||||
$Icon = "Information"
|
$Icon = "Information"
|
||||||
)
|
)
|
||||||
|
|
||||||
[System.Windows.MessageBox]::Show($Message, $Title, $Button, $Icon)
|
Write-WinUtilLog -Component "Dialog" -Message "$Title : $($Message -replace '\r?\n', ' ')"
|
||||||
|
|
||||||
|
if (-not (Test-WinUtilUIAlive)) {
|
||||||
|
# Anything with a choice is answered with the one that does not go ahead, so a prompt
|
||||||
|
# nobody saw can never stand in for consent
|
||||||
|
$unattended = if ("$Button" -eq "OK") { "OK" } else { "No" }
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component "Dialog" -Message "No window to ask on, answering '$unattended' for: $Title"
|
||||||
|
return $unattended
|
||||||
|
}
|
||||||
|
|
||||||
|
return Invoke-WPFUIThread -PassThru -Parameters @{
|
||||||
|
Message = $Message
|
||||||
|
Title = $Title
|
||||||
|
Button = $Button
|
||||||
|
Icon = $Icon
|
||||||
|
} -ScriptBlock {
|
||||||
|
param($Message, $Title, $Button, $Icon)
|
||||||
|
|
||||||
|
[System.Windows.MessageBox]::Show($Message, $Title, $Button, $Icon)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
function Start-WinUtilAssetRendering {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Renders the taskbar overlay bitmaps on a thread of their own
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Rasterising the overlays costs the interface thread time it could spend getting the
|
||||||
|
window up. The bitmaps are frozen before publication, so they can be built anywhere.
|
||||||
|
|
||||||
|
Nothing waits on this: if the render has not finished when an overlay is asked for,
|
||||||
|
Set-WinUtilTaskbaritem renders it in place.
|
||||||
|
|
||||||
|
Needs STA for RenderTargetBitmap, which the shared worker pool is not.
|
||||||
|
#>
|
||||||
|
|
||||||
|
$runspace = [runspacefactory]::CreateRunspace((New-WinUtilSessionState))
|
||||||
|
$runspace.ApartmentState = "STA"
|
||||||
|
$runspace.ThreadOptions = "ReuseThread"
|
||||||
|
$runspace.Open()
|
||||||
|
|
||||||
|
$shell = [powershell]::Create()
|
||||||
|
$shell.Runspace = $runspace
|
||||||
|
[void]$shell.AddScript({
|
||||||
|
# A new runspace does not inherit assemblies loaded by the interface runspace. On a cold
|
||||||
|
# process these types otherwise fail before the off-thread render can do any work.
|
||||||
|
Add-Type -AssemblyName WindowsBase
|
||||||
|
Add-Type -AssemblyName PresentationCore
|
||||||
|
Add-Type -AssemblyName PresentationFramework
|
||||||
|
|
||||||
|
Measure-WinUtilStep -Scope "UI" -Name "render taskbar overlays (off thread)" -ScriptBlock {
|
||||||
|
Initialize-WinUtilTaskbarOverlayAssets -IncludeLogo $true -IncludeStatusAssets $true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$handle = $shell.BeginInvoke()
|
||||||
|
|
||||||
|
Register-WinUtilRunspaceCleanup -PowerShell $shell -Handle $handle -Runspace $runspace
|
||||||
|
|
||||||
|
return $handle
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
function Start-WinUtilBackgroundQueue {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Drains a queue of interface work one item at a time, between the things the user does
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
For work that must run on the interface thread but that nobody waits on: unopened
|
||||||
|
tabs, app list entries. One item per queued operation, so input is answered between
|
||||||
|
them instead of after the whole list.
|
||||||
|
|
||||||
|
Re-posted rather than looped: only returning to the dispatcher lets it service input.
|
||||||
|
Posted as a compiled action rather than through Invoke-WPFUIThread, whose body
|
||||||
|
crosses runspaces as text and would recompile on each of the hundreds of posts a full
|
||||||
|
app list costs.
|
||||||
|
|
||||||
|
.PARAMETER Name
|
||||||
|
Identifies the queue in $sync so a re-posted pump finds its state.
|
||||||
|
|
||||||
|
.PARAMETER Queue
|
||||||
|
The queue to drain. Items mean whatever Step says they mean.
|
||||||
|
|
||||||
|
.PARAMETER Step
|
||||||
|
Runs one item. Receives the dequeued item.
|
||||||
|
|
||||||
|
.PARAMETER OnComplete
|
||||||
|
Runs once on the interface thread after the last item.
|
||||||
|
|
||||||
|
.PARAMETER RequiresTab
|
||||||
|
Work drawing into this tab waits while another tab is shown.
|
||||||
|
|
||||||
|
.PARAMETER DeferWhile
|
||||||
|
Extra reason to hold off, tested each round. Lets a more urgent queue go first.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Name,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
$Queue,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[scriptblock]$Step,
|
||||||
|
|
||||||
|
[scriptblock]$OnComplete,
|
||||||
|
|
||||||
|
[string]$RequiresTab,
|
||||||
|
|
||||||
|
[scriptblock]$DeferWhile
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($null -eq $sync.BackgroundQueues) {
|
||||||
|
$sync.BackgroundQueues = [hashtable]::Synchronized(@{})
|
||||||
|
}
|
||||||
|
|
||||||
|
$sync.BackgroundQueues[$Name] = @{
|
||||||
|
Queue = $Queue
|
||||||
|
Step = $Step
|
||||||
|
OnComplete = $OnComplete
|
||||||
|
RequiresTab = $RequiresTab
|
||||||
|
DeferWhile = $DeferWhile
|
||||||
|
}
|
||||||
|
|
||||||
|
# No window means no dispatcher to spread over and nothing competing for the thread
|
||||||
|
if (-not (Test-WinUtilUIAlive)) {
|
||||||
|
while ($Queue.Count -gt 0) {
|
||||||
|
# One failing item must not abandon the rest or strand the state, matching the
|
||||||
|
# dispatcher path
|
||||||
|
try {
|
||||||
|
& $Step $Queue.Dequeue()
|
||||||
|
} catch {
|
||||||
|
Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Background queue '$Name'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($OnComplete) { & $OnComplete }
|
||||||
|
$sync.BackgroundQueues.Remove($Name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Request-WinUtilBackgroundQueueStep -Name $Name
|
||||||
|
}
|
||||||
|
|
||||||
|
function Request-WinUtilBackgroundQueueStep {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Posts the next step of a queue at background priority
|
||||||
|
|
||||||
|
.PARAMETER Name
|
||||||
|
Which queue to advance.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-WinUtilUIAlive)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# The name travels as the dispatcher's argument, not captured: this function has returned by
|
||||||
|
# the time the block runs, and a closure would bind command lookup to a copied scope.
|
||||||
|
$null = $sync.Form.Dispatcher.BeginInvoke(
|
||||||
|
[System.Windows.Threading.DispatcherPriority]::Background,
|
||||||
|
[System.Windows.Threading.DispatcherOperationCallback]{
|
||||||
|
param($QueueName)
|
||||||
|
Invoke-WinUtilBackgroundQueueStep -Name $QueueName
|
||||||
|
return $null
|
||||||
|
},
|
||||||
|
$Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-WinUtilBackgroundQueueStep {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Runs one item of a queue and asks for the next, or finishes
|
||||||
|
|
||||||
|
.PARAMETER Name
|
||||||
|
Which queue to advance.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
$state = $sync.BackgroundQueues[$Name]
|
||||||
|
if ($null -eq $state) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state.Queue.Count -gt 0) {
|
||||||
|
$defer = (Test-WinUtilDeferBackgroundWork -RequiresTab $state.RequiresTab) -or
|
||||||
|
($state.DeferWhile -and (& $state.DeferWhile))
|
||||||
|
|
||||||
|
# Waits rather than competing with whatever the user is doing
|
||||||
|
if ($defer) {
|
||||||
|
Invoke-WinUtilWhenIdle -Argument $Name -Callback {
|
||||||
|
param($QueueName)
|
||||||
|
Invoke-WinUtilBackgroundQueueStep -Name $QueueName
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
& $state.Step $state.Queue.Dequeue()
|
||||||
|
} catch {
|
||||||
|
Write-WinUtilErrorRecord -ErrorRecord $_ -Component "UI" -Context "Background queue '$Name'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($state.Queue.Count -gt 0) {
|
||||||
|
Request-WinUtilBackgroundQueueStep -Name $Name
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$sync.BackgroundQueues.Remove($Name)
|
||||||
|
if ($state.OnComplete) { & $state.OnComplete }
|
||||||
|
}
|
||||||
@@ -4,8 +4,30 @@ function Invoke-WinUtilInstallAppRenderBatch {
|
|||||||
$CategoryBatch
|
$CategoryBatch
|
||||||
)
|
)
|
||||||
|
|
||||||
foreach ($appKey in $CategoryBatch.AppKeys) {
|
# A count is not a unit of time. How long a fixed number of entries takes depends on the
|
||||||
|
# machine and on the category, so the pass runs to a deadline instead and hands back
|
||||||
|
# whatever it did not reach. That caps how long a click can be left waiting.
|
||||||
|
$budgetMs = 25
|
||||||
|
$keys = @($CategoryBatch.AppKeys)
|
||||||
|
|
||||||
|
# This runs on the dispatcher, so keep the slice free of logging and other disk I/O.
|
||||||
|
$clock = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
$rendered = 0
|
||||||
|
foreach ($appKey in $keys) {
|
||||||
$sync.$appKey = Initialize-InstallAppEntry -TargetElement $CategoryBatch.TargetElement -AppKey $appKey
|
$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
|
# Entries render in batches, so a filter that is already active has to be applied to each new
|
||||||
@@ -21,23 +43,7 @@ function Invoke-WinUtilInstallAppRenderBatch {
|
|||||||
|
|
||||||
function Complete-WinUtilInstallAppRendering {
|
function Complete-WinUtilInstallAppRendering {
|
||||||
$sync.InstallAppEntriesRendered = $true
|
$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 {
|
function Start-WinUtilInstallAppRendering {
|
||||||
@@ -47,18 +53,15 @@ function Start-WinUtilInstallAppRendering {
|
|||||||
|
|
||||||
$sync.InstallAppEntriesRendered = $false
|
$sync.InstallAppEntriesRendered = $false
|
||||||
|
|
||||||
if ($sync.Form -and $sync.Form.Dispatcher) {
|
Start-WinUtilBackgroundQueue -Name "InstallAppRender" -Queue $sync.InstallAppRenderQueue `
|
||||||
$sync.Form.Dispatcher.BeginInvoke(
|
-RequiresTab "Install" `
|
||||||
[System.Windows.Threading.DispatcherPriority]::Background,
|
-Step { param($CategoryBatch) Invoke-WinUtilInstallAppRenderBatch -CategoryBatch $CategoryBatch } `
|
||||||
[action]{ Invoke-WinUtilInstallAppRenderNextBatch }
|
-OnComplete { Complete-WinUtilInstallAppRendering } `
|
||||||
) | Out-Null
|
-DeferWhile {
|
||||||
return
|
# 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
|
||||||
while ($sync.InstallAppRenderQueue.Count -gt 0) {
|
# half a second to open is.
|
||||||
$categoryBatch = $sync.InstallAppRenderQueue.Dequeue()
|
$sync.TabWarmupQueue -and $sync.TabWarmupQueue.Count -gt 0
|
||||||
Invoke-WinUtilInstallAppRenderBatch -CategoryBatch $categoryBatch
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Complete-WinUtilInstallAppRendering
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
function Start-WinUtilJob {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Runs a long operation off the UI thread with the shared progress, taskbar, log and
|
||||||
|
error handling applied around it
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
One job at a time. Owns the busy flag, the progress bar, the taskbar item, the
|
||||||
|
console banner and the log lines for the job's lifetime. The body does the work and
|
||||||
|
calls Step-WinUtilJob; it must not print a banner or set the busy flag itself. A body
|
||||||
|
that throws is caught and the interface restored in a finally, so a failure cannot
|
||||||
|
leave the UI stuck busy.
|
||||||
|
|
||||||
|
.PARAMETER Name
|
||||||
|
Log component and progress text, for example Install.
|
||||||
|
|
||||||
|
.PARAMETER ScriptBlock
|
||||||
|
The work. Receives Parameters as named parameters.
|
||||||
|
|
||||||
|
.PARAMETER Parameters
|
||||||
|
Values passed to the body by name.
|
||||||
|
|
||||||
|
.PARAMETER Description
|
||||||
|
Progress text shown while the job starts. Defaults to the job name.
|
||||||
|
|
||||||
|
.PARAMETER DisableAppList
|
||||||
|
Greys out the app list for the duration, for jobs that change what is installed.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
Start-WinUtilJob -Name "Install" -Parameters @{ Packages = $packages } -ScriptBlock {
|
||||||
|
param($Packages)
|
||||||
|
Step-WinUtilJob -Status "Installing" -Percent 10
|
||||||
|
}
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[string]$Name,
|
||||||
|
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[scriptblock]$ScriptBlock,
|
||||||
|
|
||||||
|
[hashtable]$Parameters = @{},
|
||||||
|
|
||||||
|
[string]$Description,
|
||||||
|
|
||||||
|
[switch]$DisableAppList
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($sync.ShuttingDown -or $sync.FinishInConsole) {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component $Name -Message "Refused to start $Name, WinUtil is closing."
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# A nested job runs inline: the outer already owns the slot and the reporting, so claiming
|
||||||
|
# again would refuse it and skip its work. Feature installs arrive twice, from feature.json
|
||||||
|
# and from Invoke-WPFFeatureInstall.
|
||||||
|
if ($global:WinUtilIsJobWorker) {
|
||||||
|
& $ScriptBlock @Parameters
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Locked: a headless or scheduled caller is not serialised by the dispatcher, where
|
||||||
|
# test-then-assign lets two jobs both own the slot. The token identifies the run, so a worker
|
||||||
|
# still unwinding cannot release a slot the next job holds.
|
||||||
|
$jobToken = [guid]::NewGuid().ToString()
|
||||||
|
$blockedBy = $null
|
||||||
|
[System.Threading.Monitor]::Enter($sync.SyncRoot)
|
||||||
|
try {
|
||||||
|
if ($sync.ActiveJob) {
|
||||||
|
$blockedBy = $sync.ActiveJob
|
||||||
|
} else {
|
||||||
|
$sync.ActiveJob = $Name
|
||||||
|
$sync.ActiveJobToken = $jobToken
|
||||||
|
$sync.LastJobResult = $null
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
[System.Threading.Monitor]::Exit($sync.SyncRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($blockedBy) {
|
||||||
|
Show-WinUtilMessage -Message "$blockedBy is still running. Wait for it to finish before starting another action." -Title "WinUtil" -Button "OK" -Icon "Warning" | Out-Null
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = if ($Description) { $Description } else { $Name }
|
||||||
|
try {
|
||||||
|
$timingStartIndex = if ($sync.StepTimings) { $sync.StepTimings.Count } else { 0 }
|
||||||
|
|
||||||
|
Write-WinUtilLog -Component $Name -Message "$Name job started."
|
||||||
|
Write-WinUtilJobBanner -Message $label
|
||||||
|
Step-WinUtilJob -Status "$label..." -Percent 0 -State "Normal" -Overlay "logo"
|
||||||
|
|
||||||
|
if ($DisableAppList -and (Test-WinUtilUIAlive)) {
|
||||||
|
Invoke-WPFUIThread -ScriptBlock {
|
||||||
|
if ($null -ne $sync.ItemsControl) { $sync.ItemsControl.IsEnabled = $false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rebuilt from its text inside the runspace: a scriptblock carries the session state it was
|
||||||
|
# defined in, and recreating it there binds it to the worker. The handle is discarded,
|
||||||
|
# printing it puts an IAsyncResult table on the console on every button press.
|
||||||
|
$null = Invoke-WPFRunspace -ParameterList @(
|
||||||
|
("JobName", $Name),
|
||||||
|
("JobLabel", $label),
|
||||||
|
("JobBody", $ScriptBlock.ToString()),
|
||||||
|
("JobParameters", $Parameters),
|
||||||
|
("JobRestoresAppList", [bool]$DisableAppList),
|
||||||
|
("JobToken", $jobToken),
|
||||||
|
("TimingStartIndex", $timingStartIndex)
|
||||||
|
) -ScriptBlock {
|
||||||
|
param($JobName, $JobLabel, $JobBody, $JobParameters, $JobRestoresAppList, $JobToken, $TimingStartIndex)
|
||||||
|
|
||||||
|
# Marks this runspace as the one doing the work, so a pause holds here and not in
|
||||||
|
# whoever asked for it
|
||||||
|
$global:WinUtilIsJobWorker = $true
|
||||||
|
$global:WinUtilJobErrorCount = 0
|
||||||
|
$global:WinUtilJobWarningCount = 0
|
||||||
|
|
||||||
|
$jobClock = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
try {
|
||||||
|
$body = [scriptblock]::Create($JobBody)
|
||||||
|
|
||||||
|
# A worker's warning and error streams buffer on a PowerShell object nobody reads.
|
||||||
|
# Merging them into the output stream is what gets them to the log.
|
||||||
|
& $body @JobParameters 2>&1 3>&1 | ForEach-Object {
|
||||||
|
if ($_ -is [System.Management.Automation.WarningRecord]) {
|
||||||
|
$global:WinUtilJobWarningCount++
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component $JobName -Message $_.Message
|
||||||
|
} elseif ($_ -is [System.Management.Automation.ErrorRecord]) {
|
||||||
|
Write-WinUtilErrorRecord -ErrorRecord $_ -Component $JobName -Context "Non-terminating error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobClock.Stop()
|
||||||
|
|
||||||
|
# A step can fail without throwing, for example a registry write refused by policy.
|
||||||
|
# The counter belongs to this worker runspace, so unrelated UI errors cannot change
|
||||||
|
# this job's result while it is running.
|
||||||
|
$newErrors = $global:WinUtilJobErrorCount
|
||||||
|
$newWarnings = $global:WinUtilJobWarningCount
|
||||||
|
if ($newErrors -gt 0) {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component $JobName -Message "$JobName job finished in $($jobClock.ElapsedMilliseconds) ms with $newErrors error(s)."
|
||||||
|
Write-WinUtilJobBanner -Message "$JobLabel finished with $newErrors error(s), see the log" -Level "ERROR"
|
||||||
|
Step-WinUtilJob -Status "$JobName finished with $newErrors error(s)" -Percent 100 -State "Paused" -Overlay "warning"
|
||||||
|
} elseif ($newWarnings -gt 0) {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component $JobName -Message "$JobName job finished in $($jobClock.ElapsedMilliseconds) ms with $newWarnings warning(s)."
|
||||||
|
Write-WinUtilJobBanner -Message "$JobLabel finished with $newWarnings warning(s), see the log"
|
||||||
|
Step-WinUtilJob -Status "$JobName finished with $newWarnings warning(s)" -Percent 100 -State "Paused" -Overlay "warning"
|
||||||
|
} else {
|
||||||
|
Write-WinUtilLog -Component $JobName -Message "$JobName job finished in $($jobClock.ElapsedMilliseconds) ms."
|
||||||
|
Write-WinUtilJobBanner -Message "$JobLabel finished"
|
||||||
|
Step-WinUtilJob -Status "$JobName finished" -Percent 100 -State "None" -Overlay "checkmark"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$jobClock.Stop()
|
||||||
|
# A leaf that logs before rethrowing marks that exact exception. Preserve the outer
|
||||||
|
# context and stack without counting it twice; unrelated earlier errors do not qualify.
|
||||||
|
$errorAlreadyReported = $_.Exception.Data["WinUtilErrorReported"] -eq $true
|
||||||
|
Write-WinUtilErrorRecord -ErrorRecord $_ -Component $JobName -Context "$JobName failed after $($jobClock.ElapsedMilliseconds) ms" -DetailOnly:$errorAlreadyReported
|
||||||
|
Write-WinUtilJobBanner -Message "$JobLabel failed: $($_.Exception.Message)" -Level "ERROR"
|
||||||
|
Step-WinUtilJob -Status "$JobName failed" -Percent 100 -State "Error" -Overlay "warning"
|
||||||
|
} finally {
|
||||||
|
$jobResult = [pscustomobject]@{
|
||||||
|
Token = $JobToken
|
||||||
|
Errors = $global:WinUtilJobErrorCount
|
||||||
|
Warnings = $global:WinUtilJobWarningCount
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pool runspaces are reused, so leaving this set would make the next piece of
|
||||||
|
# background work on this runspace believe it is a job worker
|
||||||
|
$global:WinUtilIsJobWorker = $false
|
||||||
|
$global:WinUtilJobErrorCount = 0
|
||||||
|
$global:WinUtilJobWarningCount = 0
|
||||||
|
|
||||||
|
Write-WinUtilTimingSummary -Scope $JobName -TotalMilliseconds $jobClock.ElapsedMilliseconds -StartIndex $TimingStartIndex
|
||||||
|
|
||||||
|
# A worker the watchdog cut off can reach here after the next job claimed the slot,
|
||||||
|
# and everything below releases shared state.
|
||||||
|
$stillOwns = $false
|
||||||
|
[System.Threading.Monitor]::Enter($sync.SyncRoot)
|
||||||
|
try {
|
||||||
|
$stillOwns = $sync.ActiveJobToken -eq $JobToken
|
||||||
|
if ($stillOwns) {
|
||||||
|
$sync.LastJobResult = $jobResult
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
[System.Threading.Monitor]::Exit($sync.SyncRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stillOwns) {
|
||||||
|
try {
|
||||||
|
if ($JobRestoresAppList -and (Test-WinUtilUIAlive)) {
|
||||||
|
Invoke-WPFUIThread -ScriptBlock {
|
||||||
|
if ($null -ne $sync.ItemsControl) { $sync.ItemsControl.IsEnabled = $true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component $JobName -Message "Could not restore the app list after $JobName finished: $($_.Exception.Message)"
|
||||||
|
} finally {
|
||||||
|
# Dispatcher shutdown can race the alive check and abort the restore call.
|
||||||
|
# The worker is still finished, so its slot must always be released.
|
||||||
|
$null = Clear-WinUtilActiveJob -Token $JobToken
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component $JobName -Message "$JobName unwound after another job had started; leaving its state alone."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$scheduleError = $_
|
||||||
|
try {
|
||||||
|
Write-WinUtilErrorRecord -ErrorRecord $scheduleError -Component $Name -Context "Could not schedule $Name"
|
||||||
|
$sync.LastJobResult = [pscustomobject]@{ Token = $jobToken; Errors = 1; Warnings = 0 }
|
||||||
|
Write-WinUtilJobBanner -Message "$label could not start" -Level "ERROR"
|
||||||
|
Step-WinUtilJob -Status "$Name could not start" -Percent 100 -State "Error" -Overlay "warning"
|
||||||
|
} catch {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component $Name -Message "Could not report that $Name failed to start: $($_.Exception.Message)"
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if ($DisableAppList -and (Test-WinUtilUIAlive)) {
|
||||||
|
Invoke-WPFUIThread -ScriptBlock {
|
||||||
|
if ($null -ne $sync.ItemsControl) { $sync.ItemsControl.IsEnabled = $true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component $Name -Message "Could not restore the app list after $Name failed to start: $($_.Exception.Message)"
|
||||||
|
} finally {
|
||||||
|
$null = Clear-WinUtilActiveJob -Token $jobToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Clear-WinUtilActiveJob {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Releases the active job slot, clearing its name and its token together
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
A token left set still matches a later run and blocks new work.
|
||||||
|
|
||||||
|
.PARAMETER Token
|
||||||
|
Release only if this run still owns the slot. Omit to release unconditionally.
|
||||||
|
#>
|
||||||
|
param([string]$Token)
|
||||||
|
|
||||||
|
[System.Threading.Monitor]::Enter($sync.SyncRoot)
|
||||||
|
try {
|
||||||
|
if (-not $Token -or $sync.ActiveJobToken -eq $Token) {
|
||||||
|
$sync.ActiveJobToken = $null
|
||||||
|
$sync.ActiveJob = $null
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
return $false
|
||||||
|
} finally {
|
||||||
|
[System.Threading.Monitor]::Exit($sync.SyncRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
function Start-WinUtilTabWarmup {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Builds the tabs the user has not opened yet, while the interface is idle
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Tab content has to be built on the interface thread, so a tab that is still empty
|
||||||
|
when it is first clicked makes that click pay for the build. Queueing the builds
|
||||||
|
moves that cost to where nothing is waiting on it.
|
||||||
|
|
||||||
|
Queued at background priority rather than idle priority. At idle priority this never
|
||||||
|
ran until the app list had finished, which is the exact window in which a tab the
|
||||||
|
user clicks is still empty and costs a full build to open.
|
||||||
|
#>
|
||||||
|
|
||||||
|
# Win11ISO is left out: building it runs the existing work check, which raises the resume
|
||||||
|
# prompt while the user is on another tab. That check belongs to opening the tab, not warming
|
||||||
|
# it.
|
||||||
|
$pending = [System.Collections.Queue]::new()
|
||||||
|
foreach ($tab in @("Tweaks", "Config", "AppX")) {
|
||||||
|
if (-not $sync.InitializedTabs[$tab]) {
|
||||||
|
$pending.Enqueue($tab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pending.Count -eq 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$sync.TabWarmupQueue = $pending
|
||||||
|
Start-WinUtilBackgroundQueue -Name "TabWarmup" -Queue $pending -Step {
|
||||||
|
param($Tab)
|
||||||
|
|
||||||
|
Measure-WinUtilStep -Scope "UI" -Name "warm $Tab tab" -ScriptBlock {
|
||||||
|
Initialize-WinUtilTabContent -TabName $Tab -Yield
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,538 @@
|
|||||||
|
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 <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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
function Step-WinUtilJob {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Advances a job to its next reportable point, honouring a pause or stop on the way
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Every loop calls this, so it is the one point a run reliably passes between steps and
|
||||||
|
therefore the only place it can be held or ended without cutting into a command in
|
||||||
|
flight. It blocks while the run is paused and throws OperationCanceledException once
|
||||||
|
a stop is asked for, so calling it from a finally, or from a catch already reporting
|
||||||
|
a failure, re-raises that stop. The job layer clears the flags before its own finish
|
||||||
|
reporting for that reason.
|
||||||
|
|
||||||
|
Drives the progress bar and taskbar item together and does nothing without a window,
|
||||||
|
so job bodies need no UI checks. The update is posted rather than waited on: a job
|
||||||
|
reporting per item would otherwise stall on the interface thread each time.
|
||||||
|
|
||||||
|
.PARAMETER Status
|
||||||
|
Text for the progress label
|
||||||
|
|
||||||
|
.PARAMETER Percent
|
||||||
|
Completion between 0 and 100
|
||||||
|
|
||||||
|
.PARAMETER State
|
||||||
|
Taskbar state. Normal while working, Error on failure, None when finished.
|
||||||
|
|
||||||
|
.PARAMETER Overlay
|
||||||
|
Taskbar overlay icon: logo, checkmark, warning or None
|
||||||
|
|
||||||
|
.PARAMETER Hide
|
||||||
|
Clears and hides the progress bar. Used when leaving a finished job behind rather
|
||||||
|
than while one is running.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$Status,
|
||||||
|
[int]$Percent = -1,
|
||||||
|
[ValidateSet("Normal", "Error", "Paused", "Indeterminate", "None")]
|
||||||
|
[string]$State,
|
||||||
|
[string]$Overlay,
|
||||||
|
[switch]$Hide
|
||||||
|
)
|
||||||
|
|
||||||
|
# With no window every update is thrown away, and a window closed over running work counts
|
||||||
|
# as none: its dispatcher accepts posts and discards them. The console is what is left.
|
||||||
|
if (-not (Test-WinUtilUIAlive)) {
|
||||||
|
if (-not $Hide) {
|
||||||
|
Write-WinUtilConsoleProgress -Status $Status -Percent $Percent
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Invoke-WPFUIThread -Async -Parameters @{
|
||||||
|
Status = $Status
|
||||||
|
Percent = [Math]::Min([Math]::Max($Percent, -1), 100)
|
||||||
|
State = $State
|
||||||
|
Overlay = $Overlay
|
||||||
|
HideBar = [bool]$Hide
|
||||||
|
HasStatus = $PSBoundParameters.ContainsKey('Status')
|
||||||
|
HasState = $PSBoundParameters.ContainsKey('State')
|
||||||
|
HasOverlay = $PSBoundParameters.ContainsKey('Overlay')
|
||||||
|
} -ScriptBlock {
|
||||||
|
param($Status, $Percent, $State, $Overlay, $HideBar, $HasStatus, $HasState, $HasOverlay)
|
||||||
|
|
||||||
|
if ($HideBar) {
|
||||||
|
$sync.WPFTweaksProgressBar.Visibility = [Windows.Visibility]::Collapsed
|
||||||
|
$sync.WPFTweaksProgressLabel.Text = ""
|
||||||
|
$sync.WPFTweaksProgressLabel.ToolTip = $null
|
||||||
|
$sync.WPFTweaksProgressValue.Value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasPercent = $Percent -ge 0
|
||||||
|
|
||||||
|
if ($HasStatus -or $hasPercent) {
|
||||||
|
$sync.WPFTweaksProgressBar.Visibility = [Windows.Visibility]::Visible
|
||||||
|
}
|
||||||
|
if ($HasStatus) {
|
||||||
|
$sync.WPFTweaksProgressLabel.Text = $Status
|
||||||
|
$sync.WPFTweaksProgressLabel.ToolTip = $Status
|
||||||
|
}
|
||||||
|
if ($hasPercent) {
|
||||||
|
$sync.WPFTweaksProgressValue.Value = $Percent
|
||||||
|
$sync.Form.TaskbarItemInfo.ProgressValue = $Percent / 100
|
||||||
|
}
|
||||||
|
if ($HasState) {
|
||||||
|
# Pulse in place at whatever progress has been reached. IsIndeterminate would make
|
||||||
|
# WPF discard Value and fill the whole bar, which reads as finished.
|
||||||
|
$sync.WPFTweaksProgressValue.Tag = if ($State -eq "Indeterminate") { "Pulse" } else { $null }
|
||||||
|
|
||||||
|
# By resource reference rather than a fixed brush, so switching theme repaints it
|
||||||
|
$barColor = switch ($State) {
|
||||||
|
"Error" { "ProgressBarErrorColor" }
|
||||||
|
"Paused" { "ProgressBarWarningColor" }
|
||||||
|
default { "ProgressBarForegroundColor" }
|
||||||
|
}
|
||||||
|
$sync.WPFTweaksProgressValue.SetResourceReference([Windows.Controls.Control]::ForegroundProperty, $barColor)
|
||||||
|
|
||||||
|
Set-WinUtilTaskbaritem -state $State
|
||||||
|
}
|
||||||
|
if ($HasOverlay) {
|
||||||
|
Set-WinUtilTaskbaritem -overlay $Overlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
function Test-WinUtilShellRunning {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Whether one instance is still running, treating a disposed one as finished
|
||||||
|
#>
|
||||||
|
param($PowerShell)
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $PowerShell.InvocationStateInfo.State -in @(
|
||||||
|
[System.Management.Automation.PSInvocationState]::Running,
|
||||||
|
[System.Management.Automation.PSInvocationState]::Stopping
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Register-WinUtilActiveShell {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Records a PowerShell instance that is running on the worker pool
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
An instance still queued when the pool closes starts on a closing runspace, throws on
|
||||||
|
a thread pool thread where nothing catches, and takes the process down with it.
|
||||||
|
Tracking what is in flight is what lets those be stopped first.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
$PowerShell
|
||||||
|
)
|
||||||
|
|
||||||
|
# Synchronized protects one operation, not a test followed by an assignment, so the
|
||||||
|
# collection is created under the shared lock
|
||||||
|
[System.Threading.Monitor]::Enter($sync.SyncRoot)
|
||||||
|
try {
|
||||||
|
if ($null -eq $sync.ActiveShells) {
|
||||||
|
$sync.ActiveShells = [System.Collections.ArrayList]::Synchronized([System.Collections.ArrayList]::new())
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
[System.Threading.Monitor]::Exit($sync.SyncRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Nothing disposes these on the way out, so finished ones are dropped here instead of
|
||||||
|
# accumulating for the life of the session
|
||||||
|
foreach ($finished in (Get-WinUtilActiveShell)) {
|
||||||
|
if (-not (Test-WinUtilShellRunning $finished)) {
|
||||||
|
try {
|
||||||
|
$sync.ActiveShells.Remove($finished)
|
||||||
|
} catch {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Could not remove a completed worker from the active set: $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$null = $sync.ActiveShells.Add($PowerShell)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-WinUtilActiveShell {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
A snapshot of the tracked instances, copied under the collection's own lock
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Enumerating a synchronized ArrayList is not itself synchronized; a concurrent Add or
|
||||||
|
Remove throws mid-loop. SyncRoot is the documented fix.
|
||||||
|
#>
|
||||||
|
|
||||||
|
if ($null -eq $sync.ActiveShells) {
|
||||||
|
return @()
|
||||||
|
}
|
||||||
|
|
||||||
|
[System.Threading.Monitor]::Enter($sync.ActiveShells.SyncRoot)
|
||||||
|
try {
|
||||||
|
return @($sync.ActiveShells.ToArray())
|
||||||
|
} finally {
|
||||||
|
[System.Threading.Monitor]::Exit($sync.ActiveShells.SyncRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-WinUtilActiveWork {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Asks everything running on the worker pool to stop, and waits for it
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Stop is a request, not a kill: a command already inside an installer runs until it
|
||||||
|
returns. The wait is bounded so a worker that never returns cannot hold the window
|
||||||
|
open.
|
||||||
|
|
||||||
|
.PARAMETER TimeoutSeconds
|
||||||
|
How long to wait before giving up on it.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[int]$TimeoutSeconds = 15,
|
||||||
|
|
||||||
|
# Issue the stop and return. The caller polls Test-WinUtilActiveWorkRunning instead of
|
||||||
|
# blocking here, which matters on the interface thread where a wait freezes the window.
|
||||||
|
[switch]$NoWait
|
||||||
|
)
|
||||||
|
|
||||||
|
$shells = Get-WinUtilActiveShell
|
||||||
|
if ($shells.Count -eq 0) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-WinUtilLog -Component "UI" -Message "Stopping $($shells.Count) running item(s) before closing."
|
||||||
|
|
||||||
|
foreach ($shell in $shells) {
|
||||||
|
if (Test-WinUtilShellRunning $shell) {
|
||||||
|
try {
|
||||||
|
$null = $shell.BeginStop($null, $null)
|
||||||
|
} catch {
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Could not request that a worker stop: $_"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($NoWait) {
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
$clock = [System.Diagnostics.Stopwatch]::StartNew()
|
||||||
|
while ($clock.Elapsed.TotalSeconds -lt $TimeoutSeconds) {
|
||||||
|
$stillRunning = @(Get-WinUtilActiveShell | Where-Object { Test-WinUtilShellRunning $_ }).Count
|
||||||
|
|
||||||
|
if ($stillRunning -eq 0) {
|
||||||
|
Write-WinUtilLog -Component "UI" -Message "Everything stopped after $($clock.ElapsedMilliseconds) ms."
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
Start-Sleep -Milliseconds 100
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-WinUtilLog -Level "WARN" -Component "UI" -Message "Gave up waiting for work to stop after $TimeoutSeconds seconds, closing anyway."
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-WinUtilActiveWorkRunning {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Whether any tracked instance is still running
|
||||||
|
#>
|
||||||
|
|
||||||
|
foreach ($shell in (Get-WinUtilActiveShell)) {
|
||||||
|
if (Test-WinUtilShellRunning $shell) { return $true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
function Register-WinUtilInputWatch {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Records when the user last did something, so background work can step aside
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Preview events run before the control handles the input, so the timestamp is set
|
||||||
|
even for a click the control then spends time on.
|
||||||
|
#>
|
||||||
|
|
||||||
|
$sync.LastInputAt = [datetime]::MinValue
|
||||||
|
|
||||||
|
$stamp = { $sync.LastInputAt = [datetime]::Now }
|
||||||
|
$sync.Form.Add_PreviewMouseDown($stamp)
|
||||||
|
$sync.Form.Add_PreviewKeyDown($stamp)
|
||||||
|
$sync.Form.Add_PreviewMouseWheel($stamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-WinUtilDeferBackgroundWork {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Whether speculative work should wait rather than run now
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Background priority queues work behind input but does not make it interruptible:
|
||||||
|
whatever is running must finish before a click is looked at, which is why the pieces
|
||||||
|
are kept short. Waits while the user is active, or while the work draws into a tab
|
||||||
|
that is not on screen.
|
||||||
|
|
||||||
|
.PARAMETER RequiresTab
|
||||||
|
The tab this work draws into. Work for a hidden tab waits.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[string]$RequiresTab
|
||||||
|
)
|
||||||
|
|
||||||
|
if ($sync.LastInputAt) {
|
||||||
|
$sinceInput = ([datetime]::Now - $sync.LastInputAt).TotalMilliseconds
|
||||||
|
# long enough to cover a click and the work it starts, short enough not to be noticed
|
||||||
|
if ($sinceInput -lt 400) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($RequiresTab -and $sync.currentTab -and $sync.currentTab -ne $RequiresTab) {
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-WinUtilWhenIdle {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Runs a callback once the interface is not being used
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
A one shot timer, not a dispatcher post: a post at background priority runs straight
|
||||||
|
away and the point is to leave a gap.
|
||||||
|
|
||||||
|
.PARAMETER Callback
|
||||||
|
What to run once the wait is over.
|
||||||
|
|
||||||
|
.PARAMETER Argument
|
||||||
|
Passed to the callback. Carried on the timer rather than captured, so the callback
|
||||||
|
resolves commands where it was written, not in a copied scope.
|
||||||
|
|
||||||
|
.PARAMETER DelayMilliseconds
|
||||||
|
How long to wait before looking again.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)]
|
||||||
|
[scriptblock]$Callback,
|
||||||
|
|
||||||
|
$Argument,
|
||||||
|
|
||||||
|
[int]$DelayMilliseconds = 150
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-WinUtilUIAlive)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bound to the interface dispatcher explicitly: the default picks up the calling thread's,
|
||||||
|
# which is only correct while every caller reaches here through a UI post
|
||||||
|
$timer = New-Object System.Windows.Threading.DispatcherTimer([System.Windows.Threading.DispatcherPriority]::Background, $sync.Form.Dispatcher)
|
||||||
|
$timer.Interval = [timespan]::FromMilliseconds($DelayMilliseconds)
|
||||||
|
$timer.Tag = @{ Callback = $Callback; Argument = $Argument }
|
||||||
|
# Sender taken from the argument, matching how the rest of this codebase handles timer ticks
|
||||||
|
$timer.Add_Tick({
|
||||||
|
param($eventSender)
|
||||||
|
$ticked = [System.Windows.Threading.DispatcherTimer]$eventSender
|
||||||
|
$ticked.Stop()
|
||||||
|
& $ticked.Tag.Callback $ticked.Tag.Argument
|
||||||
|
})
|
||||||
|
$timer.Start()
|
||||||
|
}
|
||||||
@@ -19,28 +19,16 @@ function Test-WinUtilPackageManager {
|
|||||||
|
|
||||||
if ($winget) {
|
if ($winget) {
|
||||||
if (Get-Command winget -ErrorAction SilentlyContinue) {
|
if (Get-Command winget -ErrorAction SilentlyContinue) {
|
||||||
Write-Host "===========================================" -ForegroundColor Green
|
|
||||||
Write-Host "--- WinGet is installed ---" -ForegroundColor Green
|
|
||||||
Write-Host "===========================================" -ForegroundColor Green
|
|
||||||
$status = "installed"
|
$status = "installed"
|
||||||
} else {
|
} else {
|
||||||
Write-Host "===========================================" -ForegroundColor Red
|
|
||||||
Write-Host "--- WinGet is not installed ---" -ForegroundColor Red
|
|
||||||
Write-Host "===========================================" -ForegroundColor Red
|
|
||||||
$status = "not-installed"
|
$status = "not-installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($choco) {
|
if ($choco) {
|
||||||
if (Get-Command choco -ErrorAction SilentlyContinue) {
|
if (Get-Command choco -ErrorAction SilentlyContinue) {
|
||||||
Write-Host "===========================================" -ForegroundColor Green
|
|
||||||
Write-Host "--- Chocolatey is installed ---" -ForegroundColor Green
|
|
||||||
Write-Host "===========================================" -ForegroundColor Green
|
|
||||||
$status = "installed"
|
$status = "installed"
|
||||||
} else {
|
} else {
|
||||||
Write-Host "===========================================" -ForegroundColor Red
|
|
||||||
Write-Host "--- Chocolatey is not installed ---" -ForegroundColor Red
|
|
||||||
Write-Host "===========================================" -ForegroundColor Red
|
|
||||||
$status = "not-installed"
|
$status = "not-installed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user