Use PowerShell to Discover New Dell BIOS/Driver Updates Faster – Part 2

If you haven’t read my first post on this topic, please check it out for the background info on what this is all about:

For everyone else, on to the update! My original effort was pretty basic; I just wanted to feed in a list of models and then find out what the most recent release date was for a given item like a BIOS update. Then, I could go to the full drivers and downloads site and look up the rest of the info.

In this updated version of the code, I’m not just looking for the release dates, I’m parsing through each cell of each table of the desired section and capturing the details into custom objects.

BIOS results example:part2-bios

Video driver results example:part2-drivers2

I want to keep working on this as time allows and eventually turn it into a real cmdlet with parameters and all that fun stuff, but I think it’s reached another milestone of usability as it is, so I wanted to share. Anyway, check out the code comments for more details on my design decisions and let me know if you have any feedback. Thanks!


# initialize array of desired models
$models = @("Latitude 5480/5488",
            "Latitude E7450",
            "OptiPlex 7440 AIO",
            "Optiplex 9010",
            "Precision 5820 Tower",
            "Venue 7130 Pro/7139 Pro")

# set URI variables
$baseURI = ""
$pagesURI = $baseURI + "/published/Pages/"
$indexURI = $pagesURI + "index.html"

# set section ID variable
$sectionID = "Drivers-Category.BI-Type.BIOS"

# request the download index webpage
$dlIndex = Invoke-WebRequest -Uri $indexURI

# get all links from the webpage
$indexLinks = $dlIndex.Links

# initialize an empty array to store model results
$modelResults = @()

