Short answer. Put the script in SYSVOL, list your apps by Store ID, and add it to a GPO under User Configuration → Windows Settings → Scripts (Logon/Logoff) → Logon, on the Scripts tab, as powershell.exe with -ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File "<path>". On each logon it checks which apps the user already has and installs only the missing ones with winget. In our tests, real Store packages installed without admin rights.

What the script does

The script runs at every logon, but it only does real work when something is missing. One run looks like this:

  1. Hands off to the background. The logon script itself returns in about a second and starts a hidden copy of itself, so it never delays sign-in.
  2. Checks what is already installed — one winget export call lists every Store app the user has, in about two seconds.
  3. Skips what's there. Installed apps are never touched — no reinstall, no upgrade. The Store keeps them up to date on its own.
  4. Installs what's missing silently with winget install --source msstore, checks again that the app is really there, and opens it once.
  5. Writes a short log to %LOCALAPPDATA%\StoreAppInstall\install.log with one result line per run: OK, FAILED, POSTPONED, CONFIG_ERRORS.

Out of the box it installs our own two apps, OneMount and PostMount, as the working example. Replace them with any Store apps you need — the list is the only thing you edit.

Tested in a real domain

Every number below comes from a lab domain (Windows Server domain controller, Windows 11 22H2 clients, a standard non-admin domain user), not from documentation.

What happenedTime
Logon script returns control to Windows~1 s
First logon: OneMount installed from the Store and opened2 min 14 s
First logon: PostMount installed and opened1 min 58 s
WhatsApp, the slowest app we tried5 min 12 s
Every later logon (both apps already installed)4 s

Most of a first install is the Store fetching the package for the user, not the script. That is also why the script works in the background: a single slow app would otherwise run into the Group Policy script time limit.

Before you start

  • Windows 10 or 11 with winget (App Installer). It ships with current Windows; on a brand-new profile it appears a minute or so after the first logon, and the script waits for it. We tested on Windows 11 22H2; Windows 10 with a current App Installer should behave the same, but we haven't verified it.
  • winget can reach the msstore source. Check with winget source list on a user's PC. That's what matters, not whether the Store app opens: Microsoft notes that blocking the Store app doesn't necessarily stop installs through winget.
  • Domain-joined PCs and GPMC (gpmc.msc) on a machine with RSAT or on the domain controller.
  • A test OU with one test user. Link the GPO there first, then widen it.

Step 1 — Find the Store ID of each app

Open the app's page on apps.microsoft.com. The Store ID is the last part of the address — for OneMount it's 9P68LJCNCHND. You can also run winget search "app name" --source msstore and take the Id column. Most IDs look like 9XXXXXXXXXXX; listings that run a publisher's own installer use longer IDs starting with XP.

