Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,13 @@ Each entry in `Applications` declares what to do and what to do it to:
| `OpenUrl` | Opens a URL in the default browser |
| `CheckProcess` | Verifies a process is running; `Target` is an array of names |
| `CheckPath` | Verifies a path exists; launches nothing |
| `CheckWinget` | Verifies a winget package id is installed; installs nothing |

`CheckWinget` asks App Installer whether a package id is installed, which is
stable where an install path is not. It never installs or upgrades anything. On
an image without winget — LTSC and stripped images — the run reports that once
as a warning and every `CheckWinget` entry becomes a **Manual Step** to verify by
hand.

`Environment` and `FormFactor` filter an entry to matching machines. A filtered-out
target is reported **Not Applicable** in the checklist rather than omitted, so the
Expand Down
14 changes: 14 additions & 0 deletions WinContextDeploy.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
# CheckProcess verify a process is running, launch nothing
# (Target is an array of process names)
# CheckPath verify a path exists, launch nothing
# CheckWinget verify a winget package id is installed,
# launch nothing, install nothing (Target is
# the exact package id)
# Target Path, URL, command, or array of process names. Required.
# Environment Restrict to 'Workstation' or 'Vdi'. Omit to always apply.
# FormFactor Restrict to 'Laptop' or 'Desktop'. Omit to always apply.
Expand Down Expand Up @@ -145,6 +148,17 @@
Prompt = $true
}

# Verify a package that arrived through App Installer. The package id
# is stable where an install path is not. Never installs anything, and
# on an image without winget the entry becomes a Manual Step.
# @{
# Step = 'AppPowerToys'
# Name = 'PowerToys'
# Action = 'CheckWinget'
# Target = 'Microsoft.PowerToys'
# Optional = $true
# }

# @{
# Step = 'AppFleetTelemetry'
# Name = 'Fleet telemetry portal'
Expand Down
Binary file modified docs/manual.pdf
Binary file not shown.
10 changes: 10 additions & 0 deletions docs/manual.typ
Original file line number Diff line number Diff line change
Expand Up @@ -648,8 +648,18 @@ The manifest-driven core of the tool. It walks the `Applications` list in
[`OpenUrl`], [Opens a URL in the default browser.],
[`CheckProcess`], [Verifies a process is running. Launches nothing. `Target` is a list of process names, and any one of them counts.],
[`CheckPath`], [Verifies a path exists. Launches nothing.],
[`CheckWinget`], [Verifies a winget package id is installed. Launches nothing, and installs nothing. `Target` is the exact package id.],
)

#note[
`CheckWinget` exists because an install path moves between versions and
between machines — a per-user MSIX lands somewhere quite different from a
per-machine MSI — while the package id does not. winget itself is missing from
LTSC and stripped images: the run probes for it once, reports that as a single
warning, and marks every `CheckWinget` entry as a Manual Step to verify by
hand. One honest cause, and rows a technician can act on.
]

Adding, removing or reordering an application is a manifest edit. No code
changes, no new module.

Expand Down
123 changes: 119 additions & 4 deletions src/Config-Applications.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,71 @@
# Adding, removing or reordering an application is a manifest edit; nothing in
# this file needs to change for it.

$script:WcdApplicationActions = @('Launch', 'OpenFolder', 'OpenUrl', 'CheckProcess', 'CheckPath')
$script:WcdApplicationActions = @('Launch', 'OpenFolder', 'OpenUrl', 'CheckProcess', 'CheckPath', 'CheckWinget')

function Test-WcdWingetAvailable {
<#
.SYNOPSIS
Reports whether winget.exe exists on this machine.

.DESCRIPTION
App Installer is absent from LTSC and stripped images, so a CheckWinget
target cannot be verified there at all. Probed once per run rather than
once per target, so a missing App Installer reads as one cause instead of
N identical failures.

.OUTPUTS
[bool] $true when winget.exe can be resolved.

.EXAMPLE
if (-not (Test-WcdWingetAvailable)) { 'verify by hand' }
#>
[CmdletBinding()]
param()

return ($null -ne (Get-Command -Name 'winget.exe' -ErrorAction SilentlyContinue))
}