foreach ($model in $models)
    # set the link variable for the specific model webpage
    $modelLink = $indexLinks | Where-Object {$_.innerHTML -eq $model}

    # set the URI variable for the specific model webpage
    $modelURI = $pagesURI + $modelLink.href

    # request the specific model webpage
    $modelIndex = Invoke-WebRequest -Uri $modelURI

    # get webpage elements for the desired section ID
    $sectionIndex = $modelIndex.ParsedHtml.getElementsByTagName('DIV') | Where-Object {$ -eq $sectionID}

    # get webpage elements for the section rows
    $sectionRows = $sectionIndex.getElementsByTagName('TR')

    # initialize an empty array to store section results
    $sectionResults = @()

    # loop through each section row (skipping the first which only contains known header values)
    for ($secCounter = 1; $secCounter -lt ($sectionRows | Measure-Object).Count; $secCounter++)
        # get webpage elements for the row cells
        $sectionCells = $sectionRows[$secCounter].getElementsByTagName('TD')

        # loop through each row cell
        for ($cellCounter = 0; $cellCounter -lt ($sectionCells | Measure-Object).Count; $cellCounter++)
            # set Download cell value(s)
            if ($cellCounter -eq 5)
                # get hyperlink webpage elements for the download cell
                $cellLinks = $sectionCells[$cellCounter].getElementsByTagName('A')
                # get the download links and change them to https (seems to work better for actual downloading)
                $dlLinks = ($cellLinks | Select-Object -ExpandProperty href) -replace 'http://','https://'
                if ($dlLinks.Count -gt 1)
                    # for cells with multiple links, convert array to single string with newlines.
                    # this allows the final results to display like the other cells
                    $dlLinks = ($dlLinks -join [Environment]::NewLine | Out-String).TrimEnd()
                # set other cell values
                switch ($cellCounter)
                    '0' {$Description = $sectionCells[$cellCounter].innerText}
                    '1' {$Importance = $sectionCells[$cellCounter].innerText}
                    '2' {$Version = $sectionCells[$cellCounter].innerText}
                    '3' {$Released = ($sectionCells[$cellCounter].innerText | Get-Date)}
                    '4' {$SupportedOS = $sectionCells[$cellCounter].innerText}

        # add cell values for each row to the section results array
        $sectionResults += New-Object psobject -Property @{Description=$Description;
    # set variable for the latest date found in the section results array
    $latestDate = ($sectionResults.Released | Measure-Object -Maximum).Maximum

    # set variable for the latest release(s) found that match(es) the latest date
    $latestRelease = $sectionResults | Where-Object {$_.Released -eq $latestDate}

    foreach ($release in $latestRelease)
        # add the latest release row(s) to the model results array
        $modelResults += New-Object psobject -Property @{Model=$model;

# define desired properties to display
$properties = 'Model','Description','Released','Version','SupportedOS','Download'

# sort results by date
$sortedResults = $modelResults | Sort-Object -Property Released -Descending

# change the Released datetimes to short date strings so the unnecessary time part doesn't display
$sortedResults | ForEach-Object {$_.Released = $_.Released.ToShortDateString()}

# display results
$sortedResults | Select-Object -Property $properties | Out-GridView


Use PowerShell to Discover New Dell BIOS/Driver Updates Faster – Part 1

Update: Make sure to check out part 2 for updated code with some enhancements:

Original Post:

For the past several months, I’ve been using “modern” techniques to dynamically manage driver and BIOS updates within ConfigMgr/SCCM. There are several great community solutions out there, but I opted to go with Mike Terrill’s:

It works great and I can’t recommend it highly enough, especially if you already have your ConfigMgr deployments integrated with MDT.

Along with that, I’ve been using another great offering from the community to download, package, and distribute the driver/BIOS bits into ConfigMgr: Maurice Daly’s Driver Automation Tool:

This process works well and has made life a lot easier. I did start noticing something interesting after a while though…

When the process went live in production, I asked my colleagues to let me know if they noticed any newer BIOS versions available than the ones that are being installed via ConfigMgr so I could get them updated. After several reports of newer versions, I learned that some of them were installing the Dell SupportAssist app and installing newer updates from there. Some of these updates were very new, released only within the past few days.

I would then go back to the Driver Automation Tool to grab the latest updates. To my surprise it seemed that more often than not, the tool would not find any of these new updates. Behind the scenes, the tool uses a .cab file provided by Dell as the catalog of available updates:

So, apparently, the SupportAssist app has access to updates that have not yet been added to the .cab file.

I then tried to figure out if there was a way I could be notified of these updates proactively, perhaps something like an RSS feed (If I recall correctly, Dell did at one point have an RSS feed for updates, but it is now discontinued.) The current option is to sign up for email alerts:

This is problematic, because I have about 40 models that I need to support, and each model requires its own subscription. I ended up slogging through creating these subscriptions, but since then, I haven’t received any notifications, despite the fact that several new BIOS versions have been installed on my systems in that time via the SupportAssist app.

At this point you might be thinking that I’m being nitpicky – and I’ll admit that this is definitely more of a “nice to have” thing – but is there really no better/easier way to find out what new updates are available without waiting for them to be included in the driver pack catalog? After some investigation, I think there might be…

It turns out that Dell has a webpage with a “simplified interface” and direct links to product support pages that list available driver and BIOS downloads:

With a little bit of PowerShell, these pages can be scraped to discover new driver/BIOS updates. Here’s the code, with some explanation below:

# Initialize array of desired models
$models = @("Latitude E7240 Ultrabook",
            "OptiPlex 7060",
            "OptiPlex 7460 All In One",
            "Optiplex 9010",
            "Precision 5820 Tower",
            "Venue 7130 Pro/7139 Pro")

# Set URI variables
$baseURI = ""
$indexURI = $baseURI + "index.html"

# Set search variables
$sectionID = "Drivers-Category.BI-Type.BIOS"
$datePattern = "*/*/201*"

# Scrape the download index webpage
$dlIndex = Invoke-WebRequest -Uri $indexURI

# Get all links from the webpage
$indexLinks = $dlIndex.Links

# Initialize an empty array to store results
$results = @()

foreach ($model in $models)
  # Get the link for the specific model webpage
  $modelLink = $indexLinks | Where-Object {$_.innerHTML -eq $model}

  # Set the URI variable for the specific model webpage
  $modelURI = $baseURI + $modelLink.href

  # Scrape the specific model webpage
  $modelIndex = Invoke-WebRequest -Uri $modelURI

  # Get webpage elements for the desired section ID
  $sectionIndex = $modelIndex.ParsedHtml.getElementsByTagName('div') | Where-Object {$ -eq $sectionID}

  # Get innerText values that are like the date pattern
  $releases = ($sectionIndex.getElementsByTagName('TD') | Where-Object {$_.innerText -like $datePattern}).innerText

  # Convert the innerText values to datetime objects
  $releaseDates = $releases | Get-Date

  # Find the object with the most recent date 
  $latestRelease = ($releaseDates | Measure-Object -Maximum).Maximum

  # Populate the results array with the model and most recent release date
  $results += New-Object psobject -Property @{Model=$model; Date=$latestRelease}

# Display results and sort by date
$results | Sort-Object -Property Date -Descending

Here are the results. Notice that an update as recent as 11/27 was found. Compare that to the which, as of this writing, was last updated on 11/23:


The model names must match the ones on the index page. I’ve included a handful of example models. With the full list of approximately 40 models I support, the execution time takes about 50 seconds, but your mileage my vary.

I originally wrote this script with BIOS updates in mind, but it can be used for other update types as well, just swap out the sectionID value with one from this list (not sure if these are all possible values, or if every value is valid for every model):

Drivers-Category.AP-Type.APP  - Application
Drivers-Category.AU-Type.DRVR - Audio Driver
Drivers-Category.BI-Type.BIOS - BIOS
Drivers-Category.BR-Type.APP  - Backup and Recovery
Drivers-Category.CM-Type.DRVR - Communications
Drivers-Category.CS-Type.APP  - Chipset App
Drivers-Category.CS-Type.DRVR - Chipset Driver
Drivers-Category.DD-Type.APP  - OS Deployment App
Drivers-Category.DD-Type.DRVR - OS Deployment Driver
Drivers-Category.DP-Type.APP  - Dell Data Protection App
Drivers-Category.DP-Type.DRVR - Dell Data Protection Driver
Drivers-Category.IN-Type.DRVR - Input
Drivers-Category.NI-Type.DIAG - Network Diagnostics
Drivers-Category.NI-Type.DRVR - Network Driver
Drivers-Category.NI-Type.HTML - Network HTML
Drivers-Category.RS-Type.FRMW - Removable Storage Firmware
Drivers-Category.SA-Type.DRVR - SATA Driver
Drivers-Category.SA-Type.FRMW - SATA Firmware
Drivers-Category.SA-Type.UTIL - SATA Utility
Drivers-Category.SK-Type.APP  - CMDSK App
Drivers-Category.SM-Type.APP  - Systems Management App
Drivers-Category.SM-Type.DRVR - Systems Management Driver
Drivers-Category.SM-Type.UTIL - Systems Management Utility
Drivers-Category.SP-Type.APP  - Security Encryption App
Drivers-Category.SP-Type.DRVR - Security Encryption Driver
Drivers-Category.UT-Type.UTIL - System Utilities
Drivers-Category.VI-Type.DRVR - Video Driver
Drivers-Category.VI-Type.UTIL - Video Utility

It was relatively simple to find the desired section of HTML on each model page because each section has a unique ID. However, all of the dates in the HTML don’t have anything unique designating them as dates. They are just text, so I ended up using what is probably a sub-optimal “like” method. Perhaps a “match” using a regex would be better…but I’m satisfied with how the script is working for now. If anyone has any suggestions for improvement, please let me know!

Thanks for reading. I hope you found this useful or at least interesting!


(Re)Install RSAT during a Windows 10 1809 Feature Update Task Sequence in ConfigMgr/SCCM

If you’re an IT admin who works with Microsoft technologies, I hope you are familiar with the Remote Server Administration Tools (RSAT). With previous versions of Windows 10, installing these tools required downloading a separate .MSU package:

This changes with 1809. The tools are now available as “features on demand” and can be installed via DISM or PowerShell:

If you’ve installed a Windows 10 feature update and gone from 1607 to 1709 for example, you may have noticed that the tools get removed. Perhaps one of your fellow IT admins got annoyed because you didn’t automatically handle this scenario for them (sometimes they can be the most difficult customers to please 🙂 )

Well, here’s a method for making sure the tools get reinstalled during a feature update task sequence if they were installed previously.

The first part is to add a pre-processing step to check for RSAT installation and set a task sequence variable if installed. Gary Blok already has a blog post explaining how to do that, so I won’t reinvent the wheel:

The second part is to add some post-processing steps to reinstall the tools.

Add a new group named “Reinstall RSAT if Previously Installed” and set a task sequence variable condition to only run the group if RSATInstalled equals true:


The next part requires some explanation. Since I’m doing this in the context of a ConfigMgr environment, the clients are configured to use a SUP, and thus an internal WSUS server. When you try to run the command for installing RSAT via “features on demand”, it will reach out to the WSUS server. Typically, a WSUS instance in a ConfigMgr environment will not have any “features on demand” content synced, so this causes an error (0x800f0954). It might be possible to get it to work that way somehow, but I opted to make a configuration change that allows the system to sidestep WSUS and check for the content directly from Windows Update (which, of course, requires an active Internet connection during the task sequence). I do this by configuring the following policy via a registry value:

Create a “run command line” step with the following command:

reg add "HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\Policies\Servicing" /v RepairContentServerSource /d 2 /t REG_DWORD /f

Now we’re ready for the step that actually installs the tools. Martin Bengtsson has a great blog post explaining how to do this with PowerShell and wrote a script that can be used with ConfigMgr:

That’s a great option, but I decided to just go with a one-liner that installs all the tools. Create another “run command line” step with the following command:

powershell.exe -NoProfile -Command "Get-WindowsCapability -Online | Where-Object {$_.Name -like 'Rsat*'} | Add-WindowsCapability -Online -LogPath %TEMP%\Add-WindowsCapability-RSAT.log"



Notice that I added logging to %TEMP% which in this context will be written to C:\Windows\Temp

I’ve tested this successfully with 1607 -> 1809 and 1709 -> 1809 (not with 1703 or 1803 -> 1809, but I’m assuming it works just the same.) If you’ve pinned any shortcuts to the Start Menu or taskbar, they still work after the update! It adds about 10 minutes to the overall update time, but it’s worth it for those special admins in your life that just need RSAT to work 🙂

That’s it for now. Thanks for reading.


Fixing a Windows 10 Upgrade Blocked by a File on a Network Location

I ran into a Windows 10 upgrade issue recently that led me down a rabbit hole. It’s probably not a very common scenario, but wanted to document a workaround in case anybody else encounters it.

The system of note was being upgraded from 1607 to 1703 via a ConfigMgr task sequence. The task sequence contains a step to run the compatibility scan only and discontinue if any blocking issues are found. When the compatibility scan failed, I checked the log files in C:\$WINDOWS~BT\Sources\Panther

The most recently modified CompatData*.xml file showed that the blocking file was wussetup.exe. This is related to WSUS (Windows Server Update Services)

At first, I thought this might be related to an incompatible version of RSAT tools that was installed. The machine belonged to an IT admin, so this seemed reasonable. However, other systems being upgraded had RSAT installed and this did not block the upgrade from proceeding.

I did some more digging in the Panther folder, and looked in a file named *_APPRAISER_HumanReadable.xml (which is kind of an interesting name, because there doesn’t seem to be anything unique about this file that makes it more “human readable” than any of the other xml files in this location…but anyway…)

I searched for wussetup.exe, and found that the file actually resided on a network location! I looked for any obvious references to this network location, like a mapped drive, or network shortcut, or installed software with that location as the install source, but came up empty.

After more digging, I discovered that there were shortcuts (.lnk files) to the network location within a subfolder of C:\Program Files (x86). I assume that the compatibility scan not only checks locally installed software, but if it finds a shortcut in a Program Files location, it scans that target path as well, just in case you are dependent on running executables from that location  that aren’t actually installed. It’s not that simple though:

The shortcuts were pointing to \\server\share\folder\program\ but the compatibility check was scanning everything under \\server\share\folder\, which is how the seemingly unrelated wussetup.exe file was being detected.

The workaround seemed simple: Remove the shortcuts from the Program Files location and rerun the upgrade…however, after doing so, the upgrade still failed. Deleting the C:\$WINDOWS~BT folder didn’t work either.

I was able to reproduce the issue on a virtual machine so I could do more troubleshooting. Next, I turned to Sysinternals Process Monitor. I ran a trace during the compatibility scan and found that the network location was still being referenced in a registry location. However, it wasn’t part of the registry that can be accessed normally via regedit, it was in another hive that was mounted as \REGISTRY\A\. I eventually found the operation that had loaded the registry hive from C:\Windows\AppCompat\Programs\Amcache.hve

I tried to see if I could manipulate the file in any way to remove the references to the network location, but the file was already in use. My next thought was to shut down the system and access the file offline via bootable USB media. (If the drive is Bitlockered, make sure to temporarily disable protectors to make it easy to access offline.)

Offline, I was able to rename the file to Amcache.hve.old. I then restarted the system (and re-enabled Bitlocker protectors). When I reran the Windows 10 upgrade, it recreated the Amcache.hve file and successfully passed the compatibility check!

I couldn’t really find any documentation about Amcache.hve – almost all the links that mention it are related to its use in forensic analysis of Windows – so I’m not sure exactly how it ties into the Windows 10 upgrade process, or if there are any potential issues with deleting/renaming it.

But, from this example, it seems that once a network location is scanned by the compatibility assessment, it is remembered by the Amcache and scanned by future runs even if the reason for it being scanned in the first place is corrected.

Hopefully someone out there with more knowledge on this can provide more info.

Thanks for reading!

Workaround for Windows 10 1709 AutoAdminLogon at the end of ConfigMgr OSD Task Sequence

I’ve recently been working on a bare-metal task sequence for 1709 that has a step in it to configure (via the registry) a one-time auto logon to take place at the end of the TS:

Reference link:

This process worked fine in 1607, but failed in 1709 (never tried 1703). After searching around for reports of similar issues and doing some troubleshooting, I found that something was happening after the task sequence completed – either during the OOBE phase (the “now we can go look for any updates” screen) or immediately after – that was removing/resetting the auto logon related registry settings I had configured earlier.

I found multiple threads where others had described similar behavior, and a couple who said they opened cases with Microsoft who eventually confirmed that this is a bug. Some claimed to solve it by editing unattend.xml to skip OOBE (settings which are deprecated in Windows 10) while others said nothing they tried worked.

I was eventually able to come up with a workaround using scheduled tasks. Here are the high-level steps:

  1. Create a package in ConfigMgr containing script files
  2. Add a task sequence step to copy these script files to the local system
  3. Add a final task sequence step to set the SMSTSPostAction variable to run one of these scripts that will create an AutoLogon scheduled task, and then restart the system after a delay
  4. On system startup, the AutoLogon task executes a script that creates the auto logon registry settings, then creates and executes another scheduled task to run a script to cleanup the AutoLogon task and related scripts, then restart the system again, enabling the auto logon

It sounds kind of Rube Goldberg-esque, but it seems to work quite nicely. Here are the detailed steps:

Create three .bat files

  • createtask.bat
    schtasks.exe /create /ru system /rl highest /tn AutoLogon /tr "C:\Windows\Temp\autologon.bat" /sc onstart
    shutdown.exe /r /f /t 120
  • autologon.bat
    reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v "AutoAdminLogon" /t REG_SZ /d 1 /f
    reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v "AutoLogonCount" /t REG_DWORD /d 1 /f
    reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v "DefaultPassword" /t REG_SZ /d "YourPassword" /f
    reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v "DefaultUserName" /t REG_SZ /d "YourUserName" /f
    reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v "DefaultDomainName" /t REG_SZ /d "YourDomain" /f
    schtasks.exe /create /ru system /rl highest /sc once /sd 01/01/1910 /st 00:00 /tn Restart /tr "C:\Windows\Temp\restart.bat"
    schtasks.exe /run /tn Restart
  • restart.bat
    schtasks.exe /end /tn AutoLogon
    schtasks.exe /delete /tn AutoLogon /f
    del /F /Q C:\Windows\Temp\autologon.bat
    del /F /Q C:\Windows\Temp\createtask.bat
    shutdown.exe /r /t 0

Create a ConfigMgr package containing the .bat files

  • Create a parent folder named whatever you want in your ConfigMgr sources location
  • In the parent folder, create a subfolder named files
  • Put the three .bat files in the files folder
  • Create another .bat file in the parent folder named filecopy.bat
    copy /y "%~dp0files\*.*" "%~1"
  • Create the package in ConfigMgr and distribute it to the necessary distribution points

Create the task sequence steps

  • Near the end of the task sequence, create a “Run Command Line” step as follows, and make sure to select the package you created in step 2:
    • autologon1
  • Command line:
filecopy.bat C:\Windows\Temp
  • For the last step of the task sequence, create a “Set Task Sequence Variable” step as follows:
    • autologon2
  • Task Sequence Variable: SMSTSPostAction
  • Value:
    cmd /c C:\Windows\Temp\createtask.bat

And that’s all there is to it. You could go another level deeper and clean up the autologon registry settings, but I will leave that as an exercise for the reader.

I realize this isn’t an ideal or secure solution…it may be more useful to consider as a proof-of-concept that you shouldn’t use without thorough testing. However, it is an effective workaround…one which hopefully will not be needed in future versions of Windows 10! 🙂

Servicing a Windows 10 Upgrade Package with ConfigMgr: Results May Vary

Goal: Use ConfigMgr to service and deploy a fully updated Windows 10 operating system upgrade package

On the surface, this seems like a simple, straightforward task. However, I encountered some head-scratching issues along the way that I’ll attempt to detail in this blog post.

Issue #1 – The cumulative update needs to be reinstalled following a successful upgrade deployment, even though it has already been serviced into the OS upgrade package.

Cause: The .NET Framework 3.5 feature. The Windows 10 1607 systems being upgraded had that feature installed as part of their original bare-metal installs. The 1703 OS upgrade package I was using did not have that feature installed when it was serviced by ConfigMgr with the CU. At some point during the OS upgrade task sequence, the feature gets enabled in 1703. Since the CU contains .NET related updates, it has to re-apply once the OS upgrade is complete. This won’t be apparent until the next time the Windows Update Agent scan cycle runs.

Solution: Add the .NET 3.5 feature to the OS install source before importing it into ConfigMgr and servicing it with the CU. The DISM command will look something like this:

DISM /Image:C:\test\offline /Enable-Feature /FeatureName:NetFx3 /All /LimitAccess /Source:D:\sources\sxs

Issue #2 (Bug?)- OS upgrade package servicing with ConfigMgr completes successfully (no errors in console or logs), but incorrect content is sent to distribution points on the initial distribution.

Scenario: I imported an OS upgrade package into ConfigMgr but did not distribute it to any DPs yet because I wanted to service it first. I used the “schedule updates” dialog to start the servicing process and then I followed the progress in the OfflineServicingMgr.log. The process completed without error and everything looked as it should in the console. Satisfied that everything was ready, I distributed the content – for the first time – to the DPs. Distribution completed successfully so I moved on to deploying to a test system. On completion of the OS upgrade task sequence, the updates I had added to the OS package were not showing as installed.

Cause: The instance of install.wim in the OS upgrade package source folder was different (much larger) than the install.wim in the package content on the DPs. This resulted in the wrong content being used during the task sequence.

Solution: Redistribute the package to the DPs, even though there were no errors the first time, and the content in the source folder did not change after the first distribution (Sounds like a bug to me!)


Issue #1 isn’t technically a ConfigMgr issue, but it would be nice if there was a built-in option to add the .NET 3.5 feature as part of the “schedule updates” process. I might have to create a UserVoice item for that if one doesn’t already exist.

Issue #2 is problematic because OS packages are very large. Any extra distribution activity is not desirable. Perhaps the best workaround for now is to do the initial distribution to a single, well-connected DP, and then do the second distribution to all DPs.

That’s all for now. Feedback is always welcome. Thanks for reading!


Use PowerShell to Dynamically Manage Windows 10 Start Menu Layout XML Files

Microsoft provides a way to manage and enforce a customized Start Menu layout (pinned tiles) in Windows 10:

Documentation Link:

This blog post will assume that the reader is familiar with the high-level steps involved:

  1. Manually configuring the Start Menu layout on a Windows 10 system
  2. Using the Export-StartLayout PowerShell cmdlet to generate a layout XML file
  3. Applying a policy to machines in your organization so they use the layout XML file

This process works fine, but it’s a static “set it and forget it” approach that doesn’t handle configuration changes or differences very well. I’ve attempted to come up with a more dynamic approach with the following features:

  1. Read in a group (or two) of apps to be pinned (can be different per system)
  2. Dynamically generate the layout XML file
  3. Only write entries for apps that are present/installed on the system
  4. Write updated layout XML file before logon (prevents issues with the layout file being locked in-use)
  5. Works for both Modern and Desktop apps

So, to get a better idea of how this works, start by using the Export-StartLayout command, and look at the exported XML file in Notepad:


Notice that for desktop application tiles, it uses DesktopApplicationLinkPath to specify the location of the .lnk or .url file to pin. This means that you must know/maintain the exact location of these items for the Start Menu to be able to display them correctly. Fortunately, you can use the DesktopApplicationID instead. The Microsoft doc I linked to earlier has an “Important” note mentioning this:


So, how do I find the DesktopApplicationID of the items I want to pin? The answer is, via another PowerShell cmdlet called Get-StartApps. If you look under the hood of that cmdlet in


you’ll find that what it’s really doing is enumerating the items found in a “virtual” folder named AppsFolder:


This location can’t be browsed to normally via Windows Explorer, but you can view it by entering shell:AppsFolder into a run command line or explorer bar. This folder essentially contains all the apps available for pinning, both Desktop and Modern.

In summary, by using DesktopApplicationID in the layout XML instead of DesktopApplicationLinkPath, you don’t have to know the location of the items you want to pin. You just need to know the names of the apps, and Get-StartApps will give you the associated app IDs.

Another thing to note in the layout XML is that the entries for Modern apps require different attributes than the Desktop apps. If I’m creating the layout file dynamically, how do I determine the difference between Modern and Desktop apps so I know which attributes to use for which line? Unfortunately, Get-StartApps doesn’t have an explicit property that distinguishes between Modern and Desktop apps. However, the AppID for a Modern app will contain the publisher ID. Example:


If I have a list of the publisher IDs, I can check to see if an AppID contains one, and then I’ll know which XML attributes to write. A list of unique publisher IDs can be obtained with the following PowerShell command:

Get-AppxPackage | Select-Object -ExpandProperty PublisherID | Sort-Object | Get-Unique

The only other information I need to know is the tile size, column, and row values. To greatly simplify the logic involved, I decided to go with a three-by-three group of medium size tiles, meaning that the tile size is the same for all nine tiles: 2×2. That makes the column and row values easy to determine as well.

Now that I know how to dynamically generate the pinned app entries in the layout XML, how do I provide a list (or two) of apps to pin? The answer is to obtain the desired app names from Get-StartApps, and create a simple text file with the app names listed in the order in which you want them to be pinned. Example:

This list of apps


Will result in this Start Menu layout:


Notice that the text file name (Enterprise Apps) determines the name of the group on the Start Menu. Also, the file extension (.1) means that it is the first group of apps that should be pinned. If I create another list of apps with a .2 extension like this:


The resulting Start Menu layout would look like this:


If only the first file exists on the system, only that list of apps is pinned. The group names and app lists are completely customize-able per system.

If an app on the list isn’t found, it is simply skipped, and no line is written for it in the XML. So, for example, a system could be missing three of the nine apps in a group, and the top six spots will still be used, leaving no gaps.

If you put all the related files in the same folder that I’m using as the location in my scripts, it will look like this:


At this point, you should have everything you need to dynamically create the layout XML…but there are a few remaining issues:

  1. It’s not always the case, but typically I’ve found that a layout XML file that’s already in place can’t be modified while a user is logged on because the file will be locked in-use
  2. Even if the layout XML is modified, the user wouldn’t see the changes until they log off/log on again (or until explorer.exe is killed/restarted, which doesn’t seem like a very clean workaround outside of testing.)
  3. A user needs to be logged on for the Get-StartApps and Get-AppxPackage cmdlets to return the full list of available apps and publisher IDs. Running these command as the computer/SYSTEM account will result in only returning the apps that are provisioned for all users.

To work around these issues, I used a two-stage approach:

  1. A logoff script that runs Get-StartApps and Get-AppxPackage while a user is still logged on, and exports the content into files.
  2. A startup script that reads the App list and publisher IDs from the exported files, and writes the layout XML file before the user logs back on

Consider the following scenario:

You want to deploy a new app to a certain department in your organization and pin its tile to the Start Menu on those systems. With my process in place, you could automate a step in the app install sequence that simply adds the app name to one of the app list text files. You could then call for the system to restart on completion of the install sequence. The new app gets picked up and written to the layout XML file automatically, and the tile is ready for the user when they log back on. Conversely, you could remove a pinned app on uninstallation without leaving a blank tile in its place.

Going Further:

I have some other ideas that I’ve left out of the scripts for now for the sake of simplicity, but I still want to mention them:

  1. Add a registry property value check that determines whether a system should have a fully locked down Start Menu, or partially locked down which would add LayoutCustomizationRestrictionType=”OnlySpecifiedGroups” to the layout XML file.
  2. Create a subfolder for the PublisherIDs and StartApps files that has write permissions for normal users. This will allow the logoff script to run successfully, while the app list and layout xml files can remain in a protected area only accessible to administrators.


The PowerShell scripts can be grabbed from my GitHub page:

Create-Start-Menu-Layout-XML.ps1 is meant to be used as a startup script in group/local policy, and Get-Apps-and-IDs.ps1 is meant to be used as a logoff script. Also, don’t forget that the file path in your Start Layout policy must match the path you use in these scripts:

I don’t have this widely deployed at the moment, but throughout my testing on Windows 10 1607 and 1703, it has seemed to work well and doesn’t add a noticeable amount of time to the logoff/logon/restart process. I’m curious to see what kind of feedback I get from the community. Let me know if you have any ideas for improvement.

Thanks for reading!