For anyone in an environment with multiple customers, packaging, configuring and deploying the same app multiple times seems such a waste of valuable time, especially when having to do so with every update too!
Some thanks first, this script has come together with some help from others;
First up, Niels Kok for his excellent Packaging Script which gave me inspiration for this one
Also to Nickolaj Andersen and his Intune module which does a lot of the work on this one
And Phil Jorgensen for his wizardry in getting Winget to work in the System context
Now, on to the script. This one takes a custom Winget YAML manifest, extracts the juicy details and creates a Win32 app, assignment groups, detection methods, uploads and assigns. Create the YAML, run the script and deploy, easy as that. Using a custom manifest of course means the same can be deployed to multiple tenants, but with only one manifest to update with new app versions!
As always, the raw script is on Github and an example manifest is available here. You can also download from PSGallery to make it quicker to deploy apps
Install-Script -Name add-winget-package
Usage: add-winget-package -yamlfile http://url-here
First up, we need to install and import 3 modules: Powershell-YAML, IntuneWin32App and Microsoft.Graph (all as current user to avoid any pesky admin rights restrictions).
#Install MS Graph if not available
if (Get-Module -ListAvailable -Name powershell-yaml) {
Write-Host "PowerShell YAML Already Installed"
}
else {
try {
Install-Module -Name powershell-yaml -Scope CurrentUser -Repository PSGallery -Force
}
catch [Exception] {
$_.message
exit
}
}
Write-Host "Installing Microsoft Graph modules if required (current user scope)"
#Install MS Graph if not available
if (Get-Module -ListAvailable -Name Microsoft.Graph) {
Write-Host "Microsoft Graph Already Installed"
}
else {
try {
Install-Module -Name Microsoft.Graph -Scope CurrentUser -Repository PSGallery -Force
}
catch [Exception] {
$_.message
exit
}
}
#Install IntuneWin32App if not available
if (Get-Module -ListAvailable -Name IntuneWin32App ) {
Write-Host "IntuneWin32App Module Already Installed"
}
else {
try {
Install-Module -Name IntuneWin32App -Scope CurrentUser -Repository PSGallery -Force -AllowClobber -AcceptLicense
}
catch [Exception] {
$_.message
exit
}
}
#Importing Modules
Import-Module powershell-yaml
import-module IntuneWin32App
Import-Module microsoft.graph
Next we need to connect to MG Graph for the group creation and Intune Graph for everything else. You get 2 login boxes, but the second one should discover the account so won’t need the password.
I’m also grabbing the tenant ID directly from Azure to avoid any further input
Select-MgProfile -Name Beta
Connect-MgGraph -Scopes RoleAssignmentSchedule.ReadWrite.Directory, Domain.Read.All, Domain.ReadWrite.All, Directory.Read.All, Policy.ReadWrite.ConditionalAccess, DeviceManagementApps.ReadWrite.All, DeviceManagementConfiguration.ReadWrite.All, DeviceManagementManagedDevices.ReadWrite.All, openid, profile, email, offline_access
#Get Tenant ID
$uri = "https://graph.microsoft.com/beta/organization"
$tenantdetails = (Invoke-MgGraphRequest -Uri $uri -Method Get -OutputType PSObject).value
$tenantid = $tenantdetails.id
Connect-MSIntuneGraph -TenantID $tenantId
Now we need a working directory so I’m creating a random named folder within Temp so it’s well hidden
$directory = $env:TEMP
#Create Temp location
$random = Get-Random -Maximum 1000
$random = $random.ToString()
$date =get-date -format yyMMddmmss
$date = $date.ToString()
$path2 = $random + "-" + $date
$path = $directory + "\" + $path2 + "\"
new-item -ItemType Directory -Path $path
We need the filename to import the YAML later and get important details
$filename = $yamlFile.Substring($yamlFile.LastIndexOf("/") + 1)
Before downloading the YAML, time to have a look at it
If we look at the Official Manifest Specifications it is pretty strict on the items, but the Tags is basically free text with up to 16 items, so I’m going to use that for some of the extra fields we need, I’m adding an ICON path, Detection file type, Uninstall Command and the Azure AD Group names for Install and Uninstalling.
PackageIdentifier: Foxit.FoxitReader
Publisher: Foxit
PackageName: FoxitReader
PackageVersion: 11.0.0.49893
License: 2020 © Foxit Software Incorporated. All rights reserved.
InstallerType: exe
Tags:
- PDF
- Foxit
- ICON=https://cdn.imgbin.com/1/24/20/imgbin-foxit-reader-pdf-foxit-software-computer-icons-adobe-acrobat-top-view-m5LPTpiUBTVMVjaHzkfHBRtDb.jpg
- DETECTION="c:\program files(x86)\Foxit Software\Foxit Reader\FoxitReader.exe"
- UNINSTALLCOMMAND="%ProgramFiles%\Foxit Software\Foxit Reader\unins000.exe" /silent
- ADGROUPI=Intune-App-FoxitReader-Install
- ADGROUPU=Intune-App-FoxitReader-Uninstall
ShortDescription: Industry's Most Powerful PDF Reader.
PackageUrl: https://www.foxitsoftware.com/pdf-editor/
Installers:
- Architecture: x86
InstallerUrl: https://cdn01.foxitsoftware.com/product/reader/desktop/win/11.0.0/FoxitReader110_L10N_Setup_Prom.exe
InstallerSha256: 6D33CA56B5C6E7F412FF7E7AF6A78036DF4E7AFFD1754975CA9A0A89DF9D570C
InstallerSwitches:
Silent: /silent /COMPONENTS="pdfviewer,ffSpellCheck,ffse" CPDF_DISABLE="1" /TASKS="startmenufolder"
SilentWithProgress: /silent /COMPONENTS="pdfviewer,ffSpellCheck,ffse" CPDF_DISABLE="1" /TASKS="startmenufolder"
PackageLocale: en-US
ManifestType: singleton
ManifestVersion: 1.0.0
MinimumOSVersion: 10.0.18362.0
So now we download it to the temp location we made earlier
Invoke-WebRequest `
-Uri $yamlFile `
-OutFile $templateFilePath `
-UseBasicParsing `
-Headers @{"Cache-Control"="no-cache"}
Now, import the YAML and grab the fields from the array
[string[]]$fileContent = Get-Content $templateFilePath
$content = ''
foreach ($line in $fileContent) { $content = $content + "`n" + $line }
$obj = ConvertFrom-Yaml $content
$tags = $obj.Tags
foreach ($tag in $tags) {
if ($tag -like '*ICON*') {
$icon = $tag
}
if ($tag -like '*DETECTION*') {
$detection = $tag
}
if ($tag -like 'UNINSTALLCOMMAND*') {
$uninstall = $tag
}
if ($tag -like '*ADGROUPI*') {
$adgroupi = $tag
}
if ($tag -like '*ADGROUPU*') {
$adgroupu = $tag
}
}
For each variable we have created, we need to split on the “=” to grab the value
In the case of the icon, we also need the file name and path to save to
$icon2 = $icon -split '='
$iconpath = $icon2[1]
$iconname = $iconpath.Substring($iconpath.LastIndexOf("/") + 1)
$icondownload = $path + $iconname
##Download Icon
Invoke-WebRequest `
-Uri $iconpath `
-OutFile $icondownload `
-UseBasicParsing `
-Headers @{"Cache-Control"="no-cache"}
Now to create the other variables
$detection2 = $detection -split '='
$detectionrule = $detection2[1]
$uninstall2 = $uninstall -split '='
$uninstallcommand = $uninstall2[1]
$adgroupi2 = $adgroupi -split '='
$adgroupinstall = $adgroupi2[1]
$adgroupu2 = $adgroupu -split '='
$adgroupuninstall = $adgroupu2[1]
$publisher = $obj.publisher
$name = $obj.packagename
$description = $obj.shortdescription
$appversion = $obj.PackageVersion
$infourl = $obj.PackageUrl
Create the Entra ID groups
$groupname1 = $name + "-INSTALL"
#Create Install Group
$installgroup = New-MgGroup -DisplayName $adgroupinstall -Description "Install group for $name" -SecurityEnabled -MailEnabled:$false -MailNickName "group"
$groupname2 = $name + "-UNINSTALL"
#Create Uninstall Group
$uninstallgroup = New-MgGroup -DisplayName $adgroupuninstall -Description "Uninstall group for $name" -SecurityEnabled -MailEnabled:$false -MailNickName "group"
Next we need to create the install.ps1 file which we’ll wrap into the win32 package. We need to pass through the filename for the manifest so it knows what to use and then call winget pointing to the manifest file. We’re dumping this info into the setup file itself. You’ll note that we don’t even have an installer at this point, we’ll let Winget sort that bit so the packages we are creating here are tiny!
$setupfile = "$path$name-Install.ps1"
$setupfilename = "$name-Install.ps1"
##Create Install File
Set-Content $setupfile @'
$filename2 =
'@ -NoNewline
add-Content $setupfile @"
"$filename"
"@
add-Content $setupfile @'
$filename = $filename2.Substring($filename2.LastIndexOf("/") + 1)
$curDir = Get-Location
$filebase = Join-Path $curDir $filename
$Winget = Get-ChildItem -Path (Join-Path -Path (Join-Path -Path $env:ProgramFiles -ChildPath "WindowsApps") -ChildPath "Microsoft.DesktopAppInstaller*_x64*\Winget.exe")
Start-Process -NoNewWindow -FilePath $winget -ArgumentList "settings --enable LocalManifestFiles"
Start-Process -NoNewWindow -FilePath $winget -ArgumentList "install --silent --manifest $filename"
'@
Now we hand over to the Intunewin32 app PS module to do the creation and uploading. I’m also using the extra functionality to add an icon as well for a better user experience. I’m also populating many other fields from the data in the manifest (you can see the end result at the bottom)
# Package as .intunewin file
$SourceFolder = $path
$OutputFolder = $path
New-IntuneWin32AppPackage -SourceFolder $SourceFolder -SetupFile $setupfilename -OutputFolder $OutputFolder -Verbose
$IntuneWinFile = Get-ChildItem -Path $path | Where-Object Name -Like "*.intunewin"
$IntuneWinFile.Name
# Create custom display name like 'Name' and 'Version'
$DisplayName = $name
# Create detection rule
$DetectionRule = New-IntuneWin32AppDetectionRuleFile -Existence -Path "$fpath" -FileOrFolder $fname -Check32BitOn64System $false -DetectionType "exists"
# Add new EXE Win32 app
$InstallationScriptFile = Get-ChildItem -Path $path | Where-Object Name -Like "*-Install.ps1"
$InstallCommandLine = "powershell.exe -ExecutionPolicy Bypass -File .\$($InstallationScriptFile.Name)"
$UninstallCommandLine = $uninstallcommand
$ImageFile = $icondownload
$Icon = New-IntuneWin32AppIcon -FilePath $ImageFile
Add-IntuneWin32App -FilePath $IntuneWinFile.FullName -DisplayName $DisplayName -Description $description -Publisher $publisher -AppVersion $appversion -InformationURL $infourl -Icon $Icon -InstallExperience "system" -RestartBehavior "suppress" -DetectionRule $DetectionRule -InstallCommandLine $InstallCommandLine -UninstallCommandLine $UninstallCommandLine -Verbose
Finally, we need to assign it
##Assignments
$Win32App = Get-IntuneWin32App -DisplayName $DisplayName -Verbose
#Install
$installid = $installgroup.Id
Add-IntuneWin32AppAssignmentGroup -Include -ID $Win32App.id -GroupID $installid -Intent "available" -Notification "showAll" -Verbose
#Uninstall
$uninstallid = $uninstallgroup.Id
Add-IntuneWin32AppAssignmentGroup -Include -ID $Win32App.id -GroupID $uninstallid -Intent "uninstall" -Notification "showAll" -Verbose
Here is the end result:
Using this, we can package any application, even to the point of storing the installers and manifests in an Azure Blob for custom applications.
And even better, when you update an application, you can use the same process to push out using the winget upgrade –manifest command!
Absolutely LOVE this idea but I cannot get this to work. I’ve ran add-winget-package.ps1 -yamlFile https://github.com/andrew-s-taylor/winget/blob/main/manifests/f/Foxit/FoxitReader/11.0.0.49893/Foxit.FoxitReader.yaml and it seems to package everything but fails when uploading to Intune. I’ve tried my own yaml as well but getting the exact same errors each time, that’s why I tried the existing FoxitReader one in case something in my yaml was wrong.
Here is what I’m getting, any help is much appreciated and great work on this tool!!
-Install.intunewin
New-IntuneWin32AppDetectionRuleFile : Cannot validate argument on parameter ‘Path’. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
At C:\Program Files\WindowsPowerShell\Scripts\add-winget-package.ps1:244 char:75
+ … New-IntuneWin32AppDetectionRuleFile -Existence -Path “$fpath” -FileOr …
+ ~~~~~~~~
+ CategoryInfo : InvalidData: (:) [New-IntuneWin32AppDetectionRuleFile], ParameterBindingValidationException
+ FullyQualifiedErrorId : ParameterArgumentValidationError,New-IntuneWin32AppDetectionRuleFile
WARNING: 419-2202284207 contains unsupported file extension. Supported extensions are ‘.png’, ‘.jpg’ and ‘.jpeg’
New-IntuneWin32AppIcon : Cannot validate argument on parameter ‘FilePath’. System error.
At C:\Program Files\WindowsPowerShell\Scripts\add-winget-package.ps1:251 char:46
+ $Icon = New-IntuneWin32AppIcon -FilePath $ImageFile
+ ~~~~~~~~~~
+ CategoryInfo : InvalidData: (:) [New-IntuneWin32AppIcon], ParameterBindingValidationException
+ FullyQualifiedErrorId : ParameterArgumentValidationError,New-IntuneWin32AppIcon
Add-IntuneWin32App : Cannot validate argument on parameter ‘DisplayName’. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
At C:\Program Files\WindowsPowerShell\Scripts\add-winget-package.ps1:252 char:71
+ … p -FilePath $IntuneWinFile.FullName -DisplayName $DisplayName -Descri …
+ ~~~~~~~~~~~~
+ CategoryInfo : InvalidData: (:) [Add-IntuneWin32App], ParameterBindingValidationException
+ FullyQualifiedErrorId : ParameterArgumentValidationError,Add-IntuneWin32App
Get-IntuneWin32App : Cannot validate argument on parameter ‘DisplayName’. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
At C:\Program Files\WindowsPowerShell\Scripts\add-winget-package.ps1:256 char:49
+ $Win32App = Get-IntuneWin32App -DisplayName $DisplayName -Verbose
+ ~~~~~~~~~~~~
+ CategoryInfo : InvalidData: (:) [Get-IntuneWin32App], ParameterBindingValidationException
+ FullyQualifiedErrorId : ParameterArgumentValidationError,Get-IntuneWin32App
Add-IntuneWin32AppAssignmentGroup : Cannot validate argument on parameter ‘ID’. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
At C:\Program Files\WindowsPowerShell\Scripts\add-winget-package.ps1:260 char:48
+ Add-IntuneWin32AppAssignmentGroup -Include -ID $Win32App.id -GroupID …
+ ~~~~~~~~~~~~
+ CategoryInfo : InvalidData: (:) [Add-IntuneWin32AppAssignmentGroup], ParameterBindingValidationException
+ FullyQualifiedErrorId : ParameterArgumentValidationError,Add-IntuneWin32AppAssignmentGroup
Add-IntuneWin32AppAssignmentGroup : Cannot validate argument on parameter ‘ID’. The argument is null or empty. Provide an argument that is not null or empty, and then try the command again.
At C:\Program Files\WindowsPowerShell\Scripts\add-winget-package.ps1:265 char:48
+ Add-IntuneWin32AppAssignmentGroup -Include -ID $Win32App.id -GroupID …
+ ~~~~~~~~~~~~
+ CategoryInfo : InvalidData: (:) [Add-IntuneWin32AppAssignmentGroup], ParameterBindingValidationException
+ FullyQualifiedErrorId : ParameterArgumentValidationError,Add-IntuneWin32AppAssignmentGroup
Hi Jase,
Can you check in the temp directory, there should be a new directory with a random number and the date the script was run in there.
Check if the YAML file and icon are in there. It looks like the script isn’t finding any of the YAML data which would imply it hasn’t downloaded properly.
If it isn’t downloading, you can always hard-code a download directory at the top of the script:
$directory = $env:TEMP
#Create Temp location
$random = Get-Random -Maximum 1000
$random = $random.ToString()
$date =get-date -format yyMMddmmss
$date = $date.ToString()
$path2 = $random + “-” + $date
$path = $directory + “\” + $path2 + “\”
new-item -ItemType Directory -Path $path
Hopefully that fixes it, but please let me know if not