mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-08-11 02:21:16 +10:00
Compare commits
23
Commits
a0142ace72
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea5de27b6c | ||
|
|
2752fe2e03 | ||
|
|
1458327638 | ||
|
|
ea7fcf9d2b | ||
|
|
0aa4ab3a40 | ||
|
|
afc3e1eec2 | ||
|
|
53fc260cc0 | ||
|
|
bc607b6c91 | ||
|
|
7f18b4fd60 | ||
|
|
32ab9f0ab0 | ||
|
|
9fdadd1c8f | ||
|
|
6de45a38b9 | ||
|
|
8860caf357 | ||
|
|
8fb078da4c | ||
|
|
95e3a54405 | ||
|
|
c817d3329a | ||
|
|
19f938e876 | ||
|
|
d64bb18717 | ||
|
|
5ad168e0c4 | ||
|
|
8d3adb599f | ||
|
|
f7c072341d | ||
|
|
32cc623959 | ||
|
|
b096aaa5e5 |
@@ -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:
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copilot Instructions
|
||||
|
||||
Read `AGENTS.md` in the repository root for operating instructions before doing anything else.
|
||||
@@ -13,8 +13,3 @@ desktop.ini
|
||||
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# hugo files (archived docs)
|
||||
docs-old/public/
|
||||
docs-old/.hugo_build.lock
|
||||
docs-old/resources/
|
||||
|
||||
@@ -4,35 +4,22 @@ Drop-in operating instructions for coding agents. Read this file before every ta
|
||||
|
||||
**Working code only. Finish the job. Plausibility is not correctness.**
|
||||
|
||||
This repository follows the AGENTS.md convention: these instructions are for Codex, Claude Code, Cursor, Windsurf, Copilot, Aider, Devin, Amp, and other coding agents that read `AGENTS.md`.
|
||||
`SPEC.md` in the repository root is the project contract — read it for what WinUtil is and how it's architected. This file covers how to work on it.
|
||||
|
||||
## 0. Non-Negotiables
|
||||
|
||||
These rules override everything else in this file when in conflict:
|
||||
|
||||
1. **Do not edit `winutil.ps1` directly.** It is generated build output. Change source files and compile.
|
||||
1. **Do not edit `winutil.ps1` directly.** It is generated build output (see SPEC.md's Build Model). Change source files and compile.
|
||||
2. **Do not commit `winutil.ps1`.** It is ignored locally and generated by GitHub Actions for releases.
|
||||
3. **Never fabricate.** Do not invent file paths, function names, command output, test results, commit hashes, or API behavior. Read the file or run the command.
|
||||
4. **Disagree when the premise is wrong.** Say what is wrong before acting on it.
|
||||
5. **Stop when genuinely ambiguous.** If two interpretations would produce materially different diffs, ask before editing.
|
||||
6. **Touch only what the task requires.** No drive-by refactors, formatting sweeps, or unrelated cleanup.
|
||||
7. **Verify before saying done.** A plausible-looking diff is not proof.
|
||||
3. **Never touch `docs/src/content/docs/code-reference/tweaks/` or `docs/src/content/docs/code-reference/features/`.** Both are auto-generated (see SPEC.md's Docs Site). Edit the source JSON (`config/tweaks.json`, `config/feature.json`) or the relevant PowerShell function file instead. Other hand-written pages under `code-reference/` (e.g. `architecture.mdx`) are not touched by the generator and may be edited directly.
|
||||
4. **Never fabricate.** Do not invent file paths, function names, command output, test results, commit hashes, or API behavior. Read the file or run the command.
|
||||
5. **Disagree when the premise is wrong.** Say what is wrong before acting on it.
|
||||
6. **Stop when genuinely ambiguous.** If two interpretations would produce materially different diffs, ask before editing.
|
||||
7. **Touch only what the task requires.** No drive-by refactors, formatting sweeps, or unrelated cleanup.
|
||||
8. **Verify before saying done.** A plausible-looking diff is not proof.
|
||||
|
||||
## 1. Project Context
|
||||
|
||||
WinUtil is a Windows PowerShell utility with a WPF interface. The repository is maintained as modular source, but the distributed artifact is one compiled PowerShell script.
|
||||
|
||||
### Stack
|
||||
|
||||
- Language: Windows PowerShell / PowerShell.
|
||||
- UI: WPF via `xaml/inputXML.xaml`.
|
||||
- Configuration: JSON files under `config/`.
|
||||
- Tests: Pester tests under `pester/`.
|
||||
- Lint: PowerShell Script Analyzer with settings in `lint/PSScriptAnalyser.ps1`.
|
||||
- Docs: Hugo site under `docs/`.
|
||||
- Release artifact: generated root `winutil.ps1`.
|
||||
|
||||
### Key Commands
|
||||
## 1. Key Commands
|
||||
|
||||
- Compile:
|
||||
```powershell
|
||||
@@ -42,48 +29,47 @@ WinUtil is a Windows PowerShell utility with a WPF interface. The repository is
|
||||
```powershell
|
||||
.\Compile.ps1 -Run
|
||||
```
|
||||
- Install the supported Pester version (one-time). `-SkipPublisherCheck` is required because Windows ships an inbox Pester 3.4.0 that is catalog-signed, and PowerShell Gallery's Pester 5.8.0 is Authenticode-signed — `Install-Module` refuses the upgrade without it. This does not skip download integrity (still HTTPS + NuGet package hash verification); `-Repository PSGallery` pins the trusted source explicitly rather than relying on whatever repositories happen to be registered:
|
||||
```powershell
|
||||
Install-Module -Name Pester -RequiredVersion 5.8.0 -Repository PSGallery -Scope CurrentUser -Force -SkipPublisherCheck
|
||||
```
|
||||
- Run tests:
|
||||
```powershell
|
||||
Import-Module Pester -RequiredVersion 5.8.0 -Force
|
||||
Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed
|
||||
Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed -CI
|
||||
```
|
||||
- Run Script Analyzer with project settings when available:
|
||||
- Run Script Analyzer with project settings when available. If a locally compiled `winutil.ps1` exists, delete it first — `lint/PSScriptAnalyser.ps1` only excludes rules, not files, so `-Recurse` would also lint the generated script and produce noise against line numbers that don't map to any source file:
|
||||
```powershell
|
||||
Invoke-ScriptAnalyzer -Path . -Settings .\lint\PSScriptAnalyser.ps1 -Recurse
|
||||
```
|
||||
- Docs site dev server (run from `docs/`; see Section 2 for why this goes through Docker):
|
||||
```powershell
|
||||
docker compose up winutil-astro
|
||||
```
|
||||
- Docs site production build (run from `docs/`):
|
||||
```powershell
|
||||
docker compose run --rm winutil-astro npm run build
|
||||
```
|
||||
|
||||
Prefer the narrowest useful verification while iterating. Use the full relevant check before finishing.
|
||||
|
||||
## 2. Source Of Truth
|
||||
## 2. Dependency Installs, Builds, And Dev Servers
|
||||
|
||||
Make durable changes only in files consumed by `Compile.ps1` or in documentation/test files:
|
||||
Given the current wave of npm/pnpm/yarn supply-chain worms (malicious postinstall/preinstall scripts, credential-stealing packages): **never run npm/pnpm/yarn/npx directly on the host, full stop.** The docs site (`docs/`) is the only npm-based project in this repo; always run its tooling inside Docker via `docs/Dockerfile` and `docs/docker-compose.yml` (service `winutil-astro`).
|
||||
|
||||
- `scripts/start.ps1` for startup/bootstrap code.
|
||||
- `functions/public/*.ps1` for UI-facing and user-facing workflows.
|
||||
- `functions/private/*.ps1` for internal helpers.
|
||||
- `config/*.json` for applications, tweaks, features, DNS, presets, navigation, themes, and related declarative data.
|
||||
- `xaml/inputXML.xaml` for the WPF UI layout.
|
||||
- `tools/autounattend.xml` for the embedded unattended Windows setup template.
|
||||
- `scripts/main.ps1` for the final entrypoint and GUI initialization logic appended during compile.
|
||||
- `pester/*.Tests.ps1` for automated checks.
|
||||
- `docs/` for Hugo documentation.
|
||||
- Never run `npm install`, `npm run <script>`, `npx <pkg>`, `pnpm`, or `yarn` directly on the host shell in `docs/`. Use `docker compose run --rm winutil-astro <command>` / `docker compose up winutil-astro` instead (see Section 1 for the exact commands).
|
||||
- If a task needs a new docs dependency, add it to `docs/package.json` yourself, then rebuild the image and drop the `node_modules` volume so it repopulates from the new image (run from `docs/`): `docker compose build winutil-astro`, then `docker compose down -v`. Docker only seeds a named volume from the image the first time it's created, so a plain rebuild silently leaves the old `node_modules` in place. Don't install packages on the host, even temporarily, "just to check something."
|
||||
- If Docker isn't available on the host, propose the install command for the current OS and wait for confirmation before running it — don't fall back to running npm on the host instead. If the daemon just isn't running (Docker is installed but not started), tell the user rather than trying to start it yourself.
|
||||
- Treat any `postinstall`/`preinstall` lifecycle script in a new dependency as worth flagging to the user before installing — summarize what it does.
|
||||
- Don't put real secrets anywhere under `docs/`. `docs/.dockerignore` only trims what `docker build` copies into the image — it does not affect the `docker compose` bind mount, which exposes the entire `docs/` directory (including any `.env` file) inside the container for every dev/build/preview command (see the next bullet). There is no "keep it out unless mounted" middle ground here.
|
||||
- The container mounts `docs/` as a volume, so file edits on the host are reflected inside the container immediately — no rebuild needed for normal code changes, only when `docs/package.json`/`docs/package-lock.json` change (see the rebuild-and-drop-volume steps above).
|
||||
- This Docker requirement is specific to `docs/`. The rest of the repo is PowerShell (`Compile.ps1`, Pester, Script Analyzer) and runs directly on the host per Section 1.
|
||||
|
||||
If behavior changes require the compiled script to change, update these source files and run `.\Compile.ps1` only to verify generation.
|
||||
## 3. Source Of Truth
|
||||
|
||||
## 3. Build Model
|
||||
For changes that affect the compiled WinUtil script, make them only in the source files described in SPEC.md's Repository Layout — never in `winutil.ps1` itself. If behavior changes require the compiled script to change, update the source files and run `.\Compile.ps1` only to verify generation.
|
||||
|
||||
`Compile.ps1` combines the repository sources into `winutil.ps1` in this order:
|
||||
|
||||
1. Read `scripts/start.ps1` and replace `#{replaceme}` with the current `yy.MM.dd` build date.
|
||||
2. Append every file under `functions/` recursively.
|
||||
3. Convert each `config/*.json` file into embedded `$sync.configs` objects.
|
||||
4. Special-case `config/applications.json` so keys receive the `WPFInstall` prefix in compiled config.
|
||||
5. Embed `xaml/inputXML.xaml` into `$inputXML`.
|
||||
6. Embed `tools/autounattend.xml` into `$WinUtilAutounattendXml`.
|
||||
7. Append `scripts/main.ps1`.
|
||||
8. Write the result to root `winutil.ps1`.
|
||||
|
||||
Because the final script is concatenated, do not rely on runtime module imports or source-relative dot-sourcing unless the compiled script will also contain the required code/data.
|
||||
This scoping applies to compiled-script behavior only. Repository metadata — `AGENTS.md`, `SPEC.md`, `CLAUDE.md`/`GEMINI.md`/`.github/copilot-instructions.md`, `.github/workflows/`, and the root `.gitignore` — is edited directly when a task requires it, per the other sections of this file.
|
||||
|
||||
## 4. Before Editing
|
||||
|
||||
@@ -97,11 +83,10 @@ Because the final script is concatenated, do not rely on runtime module imports
|
||||
|
||||
- Prefer the minimum code that solves the stated problem.
|
||||
- Keep PowerShell functions in one function file when practical, with the file name matching the primary function name.
|
||||
- Use approved PowerShell verb-noun names and follow the existing `WPF` / `WinUtil` naming conventions.
|
||||
- Keep UI event handler names aligned with XAML element names. A button named `WPFExampleButton` is typically handled by `Invoke-WPFExampleButton`.
|
||||
- Use `$sync` for shared state and UI references, consistent with the existing runspace model.
|
||||
- Use approved PowerShell verb-noun names and follow the existing `WPF` / `WinUtil` naming conventions; keep UI event handler names aligned with XAML element names per SPEC.md's UI And Event Contract.
|
||||
- Use `$sync` for shared state and UI references, consistent with SPEC.md's Runtime Model.
|
||||
- Update WPF controls through the UI dispatcher when running work in a background runspace.
|
||||
- Keep config-driven features in JSON when they fit the existing schema instead of hard-coding lists in PowerShell.
|
||||
- Keep config-driven features in JSON when they fit the existing schema instead of hard-coding lists in PowerShell; follow SPEC.md's Configuration Contract for required fields and key-renaming rules.
|
||||
- Preserve undo/original-state data for tweaks so users can reverse changes.
|
||||
- Do not add abstractions, configurability, hooks, or "future extensibility" unless the task needs them now.
|
||||
- Clean up orphans created by your own changes, such as unused variables or functions made obsolete by the edit.
|
||||
@@ -109,7 +94,7 @@ Because the final script is concatenated, do not rely on runtime module imports
|
||||
|
||||
## 6. Runtime And Safety Rules
|
||||
|
||||
- WinUtil performs system-level Windows changes. Treat registry, services, AppX removal, package manager, Windows Update, ISO, and unattended setup changes as high-risk.
|
||||
- WinUtil performs system-level Windows changes; treat registry, services, AppX removal, package manager, Windows Update, ISO, and unattended setup changes as high-risk (see SPEC.md's Safety Requirements).
|
||||
- Prefer existing helper functions for WinGet, Chocolatey, registry, services, progress, and UI updates.
|
||||
- Keep tweaks reversible where the schema supports it by including original values or original states.
|
||||
- Never modify a user's original ISO in-place; follow existing copy/mount/export patterns.
|
||||
@@ -136,24 +121,26 @@ Define success in terms that can be checked, then check it.
|
||||
- Read command output. Do not report tests as passing unless they actually passed.
|
||||
- If verification fails, fix the cause rather than weakening the test.
|
||||
|
||||
If a check cannot be run, say exactly why and what residual risk remains.
|
||||
If a check cannot be run, say exactly why and what residual risk remains. See SPEC.md's Testing And CI for what GitHub Actions runs on every push.
|
||||
|
||||
## 9. Generated Files And Git Hygiene
|
||||
|
||||
- Treat local `winutil.ps1` changes as disposable compile output.
|
||||
- Never stage or commit `winutil.ps1`, `docs/public/`, `docs/resources/`, `binary/`, editor folders, or other ignored build artifacts.
|
||||
- Never stage or commit `winutil.ps1`, `binary/`, or anything else ignored by the root `.gitignore` or `docs/.gitignore` — read those files rather than assuming. `docs/public/` is tracked source for static assets, not generated output.
|
||||
- Do not remove `.gitignore` rules that keep generated artifacts out of Git.
|
||||
- Before finishing, check `git status --short` and separate your changes from pre-existing user changes.
|
||||
- Do not revert user changes unless explicitly asked.
|
||||
- Commit messages, when requested, should be descriptive: short subject under 72 characters, body explaining why when needed.
|
||||
- When committing, split changes into small, logical commits rather than one large commit, so each commit's diff is reviewable as a single group of related changes.
|
||||
|
||||
## 10. Documentation Expectations
|
||||
|
||||
- Update `docs/content/` when user-facing behavior changes.
|
||||
- Update developer docs when architecture, build flow, config schema, or contribution workflow changes.
|
||||
- Update `docs/src/content/docs/guides/` when user-facing behavior changes.
|
||||
- Update `docs/src/content/docs/code-reference/architecture.mdx` and other hand-written developer docs when architecture, build flow, config schema, or contribution workflow changes — but never hand-edit the auto-generated `code-reference/tweaks/` or `code-reference/features/` subfolders (see Non-Negotiables).
|
||||
- Keep sidebar entries in `docs/astro.config.mjs` in sync with page slugs (see SPEC.md's Docs Site).
|
||||
- Keep README changes brief and high-level.
|
||||
- Put detailed user and developer documentation under `docs/`.
|
||||
- Keep `SPEC.md` aligned with build/runtime contract changes.
|
||||
- Keep SPEC.md aligned with project/architecture changes, and this file aligned with process changes.
|
||||
|
||||
## 11. Communication Style
|
||||
|
||||
@@ -187,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.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# GEMINI.md
|
||||
|
||||
Read `AGENTS.md` in the repository root for operating instructions before doing anything else.
|
||||
@@ -1,10 +1,37 @@
|
||||
# SPEC.md
|
||||
|
||||
## Project Contract
|
||||
Project contract for WinUtil — what the project is, how it's built, and how it runs. Written for anyone, human or AI, who needs to understand the project itself.
|
||||
|
||||
WinUtil is a Windows PowerShell utility with a WPF interface. The repository is maintained as modular source files, but the released artifact is a single generated `winutil.ps1` script.
|
||||
`AGENTS.md` in the repository root points here for these facts, and separately covers how an agent should behave while working in this repo. This file does not change based on who's reading it.
|
||||
|
||||
The compiled `winutil.ps1` is not source code for editing or review. It is generated by `Compile.ps1` and produced during release automation. All durable changes must be made to the source files that feed the compiler.
|
||||
## Project Context
|
||||
|
||||
WinUtil is a Windows PowerShell utility with a WPF interface. The repository is maintained as modular source, but the distributed artifact is one compiled PowerShell script.
|
||||
|
||||
### Stack
|
||||
|
||||
- Language: Windows PowerShell / PowerShell.
|
||||
- UI: WPF via `xaml/inputXML.xaml`.
|
||||
- Configuration: JSON files under `config/`.
|
||||
- Tests: Pester tests under `pester/`.
|
||||
- Lint: PowerShell Script Analyzer with settings in `lint/PSScriptAnalyser.ps1`.
|
||||
- Docs: Astro + Starlight site under `docs/`, built independently of `Compile.ps1` (its own `package.json`/`node_modules`).
|
||||
- Release artifact: generated root `winutil.ps1`.
|
||||
|
||||
### Repository Layout
|
||||
|
||||
- `Compile.ps1`: build script that creates `winutil.ps1`.
|
||||
- `scripts/start.ps1`: startup/bootstrap segment used at the beginning of the compiled script.
|
||||
- `scripts/main.ps1`: main entrypoint appended at the end of the compiled script.
|
||||
- `functions/public/`: public/UI-facing PowerShell functions.
|
||||
- `functions/private/`: internal helper PowerShell functions.
|
||||
- `config/`: JSON configuration consumed at compile time and embedded into `$sync.configs`.
|
||||
- `xaml/inputXML.xaml`: WPF UI markup embedded into the compiled script.
|
||||
- `tools/autounattend.xml`: unattended setup XML embedded for Windows ISO workflows.
|
||||
- `pester/`: Pester tests for config and function checks.
|
||||
- `lint/PSScriptAnalyser.ps1`: PowerShell Script Analyzer settings.
|
||||
- `docs/`: Astro + Starlight documentation site, with its own `package.json` and build independent of `Compile.ps1`.
|
||||
- `winutil.ps1`: ignored generated build artifact.
|
||||
|
||||
## Goals
|
||||
|
||||
@@ -21,85 +48,65 @@ The compiled `winutil.ps1` is not source code for editing or review. It is gener
|
||||
- The GUI is not a separate packaged desktop application in this repository's normal release path.
|
||||
- Generated files should not be reviewed as source changes.
|
||||
|
||||
## Repository Layout
|
||||
## Build Model
|
||||
|
||||
- `Compile.ps1`: build script that creates `winutil.ps1`.
|
||||
- `scripts/start.ps1`: startup/bootstrap segment used at the beginning of the compiled script.
|
||||
- `scripts/main.ps1`: main entrypoint appended at the end of the compiled script.
|
||||
- `functions/public/`: public/UI-facing PowerShell functions.
|
||||
- `functions/private/`: internal helper PowerShell functions.
|
||||
- `config/`: JSON configuration consumed at compile time and embedded into `$sync.configs`.
|
||||
- `xaml/inputXML.xaml`: WPF UI markup embedded into the compiled script.
|
||||
- `tools/autounattend.xml`: unattended setup XML embedded for Windows ISO workflows.
|
||||
- `pester/`: Pester tests for config and function checks.
|
||||
- `lint/PSScriptAnalyser.ps1`: PowerShell Script Analyzer settings.
|
||||
- `docs/`: Hugo documentation site.
|
||||
- `winutil.ps1`: ignored generated build artifact.
|
||||
`Compile.ps1` combines the repository sources into `winutil.ps1` in this order:
|
||||
|
||||
## Compile Specification
|
||||
1. Read `scripts/start.ps1` and replace `#{replaceme}` with the current `yy.MM.dd` build date.
|
||||
2. Append every file under `functions/` recursively.
|
||||
3. Convert each `config/*.json` file into embedded `$sync.configs` objects.
|
||||
4. Special-case `config/applications.json` so keys receive the `WPFInstall` prefix in compiled config.
|
||||
5. Embed `xaml/inputXML.xaml` into `$inputXML`.
|
||||
6. Embed `tools/autounattend.xml` into `$WinUtilAutounattendXml`.
|
||||
7. Append `scripts/main.ps1`.
|
||||
8. Write the result to root `winutil.ps1`.
|
||||
|
||||
`Compile.ps1` must produce a standalone root `winutil.ps1` by combining all required project files.
|
||||
|
||||
The compile flow is:
|
||||
|
||||
1. Initialize shared state with `$sync = [Hashtable]::Synchronized(@{})` and `$sync.configs = @{}`.
|
||||
2. Read `scripts/start.ps1`.
|
||||
3. Replace `#{replaceme}` in startup code with the current `yy.MM.dd` build date.
|
||||
4. Append raw content from all files under `functions/` recursively.
|
||||
5. For every file in `config/`, parse JSON and embed it into `$sync.configs.<basename>`.
|
||||
6. Special-case `config/applications.json` so application keys are emitted with the `WPFInstall` prefix.
|
||||
7. Embed `xaml/inputXML.xaml` as `$inputXML`.
|
||||
8. Embed `tools/autounattend.xml` as `$WinUtilAutounattendXml`.
|
||||
9. Append `scripts/main.ps1`.
|
||||
10. Write the combined script to `winutil.ps1`.
|
||||
11. If `-Run` is supplied, execute the generated script.
|
||||
|
||||
The generated script must have everything it needs from repository sources embedded or appended by this process.
|
||||
Because the final script is concatenated, code cannot rely on runtime module imports or source-relative dot-sourcing unless the compiled script will also contain the required code/data.
|
||||
|
||||
## Runtime Model
|
||||
|
||||
- WinUtil runs in PowerShell on Windows and uses WPF for the UI.
|
||||
- Shared mutable state is stored in `$sync`, including configs, UI element references, runspace state, selections, and progress.
|
||||
- Long-running operations should use runspaces or existing async patterns so the UI remains responsive.
|
||||
- UI updates from background work must be dispatched back to the WPF UI thread.
|
||||
- Declarative features such as apps, tweaks, presets, DNS providers, and navigation should stay in `config/*.json` unless code is required.
|
||||
- Long-running operations use runspaces or existing async patterns so the UI remains responsive.
|
||||
- UI updates from background work are dispatched back to the WPF UI thread.
|
||||
- Declarative features such as apps, tweaks, presets, DNS providers, and navigation stay in `config/*.json` unless code is required.
|
||||
|
||||
## UI And Event Contract
|
||||
|
||||
- UI layout lives in `xaml/inputXML.xaml`.
|
||||
- Named WPF controls are discovered and stored in `$sync`.
|
||||
- Button/action wiring follows existing naming conventions, where an element named like `WPFThingButton` maps to a function named like `Invoke-WPFThingButton`.
|
||||
- When adding controls, ensure the XAML name, config key, and PowerShell function names line up with the existing event system.
|
||||
- Button/action wiring follows a naming convention: an element named like `WPFThingButton` maps to a function named like `Invoke-WPFThingButton`.
|
||||
|
||||
## Configuration Contract
|
||||
|
||||
Config files must remain valid JSON and compile cleanly through `ConvertFrom-Json`.
|
||||
|
||||
`config/applications.json` defines installable applications. Each application entry should include the fields expected by tests and UI code, such as package manager IDs, category, display content, description, and link.
|
||||
|
||||
`config/tweaks.json` defines Windows tweaks. Registry and service changes should include original values or original states when applicable so undo workflows can restore user systems.
|
||||
|
||||
Preset and navigation files should reference valid config keys. Avoid renaming config keys unless all presets, UI references, docs, and code paths are updated together.
|
||||
- Config files must remain valid JSON and compile cleanly through `ConvertFrom-Json`.
|
||||
- `config/applications.json` defines installable applications; each entry includes the fields expected by tests and UI code, such as package manager IDs, category, display content, description, and link.
|
||||
- `config/tweaks.json` defines Windows tweaks; registry and service changes include original values or original states when applicable so undo workflows can restore user systems.
|
||||
- Preset and navigation files reference valid config keys. Renaming a config key requires updating all presets, UI references, docs, and code paths together.
|
||||
|
||||
## Safety Requirements
|
||||
|
||||
- Registry, service, package manager, Windows Update, AppX removal, and ISO operations can affect the host system. Changes must be explicit, reversible where practical, and consistent with existing logging and confirmation patterns.
|
||||
- Tweak changes should include undo metadata when the schema supports it.
|
||||
- Package installation should prefer existing WinGet and Chocolatey helper functions.
|
||||
- ISO workflows must not modify the user's original ISO file; they should work on copied/mounted content following existing patterns.
|
||||
- Registry, service, package manager, Windows Update, AppX removal, and ISO operations affect the host system and are treated as high-risk.
|
||||
- Tweak changes include undo metadata when the schema supports it, so changes stay reversible.
|
||||
- ISO workflows never modify the user's original ISO file; they work on copied/mounted content.
|
||||
|
||||
## Docs Site (Astro)
|
||||
|
||||
- `docs/` is an Astro + Starlight site, independent of `Compile.ps1`'s build (its own `package.json`/`node_modules`, deployed via the `docs.yaml` GitHub Actions workflow to GitHub Pages).
|
||||
- Pages live under `docs/src/content/docs/` (`.mdx`), organized into `guides/`, `code-reference/`, plus top-level pages like `faq.mdx`, `knownissues.mdx`, `contributing.mdx`, `index.mdx`.
|
||||
- `docs/src/content/docs/code-reference/tweaks/` and `.../features/` are auto-generated by `tools/devdocs-generator.ps1` from `config/tweaks.json`/`config/feature.json` and the relevant PowerShell function files. Other pages under `code-reference/` (e.g. `architecture.mdx`) are hand-written and untouched by the generator.
|
||||
- Sidebar entries in `docs/astro.config.mjs` must match actual page slugs under `docs/src/content/docs/`.
|
||||
- `docs/public/` is tracked source for static assets (favicons, etc.), not generated output. Generated/ignored paths are listed in `docs/.gitignore` (`dist/`, `.astro/`, `node_modules/`, local env files).
|
||||
- `docs/Dockerfile` and `docs/docker-compose.yml` (service `winutil-astro`) containerize the site's npm tooling; see AGENTS.md's Dependency Installs, Builds, And Dev Servers for why and how agents must use them instead of running npm on the host.
|
||||
|
||||
## Testing And CI
|
||||
|
||||
Expected validation for source changes:
|
||||
|
||||
- `.\Compile.ps1` verifies the compiler can generate `winutil.ps1`.
|
||||
- `.\Compile.ps1 -Run` compiles and launches the generated utility for manual GUI verification.
|
||||
- `Install-Module -Name Pester -RequiredVersion 5.8.0 -Scope CurrentUser -Force -SkipPublisherCheck` installs the supported Pester version.
|
||||
- `Import-Module Pester -RequiredVersion 5.8.0 -Force; Invoke-Pester -Path 'pester/*.Tests.ps1' -Output Detailed -CI` runs the Pester suite.
|
||||
- GitHub Actions also runs a compile check and PowerShell Script Analyzer with `lint/PSScriptAnalyser.ps1`.
|
||||
|
||||
The generated `winutil.ps1` may appear locally after compile. It remains ignored build output and must not be committed.
|
||||
- Pester 5.8.0 runs the suite under `pester/*.Tests.ps1`. GitHub Actions (`unittests.yaml`) installs Pester 5.8.0 fresh and runs with `-CI`, which produces `testResults.xml` and exits non-zero on failure.
|
||||
- GitHub Actions also runs PowerShell Script Analyzer with `lint/PSScriptAnalyser.ps1` on every push.
|
||||
- The generated `winutil.ps1` may appear locally after compile. It remains ignored build output (see root `.gitignore`) and must not be committed.
|
||||
|
||||
## Release Artifact
|
||||
|
||||
GitHub Actions is responsible for producing the release `winutil.ps1` from repository sources. A release should be considered valid only if the generated script came from the compile process, not from direct manual edits to `winutil.ps1`.
|
||||
GitHub Actions is responsible for producing the release `winutil.ps1` from repository sources. A release is considered valid only if the generated script came from the compile process, not from direct manual edits to `winutil.ps1`.
|
||||
|
||||
@@ -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",
|
||||
@@ -1510,6 +1599,15 @@
|
||||
"winget": "TeamSpeakSystems.TeamSpeakClient",
|
||||
"foss": false
|
||||
},
|
||||
"teamspeak6": {
|
||||
"category": "Communications",
|
||||
"choco": "na",
|
||||
"content": "TeamSpeak 6",
|
||||
"description": "TEAMSPEAK. YOUR TEAM. YOUR RULES. Use crystal clear sound to communicate with your teammates cross-platform with military-grade security, lag-free performance & unparalleled reliability and uptime.",
|
||||
"link": "https://www.teamspeak.com/",
|
||||
"winget": "TeamSpeakSystems.TeamSpeakClient.Beta.6",
|
||||
"foss": false
|
||||
},
|
||||
"telegram": {
|
||||
"category": "Communications",
|
||||
"choco": "telegram",
|
||||
@@ -1609,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,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
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.astro
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
@@ -16,6 +16,8 @@ pnpm-debug.log*
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
|
||||
EXPOSE 4321
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
+29
-17
@@ -1,26 +1,24 @@
|
||||
# Starlight Starter Kit: Basics
|
||||
# WinUtil Docs
|
||||
|
||||
[](https://starlight.astro.build)
|
||||
|
||||
```
|
||||
npm create astro@latest -- --template starlight
|
||||
```
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
Documentation site for [WinUtil](https://github.com/ChrisTitusTech/winutil), built with [Astro](https://astro.build) and [Starlight](https://starlight.astro.build). Served at [winutil.christitus.com](https://winutil.christitus.com/).
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro + Starlight project, you'll see the following folders and files:
|
||||
|
||||
```
|
||||
.
|
||||
├── public/
|
||||
├── src/
|
||||
│ ├── assets/
|
||||
│ ├── components/
|
||||
│ ├── content/
|
||||
│ │ └── docs/
|
||||
│ ├── styles/
|
||||
│ └── content.config.ts
|
||||
├── astro.config.mjs
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
@@ -33,17 +31,31 @@ Static assets, like favicons, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
All commands run in a Docker container — there's no need to install Node or npm dependencies on your host. This is deliberate, not just convenience: npm/pnpm/yarn have seen a steady stream of supply-chain attacks (malicious `postinstall`/`preinstall` scripts, credential-stealing packages), so `npm install` and friends never run directly on a contributor's machine here. Note the container still has read-write access to this `docs/` directory (it's bind-mounted for live reload), so this only contains a compromised package to the project folder plus the container itself — it doesn't reach the rest of your host (SSH keys, other repos, cloud credentials elsewhere on disk). Don't keep real secrets in `docs/` as a result.
|
||||
|
||||
[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 |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `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 compose build` | Builds the dev image (needed after Dockerfile or dependency changes) |
|
||||
| `docker compose up winutil-astro` | Starts local dev server at `localhost:4321` |
|
||||
| `docker compose run --rm winutil-astro npm run build` | Build the production site to `./dist/` |
|
||||
| `docker compose run --rm --service-ports winutil-astro npm run preview -- --host 0.0.0.0` | Preview the build locally, before deploying |
|
||||
| `docker compose run --rm winutil-astro npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `docker compose down` | Stop and remove the dev container |
|
||||
|
||||
Source files are bind-mounted into the container, so edits on the host are picked up immediately by the dev server — no rebuild needed for normal content or code changes. After changing `package.json`, `package-lock.json`, or the `Dockerfile`, rebuild the image *and* drop the `node_modules` volume, since Docker only seeds a named volume from the image the first time it's created — a plain rebuild leaves the old `node_modules` in place:
|
||||
|
||||
```sh
|
||||
docker compose build
|
||||
docker compose down -v
|
||||
docker compose up winutil-astro
|
||||
```
|
||||
|
||||
The first `docker compose up` (or any command before an image exists) builds the image and runs `npm install` from scratch, which can take a few minutes. Subsequent runs reuse the cached image and start almost immediately.
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Check out [Starlight’s docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
|
||||
Check out [Starlight's docs](https://starlight.astro.build/), read [the Astro documentation](https://docs.astro.build), or jump into the [Astro Discord server](https://astro.build/chat).
|
||||
|
||||
@@ -15,6 +15,12 @@ export default defineConfig({
|
||||
replacesTitle: true,
|
||||
},
|
||||
favicon: '/favicon.svg',
|
||||
head: [
|
||||
{ tag: 'meta', attrs: { property: 'og:image', content: 'https://winutil.christitus.com/social-preview.png' } },
|
||||
{ tag: 'meta', attrs: { property: 'og:image:width', content: '1200' } },
|
||||
{ tag: 'meta', attrs: { property: 'og:image:height', content: '630' } },
|
||||
{ tag: 'meta', attrs: { name: 'twitter:image', content: 'https://winutil.christitus.com/social-preview.png' } },
|
||||
],
|
||||
social: [
|
||||
{ icon: 'github', label: 'GitHub', href: 'https://github.com/ChrisTitusTech/winutil' },
|
||||
{ icon: 'discord', label: 'Discord', href: 'https://discord.gg/RUbZUZyByQ' },
|
||||
|
||||
@@ -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:
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ Use the Applications tab to install, upgrade, uninstall, and review supported ap
|
||||
|
||||

|
||||
</TabItem>
|
||||
<TabItem label="Category Filters">
|
||||
* Click a category chip at the top of the tab to show only that category. The chip stays highlighted while its filter is active.
|
||||
* Hold `Ctrl` and click to add more categories to the filter, or to remove one again.
|
||||
* Click `All`, or click the highlighted category again while it is the only one selected, to clear the filter.
|
||||
* Categories with matching results open while a filter is active. Ones that filtering opened for you go back to collapsed when you clear it, ones you opened yourself stay open.
|
||||
</TabItem>
|
||||
<TabItem label="Selected Apps Counter">
|
||||
* The `Selected Apps` counter in the sidebar shows how many applications are currently selected.
|
||||
* Use it to keep track of your selection as you browse categories.
|
||||
@@ -57,7 +63,7 @@ Use the Applications tab to install, upgrade, uninstall, and review supported ap
|
||||
</Tabs>
|
||||
|
||||
:::tip
|
||||
If you have trouble finding an application, press `Ctrl + F` and search for its name. The list filters as you type.
|
||||
If you have trouble finding an application, press `Ctrl + F` and search for its name. The list filters as you type. The search and the category chips work together, so you can search inside the categories you picked.
|
||||
:::
|
||||
|
||||
:::note
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 1–2 GB per removed edition
|
||||
- **Pin the selected edition during setup** — writes setup metadata so OEM firmware keys for a different edition do not force the installer down the wrong product-key path
|
||||
- **Clean the component store** — runs DISM cleanup to reclaim another 300–800 MB
|
||||
|
||||
**Privacy & Telemetry Tweaks:**
|
||||
- **Disable telemetry** — advertising ID, tailored experiences, input personalization, speech online privacy
|
||||
@@ -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 **10–30 minutes** depending on disk speed. The WIM dismount near the end is the slowest part, so do not close WinUtil while it is running.
|
||||
|
||||
:::note
|
||||
The resulting ISO is close to the size of the source ISO. WinUtil does not remove the unused editions from `install.wim`; it selects your edition through `sources\ei.cfg` and `autounattend.xml` so Windows Setup installs the right one.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
### Step 4 — Export Your Result
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -241,6 +241,7 @@ function Invoke-WinUtilISOWriteUSB {
|
||||
$wimSizeMB = [math]::Round((Get-Item $installWim).Length / 1MB)
|
||||
if ($wimSizeMB -gt 3800) {
|
||||
Log "install.wim is $wimSizeMB MB - splitting for FAT32 compatibility... This will take several minutes."
|
||||
Set-ItemProperty -LiteralPath $installWim -Name IsReadOnly -Value $false
|
||||
$splitDest = Join-Path $usbDrive "sources\install.swm"
|
||||
New-Item -ItemType Directory -Path (Split-Path $splitDest) -Force
|
||||
Split-WindowsImage -ImagePath $installWim -SplitImagePath $splitDest -FileSize 3800 -CheckIntegrity
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,20 +70,24 @@ 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) {
|
||||
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 $dns.DohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true -ErrorAction Stop
|
||||
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 $dns.DohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true -ErrorAction Stop
|
||||
Add-DnsClientDohServerAddress -ServerAddress $ip -DohTemplate $dohTemplate -AllowFallbackToUdp $false -AutoUpgrade $true -ErrorAction Stop
|
||||
}
|
||||
|
||||
$leaf = if ($ip.Contains(':')) { 'Doh6' } else { 'Doh' }
|
||||
@@ -88,16 +98,31 @@ function Set-WinUtilDNS {
|
||||
}
|
||||
New-ItemProperty -Path $regPath -Name "DohFlags" -Value 1 -PropertyType QWord -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
} catch {
|
||||
if ($dns.DohOnly) {
|
||||
throw
|
||||
}
|
||||
|
||||
Write-Warning "DNS over HTTPS setup for provider $DNSProvider failed; continuing with plain DNS."
|
||||
Write-WinUtilLog -Level "WARN" -Component "DNS" -Message "DNS over HTTPS setup for provider $DNSProvider failed; continuing with plain DNS: $($psitem.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-WinUtilLog -Component "DNS" -Message "Setting IPv4 DNS on adapter $($Adapter.Name) (ifIndex: $($Adapter.ifIndex)) to $($dns.Primary), $($dns.Secondary)."
|
||||
Set-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -ServerAddresses $ipv4Addresses -ErrorAction Stop
|
||||
Write-WinUtilLog -Component "DNS" -Message "Setting IPv6 DNS on adapter $($Adapter.Name) (ifIndex: $($Adapter.ifIndex)) to $($dns.Primary6), $($dns.Secondary6)."
|
||||
Set-DnsClientServerAddress -InterfaceIndex $Adapter.ifIndex -ServerAddresses $ipv6Addresses -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
if ($DNSProvider -ne "DHCP" -and $dohSupported -and $dns.DohTemplate) {
|
||||
Clear-DnsClientCache
|
||||
}
|
||||
Write-WinUtilLog -Component "DNS" -Message "DNS provider change completed: $DNSProvider"
|
||||
return $true
|
||||
} catch {
|
||||
Write-Warning "DNS provider $DNSProvider was not completed because an error occurred."
|
||||
Write-Warning $psitem.Exception.Message
|
||||
Write-WinUtilLog -Level "ERROR" -Component "DNS" -Message "DNS provider $DNSProvider was not completed: $($psitem.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ function Set-WinUtilRegistry {
|
||||
)
|
||||
|
||||
try {
|
||||
if(!(Test-Path 'HKU:\')) {New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS}
|
||||
if(!(Test-Path 'HKU:\')) {New-PSDrive -PSProvider Registry -Name HKU -Root HKEY_USERS | Out-Null}
|
||||
|
||||
If (!(Test-Path $Path)) {
|
||||
Write-Host "$Path was not found. Creating..."
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
function Set-WinUtilRegistryComboState {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Applies and verifies a config-defined registry combo-box state.
|
||||
|
||||
.PARAMETER Registry
|
||||
Registry settings containing a value mapping for each supported state.
|
||||
|
||||
.PARAMETER State
|
||||
The state name to apply.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
$Registry,
|
||||
|
||||
[Parameter(Mandatory)]
|
||||
[string]$State
|
||||
)
|
||||
|
||||
if ($Registry[0].Values.PSObject.Properties.Name -notcontains $State) {
|
||||
throw "Unknown registry state '$State'."
|
||||
}
|
||||
|
||||
# Preserve exact prior values so a partial update can be rolled back.
|
||||
$previousValues = foreach ($setting in @($Registry)) {
|
||||
$currentValue = Get-WinUtilRegistryComboValue -Setting $setting
|
||||
[pscustomobject]@{ Setting = $setting; Exists = $currentValue.Exists; Value = $currentValue.Value }
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($setting in @($Registry)) {
|
||||
$configuredValue = $setting.Values.PSObject.Properties[$State].Value
|
||||
$previousValue = $previousValues | Where-Object Setting -EQ $setting
|
||||
if ($configuredValue -ne "<RemoveEntry>" -or $previousValue.Exists) {
|
||||
Set-WinUtilRegistry -Name $setting.Name -Path $setting.Path -Type $setting.Type -Value $configuredValue
|
||||
}
|
||||
}
|
||||
|
||||
# Set-WinUtilRegistry reports write errors without throwing, so verify each result explicitly.
|
||||
foreach ($setting in @($Registry)) {
|
||||
$configuredValue = $setting.Values.PSObject.Properties[$State].Value
|
||||
$currentValue = Get-WinUtilRegistryComboValue -Setting $setting
|
||||
$writeMatches = if ($configuredValue -eq "<RemoveEntry>") {
|
||||
-not $currentValue.Exists
|
||||
} else {
|
||||
$currentValue.Exists -and [string]$currentValue.Value -eq [string]$configuredValue
|
||||
}
|
||||
if (-not $writeMatches) {
|
||||
throw "The registry values did not match the requested state."
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$applyError = $_.Exception.Message
|
||||
if ([string]::IsNullOrWhiteSpace($applyError)) {
|
||||
$applyError = "The registry values did not match the requested state."
|
||||
}
|
||||
$rollbackFailed = $false
|
||||
foreach ($previousValue in $previousValues) {
|
||||
try {
|
||||
$currentValue = Get-WinUtilRegistryComboValue -Setting $previousValue.Setting
|
||||
if ($previousValue.Exists -or $currentValue.Exists) {
|
||||
$rollbackValue = if ($previousValue.Exists) { $previousValue.Value } else { "<RemoveEntry>" }
|
||||
Set-WinUtilRegistry -Name $previousValue.Setting.Name -Path $previousValue.Setting.Path -Type $previousValue.Setting.Type -Value $rollbackValue
|
||||
}
|
||||
$restoredValue = Get-WinUtilRegistryComboValue -Setting $previousValue.Setting
|
||||
if ($restoredValue.Exists -ne $previousValue.Exists -or ($restoredValue.Exists -and [string]$restoredValue.Value -ne [string]$previousValue.Value)) {
|
||||
$rollbackFailed = $true
|
||||
}
|
||||
} catch {
|
||||
$rollbackFailed = $true
|
||||
}
|
||||
}
|
||||
if ($rollbackFailed) {
|
||||
throw "Unable to apply registry state '$State': $applyError. The previous registry state could not be restored."
|
||||
}
|
||||
throw "Unable to apply registry state '$State': $applyError"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -110,5 +110,5 @@ function Invoke-WPFInstall {
|
||||
}
|
||||
$sync.ProcessRunning = $False
|
||||
}
|
||||
}
|
||||
} | Out-Null
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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\)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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*"
|
||||
}
|
||||
}
|
||||
@@ -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" {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,18 @@ Describe "Win11 Creator setup media" {
|
||||
$confirmationIndex | Should -BeGreaterThan $guardIndex
|
||||
}
|
||||
|
||||
It "clears install.wim read-only attribute before FAT32 splitting" {
|
||||
$splitGuardIndex = $script:writeUsbFunction.IndexOf('$wimSizeMB -gt 3800')
|
||||
$readOnlyResetIndex = $script:writeUsbFunction.IndexOf(
|
||||
'Set-ItemProperty -LiteralPath $installWim -Name IsReadOnly -Value $false'
|
||||
)
|
||||
$splitCommandIndex = $script:writeUsbFunction.IndexOf('Split-WindowsImage')
|
||||
|
||||
$splitGuardIndex | Should -BeGreaterThan -1
|
||||
$readOnlyResetIndex | Should -BeGreaterThan $splitGuardIndex
|
||||
$splitCommandIndex | Should -BeGreaterThan $readOnlyResetIndex
|
||||
}
|
||||
|
||||
It "maps Windows edition names to setup edition IDs" {
|
||||
. ([scriptblock]::Create($script:editionIdFunction))
|
||||
|
||||
|
||||
@@ -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*"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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=""/>
|
||||
<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=""
|
||||
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}">
|
||||
|
||||
Reference in New Issue
Block a user