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:
- 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.
- Controleert wat er al geïnstalleerd is – één aanroep van
winget exportgeeft in zo'n twee seconden alle Store-apps van de gebruiker. - Slaat over wat er al is. Geïnstalleerde apps blijven onaangeroerd – geen herinstallatie, geen update. De Store houdt ze zelf bij.
- Installeert wat ontbreekt stil met
winget install --source msstore, controleert opnieuw of de app er echt is en opent hem één keer. - Schrijft een kort logboek naar
%LOCALAPPDATA%\StoreAppInstall\install.logmet éé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 gebeurde | Tijd |
|---|---|
| Aanmeldscript geeft de controle terug aan Windows | ~1 s |
| Eerste aanmelding: OneMount uit de Store geïnstalleerd en geopend | 2 min 14 s |
| Eerste aanmelding: PostMount geïnstalleerd en geopend | 1 min 58 s |
| WhatsApp, de traagste app die we probeerden | 5 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
msstorebereiken. Controleer dat metwinget source listop 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.
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.
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.
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.
-ExecutionPolicy Bypass weg.
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.
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
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:
| App | Installatietype | Gewone gebruiker, zonder beheerdersrechten |
|---|---|---|
| OneMount, PostMount, Telegram Desktop, WhatsApp, ChatGPT, Pinterest | msstore – echt Store-pakket | Installeert |
| Microsoft Teams, Zoom Workplace | exe – installatieprogramma van de uitgever, per gebruiker | Installeert |
| Adobe Acrobat Reader, TeamViewer | exe – installatieprogramma van de uitgever, per computer | Beheerder nodig (UAC) |
| Microsoft 365 (Office) | – helemaal geen pakket | Zo 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 logboek | Wat het betekent | Wat te doen |
|---|---|---|
0x8A150014 – No package found | De Store-ID is geen installeerbaar pakket | Controleer de ID; sommige vermeldingen (Microsoft 365) zijn alleen webpagina's |
0x800704C7 – Canceled | Het installatieprogramma vroeg om beheerdersrechten en de melding werd gesloten | Rol die app op een andere manier uit, of alleen naar gebruikers met beheerdersrechten |
0x8A15005E – certificaat komt niet overeen | winget kon het certificaat van de Store-server niet vertrouwen | Eé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 min | Een Store-download hangt (zichtbaar als in behandeling in Store → Bibliotheek) | Niets – bij de volgende aanmelding volgt een nieuwe poging |
| winget not available | Gloednieuw profiel, App Installer nog niet geregistreerd | Lost zich meestal op tegen de volgende aanmelding; controleer anders App Installer |
| „Toegang geweigerd” bij het kopiëren van het script naar SYSVOL | Schrijven naar SYSVOL via het netwerk is beperkt | Kopieer het op een domeincontroller naar C:\Windows\SYSVOL\sysvol\<jouw-domein>\scripts\ |
| Config error: listed twice / invalid | Een typefout in de lijst met apps | Corrigeer 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?
| Methode | Wanneer het past | Het nadeel |
|---|---|---|
| GPO-aanmeldscript + winget (deze gids) | Active Directory-domein, geen Intune | Installeert per gebruiker; het script onderhoud je zelf |
| Microsoft Intune | Apparaten al geregistreerd in Intune | Vereist Intune-licenties en apparaatregistratie |
Provisioning (Add-AppxProvisionedPackage) | Elk account op een pc, masterimages | Beheerdersrechten, het pakketbestand en een offline licentie |
| Met de hand uit de Store installeren | Een handvol pc's | Schaalt 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.
<#
.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
- Microsoft – retourcodes van winget (winget-cli op GitHub).
- Microsoft – opdracht winget export.
- Microsoft – Maximum wait time for Group Policy scripts (standaard 600 seconden).
- Fleet – msstore-installaties via winget worden onder het SYSTEM-account niet ondersteund.
- Microsoft – toegang tot de Microsoft Store configureren.