mirror of
https://github.com/ChrisTitusTech/winutil.git
synced 2026-08-10 10:01:16 +10:00
Compare commits
15
Commits
f7c072341d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc3e1eec2 | ||
|
|
53fc260cc0 | ||
|
|
bc607b6c91 | ||
|
|
7f18b4fd60 | ||
|
|
32ab9f0ab0 | ||
|
|
9fdadd1c8f | ||
|
|
6de45a38b9 | ||
|
|
8860caf357 | ||
|
|
8fb078da4c | ||
|
|
95e3a54405 | ||
|
|
c817d3329a | ||
|
|
19f938e876 | ||
|
|
d64bb18717 | ||
|
|
5ad168e0c4 | ||
|
|
8d3adb599f |
@@ -163,6 +163,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.
|
- Import Pester 5.8.0 before running tests so `Invoke-Pester -Output Detailed -CI` does not resolve to Windows' inbox Pester 3.4.0.
|
||||||
- Keep package install/uninstall process launches simple unless explicitly requested; do not add a separate stdout/stderr process logging helper for winget or Chocolatey.
|
- Keep package install/uninstall process launches simple unless explicitly requested; do not add a separate stdout/stderr process logging helper for winget or Chocolatey.
|
||||||
- When the active log file is owned by `Start-Transcript`, do not call `Add-Content` against that file; write to host output so the transcript captures the line in the same log file without recording a terminating-error diagnostic.
|
- When the active log file is owned by `Start-Transcript`, do not call `Add-Content` against that file; write to host output so the transcript captures the line in the same log file without recording a terminating-error diagnostic.
|
||||||
|
- Keep UI helpers such as `Invoke-WPFUIThread` and `Set-WinUtilTweaksProgressIndicator` safe to call without a window; the `-Preset` and `-Config` paths run the workflows before the form is created and before PresentationCore is loaded.
|
||||||
- Log install/uninstall package names and package-manager IDs before queuing background runspace work; do not rely on runspace host output for the package identity.
|
- Log install/uninstall package names and package-manager IDs before queuing background runspace work; do not rely on runspace host output for the package identity.
|
||||||
- For Win11 Creator, start each new ISO modification in a fresh `WinUtil_Win11ISO_*` temp directory; existing-work detection is only for resuming/exporting already modified media.
|
- For Win11 Creator, start each new ISO modification in a fresh `WinUtil_Win11ISO_*` temp directory; existing-work detection is only for resuming/exporting already modified media.
|
||||||
- For Win11 Creator driver injection, keep offline WIM servicing to one mount, one `/Add-Driver`, and one commit; do not export editions or run unrelated WIM cleanup, and reject damaged metadata before ISO export.
|
- For Win11 Creator driver injection, 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.
|
||||||
|
|||||||
@@ -125,6 +125,15 @@
|
|||||||
"winget": "Brave.Brave",
|
"winget": "Brave.Brave",
|
||||||
"foss": true
|
"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": {
|
"bulkcrapuninstaller": {
|
||||||
"category": "Utilities",
|
"category": "Utilities",
|
||||||
"choco": "bulk-crap-uninstaller",
|
"choco": "bulk-crap-uninstaller",
|
||||||
@@ -323,6 +332,15 @@
|
|||||||
"winget": "SpikeHD.Dorion",
|
"winget": "SpikeHD.Dorion",
|
||||||
"foss": true
|
"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": {
|
"dotnet6": {
|
||||||
"category": "Microsoft Tools",
|
"category": "Microsoft Tools",
|
||||||
"choco": "dotnet-6.0-runtime",
|
"choco": "dotnet-6.0-runtime",
|
||||||
@@ -458,6 +476,24 @@
|
|||||||
"winget": "flux.flux",
|
"winget": "flux.flux",
|
||||||
"foss": false
|
"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": {
|
"foxpdfreader": {
|
||||||
"category": "Document",
|
"category": "Document",
|
||||||
"choco": "foxitreader",
|
"choco": "foxitreader",
|
||||||
@@ -494,6 +530,24 @@
|
|||||||
"winget": "Git.Git",
|
"winget": "Git.Git",
|
||||||
"foss": true
|
"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": {
|
"githubdesktop": {
|
||||||
"category": "Development",
|
"category": "Development",
|
||||||
"choco": "git;github-desktop",
|
"choco": "git;github-desktop",
|
||||||
@@ -791,6 +845,14 @@
|
|||||||
"winget": "mpc-qt.mpc-qt",
|
"winget": "mpc-qt.mpc-qt",
|
||||||
"foss": true
|
"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": {
|
"matrix": {
|
||||||
"category": "Communications",
|
"category": "Communications",
|
||||||
"choco": "element-desktop",
|
"choco": "element-desktop",
|
||||||
@@ -1186,6 +1248,15 @@
|
|||||||
"winget": "JanDeDobbeleer.OhMyPosh",
|
"winget": "JanDeDobbeleer.OhMyPosh",
|
||||||
"foss": true
|
"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": {
|
"powershell": {
|
||||||
"category": "Microsoft Tools",
|
"category": "Microsoft Tools",
|
||||||
"choco": "powershell-core",
|
"choco": "powershell-core",
|
||||||
@@ -1438,6 +1509,15 @@
|
|||||||
"winget": "StartIsBack.StartAllBack",
|
"winget": "StartIsBack.StartAllBack",
|
||||||
"foss": false
|
"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": {
|
"steam": {
|
||||||
"category": "Games",
|
"category": "Games",
|
||||||
"choco": "steam-client",
|
"choco": "steam-client",
|
||||||
@@ -1447,6 +1527,15 @@
|
|||||||
"winget": "Valve.Steam",
|
"winget": "Valve.Steam",
|
||||||
"foss": false
|
"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": {
|
"sublimetext": {
|
||||||
"category": "Development",
|
"category": "Development",
|
||||||
"choco": "sublimetext4",
|
"choco": "sublimetext4",
|
||||||
@@ -1618,6 +1707,15 @@
|
|||||||
"winget": "Unity.UnityHub",
|
"winget": "Unity.UnityHub",
|
||||||
"foss": false
|
"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": {
|
"everything": {
|
||||||
"category": "Utilities",
|
"category": "Utilities",
|
||||||
"choco": "everything",
|
"choco": "everything",
|
||||||
|
|||||||
+21
-9
@@ -1459,28 +1459,40 @@
|
|||||||
],
|
],
|
||||||
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/scrollbars"
|
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/scrollbars"
|
||||||
},
|
},
|
||||||
"WPFToggleMultiplaneOverlay": {
|
"WPFMultiplaneOverlay": {
|
||||||
"Content": "Multiplane Overlay",
|
"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",
|
"category": "Customize Preferences",
|
||||||
"panel": "2",
|
"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": [
|
"registry": [
|
||||||
{
|
{
|
||||||
"Path": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\Dwm",
|
"Path": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\Dwm",
|
||||||
"Name": "OverlayTestMode",
|
"Name": "OverlayTestMode",
|
||||||
"Value": "0",
|
|
||||||
"Type": "DWord",
|
"Type": "DWord",
|
||||||
"OriginalValue": "5",
|
"DefaultValue": "0",
|
||||||
"DefaultState": "true"
|
"Values": {
|
||||||
|
"Enabled": "<RemoveEntry>",
|
||||||
|
"Disabled (Compatibility)": "5",
|
||||||
|
"Fully Disabled": "5"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Path": "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\GraphicsDrivers",
|
"Path": "HKLM:\\SYSTEM\\CurrentControlSet\\Control\\GraphicsDrivers",
|
||||||
"Name": "DisableOverlays",
|
"Name": "DisableOverlays",
|
||||||
"Value": "0",
|
|
||||||
"Type": "DWord",
|
"Type": "DWord",
|
||||||
"OriginalValue": "1",
|
"DefaultValue": "0",
|
||||||
"DefaultState": "true"
|
"Values": {
|
||||||
|
"Enabled": "<RemoveEntry>",
|
||||||
|
"Disabled (Compatibility)": "<RemoveEntry>",
|
||||||
|
"Fully Disabled": "1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/multiplaneoverlay"
|
"link": "https://winutil.christitus.com/code-reference/tweaks/customize-preferences/multiplaneoverlay"
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export default defineConfig({
|
|||||||
replacesTitle: true,
|
replacesTitle: true,
|
||||||
},
|
},
|
||||||
favicon: '/favicon.svg',
|
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: [
|
social: [
|
||||||
{ icon: 'github', label: 'GitHub', href: 'https://github.com/ChrisTitusTech/winutil' },
|
{ icon: 'github', label: 'GitHub', href: 'https://github.com/ChrisTitusTech/winutil' },
|
||||||
{ icon: 'discord', label: 'Discord', href: 'https://discord.gg/RUbZUZyByQ' },
|
{ icon: 'discord', label: 'Discord', href: 'https://discord.gg/RUbZUZyByQ' },
|
||||||
|
|||||||
@@ -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 |
@@ -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)
|
- Applies offline registry tweaks (hardware bypass, privacy, telemetry, OOBE)
|
||||||
- Deletes telemetry scheduled task definitions
|
- Deletes telemetry scheduled task definitions
|
||||||
- Pre-stages setup scripts from autounattend.xml
|
- Pre-stages setup scripts from autounattend.xml
|
||||||
- Removes unused Windows editions
|
|
||||||
- Cleans component store via DISM
|
|
||||||
|
|
||||||
### Win11 Creator Data Flow
|
### Win11 Creator Data Flow
|
||||||
|
|
||||||
@@ -181,16 +179,14 @@ Invoke-WinUtilISOModify (runs in background runspace)
|
|||||||
│ ├─ Delete telemetry scheduled task files
|
│ ├─ Delete telemetry scheduled task files
|
||||||
│ ├─ Pre-stage setup scripts from autounattend.xml to C:\Windows\Setup\Scripts\
|
│ ├─ Pre-stage setup scripts from autounattend.xml to C:\Windows\Setup\Scripts\
|
||||||
│ └─ Unload registry hives
|
│ └─ Unload registry hives
|
||||||
├─ DISM /Cleanup-Image /StartComponentCleanup /ResetBase (saves 300-800 MB)
|
|
||||||
├─ Dismount and save the modified install.wim (~10+ minutes, slowest step)
|
├─ 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
|
├─ Dismount source ISO
|
||||||
└─ Report completion, enable export options
|
└─ Report completion, enable export options
|
||||||
↓
|
↓
|
||||||
Invoke-WinUtilISOExport (user chooses output)
|
Invoke-WinUtilISOExport (user chooses output)
|
||||||
├─ Option 1: Save as ISO
|
├─ Option 1: Save as ISO
|
||||||
│ ├─ Build bootable ISO via oscdimg.exe (BIOS/UEFI dual-boot)
|
│ ├─ 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
|
└─ Option 2: Write to USB
|
||||||
├─ Format USB as GPT
|
├─ Format USB as GPT
|
||||||
@@ -272,7 +268,7 @@ The `Invoke-WinUtilISOScript` function applies **50+ offline registry tweaks**:
|
|||||||
|
|
||||||
- **Temporary working directory**: ~10-15 GB
|
- **Temporary working directory**: ~10-15 GB
|
||||||
- **Original ISO**: 4-6 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
|
- **Total needed**: ~25 GB for safe operation
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
@@ -389,6 +385,8 @@ Update UI
|
|||||||
- `Description`: What it does
|
- `Description`: What it does
|
||||||
- `category`: Essential/Advanced/Customize
|
- `category`: Essential/Advanced/Customize
|
||||||
- `registry`: Registry changes to make
|
- `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
|
- `service`: Services to change
|
||||||
- `OriginalValue/State`: For undo functionality
|
- `OriginalValue/State`: For undo functionality
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ Use the Applications tab to install, upgrade, uninstall, and review supported ap
|
|||||||
|
|
||||||

|

|
||||||
</TabItem>
|
</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">
|
<TabItem label="Selected Apps Counter">
|
||||||
* The `Selected Apps` counter in the sidebar shows how many applications are currently selected.
|
* 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.
|
* 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>
|
</Tabs>
|
||||||
|
|
||||||
:::tip
|
:::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
|
:::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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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
|
- **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 BitLocker and device encryption** — removes startup overhead
|
||||||
- **Disable Chat icon** — removes chat taskbar button
|
- **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
|
- **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:**
|
**Privacy & Telemetry Tweaks:**
|
||||||
- **Disable telemetry** — advertising ID, tailored experiences, input personalization, speech online privacy
|
- **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.
|
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
|
### Step 4 — Export Your Result
|
||||||
|
|||||||
@@ -27,6 +27,38 @@ hero:
|
|||||||
link: https://github.com/ChrisTitusTech/winutil
|
link: https://github.com/ChrisTitusTech/winutil
|
||||||
icon: external
|
icon: external
|
||||||
variant: secondary
|
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';
|
import { Icon, Code } from '@astrojs/starlight/components';
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
function Find-AppsByNameOrDescription {
|
function Find-AppsByNameOrDescription {
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.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
|
.DESCRIPTION
|
||||||
Filters application entries by name or description using literal string matching.
|
Search text and categories are independent filters that both have to pass. An entry is
|
||||||
Respects collapsed category state and handles null $sync gracefully.
|
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
|
.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
|
.PARAMETER Categories
|
||||||
When provided, only applications in this exact category are shown.
|
The categories to show. An empty or missing array shows all of them.
|
||||||
|
|
||||||
.NOTES
|
.NOTES
|
||||||
- Uses module-scope $sync (no parameter needed; inherits from caller's scope)
|
- 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
|
- Safely handles missing hashtable keys and null UI elements
|
||||||
- Protected by try/catch to prevent UI thread crashes
|
- Protected by try/catch to prevent UI thread crashes
|
||||||
#>
|
#>
|
||||||
@@ -24,7 +29,7 @@ function Find-AppsByNameOrDescription {
|
|||||||
[string]$SearchString = "",
|
[string]$SearchString = "",
|
||||||
|
|
||||||
[Parameter(Mandatory = $false)]
|
[Parameter(Mandatory = $false)]
|
||||||
[string]$Category = ""
|
[string[]]$Categories = @()
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate that $sync exists and has required structure
|
# Validate that $sync exists and has required structure
|
||||||
@@ -43,21 +48,34 @@ function Find-AppsByNameOrDescription {
|
|||||||
return
|
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 {
|
try {
|
||||||
# Reset the visibility if the search string is empty or the search is cleared
|
$activeCategories = @($Categories | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||||
if ([string]::IsNullOrWhiteSpace($SearchString) -and [string]::IsNullOrWhiteSpace($Category)) {
|
$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 {
|
$sync.ItemsControl.Items | ForEach-Object {
|
||||||
# Each item is a StackPanel container
|
|
||||||
$_.Visibility = [Windows.Visibility]::Visible
|
$_.Visibility = [Windows.Visibility]::Visible
|
||||||
|
|
||||||
if ($_.Children.Count -ge 2) {
|
if ($_.Children.Count -ge 2) {
|
||||||
$categoryLabel = $_.Children[0]
|
$categoryLabel = $_.Children[0]
|
||||||
$wrapPanel = $_.Children[1]
|
$wrapPanel = $_.Children[1]
|
||||||
|
|
||||||
# Keep category label visible
|
|
||||||
$categoryLabel.Visibility = [Windows.Visibility]::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 "+*") {
|
if ($categoryLabel.Content -like "+*") {
|
||||||
$wrapPanel.Visibility = [Windows.Visibility]::Collapsed
|
$wrapPanel.Visibility = [Windows.Visibility]::Collapsed
|
||||||
}
|
}
|
||||||
@@ -65,7 +83,6 @@ function Find-AppsByNameOrDescription {
|
|||||||
$wrapPanel.Visibility = [Windows.Visibility]::Visible
|
$wrapPanel.Visibility = [Windows.Visibility]::Visible
|
||||||
}
|
}
|
||||||
|
|
||||||
# Show all apps within the category
|
|
||||||
$wrapPanel.Children | ForEach-Object {
|
$wrapPanel.Children | ForEach-Object {
|
||||||
$_.Visibility = [Windows.Visibility]::Visible
|
$_.Visibility = [Windows.Visibility]::Visible
|
||||||
}
|
}
|
||||||
@@ -77,7 +94,6 @@ function Find-AppsByNameOrDescription {
|
|||||||
# Escape wildcard characters for literal matching
|
# Escape wildcard characters for literal matching
|
||||||
$escapedSearchString = [System.Management.Automation.WildcardPattern]::Escape($SearchString)
|
$escapedSearchString = [System.Management.Automation.WildcardPattern]::Escape($SearchString)
|
||||||
|
|
||||||
# Perform search
|
|
||||||
$sync.ItemsControl.Items | ForEach-Object {
|
$sync.ItemsControl.Items | ForEach-Object {
|
||||||
# Each item is a StackPanel container with Children[0] = label, Children[1] = WrapPanel
|
# Each item is a StackPanel container with Children[0] = label, Children[1] = WrapPanel
|
||||||
if ($_.Children.Count -ge 2) {
|
if ($_.Children.Count -ge 2) {
|
||||||
@@ -85,12 +101,9 @@ function Find-AppsByNameOrDescription {
|
|||||||
$wrapPanel = $_.Children[1]
|
$wrapPanel = $_.Children[1]
|
||||||
$categoryHasMatch = $false
|
$categoryHasMatch = $false
|
||||||
|
|
||||||
# Keep category label visible
|
|
||||||
$categoryLabel.Visibility = [Windows.Visibility]::Visible
|
$categoryLabel.Visibility = [Windows.Visibility]::Visible
|
||||||
|
|
||||||
# Search through apps in this category
|
|
||||||
foreach ($appControl in $wrapPanel.Children) {
|
foreach ($appControl in $wrapPanel.Children) {
|
||||||
# Safely retrieve app entry from hashtable
|
|
||||||
$appTag = $appControl.Tag
|
$appTag = $appControl.Tag
|
||||||
$appEntry = $null
|
$appEntry = $null
|
||||||
|
|
||||||
@@ -98,14 +111,13 @@ function Find-AppsByNameOrDescription {
|
|||||||
$appEntry = $sync.configs.applicationsHashtable[$appTag]
|
$appEntry = $sync.configs.applicationsHashtable[$appTag]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check if app matches search criteria
|
|
||||||
if ($null -ne $appEntry) {
|
if ($null -ne $appEntry) {
|
||||||
$categoryMatch = -not [string]::IsNullOrWhiteSpace($Category) -and $appEntry.Category -eq $Category
|
$categoryMatch = -not $hasCategories -or $activeCategories -contains $appEntry.Category
|
||||||
$contentMatch = [string]::IsNullOrWhiteSpace($Category) -and $appEntry.Content -like "*$escapedSearchString*"
|
$textMatch = -not $hasSearch -or
|
||||||
$descriptionMatch = [string]::IsNullOrWhiteSpace($Category) -and $appEntry.Description -like "*$escapedSearchString*"
|
$appEntry.Content -like "*$escapedSearchString*" -or
|
||||||
|
$appEntry.Description -like "*$escapedSearchString*"
|
||||||
|
|
||||||
if ($categoryMatch -or $contentMatch -or $descriptionMatch) {
|
if ($categoryMatch -and $textMatch) {
|
||||||
# Show the App and mark that this category has a match
|
|
||||||
$appControl.Visibility = [Windows.Visibility]::Visible
|
$appControl.Visibility = [Windows.Visibility]::Visible
|
||||||
$categoryHasMatch = $true
|
$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) {
|
if ($categoryHasMatch) {
|
||||||
$wrapPanel.Visibility = [Windows.Visibility]::Visible
|
$wrapPanel.Visibility = [Windows.Visibility]::Visible
|
||||||
$_.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 "+*") {
|
if ($categoryLabel.Content -like "+*") {
|
||||||
$categoryLabel.Content = $categoryLabel.Content -replace "^\+ ", "- "
|
$categoryLabel.Content = $categoryLabel.Content -replace "^\+ ", "- "
|
||||||
|
$sync.AppCategoryAutoExpanded[($categoryLabel.Content -replace '^- ', '')] = $true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
# Hide the entire category container if no matches
|
|
||||||
$_.Visibility = [Windows.Visibility]::Collapsed
|
$_.Visibility = [Windows.Visibility]::Collapsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,18 +90,25 @@ function Find-TweaksByNameOrDescription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($dockPanel -is [Windows.Controls.DockPanel]) {
|
if ($dockPanel -is [Windows.Controls.DockPanel]) {
|
||||||
$itemsControl = $null
|
$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
|
||||||
$itemsControl = $dockPanel.Children | Where-Object { $_ -is [Windows.Controls.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
|
# Show all items in the category
|
||||||
foreach ($item in $itemsControl.Items) {
|
foreach ($item in $items) {
|
||||||
if ($null -ne $item) {
|
if ($null -ne $item) {
|
||||||
# Check if it's a category label (first Label in the ItemsControl)
|
# Check if it's a category label (first Label in the container)
|
||||||
if ($item -is [Windows.Controls.Label]) {
|
if ($item -is [Windows.Controls.Label] -or $item.GetType().Name -eq "Label") {
|
||||||
$item.Visibility = [Windows.Visibility]::Visible
|
$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
|
# Show all checkbox containers
|
||||||
$item.Visibility = [Windows.Visibility]::Visible
|
$item.Visibility = [Windows.Visibility]::Visible
|
||||||
}
|
}
|
||||||
@@ -143,16 +150,21 @@ function Find-TweaksByNameOrDescription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($dockPanel -is [Windows.Controls.DockPanel]) {
|
if ($dockPanel -is [Windows.Controls.DockPanel]) {
|
||||||
$itemsControl = $null
|
$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
|
||||||
$itemsControl = $dockPanel.Children | Where-Object { $_ -is [Windows.Controls.ItemsControl] } | Select-Object -First 1
|
|
||||||
|
|
||||||
if ($null -ne $itemsControl) {
|
if ($null -ne $container) {
|
||||||
$categoryLabel = $null
|
$categoryLabel = $null
|
||||||
|
|
||||||
# Process all items (checkboxes, labels, panels) in the ItemsControl
|
$targetPanel = if ($container.PSObject.Properties['Content'] -and $null -ne $container.Content) { $container.Content } else { $container }
|
||||||
for ($i = 0; $i -lt $itemsControl.Items.Count; $i++) {
|
$items = $null
|
||||||
$item = $itemsControl.Items[$i]
|
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) {
|
if ($null -eq $item) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -161,7 +173,7 @@ function Find-TweaksByNameOrDescription {
|
|||||||
# Check if this is a category label (usually first Label)
|
# 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
|
$categoryLabel = $item
|
||||||
# Initially hide category label; show it only if matches found
|
# Initially hide category label; show it only if matches found
|
||||||
$item.Visibility = [Windows.Visibility]::Collapsed
|
$item.Visibility = [Windows.Visibility]::Collapsed
|
||||||
@@ -171,13 +183,13 @@ function Find-TweaksByNameOrDescription {
|
|||||||
# Check if this is a DockPanel containing a tweak checkbox
|
# 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
|
$checkbox = $null
|
||||||
$label = $null
|
$label = $null
|
||||||
|
|
||||||
# Safely extract checkbox and label
|
# Safely extract checkbox and label
|
||||||
$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
|
||||||
$label = $item.Children | Where-Object { $_ -is [Windows.Controls.Label] } | 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
|
# Check if tweak matches search criteria
|
||||||
$itemMatches = $false
|
$itemMatches = $false
|
||||||
@@ -221,9 +233,9 @@ function Find-TweaksByNameOrDescription {
|
|||||||
# Check if this is a StackPanel containing a tweak checkbox
|
# 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 = $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
|
$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.Tag = $appKey
|
||||||
$border.ToolTip = $app.description
|
$border.ToolTip = $app.description
|
||||||
$border.Add_MouseLeftButtonUp({
|
$border.Add_MouseLeftButtonUp({
|
||||||
$childCheckbox = ($this.Child | Where-Object {$_.Template.TargetType -eq [System.Windows.Controls.Checkbox]})[0]
|
# Resolve through $sync because the border's child is a layout Grid for FOSS entries
|
||||||
$childCheckBox.isChecked = -not $childCheckbox.IsChecked
|
$childCheckbox = $sync.$($this.Tag)
|
||||||
|
$childCheckbox.IsChecked = -not $childCheckbox.IsChecked
|
||||||
})
|
})
|
||||||
$border.Add_MouseEnter({
|
$border.Add_MouseEnter({
|
||||||
if (($sync.$($this.Tag).IsChecked) -eq $false) {
|
if (($sync.$($this.Tag).IsChecked) -eq $false) {
|
||||||
@@ -48,15 +49,16 @@ function Initialize-InstallAppEntry {
|
|||||||
# Store the original appKey in Tag
|
# Store the original appKey in Tag
|
||||||
$checkBox.Tag = $appKey
|
$checkBox.Tag = $appKey
|
||||||
$checkbox.Style = $sync.Form.Resources.AppEntryCheckboxStyle
|
$checkbox.Style = $sync.Form.Resources.AppEntryCheckboxStyle
|
||||||
|
# The checkbox sits inside the entry layout Grid, so the border is one level further up
|
||||||
$checkbox.Add_Checked({
|
$checkbox.Add_Checked({
|
||||||
Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName $this.Parent.Tag
|
Invoke-WPFSelectedCheckboxesUpdate -type "Add" -checkboxName $this.Tag
|
||||||
$borderElement = $this.Parent
|
$borderElement = $this.Parent.Parent
|
||||||
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallSelectedColor")
|
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallSelectedColor")
|
||||||
})
|
})
|
||||||
|
|
||||||
$checkbox.Add_Unchecked({
|
$checkbox.Add_Unchecked({
|
||||||
Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName $this.Parent.Tag
|
Invoke-WPFSelectedCheckboxesUpdate -type "Remove" -checkboxName $this.Tag
|
||||||
$borderElement = $this.Parent
|
$borderElement = $this.Parent.Parent
|
||||||
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor")
|
$borderElement.SetResourceReference([Windows.Controls.Control]::BackgroundProperty, "AppInstallUnselectedColor")
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -88,15 +90,6 @@ function Initialize-InstallAppEntry {
|
|||||||
$appName = New-Object Windows.Controls.TextBlock
|
$appName = New-Object Windows.Controls.TextBlock
|
||||||
$appName.Style = $sync.Form.Resources.AppEntryNameStyle
|
$appName.Style = $sync.Form.Resources.AppEntryNameStyle
|
||||||
$appName.Text = $app.content
|
$appName.Text = $app.content
|
||||||
|
|
||||||
# Add FOSS label after the name if FOSS
|
|
||||||
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)
|
[void]$contentPanel.Children.Add($appName)
|
||||||
$checkBox.Content = $contentPanel
|
$checkBox.Content = $contentPanel
|
||||||
|
|
||||||
@@ -104,7 +97,20 @@ function Initialize-InstallAppEntry {
|
|||||||
$checkBox.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $app.content)
|
$checkBox.SetValue([Windows.Automation.AutomationProperties]::NameProperty, $app.content)
|
||||||
$border.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) {
|
if ($sync.selectedApps -contains $appKey) {
|
||||||
$checkBox.IsChecked = $true
|
$checkBox.IsChecked = $true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ function Initialize-InstallCategoryAppList {
|
|||||||
# The WrapPanel is the second child
|
# The WrapPanel is the second child
|
||||||
$wrapPanel = $categoryContainer.Children[1]
|
$wrapPanel = $categoryContainer.Children[1]
|
||||||
|
|
||||||
|
# An explicit click wins over anything filtering expanded automatically
|
||||||
|
if ($sync.AppCategoryAutoExpanded) {
|
||||||
|
$sync.AppCategoryAutoExpanded.Remove(($categoryToggle.Content -replace '^[+-] ', ''))
|
||||||
|
}
|
||||||
|
|
||||||
# Toggle visibility
|
# Toggle visibility
|
||||||
if ($wrapPanel.Visibility -eq [Windows.Visibility]::Visible) {
|
if ($wrapPanel.Visibility -eq [Windows.Visibility]::Visible) {
|
||||||
$wrapPanel.Visibility = [Windows.Visibility]::Collapsed
|
$wrapPanel.Visibility = [Windows.Visibility]::Collapsed
|
||||||
|
|||||||
@@ -36,4 +36,7 @@ function Initialize-WinUtilTabContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$sync.InitializedTabs[$TabName] = $true
|
$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
|
$serviceKeys = $entry.service
|
||||||
$entryType = $entry.Type
|
$entryType = $entry.Type
|
||||||
|
|
||||||
if ($registryKeys -or $serviceKeys) {
|
if (($registryKeys -or $serviceKeys) -and $entryType -ne "Combobox") {
|
||||||
$Values = @()
|
$Values = @()
|
||||||
|
|
||||||
if ($entryType -eq "Toggle") {
|
if ($entryType -eq "Toggle") {
|
||||||
|
|||||||
@@ -25,36 +25,66 @@ function Invoke-WinUtilSSHServer {
|
|||||||
Write-Host "Firewall rule for OpenSSH Server created and enabled."
|
Write-Host "Firewall rule for OpenSSH Server created and enabled."
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check for the authorized_keys file
|
# An SSH logon for a member of the administrators group gets a full token
|
||||||
$sshFolderPath = "$Home\.ssh"
|
# with no UAC prompt, so sshd reads administrator keys from a machine-wide
|
||||||
$authorizedKeysPath = "$sshFolderPath\authorized_keys"
|
# 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)) {
|
if (-not (Test-Path -Path $sshProgramDataPath)) {
|
||||||
Write-Host "Creating ssh directory..."
|
New-Item -Path $sshProgramDataPath -ItemType Directory -Force | Out-Null
|
||||||
New-Item -Path $sshFolderPath -ItemType Directory -Force
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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)) {
|
if (-not (Test-Path -Path $authorizedKeysPath)) {
|
||||||
Write-Host "Creating authorized_keys file..."
|
Write-Host "Creating administrators_authorized_keys file..."
|
||||||
New-Item -Path $authorizedKeysPath -ItemType File -Force
|
New-Item -Path $authorizedKeysPath -ItemType File -Force | Out-Null
|
||||||
Write-Host "authorized_keys file created at $authorizedKeysPath."
|
Write-Host "administrators_authorized_keys file created at $authorizedKeysPath."
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Configuring sshd_config for standard authorized_keys behavior..."
|
if ($configWasOverridden -and (Test-Path -Path $profileKeysPath)) {
|
||||||
$sshdConfigPath = "C:\ProgramData\ssh\sshd_config"
|
$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'
|
# sshd ignores the file unless inheritance is off and access is limited to
|
||||||
$updatedContent = $updatedContent -replace '(?m)^(\s+AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys)$', '# $1'
|
# 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) {
|
if ($configWasOverridden) {
|
||||||
Set-Content -Path $sshdConfigPath -Value $updatedContent -Force
|
Set-Content -Path $sshdConfigPath -Value $restoredContent -Force
|
||||||
Write-Host "Commented out administrator-specific SSH key configuration in sshd_config"
|
Write-Host "Restored the administrator key file setting in sshd_config."
|
||||||
Restart-Service -Name sshd -Force
|
Restart-Service -Name sshd -Force
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "OpenSSH server was successfully enabled."
|
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"
|
Write-Host "Add your public keys to this file -> $authorizedKeysPath"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ function Invoke-WinUtilTweaks {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($sync.configs.tweaks.$CheckBox.registry) {
|
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)
|
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 {
|
function Set-WinUtilAppCategoryFilter {
|
||||||
<#
|
<#
|
||||||
.SYNOPSIS
|
.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
|
.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(
|
param(
|
||||||
[Parameter(Mandatory = $false)]
|
[Parameter(Mandatory = $false)]
|
||||||
[string]$Category = ""
|
[string]$Category = "",
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $false)]
|
||||||
|
[switch]$Additive
|
||||||
)
|
)
|
||||||
|
|
||||||
$sync.SearchBar.Tag = $Category
|
if ($null -eq $sync.SelectedAppCategories) {
|
||||||
$sync.SearchBar.Text = $Category
|
$sync.SelectedAppCategories = [System.Collections.Generic.List[string]]::new()
|
||||||
Find-AppsByNameOrDescription -SearchString $Category -Category $Category
|
}
|
||||||
|
$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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function Set-WinUtilRegistry {
|
|||||||
)
|
)
|
||||||
|
|
||||||
try {
|
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)) {
|
If (!(Test-Path $Path)) {
|
||||||
Write-Host "$Path was not found. Creating..."
|
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
|
[int]$Percent
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
$indicatorVisible = if ($Visible) { [Windows.Visibility]::Visible } else { [Windows.Visibility]::Collapsed }
|
$indicatorVisible = if ($Visible) { [Windows.Visibility]::Visible } else { [Windows.Visibility]::Collapsed }
|
||||||
$indicatorLabel = $Label
|
$indicatorLabel = $Label
|
||||||
$hasLabel = $PSBoundParameters.ContainsKey('Label')
|
$hasLabel = $PSBoundParameters.ContainsKey('Label')
|
||||||
|
|||||||
@@ -8,8 +8,14 @@ function Invoke-WinUtilInstallAppRenderBatch {
|
|||||||
$sync.$appKey = Initialize-InstallAppEntry -TargetElement $CategoryBatch.TargetElement -AppKey $appKey
|
$sync.$appKey = Initialize-InstallAppEntry -TargetElement $CategoryBatch.TargetElement -AppKey $appKey
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($sync.currentTab -eq "Install" -and $sync.SearchBar -and -not [string]::IsNullOrWhiteSpace($sync.SearchBar.Text)) {
|
# Entries render in batches, so a filter that is already active has to be applied to each new
|
||||||
Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Category $sync.SearchBar.Tag
|
# 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
|
$sync.ProcessRunning = $false
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
} | Out-Null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,5 +36,5 @@ function Invoke-WPFFeatureInstall {
|
|||||||
Write-Host "--- Features are Installed ---"
|
Write-Host "--- Features are Installed ---"
|
||||||
Write-Host "--- A Reboot may be required ---"
|
Write-Host "--- A Reboot may be required ---"
|
||||||
Write-Host "==================================="
|
Write-Host "==================================="
|
||||||
}
|
} | Out-Null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,5 +110,5 @@ function Invoke-WPFInstall {
|
|||||||
}
|
}
|
||||||
$sync.ProcessRunning = $False
|
$sync.ProcessRunning = $False
|
||||||
}
|
}
|
||||||
}
|
} | Out-Null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,8 +32,9 @@ function Invoke-WPFTab {
|
|||||||
|
|
||||||
# Always reset the filter for the current tab
|
# Always reset the filter for the current tab
|
||||||
if ($sync.currentTab -eq "Install") {
|
if ($sync.currentTab -eq "Install") {
|
||||||
# Reset Install tab filter
|
# Reset the search text, but keep the categories the chips are still showing as selected
|
||||||
Find-AppsByNameOrDescription -SearchString ""
|
$selectedCategories = if ($sync.SelectedAppCategories) { $sync.SelectedAppCategories.ToArray() } else { @() }
|
||||||
|
Find-AppsByNameOrDescription -SearchString "" -Categories $selectedCategories
|
||||||
} elseif ($sync.currentTab -eq "Tweaks") {
|
} elseif ($sync.currentTab -eq "Tweaks") {
|
||||||
# Reset Tweaks tab filter
|
# Reset Tweaks tab filter
|
||||||
Find-TweaksByNameOrDescription -SearchString ""
|
Find-TweaksByNameOrDescription -SearchString ""
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function Invoke-WPFUIElements {
|
|||||||
# Add ColumnDefinitions to the target Grid
|
# Add ColumnDefinitions to the target Grid
|
||||||
for ($i = 0; $i -lt $columncount; $i++) {
|
for ($i = 0; $i -lt $columncount; $i++) {
|
||||||
$colDef = New-Object Windows.Controls.ColumnDefinition
|
$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
|
$targetGrid.ColumnDefinitions.Add($colDef) | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +76,8 @@ function Invoke-WPFUIElements {
|
|||||||
Description = $entryInfo.description
|
Description = $entryInfo.description
|
||||||
Type = $entryInfo.type
|
Type = $entryInfo.type
|
||||||
ComboItems = $entryInfo.ComboItems
|
ComboItems = $entryInfo.ComboItems
|
||||||
|
ComboDescriptions = $entryInfo.ComboDescriptions
|
||||||
|
Registry = $entryInfo.registry
|
||||||
Checked = $entryInfo.Checked
|
Checked = $entryInfo.Checked
|
||||||
ButtonWidth = $entryInfo.ButtonWidth
|
ButtonWidth = $entryInfo.ButtonWidth
|
||||||
GroupName = $entryInfo.GroupName # Added for RadioButton groupings
|
GroupName = $entryInfo.GroupName # Added for RadioButton groupings
|
||||||
@@ -111,43 +113,63 @@ function Invoke-WPFUIElements {
|
|||||||
$dockPanelContainer = New-Object Windows.Controls.DockPanel
|
$dockPanelContainer = New-Object Windows.Controls.DockPanel
|
||||||
$border.Child = $dockPanelContainer
|
$border.Child = $dockPanelContainer
|
||||||
|
|
||||||
# Create an ItemsControl for application content
|
# Create a StackPanel for application content controls
|
||||||
$itemsControl = New-Object Windows.Controls.ItemsControl
|
$stackPanelContainer = New-Object Windows.Controls.StackPanel
|
||||||
$itemsControl.HorizontalAlignment = 'Stretch'
|
$stackPanelContainer.HorizontalAlignment = 'Stretch'
|
||||||
$itemsControl.VerticalAlignment = 'Stretch'
|
$stackPanelContainer.VerticalAlignment = 'Stretch'
|
||||||
|
|
||||||
# Set the ItemsPanel to a VirtualizingStackPanel
|
# Check if the target grid (or any ancestor) is already inside a ScrollViewer
|
||||||
$itemsPanelTemplate = New-Object Windows.Controls.ItemsPanelTemplate
|
$hasOuterScrollViewer = $false
|
||||||
$factory = New-Object Windows.FrameworkElementFactory ([Windows.Controls.VirtualizingStackPanel])
|
$currentElement = $targetGrid
|
||||||
$itemsPanelTemplate.VisualTree = $factory
|
while ($null -ne $currentElement) {
|
||||||
$itemsControl.ItemsPanel = $itemsPanelTemplate
|
if ($currentElement -is [System.Windows.Controls.ScrollViewer] -or $currentElement.GetType().Name -eq "ScrollViewer") {
|
||||||
|
$hasOuterScrollViewer = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
$currentElement = $currentElement.Parent
|
||||||
|
}
|
||||||
|
|
||||||
# Set virtualization properties
|
if ($hasOuterScrollViewer) {
|
||||||
$itemsControl.SetValue([Windows.Controls.VirtualizingStackPanel]::IsVirtualizingProperty, $true)
|
# Add StackPanel directly to DockPanel without nesting a ScrollViewer
|
||||||
$itemsControl.SetValue([Windows.Controls.VirtualizingStackPanel]::VirtualizationModeProperty, [Windows.Controls.VirtualizationMode]::Recycling)
|
[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($scrollViewer, [Windows.Controls.Dock]::Bottom)
|
||||||
[Windows.Controls.DockPanel]::SetDock($itemsControl, [Windows.Controls.Dock]::Bottom)
|
$dockPanelContainer.Children.Add($scrollViewer) | Out-Null
|
||||||
$dockPanelContainer.Children.Add($itemsControl) | Out-Null
|
}
|
||||||
$panelcount++
|
$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)) {
|
foreach ($category in ($organizedData[$panelKey].Keys | Sort-Object)) {
|
||||||
$count++
|
$count++
|
||||||
|
|
||||||
$label = New-Object Windows.Controls.Label
|
$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]::FontSizeProperty, "HeaderFontSize")
|
||||||
$label.SetResourceReference([Windows.Controls.Control]::FontFamilyProperty, "HeaderFontFamily")
|
$label.SetResourceReference([Windows.Controls.Control]::FontFamilyProperty, "HeaderFontFamily")
|
||||||
$label.UseLayoutRounding = $true
|
$label.UseLayoutRounding = $true
|
||||||
$itemsControl.Items.Add($label) | Out-Null
|
$stackPanelContainer.Children.Add($label) | Out-Null
|
||||||
$sync[$category] = $label
|
$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 = {
|
$entries = $organizedData[$panelKey][$category] | Sort-Object @{Expression = {
|
||||||
switch ($_.Type) {
|
switch ($_.Type) {
|
||||||
'Button' { 1 }
|
'Button' { 1 }
|
||||||
'Combobox' { 2 }
|
'Combobox' { 2 }
|
||||||
|
'Note' { 3 }
|
||||||
default { 0 }
|
default { 0 }
|
||||||
}
|
}
|
||||||
}}, Content
|
}}, Content
|
||||||
@@ -174,7 +196,7 @@ function Invoke-WPFUIElements {
|
|||||||
$label.SetResourceReference([Windows.Controls.Control]::ForegroundProperty, "MainForegroundColor")
|
$label.SetResourceReference([Windows.Controls.Control]::ForegroundProperty, "MainForegroundColor")
|
||||||
$label.UseLayoutRounding = $true
|
$label.UseLayoutRounding = $true
|
||||||
$dockPanel.Children.Add($label) | Out-Null
|
$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] = $checkBox
|
||||||
$sync[$entryInfo.Name].IsChecked = (Get-WinUtilToggleStatus $entryInfo.Name)
|
$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 }
|
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
|
$sync[$entryInfo.Name] = $toggleButton
|
||||||
|
|
||||||
@@ -246,6 +268,7 @@ function Invoke-WPFUIElements {
|
|||||||
$label = New-Object Windows.Controls.Label
|
$label = New-Object Windows.Controls.Label
|
||||||
$label.Content = $entryInfo.Content
|
$label.Content = $entryInfo.Content
|
||||||
$label.HorizontalAlignment = "Left"
|
$label.HorizontalAlignment = "Left"
|
||||||
|
$label.ToolTip = $entryInfo.Description
|
||||||
$label.VerticalAlignment = "Center"
|
$label.VerticalAlignment = "Center"
|
||||||
$label.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
|
$label.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
|
||||||
$label.UseLayoutRounding = $true
|
$label.UseLayoutRounding = $true
|
||||||
@@ -260,35 +283,115 @@ function Invoke-WPFUIElements {
|
|||||||
$comboBox.SetResourceReference([Windows.Controls.Control]::MarginProperty, "ButtonMargin")
|
$comboBox.SetResourceReference([Windows.Controls.Control]::MarginProperty, "ButtonMargin")
|
||||||
$comboBox.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
|
$comboBox.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
|
||||||
$comboBox.UseLayoutRounding = $true
|
$comboBox.UseLayoutRounding = $true
|
||||||
|
$comboBox.Tag = [pscustomobject]@{
|
||||||
|
Registry = $entryInfo.Registry
|
||||||
|
State = $null
|
||||||
|
}
|
||||||
[System.Windows.Automation.AutomationProperties]::SetName($comboBox, $entryInfo.Content)
|
[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 = New-Object Windows.Controls.ComboBoxItem
|
||||||
$comboBoxItem.Content = $comboitem
|
$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.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "ButtonFontSize")
|
||||||
$comboBoxItem.UseLayoutRounding = $true
|
$comboBoxItem.UseLayoutRounding = $true
|
||||||
$comboBox.Items.Add($comboBoxItem) | Out-Null
|
$comboBox.Items.Add($comboBoxItem) | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
$horizontalStackPanel.Children.Add($comboBox) | Out-Null
|
$horizontalStackPanel.Children.Add($comboBox) | Out-Null
|
||||||
$itemsControl.Items.Add($horizontalStackPanel) | Out-Null
|
$stackPanelContainer.Children.Add($horizontalStackPanel) | Out-Null
|
||||||
|
|
||||||
$comboBox.SelectedIndex = 0
|
if ($entryInfo.Registry -and @($entryInfo.Registry)[0].Values) {
|
||||||
|
try {
|
||||||
|
$comboBox.Tag.State = Get-WinUtilRegistryComboState -Registry $entryInfo.Registry
|
||||||
|
$comboBox.SelectedIndex = @($comboBox.Items.Content).IndexOf([string]$comboBox.Tag.State)
|
||||||
|
} catch {
|
||||||
|
$unknownStateItem = New-Object Windows.Controls.ComboBoxItem
|
||||||
|
$unknownStateItem.Content = "Custom / Unknown - select a state"
|
||||||
|
$unknownStateItem.IsEnabled = $false
|
||||||
|
$unknownStateItem.ToolTip = "$($_.Exception.Message) Select one of the supported states to replace these values."
|
||||||
|
$comboBox.Items.Add($unknownStateItem) | Out-Null
|
||||||
|
$comboBox.SelectedItem = $unknownStateItem
|
||||||
|
$comboBox.ToolTip = $unknownStateItem.ToolTip
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$comboBox.SelectedIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
# Set initial text
|
# Set initial text
|
||||||
if ($comboBox.Items.Count -gt 0) {
|
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
|
# Add SelectionChanged event handler to update the text property
|
||||||
$comboBox.Add_SelectionChanged({
|
$comboBox.Add_SelectionChanged({
|
||||||
$selectedItem = $this.SelectedItem
|
$selectedItem = $this.SelectedItem
|
||||||
if ($selectedItem) {
|
if ($selectedItem) {
|
||||||
$this.Text = $selectedItem.Content
|
$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" {
|
"Button" {
|
||||||
@@ -303,7 +406,7 @@ function Invoke-WPFUIElements {
|
|||||||
$button.Width = [math]::Max($baseWidth, 350)
|
$button.Width = [math]::Max($baseWidth, 350)
|
||||||
}
|
}
|
||||||
[System.Windows.Automation.AutomationProperties]::SetName($button, $entryInfo.Content)
|
[System.Windows.Automation.AutomationProperties]::SetName($button, $entryInfo.Content)
|
||||||
$itemsControl.Items.Add($button) | Out-Null
|
$stackPanelContainer.Children.Add($button) | Out-Null
|
||||||
|
|
||||||
$sync[$entryInfo.Name] = $button
|
$sync[$entryInfo.Name] = $button
|
||||||
|
|
||||||
@@ -330,7 +433,7 @@ function Invoke-WPFUIElements {
|
|||||||
$radioButtonGroups[$entryInfo.GroupName] = $groupStackPanel
|
$radioButtonGroups[$entryInfo.GroupName] = $groupStackPanel
|
||||||
|
|
||||||
# Add the group container to the ItemsControl
|
# Add the group container to the ItemsControl
|
||||||
$itemsControl.Items.Add($groupStackPanel) | Out-Null
|
$stackPanelContainer.Children.Add($groupStackPanel) | Out-Null
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
# Retrieve the existing group container
|
# Retrieve the existing group container
|
||||||
@@ -364,20 +467,18 @@ function Invoke-WPFUIElements {
|
|||||||
$textBlock.Margin = "5,5,5,5"
|
$textBlock.Margin = "5,5,5,5"
|
||||||
$textBlock.UseLayoutRounding = $true
|
$textBlock.UseLayoutRounding = $true
|
||||||
|
|
||||||
$bulletRun = New-Object Windows.Documents.Run
|
$bulletBadge = [Windows.Documents.InlineUIContainer]::new((New-WinUtilFossBadge -Size 18 -Round))
|
||||||
$bulletRun.Text = [char]0x25CF
|
$bulletBadge.BaselineAlignment = [Windows.BaselineAlignment]::Center
|
||||||
$bulletRun.Foreground = [Windows.Media.SolidColorBrush]::new([Windows.Media.Color]::FromRgb(110, 255, 114))
|
|
||||||
$bulletRun.FontSize = 11.5
|
|
||||||
|
|
||||||
$textRun = New-Object Windows.Documents.Run
|
$textRun = New-Object Windows.Documents.Run
|
||||||
$textRun.Text = " $($entryInfo.Content)"
|
$textRun.Text = " $($entryInfo.Content)"
|
||||||
$textRun.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "FontSize")
|
$textRun.SetResourceReference([Windows.Controls.Control]::FontSizeProperty, "FontSize")
|
||||||
$textRun.Foreground = [Windows.Media.SolidColorBrush]::new([Windows.Media.Color]::FromRgb(19, 143, 83))
|
$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)
|
$textBlock.Inlines.Add($textRun)
|
||||||
|
|
||||||
$itemsControl.Items.Add($textBlock) | Out-Null
|
$stackPanelContainer.Children.Add($textBlock) | Out-Null
|
||||||
}
|
}
|
||||||
|
|
||||||
default {
|
default {
|
||||||
@@ -437,7 +538,7 @@ function Invoke-WPFUIElements {
|
|||||||
$sync[$textBlock.Name] = $textBlock
|
$sync[$textBlock.Name] = $textBlock
|
||||||
}
|
}
|
||||||
|
|
||||||
$itemsControl.Items.Add($horizontalStackPanel) | Out-Null
|
$stackPanelContainer.Children.Add($horizontalStackPanel) | Out-Null
|
||||||
$sync[$entryInfo.Name] = $checkBox
|
$sync[$entryInfo.Name] = $checkBox
|
||||||
|
|
||||||
$sync[$entryInfo.Name].Add_Checked({
|
$sync[$entryInfo.Name].Add_Checked({
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
function Invoke-WPFUIThread ($ScriptBlock) {
|
function Invoke-WPFUIThread ($ScriptBlock) {
|
||||||
|
if ($null -eq $sync.form -or $null -eq $sync.form.Dispatcher) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
$sync.form.Dispatcher.Invoke([action]$ScriptBlock)
|
$sync.form.Dispatcher.Invoke([action]$ScriptBlock)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,5 +88,5 @@ function Invoke-WPFtweaksbutton {
|
|||||||
Write-Host "-- Tweaks are Finished ---"
|
Write-Host "-- Tweaks are Finished ---"
|
||||||
Write-Host "================================="
|
Write-Host "================================="
|
||||||
Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed."
|
Write-WinUtilLog -Component "Tweaks" -Message "Tweaks workflow completed."
|
||||||
}
|
} | Out-Null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,6 +189,14 @@ Describe "Tweaks config" {
|
|||||||
foreach ($registryEntry in @($tweak.Value.registry)) {
|
foreach ($registryEntry in @($tweak.Value.registry)) {
|
||||||
if ($null -eq $registryEntry) { continue }
|
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
|
if ($registryEntry.PSObject.Properties.Name -notcontains "OriginalValue" -or
|
||||||
[string]::IsNullOrWhiteSpace([string]$registryEntry.OriginalValue)) {
|
[string]::IsNullOrWhiteSpace([string]$registryEntry.OriginalValue)) {
|
||||||
$invalidTweaks.Add("$($tweak.Name),registry")
|
$invalidTweaks.Add("$($tweak.Name),registry")
|
||||||
@@ -429,6 +437,27 @@ Describe "UI-rendered config entries" {
|
|||||||
if (-not (Test-WinUtilHasNonEmptyProperty -Object $entry.Value -Name "ComboItems")) {
|
if (-not (Test-WinUtilHasNonEmptyProperty -Object $entry.Value -Name "ComboItems")) {
|
||||||
$invalidEntries.Add("$($entry.Name) combobox missing 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 {
|
} else {
|
||||||
if (-not (Test-WinUtilHasNonEmptyProperty -Object $entry.Value -Name "Description")) {
|
if (-not (Test-WinUtilHasNonEmptyProperty -Object $entry.Value -Name "Description")) {
|
||||||
$invalidEntries.Add("$($entry.Name) missing Description")
|
$invalidEntries.Add("$($entry.Name) missing Description")
|
||||||
@@ -442,7 +471,12 @@ Describe "UI-rendered config entries" {
|
|||||||
foreach ($registryEntry in @($entry.Value.registry)) {
|
foreach ($registryEntry in @($entry.Value.registry)) {
|
||||||
if ($null -eq $registryEntry) { continue }
|
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)
|
$invalidEntries.Add($missingField)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 'Dispatcher\.BeginInvoke'
|
||||||
$renderScript | Should -Match 'Invoke-WinUtilInstallAppRenderNextBatch'
|
$renderScript | Should -Match 'Invoke-WinUtilInstallAppRenderNextBatch'
|
||||||
$renderScript | Should -Match 'Initialize-InstallAppEntry'
|
$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'
|
$renderScript | Should -Match '\$sync\.InstallAppEntriesRendered = \$true'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ BeforeAll {
|
|||||||
param([string]$TargetGridName)
|
param([string]$TargetGridName)
|
||||||
}
|
}
|
||||||
function Invoke-WinUtilISOCheckExistingWork { }
|
function Invoke-WinUtilISOCheckExistingWork { }
|
||||||
|
function Reset-WPFCheckBoxes { param([bool]$doToggles) }
|
||||||
|
|
||||||
. (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTabContent.ps1")
|
. (Join-Path $script:repoRoot "functions\private\Initialize-WinUtilTabContent.ps1")
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,7 @@ Describe "Initialize-WinUtilTabContent" {
|
|||||||
|
|
||||||
Mock Invoke-WPFUIElements { }
|
Mock Invoke-WPFUIElements { }
|
||||||
Mock Initialize-WPFUI { }
|
Mock Initialize-WPFUI { }
|
||||||
|
Mock Reset-WPFCheckBoxes { }
|
||||||
}
|
}
|
||||||
|
|
||||||
AfterEach {
|
AfterEach {
|
||||||
@@ -51,6 +53,21 @@ Describe "Initialize-WinUtilTabContent" {
|
|||||||
$script:sync.InitializedTabs["Install"] | Should -BeTrue
|
$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" {
|
It "initializes deferred config-backed tabs once" {
|
||||||
Initialize-WinUtilTabContent -TabName "Tweaks"
|
Initialize-WinUtilTabContent -TabName "Tweaks"
|
||||||
Initialize-WinUtilTabContent -TabName "Config"
|
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'
|
$rendererScript | Should -Match '(?s)if \(\$entryInfo\.Link\).*\$textBlock\.Add_MouseUp\(\{.*Start-Process \$Sender\.ToolTip -ErrorAction Stop'
|
||||||
$mainScript | Should -Not -Match '\.Name\.EndsWith\("Link"\)'
|
$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)
|
$category = New-WinUtilAppCategory -Label "- Tools" -Items @($utilityItem, $powerToysItem)
|
||||||
New-WinUtilAppSearchContext -Categories @($category)
|
New-WinUtilAppSearchContext -Categories @($category)
|
||||||
|
|
||||||
Find-AppsByNameOrDescription -SearchString "Utilities" -Category "Utilities"
|
Find-AppsByNameOrDescription -Categories @("Utilities")
|
||||||
|
|
||||||
$utilityItem.Visibility | Should -Be ([Windows.Visibility]::Visible)
|
$utilityItem.Visibility | Should -Be ([Windows.Visibility]::Visible)
|
||||||
$powerToysItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed)
|
$powerToysItem.Visibility | Should -Be ([Windows.Visibility]::Collapsed)
|
||||||
$category.Visibility | Should -Be ([Windows.Visibility]::Visible)
|
$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" {
|
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"
|
$registryPath = "HKLM:\Software\WinUtilTest"
|
||||||
$script:testPathResults["HKU:\"] = $true
|
$script:testPathResults["HKU:\"] = $true
|
||||||
$script:testPathResults[$registryPath] = $true
|
$script:testPathResults[$registryPath] = $true
|
||||||
|
|
||||||
Set-WinUtilRegistry -Path $registryPath -Name "ObsoleteValue" -Type "String" -Value "<RemoveEntry>"
|
Set-WinUtilRegistry -Path $registryPath -Name "ObsoleteValue" -Type "String" -Value "<RemoveEntry>"
|
||||||
|
|
||||||
Should -Invoke -CommandName Set-ItemProperty -Times 0 -Exactly
|
Should -Invoke -CommandName Set-ItemProperty -Times 0 -Exactly
|
||||||
@@ -159,6 +158,7 @@ Describe "Set-WinUtilRegistry" {
|
|||||||
$ErrorAction -eq "Stop"
|
$ErrorAction -eq "Stop"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Describe "Set-WinUtilService" {
|
Describe "Set-WinUtilService" {
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ Describe "Invoke-WinUtilTweaks" {
|
|||||||
$Name -eq "DiagTrack" -and $StartupType -eq "Disabled"
|
$Name -eq "DiagTrack" -and $StartupType -eq "Disabled"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Describe "Invoke-WPFtweaksbutton" {
|
Describe "Invoke-WPFtweaksbutton" {
|
||||||
|
|||||||
@@ -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" {
|
It "wires the Document search chip to an existing Document category" {
|
||||||
$mainScript = Get-Content -Path $script:mainScriptPath -Raw
|
$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"
|
$applications = Get-WinUtilConfigObject -Name "applications"
|
||||||
$categories = @($applications.PSObject.Properties | ForEach-Object { $_.Value.category } | Sort-Object -Unique)
|
$categories = @($applications.PSObject.Properties | ForEach-Object { $_.Value.category } | Sort-Object -Unique)
|
||||||
@@ -468,7 +469,10 @@ Describe "XAML and sync wiring" {
|
|||||||
"Win11ISOProcessRunning",
|
"Win11ISOProcessRunning",
|
||||||
"Win11ISOWorkDir",
|
"Win11ISOWorkDir",
|
||||||
"Win11ISOContentsDir",
|
"Win11ISOContentsDir",
|
||||||
"Win11ISOUSBDisks"
|
"Win11ISOUSBDisks",
|
||||||
|
"AppCategoryChips",
|
||||||
|
"SelectedAppCategories",
|
||||||
|
"AppCategoryAutoExpanded"
|
||||||
)
|
)
|
||||||
$allowedNames = @($xamlNames + $generatedNames + $dynamicStateNames) | Sort-Object -Unique
|
$allowedNames = @($xamlNames + $generatedNames + $dynamicStateNames) | Sort-Object -Unique
|
||||||
$bracketReferences = @(
|
$bracketReferences = @(
|
||||||
|
|||||||
+32
-22
@@ -329,7 +329,7 @@ $searchBarTimer.add_Tick({
|
|||||||
$searchBarTimer.Stop()
|
$searchBarTimer.Stop()
|
||||||
switch ($sync.currentTab) {
|
switch ($sync.currentTab) {
|
||||||
"Install" {
|
"Install" {
|
||||||
Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Category $sync.SearchBar.Tag
|
Find-AppsByNameOrDescription -SearchString $sync.SearchBar.Text -Categories $sync.SelectedAppCategories.ToArray()
|
||||||
}
|
}
|
||||||
"Tweaks" {
|
"Tweaks" {
|
||||||
Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text
|
Find-TweaksByNameOrDescription -SearchString $sync.SearchBar.Text
|
||||||
@@ -340,10 +340,6 @@ $searchBarTimer.add_Tick({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
$sync["SearchBar"].Add_TextChanged({
|
$sync["SearchBar"].Add_TextChanged({
|
||||||
if ($sync.SearchBar.Tag -ne $sync.SearchBar.Text) {
|
|
||||||
$sync.SearchBar.Tag = $null
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($sync.SearchBar.Text -ne "") {
|
if ($sync.SearchBar.Text -ne "") {
|
||||||
$sync.SearchBarClearButton.Visibility = "Visible"
|
$sync.SearchBarClearButton.Visibility = "Visible"
|
||||||
$sync.SearchBarIcon.Visibility = "Collapsed"
|
$sync.SearchBarIcon.Visibility = "Collapsed"
|
||||||
@@ -352,29 +348,43 @@ $sync["SearchBar"].Add_TextChanged({
|
|||||||
$sync.SearchBarIcon.Visibility = "Visible"
|
$sync.SearchBarIcon.Visibility = "Visible"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Category chip handlers apply their filter immediately.
|
|
||||||
if ($sync.SearchBar.Tag -eq $sync.SearchBar.Text) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($searchBarTimer.IsEnabled) {
|
if ($searchBarTimer.IsEnabled) {
|
||||||
$searchBarTimer.Stop()
|
$searchBarTimer.Stop()
|
||||||
}
|
}
|
||||||
$searchBarTimer.Start()
|
$searchBarTimer.Start()
|
||||||
})
|
})
|
||||||
|
|
||||||
# Quick Category Search Chips
|
# Category filter chips. The chip carries its category in Tag, so one handler covers all of them.
|
||||||
$sync["WPFSearchChipAll"].Add_Click({ Set-WinUtilAppCategoryFilter })
|
$sync.AppCategoryChips = @(
|
||||||
$sync["WPFSearchChipBrowsers"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Browsers" })
|
@{ Name = "WPFSearchChipAll"; Category = "" }
|
||||||
$sync["WPFSearchChipCommunications"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Communications" })
|
@{ Name = "WPFSearchChipBrowsers"; Category = "Browsers" }
|
||||||
$sync["WPFSearchChipDevelopment"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Development" })
|
@{ Name = "WPFSearchChipCommunications"; Category = "Communications" }
|
||||||
$sync["WPFSearchChipDocument"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Document" })
|
@{ Name = "WPFSearchChipDevelopment"; Category = "Development" }
|
||||||
$sync["WPFSearchChipGames"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Games" })
|
@{ Name = "WPFSearchChipDocument"; Category = "Document" }
|
||||||
$sync["WPFSearchChipMicrosoftTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Microsoft Tools" })
|
@{ Name = "WPFSearchChipGames"; Category = "Games" }
|
||||||
$sync["WPFSearchChipMultimediaTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Multimedia Tools" })
|
@{ Name = "WPFSearchChipMicrosoftTools"; Category = "Microsoft Tools" }
|
||||||
$sync["WPFSearchChipProTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Pro Tools" })
|
@{ Name = "WPFSearchChipMultimediaTools"; Category = "Multimedia Tools" }
|
||||||
$sync["WPFSearchChipSelfhostedTools"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Selfhosted Tools" })
|
@{ Name = "WPFSearchChipProTools"; Category = "Pro Tools" }
|
||||||
$sync["WPFSearchChipUtilities"].Add_Click({ Set-WinUtilAppCategoryFilter -Category "Utilities" })
|
@{ 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({
|
$sync["Form"].Add_Loaded({
|
||||||
param($e)
|
param($e)
|
||||||
|
|||||||
+57
-16
@@ -997,6 +997,44 @@
|
|||||||
</Setter.Value>
|
</Setter.Value>
|
||||||
</Setter>
|
</Setter>
|
||||||
</Style>
|
</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>
|
</Window.Resources>
|
||||||
<Grid Background="{DynamicResource MainBackgroundColor}" ShowGridLines="False" Name="WPFMainGrid" Width="Auto" Height="Auto" HorizontalAlignment="Stretch">
|
<Grid Background="{DynamicResource MainBackgroundColor}" ShowGridLines="False" Name="WPFMainGrid" Width="Auto" Height="Auto" HorizontalAlignment="Stretch">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
@@ -1219,6 +1257,8 @@
|
|||||||
HorizontalAlignment="Right" VerticalAlignment="Center"
|
HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||||
Margin="0,0,2,0"
|
Margin="0,0,2,0"
|
||||||
FontFamily="Segoe MDL2 Assets"
|
FontFamily="Segoe MDL2 Assets"
|
||||||
|
ToolTip="Settings"
|
||||||
|
AutomationProperties.Name="Settings"
|
||||||
Content=""/>
|
Content=""/>
|
||||||
<Popup Name="SettingsPopup"
|
<Popup Name="SettingsPopup"
|
||||||
IsOpen="False"
|
IsOpen="False"
|
||||||
@@ -1313,26 +1353,27 @@
|
|||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
</Grid.RowDefinitions>
|
</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">
|
<WrapPanel Grid.Row="0" Orientation="Horizontal" Margin="5,5,5,5" Name="WPFSearchChips">
|
||||||
<TextBlock Text="Filters"
|
<TextBlock Text=""
|
||||||
FontSize="{DynamicResource HeaderFontSize}"
|
FontFamily="Segoe MDL2 Assets"
|
||||||
FontFamily="{DynamicResource HeaderFontFamily}"
|
FontSize="{DynamicResource IconFontSize}"
|
||||||
Foreground="{DynamicResource LabelboxForegroundColor}"
|
Foreground="{DynamicResource LabelboxForegroundColor}"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Margin="15,0,8,0"/>
|
Margin="10,0,10,0"
|
||||||
<Button Name="WPFSearchChipAll" Content="All" Style="{StaticResource FilterChipStyle}"/>
|
ToolTip="Filter by category. Ctrl click to select more than one."/>
|
||||||
<Button Name="WPFSearchChipBrowsers" Content="Browsers" Style="{StaticResource FilterChipStyle}"/>
|
<ToggleButton Name="WPFSearchChipAll" Content="All" Style="{StaticResource FilterChipToggleStyle}" IsChecked="True"/>
|
||||||
<Button Name="WPFSearchChipCommunications" Content="Communications" Style="{StaticResource FilterChipStyle}"/>
|
<ToggleButton Name="WPFSearchChipBrowsers" Content="Browsers" Style="{StaticResource FilterChipToggleStyle}"/>
|
||||||
<Button Name="WPFSearchChipDevelopment" Content="Development" Style="{StaticResource FilterChipStyle}"/>
|
<ToggleButton Name="WPFSearchChipCommunications" Content="Communications" Style="{StaticResource FilterChipToggleStyle}"/>
|
||||||
<Button Name="WPFSearchChipDocument" Content="Document" Style="{StaticResource FilterChipStyle}"/>
|
<ToggleButton Name="WPFSearchChipDevelopment" Content="Development" Style="{StaticResource FilterChipToggleStyle}"/>
|
||||||
<Button Name="WPFSearchChipGames" Content="Games" Style="{StaticResource FilterChipStyle}"/>
|
<ToggleButton Name="WPFSearchChipDocument" Content="Document" Style="{StaticResource FilterChipToggleStyle}"/>
|
||||||
<Button Name="WPFSearchChipMicrosoftTools" Content="Microsoft Tools" Style="{StaticResource FilterChipStyle}"/>
|
<ToggleButton Name="WPFSearchChipGames" Content="Games" Style="{StaticResource FilterChipToggleStyle}"/>
|
||||||
<Button Name="WPFSearchChipMultimediaTools" Content="Multimedia Tools" Style="{StaticResource FilterChipStyle}"/>
|
<ToggleButton Name="WPFSearchChipMicrosoftTools" Content="Microsoft Tools" Style="{StaticResource FilterChipToggleStyle}"/>
|
||||||
<Button Name="WPFSearchChipProTools" Content="Pro Tools" Style="{StaticResource FilterChipStyle}"/>
|
<ToggleButton Name="WPFSearchChipMultimediaTools" Content="Multimedia Tools" Style="{StaticResource FilterChipToggleStyle}"/>
|
||||||
<Button Name="WPFSearchChipSelfhostedTools" Content="Selfhosted Tools" Style="{StaticResource FilterChipStyle}"/>
|
<ToggleButton Name="WPFSearchChipProTools" Content="Pro Tools" Style="{StaticResource FilterChipToggleStyle}"/>
|
||||||
<Button Name="WPFSearchChipUtilities" Content="Utilities" Style="{StaticResource FilterChipStyle}"/>
|
<ToggleButton Name="WPFSearchChipSelfhostedTools" Content="Selfhosted Tools" Style="{StaticResource FilterChipToggleStyle}"/>
|
||||||
|
<ToggleButton Name="WPFSearchChipUtilities" Content="Utilities" Style="{StaticResource FilterChipToggleStyle}"/>
|
||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
|
|
||||||
<Grid Grid.Row="1" Margin="{DynamicResource TabContentMargin}">
|
<Grid Grid.Row="1" Margin="{DynamicResource TabContentMargin}">
|
||||||
|
|||||||
Reference in New Issue
Block a user