OneMount page on apps.microsoft.com; the Store ID 9p68ljcnchnd is the last part of the address
The Store ID is the last segment of the apps.microsoft.com address (case doesn't matter).

Step 2 — List your apps in the script

At the top of StoreAppInstall.ps1 there is one list. Each line is one app: a Name for the log and the StoreId. Add Launch = $false if the app shouldn't open after installing, or add -NoLaunch to the script parameters so that nothing opens at all.

[object[]]$Apps = @(
  @{ Name = 'OneMount';  StoreId = '9P68LJCNCHND' }
  @{ Name = 'PostMount'; StoreId = '9PN0511DJX9K' }
  @{ Name = 'WhatsApp';  StoreId = '9NKSQGP7F2NH'; Launch = $false }
)

Nothing else needs editing. The script checks the list itself: a malformed or duplicate Store ID is reported in the log as a configuration error instead of breaking the run.

Step 3 — Put the script in SYSVOL

The script has to sit where every domain user can read it: \\<your-domain>\SYSVOL\<your-domain>\scripts\. Copying it straight to that network path from your own PC will most likely fail — writing to SYSVOL over the network is restricted. Do it on one of the domain controllers instead: copy the file into the local folder C:\Windows\SYSVOL\sysvol\<your-domain>\scripts\ (in our test domain, C:\Windows\SYSVOL\sysvol\skylflow.local\scripts\).

That local folder is exactly what's shared as SYSVOL, and Windows replicates it to the other domain controllers on its own. Open the network path afterwards to check the file is there — that's the address the GPO will use.

File Explorer showing StoreAppInstall.ps1 at the network path skylflow.local\SYSVOL\skylflow.local\scripts
The script at the network path \\skylflow.local\SYSVOL\skylflow.local\scripts — the address the GPO uses.

Step 4 — Create a GPO and link it to the OU

In Group Policy Management, right-click the OU that holds your users → Create a GPO in this domain, and Link it here…, and give it a clear name — we used StoreAppInstall. Then right-click the new link → Edit.

Group Policy Management console with the HQ organizational unit selected
The OU with the users who should get the apps — here, HQ.
New GPO dialog with the name StoreAppInstall
Creating the GPO directly on that OU.
The StoreAppInstall GPO linked to the HQ OU, with the Edit command in its context menu
The GPO is linked and enabled; Edit opens the Group Policy Management Editor.

Step 5 — Add it as a logon script (the Scripts tab)

In the editor go to User Configuration → Policies → Windows Settings → Scripts (Logon/Logoff) and open Logon. Stay on the Scripts tab — not PowerShell Scripts — and click Add:

Script Name:       powershell.exe
Script Parameters: -ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File "\\your-domain\SYSVOL\your-domain\scripts\StoreAppInstall.ps1"

Why not the PowerShell Scripts tab? It runs scripts under the machine's execution policy, which on most PCs blocks unsigned scripts unless you add one more policy (Turn on Script Execution). Calling powershell.exe with -ExecutionPolicy Bypass from the Scripts tab needs nothing else, and -WindowStyle Hidden keeps the user from seeing a console window.

Treat it like any code you deploy. The script runs at every user's logon, so whoever can edit the file can run code for everyone in the OU. Read it before rollout and keep the SYSVOL scripts folder writable only by domain admins. Execution policy is a safety net, not a security boundary — if your organization requires signed scripts, sign this one and drop -ExecutionPolicy Bypass.
Add a Script dialog in the StoreAppInstall GPO: Script Name powershell.exe and the parameters line
Script Name is powershell.exe; the path to the script goes into Script Parameters.
Logon Properties of the StoreAppInstall GPO with powershell.exe added on the Scripts tab
The logon script is in place. Click OK and close the editor.

Step 6 — Check the scope and test

On the GPO's Scope tab, make sure it's linked to the right OU and that Security Filtering includes your users (Authenticated Users by default). Then, on a test PC, run gpupdate /force, sign out, and sign in as a user from that OU.

Scope tab of the StoreAppInstall GPO: linked to skylflow.local/HQ, Authenticated Users in security filtering
Linked to HQ, applied to Authenticated Users.

A minute or two after sign-in the apps open on their own. The log shows exactly what happened — this is a real first logon from our test:

Logon script handed off to background process (PID 10180).
RUN START  2026-10-14 18:00:12  User: SKYLFLOW\demo.user  Host: SKT-NB-001
--- OneMount (9P68LJCNCHND) ---
[INFO] [OneMount] Installing (time limit 20 min)...
[INFO]   winget> Successfully installed
[OK] [OneMount] Installed.
[OK] [OneMount] Opened (OneMount).
--- PostMount (9PN0511DJX9K) ---
[OK] [PostMount] Installed.
[OK] [PostMount] Opened (PostMount).
RUN END    2026-10-14 18:04:36  RESULT: 2 OK, 0 FAILED, 0 POSTPONED, 0 CONFIG_ERRORS

And the next logon, four seconds in total:

[OK] [OneMount] Already installed - skipping.
[OK] [PostMount] Already installed - skipping.
RUN END    2026-10-14 19:14:32  RESULT: 2 OK, 0 FAILED, 0 POSTPONED, 0 CONFIG_ERRORS
PostMount and OneMount windows opened automatically after the first logon
First logon of a standard user: both apps installed from the Store and opened — no admin rights, nothing clicked.

Which Store apps can be deployed this way

Not every Store listing is the same kind of package. winget show --id <StoreId> --source msstore tells you the Installer Type, and that decides whether a regular user can install it. We tried these:

AppInstaller typeStandard user, no admin
OneMount, PostMount, Telegram Desktop, WhatsApp, ChatGPT, Pinterestmsstore — a real Store packageInstalls
Microsoft Teams, Zoom Workplaceexe — publisher's installer, per-userInstalls
Adobe Acrobat Reader, TeamViewerexe — publisher's installer, per-machineNeeds admin (UAC prompt)
Microsoft 365 (Office)— not a package at allCan't be installed this way

The rule of thumb: every msstore listing we tried installed without admin rights, though your organization's policies can still restrict it. exe listings are the publisher's own setup program behind a Store page — some install per user, some ask for elevation, which a standard user can't approve. The script warns about these in the log before installing. Microsoft 365 returns "No package found": its Store page is a sign-up page, and Office is deployed with the Office Deployment Tool instead.

Why the script is built this way

A user logon script, not a computer startup script

Store packages are installed for a user, and the Store refuses installs from the SYSTEM account — winget reports "Device wide install for msstore type is not supported under admin context". A startup script runs as SYSTEM, so it can't do this job. A logon script runs as the user who should get the app, which is exactly what's needed.

It works in the background

Windows stops Group Policy scripts after 10 minutes by default (the Maximum wait time for Group Policy scripts policy). A first install of several apps can take longer than that — WhatsApp alone took five minutes. So the logon script starts a hidden copy of itself and exits immediately: sign-in isn't delayed, and nothing is cut off midway. Each install still has its own 20-minute limit, so a download stuck in the Store can't hang the run forever.

It never updates apps

winget install on an app that's already installed doesn't stop there — it tries to upgrade it. A logon script doing that would silently update apps on someone's schedule other than yours. That's why the script first asks winget export what's installed and only calls install for apps that are missing. Updates stay with the Store.

One caveat: winget export lists the apps winget can match to the Store source. An app installed some other way — say, Zoom from the vendor's MSI — may not be matched. The script then tries the Store install, and winget, finding the existing copy, tries to upgrade it. Leave apps you already deploy another way out of the list.

No state files, nothing to clean up

The script keeps no cache and no markers — only its log. What's installed is read fresh every time, so a user who uninstalls an app gets it back at the next logon, and a reimaged PC simply starts over. Two copies never run at once: a per-user lock catches parallel sessions.

Troubleshooting

Every problem shows up in %LOCALAPPDATA%\StoreAppInstall\install.log with a hint. The ones we met:

In the logWhat it meansWhat to do
0x8A150014 — No package foundThe Store ID isn't an installable packageCheck the ID; some listings (Microsoft 365) are only web pages
0x800704C7 — CanceledThe installer asked for admin rights and the prompt was closedDeploy that app another way, or only to users with admin rights
0x8A15005E — certificate mismatchwinget couldn't trust the Store server's certificateOnce per PC, as admin: winget source reset --force; if that doesn't help, check for a TLS-inspecting proxy
Install did not finish in 20 minA Store download is stuck (shown as pending in Store → Library)Nothing — it's retried at the next logon
winget not availableA brand-new profile, App Installer not registered yetUsually solves itself by the next logon; otherwise check App Installer
"Access denied" when copying the script into SYSVOLWriting to SYSVOL over the network is restrictedOn a domain controller, copy it into C:\Windows\SYSVOL\sysvol\<your-domain>\scripts\
Config error: listed twice / invalidA typo in the app listFix the $Apps line named in the log

To see what the script does without waiting for a logon, run it by hand in a normal PowerShell window with -Foreground — it then works in that window instead of handing off to the background.

GPO script, Intune or provisioning?

MethodWhen it fitsThe catch
GPO logon script + winget (this guide)Active Directory domain, no IntuneInstalls per user; the script is yours to maintain
Microsoft IntuneDevices already enrolled in IntuneNeeds Intune licences and device enrollment
Provisioning (Add-AppxProvisionedPackage)Every account on a PC, golden imagesAdmin rights, the package file and an offline license
Installing from the Store by handA handful of PCsDoesn't scale

This approach installs an app for each user the GPO applies to, at that user's logon. That covers the usual case — everyone in the OU gets the app the first time they sign in. If you need an app present for every account on a machine before anyone signs in, that's provisioning (Add-AppxProvisionedPackage): it runs as admin and needs the package file itself and, for Store apps, an offline license — which used to come from the now-retired Microsoft Store for Business. For most small and mid-size organizations, the per-user logon script is the practical option.

The full script

Free to use and adapt (MIT license). Only the $Apps list needs editing; everything else is commented in the file. The script also lives on GitHub — issues and improvements are welcome there.

StoreAppInstall.ps1 · PowerShell 5.1 GitHub Download .ps1
<#
.SYNOPSIS
  Silently installs Microsoft Store apps for the current user from a GPO
  logon script, using winget. No admin rights, no Intune/MDM.

.DESCRIPTION
  Made by SkylFlow for our own apps, and works the same way for any app from
  the Microsoft Store - just add it to $Apps below:
    OneMount  - https://onemount.app  - mounts SharePoint/OneDrive libraries
                as Windows network drives.
    PostMount - https://postmount.app - IMAP/POP3/SMTP relay so mail apps,
                scanners and ERP systems can still reach Microsoft 365.

  Every logon:
    1. One `winget export` call lists which Store apps this user already has.
    2. Apps already installed are skipped - never updated (the Store updates
       them on its own).
    3. Missing apps are installed silently, checked again, and opened once.

  GPO: User Configuration -> Windows Settings -> Scripts (Logon/Logoff) ->
  Logon -> "Scripts" tab:
    Script Name:       powershell.exe
    Script Parameters: -ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden
                       -File "\\<domain>\SYSVOL\<domain>\scripts\StoreAppInstall.ps1"
  User logon only - winget does not work as a computer startup/SYSTEM script.
  The logon script itself returns in about a second; the work continues in a
  hidden background process (see -Foreground).

  Log: %LOCALAPPDATA%\StoreAppInstall\install.log
#>
[CmdletBinding()]
param(
  # ==========================================================================
  # APPS TO INSTALL - one line per app.
  #   Name    - any label, used in the log and to find the app to open.
  #   StoreId - the code from the app's page URL on apps.microsoft.com
  #             (last URL segment), or the Id column of `winget search "name"`.
  #   Launch  - optional, default $true. $false = install only, don't open.
  #
  # Apps marked "exe installer" are Store listings that run the publisher's own
  # setup program; some of them ask for admin rights, which a regular user
  # can't approve (the log says so). True Store apps (msstore) installed
  # without admin rights in all our tests.
  # ==========================================================================
  [object[]]$Apps = @(
    @{ Name = 'OneMount';  StoreId = '9P68LJCNCHND' }
    @{ Name = 'PostMount'; StoreId = '9PN0511DJX9K' }
    # Tested examples:
    # @{ Name = 'Telegram Desktop'; StoreId = '9NZTWSQNTD0S' }
    # @{ Name = 'WhatsApp';         StoreId = '9NKSQGP7F2NH' }
    # @{ Name = 'ChatGPT';          StoreId = '9PLM9XGG6VKS' }
    # @{ Name = 'Pinterest';        StoreId = '9PFHDSF91B9R' }
    # @{ Name = 'Microsoft Teams';  StoreId = 'XP8BT8DW290MPQ' }   # exe installer, no admin needed
    # @{ Name = 'Zoom Workplace';   StoreId = 'XP99J3KP4XZ4VV' }   # exe installer, no admin needed
    # @{ Name = 'Adobe Acrobat';    StoreId = 'XPDP273C0XHQH2' }   # exe installer, NEEDS admin
  ),

  # Never open any app after installing it.
  [switch]$NoLaunch,
  # Run in this window instead of handing off to a hidden background process
  # (for testing by hand). Without it, the logon script starts a copy of itself
  # in the background and exits at once, so it never delays logon and isn't
  # cut off by the Group Policy script time limit (10 minutes by default).
  [switch]$Foreground,
  # Max time for one app's install. Some Store apps take several minutes on a
  # first install (WhatsApp: 5 min in testing). A download stuck as pending in
  # Microsoft Store > Library never finishes - it is cut off here and retried
  # on the next logon.
  [int]$InstallTimeoutMin = 20,
  # Max time for the whole run; apps not reached are installed on the next logon.
  [int]$MaxRunMin = 60,
  # Max wait for winget on a brand-new profile (it appears a little after the
  # first logon).
  [int]$WingetWaitMin = 3,
  # Clear the log once it grows past this size.
  [int]$LogMaxKB = 100
)

$ScriptVersion = '2026.10.14.22'

$StateDir = Join-Path $env:LOCALAPPDATA 'StoreAppInstall'
New-Item -ItemType Directory -Force -Path $StateDir | Out-Null
$LogPath = Join-Path $StateDir 'install.log'
if ((Test-Path -LiteralPath $LogPath) -and ((Get-Item -LiteralPath $LogPath).Length -gt ($LogMaxKB * 1KB))) {
  Clear-Content -LiteralPath $LogPath -ErrorAction SilentlyContinue
}

function Write-Raw([string]$Line) {
  try { Add-Content -LiteralPath $LogPath -Value $Line -Encoding UTF8 } catch {}
}
function Write-Log([string]$Message, [string]$Level = 'INFO') {
  Write-Raw ('{0:yyyy-MM-dd HH:mm:ss} [{1}] {2}' -f (Get-Date), $Level, $Message)
}
$Separator = '=' * 70

# Per user, across sessions (console + RDP at once).
$Script:LockMutex = $null
function Enter-SingleInstance {
  try {
    $Script:LockMutex = New-Object System.Threading.Mutex($false, "Global\StoreAppInstall_$($env:USERDOMAIN)_$($env:USERNAME)")
    return $Script:LockMutex.WaitOne([TimeSpan]::FromSeconds(3))
  } catch [System.Threading.AbandonedMutexException] {
    return $true
  } catch {
    return $false
  }
}
function Exit-SingleInstance {
  if ($Script:LockMutex) {
    try { $Script:LockMutex.ReleaseMutex() } catch {}
    $Script:LockMutex.Dispose()
  }
}

function Find-Winget {
  $cmd = Get-Command winget.exe -ErrorAction SilentlyContinue
  if ($cmd) { return $cmd.Source }
  $alias = Join-Path $env:LOCALAPPDATA 'Microsoft\WindowsApps\winget.exe'
  if (Test-Path -LiteralPath $alias) { return $alias }
  return $null
}

# Output goes to a temp file via cmd.exe rather than a pipe, and every call has
# a hard timeout. On timeout, taskkill /T takes down winget.exe too - killing
# only cmd.exe would leave it running.
function Invoke-Winget([string]$Winget, [string]$Arguments, [int]$TimeoutSec) {
  $tempOut = [System.IO.Path]::GetTempFileName()
  try {
    $psi = New-Object System.Diagnostics.ProcessStartInfo
    $psi.FileName = 'cmd.exe'
    $psi.Arguments = "/c `"`"$Winget`" $Arguments > `"$tempOut`" 2>&1`""
    $psi.UseShellExecute = $false
    $psi.CreateNoWindow = $true
    $proc = [System.Diagnostics.Process]::Start($psi)
    if (-not $proc.WaitForExit($TimeoutSec * 1000)) {
      try { & taskkill.exe /PID $proc.Id /T /F 2>&1 | Out-Null } catch {}
      return [PSCustomObject]@{ TimedOut = $true; Rc = $null; Lines = @() }
    }
    $lines = @(Get-Content -LiteralPath $tempOut -Encoding UTF8 -ErrorAction SilentlyContinue | Where-Object { "$_".Trim() })
    return [PSCustomObject]@{ TimedOut = $false; Rc = $proc.ExitCode; Lines = $lines }
  } finally {
    Remove-Item -LiteralPath $tempOut -Force -ErrorAction SilentlyContinue
  }
}

# Every Store app this user has installed, as StoreIds, in one call. Covers
# both true Store packages and Store listings with an exe installer. Ok=$false
# means "unknown" - never treat that as "nothing installed", or every app
# would get an install attempt (and winget upgrades apps it finds installed).
function Get-InstalledStoreIds([string]$Winget) {
  $exportFile = Join-Path ([System.IO.Path]::GetTempPath()) ("storeappinstall-{0}.json" -f [guid]::NewGuid())
  try {
    $r = Invoke-Winget $Winget "export -o `"$exportFile`" --source msstore --accept-source-agreements --disable-interactivity" 60
    if ($r.TimedOut -or -not (Test-Path -LiteralPath $exportFile)) {
      return [PSCustomObject]@{ Ok = $false; Ids = @() }
    }
    $json = Get-Content -LiteralPath $exportFile -Raw -Encoding UTF8 | ConvertFrom-Json
    $ids = @($json.Sources | ForEach-Object { $_.Packages } | ForEach-Object { $_.PackageIdentifier } | Where-Object { $_ })
    return [PSCustomObject]@{ Ok = $true; Ids = $ids }
  } catch {
    return [PSCustomObject]@{ Ok = $false; Ids = @() }
  } finally {
    Remove-Item -LiteralPath $exportFile -Force -ErrorAction SilentlyContinue
  }
}

$TraditionalInstallerTypes = @('exe', 'msi', 'inno', 'nullsoft', 'wix', 'burn', 'portable', 'zip')
function Get-InstallerType([string]$Winget, [string]$StoreId) {
  $r = Invoke-Winget $Winget "show --id $StoreId --source msstore --exact --accept-source-agreements --disable-interactivity" 30
  if ($r.TimedOut) { return $null }
  $line = $r.Lines | Where-Object { $_ -match 'Installer Type:\s*(\S+)' } | Select-Object -First 1
  if ($line -and $line -match 'Installer Type:\s*(\S+)') { return $Matches[1] }
  return $null
}

# Start menu entries (AppID -> Name). AppID works with shell:AppsFolder for
# both Store and exe apps.
function Get-StartEntries {
  $map = @{}
  try { Get-StartApps | ForEach-Object { $map[$_.AppID] = $_.Name } } catch {}
  return $map
}

# Opens the Start menu entry that appeared with this install. Uninstall
# shortcuts are ignored; if several entries are left, the one matching the
# app's Name is used; if it's still ambiguous, nothing is opened.
function Start-NewApp([hashtable]$Before, [string]$AppName) {
  $after = Get-StartEntries
  $new = @($after.Keys | Where-Object { -not $Before.ContainsKey($_) -and $after[$_] -notlike 'Uninstall*' })
  if ($new.Count -gt 1) { $new = @($new | Where-Object { $after[$_] -like "*$AppName*" }) }
  if ($new.Count -ne 1) {
    Write-Log "[$AppName] Installed, but couldn't tell which Start menu entry to open - not opening it." 'WARN'
    return
  }
  try {
    Start-Process -FilePath 'explorer.exe' -ArgumentList "shell:AppsFolder\$($new[0])"
    Write-Log "[$AppName] Opened ($($after[$new[0]]))." 'OK'
  } catch {
    Write-Log "[$AppName] Could not open it: $($_.Exception.Message)" 'WARN'
  }
}

function Format-Rc($Rc) { if ($null -eq $Rc) { 'none' } else { '0x{0:X8}' -f $Rc } }

function Get-FailureHint($Rc, [string]$InstallerType) {
  switch ($Rc) {
    -1978335212 { return 'StoreId not found in the Microsoft Store catalog - check it. Some listings (e.g. Microsoft 365) are only a web page, not an installable package.' }
    -2147023673 { return 'Canceled - an admin (UAC) prompt was closed or declined. This app needs admin rights to install.' }
    -1978335138 { return 'Certificate pinning mismatch. Run once as admin: winget source reset --force. If that does not help, check for a TLS-inspecting proxy.' }
  }
  if ($InstallerType -in $TraditionalInstallerTypes) {
    return "This listing runs the publisher's own '$InstallerType' installer, which may need admin rights."
  }
  return 'Install failed - will retry on next logon.'
}

function Invoke-Main {
  Write-Raw $Separator
  Write-Raw ('RUN START  {0:yyyy-MM-dd HH:mm:ss}  User: {1}\{2}  Host: {3}  Script: {4}' -f (Get-Date), $env:USERDOMAIN, $env:USERNAME, $env:COMPUTERNAME, $ScriptVersion)
  Write-Raw $Separator
  $deadline = (Get-Date).AddMinutes($MaxRunMin)

  # @(...) everywhere: a one-item result would otherwise unwrap to a bare
  # hashtable, and .Count on a hashtable counts its keys.
  $valid = @()
  $seen = @{}
  foreach ($app in $Apps) {
    if (-not $app.Name -or "$($app.StoreId)" -notmatch '^[A-Za-z0-9]{10,16}$') {
      Write-Log "Config error: entry '$($app.Name)' / StoreId '$($app.StoreId)' is invalid - skipped." 'ERROR'
      continue
    }
    $key = "$($app.StoreId)".ToUpperInvariant()
    if ($seen.ContainsKey($key)) {
      Write-Log "Config error: StoreId '$($app.StoreId)' is listed twice ('$($seen[$key])' and '$($app.Name)') - second one skipped." 'ERROR'
      continue
    }
    $seen[$key] = $app.Name
    $valid += $app
  }
  $configErrors = $Apps.Count - $valid.Count
  $ok = 0; $failed = 0; $postponed = 0

  $winget = Find-Winget
  $sw = [Diagnostics.Stopwatch]::StartNew()
  while (-not $winget -and $sw.Elapsed.TotalMinutes -lt $WingetWaitMin) {
    Start-Sleep -Seconds 10
    $winget = Find-Winget
  }

  if (-not $winget) {
    Write-Log "winget not available after $WingetWaitMin min - nothing done, will retry on next logon." 'ERROR'
    $failed = $valid.Count
  } else {
    $inventory = Get-InstalledStoreIds $winget
    if (-not $inventory.Ok) {
      Write-Log 'Could not list installed Store apps (winget export failed) - nothing done, will retry on next logon.' 'ERROR'
      $failed = $valid.Count
    } else {
      $pending = @()
      foreach ($app in $valid) {
        if ($inventory.Ids -contains $app.StoreId) {
          Write-Log "[$($app.Name)] Already installed - skipping." 'OK'
          $ok++
        } else {
          $pending += $app
        }
      }

      foreach ($app in $pending) {
        $remainingSec = [int]($deadline - (Get-Date)).TotalSeconds
        if ($remainingSec -lt 60) {
          Write-Log "[$($app.Name)] Out of time for this logon - will install on the next one." 'WARN'
          $postponed++
          continue
        }
        Write-Raw "--- $($app.Name) ($($app.StoreId)) ---"
        try {
          $type = Get-InstallerType $winget $app.StoreId
          if ($type -in $TraditionalInstallerTypes) {
            Write-Log "[$($app.Name)] Installer type '$type': the publisher's own setup program, not a Store package. It may ask for admin rights." 'WARN'
          }
          $launch = ($app.Launch -ne $false) -and -not $NoLaunch
          $startBefore = if ($launch) { Get-StartEntries } else { @{} }

          $timeoutSec = [Math]::Min($InstallTimeoutMin * 60, $remainingSec - 30)
          Write-Log "[$($app.Name)] Installing (time limit $([int]($timeoutSec / 60)) min)..."
          $r = Invoke-Winget $winget "install --id $($app.StoreId) --source msstore --exact --accept-package-agreements --accept-source-agreements --disable-interactivity --silent" $timeoutSec
          if ($r.TimedOut) {
            Write-Log "[$($app.Name)] Install did not finish in $([int]($timeoutSec / 60)) min - stopped. Usually a Store download stuck as 'pending' (Microsoft Store > Library). Will retry on next logon." 'ERROR'
            $failed++
            continue
          }
          $r.Lines | Where-Object { $_ -notmatch '^[\s\-\\|/]+$|\d+\s*%|[\u2580-\u259F]|[KMG]B\s*/\s*[\d.]+\s*[KMG]B' } |
            ForEach-Object { Write-Log "  winget> $($_.Trim())" }

          $check = Get-InstalledStoreIds $winget
          $installed = if ($check.Ok) { $check.Ids -contains $app.StoreId } else { $r.Rc -eq 0 }
          if ($installed) {
            Write-Log "[$($app.Name)] Installed." 'OK'
            $ok++
            if ($launch) { Start-NewApp $startBefore $app.Name }
          } else {
            Write-Log "[$($app.Name)] Not installed (winget exit $(Format-Rc $r.Rc)). $(Get-FailureHint $r.Rc $type)" 'ERROR'
            $failed++
          }
        } catch {
          # Never let one app's unexpected error take down the whole logon script.
          Write-Log "[$($app.Name)] Unexpected error, skipped: $($_.Exception.Message)" 'ERROR'
          $failed++
        }
      }
    }
  }

  Write-Raw $Separator
  Write-Raw ('RUN END    {0:yyyy-MM-dd HH:mm:ss}  RESULT: {1} OK, {2} FAILED, {3} POSTPONED, {4} CONFIG_ERRORS' -f (Get-Date), $ok, $failed, $postponed, $configErrors)
  Write-Raw $Separator
  if ($failed -gt 0) { return 2 }
  if ($configErrors -gt 0) { return 4 }
  return 0
}

if (-not $Foreground -and -not $PSBoundParameters.ContainsKey('Apps')) {
  $argList = "-ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File `"$PSCommandPath`" -Foreground"
  if ($NoLaunch) { $argList += ' -NoLaunch' }
  foreach ($p in 'InstallTimeoutMin', 'MaxRunMin', 'WingetWaitMin', 'LogMaxKB') {
    if ($PSBoundParameters.ContainsKey($p)) { $argList += " -$p $($PSBoundParameters[$p])" }
  }
  try {
    $bg = Start-Process -FilePath 'powershell.exe' -ArgumentList $argList -WindowStyle Hidden -PassThru
    Write-Log "Logon script handed off to background process (PID $($bg.Id))."
    exit 0
  } catch {
    Write-Log "Could not start background process ($($_.Exception.Message)) - running here instead." 'WARN'
  }
}

if (-not (Enter-SingleInstance)) {
  Write-Log 'Another instance is already running for this user - exiting.' 'WARN'
  exit 0
}
$exitCode = 1
try {
  $exitCode = Invoke-Main
} catch {
  Write-Log "Unexpected error: $($_.Exception.Message)" 'ERROR'
} finally {
  Exit-SingleInstance
}
exit $exitCode

FAQ

Can I deploy Microsoft Store apps with Group Policy without Intune?

Yes. Group Policy has no built-in "install Store app" setting, but a user logon script that calls winget does the job for Store packages. The script above has been tested end to end in an Active Directory domain.

Do users need admin rights?

Not for real Store packages (msstore) — none of the ones we tested needed them, though organization policies can still restrict installs. Some listings run the publisher's own installer, and a few of those ask for elevation — the script flags them in the log.

Will the script update apps that are already installed?

No. It checks what's installed first and only installs what's missing. The Microsoft Store updates installed apps on its own.

Does it slow down logon?

No. The logon script hands its work to a hidden background process and returns in about a second.

Can it run as a computer startup script?

No. Startup scripts run as SYSTEM, and the Store doesn't allow installs in that context. It must run as the user.

Where is the log?

In each user's profile: %LOCALAPPDATA%\StoreAppInstall\install.log. It's cleared automatically when it grows past 100 KB.

About the two apps in the example

We wrote this script for our own apps, and they stay in it as the working example. OneMount mounts SharePoint Online and OneDrive libraries as ordinary Windows drives — no sync, no local copies, Office co-authoring intact; one GPO and every user in the OU has it after the next sign-in. PostMount keeps scanners, printers, ERP systems and older mail apps sending and receiving mail through Microsoft 365 after Basic Auth was switched off. If either fits your organization, the script is already set up for them.

Related: Map SharePoint as a network drive · Create a SharePoint site for your organization · OneMount deployment & policies


Sources