Kort antwoord. Zet het script in SYSVOL, zet je apps erin met hun Store-ID en voeg het toe aan een GPO onder User Configuration → Windows Settings → Scripts (Logon/Logoff) → Logon, op het tabblad Scripts, als powershell.exe met -ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File "<pad>". Bij elke aanmelding controleert het welke apps de gebruiker al heeft en installeert het met winget alleen wat ontbreekt. In onze tests installeerden echte Store-pakketten zonder beheerdersrechten.

Wat het script doet

Het script draait bij elke aanmelding, maar doet alleen echt iets als er iets ontbreekt. Eén uitvoering ziet er zo uit:

  1. Geeft het werk door aan de achtergrond. Het aanmeldscript zelf is na ongeveer een seconde klaar en start een verborgen kopie van zichzelf, dus de aanmelding wordt nooit vertraagd.
  2. Controleert wat er al geïnstalleerd is – één aanroep van winget export geeft in zo'n twee seconden alle Store-apps van de gebruiker.
  3. Slaat over wat er al is. Geïnstalleerde apps blijven onaangeroerd – geen herinstallatie, geen update. De Store houdt ze zelf bij.
  4. Installeert wat ontbreekt stil met winget install --source msstore, controleert opnieuw of de app er echt is en opent hem één keer.
  5. Schrijft een kort logboek naar %LOCALAPPDATA%\StoreAppInstall\install.log met één resultaatregel per uitvoering: OK, FAILED, POSTPONED, CONFIG_ERRORS.

Standaard installeert het onze eigen twee apps, OneMount en PostMount, als werkend voorbeeld. Vervang ze door de Store-apps die je nodig hebt – de lijst is het enige wat je aanpast.

Getest in een echt domein

Alle cijfers hieronder komen uit een testdomein (domeincontroller met Windows Server, clients met Windows 11 22H2, een gewone domeingebruiker zonder beheerdersrechten), niet uit de documentatie.

Wat er gebeurdeTijd
Aanmeldscript geeft de controle terug aan Windows~1 s
Eerste aanmelding: OneMount uit de Store geïnstalleerd en geopend2 min 14 s
Eerste aanmelding: PostMount geïnstalleerd en geopend1 min 58 s
WhatsApp, de traagste app die we probeerden5 min 12 s
Elke volgende aanmelding (beide apps al geïnstalleerd)4 s

Het grootste deel van een eerste installatie is de Store die het pakket voor de gebruiker ophaalt, niet het script. Daarom werkt het script ook op de achtergrond: anders zou één trage app al tegen de tijdslimiet voor Groepsbeleid-scripts aanlopen.

Voordat je begint

  • Windows 10 of 11 met winget (App Installer). Zit in de huidige Windows; in een gloednieuw profiel verschijnt het ongeveer een minuut na de eerste aanmelding, en het script wacht erop. We hebben getest op Windows 11 22H2; Windows 10 met een actuele App Installer zou zich hetzelfde moeten gedragen, maar dat hebben we niet gecontroleerd.
  • winget kan de bron msstore bereiken. Controleer dat met winget source list op de pc van een gebruiker. Dat is wat telt, niet of de Store-app opent: volgens Microsoft houdt het blokkeren van de Store-app installaties via winget niet per se tegen.
  • Domeincomputers en GPMC (gpmc.msc) op een computer met RSAT of op de domeincontroller.
  • Een test-OU met één testgebruiker. Koppel de GPO daar eerst aan en breid daarna uit.

Stap 1 – Zoek de Store-ID van elke app

Open de pagina van de app op apps.microsoft.com. De Store-ID is het laatste deel van het adres – voor OneMount is dat 9P68LJCNCHND. Je kunt ook winget search "naam" --source msstore uitvoeren en de kolom Id nemen. De meeste ID's zien eruit als 9XXXXXXXXXXX; vermeldingen die het eigen installatieprogramma van de uitgever starten, hebben langere ID's die met XP beginnen.

OneMount-pagina op apps.microsoft.com; de Store-ID 9p68ljcnchnd is het laatste deel van het adres
De Store-ID is het laatste segment van het adres op apps.microsoft.com (hoofdletters maken niet uit).

Stap 2 – Zet je apps in het script

Bovenaan StoreAppInstall.ps1 staat één lijst. Elke regel is één app: een Name voor het logboek en de StoreId. Voeg Launch = $false toe als de app na installatie niet mag openen, of voeg -NoLaunch toe aan de scriptparameters zodat er helemaal niets opent.

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

Verder hoef je niets aan te passen. Het script controleert de lijst zelf: een foute of dubbele Store-ID komt als configuratiefout in het logboek, in plaats van de uitvoering te laten vastlopen.