function Test-WcdWingetPackageInstalled {
<#
.SYNOPSIS
Reports whether winget lists a package id as installed.

.DESCRIPTION
Verify only: this never installs or upgrades anything. The agreement and
interactivity flags are required, not polish - a winget that has never
run otherwise blocks on a source-agreement prompt, and nothing in a
one-shot run may stop for input. Without --exact a bare name matches
several packages and the check means nothing.

Exit code 0 means installed. The 'no applications found' code means
absent. Any other code means winget itself failed, which throws rather
than being reported as an absent package.

.PARAMETER Id
Exact winget package id, for example 'Microsoft.PowerToys'.

.OUTPUTS
[bool] $true when the package is installed.

.EXAMPLE
Test-WcdWingetPackageInstalled -Id 'Microsoft.PowerToys'
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Id
)

$output = & winget.exe list --id $Id --exact --accept-source-agreements --disable-interactivity 2>&1
$exitCode = $LASTEXITCODE

if ($exitCode -eq 0) { return $true }
# APPINSTALLER_CLI_ERROR_NO_APPLICATIONS_FOUND (0x8a150014): not installed.
if ($exitCode -eq -1978335212) { return $false }

throw ('winget list failed for {0} (exit {1}): {2}' -f $Id, $exitCode, ((($output | Where-Object { $_ -match '\S' }) -join ' ').Trim()))
}

