mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-10-08 11:39:10 +11:00
Compare commits
85
Commits
26.08.04
...
48885d7c1e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
086aecf4b7 | ||
|
|
436773df73 | ||
|
|
4b2fe55a7a | ||
|
|
b8b81edcdb | ||
|
|
a7074963a6 | ||
|
|
032ce8ead0 | ||
|
|
792122e998 | ||
|
|
2d0fd43bd3 | ||
|
|
219b2fe137 | ||
|
|
527eefb114 | ||
|
|
d1b8470af2 | ||
|
|
6066675f93 | ||
|
|
b21d416161 | ||
|
|
87a0eb8637 | ||
|
|
8500ed116f | ||
|
|
d16ee0e9f1 | ||
|
|
5ba8686dd7 | ||
|
|
3f1850f623 | ||
|
|
0dbe39bc7d | ||
|
|
91972b0853 | ||
|
|
aee3e7a1f4 | ||
|
|
ea5de27b6c | ||
|
|
2752fe2e03 | ||
|
|
1458327638 | ||
|
|
ea7fcf9d2b | ||
|
|
0aa4ab3a40 | ||
|
|
afc3e1eec2 | ||
|
|
53fc260cc0 | ||
|
|
bc607b6c91 | ||
|
|
7f18b4fd60 | ||
|
|
32ab9f0ab0 | ||
|
|
9fdadd1c8f | ||
|
|
6de45a38b9 | ||
|
|
8860caf357 | ||
|
|
8fb078da4c | ||
|
|
95e3a54405 | ||
|
|
c817d3329a | ||
|
|
19f938e876 | ||
|
|
d64bb18717 | ||
|
|
5ad168e0c4 | ||
|
|
8d3adb599f | ||
|
|
f7c072341d | ||
|
|
32cc623959 | ||
|
|
b096aaa5e5 |
+4
-1
@@ -3,4 +3,7 @@
|
||||
|
||||
# Seanh1917 (ANGRYxScotsmans) is the docs guy
|
||||
docs/ @ChrisTitusTech @seanh1995
|
||||
tools/devdocs-generator.* @ChrisTitusTech @seanh1995
|
||||
tools/devdocs-generator.* @ChrisTitusTech @seanh1995
|
||||
|
||||
# Title screen generation
|
||||
tools/title-screen/ @ChrisTitusTech @mewclouds
|
||||
|
||||
@@ -91,7 +91,7 @@ graph TD
|
||||
* Run the following command to compile and run WinUtil:
|
||||
* `.\Compile.ps1 -run`
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
Open PowerShell as Administrator.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
name: "Bug report"
|
||||
description: "Report a bug to help us identify and fix issues in the project."
|
||||
title: "[Bug Report] - <your summary here>"
|
||||
labels: ["bug"]
|
||||
|
||||
body:
|
||||
@@ -42,14 +43,16 @@ body:
|
||||
- type: textarea
|
||||
id: issue_description
|
||||
attributes:
|
||||
label: Provide a clear and concise description of the issue.
|
||||
label: "Description of the issue"
|
||||
description: Provide a clear and concise description of the issue.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: tweaks_applied
|
||||
attributes:
|
||||
label: List the tweaks you applied before the issue occurred.
|
||||
label: "List of Applied Tweaks"
|
||||
description: List the tweaks you applied before the issue occurred.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -57,4 +60,5 @@ body:
|
||||
id: error_output
|
||||
attributes:
|
||||
label: Paste the full error output (if available) or Screenshot or Video.
|
||||
placeholder: "Include any relevant logs or error messages."
|
||||
description: "If you're willing to share the whole log, please provide an external link to text-sharing service, i.e. Pastebin"
|
||||
placeholder: "Include any relevant logs or error messages. Keep any sensitive info out of the presented log"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
name: "Feature request"
|
||||
description: "Suggest a new feature or improvement for the project."
|
||||
title: "[Feature Request] - <your summary here>"
|
||||
labels: ["enhancement"]
|
||||
|
||||
body:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copilot Instructions
|
||||
|
||||
Read `AGENTS.md` in the repository root for operating instructions before doing anything else.
|
||||
@@ -0,0 +1,207 @@
|
||||
name: Generate WinUtil title screen
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: winutil-title-screen
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
WINUTIL_CAPTURE_WIDTH: "1920"
|
||||
WINUTIL_CAPTURE_HEIGHT: "1080"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: pwsh
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install uv and Python
|
||||
uses: astral-sh/setup-uv@v10.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
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
name: Repo Contributors' Issue Triage Tools
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
triage-tools:
|
||||
if: ${{ !github.event.issue.pull_request }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: none
|
||||
contents: none
|
||||
steps:
|
||||
- name: Process issue command
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const trustedUsers = [
|
||||
7896101, // ChrisTitusTech
|
||||
57459428, // FluffyPunk
|
||||
125669256, // FallenGME
|
||||
90123670, // mewclouds
|
||||
121827219, // MyDrift-user
|
||||
17331812, // Callum
|
||||
101426328, // CodingWonders
|
||||
70659536 // og-mrk
|
||||
];
|
||||
|
||||
if (context.payload.issue.pull_request) {
|
||||
console.log("Pull request comments are not supported. Exiting.");
|
||||
return;
|
||||
}
|
||||
|
||||
const comment = context.payload.comment;
|
||||
const commentAuthor = comment.user;
|
||||
|
||||
if (!trustedUsers.includes(commentAuthor.id)) {
|
||||
console.log(`Comment author ${commentAuthor.login} is not a trusted user. Exiting.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const {owner, repo} = context.repo;
|
||||
const issueNumber = context.issue.number;
|
||||
const command = comment.body.trim().toLowerCase();
|
||||
const triageMatch = command.match(/^\/triage$/);
|
||||
const triageOffMatch = command.match(/^\/triageoff$/);
|
||||
const notPlannedMatch = command.match(/^\/np(?:\s+(\w+))?$/);
|
||||
const duplicateMatch = command.match(/^\/duplicate\s+#?(\d+)$/);
|
||||
|
||||
if (triageMatch) {
|
||||
const issue = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
|
||||
if (issue.data.labels.some(label => label.name === "needs-triage")) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: "This issue already has the 'needs-triage' label. Use /triageoff to remove it."
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
labels: ["needs-triage"]
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: `${commentAuthor.login} requested maintainer triage for this issue.`
|
||||
});
|
||||
}
|
||||
} else if (triageOffMatch) {
|
||||
const issue = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber
|
||||
});
|
||||
|
||||
if (issue.data.labels.some(label => label.name === "needs-triage")) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
name: "needs-triage"
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: `${commentAuthor.login} removed the triage request from this issue.`
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: "This issue is not currently marked for triage."
|
||||
});
|
||||
}
|
||||
} else if (notPlannedMatch) {
|
||||
const reason = notPlannedMatch[1];
|
||||
|
||||
if (reason === "wontfix") {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
labels: ["wontfix"]
|
||||
});
|
||||
} else if (reason === "notrelated") {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
labels: ["not-related"]
|
||||
});
|
||||
}
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
state: "closed",
|
||||
state_reason: "not_planned"
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: `${commentAuthor.login} closed this issue as not planned.`
|
||||
});
|
||||
} else if (duplicateMatch) {
|
||||
const duplicateIssueNumber = Number(duplicateMatch[1]);
|
||||
|
||||
if (!Number.isSafeInteger(duplicateIssueNumber) || duplicateIssueNumber < 1) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: "The duplicate target must be a positive issue number."
|
||||
});
|
||||
} else if (duplicateIssueNumber === issueNumber) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: "An issue cannot be marked as a duplicate of itself."
|
||||
});
|
||||
} else {
|
||||
let duplicateIssue;
|
||||
try {
|
||||
const response = await github.rest.issues.get({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: duplicateIssueNumber
|
||||
});
|
||||
duplicateIssue = response.data;
|
||||
} catch (error) {
|
||||
if (error.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (duplicateIssue?.pull_request) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: `#${duplicateIssueNumber} is a pull request, not an issue.`
|
||||
});
|
||||
} else if (duplicateIssue) {
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
state: "closed",
|
||||
state_reason: "duplicate",
|
||||
duplicate_issue_id: duplicateIssue.id
|
||||
});
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: `${commentAuthor.login} closed this issue as a duplicate of #${duplicateIssueNumber}.`
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: `Issue #${duplicateIssueNumber} does not exist.`
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log("Comment does not contain a supported issue command. Exiting.");
|
||||
return;
|
||||
}
|
||||
|
||||
await github.rest.issues.deleteComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: comment.id
|
||||
});
|
||||
+3
-5
@@ -10,11 +10,9 @@ testResults.xml
|
||||
# general software/os specific
|
||||
desktop.ini
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# hugo files (archived docs)
|
||||
docs-old/public/
|
||||
docs-old/.hugo_build.lock
|
||||
docs-old/resources/
|
||||
|
||||
@@ -4,35 +4,22 @@ Drop-in operating instructions for coding agents. Read this file before every ta
|
||||
|
||||
**Working code only. Finish the job. Plausibility is not correctness.**
|
||||
|
||||
This repository follows the AGENTS.md convention: these instructions are for Codex, Claude Code, Cursor, Windsurf, Copilot, Aider, Devin, Amp, and other coding agents that read `AGENTS.md`.
|
||||
`SPEC.md` in the repository root is the project contract — read it for what WinUtil is and how it's architected. This file covers how to work on it.
|
||||
|
||||
## 0. Non-Negotiables
|
||||
|
||||
These rules override everything else in this file when in conflict:
|
||||
|
||||
1. **Do not edit `winutil.ps1` directly.** It is generated build output. Change source files and compile.
|
||||
1. **Do not edit `winutil.ps1` directly.** It is generated build output (see SPEC.md's Build Model). Change source files and compile.
|
||||
2. **Do not commit `winutil.ps1`.** It is ignored locally and generated by GitHub Actions for releases.
|
||||
3. **Never fabricate.** Do not invent file paths, function names, command output, test results, commit hashes, or API behavior. Read the file or run the command.
|
||||
4. **Disagree when the premise is wrong.** Say what is wrong before acting on it.
|
||||
5. **Stop when genuinely ambiguous.** If two interpretations would produce materially different diffs, ask before editing.
|
||||
6. **Touch only what the task requires.** No drive-by refactors, formatting sweeps, or unrelated cleanup.
|
||||
7. **Verify before saying done.** A plausible-looking diff is not proof.
|
||||
3. **Never touch `docs/src/content/docs/code-reference/tweaks/` or `docs/src/content/docs/code-reference/features/`.** Both are auto-generated (see SPEC.md's Docs Site). Edit the source JSON (`config/tweaks.json`, `config/feature.json`) or the relevant PowerShell function file instead. Other hand-written pages under `code-reference/` (e.g. `architecture.mdx`) are not touched by the generator and may be edited directly.
|
||||
4. **Never fabricate.** Do not invent file paths, function names, command output, test results, commit hashes, or API behavior. Read the file or run the command.
|
||||
5. **Disagree when the premise is wrong.** Say what is wrong before acting on it.
|
||||
6. **Stop when genuinely ambiguous.** If two interpretations would produce materially different diffs, ask before editing.
|
||||
7. **Touch only what the task requires.** No drive-by refactors, formatting sweeps, or unrelated cleanup.
|
||||
8. **Verify before saying done.** A plausible-looking diff is not proof.
|
||||
|
||||
## 1. Project Context
|
||||
|
||||
WinUtil is a Windows PowerShell utility with a WPF interface. The repository is maintained as modular source, but the distributed artifact is one compiled PowerShell script.
|
||||
|
||||
### Stack
|
||||
|
||||
- Language: Windows PowerShell / PowerShell.
|
||||
- UI: WPF via `xaml/inputXML.xaml`.
|
||||
- Configuration: JSON files under `config/`.
|
||||
- Tests: Pester tests under `pester/`.
|
||||
- Lint: PowerShell Script Analyzer with settings in `lint/PSScriptAnalyser.ps1`.
|
||||
- Docs: Hugo site under `docs/`.
|
||||
- Release artifact: generated root `winutil.ps1`.
|
||||
|
||||
### Key Commands
|
||||
## 1. Key Commands
|
||||
|
||||
- Compile:
|
||||
```powershell
|
||||
@@ -42,48 +29,47 @@ WinUtil is a Windows PowerShell utility with a WPF interface. The repository is
|
||||
```powershell
|
||||
.\Compile.ps1 -Run
|
||||
```
|
||||
- Install the supported Pester version (one-time). `-SkipPublisherCheck` is required because Windows ships an inbox Pester 3.4.0 that is catalog-signed, and PowerShell Gallery's Pester 5.8.0 is Authenticode-signed — `Install-Module` refuses the upgrade without it. This does not skip download integrity (still HTTPS + NuGet package hash verification); `-Repository PSGallery` pins the trusted source explicitly rather than relying on whatever repositories happen to be registered:
|
||||
```powershell
|
||||
Install-Module -Name Pester -RequiredVersion 5.8.0 -Repository PSGallery -Scope CurrentUser -Force -SkipPublisherCheck
|
||||
```
|
||||
- Run tests:
|
||||
```powershell
|
||||
Import-Module Pester -RequiredVersion 5.8.0 -Force
|
||||
Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed
|
||||
Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed -CI
|
||||
```
|
||||
- Run Script Analyzer with project settings when available:
|
||||
- Run Script Analyzer with project settings when available. If a locally compiled `winutil.ps1` exists, delete it first — `lint/PSScriptAnalyser.ps1` only excludes rules, not files, so `-Recurse` would also lint the generated script and produce noise against line numbers that don't map to any source file:
|
||||
```powershell
|
||||
Invoke-ScriptAnalyzer -Path . -Settings .\lint\PSScriptAnalyser.ps1 -Recurse
|
||||
```
|
||||
- Docs site dev server (run from `docs/`; see Section 2 for why this goes through Docker):
|
||||
```powershell
|
||||
docker compose up winutil-astro
|
||||
```
|
||||
- Docs site production build (run from `docs/`):
|
||||
```powershell
|
||||
docker compose run --rm winutil-astro npm run build
|
||||
```
|
||||
|
||||
Prefer the narrowest useful verification while iterating. Use the full relevant check before finishing.
|
||||
|
||||
## 2. Source Of Truth
|
||||
## 2. Dependency Installs, Builds, And Dev Servers
|
||||
|
||||
Make durable changes only in files consumed by `Compile.ps1` or in documentation/test files:
|
||||
Given the current wave of npm/pnpm/yarn supply-chain worms (malicious postinstall/preinstall scripts, credential-stealing packages): **never run npm/pnpm/yarn/npx directly on the host, full stop.** The docs site (`docs/`) is the only npm-based project in this repo; always run its tooling inside Docker via `docs/Dockerfile` and `docs/docker-compose.yml` (service `winutil-astro`).
|
||||
|
||||
- `scripts/start.ps1` for startup/bootstrap code.
|
||||
- `functions/public/*.ps1` for UI-facing and user-facing workflows.
|
||||
- `functions/private/*.ps1` for internal helpers.
|
||||
- `config/*.json` for applications, tweaks, features, DNS, presets, navigation, themes, and related declarative data.
|
||||
- `xaml/inputXML.xaml` for the WPF UI layout.
|
||||
- `tools/autounattend.xml` for the embedded unattended Windows setup template.
|
||||
- `scripts/main.ps1` for the final entrypoint and GUI initialization logic appended during compile.
|
||||
- `pester/*.Tests.ps1` for automated checks.
|
||||
- `docs/` for Hugo documentation.
|
||||
- Never run `npm install`, `npm run <script>`, `npx <pkg>`, `pnpm`, or `yarn` directly on the host shell in `docs/`. Use `docker compose run --rm winutil-astro <command>` / `docker compose up winutil-astro` instead (see Section 1 for the exact commands).
|
||||
- If a task needs a new docs dependency, add it to `docs/package.json` yourself, then rebuild the image and drop the `node_modules` volume so it repopulates from the new image (run from `docs/`): `docker compose build winutil-astro`, then `docker compose down -v`. Docker only seeds a named volume from the image the first time it's created, so a plain rebuild silently leaves the old `node_modules` in place. Don't install packages on the host, even temporarily, "just to check something."
|
||||
- If Docker isn't available on the host, propose the install command for the current OS and wait for confirmation before running it — don't fall back to running npm on the host instead. If the daemon just isn't running (Docker is installed but not started), tell the user rather than trying to start it yourself.
|
||||
- Treat any `postinstall`/`preinstall` lifecycle script in a new dependency as worth flagging to the user before installing — summarize what it does.
|
||||
- Don't put real secrets anywhere under `docs/`. `docs/.dockerignore` only trims what `docker build` copies into the image — it does not affect the `docker compose` bind mount, which exposes the entire `docs/` directory (including any `.env` file) inside the container for every dev/build/preview command (see the next bullet). There is no "keep it out unless mounted" middle ground here.
|
||||
- The container mounts `docs/` as a volume, so file edits on the host are reflected inside the container immediately — no rebuild needed for normal code changes, only when `docs/package.json`/`docs/package-lock.json` change (see the rebuild-and-drop-volume steps above).
|
||||
- This Docker requirement is specific to `docs/`. PowerShell tooling runs directly on the host per Section 1. The Python project under `tools/title-screen/` runs with uv as documented in its README.
|
||||
|
||||
If behavior changes require the compiled script to change, update these source files and run `.\Compile.ps1` only to verify generation.
|
||||
## 3. Source Of Truth
|
||||
|
||||
## 3. Build Model
|
||||
For changes that affect the compiled WinUtil script, make them only in the source files described in SPEC.md's Repository Layout — never in `winutil.ps1` itself. If behavior changes require the compiled script to change, update the source files and run `.\Compile.ps1` only to verify generation.
|
||||
|
||||
`Compile.ps1` combines the repository sources into `winutil.ps1` in this order:
|
||||
|
||||
1. Read `scripts/start.ps1` and replace `#{replaceme}` with the current `yy.MM.dd` build date.
|
||||
2. Append every file under `functions/` recursively.
|
||||
3. Convert each `config/*.json` file into embedded `$sync.configs` objects.
|
||||
4. Special-case `config/applications.json` so keys receive the `WPFInstall` prefix in compiled config.
|
||||
5. Embed `xaml/inputXML.xaml` into `$inputXML`.
|
||||
6. Embed `tools/autounattend.xml` into `$WinUtilAutounattendXml`.
|
||||
7. Append `scripts/main.ps1`.
|
||||
8. Write the result to root `winutil.ps1`.
|
||||
|
||||
Because the final script is concatenated, do not rely on runtime module imports or source-relative dot-sourcing unless the compiled script will also contain the required code/data.
|
||||
This scoping applies to compiled-script behavior only. Repository metadata — `AGENTS.md`, `SPEC.md`, `CLAUDE.md`/`GEMINI.md`/`.github/copilot-instructions.md`, `.github/workflows/`, and the root `.gitignore` — is edited directly when a task requires it, per the other sections of this file.
|
||||
|
||||
## 4. Before Editing
|
||||
|
||||
@@ -97,11 +83,10 @@ Because the final script is concatenated, do not rely on runtime module imports
|
||||
|
||||
- Prefer the minimum code that solves the stated problem.
|
||||
- Keep PowerShell functions in one function file when practical, with the file name matching the primary function name.
|
||||
- Use approved PowerShell verb-noun names and follow the existing `WPF` / `WinUtil` naming conventions.
|
||||
- Keep UI event handler names aligned with XAML element names. A button named `WPFExampleButton` is typically handled by `Invoke-WPFExampleButton`.
|
||||
- Use `$sync` for shared state and UI references, consistent with the existing runspace model.
|
||||
- Use approved PowerShell verb-noun names and follow the existing `WPF` / `WinUtil` naming conventions; keep UI event handler names aligned with XAML element names per SPEC.md's UI And Event Contract.
|
||||
- Use `$sync` for shared state and UI references, consistent with SPEC.md's Runtime Model.
|
||||
- Update WPF controls through the UI dispatcher when running work in a background runspace.
|
||||
- Keep config-driven features in JSON when they fit the existing schema instead of hard-coding lists in PowerShell.
|
||||
- Keep config-driven features in JSON when they fit the existing schema instead of hard-coding lists in PowerShell; follow SPEC.md's Configuration Contract for required fields and key-renaming rules.
|
||||
- Preserve undo/original-state data for tweaks so users can reverse changes.
|
||||
- Do not add abstractions, configurability, hooks, or "future extensibility" unless the task needs them now.
|
||||
- Clean up orphans created by your own changes, such as unused variables or functions made obsolete by the edit.
|
||||
@@ -109,7 +94,7 @@ Because the final script is concatenated, do not rely on runtime module imports
|
||||
|
||||
## 6. Runtime And Safety Rules
|
||||
|
||||
- WinUtil performs system-level Windows changes. Treat registry, services, AppX removal, package manager, Windows Update, ISO, and unattended setup changes as high-risk.
|
||||
- WinUtil performs system-level Windows changes; treat registry, services, AppX removal, package manager, Windows Update, ISO, and unattended setup changes as high-risk (see SPEC.md's Safety Requirements).
|
||||
- Prefer existing helper functions for WinGet, Chocolatey, registry, services, progress, and UI updates.
|
||||
- Keep tweaks reversible where the schema supports it by including original values or original states.
|
||||
- Never modify a user's original ISO in-place; follow existing copy/mount/export patterns.
|
||||
@@ -136,24 +121,27 @@ Define success in terms that can be checked, then check it.
|
||||
- Read command output. Do not report tests as passing unless they actually passed.
|
||||
- If verification fails, fix the cause rather than weakening the test.
|
||||
|
||||
If a check cannot be run, say exactly why and what residual risk remains.
|
||||
If a check cannot be run, say exactly why and what residual risk remains. See SPEC.md's Testing And CI for what GitHub Actions runs on every push.
|
||||
|
||||
## 9. Generated Files And Git Hygiene
|
||||
|
||||
- Treat local `winutil.ps1` changes as disposable compile output.
|
||||
- Never stage or commit `winutil.ps1`, `docs/public/`, `docs/resources/`, `binary/`, editor folders, or other ignored build artifacts.
|
||||
- Never stage or commit `winutil.ps1`, `binary/`, or anything else ignored by the root `.gitignore` or `docs/.gitignore` — read those files rather than assuming. `docs/public/` is tracked source for static assets, not generated output.
|
||||
- `docs/src/assets/branding/title-screen.png` is a tracked generated asset. Do not edit it manually. Update `tools/title-screen/` or run the title-screen workflow.
|
||||
- Do not remove `.gitignore` rules that keep generated artifacts out of Git.
|
||||
- Before finishing, check `git status --short` and separate your changes from pre-existing user changes.
|
||||
- Do not revert user changes unless explicitly asked.
|
||||
- Commit messages, when requested, should be descriptive: short subject under 72 characters, body explaining why when needed.
|
||||
- When committing, split changes into small, logical commits rather than one large commit, so each commit's diff is reviewable as a single group of related changes.
|
||||
|
||||
## 10. Documentation Expectations
|
||||
|
||||
- Update `docs/content/` when user-facing behavior changes.
|
||||
- Update developer docs when architecture, build flow, config schema, or contribution workflow changes.
|
||||
- Update `docs/src/content/docs/guides/` when user-facing behavior changes.
|
||||
- Update `docs/src/content/docs/code-reference/architecture.mdx` and other hand-written developer docs when architecture, build flow, config schema, or contribution workflow changes — but never hand-edit the auto-generated `code-reference/tweaks/` or `code-reference/features/` subfolders (see Non-Negotiables).
|
||||
- Keep sidebar entries in `docs/astro.config.mjs` in sync with page slugs (see SPEC.md's Docs Site).
|
||||
- Keep README changes brief and high-level.
|
||||
- Put detailed user and developer documentation under `docs/`.
|
||||
- Keep `SPEC.md` aligned with build/runtime contract changes.
|
||||
- Keep SPEC.md aligned with project/architecture changes, and this file aligned with process changes.
|
||||
|
||||
## 11. Communication Style
|
||||
|
||||
@@ -187,8 +175,16 @@ When the user corrects an agent approach, add or tighten one concrete rule here
|
||||
- Import Pester 5.8.0 before running tests so `Invoke-Pester -Output Detailed -CI` does not resolve to Windows' inbox Pester 3.4.0.
|
||||
- Keep package install/uninstall process launches simple unless explicitly requested; do not add a separate stdout/stderr process logging helper for winget or Chocolatey.
|
||||
- When the active log file is owned by `Start-Transcript`, do not call `Add-Content` against that file; write to host output so the transcript captures the line in the same log file without recording a terminating-error diagnostic.
|
||||
- Keep UI helpers such as `Invoke-WPFUIThread` and `Step-WinUtilJob` safe to call without a window; the `-Preset` and `-Config` paths run the workflows before the form is created and before PresentationCore is loaded. Ask `Test-WinUtilUIAlive` rather than writing the `$sync.Form` / dispatcher / `HasShutdownStarted` check out by hand.
|
||||
- Put long operations on the job layer with `Start-WinUtilJob` and report from them with `Step-WinUtilJob`; a job body must not set the busy flag, print its own banner, or carry its own try/catch/finally around the interface. `Invoke-WPFRunspace` directly is for fire-and-forget work that is not a job.
|
||||
- Drain interface work that nobody is waiting for through `Start-WinUtilBackgroundQueue`; do not hand-roll another dequeue-and-re-post pump.
|
||||
- Values a posted scriptblock needs travel as the dispatcher's argument or through `-Parameters`, never captured from the caller: a plain block resolves them when the dispatcher gets to it, and `GetNewClosure` binds command lookup to a copied scope. For the same reason, prefer a compiled `[action]` over `Invoke-WPFUIThread -Async` on hot re-posting paths, which marshals its body as text and recompiles it per post.
|
||||
- Diagnostic scaffolding does not ship. Measure with it, then delete it.
|
||||
- Have each Pester file load the assemblies and dot-source the functions it needs; several passed only because an earlier file in alphabetical order happened to load them.
|
||||
- Log install/uninstall package names and package-manager IDs before queuing background runspace work; do not rely on runspace host output for the package identity.
|
||||
- For Win11 Creator, start each new ISO modification in a fresh `WinUtil_Win11ISO_*` temp directory; existing-work detection is only for resuming/exporting already modified media.
|
||||
- For Win11 Creator driver injection, keep offline WIM servicing to one mount, one `/Add-Driver`, and one commit; do not export editions or run unrelated WIM cleanup, and reject damaged metadata before ISO export.
|
||||
- For Win11 Creator driver injection, 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 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 = @{}
|
||||
|
||||
$script = (Get-Content -Path scripts\start.ps1) -replace '#{replaceme}', (Get-Date -Format 'yy.MM.dd')
|
||||
$isLocalCompile = -not [string]::Equals($env:GITHUB_ACTIONS, "true", [StringComparison]::OrdinalIgnoreCase)
|
||||
$script = $script -replace '#{islocalcompile}', $isLocalCompile.ToString().ToLowerInvariant()
|
||||
|
||||
$script += Get-ChildItem -Path functions -Recurse -File | ForEach-Object {
|
||||
Get-Content -Path $_.FullName -Raw
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# GEMINI.md
|
||||
|
||||
Read `AGENTS.md` in the repository root for operating instructions before doing anything else.
|
||||
@@ -13,7 +13,7 @@ A curated compilation of Windows system tasks streamline **installs**, debloat w
|
||||
|
||||
## Quick Start
|
||||
|
||||
> **WinUtil must be run as Administrator** Because it performs system-wide changes.
|
||||
> **WinUtil must be run as Administrator** because it performs system-wide changes.
|
||||
|
||||
Open PowerShell or Terminal as admin, then run:
|
||||
|
||||
@@ -72,13 +72,15 @@ See https://github.com/ChrisTitusTech/winutil/blob/main/.github/CONTRIBUTING.md
|
||||
## Support
|
||||
|
||||
- Leave a ⭐ to show support!
|
||||
- EXE Wrapper for $10 @ https://www.cttstore.com/windows-toolbox
|
||||
- Faster Dotnet Implementation for sale here: https://www.cttstore.com/windows-toolbox
|
||||
|
||||
## Sponsors
|
||||
|
||||
These are the sponsors that help keep this project alive with monthly contributions.
|
||||
|
||||
<!-- sponsors --><a href="https://github.com/ysaito8015"><img src="https://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/andrewpayne68"><img src="https://github.com/andrewpayne68.png" width="60px" alt="User avatar: Andrew P" /></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><!-- 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>
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,10 +1,38 @@
|
||||
# SPEC.md
|
||||
|
||||
## Project Contract
|
||||
Project contract for WinUtil — what the project is, how it's built, and how it runs. Written for anyone, human or AI, who needs to understand the project itself.
|
||||
|
||||
WinUtil is a Windows PowerShell utility with a WPF interface. The repository is maintained as modular source files, but the released artifact is a single generated `winutil.ps1` script.
|
||||
`AGENTS.md` in the repository root points here for these facts, and separately covers how an agent should behave while working in this repo. This file does not change based on who's reading it.
|
||||
|
||||
The compiled `winutil.ps1` is not source code for editing or review. It is generated by `Compile.ps1` and produced during release automation. All durable changes must be made to the source files that feed the compiler.
|
||||
## Project Context
|
||||
|
||||
WinUtil is a Windows PowerShell utility with a WPF interface. The repository is maintained as modular source, but the distributed artifact is one compiled PowerShell script.
|
||||
|
||||
### Stack
|
||||
|
||||
- Language: Windows PowerShell / PowerShell.
|
||||
- UI: WPF via `xaml/inputXML.xaml`.
|
||||
- Configuration: JSON files under `config/`.
|
||||
- Tests: Pester tests under `pester/`.
|
||||
- Lint: PowerShell Script Analyzer with settings in `lint/PSScriptAnalyser.ps1`.
|
||||
- Docs: Astro + Starlight site under `docs/`, built independently of `Compile.ps1` (its own `package.json`/`node_modules`).
|
||||
- Release artifact: generated root `winutil.ps1`.
|
||||
|
||||
### Repository Layout
|
||||
|
||||
- `Compile.ps1`: build script that creates `winutil.ps1`.
|
||||
- `scripts/start.ps1`: startup/bootstrap segment used at the beginning of the compiled script.
|
||||
- `scripts/main.ps1`: main entrypoint appended at the end of the compiled script.
|
||||
- `functions/public/`: public/UI-facing PowerShell functions.
|
||||
- `functions/private/`: internal helper PowerShell functions.
|
||||
- `config/`: JSON configuration consumed at compile time and embedded into `$sync.configs`.
|
||||
- `xaml/inputXML.xaml`: WPF UI markup embedded into the compiled script.
|
||||
- `tools/autounattend.xml`: unattended setup XML embedded for Windows ISO workflows.
|
||||
- `pester/`: Pester tests for config and function checks.
|
||||
- `lint/PSScriptAnalyser.ps1`: PowerShell Script Analyzer settings.
|
||||
- `tools/title-screen/`: uv project that captures WinUtil's Light and Dark themes and generates the title-screen composite.
|
||||
- `docs/`: Astro + Starlight documentation site, with its own `package.json` and build independent of `Compile.ps1`.
|
||||
- `winutil.ps1`: ignored generated build artifact.
|
||||
|
||||
## Goals
|
||||
|
||||
@@ -21,85 +49,68 @@ The compiled `winutil.ps1` is not source code for editing or review. It is gener
|
||||
- The GUI is not a separate packaged desktop application in this repository's normal release path.
|
||||
- Generated files should not be reviewed as source changes.
|
||||
|
||||
## Repository Layout
|
||||
## Build Model
|
||||
|
||||
- `Compile.ps1`: build script that creates `winutil.ps1`.
|
||||
- `scripts/start.ps1`: startup/bootstrap segment used at the beginning of the compiled script.
|
||||
- `scripts/main.ps1`: main entrypoint appended at the end of the compiled script.
|
||||
- `functions/public/`: public/UI-facing PowerShell functions.
|
||||
- `functions/private/`: internal helper PowerShell functions.
|
||||
- `config/`: JSON configuration consumed at compile time and embedded into `$sync.configs`.
|
||||
- `xaml/inputXML.xaml`: WPF UI markup embedded into the compiled script.
|
||||
- `tools/autounattend.xml`: unattended setup XML embedded for Windows ISO workflows.
|
||||
- `pester/`: Pester tests for config and function checks.
|
||||
- `lint/PSScriptAnalyser.ps1`: PowerShell Script Analyzer settings.
|
||||
- `docs/`: Hugo documentation site.
|
||||
- `winutil.ps1`: ignored generated build artifact.
|
||||
`Compile.ps1` combines the repository sources into `winutil.ps1` in this order:
|
||||
|
||||
## Compile Specification
|
||||
1. Read `scripts/start.ps1` and replace `#{replaceme}` with the current `yy.MM.dd` build date.
|
||||
2. Append every file under `functions/` recursively.
|
||||
3. Convert each `config/*.json` file into embedded `$sync.configs` objects.
|
||||
4. Special-case `config/applications.json` so keys receive the `WPFInstall` prefix in compiled config.
|
||||
5. Embed `xaml/inputXML.xaml` into `$inputXML`.
|
||||
6. Embed `tools/autounattend.xml` into `$WinUtilAutounattendXml`.
|
||||
7. Append `scripts/main.ps1`.
|
||||
8. Write the result to root `winutil.ps1`.
|
||||
|
||||
`Compile.ps1` must produce a standalone root `winutil.ps1` by combining all required project files.
|
||||
|
||||
The compile flow is:
|
||||
|
||||
1. Initialize shared state with `$sync = [Hashtable]::Synchronized(@{})` and `$sync.configs = @{}`.
|
||||
2. Read `scripts/start.ps1`.
|
||||
3. Replace `#{replaceme}` in startup code with the current `yy.MM.dd` build date.
|
||||
4. Append raw content from all files under `functions/` recursively.
|
||||
5. For every file in `config/`, parse JSON and embed it into `$sync.configs.<basename>`.
|
||||
6. Special-case `config/applications.json` so application keys are emitted with the `WPFInstall` prefix.
|
||||
7. Embed `xaml/inputXML.xaml` as `$inputXML`.
|
||||
8. Embed `tools/autounattend.xml` as `$WinUtilAutounattendXml`.
|
||||
9. Append `scripts/main.ps1`.
|
||||
10. Write the combined script to `winutil.ps1`.
|
||||
11. If `-Run` is supplied, execute the generated script.
|
||||
|
||||
The generated script must have everything it needs from repository sources embedded or appended by this process.
|
||||
Because the final script is concatenated, code cannot rely on runtime module imports or source-relative dot-sourcing unless the compiled script will also contain the required code/data.
|
||||
|
||||
## Runtime Model
|
||||
|
||||
- WinUtil runs in PowerShell on Windows and uses WPF for the UI.
|
||||
- Shared mutable state is stored in `$sync`, including configs, UI element references, runspace state, selections, and progress.
|
||||
- Long-running operations should use runspaces or existing async patterns so the UI remains responsive.
|
||||
- UI updates from background work must be dispatched back to the WPF UI thread.
|
||||
- Declarative features such as apps, tweaks, presets, DNS providers, and navigation should stay in `config/*.json` unless code is required.
|
||||
- Long-running operations use runspaces or existing async patterns so the UI remains responsive.
|
||||
- UI updates from background work are dispatched back to the WPF UI thread.
|
||||
- Declarative features such as apps, tweaks, presets, DNS providers, and navigation stay in `config/*.json` unless code is required.
|
||||
|
||||
## UI And Event Contract
|
||||
|
||||
- UI layout lives in `xaml/inputXML.xaml`.
|
||||
- Named WPF controls are discovered and stored in `$sync`.
|
||||
- Button/action wiring follows existing naming conventions, where an element named like `WPFThingButton` maps to a function named like `Invoke-WPFThingButton`.
|
||||
- When adding controls, ensure the XAML name, config key, and PowerShell function names line up with the existing event system.
|
||||
- Button/action wiring follows a naming convention: an element named like `WPFThingButton` maps to a function named like `Invoke-WPFThingButton`.
|
||||
|
||||
## Configuration Contract
|
||||
|
||||
Config files must remain valid JSON and compile cleanly through `ConvertFrom-Json`.
|
||||
|
||||
`config/applications.json` defines installable applications. Each application entry should include the fields expected by tests and UI code, such as package manager IDs, category, display content, description, and link.
|
||||
|
||||
`config/tweaks.json` defines Windows tweaks. Registry and service changes should include original values or original states when applicable so undo workflows can restore user systems.
|
||||
|
||||
Preset and navigation files should reference valid config keys. Avoid renaming config keys unless all presets, UI references, docs, and code paths are updated together.
|
||||
- Config files must remain valid JSON and compile cleanly through `ConvertFrom-Json`.
|
||||
- `config/dns.json` opts unfiltered providers into Fastest selection with `BenchmarkEligible: true`; missing or false values exclude a provider from the TCP latency benchmark.
|
||||
- `config/applications.json` defines installable applications; each entry includes the fields expected by tests and UI code, such as package manager IDs, category, display content, description, and link.
|
||||
- `config/tweaks.json` defines Windows tweaks; registry and service changes include original values or original states when applicable so undo workflows can restore user systems.
|
||||
- Preset and navigation files reference valid config keys. Renaming a config key requires updating all presets, UI references, docs, and code paths together.
|
||||
|
||||
## Safety Requirements
|
||||
|
||||
- Registry, service, package manager, Windows Update, AppX removal, and ISO operations can affect the host system. Changes must be explicit, reversible where practical, and consistent with existing logging and confirmation patterns.
|
||||
- Tweak changes should include undo metadata when the schema supports it.
|
||||
- Package installation should prefer existing WinGet and Chocolatey helper functions.
|
||||
- ISO workflows must not modify the user's original ISO file; they should work on copied/mounted content following existing patterns.
|
||||
- Registry, service, package manager, Windows Update, AppX removal, and ISO operations affect the host system and are treated as high-risk.
|
||||
- Tweak changes include undo metadata when the schema supports it, so changes stay reversible.
|
||||
- ISO workflows never modify the user's original ISO file; they work on copied/mounted content.
|
||||
|
||||
## Docs Site (Astro)
|
||||
|
||||
- `docs/` is an Astro + Starlight site, independent of `Compile.ps1`'s build (its own `package.json`/`node_modules`, deployed via the `docs.yaml` GitHub Actions workflow to GitHub Pages).
|
||||
- Pages live under `docs/src/content/docs/` (`.mdx`), organized into `guides/`, `code-reference/`, plus top-level pages like `faq.mdx`, `knownissues.mdx`, `contributing.mdx`, `index.mdx`.
|
||||
- `docs/src/content/docs/code-reference/tweaks/` and `.../features/` are auto-generated by `tools/devdocs-generator.ps1` from `config/tweaks.json`/`config/feature.json` and the relevant PowerShell function files. Other pages under `code-reference/` (e.g. `architecture.mdx`) are hand-written and untouched by the generator.
|
||||
- Sidebar entries in `docs/astro.config.mjs` must match actual page slugs under `docs/src/content/docs/`.
|
||||
- `docs/public/` is tracked source for static assets (favicons, etc.), not generated output. Generated/ignored paths are listed in `docs/.gitignore` (`dist/`, `.astro/`, `node_modules/`, local env files).
|
||||
- `docs/src/assets/branding/title-screen.png` is a tracked generated image used by the repository README and docs homepage. Its raw Light and Dark captures are temporary.
|
||||
- `docs/Dockerfile` and `docs/docker-compose.yml` (service `winutil-astro`) containerize the site's npm tooling; see AGENTS.md's Dependency Installs, Builds, And Dev Servers for why and how agents must use them instead of running npm on the host.
|
||||
|
||||
## Testing And CI
|
||||
|
||||
Expected validation for source changes:
|
||||
|
||||
- `.\Compile.ps1` verifies the compiler can generate `winutil.ps1`.
|
||||
- `.\Compile.ps1 -Run` compiles and launches the generated utility for manual GUI verification.
|
||||
- `Install-Module -Name Pester -RequiredVersion 5.8.0 -Scope CurrentUser -Force -SkipPublisherCheck` installs the supported Pester version.
|
||||
- `Import-Module Pester -RequiredVersion 5.8.0 -Force; Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed -CI` runs the Pester suite.
|
||||
- GitHub Actions also runs a compile check and PowerShell Script Analyzer with `lint/PSScriptAnalyser.ps1`.
|
||||
|
||||
The generated `winutil.ps1` may appear locally after compile. It remains ignored build output and must not be committed.
|
||||
- Pester 5.8.0 runs the suite under `pester/*.Tests.ps1`. GitHub Actions (`unittests.yaml`) installs Pester 5.8.0 fresh and runs with `-CI`, which produces `testResults.xml` and exits non-zero on failure.
|
||||
- GitHub Actions also runs PowerShell Script Analyzer with `lint/PSScriptAnalyser.ps1` on every push.
|
||||
- The generated `winutil.ps1` may appear locally after compile. It remains ignored build output (see root `.gitignore`) and must not be committed.
|
||||
- The manually triggered title-screen workflow compiles WinUtil from `main` and opens an image-only pull request when the generated composite changes. These pull requests require manual review. Failed runs retain diagnostics for 14 days.
|
||||
|
||||
## Release Artifact
|
||||
|
||||
GitHub Actions is responsible for producing the release `winutil.ps1` from repository sources. A release should be considered valid only if the generated script came from the compile process, not from direct manual edits to `winutil.ps1`.
|
||||
GitHub Actions is responsible for producing the release `winutil.ps1` from repository sources. A release is considered valid only if the generated script came from the compile process, not from direct manual edits to `winutil.ps1`.
|
||||
|
||||
+208
-20
@@ -17,6 +17,15 @@
|
||||
"winget": "7zip.7zip",
|
||||
"foss": true
|
||||
},
|
||||
"abdownloadmanager": {
|
||||
"category": "Utilities",
|
||||
"choco": "ab-download-manager",
|
||||
"content": "AB Download Manager",
|
||||
"description": "AB Download Manager is an open-source download accelerator and manager with multi-threaded downloads, queue scheduling, speed limiting, and browser integration.",
|
||||
"link": "https://abdownloadmanager.com/",
|
||||
"winget": "amir1376.ABDownloadManager",
|
||||
"foss": true
|
||||
},
|
||||
"adobe": {
|
||||
"category": "Document",
|
||||
"choco": "adobereader",
|
||||
@@ -98,6 +107,15 @@
|
||||
"winget": "AutoHotkey.AutoHotkey",
|
||||
"foss": true
|
||||
},
|
||||
"battlenet": {
|
||||
"category": "Games",
|
||||
"choco": "na",
|
||||
"winget": "Blizzard.BattleNet",
|
||||
"content": "Battle.net",
|
||||
"description": "Battle.net is a launcher for games created and developed by Activision Blizzard",
|
||||
"link": "https://battle.net",
|
||||
"foss": false
|
||||
},
|
||||
"bitwarden": {
|
||||
"category": "Utilities",
|
||||
"choco": "bitwarden",
|
||||
@@ -125,6 +143,15 @@
|
||||
"winget": "Brave.Brave",
|
||||
"foss": true
|
||||
},
|
||||
"bruno": {
|
||||
"category": "Development",
|
||||
"choco": "bruno",
|
||||
"content": "Bruno",
|
||||
"description": "Bruno is a local-first API client that stores collections as plain text files for version control and collaboration.",
|
||||
"link": "https://www.usebruno.com/",
|
||||
"winget": "Bruno.Bruno",
|
||||
"foss": true
|
||||
},
|
||||
"bulkcrapuninstaller": {
|
||||
"category": "Utilities",
|
||||
"choco": "bulk-crap-uninstaller",
|
||||
@@ -166,7 +193,7 @@
|
||||
"choco": "na",
|
||||
"content": "ChatGPT Desktop",
|
||||
"description": "The official ChatGPT desktop app for Windows, distributed through the Microsoft Store.",
|
||||
"link": "https://apps.microsoft.com/detail/9nt1r1c2hh7j",
|
||||
"link": "https://openai.com/chatgpt/download/",
|
||||
"winget": "msstore:9NT1R1C2HH7J",
|
||||
"foss": false
|
||||
},
|
||||
@@ -193,7 +220,7 @@
|
||||
"choco": "chromium",
|
||||
"content": "Chromium",
|
||||
"description": "Chromium is the open-source project that serves as the foundation for various web browsers, including Chrome.",
|
||||
"link": "https://github.com/Hibbiki/chromium-win64",
|
||||
"link": "https://www.chromium.org/",
|
||||
"winget": "Hibbiki.Chromium",
|
||||
"foss": true
|
||||
},
|
||||
@@ -319,10 +346,19 @@
|
||||
"choco": "dorion",
|
||||
"content": "Dorion",
|
||||
"description": "Tiny alternative Discord client with a smaller footprint, snappier startup, themes, plugins and more!",
|
||||
"link": "https://github.com/SpikeHD/Dorion",
|
||||
"link": "https://spikehd.dev/projects/dorion/",
|
||||
"winget": "SpikeHD.Dorion",
|
||||
"foss": true
|
||||
},
|
||||
"dockerdesktop": {
|
||||
"category": "Development",
|
||||
"choco": "docker-desktop",
|
||||
"content": "Docker Desktop",
|
||||
"description": "Docker Desktop provides a local environment for building, running, and testing containerized applications on Windows.",
|
||||
"link": "https://www.docker.com/products/docker-desktop/",
|
||||
"winget": "Docker.DockerDesktop",
|
||||
"foss": false
|
||||
},
|
||||
"dotnet6": {
|
||||
"category": "Microsoft Tools",
|
||||
"choco": "dotnet-6.0-runtime",
|
||||
@@ -395,6 +431,16 @@
|
||||
"winget": "Microsoft.Edge",
|
||||
"foss": false
|
||||
},
|
||||
"es-de": {
|
||||
"category": "Games",
|
||||
"choco": "",
|
||||
"content": "EmulationStation Desktop Edition",
|
||||
"_comment": "This and emulationstation are two completely different things. ES-DE is your frontend for everything and has its own set of emulators. Emulationstation is a graphical frontend for RetroArch.",
|
||||
"description": "EmulationStation Desktop Edition is a frontend for browsing and launching games from your multi-platform game collection.",
|
||||
"link": "https://es-de.org/",
|
||||
"winget": "ES-DE.EmulationStation-DE",
|
||||
"foss": true
|
||||
},
|
||||
"enteauth": {
|
||||
"category": "Utilities",
|
||||
"choco": "ente-auth",
|
||||
@@ -418,10 +464,19 @@
|
||||
"choco": "files",
|
||||
"content": "Files",
|
||||
"description": "Alternative file explorer.",
|
||||
"link": "https://github.com/files-community/Files",
|
||||
"link": "https://files.community",
|
||||
"winget": "FilesCommunity.Files",
|
||||
"foss": true
|
||||
},
|
||||
"fileconverter": {
|
||||
"category": "Multimedia Tools",
|
||||
"choco": "file-converter",
|
||||
"content": "File Converter",
|
||||
"description": "File Converter converts and compresses files from the Windows Explorer context menu.",
|
||||
"link": "https://file-converter.io/",
|
||||
"winget": "AdrienAllard.FileConverter",
|
||||
"foss": true
|
||||
},
|
||||
"firefox": {
|
||||
"category": "Browsers",
|
||||
"choco": "firefox",
|
||||
@@ -458,6 +513,24 @@
|
||||
"winget": "flux.flux",
|
||||
"foss": false
|
||||
},
|
||||
"foobar": {
|
||||
"category": "Multimedia Tools",
|
||||
"choco": "foobar2000",
|
||||
"content": "foobar2000 (Music Player)",
|
||||
"description": "foobar2000 is a highly customizable and extensible music player for Windows, known for its modular design and advanced features.",
|
||||
"link": "https://www.foobar2000.org/",
|
||||
"winget": "PeterPawlowski.foobar2000",
|
||||
"foss": false
|
||||
},
|
||||
"fnm": {
|
||||
"category": "Development",
|
||||
"choco": "fnm",
|
||||
"content": "Fast Node Manager",
|
||||
"description": "Fast Node Manager (fnm) is a fast, cross-platform tool for installing and switching between Node.js versions.",
|
||||
"link": "https://github.com/Schniz/fnm",
|
||||
"winget": "Schniz.fnm",
|
||||
"foss": true
|
||||
},
|
||||
"foxpdfreader": {
|
||||
"category": "Document",
|
||||
"choco": "foxitreader",
|
||||
@@ -494,6 +567,24 @@
|
||||
"winget": "Git.Git",
|
||||
"foss": true
|
||||
},
|
||||
"gitextensions": {
|
||||
"category": "Development",
|
||||
"choco": "gitextensions",
|
||||
"content": "Git Extensions",
|
||||
"description": "Git Extensions is a graphical Git client for Windows with repository, history, and commit management tools.",
|
||||
"link": "https://gitextensions.github.io/",
|
||||
"winget": "GitExtensionsTeam.GitExtensions",
|
||||
"foss": true
|
||||
},
|
||||
"githubcli": {
|
||||
"category": "Development",
|
||||
"choco": "gh",
|
||||
"content": "GitHub CLI",
|
||||
"description": "GitHub CLI brings pull requests, issues, releases, and other GitHub workflows to the terminal.",
|
||||
"link": "https://cli.github.com/",
|
||||
"winget": "GitHub.cli",
|
||||
"foss": true
|
||||
},
|
||||
"githubdesktop": {
|
||||
"category": "Development",
|
||||
"choco": "git;github-desktop",
|
||||
@@ -553,7 +644,7 @@
|
||||
"choco": "helium",
|
||||
"content": "Helium",
|
||||
"description": "Private, fast, and honest web browser.",
|
||||
"link": "https://github.com/imputnet/helium/",
|
||||
"link": "https://helium.computer",
|
||||
"winget": "ImputNet.Helium",
|
||||
"foss": true
|
||||
},
|
||||
@@ -562,7 +653,7 @@
|
||||
"choco": "hugo-extended",
|
||||
"content": "Hugo",
|
||||
"description": "The world's fastest framework for building websites.",
|
||||
"link": "https://github.com/gohugoio/hugo/",
|
||||
"link": "https://gohugo.io",
|
||||
"winget": "Hugo.Hugo.Extended",
|
||||
"foss": true
|
||||
},
|
||||
@@ -679,7 +770,7 @@
|
||||
"choco": "jellyfin-media-player",
|
||||
"content": "Jellyfin Media Player",
|
||||
"description": "Jellyfin Media Player is a client application for the Jellyfin media server, providing access to your media library.",
|
||||
"link": "https://github.com/jellyfin/jellyfin-media-player",
|
||||
"link": "https://jellyfin.org/",
|
||||
"winget": "Jellyfin.JellyfinMediaPlayer",
|
||||
"foss": true
|
||||
},
|
||||
@@ -769,7 +860,7 @@
|
||||
"choco": "librewolf",
|
||||
"content": "LibreWolf",
|
||||
"description": "LibreWolf is a privacy-focused web browser based on Firefox, with additional privacy and security enhancements.",
|
||||
"link": "https://librewolf-community.gitlab.io/",
|
||||
"link": "https://librewolf.net/",
|
||||
"winget": "LibreWolf.LibreWolf",
|
||||
"foss": true
|
||||
},
|
||||
@@ -787,10 +878,18 @@
|
||||
"choco": "mediainfo",
|
||||
"content": "mpc-qt",
|
||||
"description": "Media Player Classic Qute Theater",
|
||||
"link": "https://github.com/mpc-qt/mpc-qt",
|
||||
"link": "https://mpc-qt.github.io",
|
||||
"winget": "mpc-qt.mpc-qt",
|
||||
"foss": true
|
||||
},
|
||||
"mpv": {
|
||||
"category": "Multimedia Tools",
|
||||
"content": "mpv",
|
||||
"description": "mpv is a free, open source, and cross-platform media player supporting a wide variety of media formats, codecs, and subtitle types.",
|
||||
"link": "https://mpv.io/",
|
||||
"winget": "shinchiro.mpv",
|
||||
"foss": true
|
||||
},
|
||||
"matrix": {
|
||||
"category": "Communications",
|
||||
"choco": "element-desktop",
|
||||
@@ -832,7 +931,7 @@
|
||||
"choco": "mpc-hc-clsid2",
|
||||
"content": "Media Player Classic - Home Cinema",
|
||||
"description": "Media Player Classic - Home Cinema (MPC-HC) is a free and open-source video and audio player for Windows. MPC-HC is based on the original Guliverkli project and contains many additional features and bug fixes.",
|
||||
"link": "https://github.com/clsid2/mpc-hc/",
|
||||
"link": "https://mpc-hc.org/",
|
||||
"winget": "clsid2.mpc-hc",
|
||||
"foss": true
|
||||
},
|
||||
@@ -859,7 +958,7 @@
|
||||
"choco": "mullvad-app",
|
||||
"content": "Mullvad VPN",
|
||||
"description": "This is the VPN client software for the Mullvad VPN service.",
|
||||
"link": "https://github.com/mullvad/mullvadvpn-app",
|
||||
"link": "https://mullvad.net/",
|
||||
"winget": "MullvadVPN.MullvadVPN",
|
||||
"foss": true
|
||||
},
|
||||
@@ -886,7 +985,7 @@
|
||||
"choco": "nanazip",
|
||||
"content": "NanaZip",
|
||||
"description": "NanaZip is a fast and efficient file compression and decompression tool.",
|
||||
"link": "https://github.com/M2Team/NanaZip",
|
||||
"link": "https://nanazip.org",
|
||||
"winget": "M2Team.NanaZip",
|
||||
"foss": true
|
||||
},
|
||||
@@ -899,6 +998,15 @@
|
||||
"winget": "Netbird.Netbird",
|
||||
"foss": true
|
||||
},
|
||||
"tailscale": {
|
||||
"category": "Utilities",
|
||||
"choco": "tailscale",
|
||||
"content": "Tailscale",
|
||||
"description": "The Tailscale client allows you to connect all your devices using WireGuard®, without the hassle. Tailscale makes it as easy as installing an app and signing in.",
|
||||
"link": "https://tailscale.com/",
|
||||
"winget": "Tailscale.Tailscale",
|
||||
"foss": false
|
||||
},
|
||||
"naps2": {
|
||||
"category": "Document",
|
||||
"choco": "naps2",
|
||||
@@ -1186,6 +1294,15 @@
|
||||
"winget": "JanDeDobbeleer.OhMyPosh",
|
||||
"foss": true
|
||||
},
|
||||
"postman": {
|
||||
"category": "Development",
|
||||
"choco": "postman",
|
||||
"content": "Postman",
|
||||
"description": "Postman is an API platform and desktop client for designing, testing, documenting, and collaborating on APIs.",
|
||||
"link": "https://www.postman.com/downloads/",
|
||||
"winget": "Postman.Postman",
|
||||
"foss": false
|
||||
},
|
||||
"powershell": {
|
||||
"category": "Microsoft Tools",
|
||||
"choco": "powershell-core",
|
||||
@@ -1303,6 +1420,15 @@
|
||||
"winget": "qBittorrent.qBittorrent",
|
||||
"foss": true
|
||||
},
|
||||
"qownnotes": {
|
||||
"category": "Document",
|
||||
"choco": "qownnotes",
|
||||
"content": "QOwnNotes",
|
||||
"description": "QOwnNotes is a free open-source note-taking app with Nextcloud/ownCloud integration.",
|
||||
"link": "https://www.qownnotes.org/",
|
||||
"winget": "pbek.QOwnNotes",
|
||||
"foss": true
|
||||
},
|
||||
"qtox": {
|
||||
"category": "Communications",
|
||||
"choco": "qtox",
|
||||
@@ -1438,6 +1564,15 @@
|
||||
"winget": "StartIsBack.StartAllBack",
|
||||
"foss": false
|
||||
},
|
||||
"starship": {
|
||||
"category": "Development",
|
||||
"choco": "starship",
|
||||
"content": "Starship (Shell Prompt)",
|
||||
"description": "Starship is a fast, customizable, cross-platform prompt for PowerShell and other shells.",
|
||||
"link": "https://starship.rs/",
|
||||
"winget": "Starship.Starship",
|
||||
"foss": true
|
||||
},
|
||||
"steam": {
|
||||
"category": "Games",
|
||||
"choco": "steam-client",
|
||||
@@ -1447,6 +1582,15 @@
|
||||
"winget": "Valve.Steam",
|
||||
"foss": false
|
||||
},
|
||||
"roblox": {
|
||||
"category": "Games",
|
||||
"choco": "na",
|
||||
"content": "Roblox",
|
||||
"description": "Roblox is a platform and game creation system that allows users to create and play games developed by the community.",
|
||||
"link": "https://www.roblox.com/",
|
||||
"winget": "Roblox.Roblox",
|
||||
"foss": false
|
||||
},
|
||||
"sublimetext": {
|
||||
"category": "Development",
|
||||
"choco": "sublimetext4",
|
||||
@@ -1470,10 +1614,27 @@
|
||||
"choco": "sunshine",
|
||||
"content": "Sunshine/GameStream Server",
|
||||
"description": "Sunshine is a GameStream server that allows you to remotely play PC games on Android devices, offering low-latency streaming.",
|
||||
"link": "https://github.com/LizardByte/Sunshine",
|
||||
"link": "https://app.lizardbyte.dev/Sunshine/",
|
||||
"winget": "LizardByte.Sunshine",
|
||||
"foss": true
|
||||
},
|
||||
"synctrayzor": {
|
||||
"category": "Selfhosted Tools",
|
||||
"choco": "synctrayzor",
|
||||
"content": "SyncTrayzor",
|
||||
"description": "SyncTrayzor is a Windows tray utility that bundles and wraps Syncthing, making it behave like a native application to easily manage and monitor file synchronization.",
|
||||
"link": "https://github.com/GermanCoding/SyncTrayzor",
|
||||
"winget": "GermanCoding.SyncTrayzor"
|
||||
},
|
||||
"syncthing": {
|
||||
"category": "Selfhosted Tools",
|
||||
"choco": "syncthing",
|
||||
"content": "Syncthing (CLI / Web UI)",
|
||||
"description": "Syncthing is a decentralized, peer-to-peer file synchronization tool that securely syncs files directly across devices without cloud servers, managed via a local web interface.",
|
||||
"link": "https://syncthing.net/",
|
||||
"winget": "Syncthing.Syncthing",
|
||||
"foss": true
|
||||
},
|
||||
"tcpview": {
|
||||
"category": "Microsoft Tools",
|
||||
"choco": "tcpview",
|
||||
@@ -1510,6 +1671,15 @@
|
||||
"winget": "TeamSpeakSystems.TeamSpeakClient",
|
||||
"foss": false
|
||||
},
|
||||
"teamspeak6": {
|
||||
"category": "Communications",
|
||||
"choco": "na",
|
||||
"content": "TeamSpeak 6",
|
||||
"description": "TEAMSPEAK. YOUR TEAM. YOUR RULES. Use crystal clear sound to communicate with your teammates cross-platform with military-grade security, lag-free performance & unparalleled reliability and uptime.",
|
||||
"link": "https://www.teamspeak.com/",
|
||||
"winget": "TeamSpeakSystems.TeamSpeakClient.Beta.6",
|
||||
"foss": false
|
||||
},
|
||||
"telegram": {
|
||||
"category": "Communications",
|
||||
"choco": "telegram",
|
||||
@@ -1578,7 +1748,7 @@
|
||||
"choco": "translucenttb",
|
||||
"content": "TranslucentTB",
|
||||
"description": "TranslucentTB is a tool that allows you to customize the transparency of the Windows Taskbar.",
|
||||
"link": "https://github.com/TranslucentTB/TranslucentTB",
|
||||
"link": "https://translucenttb.github.io",
|
||||
"winget": "CharlesMilette.TranslucentTB",
|
||||
"foss": true
|
||||
},
|
||||
@@ -1609,6 +1779,15 @@
|
||||
"winget": "Unity.UnityHub",
|
||||
"foss": false
|
||||
},
|
||||
"vagrant": {
|
||||
"category": "Development",
|
||||
"choco": "vagrant",
|
||||
"content": "Vagrant",
|
||||
"description": "Vagrant builds and manages reproducible virtual machine development environments from declarative configuration.",
|
||||
"link": "https://developer.hashicorp.com/vagrant",
|
||||
"winget": "Hashicorp.Vagrant",
|
||||
"foss": false
|
||||
},
|
||||
"everything": {
|
||||
"category": "Utilities",
|
||||
"choco": "everything",
|
||||
@@ -1649,8 +1828,8 @@
|
||||
"category": "Communications",
|
||||
"choco": "na",
|
||||
"content": "Vesktop",
|
||||
"description": "A cross platform electron-based desktop app aiming to give you a snappier Discord experience with Vencord pre-installed.",
|
||||
"link": "https://github.com/Vencord/Vesktop",
|
||||
"description": "A cross-platform electron-based desktop app aiming to give you a snappier Discord experience with Vencord pre-installed.",
|
||||
"link": "https://vesktop.dev",
|
||||
"winget": "Vencord.Vesktop",
|
||||
"foss": true
|
||||
},
|
||||
@@ -1740,7 +1919,7 @@
|
||||
"choco": "na",
|
||||
"content": "WhatsApp Desktop",
|
||||
"description": "WhatsApp Desktop is the official Windows desktop messaging app from Meta, distributed through the Microsoft Store.",
|
||||
"link": "https://apps.microsoft.com/detail/9nksqgp7f2nh",
|
||||
"link": "https://www.whatsapp.com/download",
|
||||
"winget": "msstore:9NKSQGP7F2NH",
|
||||
"foss": false
|
||||
},
|
||||
@@ -1863,9 +2042,8 @@
|
||||
},
|
||||
"Overwolf": {
|
||||
"category": "Games",
|
||||
"choco": "overwolf",
|
||||
"content": "Overwolf",
|
||||
"description": "Popular platform for game overlays and companion apps (mod managers, trackers, etc.), widely used by gamers.",
|
||||
"content": "CurseForge",
|
||||
"description": "CurseForge is a desktop application for managing mods and modpacks across multiple games, powered by Overwolf.",
|
||||
"link": "https://www.overwolf.com/app/overwolf-curseforge",
|
||||
"winget": "Overwolf.CurseForge",
|
||||
"foss": false
|
||||
@@ -1932,5 +2110,15 @@
|
||||
"content": "Lua",
|
||||
"link": "https://github.com/rjpcomputing/luaforwindows",
|
||||
"foss": true
|
||||
},
|
||||
"CloudflareWARP": {
|
||||
"category": "Utilities",
|
||||
"choco": "warp",
|
||||
"winget": "Cloudflare.Warp",
|
||||
"description": "WARP is a freemium VPN service provided by Cloudflare. Includes usage of Cloudflare's DNS",
|
||||
"content": "Cloudflare WARP",
|
||||
"link": "https://one.one.one.one",
|
||||
"foss": false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -259,5 +259,13 @@
|
||||
"Description": "Bundles built-in card game modes including Klondike, Spider, FreeCell, Pyramid, and TriPeaks alongside daily challenges.",
|
||||
"Panel": "1",
|
||||
"PackageId": "Microsoft.MicrosoftSolitaireCollection"
|
||||
},
|
||||
"WPFAppxMicrosoft_ZuneVideo": {
|
||||
"Category": "Utilities & Productivity",
|
||||
"Content": "Movies & TV",
|
||||
"Description": "The default video player and storefront for purchasing or renting media.",
|
||||
"Panel": "0",
|
||||
"PackageId": "Microsoft.ZuneVideo",
|
||||
"StoreId": "9WZDNCRFJ3P2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"Google":{
|
||||
"BenchmarkEligible": true,
|
||||
"Primary": "8.8.8.8",
|
||||
"Secondary": "8.8.4.4",
|
||||
"Primary6": "2001:4860:4860::8888",
|
||||
@@ -7,6 +8,7 @@
|
||||
"DohTemplate": "https://dns.google/dns-query"
|
||||
},
|
||||
"Cloudflare":{
|
||||
"BenchmarkEligible": true,
|
||||
"Primary": "1.1.1.1",
|
||||
"Secondary": "1.0.0.1",
|
||||
"Primary6": "2606:4700:4700::1111",
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"FontSize": "12",
|
||||
"FontFamily": "Arial",
|
||||
"HeaderFontSize": "16",
|
||||
"Win11StepTitleFontSize": "22",
|
||||
"Win11StepHeroFontSize": "40",
|
||||
"Win11LogFontFamily": "Consolas, Monaco",
|
||||
"HeaderFontFamily": "Consolas, Monaco",
|
||||
"CheckBoxBulletDecoratorSize": "14",
|
||||
"CheckBoxMargin": "15,0,0,2",
|
||||
@@ -58,6 +61,8 @@
|
||||
"ScrollBarHoverColor": "#5A5D62",
|
||||
"ScrollBarDraggingColor": "#6A6D72",
|
||||
"ProgressBarForegroundColor": "#2E77FF",
|
||||
"ProgressBarErrorColor": "#D13438",
|
||||
"ProgressBarWarningColor": "#B36A00",
|
||||
"ProgressBarBackgroundColor": "Transparent",
|
||||
"ButtonInstallBackgroundColor": "#F7F7F7",
|
||||
"ButtonTweaksBackgroundColor": "#F7F7F7",
|
||||
@@ -98,6 +103,8 @@
|
||||
"ScrollBarHoverColor": "#3B4252",
|
||||
"ScrollBarDraggingColor": "#5E81AC",
|
||||
"ProgressBarForegroundColor": "#6EFF72",
|
||||
"ProgressBarErrorColor": "#FF6B6B",
|
||||
"ProgressBarWarningColor": "#FFC83D",
|
||||
"ProgressBarBackgroundColor": "Transparent",
|
||||
"ButtonInstallBackgroundColor": "#222222",
|
||||
"ButtonTweaksBackgroundColor": "#333333",
|
||||
|
||||
+73
-40
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"WPFTweaksActivity": {
|
||||
"Content": "Activity History - Disable",
|
||||
"Description": "Erases recent docs, clipboard, and run history.",
|
||||
"Description": "Stops Windows from publishing or uploading user activities while preserving clipboard history.",
|
||||
"category": "Essential Tweaks",
|
||||
"panel": "1",
|
||||
"registry": [
|
||||
{
|
||||
"Path": "HKLM:\\SOFTWARE\\Policies\\Microsoft\\Windows\\System",
|
||||
"Name": "EnableActivityFeed",
|
||||
"Value": "0",
|
||||
"Value": "1",
|
||||
"Type": "DWord",
|
||||
"OriginalValue": "<RemoveEntry>"
|
||||
},
|
||||
@@ -99,10 +99,10 @@
|
||||
"category": "Essential Tweaks",
|
||||
"panel": "1",
|
||||
"InvokeScript": [
|
||||
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /deny Everyone:F"
|
||||
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /deny *S-1-1-0:F"
|
||||
],
|
||||
"UndoScript": [
|
||||
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /grant Everyone:F"
|
||||
"icacls \"$Env:LocalAppData\\Packages\\Microsoft.WindowsStore_8wekyb3d8bbwe\\LocalState\\store.db\" /grant *S-1-1-0:F"
|
||||
],
|
||||
"link": "https://winutil.christitus.com/code-reference/tweaks/essential-tweaks/disablestoresearch"
|
||||
},
|
||||
@@ -114,7 +114,7 @@
|
||||
"service": [
|
||||
{
|
||||
"Name": "lfsvc",
|
||||
"StartupType": "Disable",
|
||||
"StartupType": "Disabled",
|
||||
"OriginalType": "Manual"
|
||||
}
|
||||
],
|
||||
@@ -647,7 +647,7 @@
|
||||
"InvokeScript": [
|
||||
"
|
||||
# Deny permission to remove OneDrive folder
|
||||
icacls $Env:OneDrive /deny \"Administrators:(D,DC)\"
|
||||
icacls $Env:OneDrive /deny \"*S-1-5-32-544:(D,DC)\"
|
||||
|
||||
Write-Host \"Uninstalling OneDrive...\"
|
||||
Start-Process -FilePath (Join-Path $Env:SystemRoot \"System32\\OneDriveSetup.exe\") -ArgumentList '/uninstall' -Wait
|
||||
@@ -661,7 +661,7 @@
|
||||
Remove-Item \"$Env:ProgramData\\Microsoft OneDrive\" -Recurse -Force
|
||||
|
||||
# Grant back permission to access OneDrive folder
|
||||
icacls $Env:OneDrive /grant \"Administrators:(D,DC)\"
|
||||
icacls $Env:OneDrive /grant \"*S-1-5-32-544:(D,DC)\"
|
||||
|
||||
if (-not (Get-ChildItem -Path $Env:OneDrive)) {
|
||||
Remove-Item -Path $Env:OneDrive -Recurse
|
||||
@@ -854,7 +854,7 @@
|
||||
},
|
||||
"WPFTweaksEndTaskOnTaskbar": {
|
||||
"Content": "End Task With Right Click - Enable",
|
||||
"Description": "Enables option to end task when right clicking a program in the taskbar.",
|
||||
"Description": "Enables option to end task when right-clicking a program in the taskbar.",
|
||||
"category": "Essential Tweaks",
|
||||
"panel": "1",
|
||||
"registry": [
|
||||
@@ -991,16 +991,51 @@
|
||||
New-Item -Path $RazerPath -ItemType Directory
|
||||
}
|
||||
|
||||
icacls $RazerPath /deny \"Everyone:(W)\"
|
||||
icacls $RazerPath /deny \"*S-1-1-0:(W)\"
|
||||
"
|
||||
],
|
||||
"UndoScript": [
|
||||
"
|
||||
icacls \"$Env:SystemRoot\\Installer\\Razer\" /remove:d Everyone
|
||||
icacls \"$Env:SystemRoot\\Installer\\Razer\" /remove:d *S-1-1-0
|
||||
"
|
||||
],
|
||||
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/razerblock"
|
||||
},
|
||||
"WPFTweaksLogiBlock": {
|
||||
"Content": "Logitech Download Assistant Auto-Install - Disable",
|
||||
"Description": "Blocks the Logi Download Assistant that Windows Update keeps reinstalling with Logitech device drivers. Logitech hardware keeps working without it.",
|
||||
"category": "z__Advanced Tweaks - CAUTION",
|
||||
"panel": "1",
|
||||
"InvokeScript": [
|
||||
"
|
||||
Stop-Process -Name \"logi_download_assistant\" -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$ProgramFiles64 = if ($Env:ProgramW6432) { $Env:ProgramW6432 } else { $Env:ProgramFiles }
|
||||
$LogiPath = \"$ProgramFiles64\\LogiDownloadAssistant\"
|
||||
|
||||
if (Test-Path $LogiPath) {
|
||||
Remove-Item $LogiPath\\* -Recurse -Force
|
||||
} else {
|
||||
New-Item -Path $LogiPath -ItemType Directory
|
||||
}
|
||||
|
||||
icacls $LogiPath /deny \"*S-1-1-0:(W)\"
|
||||
if ($LASTEXITCODE -ne 0) { throw \"icacls failed to deny write access on $LogiPath (exit code $LASTEXITCODE)\" }
|
||||
"
|
||||
],
|
||||
"UndoScript": [
|
||||
"
|
||||
$ProgramFiles64 = if ($Env:ProgramW6432) { $Env:ProgramW6432 } else { $Env:ProgramFiles }
|
||||
$LogiPath = \"$ProgramFiles64\\LogiDownloadAssistant\"
|
||||
|
||||
if (Test-Path $LogiPath) {
|
||||
icacls $LogiPath /remove:d \"*S-1-1-0\"
|
||||
if ($LASTEXITCODE -ne 0) { throw \"icacls failed to remove the write-deny rule on $LogiPath (exit code $LASTEXITCODE)\" }
|
||||
}
|
||||
"
|
||||
],
|
||||
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/logiblock"
|
||||
},
|
||||
"WPFTweaksDisableNotifications": {
|
||||
"Content": "System Tray Notifications & Calendar - Disable",
|
||||
"Description": "Disables all Notifications INCLUDING Calendar.",
|
||||
@@ -1086,8 +1121,10 @@
|
||||
"panel": "1",
|
||||
"InvokeScript": [
|
||||
"
|
||||
Remove-Item -Path \"$Env:Temp\\*\" -Recurse -Force
|
||||
Remove-Item -Path \"$Env:SystemRoot\\Temp\\*\" -Recurse -Force
|
||||
# A temp folder always holds files something has open, including this run's own, and
|
||||
# the job layer counts a logged error as a failed step
|
||||
Remove-Item -Path \"$Env:Temp\\*\" -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -Path \"$Env:SystemRoot\\Temp\\*\" -Recurse -Force -ErrorAction SilentlyContinue
|
||||
"
|
||||
],
|
||||
"link": "https://winutil.christitus.com/code-reference/tweaks/essential-tweaks/deletetempfiles"
|
||||
@@ -1168,22 +1205,6 @@
|
||||
],
|
||||
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/disablebgapps"
|
||||
},
|
||||
"WPFTweaksDisableFSO": {
|
||||
"Content": "Fullscreen Optimizations - Disable",
|
||||
"Description": "Disables FSO in all applications. NOTE: This will disable Color Management in Exclusive Fullscreen.",
|
||||
"category": "z__Advanced Tweaks - CAUTION",
|
||||
"panel": "1",
|
||||
"registry": [
|
||||
{
|
||||
"Path": "HKCU:\\System\\GameConfigStore",
|
||||
"Name": "GameDVR_DXGIHonorFSEWindowsCompatible",
|
||||
"Value": "1",
|
||||
"Type": "DWord",
|
||||
"OriginalValue": "0"
|
||||
}
|
||||
],
|
||||
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/disablefso"
|
||||
},
|
||||
"WPFTweaksDisableExplorerAutoDiscovery": {
|
||||
"Content": "File Explorer Automatic Folder Discovery - Disable",
|
||||
"Description": "Windows Explorer automatically tries to guess the type of the folder based on its contents, slowing down the browsing experience. WARNING! Will disable File Explorer grouping.",
|
||||
@@ -1401,7 +1422,7 @@
|
||||
},
|
||||
"WPFToggleNewOutlook": {
|
||||
"Content": "Microsoft Outlook New Version",
|
||||
"Description": "This will ensures the classic Outlook application is used.",
|
||||
"Description": "This will ensure the new Outlook application is used.",
|
||||
"category": "Customize Preferences",
|
||||
"panel": "2",
|
||||
"Type": "Toggle",
|
||||
@@ -1459,28 +1480,40 @@
|
||||
],
|
||||
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/scrollbars"
|
||||
},
|
||||
"WPFToggleMultiplaneOverlay": {
|
||||
"WPFMultiplaneOverlay": {
|
||||
"Content": "Multiplane Overlay",
|
||||
"Description": "Multiplane Overlay compose multiple image layers, which can sometimes cause issues with graphics cards.",
|
||||
"Description": "Multiplane Overlay composes multiple image layers, which can sometimes cause issues with graphics cards. Changes to this preference are applied immediately.",
|
||||
"category": "Customize Preferences",
|
||||
"panel": "2",
|
||||
"Type": "Toggle",
|
||||
"Type": "Combobox",
|
||||
"ComboItems": "Enabled|Disabled (Compatibility)|Fully Disabled",
|
||||
"ComboDescriptions": {
|
||||
"Enabled": "Uses Windows' default overlay behavior.",
|
||||
"Disabled (Compatibility)": "Disables MPO using OverlayTestMode=5, the less aggressive compatibility method.",
|
||||
"Fully Disabled": "Disables MPO using OverlayTestMode=5 and DisableOverlays=1, the more aggressive method."
|
||||
},
|
||||
"registry": [
|
||||
{
|
||||
"Path": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\Dwm",
|
||||
"Name": "OverlayTestMode",
|
||||
"Value": "0",
|
||||
"Type": "DWord",
|
||||
"OriginalValue": "5",
|
||||
"DefaultState": "true"
|
||||
"DefaultValue": "0",
|
||||
"Values": {
|
||||
"Enabled": "<RemoveEntry>",
|
||||
"Disabled (Compatibility)": "5",
|
||||
"Fully Disabled": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Path": "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\GraphicsDrivers",
|
||||
"Name": "DisableOverlays",
|
||||
"Value": "0",
|
||||
"Type": "DWord",
|
||||
"OriginalValue": "1",
|
||||
"DefaultState": "true"
|
||||
"DefaultValue": "0",
|
||||
"Values": {
|
||||
"Enabled": "<RemoveEntry>",
|
||||
"Disabled (Compatibility)": "<RemoveEntry>",
|
||||
"Fully Disabled": "1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/multiplaneoverlay"
|
||||
@@ -1653,7 +1686,7 @@
|
||||
],
|
||||
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/loginblur"
|
||||
},
|
||||
"WPFTweaksDisableLockscreen": {
|
||||
"WPFToggleDisableLockscreen": {
|
||||
"Content": "Lock Screen - Disable",
|
||||
"Description": "Skips the lock screen entirely and goes directly to the sign-in screen on boot and wake.",
|
||||
"category": "Customize Preferences",
|
||||
@@ -1852,7 +1885,7 @@
|
||||
"category": "z__Advanced Tweaks - CAUTION",
|
||||
"panel": "1",
|
||||
"Type": "Combobox",
|
||||
"ComboItems": "Default DHCP Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult",
|
||||
"ComboItems": "Default DHCP Fastest Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult",
|
||||
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/changedns"
|
||||
},
|
||||
"WPFAddUltPerf": {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.astro
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
@@ -16,6 +16,8 @@ pnpm-debug.log*
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
|
||||
EXPOSE 4321
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
+30
-18
@@ -1,26 +1,24 @@
|
||||
# Starlight Starter Kit: Basics
|
||||
# WinUtil Docs
|
||||
|
||||
[](https://starlight.astro.build)
|
||||
|
||||
```
|
||||
npm create astro@latest -- --template starlight
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
Documentation site for [WinUtil](https://github.com/ChrisTitusTech/winutil), built with [Astro](https://astro.build) and [Starlight](https://starlight.astro.build). Served at [winutil.christitus.com](https://winutil.christitus.com/).
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro + Starlight project, you'll see the following folders and files:
|
||||
|
||||
```
|
||||
.
|
||||
├── public/
|
||||
├── src/
|
||||
│ ├── assets/
|
||||
│ ├── components/
|
||||
│ ├── content/
|
||||
│ │ └── docs/
|
||||
│ ├── styles/
|
||||
│ └── content.config.ts
|
||||
├── astro.config.mjs
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
@@ -33,17 +31,31 @@ Static assets, like favicons, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
All commands run in a Docker container — there's no need to install Node or npm dependencies on your host. This is deliberate, not just convenience: npm/pnpm/yarn have seen a steady stream of supply-chain attacks (malicious `postinstall`/`preinstall` scripts, credential-stealing packages), so `npm install` and friends never run directly on a contributor's machine here. Note the container still has read-write access to this `docs/` directory (it's bind-mounted for live reload), so this only contains a compromised package to the project folder plus the container itself — it doesn't reach the rest of your host (SSH keys, other repos, cloud credentials elsewhere on disk). Don't keep real secrets in `docs/` as a result.
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
[Docker](https://www.docker.com/) (with Compose) is required — install Docker Desktop (or Docker Engine + the `docker compose` plugin on Linux) and make sure the daemon is running before using any of the commands below.
|
||||
|
||||
All commands are run from the `docs/` directory, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------------------------------ | :----------------------------------------------- |
|
||||
| `docker compose build` | Builds the dev image (needed after Dockerfile or dependency changes) |
|
||||
| `docker compose up winutil-astro` | Starts local dev server at `localhost:4321` |
|
||||
| `docker compose run --rm winutil-astro npm run build` | Build the production site to `./dist/` |
|
||||
| `docker compose run --rm --service-ports winutil-astro npm run preview -- --host 0.0.0.0` | Preview the build locally, before deploying |
|
||||
| `docker compose run --rm winutil-astro npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `docker compose down` | Stop and remove the dev container |
|
||||
|
||||
Source files are bind-mounted into the container, so edits on the host are picked up immediately by the dev server — no rebuild needed for normal content or code changes. After changing `package.json`, `package-lock.json`, or the `Dockerfile`, rebuild the image *and* drop the `node_modules` volume, since Docker only seeds a named volume from the image the first time it's created — a plain rebuild leaves the old `node_modules` in place:
|
||||
|
||||
```sh
|
||||
docker compose build
|
||||
docker compose down -v
|
||||
docker compose up winutil-astro
|
||||
```
|
||||
|
||||
The first `docker compose up` (or any command before an image exists) builds the image and runs `npm install` from scratch, which can take a few minutes. Subsequent runs reuse the cached image and start almost immediately.
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
|
||||
Check out [Starlight's docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
|
||||
|
||||
@@ -15,6 +15,12 @@ export default defineConfig({
|
||||
replacesTitle: true,
|
||||
},
|
||||
favicon: '/favicon.svg',
|
||||
head: [
|
||||
{ tag: 'meta', attrs: { property: 'og:image', content: 'https://winutil.christitus.com/social-preview.png' } },
|
||||
{ tag: 'meta', attrs: { property: 'og:image:width', content: '1200' } },
|
||||
{ tag: 'meta', attrs: { property: 'og:image:height', content: '630' } },
|
||||
{ tag: 'meta', attrs: { name: 'twitter:image', content: 'https://winutil.christitus.com/social-preview.png' } },
|
||||
],
|
||||
social: [
|
||||
{ icon: 'github', label: 'GitHub', href: 'https://github.com/ChrisTitusTech/winutil' },
|
||||
{ icon: 'discord', label: 'Discord', href: 'https://discord.gg/RUbZUZyByQ' },
|
||||
@@ -50,6 +56,7 @@ export default defineConfig({
|
||||
label: 'Code Reference',
|
||||
items: [
|
||||
{ label: 'Architecture & Design', slug: 'code-reference/architecture' },
|
||||
{ label: 'Issue Triage Commands', slug: 'code-reference/issue-triage' },
|
||||
{ label: 'Tweaks Reference', items: [{ autogenerate: { directory: 'code-reference/tweaks' } }] },
|
||||
{ label: 'Features Reference', items: [{ autogenerate: { directory: 'code-reference/features' } }] },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
services:
|
||||
winutil-astro:
|
||||
build: .
|
||||
ports:
|
||||
- "127.0.0.1:4321:4321"
|
||||
volumes:
|
||||
- .:/app
|
||||
- astro_node_modules:/app/node_modules
|
||||
tmpfs:
|
||||
- /app/.astro
|
||||
environment:
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
- ASTRO_TELEMETRY_DISABLED=1
|
||||
|
||||
volumes:
|
||||
astro_node_modules:
|
||||
Generated
+540
-269
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -13,7 +13,7 @@
|
||||
"@astrojs/starlight": "^0.41.5",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
||||
"@fontsource/geist-sans": "^5.3.0",
|
||||
"astro": "^7.0.2",
|
||||
"sharp": "^0.35.3"
|
||||
"astro": "^7.3.2",
|
||||
"sharp": "^0.35.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://winutil.christitus.com/sitemap-index.xml
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -79,14 +79,15 @@ winutil/
|
||||
**Why**: Makes distribution easier (single file) and improves load time.
|
||||
|
||||
#### 2. scripts/main.ps1
|
||||
**Purpose**: Entry point that initializes the GUI and event system.
|
||||
**Purpose**: Entry point that manages the run.
|
||||
|
||||
**Responsibilities**:
|
||||
- Load XAML and create WPF window
|
||||
- Initialize form elements
|
||||
- Set up event handlers
|
||||
- Load configurations
|
||||
- Display the GUI
|
||||
- Run the headless `-Preset` and `-Config` paths
|
||||
- Start the interface on a dedicated STA runspace and wait for it
|
||||
- Report anything the interface thread failed with, then clean up
|
||||
|
||||
The interface itself lives in `Start-WinUtilUserInterface`, not here. See [Threading Model](#threading-model).
|
||||
|
||||
#### 3. functions/public/
|
||||
**Purpose**: User-facing functions that implement main features.
|
||||
@@ -129,28 +130,36 @@ winutil/
|
||||
|
||||
## Win11 Creator Architecture
|
||||
|
||||
The **Win11 Creator** is a specialized subsystem within Winutil that creates customized Windows 11 ISOs. It operates independently from the main package installation and tweak system.
|
||||
The **Win11 Creator** is a specialized subsystem within Winutil that creates customized Windows 11 ISOs. It operates independently of the main package installation and tweak system.
|
||||
|
||||
### Win11 Creator Components
|
||||
|
||||
**Core Functions** (`functions/private/`):
|
||||
- `Invoke-WinUtilISO.ps1`: Main orchestrator containing all Win11 Creator functions
|
||||
- `Invoke-WinUtilISO.ps1`: Main orchestrator containing ISO workflow and helper functions
|
||||
- `Invoke-WinUtilISOBrowse`: ISO file selection dialog
|
||||
- `Invoke-WinUtilISOMountAndVerify`: Validates and mounts ISO, verifies it's official Windows 11
|
||||
- `Invoke-WinUtilISOMountAndVerify`: Validates and mounts ISO, verifies it is an official Windows 11 ISO
|
||||
- `Invoke-WinUtilISOModify`: Launches modification in background runspace
|
||||
- `Invoke-WinUtilISOExport`: Handles ISO and USB export
|
||||
- `Invoke-WinUtilISOExport`: Builds bootable ISO via `oscdimg.exe`
|
||||
- `Invoke-WinUtilISOCheckExistingWork`: Recovers incomplete work sessions
|
||||
- `Invoke-WinUtilISOCleanAndReset`: Cleans up temp directories and resets UI
|
||||
- `Write-WinUtilISOLog`: Posts log messages to UI status box and session log
|
||||
- `Set-WinUtilISOStep`: Controls wizard navigation state
|
||||
- `Get-WinUtilEditionIdFromName`: Maps edition display names to setup edition IDs
|
||||
- `Invoke-WinUtilRobocopy`: Copies files with exit code verification
|
||||
- `Find-WinUtilOscdimg` / `Get-WinUtilOscdimgPath`: Locates or installs `oscdimg.exe`
|
||||
|
||||
- `Invoke-WinUtilISOScript.ps1`: Applies modifications to mounted install.wim
|
||||
- Removes provisioned AppX packages (40+ bloatware apps)
|
||||
- Injects drivers (optional) from the current system
|
||||
- Removes OneDrive setup files
|
||||
- Applies offline registry tweaks (hardware bypass, privacy, telemetry, OOBE)
|
||||
- Deletes telemetry scheduled task definitions
|
||||
- Pre-stages setup scripts from autounattend.xml
|
||||
- Removes unused Windows editions
|
||||
- Cleans component store via DISM
|
||||
- `Invoke-WinUtilISOUSB.ps1`: USB drive detection and formatting
|
||||
- `Invoke-WinUtilISORefreshUSBDrives`: Enumerates USB drives
|
||||
- `Get-WinUtilFreeDriveLetter`: Finds available drive letters
|
||||
- `Invoke-WinUtilISOWriteUSB`: Formats USB drive as GPT/FAT32, splits WIM if needed, and copies files
|
||||
|
||||
- `Invoke-WinUtilISOScript.ps1`: Prepares setup media and customizations
|
||||
- Stages AppX removal (19 bloatware packages) into `autounattend.xml` for first logon
|
||||
- Applies 50+ registry tweaks during Windows Setup and first logon; removes scheduled tasks at first logon
|
||||
- Triggers OneDrive uninstall during first logon
|
||||
- Stages setup script fallbacks to `sources\$OEM$\$$\Setup\Scripts\`
|
||||
- Pins selected edition in `autounattend.xml` and writes `sources\ei.cfg`
|
||||
- Injects eligible current system drivers into WIM images if enabled (WIM mount is only used for driver servicing)
|
||||
|
||||
### Win11 Creator Data Flow
|
||||
|
||||
@@ -170,36 +179,38 @@ User optionally enables the Driver Injection checkbox
|
||||
↓
|
||||
Invoke-WinUtilISOModify (runs in background runspace)
|
||||
├─ Create work directory: ~WinUtil_Win11ISO_[timestamp]
|
||||
├─ Copy ISO contents to disk (~5-6 GB)
|
||||
├─ Mount install.wim at selected edition/index
|
||||
├─ Copy ISO contents to disk via robocopy (~5-6 GB)
|
||||
├─ Invoke-WinUtilISOScript:
|
||||
│ ├─ Remove 40+ bloat AppX packages
|
||||
│ ├─ Export and inject drivers (if enabled)
|
||||
│ ├─ Remove OneDrive setup
|
||||
│ ├─ Load offline registry hives
|
||||
│ ├─ Apply 50+ registry tweaks (hardware bypass, privacy, telemetry, OOBE, etc.)
|
||||
│ ├─ Delete telemetry scheduled task files
|
||||
│ ├─ Pre-stage setup scripts from autounattend.xml to C:\Windows\Setup\Scripts\
|
||||
│ └─ Unload registry hives
|
||||
├─ DISM /Cleanup-Image /StartComponentCleanup /ResetBase (saves 300-800 MB)
|
||||
├─ Dismount and save the modified install.wim (~10+ minutes, slowest step)
|
||||
├─ Export selected edition only (removes all other editions, saves 1-2 GB each)
|
||||
│ ├─ Generate autounattend.xml with Windows PE and specialize safeguards
|
||||
│ ├─ Add first-logon script (19 AppX removals, registry tweaks, OneDrive uninstall)
|
||||
│ ├─ Pin selected edition index in autounattend.xml (/IMAGE/INDEX)
|
||||
│ ├─ Stage setup script fallbacks to sources\$OEM$\$$\Setup\Scripts\
|
||||
│ ├─ Write sources\ei.cfg and remove stale sources\PID.txt
|
||||
│ └─ If driver injection enabled:
|
||||
│ ├─ Require install.wim; install.esd cannot accept driver injection
|
||||
│ ├─ Export current system drivers via DISM
|
||||
│ ├─ Exclude stale duplicate packages
|
||||
│ ├─ Inject storage drivers into boot.wim index 2 and eligible drivers into install.wim
|
||||
│ └─ Validate WIM metadata before and after injection
|
||||
├─ Dismount source ISO
|
||||
└─ Report completion, enable export options
|
||||
↓
|
||||
Invoke-WinUtilISOExport (user chooses output)
|
||||
├─ Option 1: Save as ISO
|
||||
│ ├─ Build bootable ISO via oscdimg.exe (BIOS/UEFI dual-boot)
|
||||
│ └─ Output: Win11_Modified_[date].iso (2.5-3.5 GB)
|
||||
│ └─ Output: Win11_Modified_[date].iso (close to the source ISO size)
|
||||
│
|
||||
└─ Option 2: Write to USB
|
||||
├─ Format USB as GPT
|
||||
├─ Create 512 MB EFI partition
|
||||
├─ Copy modified ISO contents
|
||||
├─ Create single FAT32 partition (capped at 32 GB)
|
||||
├─ Split install.wim into .swm files if > 3.8 GB
|
||||
├─ Reject install.esd files of 4 GB or more
|
||||
├─ Copy files via robocopy
|
||||
└─ Output: Bootable USB (minimum 8 GB)
|
||||
↓
|
||||
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
|
||||
```
|
||||
|
||||
@@ -211,20 +222,30 @@ Invoke-WinUtilISOCleanAndReset (optional)
|
||||
- Checks image metadata for "Windows 11" string
|
||||
- Rejects custom, modified, or non-Windows 11 ISOs
|
||||
|
||||
**WIM Metadata Validation**:
|
||||
- During driver injection, `Assert-WinUtilISOWimMetadata` validates critical fields (`Languages`, `Installation`, `Edition`, `ProductSuite`, `ProductType`) before and after WIM servicing
|
||||
- Driver injection stops before export if the required metadata is missing or changes
|
||||
- Without driver injection, WinUtil preserves the original installation image and skips WIM metadata validation
|
||||
|
||||
**Edition Pinning & Setup Fallback**:
|
||||
- Writes `sources\ei.cfg` and removes `sources\PID.txt` so setup does not use mismatched OEM product keys
|
||||
- Pins the selected edition index in `autounattend.xml` (`/IMAGE/INDEX`)
|
||||
- Stages fallback setup scripts under `sources\$OEM$\$$\Setup\Scripts\` with `UseConfigurationSet` enabled
|
||||
|
||||
**Work Session Recovery**:
|
||||
- Auto-detects incomplete work from previous sessions
|
||||
- Allows resuming Step 4 (export) without re-running Steps 1-3
|
||||
- Allows resuming the export step without re-running selection and modification
|
||||
- Prevents redundant modifications
|
||||
|
||||
**Modification Safety**:
|
||||
- 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
|
||||
- Logged to `WinUtil_Win11ISO.log` for debugging
|
||||
- DISM handles image dismount with automatic cleanup on error
|
||||
- Logged to the WinUtil session log and the live UI status panel
|
||||
- DISM handles image dismount with automatic cleanup and discard on error
|
||||
|
||||
### 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**:
|
||||
- TPM 2.0 check bypass
|
||||
@@ -262,9 +283,9 @@ The `Invoke-WinUtilISOScript` function applies **50+ offline registry tweaks**:
|
||||
|
||||
### Driver Injection Feature
|
||||
|
||||
**Optional Enhancement**: When enabled, exports all drivers from the running system and injects them into both:
|
||||
- `install.wim` (main OS image)
|
||||
- `boot.wim` index 2 (Windows Setup PE environment)
|
||||
**Optional Enhancement**: When enabled, exports all drivers from the running system and injects them into:
|
||||
- `install.wim` (main OS image), excluding stale duplicates
|
||||
- `boot.wim` index 2 (Windows Setup PE environment), storage controllers only (`SCSIAdapter` / `HDC`)
|
||||
|
||||
**Use Case**: Enables offline installation on systems with missing drivers.
|
||||
|
||||
@@ -272,7 +293,7 @@ The `Invoke-WinUtilISOScript` function applies **50+ offline registry tweaks**:
|
||||
|
||||
- **Temporary working directory**: ~10-15 GB
|
||||
- **Original ISO**: 4-6 GB
|
||||
- **Modified ISO**: 2.5-3.5 GB
|
||||
- **Modified ISO**: close to the source ISO size
|
||||
- **Total needed**: ~25 GB for safe operation
|
||||
|
||||
## Data Flow
|
||||
@@ -389,26 +410,73 @@ Update UI
|
||||
- `Description`: What it does
|
||||
- `category`: Essential/Advanced/Customize
|
||||
- `registry`: Registry changes to make
|
||||
- `registry[].Values`: Per-state values for a registry-backed combobox
|
||||
- `registry[].DefaultValue`: Effective value when the registry entry is absent
|
||||
- `service`: Services to change
|
||||
- `OriginalValue/State`: For undo functionality
|
||||
|
||||
## PowerShell Runspace
|
||||
## Threading Model
|
||||
|
||||
Winutil uses PowerShell runspaces for the GUI to remain responsive:
|
||||
WinUtil runs on three kinds of thread, and each has one job:
|
||||
|
||||
| Thread | Runspace | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| Main | The one the script started in | Start the interface, wait for it, surface its errors, clean up |
|
||||
| Interface | `$sync.UIRunspace`, a dedicated STA runspace | Own the window. Paint and dispatch, nothing else |
|
||||
| Workers | `$sync.runspace`, a shared pool | Run everything long: installs, tweaks, features, AppX, Win11 Creator |
|
||||
|
||||
All three are created from the same starting point, `New-WinUtilSessionState`, which carries
|
||||
`$sync`, the compiled script's globals, and every WinUtil function. That is what lets any
|
||||
thread call any helper without the caller injecting function definitions.
|
||||
|
||||
```powershell
|
||||
# Create runspace
|
||||
$sync.runspace = [runspacefactory]::CreateRunspace()
|
||||
$sync.runspace.Open()
|
||||
$sync.runspace.SessionStateProxy.SetVariable("sync", $sync)
|
||||
# main.ps1 - the interface gets its own thread
|
||||
$sync.UIRunspace = [runspacefactory]::CreateRunspace($Host, (New-WinUtilSessionState))
|
||||
$sync.UIRunspace.ApartmentState = "STA"
|
||||
$sync.UIRunspace.Open()
|
||||
|
||||
# Run code in background
|
||||
$powershell = [powershell]::Create().AddScript($scriptblock)
|
||||
$powershell.Runspace = $sync.runspace
|
||||
$handle = $powershell.BeginInvoke()
|
||||
$uiShell = [powershell]::Create()
|
||||
$uiShell.Runspace = $sync.UIRunspace
|
||||
[void]$uiShell.AddScript({ Start-WinUtilUserInterface })
|
||||
$uiHandle = $uiShell.BeginInvoke()
|
||||
$uiHandle.AsyncWaitHandle.WaitOne()
|
||||
```
|
||||
|
||||
**Why**: Prevents UI freezing during long-running operations.
|
||||
**Why**: the window never blocks on work, and a failure on the interface thread is reported
|
||||
instead of disappearing.
|
||||
|
||||
## Long-Running Work
|
||||
|
||||
Every long action goes through `Start-WinUtilJob`, which owns everything a running operation
|
||||
needs: refusing to start while another job runs, the busy flag (`$sync.ActiveJob`), the progress
|
||||
bar and taskbar item, the boxed console banner, a start/finish/failure line in the log, and
|
||||
restoring the interface in a `finally` whatever happens.
|
||||
|
||||
`Invoke-WPFButton` decides what counts as a long action. Anything that changes the system gets a
|
||||
job; anything that only changes what the interface is showing runs on the interface thread.
|
||||
That single classification is why no workflow arranges its own progress, banner or busy state.
|
||||
|
||||
```powershell
|
||||
Start-WinUtilJob -Name "Features" -Description "Installing Windows Features" -Parameters @{
|
||||
Features = @($sync.selectedFeatures)
|
||||
} -ScriptBlock {
|
||||
param($Features)
|
||||
|
||||
$total = @($Features).Count
|
||||
$completed = 0
|
||||
foreach ($feature in $Features) {
|
||||
$completed++
|
||||
Step-WinUtilJob -Status "Installing $feature ($completed/$total)" -Percent ([int](($completed / $total) * 100))
|
||||
Invoke-WinUtilFeatureInstall $feature
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The body only has to do the work and call `Step-WinUtilJob`. Anything it throws is
|
||||
caught, logged, and shown on the taskbar as a failure.
|
||||
|
||||
**Values, not closures**: the body is rebuilt inside the worker from its text, so it receives
|
||||
what it needs through `-Parameters` rather than capturing the caller's variables.
|
||||
|
||||
## WPF Event Handling
|
||||
|
||||
@@ -454,23 +522,21 @@ if (!(Get-Command choco -ErrorAction SilentlyContinue)) {
|
||||
choco install $app.choco -y
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
## Error Handling And Logging
|
||||
|
||||
Winutil uses PowerShell error handling:
|
||||
A job body does not need its own error handling. `Start-WinUtilJob` catches whatever the body
|
||||
throws, logs it, and marks the run as failed on the taskbar, so a failure can never leave the
|
||||
interface stuck busy. Only catch inside a body when you have something specific to do first,
|
||||
such as cleaning up a mounted image, and then rethrow.
|
||||
|
||||
```powershell
|
||||
try {
|
||||
# Attempt operation
|
||||
Invoke-SomeOperation
|
||||
}
|
||||
catch {
|
||||
Write-Host "Error: $_" -ForegroundColor Red
|
||||
# Log error
|
||||
Add-Content -Path $logfile -Value "ERROR: $_"
|
||||
}
|
||||
Write-WinUtilLog -Level "ERROR" -Component "Install" -Message "winget install failed: $($_.Exception.Message)"
|
||||
```
|
||||
|
||||
**Logging**: Errors and operations are logged for debugging.
|
||||
Entries and console diagnostics go to the same
|
||||
`%LocalAppData%\winutil\logs\winutil_<timestamp>.log` session file. While `Start-Transcript`
|
||||
owns that file, `Write-WinUtilLog` writes through the host so the transcript captures the entry
|
||||
without a competing direct file write.
|
||||
|
||||
## Configuration Loading
|
||||
|
||||
@@ -488,15 +554,23 @@ $sync.configs.features = Get-Content "config/feature.json" | ConvertFrom-Json
|
||||
|
||||
## UI Update Pattern
|
||||
|
||||
UI updates must happen on the UI thread:
|
||||
Controls may only be touched from the thread that owns the window, so background work reaches
|
||||
them through `Invoke-WPFUIThread`:
|
||||
|
||||
```powershell
|
||||
$sync.form.Dispatcher.Invoke([action]{
|
||||
$sync.WPFStatusLabel.Content = "Installing..."
|
||||
}, "Normal")
|
||||
Invoke-WPFUIThread -Parameters @{ Count = $installed.Count } -ScriptBlock {
|
||||
param($Count)
|
||||
$sync.WPFselectedAppsButton.Content = "Selected Apps: $Count"
|
||||
}
|
||||
```
|
||||
|
||||
**Why**: WPF requires UI updates on the main thread.
|
||||
Add `-Async` to post the update instead of waiting for it. `Step-WinUtilJob` and the
|
||||
Win11 Creator status log use that so a per-item update never stalls the worker.
|
||||
|
||||
**Values, not closures**: the body is rebuilt inside the interface runspace, so it takes what it
|
||||
needs through `-Parameters`. Handing over a scriptblock from a worker instead would keep that
|
||||
worker's session state, which loses the caller's variables on an async post and costs roughly
|
||||
twenty times as much per command - enough to turn a checkbox refresh into a visible freeze.
|
||||
|
||||
## Adding New Features
|
||||
|
||||
|
||||
@@ -125,9 +125,9 @@ function Invoke-WPFFixesUpdate {
|
||||
if (Test-Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate") {
|
||||
Write-Progress -Id 0 -Activity "Repairing Windows Update" -Status "Removing WSUS client settings..." -PercentComplete 60
|
||||
Write-Progress -Id 6 -ParentId 0 -Activity "Removing WSUS client settings" -PercentComplete 0
|
||||
Start-Process -NoNewWindow -FilePath "REG" -ArgumentList "DELETE", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate", "/v", "AccountDomainSid", "/f" -RedirectStandardError "NUL"
|
||||
Start-Process -NoNewWindow -FilePath "REG" -ArgumentList "DELETE", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate", "/v", "PingID", "/f" -RedirectStandardError "NUL"
|
||||
Start-Process -NoNewWindow -FilePath "REG" -ArgumentList "DELETE", "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate", "/v", "SusClientId", "/f" -RedirectStandardError "NUL"
|
||||
Remove-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate" -Name "AccountDomainSid" -ErrorAction SilentlyContinue
|
||||
Remove-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate" -Name "PingID" -ErrorAction SilentlyContinue
|
||||
Remove-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate" -Name "SusClientId" -ErrorAction SilentlyContinue
|
||||
Write-Progress -Id 6 -ParentId 0 -Activity "Removing WSUS client settings" -Status "Completed" -PercentComplete 100
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: Issue Triage Commands
|
||||
description: Commands trusted maintainers can use to label and close GitHub issues.
|
||||
---
|
||||
|
||||
Trusted repository members whose numeric GitHub user IDs are listed in the issue triage workflow can run the commands below. These commands work only on issues. Comments on pull requests are ignored, and the workflow token has no pull-request write permission.
|
||||
|
||||
## Commands
|
||||
|
||||
- `/triage` adds the `needs-triage` label.
|
||||
- `/triageoff` removes the `needs-triage` label when it is present.
|
||||
- `/np` closes the issue as not planned.
|
||||
- `/np wontfix` adds the `wontfix` label without replacing existing labels, then closes the issue as not planned.
|
||||
- `/np notrelated` adds the `not-related` label without replacing existing labels, then closes the issue as not planned.
|
||||
- `/np <reason>` closes the issue as not planned without adding a label for unrecognized reasons.
|
||||
- `/duplicate <issue number>` closes the issue as a duplicate of another issue. The positive issue number may optionally start with `#`; pull request numbers are rejected.
|
||||
|
||||
## Command handling
|
||||
|
||||
Put exactly one command in the comment. Leading and trailing whitespace is allowed, but text before or after the command is not. Command matching is case-insensitive, and malformed issue numbers are rejected.
|
||||
|
||||
After a command is handled, the workflow deletes the command comment and leaves an audit comment describing the result. A missing duplicate target or a request to duplicate an issue into itself is reported without closing the issue. Unexpected GitHub API failures stop the workflow and leave the command comment available for retry.
|
||||
|
||||
## Access
|
||||
|
||||
Access is granted by adding the trusted member's immutable numeric GitHub user ID to `.github/workflows/triage-tools.yaml` through a pull request. Abuse can result in removal from the allowlist or other repository moderation action.
|
||||
+22
-10
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Multiplane Overlay"
|
||||
description: "Multiplane Overlay compose multiple image layers, which can sometimes cause issues with graphics cards."
|
||||
description: "Multiplane Overlay composes multiple image layers, which can sometimes cause issues with graphics cards. Changes to this preference are applied immediately."
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
@@ -9,28 +9,40 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
||||
:::
|
||||
|
||||
```json title="config/tweaks.json"
|
||||
"WPFToggleMultiplaneOverlay": {
|
||||
"WPFMultiplaneOverlay": {
|
||||
"Content": "Multiplane Overlay",
|
||||
"Description": "Multiplane Overlay compose multiple image layers, which can sometimes cause issues with graphics cards.",
|
||||
"Description": "Multiplane Overlay composes multiple image layers, which can sometimes cause issues with graphics cards. Changes to this preference are applied immediately.",
|
||||
"category": "Customize Preferences",
|
||||
"panel": "2",
|
||||
"Type": "Toggle",
|
||||
"Type": "Combobox",
|
||||
"ComboItems": "Enabled|Disabled (Compatibility)|Fully Disabled",
|
||||
"ComboDescriptions": {
|
||||
"Enabled": "Uses Windows' default overlay behavior.",
|
||||
"Disabled (Compatibility)": "Disables MPO using OverlayTestMode=5, the less aggressive compatibility method.",
|
||||
"Fully Disabled": "Disables MPO using OverlayTestMode=5 and DisableOverlays=1, the more aggressive method."
|
||||
},
|
||||
"registry": [
|
||||
{
|
||||
"Path": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\Dwm",
|
||||
"Name": "OverlayTestMode",
|
||||
"Value": "0",
|
||||
"Type": "DWord",
|
||||
"OriginalValue": "5",
|
||||
"DefaultState": "true"
|
||||
"DefaultValue": "0",
|
||||
"Values": {
|
||||
"Enabled": "<RemoveEntry>",
|
||||
"Disabled (Compatibility)": "5",
|
||||
"Fully Disabled": "5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Path": "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\GraphicsDrivers",
|
||||
"Name": "DisableOverlays",
|
||||
"Value": "0",
|
||||
"Type": "DWord",
|
||||
"OriginalValue": "1",
|
||||
"DefaultState": "true"
|
||||
"DefaultValue": "0",
|
||||
"Values": {
|
||||
"Enabled": "<RemoveEntry>",
|
||||
"Disabled (Compatibility)": "<RemoveEntry>",
|
||||
"Fully Disabled": "1"
|
||||
}
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
||||
"service": [
|
||||
{
|
||||
"Name": "lfsvc",
|
||||
"StartupType": "Disable",
|
||||
"StartupType": "Disabled",
|
||||
"OriginalType": "Manual"
|
||||
}
|
||||
],
|
||||
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
---
|
||||
title: "Fullscreen Optimizations - Disable"
|
||||
description: "Disables FSO in all applications. NOTE: This will disable Color Management in Exclusive Fullscreen."
|
||||
editUrl: false
|
||||
---
|
||||
|
||||
:::note
|
||||
This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitusTech/winutil/blob/main/config/tweaks.json). Do not edit this page directly.
|
||||
:::
|
||||
|
||||
```json title="config/tweaks.json"
|
||||
"WPFTweaksDisableFSO": {
|
||||
"Content": "Fullscreen Optimizations - Disable",
|
||||
"Description": "Disables FSO in all applications. NOTE: This will disable Color Management in Exclusive Fullscreen.",
|
||||
"category": "z__Advanced Tweaks - CAUTION",
|
||||
"panel": "1",
|
||||
"registry": [
|
||||
{
|
||||
"Path": "HKCU:\\System\\GameConfigStore",
|
||||
"Name": "GameDVR_DXGIHonorFSEWindowsCompatible",
|
||||
"Value": "1",
|
||||
"Type": "DWord",
|
||||
"OriginalValue": "0"
|
||||
}
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Registry Changes
|
||||
|
||||
Applications and System Components store and retrieve configuration data to modify Windows settings, so we can use the registry to change many settings in one place.
|
||||
|
||||
You can find information about the registry on [Wikipedia](https://en.wikipedia.org/wiki/Windows_Registry) and [Microsoft's Website](https://learn.microsoft.com/en-us/windows/win32/sysinfo/registry).
|
||||
+1
-1
@@ -14,6 +14,6 @@ This page is generated from [`config/tweaks.json`](https://github.com/ChrisTitus
|
||||
"category": "z__Advanced Tweaks - CAUTION",
|
||||
"panel": "1",
|
||||
"Type": "Combobox",
|
||||
"ComboItems": "Default DHCP Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult",
|
||||
"ComboItems": "Default DHCP Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult Mullvad Mullvad_Ads_Trackers Mullvad_Ads_Trackers_Malware Mullvad_Ads_Trackers_Malware_Social Mullvad_Ads_Trackers_Malware_Adult_Gambling Mullvad_Ads_Trackers_Malware_Adult_Gambling_Social",
|
||||
}
|
||||
```
|
||||
|
||||
@@ -101,7 +101,7 @@ While you can make your changes directly through the Web, we recommend cloning t
|
||||
* Run the following command to compile and run WinUtil:
|
||||
* `.\Compile.ps1 -run`
|
||||
|
||||

|
||||

|
||||
|
||||
* 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.
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ Use the Applications tab to install, upgrade, uninstall, and review supported ap
|
||||
|
||||

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