Stap 3 – Zet het script in SYSVOL

Het script moet staan waar elke domeingebruiker het kan lezen: \\<jouw-domein>\SYSVOL\<jouw-domein>\scripts\. Het vanaf je eigen pc direct naar dat netwerkpad kopiëren lukt hoogstwaarschijnlijk niet – schrijven naar SYSVOL via het netwerk is beperkt. Doe het daarom op een van de domeincontrollers: kopieer het bestand naar de lokale map C:\Windows\SYSVOL\sysvol\<jouw-domein>\scripts\ (in ons testdomein C:\Windows\SYSVOL\sysvol\skylflow.local\scripts\).

Die lokale map is precies wat als SYSVOL gedeeld wordt, en Windows repliceert hem vanzelf naar de andere domeincontrollers. Open daarna het netwerkpad om te controleren of het bestand er staat – dat adres gebruikt de GPO.

Verkenner met StoreAppInstall.ps1 op het netwerkpad skylflow.local\SYSVOL\skylflow.local\scripts
Het script op het netwerkpad \\skylflow.local\SYSVOL\skylflow.local\scripts – het adres dat de GPO gebruikt.

Stap 4 – Maak een GPO en koppel die aan de OU

Klik in Group Policy Management met de rechtermuisknop op de OU met je gebruikers → Create a GPO in this domain, and Link it here… en geef hem een duidelijke naam – wij gebruikten StoreAppInstall. Klik daarna met de rechtermuisknop op de nieuwe koppeling → Edit.

Group Policy Management-console met de organisatie-eenheid HQ geselecteerd
De OU met de gebruikers die de apps moeten krijgen – hier HQ.
Dialoogvenster New GPO met de naam StoreAppInstall
De GPO wordt direct op die OU gemaakt.
GPO StoreAppInstall gekoppeld aan de OU HQ, met de opdracht Edit in het snelmenu
De GPO is gekoppeld en ingeschakeld; Edit opent de Groepsbeleidsbeheer-editor.

Stap 5 – Voeg het toe als aanmeldscript (tabblad Scripts)

Ga in de editor naar User Configuration → Policies → Windows Settings → Scripts (Logon/Logoff) en open Logon. Blijf op het tabblad Scripts – niet PowerShell Scripts – en klik op Add:

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

Waarom niet het tabblad PowerShell Scripts? Dat voert scripts uit onder het uitvoeringsbeleid van de computer, dat op de meeste pc's niet-ondertekende scripts blokkeert tenzij je nog een beleid toevoegt (Turn on Script Execution). powershell.exe aanroepen met -ExecutionPolicy Bypass vanaf het tabblad Scripts heeft verder niets nodig, en -WindowStyle Hidden zorgt dat de gebruiker geen consolevenster ziet.

Behandel het als alle code die je uitrolt. Het script draait bij de aanmelding van elke gebruiker, dus wie het bestand kan wijzigen, kan code uitvoeren voor iedereen in de OU. Lees het voor de uitrol en geef alleen domeinbeheerders schrijfrechten op de map scripts in SYSVOL. Uitvoeringsbeleid is een vangnet, geen beveiligingsgrens: eist je organisatie ondertekende scripts, onderteken dit dan en laat -ExecutionPolicy Bypass weg.
Venster Add a Script in de GPO StoreAppInstall: Script Name powershell.exe en de parameterregel
Bij Script Name staat powershell.exe; het pad naar het script komt in Script Parameters.
Logon Properties van de GPO StoreAppInstall met powershell.exe op het tabblad Scripts
Het aanmeldscript staat klaar. Klik op OK en sluit de editor.

Stap 6 – Controleer het bereik en test

Controleer op het tabblad Scope van de GPO of die aan de juiste OU gekoppeld is en of Security Filtering je gebruikers bevat (standaard Authenticated Users). Voer daarna op een test-pc gpupdate /force uit, meld je af en meld je aan als gebruiker uit die OU.

Tabblad Scope van de GPO StoreAppInstall: gekoppeld aan skylflow.local/HQ, Authenticated Users in de beveiligingsfilter
Gekoppeld aan HQ, toegepast op Authenticated Users.

Een à twee minuten na de aanmelding openen de apps vanzelf. Het logboek laat precies zien wat er gebeurde – dit is een echte eerste aanmelding uit onze 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

En de volgende aanmelding – in totaal vier seconden:

[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
Vensters van PostMount en OneMount die na de eerste aanmelding vanzelf openden
Eerste aanmelding van een gewone gebruiker: beide apps uit de Store geïnstalleerd en geopend – zonder beheerdersrechten, zonder één klik.

Welke Store-apps je zo kunt uitrollen

Niet elke vermelding in de Store is hetzelfde soort pakket. winget show --id <StoreId> --source msstore toont het Installer Type, en dat bepaalt of een gewone gebruiker de app kan installeren. Dit hebben we geprobeerd:

AppInstallatietypeGewone gebruiker, zonder beheerdersrechten
OneMount, PostMount, Telegram Desktop, WhatsApp, ChatGPT, Pinterestmsstore – echt Store-pakketInstalleert
Microsoft Teams, Zoom Workplaceexe – installatieprogramma van de uitgever, per gebruikerInstalleert
Adobe Acrobat Reader, TeamViewerexe – installatieprogramma van de uitgever, per computerBeheerder nodig (UAC)
Microsoft 365 (Office)– helemaal geen pakketZo niet te installeren

Vuistregel: elke msstore-vermelding die we probeerden, installeerde zonder beheerdersrechten, al kan beleid van je organisatie dat beperken. exe-vermeldingen zijn het eigen installatieprogramma van de uitgever achter een Store-pagina – sommige installeren per gebruiker, andere vragen om verhoogde rechten die een gewone gebruiker niet kan goedkeuren. Het script waarschuwt daar al voor de installatie voor in het logboek. Microsoft 365 geeft „No package found”: de Store-pagina is een abonnementspagina, en Office rol je uit met de Office Deployment Tool.

Waarom het script zo gebouwd is

Een aanmeldscript voor de gebruiker, geen opstartscript voor de computer

Store-pakketten worden voor een gebruiker geïnstalleerd, en de Store weigert installaties vanuit het SYSTEM-account – winget meldt „Device wide install for msstore type is not supported under admin context”. Een opstartscript draait als SYSTEM en kan deze taak dus niet doen. Een aanmeldscript draait als precies de gebruiker die de app moet krijgen – en dat is wat nodig is.

Het werkt op de achtergrond

Windows stopt Groepsbeleid-scripts standaard na 10 minuten (beleid Maximum wait time for Group Policy scripts). De eerste installatie van meerdere apps kan langer duren – alleen WhatsApp al deed er vijf minuten over. Daarom start het aanmeldscript een verborgen kopie van zichzelf en stopt het meteen: de aanmelding wordt niet vertraagd en niets wordt halverwege afgebroken. Elke installatie heeft daarnaast een eigen limiet van 20 minuten, zodat een vastgelopen download in de Store de uitvoering niet eeuwig blokkeert.

Het werkt nooit apps bij

winget install stopt bij een al geïnstalleerde app niet – het probeert hem bij te werken. Een aanmeldscript dat dat doet, zou apps stilletjes bijwerken, en niet op het moment dat jij kiest. Daarom vraagt het script eerst aan winget export wat er geïnstalleerd is en roept het install alleen aan voor apps die ontbreken. Updates blijven bij de Store.

Eén kanttekening: winget export toont de apps die winget aan de Store-bron kan koppelen. Een app die op een andere manier is geïnstalleerd – bijvoorbeeld Zoom via het MSI van de uitgever – wordt mogelijk niet gekoppeld. Dan probeert het script de installatie uit de Store, en winget vindt de bestaande kopie en probeert die bij te werken. Laat apps die je al op een andere manier uitrolt weg uit de lijst.

Geen statusbestanden, niets op te ruimen

Het script houdt geen cache of markeringen bij – alleen zijn logboek. Wat er geïnstalleerd is, leest het elke keer opnieuw in: een gebruiker die een app verwijdert, krijgt hem bij de volgende aanmelding terug, en een opnieuw ingerichte pc begint gewoon opnieuw. Er draaien nooit twee kopieën tegelijk: een vergrendeling per gebruiker vangt parallelle sessies af.

Problemen oplossen

Elk probleem staat in %LOCALAPPDATA%\StoreAppInstall\install.log met een hint. Deze kwamen we tegen:

In het logboekWat het betekentWat te doen
0x8A150014 – No package foundDe Store-ID is geen installeerbaar pakketControleer de ID; sommige vermeldingen (Microsoft 365) zijn alleen webpagina's
0x800704C7 – CanceledHet installatieprogramma vroeg om beheerdersrechten en de melding werd geslotenRol die app op een andere manier uit, of alleen naar gebruikers met beheerdersrechten
0x8A15005E – certificaat komt niet overeenwinget kon het certificaat van de Store-server niet vertrouwenEén keer per pc, als beheerder: winget source reset --force; helpt dat niet, controleer dan op een proxy die TLS inspecteert
Install did not finish in 20 minEen Store-download hangt (zichtbaar als in behandeling in Store → Bibliotheek)Niets – bij de volgende aanmelding volgt een nieuwe poging
winget not availableGloednieuw profiel, App Installer nog niet geregistreerdLost zich meestal op tegen de volgende aanmelding; controleer anders App Installer
„Toegang geweigerd” bij het kopiëren van het script naar SYSVOLSchrijven naar SYSVOL via het netwerk is beperktKopieer het op een domeincontroller naar C:\Windows\SYSVOL\sysvol\<jouw-domein>\scripts\
Config error: listed twice / invalidEen typefout in de lijst met appsCorrigeer de regel in $Apps die in het logboek staat

Wil je zien wat het script doet zonder op een aanmelding te wachten, start het dan met de hand in een gewoon PowerShell-venster met -Foreground – dan werkt het in dat venster in plaats van het werk aan de achtergrond door te geven.

GPO-script, Intune of provisioning?

MethodeWanneer het pastHet nadeel
GPO-aanmeldscript + winget (deze gids)Active Directory-domein, geen IntuneInstalleert per gebruiker; het script onderhoud je zelf
Microsoft IntuneApparaten al geregistreerd in IntuneVereist Intune-licenties en apparaatregistratie
Provisioning (Add-AppxProvisionedPackage)Elk account op een pc, masterimagesBeheerdersrechten, het pakketbestand en een offline licentie
Met de hand uit de Store installerenEen handvol pc'sSchaalt niet

Deze aanpak installeert een app voor elke gebruiker op wie de GPO van toepassing is, bij diens aanmelding. Dat dekt het gewone geval: iedereen in de OU krijgt de app bij de eerste aanmelding. Moet een app voor elk account op een computer aanwezig zijn voordat iemand zich aanmeldt, dan is dat provisioning (Add-AppxProvisionedPackage): dat draait als beheerder en heeft het pakketbestand zelf nodig en voor Store-apps een offline licentie – die vroeger uit de inmiddels stopgezette Microsoft Store voor Bedrijven kwam. Voor de meeste kleine en middelgrote organisaties is het aanmeldscript per gebruiker de praktische keuze.

Het volledige script

Vrij te gebruiken en aan te passen (MIT-licentie). Alleen de lijst $Apps hoef je aan te passen; al het andere is in het bestand toegelicht (in het Engels, zodat het in elke editor en console hetzelfde leest). Het script staat ook op GitHub – issues en verbeteringen zijn daar welkom.

StoreAppInstall.ps1 · PowerShell 5.1 GitHub .ps1 downloaden
<#
.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

Veelgestelde vragen

Kun je Microsoft Store-apps via Groepsbeleid uitrollen zonder Intune?

Ja. Groepsbeleid heeft geen ingebouwde instelling „Store-app installeren”, maar een aanmeldscript dat winget aanroept, doet dat voor Store-pakketten. Het script hierboven is van begin tot eind getest in een Active Directory-domein.

Hebben gebruikers beheerdersrechten nodig?

Niet voor echte Store-pakketten (msstore): geen van de geteste had ze nodig, al kan beleid van de organisatie installaties beperken. Sommige vermeldingen starten het eigen installatieprogramma van de uitgever, en een paar daarvan vragen om verhoogde rechten – het script markeert ze in het logboek.

Werkt het script al geïnstalleerde apps bij?

Nee. Het controleert eerst wat er geïnstalleerd is en installeert alleen wat ontbreekt. Geïnstalleerde apps werkt de Microsoft Store zelf bij.

Vertraagt het de aanmelding?

Nee. Het aanmeldscript geeft zijn werk door aan een verborgen achtergrondproces en is na ongeveer een seconde klaar.

Kan het als opstartscript van de computer draaien?

Nee. Opstartscripts draaien als SYSTEM en de Store staat in die context geen installaties toe. Het script moet als de gebruiker draaien.

Waar staat het logboek?

In het profiel van elke gebruiker: %LOCALAPPDATA%\StoreAppInstall\install.log. Het wordt automatisch leeggemaakt zodra het groter wordt dan 100 KB.

Over de twee apps in het voorbeeld

We schreven dit script voor onze eigen apps, en ze blijven erin staan als werkend voorbeeld. OneMount koppelt SharePoint Online- en OneDrive-bibliotheken als gewone Windows-stations – zonder synchronisatie, zonder lokale kopieën, met cocreatie in Office intact; één GPO en elke gebruiker in de OU heeft het na de volgende aanmelding. PostMount zorgt dat scanners, printers, ERP-systemen en oudere mailprogramma's ook na het uitschakelen van Basic Auth mail blijven verzenden en ontvangen via Microsoft 365. Past een van beide bij je organisatie, dan is het script er al op ingesteld.

Lees ook: SharePoint koppelen als netwerkstation · Een SharePoint-site maken voor je organisatie · Uitrol & beleid van OneMount


Bronnen