Compare commits

...
20 Commits
Author SHA1 Message Date
Sean (ANGRYxScotsman)andGitHub ea5de27b6c Update issue templates with prefilled titles (#4960)
* Prefill title on bug report issue template

* Prefill title on feature request issue template

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

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

* Document Docker-only npm policy for agents

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

* Harden docs Docker dev environment

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

* Clarify docs secret handling in AGENTS

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

* Fix preview command to expose port in Docker

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

* Fix Mullvad DNS review issues

* Add Mullvad secondary resolvers

* Register DoH before changing adapter DNS

* Report DNS failures to tweak workflow

* Handle non-terminating DNS assignment failures

* Preserve DNS fallback and document Mullvad options

---------

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

* Add accessibility name to SettingsButton

* Avoid nesting ScrollViewer in generated panels with outer viewers

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

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

* docs: explain MPO tweak states

* docs: address code review comments

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

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

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

* Fix the filter interactions the category chips missed

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

* Document the Install category filters

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

* Correct the category filter guide wording

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

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

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

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

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

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

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

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

* Suppress stray console output from automation runs

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

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

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

* Add Fast Node Manager entry to applications.json

---------

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

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

* Add social preview metadata

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

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

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

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

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

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

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

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

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

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

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

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

* Update functions/private/Initialize-InstallAppEntry.ps1

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-08-06 12:51:58 -05:00
62 changed files with 2171 additions and 260 deletions
+1
View File
@@ -1,5 +1,6 @@
name: "Bug report"
description: "Report a bug to help us identify and fix issues in the project."
title: "[Bug Report] - <your summary here>"
labels: ["bug"]
body:
@@ -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:
+27 -15
View File
@@ -42,25 +42,36 @@ These rules override everything else in this file when in conflict:
```powershell
Invoke-ScriptAnalyzer -Path . -Settings .\lint\PSScriptAnalyser.ps1 -Recurse
```
- Docs site dev server (run from `docs/`):
- Docs site dev server (run from `docs/`; see Section 2 for why this goes through Docker):
```powershell
npm install
npm run dev
docker compose up winutil-astro
```
- Docs site production build (run from `docs/`):
```powershell
npm run build
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
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`).
- 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/`. The rest of the repo is PowerShell (`Compile.ps1`, Pester, Script Analyzer) and runs directly on the host per Section 1.
## 3. Source Of Truth
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.
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.
## 3. Before Editing
## 4. Before Editing
- State the plan in one or two sentences before editing. For non-trivial work, include the verification you intend to run.
- Read the files you will touch and the files that call them.
@@ -68,7 +79,7 @@ This scoping applies to compiled-script behavior only. Repository metadata — `
- Surface assumptions when they affect behavior, compatibility, or user data.
- If two approaches have meaningful tradeoffs, name them before choosing. Trivial tasks can proceed directly.
## 4. Coding Guidelines
## 5. Coding Guidelines
- 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.
@@ -81,7 +92,7 @@ This scoping applies to compiled-script behavior only. Repository metadata — `
- Clean up orphans created by your own changes, such as unused variables or functions made obsolete by the edit.
- Avoid broad formatting-only edits, especially in JSON config files, XAML, docs, and generated output.
## 5. Runtime And Safety Rules
## 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 (see SPEC.md's Safety Requirements).
- Prefer existing helper functions for WinGet, Chocolatey, registry, services, progress, and UI updates.
@@ -90,7 +101,7 @@ This scoping applies to compiled-script behavior only. Repository metadata — `
- Avoid storing credentials, secrets, or machine-specific paths in repo files.
- Preserve logging and user feedback patterns for long-running or destructive operations.
## 6. Surgical Changes
## 7. Surgical Changes
- Do not improve adjacent code, comments, formatting, imports, or docs unless required.
- Do not refactor working code because you are already in the file.
@@ -98,7 +109,7 @@ This scoping applies to compiled-script behavior only. Repository metadata — `
- Keep diffs reviewable. Every changed line should trace to the user's request.
- If a change starts spreading across unrelated areas, pause and reassess the plan.
## 7. Verification
## 8. Verification
Define success in terms that can be checked, then check it.
@@ -112,7 +123,7 @@ Define success in terms that can be checked, then check it.
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.
## 8. Generated Files And Git Hygiene
## 9. Generated Files And Git Hygiene
- Treat local `winutil.ps1` changes as disposable compile output.
- Never stage or commit `winutil.ps1`, `binary/`, or anything else ignored by the root `.gitignore` or `docs/.gitignore` — read those files rather than assuming. `docs/public/` is tracked source for static assets, not generated output.
@@ -122,7 +133,7 @@ If a check cannot be run, say exactly why and what residual risk remains. See SP
- 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.
## 9. Documentation Expectations
## 10. Documentation Expectations
- 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).
@@ -131,7 +142,7 @@ If a check cannot be run, say exactly why and what residual risk remains. See SP
- Put detailed user and developer documentation under `docs/`.
- Keep SPEC.md aligned with project/architecture changes, and this file aligned with process changes.
## 10. Communication Style
## 11. Communication Style
- Be direct and concise. Start with the answer or action.
- No flattery, filler, ceremonial closings, or fake certainty.
@@ -139,7 +150,7 @@ If a check cannot be run, say exactly why and what residual risk remains. See SP
- Report what changed, how it was verified, and anything not done.
- If the user asks for a review, lead with findings and file/line references.
## 11. When To Ask
## 12. When To Ask
Ask before proceeding when:
@@ -154,7 +165,7 @@ Proceed without asking when:
- Ambiguity can be resolved by reading the code or running a local command.
- The user already answered the question in this session.
## 12. Project Learnings
## 13. Project Learnings
When the user corrects an agent approach, add or tighten one concrete rule here before ending the session. Keep this section short and prune rules that no longer matter.
@@ -163,6 +174,7 @@ When the user corrects an agent approach, add or tighten one concrete rule here
- Import Pester 5.8.0 before running tests so `Invoke-Pester -Output Detailed -CI` does not resolve to Windows' inbox Pester 3.4.0.
- Keep package install/uninstall process launches simple unless explicitly requested; do not add a separate stdout/stderr process logging helper for winget or Chocolatey.
- When the active log file is owned by `Start-Transcript`, do not call `Add-Content` against that file; write to host output so the transcript captures the line in the same log file without recording a terminating-error diagnostic.
- Keep UI helpers such as `Invoke-WPFUIThread` and `Set-WinUtilTweaksProgressIndicator` safe to call without a window; the `-Preset` and `-Config` paths run the workflows before the form is created and before PresentationCore is loaded.
- 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.
+1 -2
View File
@@ -1,3 +1,2 @@
# CLAUDE.md
Read `AGENTS.md` in this repository root for operating instructions before doing anything else.
@AGENTS.md
+1
View File
@@ -97,6 +97,7 @@ Because the final script is concatenated, code cannot rely on runtime module imp
- `docs/src/content/docs/code-reference/tweaks/` and `.../features/` are auto-generated by `tools/devdocs-generator.ps1` from `config/tweaks.json`/`config/feature.json` and the relevant PowerShell function files. Other pages under `code-reference/` (e.g. `architecture.mdx`) are hand-written and untouched by the generator.
- 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/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
+98
View File
@@ -125,6 +125,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",
@@ -323,6 +332,15 @@
"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",
@@ -458,6 +476,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 +530,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",
@@ -791,6 +845,14 @@
"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",
@@ -1186,6 +1248,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",
@@ -1438,6 +1509,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 +1527,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",
@@ -1618,6 +1707,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",
+54
View File
@@ -54,5 +54,59 @@
"Primary6": "2a10:50c0::bad1:ff",
"Secondary6": "2a10:50c0::bad2:ff",
"DohTemplate": "https://family.adguard-dns.com/dns-query"
},
"Mullvad":{
"Primary": "194.242.2.2",
"Secondary": "194.242.2.3",
"Primary6": "2a07:e340::2",
"Secondary6": "2a07:e340::3",
"DohOnly": true,
"DohTemplate": "https://dns.mullvad.net/dns-query",
"SecondaryDohTemplate": "https://adblock.dns.mullvad.net/dns-query"
},
"Mullvad_Ads_Trackers":{
"Primary": "194.242.2.3",
"Secondary": "194.242.2.2",
"Primary6": "2a07:e340::3",
"Secondary6": "2a07:e340::2",
"DohOnly": true,
"DohTemplate": "https://adblock.dns.mullvad.net/dns-query",
"SecondaryDohTemplate": "https://dns.mullvad.net/dns-query"
},
"Mullvad_Ads_Trackers_Malware":{
"Primary": "194.242.2.4",
"Secondary": "194.242.2.3",
"Primary6": "2a07:e340::4",
"Secondary6": "2a07:e340::3",
"DohOnly": true,
"DohTemplate": "https://base.dns.mullvad.net/dns-query",
"SecondaryDohTemplate": "https://adblock.dns.mullvad.net/dns-query"
},
"Mullvad_Ads_Trackers_Malware_Social":{
"Primary": "194.242.2.5",
"Secondary": "194.242.2.4",
"Primary6": "2a07:e340::5",
"Secondary6": "2a07:e340::4",
"DohOnly": true,
"DohTemplate": "https://extended.dns.mullvad.net/dns-query",
"SecondaryDohTemplate": "https://base.dns.mullvad.net/dns-query"
},
"Mullvad_Ads_Trackers_Malware_Adult_Gambling":{
"Primary": "194.242.2.6",
"Secondary": "194.242.2.5",
"Primary6": "2a07:e340::6",
"Secondary6": "2a07:e340::5",
"DohOnly": true,
"DohTemplate": "https://family.dns.mullvad.net/dns-query",
"SecondaryDohTemplate": "https://extended.dns.mullvad.net/dns-query"
},
"Mullvad_Ads_Trackers_Malware_Adult_Gambling_Social":{
"Primary": "194.242.2.9",
"Secondary": "194.242.2.6",
"Primary6": "2a07:e340::9",
"Secondary6": "2a07:e340::6",
"DohOnly": true,
"DohTemplate": "https://all.dns.mullvad.net/dns-query",
"SecondaryDohTemplate": "https://family.dns.mullvad.net/dns-query"
}
}
+22 -10
View File
@@ -1459,28 +1459,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"
@@ -1852,7 +1864,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 Google Cloudflare Cloudflare_Malware Cloudflare_Malware_Adult Open_DNS Quad9 AdGuard_Ads_Trackers AdGuard_Ads_Trackers_Malware_Adult Mullvad Mullvad_Ads_Trackers Mullvad_Ads_Trackers_Malware Mullvad_Ads_Trackers_Malware_Social Mullvad_Ads_Trackers_Malware_Adult_Gambling Mullvad_Ads_Trackers_Malware_Adult_Gambling_Social",
"link": "https://winutil.christitus.com/code-reference/tweaks/z--advanced-tweaks---caution/changedns"
},
"WPFAddUltPerf": {
+7
View File
@@ -0,0 +1,7 @@
node_modules
.astro
dist
.git
.env
.env.*
*.log
+16
View File
@@ -0,0 +1,16 @@
FROM node:22-bookworm-slim
RUN corepack enable
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN chown -R node:node /app
USER node
EXPOSE 4321
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+30 -18
View File
@@ -1,26 +1,24 @@
# Starlight Starter Kit: Basics
# WinUtil Docs
[![Built with Starlight](https://astro.badg.es/v2/built-with-starlight/tiny.svg)](https://starlight.astro.build)
```
npm create astro@latest -- --template starlight
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
Documentation site for [WinUtil](https://github.com/ChrisTitusTech/winutil), built with [Astro](https://astro.build) and [Starlight](https://starlight.astro.build). Served at [winutil.christitus.com](https://winutil.christitus.com/).
## 🚀 Project Structure
Inside of your Astro + Starlight project, you'll see the following folders and files:
```
.
├── public/
├── src/
│ ├── assets/
│ ├── components/
│ ├── content/
│ │ └── docs/
│ ├── styles/
│ └── content.config.ts
├── astro.config.mjs
├── docker-compose.yml
├── Dockerfile
├── package.json
└── tsconfig.json
```
@@ -33,17 +31,31 @@ Static assets, like favicons, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
All commands run in a Docker container — there's no need to install Node or npm dependencies on your host. This is deliberate, not just convenience: npm/pnpm/yarn have seen a steady stream of supply-chain attacks (malicious `postinstall`/`preinstall` scripts, credential-stealing packages), so `npm install` and friends never run directly on a contributor's machine here. Note the container still has read-write access to this `docs/` directory (it's bind-mounted for live reload), so this only contains a compromised package to the project folder plus the container itself — it doesn't reach the rest of your host (SSH keys, other repos, cloud credentials elsewhere on disk). Don't keep real secrets in `docs/` as a result.
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
[Docker](https://www.docker.com/) (with Compose) is required — install Docker Desktop (or Docker Engine + the `docker compose` plugin on Linux) and make sure the daemon is running before using any of the commands below.
All commands are run from the `docs/` directory, from a terminal:
| Command | Action |
| :------------------------------------------------ | :----------------------------------------------- |
| `docker compose build` | Builds the dev image (needed after Dockerfile or dependency changes) |
| `docker compose up winutil-astro` | Starts local dev server at `localhost:4321` |
| `docker compose run --rm winutil-astro npm run build` | Build the production site to `./dist/` |
| `docker compose run --rm --service-ports winutil-astro npm run preview -- --host 0.0.0.0` | Preview the build locally, before deploying |
| `docker compose run --rm winutil-astro npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `docker compose down` | Stop and remove the dev container |
Source files are bind-mounted into the container, so edits on the host are picked up immediately by the dev server — no rebuild needed for normal content or code changes. After changing `package.json`, `package-lock.json`, or the `Dockerfile`, rebuild the image *and* drop the `node_modules` volume, since Docker only seeds a named volume from the image the first time it's created — a plain rebuild leaves the old `node_modules` in place:
```sh
docker compose build
docker compose down -v
docker compose up winutil-astro
```
The first `docker compose up` (or any command before an image exists) builds the image and runs `npm install` from scratch, which can take a few minutes. Subsequent runs reuse the cached image and start almost immediately.
## 👀 Want to learn more?
Check out [Starlights 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).
+6
View File
@@ -15,6 +15,12 @@ export default defineConfig({
replacesTitle: true,
},
favicon: '/favicon.svg',
head: [
{ tag: 'meta', attrs: { property: 'og:image', content: 'https://winutil.christitus.com/social-preview.png' } },
{ tag: 'meta', attrs: { property: 'og:image:width', content: '1200' } },
{ tag: 'meta', attrs: { property: 'og:image:height', content: '630' } },
{ tag: 'meta', attrs: { name: 'twitter:image', content: 'https://winutil.christitus.com/social-preview.png' } },
],
social: [
{ icon: 'github', label: 'GitHub', href: 'https://github.com/ChrisTitusTech/winutil' },
{ icon: 'discord', label: 'Discord', href: 'https://discord.gg/RUbZUZyByQ' },
+16
View File
@@ -0,0 +1,16 @@
services:
winutil-astro:
build: .
ports:
- "127.0.0.1:4321:4321"
volumes:
- .:/app
- astro_node_modules:/app/node_modules
tmpfs:
- /app/.astro
environment:
- CHOKIDAR_USEPOLLING=true
- ASTRO_TELEMETRY_DISABLED=1
volumes:
astro_node_modules:
+4
View File
@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://winutil.christitus.com/sitemap-index.xml
Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 252 KiB

@@ -149,8 +149,6 @@ The **Win11 Creator** is a specialized subsystem within Winutil that creates cus
- Applies offline registry tweaks (hardware bypass, privacy, telemetry, OOBE)
- Deletes telemetry scheduled task definitions
- Pre-stages setup scripts from autounattend.xml
- Removes unused Windows editions
- Cleans component store via DISM
### Win11 Creator Data Flow
@@ -181,16 +179,14 @@ Invoke-WinUtilISOModify (runs in background runspace)
│ ├─ Delete telemetry scheduled task files
│ ├─ Pre-stage setup scripts from autounattend.xml to C:\Windows\Setup\Scripts\
│ └─ Unload registry hives
├─ DISM /Cleanup-Image /StartComponentCleanup /ResetBase (saves 300-800 MB)
├─ Dismount and save the modified install.wim (~10+ minutes, slowest step)
├─ Export selected edition only (removes all other editions, saves 1-2 GB each)
├─ Dismount source ISO
└─ Report completion, enable export options
Invoke-WinUtilISOExport (user chooses output)
├─ Option 1: Save as ISO
│ ├─ Build bootable ISO via oscdimg.exe (BIOS/UEFI dual-boot)
│ └─ Output: Win11_Modified_[date].iso (2.5-3.5 GB)
│ └─ Output: Win11_Modified_[date].iso (close to the source ISO size)
└─ Option 2: Write to USB
├─ Format USB as GPT
@@ -272,7 +268,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,6 +385,8 @@ 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
+7 -1
View File
@@ -42,6 +42,12 @@ Use the Applications tab to install, upgrade, uninstall, and review supported ap
![Collapse or expand all categories](../../../assets/screenshots/install-pics/Collapse&ExpandCat.png)
</TabItem>
<TabItem label="Category Filters">
* Click a category chip at the top of the tab to show only that category. The chip stays highlighted while its filter is active.
* Hold `Ctrl` and click to add more categories to the filter, or to remove one again.
* Click `All`, or click the highlighted category again while it is the only one selected, to clear the filter.
* Categories with matching results open while a filter is active. Ones that filtering opened for you go back to collapsed when you clear it, ones you opened yourself stay open.
</TabItem>
<TabItem label="Selected Apps Counter">
* The `Selected Apps` counter in the sidebar shows how many applications are currently selected.
* Use it to keep track of your selection as you browse categories.
@@ -57,7 +63,7 @@ Use the Applications tab to install, upgrade, uninstall, and review supported ap
</Tabs>
:::tip
If you have trouble finding an application, press `Ctrl + F` and search for its name. The list filters as you type.
If you have trouble finding an application, press `Ctrl + F` and search for its name. The list filters as you type. The search and the category chips work together, so you can search inside the categories you picked.
:::
:::note
@@ -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.
+8
View File
@@ -73,6 +73,14 @@ Use the DNS section to switch both IPv4 and IPv6 DNS providers without editing a
* [**Quad9**](https://quad9.net/): Focuses on security by blocking known malicious domains.
* [**AdGuard_Ads_Trackers**](https://adguard-dns.io/en/welcome.html): AdGuard DNS blocks ads, trackers, and other unwanted DNS requests. Visit the website and sign in for a dashboard, statistics, and additional server-side customization.
* [**AdGuard_Ads_Trackers_Malware_Adult**](https://adguard-dns.io/en/welcome.html): AdGuard DNS blocks ads, trackers, malware, and adult content, and enables Safe Search and Safe Mode where possible.
* [**Mullvad**](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls): Mullvad DNS without content blocking.
* [**Mullvad_Ads_Trackers**](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls): Blocks ads and trackers.
* [**Mullvad_Ads_Trackers_Malware**](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls): Blocks ads, trackers, and malware.
* [**Mullvad_Ads_Trackers_Malware_Social**](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls): Blocks ads, trackers, malware, and social media.
* [**Mullvad_Ads_Trackers_Malware_Adult_Gambling**](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls): Blocks ads, trackers, malware, adult content, and gambling.
* [**Mullvad_Ads_Trackers_Malware_Adult_Gambling_Social**](https://mullvad.net/en/help/dns-over-https-and-dns-over-tls): Applies all available Mullvad filters.
Mullvad profiles require DNS over HTTPS support in Windows. If the selected primary resolver is unavailable, WinUtil uses the closest Mullvad secondary resolver to preserve connectivity; that fallback may use a different filtering level and can be less restrictive.
### Customize Preferences
@@ -56,9 +56,7 @@ Click **Run Windows ISO Modification and Creator** to start the customization pr
- **Enable local account setup** — injects an `autounattend.xml` that skips the Microsoft account screen during OOBE
- **Disable BitLocker and device encryption** — removes startup overhead
- **Disable Chat icon** — removes chat taskbar button
- **Strip unused editions** — keeps only your selected edition, saving 12 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 300800 MB
**Privacy & Telemetry Tweaks:**
- **Disable telemetry** — advertising ID, tailored experiences, input personalization, speech online privacy
@@ -76,6 +74,10 @@ Click **Run Windows ISO Modification and Creator** to start the customization pr
A live log shows progress as each step completes. This stage usually takes **1030 minutes** depending on disk speed. The WIM dismount near the end is the slowest part, so do not close WinUtil while it is running.
:::note
The resulting ISO is close to the size of the source ISO. WinUtil does not remove the unused editions from `install.wim`; it selects your edition through `sources\ei.cfg` and `autounattend.xml` so Windows Setup installs the right one.
:::
---
### Step 4 — Export Your Result
+32
View File
@@ -27,6 +27,38 @@ hero:
link: https://github.com/ChrisTitusTech/winutil
icon: external
variant: secondary
head:
- tag: meta
attrs:
property: og:title
content: Documentation | WinUtil
- tag: meta
attrs:
name: twitter:title
content: Documentation | WinUtil
- tag: script
attrs:
type: application/ld+json
content: |
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "WinUtil",
"description": "Chris Titus Tech's Windows Utility — install apps, apply tweaks, run fixes, and manage Windows from one place.",
"url": "https://winutil.christitus.com/",
"downloadUrl": "https://github.com/ChrisTitusTech/winutil",
"operatingSystem": "Windows",
"applicationCategory": "UtilitiesApplication",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"author": {
"@type": "Person",
"name": "Chris Titus Tech"
}
}
---
import { Icon, Code } from '@astrojs/starlight/components';
@@ -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 or description matches the search text, and when its category is in
the selected set. An empty search matches everything, and an empty category set matches
every category.
While either filter is active the matching categories are expanded, since a collapsed
category would otherwise hide the very results that were asked for. With no filter at
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,13 @@ 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*"
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 +131,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,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 }
}
}
@@ -21,8 +21,9 @@ function Initialize-InstallAppEntry {
$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
# Resolve through $sync because the border's child is a layout Grid for FOSS entries
$childCheckbox = $sync.$($this.Tag)
$childCheckbox.IsChecked = -not $childCheckbox.IsChecked
})
$border.Add_MouseEnter({
if (($sync.$($this.Tag).IsChecked) -eq $false) {
@@ -48,15 +49,16 @@ function Initialize-InstallAppEntry {
# Store the original appKey in Tag
$checkBox.Tag = $appKey
$checkbox.Style = $sync.Form.Resources.AppEntryCheckboxStyle
# The checkbox sits inside the entry layout Grid, so the border is one level further up
$checkbox.Add_Checked({
Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName $this.Parent.Tag
$borderElement = $this.Parent
Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName $this.Tag
$borderElement = $this.Parent.Parent
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallSelectedColor")
})
$checkbox.Add_Unchecked({
Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName $this.Parent.Tag
$borderElement = $this.Parent
Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName $this.Tag
$borderElement = $this.Parent.Parent
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor")
})
@@ -88,15 +90,6 @@ function Initialize-InstallAppEntry {
$appName = New-Object Windows.Controls.TextBlock
$appName.Style = $sync.Form.Resources.AppEntryNameStyle
$appName.Text = $app.content
# Add FOSS label after the name if FOSS
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 +97,20 @@ 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
}
@@ -62,6 +62,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
@@ -36,4 +36,7 @@ function Initialize-WinUtilTabContent {
}
$sync.InitializedTabs[$TabName] = $true
# Sync freshly built controls to any selections already in $sync.selected* (import/preset).
Reset-WPFCheckBoxes -doToggles $true
}
@@ -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
}
@@ -61,7 +61,7 @@ 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") {
+48 -18
View File
@@ -25,36 +25,66 @@ function Invoke-WinUtilSSHServer {
Write-Host "Firewall rule for OpenSSH Server created and enabled."
}
# Check for the authorized_keys file
$sshFolderPath = "$Home\.ssh"
$authorizedKeysPath = "$sshFolderPath\authorized_keys"
# An SSH logon for a member of the administrators group gets a full token
# with no UAC prompt, so sshd reads administrator keys from a machine-wide
# file that only Administrators and SYSTEM may write. WinUtil always runs
# elevated, so the account being set up here is always an administrator.
$sshProgramDataPath = Join-Path $env:ProgramData "ssh"
$sshdConfigPath = Join-Path $sshProgramDataPath "sshd_config"
$authorizedKeysPath = Join-Path $sshProgramDataPath "administrators_authorized_keys"
$profileKeysPath = Join-Path $env:USERPROFILE ".ssh\authorized_keys"
if (-not (Test-Path -Path $sshFolderPath)) {
Write-Host "Creating ssh directory..."
New-Item -Path $sshFolderPath -ItemType Directory -Force
if (-not (Test-Path -Path $sshProgramDataPath)) {
New-Item -Path $sshProgramDataPath -ItemType Directory -Force | Out-Null
}
# Earlier WinUtil versions commented out the administrators block in
# sshd_config. Detect that state before restoring it, so administrator keys
# already in use are carried over instead of silently stopping working.
$configContent = if (Test-Path -Path $sshdConfigPath) { [string](Get-Content -Path $sshdConfigPath -Raw) } else { "" }
$restoredContent = $configContent -replace '(?m)^# (Match Group administrators)$', '$1'
$restoredContent = $restoredContent -replace '(?m)^# (\s+AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys)$', '$1'
$configWasOverridden = $restoredContent -ne $configContent
if (-not (Test-Path -Path $authorizedKeysPath)) {
Write-Host "Creating authorized_keys file..."
New-Item -Path $authorizedKeysPath -ItemType File -Force
Write-Host "authorized_keys file created at $authorizedKeysPath."
Write-Host "Creating administrators_authorized_keys file..."
New-Item -Path $authorizedKeysPath -ItemType File -Force | Out-Null
Write-Host "administrators_authorized_keys file created at $authorizedKeysPath."
}
Write-Host "Configuring sshd_config for standard authorized_keys behavior..."
$sshdConfigPath = "C:\ProgramData\ssh\sshd_config"
if ($configWasOverridden -and (Test-Path -Path $profileKeysPath)) {
$currentKeys = @(Get-Content -Path $authorizedKeysPath)
$keysToMove = @(Get-Content -Path $profileKeysPath | Where-Object {
$_.Trim() -and -not $_.TrimStart().StartsWith("#") -and $currentKeys -notcontains $_
})
$configContent = Get-Content -Path $sshdConfigPath -Raw
if ($keysToMove.Count -gt 0) {
Add-Content -Path $authorizedKeysPath -Value $keysToMove
Write-Host "Moved $($keysToMove.Count) key(s) from $profileKeysPath to $authorizedKeysPath."
}
}
$updatedContent = $configContent -replace '(?m)^(Match Group administrators)$', '# $1'
$updatedContent = $updatedContent -replace '(?m)^(\s+AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys)$', '# $1'
# sshd ignores the file unless inheritance is off and access is limited to
# Administrators (S-1-5-32-544) and SYSTEM (S-1-5-18). SIDs keep this
# working on localized installs, where the group names differ.
$acl = Get-Acl -Path $authorizedKeysPath
$acl.SetAccessRuleProtection($true, $false)
foreach ($rule in @($acl.Access)) {
[void]$acl.RemoveAccessRule($rule)
}
foreach ($sid in @("S-1-5-32-544", "S-1-5-18")) {
[void]$acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
[System.Security.Principal.SecurityIdentifier]::new($sid), "FullControl", "Allow"))
}
Set-Acl -Path $authorizedKeysPath -AclObject $acl
if ($updatedContent -ne $configContent) {
Set-Content -Path $sshdConfigPath -Value $updatedContent -Force
Write-Host "Commented out administrator-specific SSH key configuration in sshd_config"
if ($configWasOverridden) {
Set-Content -Path $sshdConfigPath -Value $restoredContent -Force
Write-Host "Restored the administrator key file setting in sshd_config."
Restart-Service -Name sshd -Force
}
Write-Host "OpenSSH server was successfully enabled."
Write-Host "The config file can be located at C:\ProgramData\ssh\sshd_config"
Write-Host "The config file can be located at $sshdConfigPath"
Write-Host "Add your public keys to this file -> $authorizedKeysPath"
}
+1 -1
View File
@@ -62,7 +62,7 @@ function Invoke-WinUtilTweaks {
}
}
if ($sync.configs.tweaks.$CheckBox.registry) {
$sync.configs.tweaks.$CheckBox.registry | ForEach-Object {
$sync.configs.tweaks.$CheckBox.registry | Where-Object { -not $psitem.Values } | ForEach-Object {
Set-WinUtilRegistry -Name $psitem.Name -Path $psitem.Path -Type $psitem.Type -Value $psitem.$($values.registry)
}
}
@@ -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
}
@@ -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()
}
+46 -21
View File
@@ -15,7 +15,7 @@ function Set-WinUtilDNS {
if($DNSProvider -eq "Default") {
Write-WinUtilLog -Component "DNS" -Message "DNS provider is Default; no DNS changes applied."
return
return $true
}
try {
@@ -29,11 +29,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 +70,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
}
} catch {
if ($dns.DohOnly) {
throw
}
$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
Write-Warning "DNS over HTTPS setup for provider $DNSProvider failed; continuing with plain DNS."
Write-WinUtilLog -Level "WARN" -Component "DNS" -Message "DNS over HTTPS setup for provider $DNSProvider failed; continuing with plain DNS: $($psitem.Exception.Message)"
}
}
Write-WinUtilLog -Component "DNS" -Message "Setting IPv4 DNS on adapter $($Adapter.Name) (ifIndex: $($Adapter.ifIndex)) to $($dns.Primary), $($dns.Secondary)."
Set-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -ServerAddresses $ipv4Addresses -ErrorAction Stop
Write-WinUtilLog -Component "DNS" -Message "Setting IPv6 DNS on adapter $($Adapter.Name) (ifIndex: $($Adapter.ifIndex)) to $($dns.Primary6), $($dns.Secondary6)."
Set-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -ServerAddresses $ipv6Addresses -ErrorAction Stop
}
}
if ($DNSProvider -ne "DHCP" -and $dohSupported -and $dns.DohTemplate) {
Clear-DnsClientCache
}
Write-WinUtilLog -Component "DNS" -Message "DNS provider change completed: $DNSProvider"
return $true
} catch {
Write-Warning "DNS provider $DNSProvider was not completed because an error occurred."
Write-Warning $psitem.Exception.Message
Write-WinUtilLog -Level "ERROR" -Component "DNS" -Message "DNS provider $DNSProvider was not completed: $($psitem.Exception.Message)"
return $false
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ function Set-WinUtilRegistry {
)
try {
if(!(Test-Path 'HKU:\')) {New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS}
if(!(Test-Path 'HKU:\')) {New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS | Out-Null}
If (!(Test-Path $Path)) {
Write-Host "$Path was not found. Creating..."
@@ -0,0 +1,78 @@
function Set-WinUtilRegistryComboState {
<#
.SYNOPSIS
Applies and verifies a config-defined registry combo-box state.
.PARAMETER Registry
Registry settings containing a value mapping for each supported state.
.PARAMETER State
The state name to apply.
#>
param(
[Parameter(Mandatory)]
$Registry,
[Parameter(Mandatory)]
[string]$State
)
if ($Registry[0].Values.PSObject.Properties.Name -notcontains $State) {
throw "Unknown registry state '$State'."
}
# Preserve exact prior values so a partial update can be rolled back.
$previousValues = foreach ($setting in @($Registry)) {
$currentValue = Get-WinUtilRegistryComboValue -Setting $setting
[pscustomobject]@{ Setting = $setting; Exists = $currentValue.Exists; Value = $currentValue.Value }
}
try {
foreach ($setting in @($Registry)) {
$configuredValue = $setting.Values.PSObject.Properties[$State].Value
$previousValue = $previousValues | Where-Object Setting -EQ $setting
if ($configuredValue -ne "<RemoveEntry>" -or $previousValue.Exists) {
Set-WinUtilRegistry -Name $setting.Name -Path $setting.Path -Type $setting.Type -Value $configuredValue
}
}
# Set-WinUtilRegistry reports write errors without throwing, so verify each result explicitly.
foreach ($setting in @($Registry)) {
$configuredValue = $setting.Values.PSObject.Properties[$State].Value
$currentValue = Get-WinUtilRegistryComboValue -Setting $setting
$writeMatches = if ($configuredValue -eq "<RemoveEntry>") {
-not $currentValue.Exists
} else {
$currentValue.Exists -and [string]$currentValue.Value -eq [string]$configuredValue
}
if (-not $writeMatches) {
throw "The registry values did not match the requested state."
}
}
} catch {
$applyError = $_.Exception.Message
if ([string]::IsNullOrWhiteSpace($applyError)) {
$applyError = "The registry values did not match the requested state."
}
$rollbackFailed = $false
foreach ($previousValue in $previousValues) {
try {
$currentValue = Get-WinUtilRegistryComboValue -Setting $previousValue.Setting
if ($previousValue.Exists -or $currentValue.Exists) {
$rollbackValue = if ($previousValue.Exists) { $previousValue.Value } else { "<RemoveEntry>" }
Set-WinUtilRegistry -Name $previousValue.Setting.Name -Path $previousValue.Setting.Path -Type $previousValue.Setting.Type -Value $rollbackValue
}
$restoredValue = Get-WinUtilRegistryComboValue -Setting $previousValue.Setting
if ($restoredValue.Exists -ne $previousValue.Exists -or ($restoredValue.Exists -and [string]$restoredValue.Value -ne [string]$previousValue.Value)) {
$rollbackFailed = $true
}
} catch {
$rollbackFailed = $true
}
}
if ($rollbackFailed) {
throw "Unable to apply registry state '$State': $applyError. The previous registry state could not be restored."
}
throw "Unable to apply registry state '$State': $applyError"
}
}
@@ -18,6 +18,10 @@ function Set-WinUtilTweaksProgressIndicator {
[int]$Percent
)
if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) {
return
}
$indicatorVisible = if ($Visible) { [Windows.Visibility]::Visible } else { [Windows.Visibility]::Collapsed }
$indicatorLabel = $Label
$hasLabel = $PSBoundParameters.ContainsKey('Label')
@@ -8,8 +8,14 @@ function Invoke-WinUtilInstallAppRenderBatch {
$sync.$appKey = Initialize-InstallAppEntry -TargetElement $CategoryBatch.TargetElement -AppKey $appKey
}
if ($sync.currentTab -eq "Install" -and $sync.SearchBar -and -not [string]::IsNullOrWhiteSpace($sync.SearchBar.Text)) {
Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Category $sync.SearchBar.Tag
# Entries render in batches, so a filter that is already active has to be applied to each new
# batch. Categories count as an active filter just like search text does.
if ($sync.currentTab -eq "Install" -and $sync.SearchBar) {
$selectedCategories = if ($sync.SelectedAppCategories) { $sync.SelectedAppCategories.ToArray() } else { @() }
if (-not [string]::IsNullOrWhiteSpace($sync.SearchBar.Text) -or $selectedCategories.Count -gt 0) {
Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Categories $selectedCategories
}
}
}
@@ -0,0 +1,19 @@
function Update-WinUtilAppCategoryChip {
<#
.SYNOPSIS
Pushes the current category selection onto the Install tab filter chips
.DESCRIPTION
The chips are toggle buttons, so their checked state has to follow the selection
rather than whatever the last click did to them. The All chip is checked when no
category is selected.
#>
$selected = $sync.SelectedAppCategories
if ($null -eq $selected) { return }
foreach ($chip in $sync.AppCategoryChips) {
$control = $sync[$chip.Name]
if ($null -eq $control) { continue }
$control.IsChecked = if ($chip.Category) { $selected.Contains($chip.Category) } else { $selected.Count -eq 0 }
}
}
+1 -1
View File
@@ -96,5 +96,5 @@ function Invoke-WPFAppxRemoval {
$sync.ProcessRunning = $false
}
}
} | Out-Null
}
@@ -36,5 +36,5 @@ function Invoke-WPFFeatureInstall {
Write-Host "--- Features are Installed ---"
Write-Host "--- A Reboot may be required ---"
Write-Host "==================================="
}
} | Out-Null
}
+1 -1
View File
@@ -110,5 +110,5 @@ function Invoke-WPFInstall {
}
$sync.ProcessRunning = $False
}
}
} | Out-Null
}
+3 -2
View File
@@ -32,8 +32,9 @@ function Invoke-WPFTab {
# Always reset the filter for the current tab
if ($sync.currentTab -eq "Install") {
# Reset Install tab filter
Find-AppsByNameOrDescription -SearchString ""
# Reset the search text, but keep the categories the chips are still showing as selected
$selectedCategories = if ($sync.SelectedAppCategories) { $sync.SelectedAppCategories.ToArray() } else { @() }
Find-AppsByNameOrDescription -SearchString "" -Categories $selectedCategories
} elseif ($sync.currentTab -eq "Tweaks") {
# Reset Tweaks tab filter
Find-TweaksByNameOrDescription -SearchString ""
+137 -36
View File
@@ -49,7 +49,7 @@ function Invoke-WPFUIElements {
# Add ColumnDefinitions to the target Grid
for ($i = 0; $i -lt $columncount; $i++) {
$colDef = New-Object Windows.Controls.ColumnDefinition
$colDef.Width = New-Object Windows.GridLength(1, [Windows.GridUnitType]::Star)
$colDef.Width = New-Object System.Windows.GridLength([double]1, [System.Windows.GridUnitType]::Star)
$targetGrid.ColumnDefinitions.Add($colDef) | Out-Null
}
@@ -76,6 +76,8 @@ function Invoke-WPFUIElements {
Description = $entryInfo.description
Type = $entryInfo.type
ComboItems = $entryInfo.ComboItems
ComboDescriptions = $entryInfo.ComboDescriptions
Registry = $entryInfo.registry
Checked = $entryInfo.Checked
ButtonWidth = $entryInfo.ButtonWidth
GroupName = $entryInfo.GroupName # Added for RadioButton groupings
@@ -111,43 +113,63 @@ function Invoke-WPFUIElements {
$dockPanelContainer = New-Object Windows.Controls.DockPanel
$border.Child = $dockPanelContainer
# Create an ItemsControl for application content
$itemsControl = New-Object Windows.Controls.ItemsControl
$itemsControl.HorizontalAlignment = 'Stretch'
$itemsControl.VerticalAlignment = 'Stretch'
# Create a StackPanel for application content controls
$stackPanelContainer = New-Object Windows.Controls.StackPanel
$stackPanelContainer.HorizontalAlignment = 'Stretch'
$stackPanelContainer.VerticalAlignment = 'Stretch'
# Set the ItemsPanel to a VirtualizingStackPanel
$itemsPanelTemplate = New-Object Windows.Controls.ItemsPanelTemplate
$factory = New-Object Windows.FrameworkElementFactory ([Windows.Controls.VirtualizingStackPanel])
$itemsPanelTemplate.VisualTree = $factory
$itemsControl.ItemsPanel = $itemsPanelTemplate
# Check if the target grid (or any ancestor) is already inside a ScrollViewer
$hasOuterScrollViewer = $false
$currentElement = $targetGrid
while ($null -ne $currentElement) {
if ($currentElement -is [System.Windows.Controls.ScrollViewer] -or $currentElement.GetType().Name -eq "ScrollViewer") {
$hasOuterScrollViewer = $true
break
}
$currentElement = $currentElement.Parent
}
# Set virtualization properties
$itemsControl.SetValue([Windows.Controls.VirtualizingStackPanel]::IsVirtualizingProperty, $true)
$itemsControl.SetValue([Windows.Controls.VirtualizingStackPanel]::VirtualizationModeProperty, [Windows.Controls.VirtualizationMode]::Recycling)
if ($hasOuterScrollViewer) {
# Add StackPanel directly to DockPanel without nesting a ScrollViewer
[Windows.Controls.DockPanel]::SetDock($stackPanelContainer, [Windows.Controls.Dock]::Bottom)
$dockPanelContainer.Children.Add($stackPanelContainer) | Out-Null
}
else {
# Create a ScrollViewer for targets that do not already have an outer ScrollViewer
$scrollViewer = New-Object Windows.Controls.ScrollViewer
$scrollViewer.VerticalScrollBarVisibility = "Auto"
$scrollViewer.HorizontalScrollBarVisibility = "Disabled"
$scrollViewer.HorizontalAlignment = 'Stretch'
$scrollViewer.VerticalAlignment = 'Stretch'
$scrollViewer.Content = $stackPanelContainer
# Add the ItemsControl directly to the DockPanel
[Windows.Controls.DockPanel]::SetDock($itemsControl, [Windows.Controls.Dock]::Bottom)
$dockPanelContainer.Children.Add($itemsControl) | Out-Null
[Windows.Controls.DockPanel]::SetDock($scrollViewer, [Windows.Controls.Dock]::Bottom)
$dockPanelContainer.Children.Add($scrollViewer) | Out-Null
}
$panelcount++
# Now proceed with adding category labels and entries to $itemsControl
# Now proceed with adding category labels and entries to $stackPanelContainer
foreach ($category in ($organizedData[$panelKey].Keys | Sort-Object)) {
$count++
$label = New-Object Windows.Controls.Label
$label.Content = $category -replace ".*__", ""
$categoryCleanName = $category -replace ".*__", ""
$label.Content = $categoryCleanName
$label.Focusable = $true
$label.IsTabStop = $true
[System.Windows.Automation.AutomationProperties]::SetName($label, $categoryCleanName)
$label.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "HeaderFontSize")
$label.SetResourceReference([Windows.Controls.Control]::FontFamilyProperty, "HeaderFontFamily")
$label.UseLayoutRounding = $true
$itemsControl.Items.Add($label) | Out-Null
$stackPanelContainer.Children.Add($label) | Out-Null
$sync[$category] = $label
# Sort entries by type (checkboxes first, then buttons, then comboboxes) and then alphabetically by Content
# Sort entries by type (checkboxes first, then buttons, then comboboxes, notes last) and then alphabetically by Content
$entries = $organizedData[$panelKey][$category] | Sort-Object @{Expression = {
switch ($_.Type) {
'Button' { 1 }
'Combobox' { 2 }
'Note' { 3 }
default { 0 }
}
}}, Content
@@ -174,7 +196,7 @@ function Invoke-WPFUIElements {
$label.SetResourceReference([Windows.Controls.Control]::ForegroundProperty, "MainForegroundColor")
$label.UseLayoutRounding = $true
$dockPanel.Children.Add($label) | Out-Null
$itemsControl.Items.Add($dockPanel) | Out-Null
$stackPanelContainer.Children.Add($dockPanel) | Out-Null
$sync[$entryInfo.Name] = $checkBox
$sync[$entryInfo.Name].IsChecked = (Get-WinUtilToggleStatus $entryInfo.Name)
@@ -212,7 +234,7 @@ function Invoke-WPFUIElements {
contentOff = if ($entryInfo.Content.Count -ge 2) { $entryInfo.Content[1] } else { $contentOn }
}
$itemsControl.Items.Add($toggleButton) | Out-Null
$stackPanelContainer.Children.Add($toggleButton) | Out-Null
$sync[$entryInfo.Name] = $toggleButton
@@ -246,6 +268,7 @@ function Invoke-WPFUIElements {
$label = New-Object Windows.Controls.Label
$label.Content = $entryInfo.Content
$label.HorizontalAlignment = "Left"
$label.ToolTip = $entryInfo.Description
$label.VerticalAlignment = "Center"
$label.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
$label.UseLayoutRounding = $true
@@ -260,35 +283,115 @@ function Invoke-WPFUIElements {
$comboBox.SetResourceReference([Windows.Controls.Control]::MarginProperty, "ButtonMargin")
$comboBox.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
$comboBox.UseLayoutRounding = $true
$comboBox.Tag = [pscustomobject]@{
Registry = $entryInfo.Registry
State = $null
}
[System.Windows.Automation.AutomationProperties]::SetName($comboBox, $entryInfo.Content)
foreach ($comboitem in ($entryInfo.ComboItems -split " ")) {
$comboItems = if ($entryInfo.ComboItems -is [string]) {
if ($entryInfo.ComboItems.Contains("|")) {
$entryInfo.ComboItems -split "\|"
} else {
$entryInfo.ComboItems -split " "
}
} else {
@($entryInfo.ComboItems)
}
foreach ($comboitem in $comboItems) {
$comboBoxItem = New-Object Windows.Controls.ComboBoxItem
$comboBoxItem.Content = $comboitem
if ($entryInfo.ComboDescriptions) {
$comboDescription = $entryInfo.ComboDescriptions.PSObject.Properties[$comboitem].Value
if ($comboDescription) {
$comboBoxItem.ToolTip = $comboDescription
}
}
$comboBoxItem.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
$comboBoxItem.UseLayoutRounding = $true
$comboBox.Items.Add($comboBoxItem) | Out-Null
}
$horizontalStackPanel.Children.Add($comboBox) | Out-Null
$itemsControl.Items.Add($horizontalStackPanel) | Out-Null
$stackPanelContainer.Children.Add($horizontalStackPanel) | Out-Null
$comboBox.SelectedIndex = 0
if ($entryInfo.Registry -and @($entryInfo.Registry)[0].Values) {
try {
$comboBox.Tag.State = Get-WinUtilRegistryComboState -Registry $entryInfo.Registry
$comboBox.SelectedIndex = @($comboBox.Items.Content).IndexOf([string]$comboBox.Tag.State)
} catch {
$unknownStateItem = New-Object Windows.Controls.ComboBoxItem
$unknownStateItem.Content = "Custom / Unknown - select a state"
$unknownStateItem.IsEnabled = $false
$unknownStateItem.ToolTip = "$($_.Exception.Message) Select one of the supported states to replace these values."
$comboBox.Items.Add($unknownStateItem) | Out-Null
$comboBox.SelectedItem = $unknownStateItem
$comboBox.ToolTip = $unknownStateItem.ToolTip
}
} else {
$comboBox.SelectedIndex = 0
}
# Set initial text
if ($comboBox.Items.Count -gt 0) {
$comboBox.Text = $comboBox.Items[0].Content
$comboBox.Text = $comboBox.SelectedItem.Content
}
$sync[$entryInfo.Name] = $comboBox
# Add SelectionChanged event handler to update the text property
$comboBox.Add_SelectionChanged({
$selectedItem = $this.SelectedItem
if ($selectedItem) {
$this.Text = $selectedItem.Content
$registry = $this.Tag.Registry
if ($registry -and $selectedItem.IsEnabled -and $selectedItem.Content -ne $this.Tag.State) {
try {
Set-WinUtilRegistryComboState -Registry $registry -State $selectedItem.Content
$this.Tag.State = $selectedItem.Content
$this.ToolTip = $null
$unknownStateItem = @($this.Items) | Where-Object Content -EQ "Custom / Unknown - select a state" | Select-Object -First 1
if ($unknownStateItem) {
$this.Items.Remove($unknownStateItem)
}
} catch {
$applyError = $_.Exception.Message
if ([string]::IsNullOrWhiteSpace($applyError)) {
$applyError = "Unable to apply registry state '$($selectedItem.Content)'."
}
$previousState = if ($this.Tag.State) { $this.Tag.State } else { "Custom / Unknown - select a state" }
$this.SelectedItem = @($this.Items) | Where-Object Content -EQ $previousState | Select-Object -First 1
[System.Windows.MessageBox]::Show(
$applyError,
"WinUtil",
[System.Windows.MessageBoxButton]::OK,
[System.Windows.MessageBoxImage]::Warning
) | Out-Null
}
}
}
})
$sync[$entryInfo.Name] = $comboBox
if ($entryInfo.Registry -and @($entryInfo.Registry)[0].Values -and $entryInfo.Link) {
$textBlock = New-Object Windows.Controls.TextBlock
$textBlock.Name = $comboBox.Name + "Link"
$textBlock.Text = "(?)"
$textBlock.ToolTip = $entryInfo.Link
$textBlock.Style = $HoverTextBlockStyle
$textBlock.UseLayoutRounding = $true
$textBlock.VerticalAlignment = "Center"
$textBlock.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "FontSize")
$textBlock.Tag = $comboBox
$textBlock.Add_MouseUp({
[System.Object]$Sender = $args[0]
Start-Process $Sender.ToolTip -ErrorAction Stop
})
$horizontalStackPanel.Children.Add($textBlock) | Out-Null
$sync[$textBlock.Name] = $textBlock
}
}
"Button" {
@@ -303,7 +406,7 @@ function Invoke-WPFUIElements {
$button.Width = [math]::Max($baseWidth, 350)
}
[System.Windows.Automation.AutomationProperties]::SetName($button, $entryInfo.Content)
$itemsControl.Items.Add($button) | Out-Null
$stackPanelContainer.Children.Add($button) | Out-Null
$sync[$entryInfo.Name] = $button
@@ -330,7 +433,7 @@ function Invoke-WPFUIElements {
$radioButtonGroups[$entryInfo.GroupName] = $groupStackPanel
# Add the group container to the ItemsControl
$itemsControl.Items.Add($groupStackPanel) | Out-Null
$stackPanelContainer.Children.Add($groupStackPanel) | Out-Null
}
else {
# Retrieve the existing group container
@@ -364,20 +467,18 @@ function Invoke-WPFUIElements {
$textBlock.Margin = "5,5,5,5"
$textBlock.UseLayoutRounding = $true
$bulletRun = New-Object Windows.Documents.Run
$bulletRun.Text = [char]0x25CF
$bulletRun.Foreground = [Windows.Media.SolidColorBrush]::new([Windows.Media.Color]::FromRgb(110, 255, 114))
$bulletRun.FontSize = 11.5
$bulletBadge = [Windows.Documents.InlineUIContainer]::new((New-WinUtilFossBadge -Size 18 -Round))
$bulletBadge.BaselineAlignment = [Windows.BaselineAlignment]::Center
$textRun = New-Object Windows.Documents.Run
$textRun.Text = " $($entryInfo.Content)"
$textRun.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "FontSize")
$textRun.Foreground = [Windows.Media.SolidColorBrush]::new([Windows.Media.Color]::FromRgb(19, 143, 83))
$textBlock.Inlines.Add($bulletRun)
$textBlock.Inlines.Add($bulletBadge)
$textBlock.Inlines.Add($textRun)
$itemsControl.Items.Add($textBlock) | Out-Null
$stackPanelContainer.Children.Add($textBlock) | Out-Null
}
default {
@@ -437,7 +538,7 @@ function Invoke-WPFUIElements {
$sync[$textBlock.Name] = $textBlock
}
$itemsControl.Items.Add($horizontalStackPanel) | Out-Null
$stackPanelContainer.Children.Add($horizontalStackPanel) | Out-Null
$sync[$entryInfo.Name] = $checkBox
$sync[$entryInfo.Name].Add_Checked({
+4
View File
@@ -1,3 +1,7 @@
function Invoke-WPFUIThread ($ScriptBlock) {
if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) {
return
}
$sync.form.Dispatcher.Invoke([action]$ScriptBlock)
}
+9 -2
View File
@@ -71,7 +71,14 @@ function Invoke-WPFtweaksbutton {
}
if ($dnsProvider -ne "Default") {
Set-WinUtilDNS -DNSProvider $dnsProvider
$dnsResult = @(Set-WinUtilDNS -DNSProvider $dnsProvider)
if ($dnsResult[-1] -ne $true) {
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "DNS change failed" -Percent 100
$sync.ProcessRunning = $false
Invoke-WPFUIThread -ScriptBlock { Set-WinUtilTaskbaritem -state "Error" -overlay "warning" }
Write-WinUtilLog -Level "ERROR" -Component "Tweaks" -Message "Tweaks workflow stopped because the DNS change failed."
return
}
}
for ($i = 0; $i -lt $tweaks.Count; $i++) {
@@ -88,5 +95,5 @@ function Invoke-WPFtweaksbutton {
Write-Host "-- Tweaks are Finished ---"
Write-Host "================================="
Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed."
}
} | Out-Null
}
+46 -1
View File
@@ -189,6 +189,14 @@ Describe "Tweaks config" {
foreach ($registryEntry in @($tweak.Value.registry)) {
if ($null -eq $registryEntry) { continue }
if ($registryEntry.Values) {
if ($registryEntry.PSObject.Properties.Name -notcontains "DefaultValue" -or
[string]::IsNullOrWhiteSpace([string]$registryEntry.DefaultValue)) {
$invalidTweaks.Add("$($tweak.Name),registry")
}
continue
}
if ($registryEntry.PSObject.Properties.Name -notcontains "OriginalValue" -or
[string]::IsNullOrWhiteSpace([string]$registryEntry.OriginalValue)) {
$invalidTweaks.Add("$($tweak.Name),registry")
@@ -366,6 +374,17 @@ Describe "UI-rendered config entries" {
}
}
It "exposes every configured DNS provider in the DNS combobox" {
$dns = Get-WinUtilConfigObject -Name "dns"
$tweaks = Get-WinUtilConfigObject -Name "tweaks"
$comboItems = @($tweaks.WPFchangedns.ComboItems -split " ")
$missingProviders = @($dns.PSObject.Properties.Name | Where-Object { $comboItems -notcontains $_ })
if ($missingProviders.Count -gt 0) {
throw "WPFchangedns missing providers: $($missingProviders -join ', ')"
}
}
It "contains required feature fields and valid configured functions" {
$feature = Get-WinUtilConfigObject -Name "feature"
$functionNames = Get-WinUtilTopLevelFunctionNames
@@ -429,6 +448,27 @@ Describe "UI-rendered config entries" {
if (-not (Test-WinUtilHasNonEmptyProperty -Object $entry.Value -Name "ComboItems")) {
$invalidEntries.Add("$($entry.Name) combobox missing ComboItems")
}
$statefulRegistry = @($entry.Value.registry | Where-Object Values)
if ($statefulRegistry.Count -gt 0) {
$comboItems = if ($entry.Value.ComboItems -is [string]) {
if ($entry.Value.ComboItems.Contains("|")) {
@($entry.Value.ComboItems -split "\|")
} else {
@($entry.Value.ComboItems -split " ")
}
} else {
@($entry.Value.ComboItems)
}
if ($statefulRegistry.Count -ne @($entry.Value.registry).Count) {
$invalidEntries.Add("$($entry.Name) registry states must all use Values")
} else {
foreach ($setting in $statefulRegistry) {
if (Compare-Object $comboItems @($setting.Values.PSObject.Properties.Name)) {
$invalidEntries.Add("$($entry.Name) ComboItems and registry states do not match")
}
}
}
}
} else {
if (-not (Test-WinUtilHasNonEmptyProperty -Object $entry.Value -Name "Description")) {
$invalidEntries.Add("$($entry.Name) missing Description")
@@ -442,7 +482,12 @@ Describe "UI-rendered config entries" {
foreach ($registryEntry in @($entry.Value.registry)) {
if ($null -eq $registryEntry) { continue }
foreach ($missingField in (Get-WinUtilMissingRequiredFields -EntryName "$($entry.Name),registry" -Entry $registryEntry -RequiredFields @("Path", "Name", "Type", "Value", "OriginalValue"))) {
$requiredRegistryFields = if ($entry.Value.Type -eq "Combobox" -and $statefulRegistry.Count -gt 0) {
@("Path", "Name", "Type", "DefaultValue", "Values")
} else {
@("Path", "Name", "Type", "Value", "OriginalValue")
}
foreach ($missingField in (Get-WinUtilMissingRequiredFields -EntryName "$($entry.Name),registry" -Entry $registryEntry -RequiredFields $requiredRegistryFields)) {
$invalidEntries.Add($missingField)
}
}
+105 -5
View File
@@ -10,7 +10,8 @@ BeforeAll {
param(
$InterfaceIndex,
$ServerAddresses,
[switch]$ResetServerAddresses
[switch]$ResetServerAddresses,
$ErrorAction
)
}
function netsh {
@@ -52,6 +53,23 @@ Describe "Set-WinUtilDNS" {
Secondary6 = "2606:4700:4700::1001"
DohTemplate = "https://cloudflare-dns.com/dns-query"
}
Mullvad = [pscustomobject]@{
Primary = "194.242.2.2"
Secondary = "194.242.2.3"
Primary6 = "2a07:e340::2"
Secondary6 = "2a07:e340::3"
DohOnly = $true
DohTemplate = "https://dns.mullvad.net/dns-query"
SecondaryDohTemplate = "https://adblock.dns.mullvad.net/dns-query"
}
MullvadNoSecondary = [pscustomobject]@{
Primary = "194.242.2.2"
Secondary = ""
Primary6 = "2a07:e340::2"
Secondary6 = ""
DohOnly = $true
DohTemplate = "https://dns.mullvad.net/dns-query"
}
}
}
})
@@ -88,8 +106,9 @@ Describe "Set-WinUtilDNS" {
}
It "sets IPv4 and IPv6 DNS server addresses separately and applies DoH templates" {
Set-WinUtilDNS -DNSProvider "Cloudflare"
$result = Set-WinUtilDNS -DNSProvider "Cloudflare"
$result | Should -BeTrue
Should -Invoke -CommandName Set-DnsClientServerAddress -Times 1 -Exactly -ParameterFilter {
$InterfaceIndex -eq 7 -and
$ServerAddresses.Count -eq 2 -and
@@ -131,6 +150,79 @@ Describe "Set-WinUtilDNS" {
Should -Invoke -CommandName Add-DnsClientDohServerAddress -Times 3 -Exactly
}
It "filters empty DNS server addresses" {
Set-WinUtilDNS -DNSProvider "MullvadNoSecondary"
Should -Invoke -CommandName Set-DnsClientServerAddress -Times 1 -Exactly -ParameterFilter {
$InterfaceIndex -eq 7 -and
$ServerAddresses.Count -eq 1 -and
$ServerAddresses[0] -eq "194.242.2.2"
}
Should -Invoke -CommandName Set-DnsClientServerAddress -Times 1 -Exactly -ParameterFilter {
$InterfaceIndex -eq 7 -and
$ServerAddresses.Count -eq 1 -and
$ServerAddresses[0] -eq "2a07:e340::2"
}
Should -Invoke -CommandName Add-DnsClientDohServerAddress -Times 2 -Exactly
}
It "applies the matching DoH template to secondary resolvers" {
Set-WinUtilDNS -DNSProvider "Mullvad"
Should -Invoke -CommandName Add-DnsClientDohServerAddress -Times 2 -Exactly -ParameterFilter {
$ServerAddress -in @("194.242.2.2", "2a07:e340::2") -and
$DohTemplate -eq "https://dns.mullvad.net/dns-query"
}
Should -Invoke -CommandName Add-DnsClientDohServerAddress -Times 2 -Exactly -ParameterFilter {
$ServerAddress -in @("194.242.2.3", "2a07:e340::3") -and
$DohTemplate -eq "https://adblock.dns.mullvad.net/dns-query"
}
}
It "does not apply a DoH-only provider when DoH is unsupported" {
Mock Get-Command { return $null } -ParameterFilter { $Name -eq "Add-DnsClientDohServerAddress" }
$result = Set-WinUtilDNS -DNSProvider "Mullvad"
$result | Should -BeFalse
Should -Invoke -CommandName Set-DnsClientServerAddress -Times 0 -Exactly
Should -Invoke -CommandName Add-DnsClientDohServerAddress -Times 0 -Exactly
Should -Invoke -CommandName Write-Warning -Times 1 -Exactly -ParameterFilter {
$Message -eq "DNS provider Mullvad requires DNS over HTTPS, which is not supported on this system."
}
}
It "does not change adapter DNS when DoH registration fails" {
Mock Add-DnsClientDohServerAddress { throw "DoH registration failed" }
$result = Set-WinUtilDNS -DNSProvider "Mullvad"
$result | Should -BeFalse
Should -Invoke -CommandName Set-DnsClientServerAddress -Times 0 -Exactly
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Level -eq "ERROR" -and
$Component -eq "DNS" -and
$Message -like "DNS provider Mullvad was not completed: *"
}
}
It "falls back to plain DNS when optional DoH registration fails" {
Mock Add-DnsClientDohServerAddress { throw "DoH registration failed" }
$result = Set-WinUtilDNS -DNSProvider "Cloudflare"
$result | Should -BeTrue
Should -Invoke -CommandName Set-DnsClientServerAddress -Times 2 -Exactly
Should -Invoke -CommandName Write-Warning -Times 1 -Exactly -ParameterFilter {
$Message -eq "DNS over HTTPS setup for provider Cloudflare failed; continuing with plain DNS."
}
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Level -eq "WARN" -and
$Component -eq "DNS" -and
$Message -like "DNS over HTTPS setup for provider Cloudflare failed; continuing with plain DNS: *"
}
}
It "resets DNS to DHCP and removes the applied DoH configuration" {
Mock Test-Path { return $true } -ParameterFilter { $Path -like "*DohInterfaceSettings*" }
Mock Get-ChildItem {
@@ -178,15 +270,23 @@ Describe "Set-WinUtilDNS" {
Should -Invoke -CommandName Remove-DnsClientDohServerAddress -Times 4 -Exactly
}
It "catches DNS setter failures so the tweak runspace can continue" {
Mock Set-DnsClientServerAddress { throw "DNS failed" }
It "catches non-terminating DNS setter failures so the tweak runspace can continue" {
Mock Set-DnsClientServerAddress { Write-Error "DNS failed" -ErrorAction $ErrorAction }
{ Set-WinUtilDNS -DNSProvider "Cloudflare" } | Should -Not -Throw
$result = Set-WinUtilDNS -DNSProvider "Cloudflare"
$result | Should -BeFalse
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Level -eq "ERROR" -and
$Component -eq "DNS" -and
$Message -like "DNS provider Cloudflare was not completed: *"
}
}
It "returns failure for an unknown DNS provider" {
$result = Set-WinUtilDNS -DNSProvider "Unknown"
$result | Should -BeFalse
Should -Invoke -CommandName Set-DnsClientServerAddress -Times 0 -Exactly
}
}
+107
View File
@@ -0,0 +1,107 @@
#===========================================================================
# Tests - UI Helpers During Headless (-Preset / -Config) Runs
#===========================================================================
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
if (-not ("Windows.Visibility" -as [type])) {
Add-Type @"
namespace Windows
{
public enum Visibility
{
Visible,
Collapsed
}
}
"@
}
. (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIThread.ps1")
. (Join-Path $script:repoRoot "functions\private\Set-WinUtilTweaksProgressIndicator.ps1")
function script:New-WinUtilFakeForm {
$dispatcher = New-Object psobject
$dispatcher | Add-Member -MemberType NoteProperty -Name InvokeCount -Value 0
$dispatcher | Add-Member -MemberType ScriptMethod -Name Invoke -Value {
param($Action)
$null = $Action
$this.InvokeCount++
}
$form = New-Object psobject
$form | Add-Member -MemberType NoteProperty -Name Dispatcher -Value $dispatcher
return $form
}
function script:New-WinUtilFakeIndicatorControlSet {
@{
Bar = [pscustomobject]@{ Visibility = [Windows.Visibility]::Collapsed }
Label = [pscustomobject]@{ Text = "" }
Value = [pscustomobject]@{ Value = 0 }
}
}
}
Describe "Invoke-WPFUIThread without a window" {
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
}
It "does nothing when the automation paths run before the form is created" {
$script:sync = [Hashtable]::Synchronized(@{})
$script:blockRan = $false
{ Invoke-WPFUIThread -ScriptBlock { $script:blockRan = $true } } | Should -Not -Throw
$script:blockRan | Should -BeFalse
}
It "does nothing when the form exists but has no dispatcher" {
$script:sync = [Hashtable]::Synchronized(@{ Form = [pscustomobject]@{ Dispatcher = $null } })
$script:blockRan = $false
{ Invoke-WPFUIThread -ScriptBlock { $script:blockRan = $true } } | Should -Not -Throw
$script:blockRan | Should -BeFalse
}
It "still marshals onto the dispatcher when a window exists" {
$form = New-WinUtilFakeForm
$script:sync = [Hashtable]::Synchronized(@{ Form = $form })
Invoke-WPFUIThread -ScriptBlock { }
$form.Dispatcher.InvokeCount | Should -Be 1
}
}
Describe "Set-WinUtilTweaksProgressIndicator without a window" {
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
}
It "returns before resolving WPF types when the form is missing" {
$script:sync = [Hashtable]::Synchronized(@{})
{ Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Creating restore point" -Percent 0 } | Should -Not -Throw
}
It "still updates the indicator controls when a window exists" {
$controls = New-WinUtilFakeIndicatorControlSet
$script:sync = [Hashtable]::Synchronized(@{
Form = New-WinUtilFakeForm
WPFTweaksProgressBar = $controls.Bar
WPFTweaksProgressLabel = $controls.Label
WPFTweaksProgressValue = $controls.Value
})
Mock Invoke-WPFUIThread { & $ScriptBlock }
Set-WinUtilTweaksProgressIndicator -Visible $true -Label "Applying WPFTweaksTelemetry (1/17)" -Percent 42
$controls.Bar.Visibility | Should -Be ([Windows.Visibility]::Visible)
$controls.Label.Text | Should -Be "Applying WPFTweaksTelemetry (1/17)"
$controls.Value.Value | Should -Be 42
}
}
+3 -1
View File
@@ -21,7 +21,9 @@ Describe "Install app rendering startup contract" {
$renderScript | Should -Match 'Dispatcher\.BeginInvoke'
$renderScript | Should -Match 'Invoke-WinUtilInstallAppRenderNextBatch'
$renderScript | Should -Match 'Initialize-InstallAppEntry'
$renderScript | Should -Match 'Find-AppsByNameOrDescription -SearchString \$sync\.SearchBar\.Text -Category \$sync\.SearchBar\.Tag'
$renderScript | Should -Match 'Find-AppsByNameOrDescription -SearchString \$sync\.SearchBar\.Text -Categories \$selectedCategories'
# A batch has to be filtered when either filter is on, not only when there is search text
$renderScript | Should -Match '\$selectedCategories\.Count -gt 0'
$renderScript | Should -Match '\$sync\.InstallAppEntriesRendered = \$true'
}
+24
View File
@@ -12,6 +12,7 @@ BeforeAll {
param([string]$TargetGridName)
}
function Invoke-WinUtilISOCheckExistingWork { }
function Reset-WPFCheckBoxes { param([bool]$doToggles) }
. (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTabContent.ps1")
}
@@ -29,6 +30,7 @@ Describe "Initialize-WinUtilTabContent" {
Mock Invoke-WPFUIElements { }
Mock Initialize-WPFUI { }
Mock Reset-WPFCheckBoxes { }
}
AfterEach {
@@ -51,6 +53,21 @@ Describe "Initialize-WinUtilTabContent" {
$script:sync.InitializedTabs["Install"] | Should -BeTrue
}
It "re-applies checkbox selections after building a tab's controls" {
Initialize-WinUtilTabContent -TabName "Tweaks"
Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly -ParameterFilter {
$doToggles -eq $true
}
}
It "does not re-apply checkbox selections on a tab that's already built" {
Initialize-WinUtilTabContent -TabName "Tweaks"
Initialize-WinUtilTabContent -TabName "Tweaks"
Should -Invoke -CommandName Reset-WPFCheckBoxes -Times 1 -Exactly
}
It "initializes deferred config-backed tabs once" {
Initialize-WinUtilTabContent -TabName "Tweaks"
Initialize-WinUtilTabContent -TabName "Config"
@@ -121,4 +138,11 @@ Describe "Startup lazy tab wiring" {
$rendererScript | Should -Match '(?s)if \(\$entryInfo\.Link\).*\$textBlock\.Add_MouseUp\(\{.*Start-Process \$Sender\.ToolTip -ErrorAction Stop'
$mainScript | Should -Not -Match '\.Name\.EndsWith\("Link"\)'
}
It "checks for an existing outer ScrollViewer before nesting an inner ScrollViewer" {
$rendererScript = Get-Content -Path (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIElements.ps1") -Raw
$rendererScript | Should -Match '\$hasOuterScrollViewer'
$rendererScript | Should -Match 'if\s*\(\$hasOuterScrollViewer\)'
}
}
+180
View File
@@ -0,0 +1,180 @@
#===========================================================================
# Tests - Multiplane Overlay
#===========================================================================
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$script:config = Get-Content (Join-Path $script:repoRoot "config\tweaks.json") -Raw | ConvertFrom-Json
$script:states = $script:config.WPFMultiplaneOverlay.registry
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilRegistryComboState.ps1")
. (Join-Path $script:repoRoot "functions\private\Get-WinUtilRegistryComboValue.ps1")
. (Join-Path $script:repoRoot "functions\private\Set-WinUtilRegistryComboState.ps1")
function Set-WinUtilRegistry {
param($Name, $Path, $Type, $Value)
}
}
Describe "Multiplane Overlay configuration" {
It "keeps every state and registry action in tweaks.json" {
$script:config.WPFMultiplaneOverlay.Type | Should -Be "Combobox"
$script:config.WPFMultiplaneOverlay.ComboItems | Should -BeOfType [string]
$comboItems = @($script:config.WPFMultiplaneOverlay.ComboItems -split "\|")
$comboItems | Should -Be @("Enabled", "Disabled (Compatibility)", "Fully Disabled")
$script:states.Count | Should -Be 2
$script:states[0].Values.PSObject.Properties.Name | Should -Be $comboItems
$script:states[0].Values.PSObject.Properties.Value | Should -Be @("<RemoveEntry>", "5", "5")
$script:states[1].Values.PSObject.Properties.Value | Should -Be @("<RemoveEntry>", "<RemoveEntry>", "1")
}
It "uses the generic combo registry handler" {
$renderer = Get-Content (Join-Path $script:repoRoot "functions\public\Invoke-WPFUIElements.ps1") -Raw
$renderer | Should -Match 'Get-WinUtilRegistryComboState'
$renderer | Should -Match 'Set-WinUtilRegistryComboState'
$renderer | Should -Not -Match 'WPFMultiplaneOverlay'
}
}
Describe "Get-WinUtilRegistryComboState" {
It "treats missing registry properties and paths as absent" -TestCases @(
@{ Exception = [System.Management.Automation.PSArgumentException]::new("Property is missing") }
@{ Exception = [System.Management.Automation.ItemNotFoundException]::new("Path is missing") }
) {
param($Exception)
Mock Get-ItemProperty { throw $Exception }
Get-WinUtilRegistryComboState -Registry $script:states | Should -Be "Enabled"
}
It "reports Enabled when the values are absent or zero" -TestCases @(
@{ OverlayTestMode = $null; DisableOverlays = $null }
@{ OverlayTestMode = 0; DisableOverlays = 0 }
) {
param($OverlayTestMode, $DisableOverlays)
Mock Get-ItemProperty {
if ($Name -eq "OverlayTestMode") {
return [pscustomobject]@{ OverlayTestMode = $OverlayTestMode }
}
[pscustomobject]@{ DisableOverlays = $DisableOverlays }
}
Get-WinUtilRegistryComboState -Registry $script:states | Should -Be "Enabled"
}
It "reports each disabled state" -TestCases @(
@{ OverlayTestMode = 5; DisableOverlays = $null; Expected = "Disabled (Compatibility)" }
@{ OverlayTestMode = 5; DisableOverlays = 1; Expected = "Fully Disabled" }
) {
param($OverlayTestMode, $DisableOverlays, $Expected)
Mock Get-ItemProperty {
if ($Name -eq "OverlayTestMode") {
return [pscustomobject]@{ OverlayTestMode = $OverlayTestMode }
}
[pscustomobject]@{ DisableOverlays = $DisableOverlays }
}
Get-WinUtilRegistryComboState -Registry $script:states | Should -Be $Expected
}
It "rejects an unsupported combination" {
Mock Get-ItemProperty {
if ($Name -eq "OverlayTestMode") {
return [pscustomobject]@{ OverlayTestMode = 0 }
}
[pscustomobject]@{ DisableOverlays = 1 }
}
{ Get-WinUtilRegistryComboState -Registry $script:states } | Should -Throw "Registry values do not match a supported state."
}
}
Describe "Set-WinUtilRegistryComboState" {
BeforeEach {
$script:registryValues = @{ OverlayTestMode = 0; DisableOverlays = 0 }
Mock Get-ItemProperty {
param($Path, $Name)
$registryName = [string]$Name
if ($script:registryValues.ContainsKey($registryName)) {
$result = [pscustomobject]@{}
$result | Add-Member -NotePropertyName $registryName -NotePropertyValue $script:registryValues[$registryName]
return $result
}
[pscustomobject]@{}
}
Mock Set-WinUtilRegistry {
param($Name, $Path, $Type, $Value)
if ($Value -eq "<RemoveEntry>") {
$script:registryValues.Remove($Name)
} else {
$script:registryValues[$Name] = [int]$Value
}
}
}
It "applies each configured state" -TestCases @(
@{ State = "Enabled"; OverlayTestMode = $null; DisableOverlays = $null }
@{ State = "Disabled (Compatibility)"; OverlayTestMode = 5; DisableOverlays = $null }
@{ State = "Fully Disabled"; OverlayTestMode = 5; DisableOverlays = 1 }
) {
param($State, $OverlayTestMode, $DisableOverlays)
Set-WinUtilRegistryComboState -Registry $script:states -State $State
$script:registryValues.OverlayTestMode | Should -Be $OverlayTestMode
$script:registryValues.DisableOverlays | Should -Be $DisableOverlays
}
It "restores previous values when verification fails" {
Mock Set-WinUtilRegistry {
param($Name, $Path, $Type, $Value)
if ($Name -eq "DisableOverlays" -and $Value -eq 1) {
return
}
if ($Value -eq "<RemoveEntry>") {
$script:registryValues.Remove($Name)
} else {
$script:registryValues[$Name] = [int]$Value
}
}
{ Set-WinUtilRegistryComboState -Registry $script:states -State "Fully Disabled" } | Should -Throw "Unable to apply registry state*"
$script:registryValues.OverlayTestMode | Should -Be 0
$script:registryValues.DisableOverlays | Should -Be 0
}
It "restores absence when a state cannot be applied" {
$script:registryValues.Clear()
Mock Set-WinUtilRegistry {
param($Name, $Path, $Type, $Value)
if ($Name -eq "DisableOverlays" -and $Value -eq 1) {
return
}
if ($Value -eq "<RemoveEntry>") {
$script:registryValues.Remove($Name)
} else {
$script:registryValues[$Name] = [int]$Value
}
}
{ Set-WinUtilRegistryComboState -Registry $script:states -State "Fully Disabled" } | Should -Throw "Unable to apply registry state*"
$script:registryValues.ContainsKey("OverlayTestMode") | Should -BeFalse
$script:registryValues.ContainsKey("DisableOverlays") | Should -BeFalse
}
It "reports when the previous values cannot be restored" {
Mock Set-WinUtilRegistry {
param($Name, $Path, $Type, $Value)
if (($Name -eq "DisableOverlays" -and $Value -eq 1) -or ($Name -eq "OverlayTestMode" -and $Value -eq 0)) {
return
}
if ($Value -eq "<RemoveEntry>") {
$script:registryValues.Remove($Name)
} else {
$script:registryValues[$Name] = [int]$Value
}
}
{ Set-WinUtilRegistryComboState -Registry $script:states -State "Fully Disabled" } | Should -Throw "*previous registry state could not be restored*"
}
}
+75 -1
View File
@@ -379,12 +379,86 @@ Describe "Find-AppsByNameOrDescription" {
$category = New-WinUtilAppCategory -Label "- Tools" -Items @($utilityItem, $powerToysItem)
New-WinUtilAppSearchContext -Categories @($category)
Find-AppsByNameOrDescription -SearchString "Utilities" -Category "Utilities"
Find-AppsByNameOrDescription -Categories @("Utilities")
$utilityItem.Visibility | Should -Be ([Windows.Visibility]::Visible)
$powerToysItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed)
$category.Visibility | Should -Be ([Windows.Visibility]::Visible)
}
It "shows apps from every selected category when several chips are active" {
$utilityItem = New-WinUtilAppSearchItem -Tag "WPFInstallLiteral"
$powerToysItem = New-WinUtilAppSearchItem -Tag "WPFInstallPowerToys"
$browserItem = New-WinUtilAppSearchItem -Tag "WPFInstallBrowser"
$category = New-WinUtilAppCategory -Label "- Tools" -Items @($utilityItem, $powerToysItem, $browserItem)
New-WinUtilAppSearchContext -Categories @($category)
Find-AppsByNameOrDescription -Categories @("Utilities", "Microsoft Tools")
$utilityItem.Visibility | Should -Be ([Windows.Visibility]::Visible)
$powerToysItem.Visibility | Should -Be ([Windows.Visibility]::Visible)
$browserItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed)
}
It "applies the search text and the category filter together" {
$powerToysItem = New-WinUtilAppSearchItem -Tag "WPFInstallPowerToys"
$literalItem = New-WinUtilAppSearchItem -Tag "WPFInstallLiteral"
$category = New-WinUtilAppCategory -Label "- Tools" -Items @($powerToysItem, $literalItem)
New-WinUtilAppSearchContext -Categories @($category)
Find-AppsByNameOrDescription -SearchString "PowerToys" -Categories @("Microsoft Tools")
$powerToysItem.Visibility | Should -Be ([Windows.Visibility]::Visible)
$literalItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed)
}
It "hides a category when the search text matches nothing inside the selected categories" {
$powerToysItem = New-WinUtilAppSearchItem -Tag "WPFInstallPowerToys"
$category = New-WinUtilAppCategory -Label "- Tools" -Items @($powerToysItem)
New-WinUtilAppSearchContext -Categories @($category)
Find-AppsByNameOrDescription -SearchString "Firefox" -Categories @("Microsoft Tools")
$powerToysItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed)
$category.Visibility | Should -Be ([Windows.Visibility]::Collapsed)
}
It "expands a collapsed category that has matches for the selected filter" {
$powerToysItem = New-WinUtilAppSearchItem -Tag "WPFInstallPowerToys"
$category = New-WinUtilAppCategory -Label "+ Tools" -Items @($powerToysItem)
New-WinUtilAppSearchContext -Categories @($category)
Find-AppsByNameOrDescription -Categories @("Microsoft Tools")
$category.Children[0].Content | Should -Be "- Tools"
$category.Children[1].Visibility | Should -Be ([Windows.Visibility]::Visible)
}
It "re-collapses a category it expanded once the filter is cleared" {
$powerToysItem = New-WinUtilAppSearchItem -Tag "WPFInstallPowerToys"
$category = New-WinUtilAppCategory -Label "+ Tools" -Items @($powerToysItem)
New-WinUtilAppSearchContext -Categories @($category)
Find-AppsByNameOrDescription -Categories @("Microsoft Tools")
$category.Children[0].Content | Should -Be "- Tools"
Find-AppsByNameOrDescription -SearchString ""
$category.Children[0].Content | Should -Be "+ Tools"
$category.Children[1].Visibility | Should -Be ([Windows.Visibility]::Collapsed)
}
It "leaves a category the user had expanded alone when the filter is cleared" {
$powerToysItem = New-WinUtilAppSearchItem -Tag "WPFInstallPowerToys"
$category = New-WinUtilAppCategory -Label "- Tools" -Items @($powerToysItem)
New-WinUtilAppSearchContext -Categories @($category)
Find-AppsByNameOrDescription -Categories @("Microsoft Tools")
Find-AppsByNameOrDescription -SearchString ""
$category.Children[0].Content | Should -Be "- Tools"
$category.Children[1].Visibility | Should -Be ([Windows.Visibility]::Visible)
}
}
Describe "Find-TweaksByNameOrDescription" {
+174
View File
@@ -0,0 +1,174 @@
#===========================================================================
# Tests - OpenSSH Server Setup
#===========================================================================
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
function Get-WindowsCapability {
param($Name, [switch]$Online)
[pscustomobject]@{ State = "Installed" }
}
function Add-WindowsCapability {
param($Name, [switch]$Online)
}
function Get-NetFirewallRule {
param($Name)
[pscustomobject]@{ Enabled = $true }
}
function New-NetFirewallRule {
param($Name, $DisplayName, $Enabled, $Direction, $Protocol, $Action, $LocalPort)
}
function Set-Service {
param($Name, $StartupType)
}
function Start-Service {
param($Name)
}
function Restart-Service {
param($Name, [switch]$Force)
}
. (Join-Path $script:repoRoot "functions\private\Invoke-WinUtilSSHServer.ps1")
$script:defaultAdministratorsBlock = "Match Group administrators`n AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys`n"
$script:overriddenAdministratorsBlock = "# Match Group administrators`n# AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys`n"
function script:New-SshdConfig {
param([string]$AdministratorsBlock)
$content = "# Default sshd_config`nPort 22`n`n$AdministratorsBlock"
Set-Content -Path $script:sshdConfigPath -Value $content -NoNewline
return $content
}
function script:Set-ProfileKeyFile {
param([string[]]$Keys)
New-Item -Path (Split-Path $script:profileKeysPath) -ItemType Directory -Force | Out-Null
Set-Content -Path $script:profileKeysPath -Value $Keys
}
function script:Get-ExplicitKeyFileAccess {
$acl = Get-Acl -Path $script:authorizedKeysPath
@($acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])) |
ForEach-Object { $_.IdentityReference.Value }
}
function script:Get-AuthorizedKeyFileContent {
# The key file ends up readable only by Administrators and SYSTEM, so an
# unelevated run has to grant itself read access back through its
# ownership of the file before it can check the contents.
$currentUserSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
icacls $script:authorizedKeysPath /grant "*${currentUserSid}:(R)" | Out-Null
@(Get-Content -Path $script:authorizedKeysPath)
}
}
Describe "Invoke-WinUtilSSHServer" {
BeforeEach {
$script:testRoot = Join-Path ([System.IO.Path]::GetTempPath()) "winutil-ssh-$([guid]::NewGuid())"
$script:programData = Join-Path $script:testRoot "ProgramData"
$script:userProfile = Join-Path $script:testRoot "Users\tester"
New-Item -Path (Join-Path $script:programData "ssh") -ItemType Directory -Force | Out-Null
New-Item -Path $script:userProfile -ItemType Directory -Force | Out-Null
$script:sshdConfigPath = Join-Path $script:programData "ssh\sshd_config"
$script:authorizedKeysPath = Join-Path $script:programData "ssh\administrators_authorized_keys"
$script:profileKeysPath = Join-Path $script:userProfile ".ssh\authorized_keys"
$script:savedProgramData = $env:ProgramData
$script:savedUserProfile = $env:USERPROFILE
$env:ProgramData = $script:programData
$env:USERPROFILE = $script:userProfile
Mock Write-Host { }
Mock Restart-Service { }
}
AfterEach {
$env:ProgramData = $script:savedProgramData
$env:USERPROFILE = $script:savedUserProfile
Remove-Item -Path $script:testRoot -Recurse -Force -ErrorAction SilentlyContinue
}
It "leaves the administrators block in a default sshd_config alone" {
$original = New-SshdConfig -AdministratorsBlock $script:defaultAdministratorsBlock
Invoke-WinUtilSSHServer
Get-Content -Path $script:sshdConfigPath -Raw | Should -BeExactly $original
Should -Invoke -CommandName Restart-Service -Times 0 -Exactly
}
It "leaves an sshd_config without an administrators block alone" {
$original = New-SshdConfig -AdministratorsBlock ""
Set-ProfileKeyFile -Keys @("ssh-ed25519 AAAAnotanadminkey laptop")
Invoke-WinUtilSSHServer
Get-Content -Path $script:sshdConfigPath -Raw | Should -BeExactly $original
Get-AuthorizedKeyFileContent | Should -Not -Contain "ssh-ed25519 AAAAnotanadminkey laptop"
Should -Invoke -CommandName Restart-Service -Times 0 -Exactly
}
It "creates administrators_authorized_keys limited to Administrators and SYSTEM" {
New-SshdConfig -AdministratorsBlock $script:defaultAdministratorsBlock | Out-Null
Invoke-WinUtilSSHServer
Test-Path -Path $script:authorizedKeysPath | Should -BeTrue
(Get-Acl -Path $script:authorizedKeysPath).AreAccessRulesProtected | Should -BeTrue
$sids = Get-ExplicitKeyFileAccess
$sids | Should -HaveCount 2
$sids | Should -Contain "S-1-5-32-544"
$sids | Should -Contain "S-1-5-18"
}
It "restores the administrators block when an earlier run commented it out" {
New-SshdConfig -AdministratorsBlock $script:overriddenAdministratorsBlock | Out-Null
Invoke-WinUtilSSHServer
$config = Get-Content -Path $script:sshdConfigPath -Raw
$config | Should -Match '(?m)^Match Group administrators$'
$config | Should -Match '(?m)^\s+AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys$'
Should -Invoke -CommandName Restart-Service -Times 1 -Exactly
}
It "moves profile keys into administrators_authorized_keys while restoring the block" {
New-SshdConfig -AdministratorsBlock $script:overriddenAdministratorsBlock | Out-Null
Set-ProfileKeyFile -Keys @("# my laptop", "", "ssh-ed25519 AAAAkeyone laptop", "ssh-ed25519 AAAAkeytwo desktop")
Invoke-WinUtilSSHServer
$keys = Get-AuthorizedKeyFileContent
$keys | Should -Contain "ssh-ed25519 AAAAkeyone laptop"
$keys | Should -Contain "ssh-ed25519 AAAAkeytwo desktop"
$keys | Should -Not -Contain "# my laptop"
}
It "does not copy profile keys when sshd_config is already at its default" {
New-SshdConfig -AdministratorsBlock $script:defaultAdministratorsBlock | Out-Null
Set-ProfileKeyFile -Keys @("ssh-ed25519 AAAAnotanadminkey laptop")
Invoke-WinUtilSSHServer
Get-AuthorizedKeyFileContent | Should -Not -Contain "ssh-ed25519 AAAAnotanadminkey laptop"
}
It "keeps keys that are already in administrators_authorized_keys" {
New-SshdConfig -AdministratorsBlock $script:overriddenAdministratorsBlock | Out-Null
Set-Content -Path $script:authorizedKeysPath -Value "ssh-ed25519 AAAAexisting server"
Set-ProfileKeyFile -Keys @("ssh-ed25519 AAAAexisting server", "ssh-ed25519 AAAAnew laptop")
Invoke-WinUtilSSHServer
$keys = @(Get-AuthorizedKeyFileContent | Where-Object { $_.Trim() })
$keys | Should -HaveCount 2
$keys | Should -Contain "ssh-ed25519 AAAAexisting server"
$keys | Should -Contain "ssh-ed25519 AAAAnew laptop"
}
}
+1 -1
View File
@@ -148,7 +148,6 @@ Describe "Set-WinUtilRegistry" {
$registryPath = "HKLM:\Software\WinUtilTest"
$script:testPathResults["HKU:\"] = $true
$script:testPathResults[$registryPath] = $true
Set-WinUtilRegistry -Path $registryPath -Name "ObsoleteValue" -Type "String" -Value "<RemoveEntry>"
Should -Invoke -CommandName Set-ItemProperty -Times 0 -Exactly
@@ -159,6 +158,7 @@ Describe "Set-WinUtilRegistry" {
$ErrorAction -eq "Stop"
}
}
}
Describe "Set-WinUtilService" {
+30 -1
View File
@@ -169,6 +169,7 @@ Describe "Invoke-WinUtilTweaks" {
$Name -eq "DiagTrack" -and $StartupType -eq "Disabled"
}
}
}
Describe "Invoke-WPFtweaksbutton" {
@@ -180,9 +181,14 @@ Describe "Invoke-WPFtweaksbutton" {
text = "Cloudflare"
}
})
$script:capturedTweaksScriptBlock = $null
Mock Invoke-WPFRunspace { [pscustomobject]@{ MockHandle = $true } }
Mock Invoke-WPFRunspace {
$script:capturedTweaksScriptBlock = $ScriptBlock
[pscustomobject]@{ MockHandle = $true }
}
Mock Invoke-WinUtilTweaks { }
Mock Set-WinUtilTweaksProgressIndicator { }
Mock Invoke-WPFUIThread { }
Mock Write-WinUtilLog { }
Mock Write-Host { }
@@ -190,6 +196,7 @@ Describe "Invoke-WPFtweaksbutton" {
AfterEach {
Remove-Variable -Name sync -Scope Script -ErrorAction SilentlyContinue
Remove-Variable -Name capturedTweaksScriptBlock -Scope Script -ErrorAction SilentlyContinue
}
It "passes selected tweaks, DNS provider, and progress counters to the tweak runspace" {
@@ -235,4 +242,26 @@ Describe "Invoke-WPFtweaksbutton" {
$ParameterList[3][1] -eq 2
}
}
It "stops the tweak workflow when the DNS change fails" {
$script:sync.selectedTweaks.Add("WPFTweaksTelemetry")
Mock Set-WinUtilDNS { return $false }
Invoke-WPFtweaksbutton
& $script:capturedTweaksScriptBlock -tweaks @("WPFTweaksTelemetry") -dnsProvider "Mullvad" -completedSteps 0 -totalSteps 1
Should -Invoke -CommandName Invoke-WinUtilTweaks -Times 0 -Exactly
Should -Invoke -CommandName Set-WinUtilTweaksProgressIndicator -Times 1 -Exactly -ParameterFilter {
$Visible -eq $true -and $Label -eq "DNS change failed" -and $Percent -eq 100
}
Should -Invoke -CommandName Invoke-WPFUIThread -Times 1 -Exactly -ParameterFilter {
$ScriptBlock.ToString() -like '*Set-WinUtilTaskbaritem -state "Error" -overlay "warning"*'
}
Should -Invoke -CommandName Write-WinUtilLog -Times 1 -Exactly -ParameterFilter {
$Level -eq "ERROR" -and
$Component -eq "Tweaks" -and
$Message -eq "Tweaks workflow stopped because the DNS change failed."
}
$script:sync.ProcessRunning | Should -BeFalse
}
}
+353
View File
@@ -0,0 +1,353 @@
#===========================================================================
# Tests - WinOneShot Catalog Compatibility
#===========================================================================
# WinOneShot downloads these files from WinUtil's main/config raw GitHub URLs.
# Keep the known field types aligned with WinOneShot Models.cs; extra fields are allowed.
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
$catalogCases = @(
@{
Name = "applications.json"
Path = Join-Path $repoRoot "config\applications.json"
ScalarFields = @{
category = "String"
choco = "String"
content = "String"
description = "String"
foss = "Boolean"
link = "String"
winget = "String"
}
ArrayFields = @{}
}
@{
Name = "appx.json"
Path = Join-Path $repoRoot "config\appx.json"
ScalarFields = @{
Category = "String"
Content = "String"
Description = "String"
Panel = "String"
PackageId = "String"
StoreId = "String"
}
ArrayFields = @{}
}
@{
Name = "feature.json"
Path = Join-Path $repoRoot "config\feature.json"
ScalarFields = @{
Content = "String"
Description = "String"
category = "String"
panel = "String"
Order = "String"
link = "String"
Type = "String"
ButtonWidth = "String"
function = "String"
}
ArrayFields = @{
feature = "StringArray"
InvokeScript = "StringArray"
}
}
@{
Name = "tweaks.json"
Path = Join-Path $repoRoot "config\tweaks.json"
ScalarFields = @{
Content = "String"
Description = "String"
category = "String"
panel = "String"
Order = "String"
Type = "String"
Checked = "String"
ButtonWidth = "String"
ComboItems = "String"
link = "String"
}
ArrayFields = @{
registry = "RegistryArray"
service = "ServiceArray"
ScheduledTask = "ScheduledTaskArray"
appx = "StringArray"
InvokeScript = "StringArray"
UndoScript = "StringArray"
}
}
)
$invalidCatalogCases = @(
@{
Name = "applications.json"
Json = '[{"App":{}}]'
ScalarFields = $catalogCases[0].ScalarFields
ArrayFields = $catalogCases[0].ArrayFields
ExpectedError = "must contain a JSON object"
}
@{
Name = "applications.json"
Json = "{}"
ScalarFields = $catalogCases[0].ScalarFields
ArrayFields = $catalogCases[0].ArrayFields
ExpectedError = "does not contain any entries"
}
@{
Name = "applications.json"
Json = '{"Broken":null}'
ScalarFields = $catalogCases[0].ScalarFields
ArrayFields = $catalogCases[0].ArrayFields
ExpectedError = "applications.json.Broken is null"
}
@{
Name = "applications.json"
Json = '{"App":{"foss":"yes"}}'
ScalarFields = $catalogCases[0].ScalarFields
ArrayFields = $catalogCases[0].ArrayFields
ExpectedError = "foss must be a Boolean"
}
@{
Name = "appx.json"
Json = '{"App":{"PackageId":[]}}'
ScalarFields = $catalogCases[1].ScalarFields
ArrayFields = $catalogCases[1].ArrayFields
ExpectedError = "PackageId must be a string or null"
}
@{
Name = "feature.json"
Json = '{"Feature":{"feature":"Example-Feature"}}'
ScalarFields = $catalogCases[2].ScalarFields
ArrayFields = $catalogCases[2].ArrayFields
ExpectedError = "feature must be an array or null"
}
@{
Name = "tweaks.json"
Json = '{"Tweak":{"registry":{}}}'
ScalarFields = $catalogCases[3].ScalarFields
ArrayFields = $catalogCases[3].ArrayFields
ExpectedError = "registry must be an array or null"
}
)
BeforeAll {
function script:Get-WinOneShotJsonKind {
param($Value)
if ($null -eq $Value) {
return "Null"
}
if ($Value -is [string]) {
return "String"
}
if ($Value -is [bool]) {
return "Boolean"
}
if ($Value -is [array]) {
return "Array"
}
if ($Value -is [pscustomobject]) {
return "Object"
}
return $Value.GetType().Name
}
function script:Get-WinOneShotProperty {
param(
[Parameter(Mandatory)]
[pscustomobject]$Object,
[Parameter(Mandatory)]
[string]$Name
)
foreach ($property in $Object.PSObject.Properties) {
if ($property.Name -ieq $Name) {
$property
}
}
}
function script:Get-WinOneShotTypeError {
param(
[Parameter(Mandatory)]
[string]$Location,
$Value,
[Parameter(Mandatory)]
[string]$ExpectedType
)
$actualKind = Get-WinOneShotJsonKind -Value $Value
if ($ExpectedType -eq "String") {
if ($actualKind -notin @("String", "Null")) {
return "$Location must be a string or null; found $actualKind"
}
return
}
if ($ExpectedType -eq "Boolean") {
if ($actualKind -ne "Boolean") {
return "$Location must be a Boolean; found $actualKind"
}
return
}
if ($actualKind -eq "Null") {
return
}
if ($actualKind -ne "Array") {
return "$Location must be an array or null; found $actualKind"
}
if ($ExpectedType -eq "StringArray") {
for ($index = 0; $index -lt $Value.Count; $index++) {
$elementKind = Get-WinOneShotJsonKind -Value $Value[$index]
if ($elementKind -notin @("String", "Null")) {
"$Location[$index] must be a string or null; found $elementKind"
}
}
return
}
$nestedFields = switch ($ExpectedType) {
"RegistryArray" {
@("Path", "Name", "Value", "OriginalValue", "DefaultState", "Type")
}
"ServiceArray" {
@("Name", "StartupType", "OriginalType")
}
"ScheduledTaskArray" {
@("Name")
}
default {
throw "Unknown WinOneShot contract type: $ExpectedType"
}
}
for ($index = 0; $index -lt $Value.Count; $index++) {
$element = $Value[$index]
$elementKind = Get-WinOneShotJsonKind -Value $element
if ($elementKind -eq "Null") {
continue
}
if ($elementKind -ne "Object") {
"$Location[$index] must be an object or null; found $elementKind"
continue
}
foreach ($field in $nestedFields) {
foreach ($property in (Get-WinOneShotProperty -Object $element -Name $field)) {
$propertyKind = Get-WinOneShotJsonKind -Value $property.Value
if ($propertyKind -notin @("String", "Null")) {
"$Location[$index].$($property.Name) must be a string or null; found $propertyKind"
}
}
}
}
}
function script:Get-WinOneShotCatalogError {
param(
[Parameter(Mandatory)]
[string]$Name,
[Parameter(Mandatory)]
[string]$Json,
[Parameter(Mandatory)]
[hashtable]$ScalarFields,
[Parameter(Mandatory)]
[hashtable]$ArrayFields
)
try {
$catalog = $Json | ConvertFrom-Json -NoEnumerate -ErrorAction Stop
} catch {
return "WinOneShot cannot parse ${Name}: $_"
}
if ((Get-WinOneShotJsonKind -Value $catalog) -ne "Object") {
return "$Name must contain a JSON object."
}
$entries = @($catalog.PSObject.Properties)
if ($entries.Count -eq 0) {
return "$Name does not contain any entries."
}
foreach ($entry in $entries) {
$entryKind = Get-WinOneShotJsonKind -Value $entry.Value
if ($entryKind -eq "Null") {
"$Name.$($entry.Name) is null"
continue
}
if ($entryKind -ne "Object") {
"$Name.$($entry.Name) must be an object; found $entryKind"
continue
}
foreach ($field in $ScalarFields.Keys) {
foreach ($property in (Get-WinOneShotProperty -Object $entry.Value -Name $field)) {
Get-WinOneShotTypeError -Location "$Name.$($entry.Name).$($property.Name)" -Value $property.Value -ExpectedType $ScalarFields[$field]
}
}
foreach ($field in $ArrayFields.Keys) {
foreach ($property in (Get-WinOneShotProperty -Object $entry.Value -Name $field)) {
Get-WinOneShotTypeError -Location "$Name.$($entry.Name).$($property.Name)" -Value $property.Value -ExpectedType $ArrayFields[$field]
}
}
}
}
}
Describe "WinOneShot catalog download contract" {
foreach ($catalogCase in $catalogCases) {
It "publishes <Name> at the exact raw-download path" -TestCases $catalogCase {
param([string]$Path)
Test-Path -LiteralPath $Path -PathType Leaf | Should -BeTrue
$exactNameMatch = @(
Get-ChildItem -LiteralPath (Split-Path -Path $Path -Parent) -File |
Where-Object { $_.Name -ceq (Split-Path -Path $Path -Leaf) }
)
$exactNameMatch.Count | Should -Be 1
}
It "loads <Name> with WinOneShot-compatible field types" -TestCases $catalogCase {
param(
[string]$Name,
[string]$Path,
[hashtable]$ScalarFields,
[hashtable]$ArrayFields
)
$json = Get-Content -LiteralPath $Path -Raw
$errors = @(Get-WinOneShotCatalogError -Name $Name -Json $json -ScalarFields $ScalarFields -ArrayFields $ArrayFields)
if ($errors.Count -gt 0) {
throw ($errors -join "`n")
}
}
}
}
Describe "WinOneShot catalog contract guard" {
It "rejects consumer-breaking <Name> fixture: <ExpectedError>" -TestCases $invalidCatalogCases {
param(
[string]$Name,
[string]$Json,
[hashtable]$ScalarFields,
[hashtable]$ArrayFields,
[string]$ExpectedError
)
$errors = @(Get-WinOneShotCatalogError -Name $Name -Json $Json -ScalarFields $ScalarFields -ArrayFields $ArrayFields)
$errors -join "`n" | Should -BeLike "*$ExpectedError*"
}
}
+6 -2
View File
@@ -182,7 +182,8 @@ Describe "XAML document" {
It "wires the Document search chip to an existing Document category" {
$mainScript = Get-Content -Path $script:mainScriptPath -Raw
$mainScript | Should -Match '\$sync\["WPFSearchChipDocument"\]\.Add_Click\(\{ Set-WinUtilAppCategoryFilter -Category "Document" \}\)'
$mainScript | Should -Match '@\{ Name = "WPFSearchChipDocument";\s+Category = "Document" \}'
$mainScript | Should -Match '\$sync\["WPFSearchChipDocument"\]\.Add_Click\(\{ Invoke-WinUtilAppCategoryChip -Chip \$this \}\)'
$applications = Get-WinUtilConfigObject -Name "applications"
$categories = @($applications.PSObject.Properties | ForEach-Object { $_.Value.category } | Sort-Object -Unique)
@@ -468,7 +469,10 @@ Describe "XAML and sync wiring" {
"Win11ISOProcessRunning",
"Win11ISOWorkDir",
"Win11ISOContentsDir",
"Win11ISOUSBDisks"
"Win11ISOUSBDisks",
"AppCategoryChips",
"SelectedAppCategories",
"AppCategoryAutoExpanded"
)
$allowedNames = @($xamlNames + $generatedNames + $dynamicStateNames) | Sort-Object -Unique
$bracketReferences = @(
+32 -22
View File
@@ -329,7 +329,7 @@ $searchBarTimer.add_Tick({
$searchBarTimer.Stop()
switch ($sync.currentTab) {
"Install" {
Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Category $sync.SearchBar.Tag
Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Categories $sync.SelectedAppCategories.ToArray()
}
"Tweaks" {
Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text
@@ -340,10 +340,6 @@ $searchBarTimer.add_Tick({
}
})
$sync["SearchBar"].Add_TextChanged({
if ($sync.SearchBar.Tag -ne $sync.SearchBar.Text) {
$sync.SearchBar.Tag = $null
}
if ($sync.SearchBar.Text -ne "") {
$sync.SearchBarClearButton.Visibility = "Visible"
$sync.SearchBarIcon.Visibility = "Collapsed"
@@ -352,29 +348,43 @@ $sync["SearchBar"].Add_TextChanged({
$sync.SearchBarIcon.Visibility = "Visible"
}
# Category chip handlers apply their filter immediately.
if ($sync.SearchBar.Tag -eq $sync.SearchBar.Text) {
return
}
if ($searchBarTimer.IsEnabled) {
$searchBarTimer.Stop()
}
$searchBarTimer.Start()
})
# Quick Category Search Chips
$sync["WPFSearchChipAll"].Add_Click({ Set-WinUtilAppCategoryFilter })
$sync["WPFSearchChipBrowsers"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Browsers" })
$sync["WPFSearchChipCommunications"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Communications" })
$sync["WPFSearchChipDevelopment"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Development" })
$sync["WPFSearchChipDocument"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Document" })
$sync["WPFSearchChipGames"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Games" })
$sync["WPFSearchChipMicrosoftTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Microsoft Tools" })
$sync["WPFSearchChipMultimediaTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Multimedia Tools" })
$sync["WPFSearchChipProTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Pro Tools" })
$sync["WPFSearchChipSelfhostedTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Selfhosted Tools" })
$sync["WPFSearchChipUtilities"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Utilities" })
# Category filter chips. The chip carries its category in Tag, so one handler covers all of them.
$sync.AppCategoryChips = @(
@{ Name = "WPFSearchChipAll"; Category = "" }
@{ Name = "WPFSearchChipBrowsers"; Category = "Browsers" }
@{ Name = "WPFSearchChipCommunications"; Category = "Communications" }
@{ Name = "WPFSearchChipDevelopment"; Category = "Development" }
@{ Name = "WPFSearchChipDocument"; Category = "Document" }
@{ Name = "WPFSearchChipGames"; Category = "Games" }
@{ Name = "WPFSearchChipMicrosoftTools"; Category = "Microsoft Tools" }
@{ Name = "WPFSearchChipMultimediaTools"; Category = "Multimedia Tools" }
@{ Name = "WPFSearchChipProTools"; Category = "Pro Tools" }
@{ Name = "WPFSearchChipSelfhostedTools"; Category = "Selfhosted Tools" }
@{ Name = "WPFSearchChipUtilities"; Category = "Utilities" }
)
$sync.SelectedAppCategories = [System.Collections.Generic.List[string]]::new()
foreach ($appCategoryChip in $sync.AppCategoryChips) {
$sync[$appCategoryChip.Name].Tag = $appCategoryChip.Category
}
$sync["WPFSearchChipAll"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this })
$sync["WPFSearchChipBrowsers"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this })
$sync["WPFSearchChipCommunications"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this })
$sync["WPFSearchChipDevelopment"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this })
$sync["WPFSearchChipDocument"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this })
$sync["WPFSearchChipGames"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this })
$sync["WPFSearchChipMicrosoftTools"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this })
$sync["WPFSearchChipMultimediaTools"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this })
$sync["WPFSearchChipProTools"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this })
$sync["WPFSearchChipSelfhostedTools"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this })
$sync["WPFSearchChipUtilities"].Add_Click({ Invoke-WinUtilAppCategoryChip -Chip $this })
$sync["Form"].Add_Loaded({
param($e)
+57 -16
View File
@@ -997,6 +997,44 @@
</Setter.Value>
</Setter>
</Style>
<!-- Category filter chips. A toggle rather than a button, so the active filter is visible
on the chip itself instead of only in the results below. -->
<Style x:Key="FilterChipToggleStyle" TargetType="ToggleButton">
<Setter Property="Margin" Value="2"/>
<Setter Property="Padding" Value="12,4,12,4"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="FontSize" Value="{DynamicResource ButtonFontSize}"/>
<Setter Property="FontFamily" Value="{DynamicResource ButtonFontFamily}"/>
<Setter Property="Foreground" Value="{DynamicResource ButtonForegroundColor}"/>
<Setter Property="Background" Value="{DynamicResource ButtonBackgroundColor}"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ToggleButton">
<Border Name="ChipBorder"
Background="{TemplateBinding Background}"
BorderBrush="{DynamicResource BorderColor}"
BorderThickness="1"
CornerRadius="{DynamicResource ButtonCornerRadius}"
Padding="{TemplateBinding Padding}">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"
TextBlock.Foreground="{TemplateBinding Foreground}"
TextBlock.FontSize="{TemplateBinding FontSize}"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ChipBorder" Property="Background" Value="{DynamicResource ButtonBackgroundMouseoverColor}"/>
</Trigger>
<!-- Only colours change on check. Anything affecting text width, bold for
instance, would resize the chip and shift every chip after it. -->
<Trigger Property="IsChecked" Value="True">
<Setter TargetName="ChipBorder" Property="Background" Value="{DynamicResource ButtonBackgroundSelectedColor}"/>
<Setter TargetName="ChipBorder" Property="BorderBrush" Value="{DynamicResource LabelboxForegroundColor}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid Background="{DynamicResource MainBackgroundColor}" ShowGridLines="False" Name="WPFMainGrid" Width="Auto" Height="Auto" HorizontalAlignment="Stretch">
<Grid.RowDefinitions>
@@ -1219,6 +1257,8 @@
HorizontalAlignment="Right" VerticalAlignment="Center"
Margin="0,0,2,0"
FontFamily="Segoe MDL2 Assets"
ToolTip="Settings"
AutomationProperties.Name="Settings"
Content="&#xE713;"/>
<Popup Name="SettingsPopup"
IsOpen="False"
@@ -1313,26 +1353,27 @@
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<!-- Quick Category Search Chips -->
<!-- Category filters. Click one to filter, ctrl click to combine several. -->
<WrapPanel Grid.Row="0" Orientation="Horizontal" Margin="5,5,5,5" Name="WPFSearchChips">
<TextBlock Text="Filters"
FontSize="{DynamicResource HeaderFontSize}"
FontFamily="{DynamicResource HeaderFontFamily}"
<TextBlock Text="&#xE71C;"
FontFamily="Segoe MDL2 Assets"
FontSize="{DynamicResource IconFontSize}"
Foreground="{DynamicResource LabelboxForegroundColor}"
Background="Transparent"
VerticalAlignment="Center"
Margin="15,0,8,0"/>
<Button Name="WPFSearchChipAll" Content="All" Style="{StaticResource FilterChipStyle}"/>
<Button Name="WPFSearchChipBrowsers" Content="Browsers" Style="{StaticResource FilterChipStyle}"/>
<Button Name="WPFSearchChipCommunications" Content="Communications" Style="{StaticResource FilterChipStyle}"/>
<Button Name="WPFSearchChipDevelopment" Content="Development" Style="{StaticResource FilterChipStyle}"/>
<Button Name="WPFSearchChipDocument" Content="Document" Style="{StaticResource FilterChipStyle}"/>
<Button Name="WPFSearchChipGames" Content="Games" Style="{StaticResource FilterChipStyle}"/>
<Button Name="WPFSearchChipMicrosoftTools" Content="Microsoft Tools" Style="{StaticResource FilterChipStyle}"/>
<Button Name="WPFSearchChipMultimediaTools" Content="Multimedia Tools" Style="{StaticResource FilterChipStyle}"/>
<Button Name="WPFSearchChipProTools" Content="Pro Tools" Style="{StaticResource FilterChipStyle}"/>
<Button Name="WPFSearchChipSelfhostedTools" Content="Selfhosted Tools" Style="{StaticResource FilterChipStyle}"/>
<Button Name="WPFSearchChipUtilities" Content="Utilities" Style="{StaticResource FilterChipStyle}"/>
Margin="10,0,10,0"
ToolTip="Filter by category. Ctrl click to select more than one."/>
<ToggleButton Name="WPFSearchChipAll" Content="All" Style="{StaticResource FilterChipToggleStyle}" IsChecked="True"/>
<ToggleButton Name="WPFSearchChipBrowsers" Content="Browsers" Style="{StaticResource FilterChipToggleStyle}"/>
<ToggleButton Name="WPFSearchChipCommunications" Content="Communications" Style="{StaticResource FilterChipToggleStyle}"/>
<ToggleButton Name="WPFSearchChipDevelopment" Content="Development" Style="{StaticResource FilterChipToggleStyle}"/>
<ToggleButton Name="WPFSearchChipDocument" Content="Document" Style="{StaticResource FilterChipToggleStyle}"/>
<ToggleButton Name="WPFSearchChipGames" Content="Games" Style="{StaticResource FilterChipToggleStyle}"/>
<ToggleButton Name="WPFSearchChipMicrosoftTools" Content="Microsoft Tools" Style="{StaticResource FilterChipToggleStyle}"/>
<ToggleButton Name="WPFSearchChipMultimediaTools" Content="Multimedia Tools" Style="{StaticResource FilterChipToggleStyle}"/>
<ToggleButton Name="WPFSearchChipProTools" Content="Pro Tools" Style="{StaticResource FilterChipToggleStyle}"/>
<ToggleButton Name="WPFSearchChipSelfhostedTools" Content="Selfhosted Tools" Style="{StaticResource FilterChipToggleStyle}"/>
<ToggleButton Name="WPFSearchChipUtilities" Content="Utilities" Style="{StaticResource FilterChipToggleStyle}"/>
</WrapPanel>
<Grid Grid.Row="1" Margin="{DynamicResource TabContentMargin}">