function Test-WcdTargetPresent {
<#
Expand All @@ -15,7 +79,8 @@ function Test-WcdTargetPresent {
Only targets that look like filesystem paths are checked here. A bare
command such as 'ms-teams.exe' is resolved by the shell, and OpenUrl and
CheckProcess targets are not paths at all, so all three are reported
present and left for Invoke-WcdApplicationTarget to attempt.
present and left for Invoke-WcdApplicationTarget to attempt. A
CheckWinget target is a package id, so it is asked of winget instead.

.PARAMETER Entry
One Application Target entry from the manifest.
Expand All @@ -36,6 +101,8 @@ function Test-WcdTargetPresent {
# us. Only targets that look like filesystem paths are checked up front.
if ($Entry.Action -eq 'OpenUrl') { return $true }
if ($Entry.Action -eq 'CheckProcess'){ return $true }
# A package id is not a path: winget is the only thing that can answer.
if ($Entry.Action -eq 'CheckWinget') { return (Test-WcdWingetPackageInstalled -Id ([string]$Entry.Target)) }
if ([string]$Entry.Target -notmatch '[\\/]') { return $true }

return (Test-Path -LiteralPath ([string]$Entry.Target))
Expand Down Expand Up @@ -72,6 +139,7 @@ function Invoke-WcdApplicationTarget {
'OpenFolder' { Start-Process 'explorer.exe' -ArgumentList ([string]$Entry.Target) -ErrorAction Stop }
'OpenUrl' { Open-WcdUrl -Url ([string]$Entry.Target) }
'CheckPath' { } # presence already established by Test-WcdTargetPresent
'CheckWinget'{ } # ditto - and this Action never installs anything
'CheckProcess' {
$running = @(Get-Process -Name @($Entry.Target) -ErrorAction SilentlyContinue)
if ($running.Count -eq 0) {
Expand All @@ -97,6 +165,10 @@ function Set-WcdApplicationsConfiguration {
declared Optional is a note; an absent required target is a warning naming
the manifest key to fix.

CheckWinget targets need winget itself, which some images do not have.
It is probed once before the loop: absent, that is one warning and every
CheckWinget entry becomes a Manual Step rather than a failure.

.PARAMETER Targets
Application Target entries to run, already filtered for the current
Environment, Form Factor and selected Optional Tools by
Expand Down Expand Up @@ -139,17 +211,53 @@ function Set-WcdApplicationsConfiguration {
return @([pscustomobject]@{ Step = 'ApplicationsSkip'; Success = $true; Error = ''; Severity = 'INFO' })
}

# winget is absent from LTSC and stripped images. Probe once, before the
# loop: one honest cause and N actionable rows beats N identical failures
# all pointing back at the same missing App Installer.
$wingetAvailable = $true
if (@($Targets | Where-Object { $_.Action -eq 'CheckWinget' }).Count -gt 0) {
$wingetAvailable = Test-WcdWingetAvailable
if (-not $wingetAvailable) {
$wingetDetail = 'winget unavailable - App Installer not provisioned on this image'
Write-WcdLog -Path $resolvedLogPath -Level 'WARNING' -Message $wingetDetail
$results += [pscustomobject]@{
Step = 'WingetUnavailable'
Success = $true
Error = $wingetDetail
Severity = 'WARNING'
RemedyKey = 'WingetMissing'
RemedyArgs = @()
}
}
}

foreach ($entry in @($Targets)) {
$step = [string]$entry.Step
Invoke-WcdProgressCallback -ProgressCallback $ProgressCallback -ModuleName $moduleName -StepKey $step -Event 'Start'

if ($entry.Action -eq 'CheckWinget' -and -not $wingetAvailable) {
# The cause is reported once above; each package is then a Manual
# Step rather than a package that is genuinely missing. The
# checklist row names it, so the detail does not repeat the name.
$detail = 'winget unavailable - verify by hand.'

Write-WcdLog -Path $resolvedLogPath -Level 'INFO' -Message ('{0}: {1}' -f $entry.Name, $detail)
Invoke-WcdProgressCallback -ProgressCallback $ProgressCallback -ModuleName $moduleName -StepKey $step -Event 'Finish' -Kind 'warning'
$results += [pscustomobject]@{ Step = $step; Success = $true; Error = $detail; Severity = 'MANUAL' }
continue
}

try {
if (-not (Test-WcdTargetPresent -Entry $entry)) {
# Absent and declared Optional is a normal outcome on many
# machines; absent and required is worth a warning.
$severity = if ($entry.Optional) { 'INFO' } else { 'WARNING' }
$kind = if ($entry.Optional) { 'success' } else { 'warning' }
$detail = '{0} not found at {1}' -f $entry.Name, $entry.Target
$detail = if ($entry.Action -eq 'CheckWinget') {
'winget does not list {0} as installed ({1})' -f $entry.Name, $entry.Target
} else {
'{0} not found at {1}' -f $entry.Name, $entry.Target
}

Write-WcdLog -Path $resolvedLogPath -Level 'INFO' -Message $detail
Invoke-WcdProgressCallback -ProgressCallback $ProgressCallback -ModuleName $moduleName -StepKey $step -Event 'Finish' -Kind $kind
Expand All @@ -160,7 +268,9 @@ function Set-WcdApplicationsConfiguration {
Severity = $severity
# An Optional target that is simply absent is a normal
# outcome, so it gets a note but no call to action.
RemedyKey = if ($entry.Optional) { '' } else { 'TargetMissing' }
RemedyKey = if ($entry.Optional) { '' }
elseif ($entry.Action -eq 'CheckWinget') { 'WingetPackageMissing' }
else { 'TargetMissing' }
RemedyArgs = @([string]$entry.Target, [string]$entry.Name)
}
continue
Expand All @@ -186,6 +296,11 @@ function Set-WcdApplicationsConfiguration {
} elseif ($entry.Action -eq 'CheckProcess') {
$remedyKey = 'ProcessNotRunning'
$remedyArgs = @([string]$entry.Name)
} elseif ($entry.Action -eq 'CheckWinget') {
# Reaching here means winget ran and failed, not that the
# package is absent - that is handled as a missing target.
$remedyKey = 'WingetCheckFailed'
$remedyArgs = @([string]$entry.Name, [string]$entry.Target)
} else {
$remedyKey = 'TargetLaunchFailed'
$remedyArgs = @([string]$entry.Target, [string]$entry.Name)
Expand Down
21 changes: 21 additions & 0 deletions src/Invoke-WcdConfiguration.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ $T = if ($ScriptUI -eq 'EN') {
Helpdesk = 'Helpdesk portal'
Favorites = 'Browser favorites'
Network = 'Network adapters'
Winget = 'App Installer (winget)'
DiskHealth = 'Disk health'
DiskFreeSpace = 'Free space'
Tpm = 'TPM readiness'
Expand Down Expand Up @@ -283,6 +284,9 @@ $T = if ($ScriptUI -eq 'EN') {
TargetMissing = "Not found at {0}. Update Applications['{1}'].Target in WinContextDeploy.psd1, or remove the entry."
TargetLaunchFailed = "Could not start {0}. Check Applications['{1}'].Target and Action in WinContextDeploy.psd1."
ProcessNotRunning = 'Confirm {0} is installed and started, or mark the entry Optional in WinContextDeploy.psd1.'
WingetMissing = 'App Installer (winget) is not provisioned on this image, so the packages below could not be checked. Verify them by hand, or install App Installer from the Microsoft Store.'
WingetPackageMissing = "winget does not list {0} as installed. Confirm imaging delivered it, or fix the package id in Applications['{1}'].Target in WinContextDeploy.psd1."
WingetCheckFailed = "winget could not check {0}. Run 'winget list --id {1} --exact' by hand to see why."
UnknownAction = "Unknown Action '{0}' for step '{1}'. Valid: {2}."
RequiresAdmin = 'Requires Administrator. Relaunch elevated to apply.'
PowerCfgFailed = 'powercfg refused the change. Check that no Group Policy pins the power plan.'
Expand Down Expand Up @@ -329,6 +333,7 @@ $T = if ($ScriptUI -eq 'EN') {
Helpdesk = 'Portail de soutien'
Favorites = 'Favoris du navigateur'
Network = 'Adaptateurs reseau'
Winget = 'App Installer (winget)'
DiskHealth = 'Sante du disque'
DiskFreeSpace = 'Espace libre'
Tpm = 'Etat du TPM'
Expand Down Expand Up @@ -449,6 +454,9 @@ $T = if ($ScriptUI -eq 'EN') {
TargetMissing = "Introuvable a {0}. Corriger Applications['{1}'].Target dans WinContextDeploy.psd1, ou retirer l entree."
TargetLaunchFailed = "Impossible de demarrer {0}. Verifier Applications['{1}'].Target et Action dans WinContextDeploy.psd1."
ProcessNotRunning = 'Confirmer que {0} est installe et demarre, ou marquer l entree Optional dans WinContextDeploy.psd1.'
WingetMissing = 'App Installer (winget) n est pas provisionne sur cette image, donc les paquets ci-dessous n ont pas pu etre verifies. Les verifier a la main, ou installer App Installer depuis le Microsoft Store.'
WingetPackageMissing = "winget ne liste pas {0} comme installe. Confirmer que l imagerie l a livre, ou corriger l identifiant de paquet dans Applications['{1}'].Target dans WinContextDeploy.psd1."
WingetCheckFailed = "winget n a pas pu verifier {0}. Lancer 'winget list --id {1} --exact' a la main pour voir pourquoi."
UnknownAction = "Action '{0}' inconnue pour l etape '{1}'. Valides: {2}."
RequiresAdmin = 'Exige les droits Administrateur. Relancer en tant qu administrateur pour appliquer.'
PowerCfgFailed = 'powercfg a refuse la modification. Verifier qu aucune GPO ne fige le mode de gestion d alimentation.'
Expand Down Expand Up @@ -1481,6 +1489,12 @@ function Resolve-WcdAutomaticEntry {
return New-WcdDiagnosticEntry -Label $Label -Kind 'warning' -Detail (Get-WcdAggregateDetail -Results $results -StepLabels $StepLabels) -Step $stepId
}

# A Step the Module could not run, and deliberately handed back to the
# technician, is a Manual Step rather than a failure.
if ($severity -eq 'MANUAL') {
return New-WcdDiagnosticEntry -Label $Label -Kind 'manual' -Detail (Get-WcdAggregateDetail -Results $results -StepLabels $StepLabels) -Step $stepId
}

if ($missingStepKeys.Count -gt 0) {
$missingLabels = @($missingStepKeys | ForEach-Object {
if ($StepLabels.ContainsKey($_)) { $StepLabels[$_] } else { $_ }
Expand Down Expand Up @@ -1725,6 +1739,13 @@ function Get-WcdFinalChecklistEntries {
-OptionalTools $ExecutionOptions.OptionalTools |
ForEach-Object { [string]$_.Step })

# Config-Applications reports the winget probe once, and only when the
# manifest has CheckWinget entries at all.
if ($lookup.ContainsKey('WingetUnavailable')) {
$entries += Resolve-WcdAutomaticEntry -Label $T.Checklist.Winget -ResultLookup $lookup `
-StepKeys @('WingetUnavailable') -StepLabels $StepLabels
}

foreach ($entry in @($Config.Applications)) {
$step = [string]$entry.Step
$name = [string]$entry.Name
Expand Down
3 changes: 2 additions & 1 deletion src/WcdHelpers.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ function Get-WcdResultSeverity {
A Step Result object.

.OUTPUTS
[string] 'ERROR', 'WARNING' or 'INFO'.
[string] 'ERROR', 'WARNING', 'MANUAL' or 'INFO'.

.EXAMPLE
Get-WcdResultSeverity -Result ([pscustomobject]@{ Step = 'X'; Success = $false }) # ERROR
Expand Down Expand Up @@ -561,6 +561,7 @@ function Get-WcdTechnicalStepLabels {
'DisplayLanguage' = 'Display language'
'KeyboardLayout' = 'Keyboard layout'
'ApplicationsSkip' = 'Applications skipped'
'WingetUnavailable' = 'App Installer (winget)'
'DeviceManagerStatus' = 'Device Manager'
'DiskHealth' = 'Disk health'
'DiskFreeSpace' = 'Free space'
Expand Down
Loading
Loading