Kurz gesagt. Legen Sie das Skript
in SYSVOL ab, tragen Sie Ihre Apps mit ihrer Store-ID ein und fügen Sie es in einer
GPO unter User Configuration → Windows Settings → Scripts (Logon/Logoff) → Logon auf der
Registerkarte Scripts hinzu – als powershell.exe mit
-ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File "<Pfad>".
Bei jeder Anmeldung prüft es, welche Apps der Benutzer schon hat, und installiert per
winget nur die fehlenden. In unseren Tests ließen sich echte Store-Pakete ohne
Adminrechte installieren.
Was das Skript macht
Das Skript läuft bei jeder Anmeldung, arbeitet aber nur dann wirklich, wenn etwas fehlt. Ein Durchlauf sieht so aus:
- Übergibt an den Hintergrund. Das Anmeldeskript selbst ist nach etwa einer Sekunde fertig und startet eine versteckte Kopie von sich, damit die Anmeldung nie verzögert wird.
- Prüft, was installiert ist – ein einziger Aufruf von
winget exportlistet in rund zwei Sekunden alle Store-Apps des Benutzers. - Überspringt Vorhandenes. Installierte Apps werden nicht angefasst – keine Neuinstallation, kein Update. Aktuell hält sie der Store selbst.
- Installiert Fehlendes still per
winget install --source msstore, prüft erneut, ob die App wirklich da ist, und öffnet sie einmal. - Schreibt ein kurzes Protokoll nach
%LOCALAPPDATA%\StoreAppInstall\install.logmit einer Ergebniszeile pro Durchlauf: OK, FAILED, POSTPONED, CONFIG_ERRORS.
Ab Werk installiert es unsere beiden eigenen Apps, OneMount und PostMount, als funktionierendes Beispiel. Ersetzen Sie sie durch beliebige Store-Apps – die Liste ist das Einzige, was Sie bearbeiten.
In einer echten Domäne getestet
Alle Zahlen unten stammen aus einer Testdomäne (Domänencontroller mit Windows Server, Clients mit Windows 11 22H2, ein normaler Domänenbenutzer ohne Adminrechte) – nicht aus der Dokumentation.
| Was passierte | Zeit |
|---|---|
| Anmeldeskript gibt die Kontrolle an Windows zurück | ~1 s |
| Erste Anmeldung: OneMount aus dem Store installiert und geöffnet | 2 min 14 s |
| Erste Anmeldung: PostMount installiert und geöffnet | 1 min 58 s |
| WhatsApp, die langsamste getestete App | 5 min 12 s |
| Jede weitere Anmeldung (beide Apps schon installiert) | 4 s |
Den Großteil einer Erstinstallation verbringt der Store damit, das Paket für den Benutzer zu holen – nicht das Skript. Genau deshalb arbeitet das Skript im Hintergrund: Sonst würde schon eine einzige langsame App an das Zeitlimit für Gruppenrichtlinienskripte stoßen.
Voraussetzungen
- Windows 10 oder 11 mit winget (App Installer). Ist in aktuellem Windows enthalten; in einem ganz neuen Profil taucht es etwa eine Minute nach der ersten Anmeldung auf, und das Skript wartet darauf. Getestet haben wir unter Windows 11 22H2; Windows 10 mit aktuellem App Installer sollte sich genauso verhalten, überprüft haben wir das aber nicht.
- winget erreicht die Quelle
msstore. Prüfen Sie das mitwinget source listauf dem PC eines Benutzers. Darauf kommt es an, nicht darauf, ob sich die Store-App öffnet: Laut Microsoft verhindert das Sperren der Store-App nicht unbedingt Installationen über winget. - Domänenrechner und GPMC (
gpmc.msc) auf einem Rechner mit RSAT oder auf dem Domänencontroller. - Eine Test-OU mit einem Testbenutzer. Verknüpfen Sie die GPO zuerst dort und erweitern Sie dann.
Schritt 1 – Store-ID jeder App finden
Öffnen Sie die Seite der App auf apps.microsoft.com. Die Store-ID ist der letzte Teil
der Adresse – bei OneMount 9P68LJCNCHND. Alternativ führen Sie
winget search "Name" --source msstore aus und nehmen die Spalte Id. Die meisten
IDs sehen aus wie 9XXXXXXXXXXX; Einträge, die den eigenen Installer des Herstellers
starten, haben längere IDs, die mit XP beginnen.
Schritt 2 – Apps ins Skript eintragen
Am Anfang von StoreAppInstall.ps1 steht eine einzige Liste. Jede Zeile ist eine App: ein
Name für das Protokoll und die StoreId. Fügen Sie Launch = $false
hinzu, wenn sich die App nach der Installation nicht öffnen soll, oder ergänzen Sie
-NoLaunch in den Skriptparametern, damit sich gar nichts öffnet.
[object[]]$Apps = @(
@{ Name = 'OneMount'; StoreId = '9P68LJCNCHND' }
@{ Name = 'PostMount'; StoreId = '9PN0511DJX9K' }
@{ Name = 'WhatsApp'; StoreId = '9NKSQGP7F2NH'; Launch = $false }
)
Mehr müssen Sie nicht bearbeiten. Das Skript prüft die Liste selbst: Eine fehlerhafte oder doppelte Store-ID landet als Konfigurationsfehler im Protokoll, statt den Durchlauf abzubrechen.
Schritt 3 – Skript in SYSVOL ablegen
Das Skript muss dort liegen, wo jeder Domänenbenutzer es lesen kann:
\\<Ihre-Domäne>\SYSVOL\<Ihre-Domäne>\scripts\. Es direkt von Ihrem eigenen PC in
diesen Netzwerkpfad zu kopieren, schlägt höchstwahrscheinlich fehl – Schreibzugriff auf SYSVOL über
das Netzwerk ist eingeschränkt. Erledigen Sie das stattdessen auf einem der Domänencontroller:
Kopieren Sie die Datei in den lokalen Ordner
C:\Windows\SYSVOL\sysvol\<Ihre-Domäne>\scripts\ (in unserer Testdomäne
C:\Windows\SYSVOL\sysvol\skylflow.local\scripts\).
Dieser lokale Ordner ist genau das, was als SYSVOL freigegeben ist, und Windows repliziert ihn von selbst auf die anderen Domänencontroller. Öffnen Sie danach den Netzwerkpfad und prüfen Sie, ob die Datei da ist – diese Adresse verwendet die GPO.
Schritt 4 – GPO erstellen und mit der OU verknüpfen
Klicken Sie in der Group Policy Management mit der rechten Maustaste auf die OU mit
Ihren Benutzern → Create a GPO in this domain, and Link it here… und geben Sie ihr einen
klaren Namen – wir haben StoreAppInstall verwendet. Danach Rechtsklick auf die neue
Verknüpfung → Edit.
Schritt 5 – als Anmeldeskript hinzufügen (Registerkarte Scripts)
Gehen Sie im Editor zu User Configuration → Policies → Windows Settings → Scripts (Logon/Logoff) und öffnen Sie Logon. Bleiben Sie auf der Registerkarte Scripts – nicht PowerShell Scripts – und klicken Sie auf Add:
Script Name: powershell.exe Script Parameters: -ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File "\\your-domain\SYSVOL\your-domain\scripts\StoreAppInstall.ps1"
Warum nicht die Registerkarte PowerShell Scripts? Sie führt Skripte unter der
Ausführungsrichtlinie des Rechners aus, die auf den meisten PCs unsignierte Skripte blockiert, solange
Sie keine weitere Richtlinie (Turn on Script Execution) hinzufügen. Ein Aufruf von
powershell.exe mit -ExecutionPolicy Bypass über die Registerkarte Scripts
braucht sonst nichts, und -WindowStyle Hidden erspart dem Benutzer ein Konsolenfenster.
-ExecutionPolicy Bypass weg.
Schritt 6 – Geltungsbereich prüfen und testen
Stellen Sie auf der Registerkarte Scope der GPO sicher, dass sie mit der richtigen OU
verknüpft ist und Security Filtering Ihre Benutzer enthält (standardmäßig Authenticated
Users). Führen Sie dann auf einem Test-PC gpupdate /force aus, melden Sie sich ab und
mit einem Benutzer aus dieser OU wieder an.
Ein bis zwei Minuten nach der Anmeldung öffnen sich die Apps von selbst. Das Protokoll zeigt genau, was passiert ist – das ist eine echte Erstanmeldung aus unserem 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
Und die nächste Anmeldung – insgesamt vier Sekunden:
[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
Welche Store-Apps sich so verteilen lassen
Nicht jeder Store-Eintrag ist dieselbe Art Paket.
winget show --id <StoreId> --source msstore zeigt den Installer Type, und der
entscheidet, ob ein normaler Benutzer die App installieren kann. Das haben wir ausprobiert:
| App | Installertyp | Standardbenutzer ohne Adminrechte |
|---|---|---|
| OneMount, PostMount, Telegram Desktop, WhatsApp, ChatGPT, Pinterest | msstore – echtes Store-Paket | Wird installiert |
| Microsoft Teams, Zoom Workplace | exe – Installer des Herstellers, pro Benutzer | Wird installiert |
| Adobe Acrobat Reader, TeamViewer | exe – Installer des Herstellers, pro Rechner | Adminrechte nötig (UAC) |
| Microsoft 365 (Office) | – überhaupt kein Paket | So nicht installierbar |
Faustregel: Alle msstore-Einträge, die wir ausprobiert haben, ließen sich ohne
Adminrechte installieren, auch wenn Richtlinien Ihrer Organisation das einschränken können.
exe-Einträge sind das eigene Setup des Herstellers hinter einer Store-Seite – manche
installieren pro Benutzer, andere verlangen Erhöhung, die ein Standardbenutzer nicht bestätigen kann.
Das Skript warnt davor schon vor der Installation im Protokoll. Microsoft 365 liefert „No package
found“: Seine Store-Seite ist eine Abo-Seite, Office wird stattdessen mit dem Office Deployment Tool
verteilt.
Warum das Skript so gebaut ist
Ein Benutzer-Anmeldeskript, kein Computer-Startskript
Store-Pakete werden für einen Benutzer installiert, und der Store lehnt Installationen aus dem SYSTEM-Konto ab – winget meldet „Device wide install for msstore type is not supported under admin context“. Ein Startskript läuft als SYSTEM und kann diese Aufgabe deshalb nicht erledigen. Ein Anmeldeskript läuft als genau der Benutzer, der die App bekommen soll – genau das Richtige.
Es arbeitet im Hintergrund
Windows beendet Gruppenrichtlinienskripte standardmäßig nach 10 Minuten (Richtlinie Maximum wait time for Group Policy scripts). Die Erstinstallation mehrerer Apps kann länger dauern – allein WhatsApp brauchte fünf Minuten. Deshalb startet das Anmeldeskript eine versteckte Kopie von sich und beendet sich sofort: Die Anmeldung wird nicht verzögert, und nichts wird mittendrin abgebrochen. Jede Installation hat zusätzlich ein eigenes Limit von 20 Minuten, sodass ein im Store hängender Download den Durchlauf nicht ewig blockiert.
Es aktualisiert nie Apps
winget install bleibt bei einer schon installierten App nicht stehen – es versucht, sie
zu aktualisieren. Ein Anmeldeskript, das das tut, würde Apps still und nicht nach Ihrem Zeitplan
aktualisieren. Deshalb fragt das Skript zuerst winget export, was installiert ist, und
ruft install nur für fehlende Apps auf. Updates bleiben Sache des Store.
Eine Einschränkung: winget export listet die Apps, die winget der Store-Quelle zuordnen
kann. Eine auf anderem Weg installierte App – etwa Zoom aus dem MSI des Herstellers – wird
möglicherweise nicht zugeordnet. Dann versucht das Skript die Installation aus dem Store, und winget
findet die vorhandene Kopie und versucht, sie zu aktualisieren. Lassen Sie Apps, die Sie bereits
anders verteilen, in der Liste weg.
Keine Statusdateien, nichts aufzuräumen
Das Skript führt weder Cache noch Markierungen – nur sein Protokoll. Was installiert ist, wird jedes Mal neu gelesen: Ein Benutzer, der eine App deinstalliert, bekommt sie bei der nächsten Anmeldung zurück, und ein neu aufgesetzter PC fängt einfach von vorn an. Zwei Kopien laufen nie gleichzeitig: Eine Sperre pro Benutzer fängt parallele Sitzungen ab.
Fehlerbehebung
Jedes Problem erscheint in %LOCALAPPDATA%\StoreAppInstall\install.log mit einem Hinweis. Diese sind uns begegnet:
| Im Protokoll | Was es bedeutet | Was zu tun ist |
|---|---|---|
0x8A150014 – No package found | Die Store-ID ist kein installierbares Paket | ID prüfen; manche Einträge (Microsoft 365) sind nur Webseiten |
0x800704C7 – Canceled | Der Installer wollte Adminrechte, die Abfrage wurde geschlossen | Diese App anders verteilen oder nur an Benutzer mit Adminrechten |
0x8A15005E – Zertifikat passt nicht | winget konnte dem Zertifikat des Store-Servers nicht vertrauen | Einmal pro PC als Admin: winget source reset --force; hilft das nicht, auf einen TLS-prüfenden Proxy prüfen |
| Install did not finish in 20 min | Ein Store-Download hängt (in Store → Bibliothek als ausstehend sichtbar) | Nichts – bei der nächsten Anmeldung neuer Versuch |
| winget not available | Ganz neues Profil, App Installer noch nicht registriert | Erledigt sich meist bis zur nächsten Anmeldung; sonst App Installer prüfen |
| „Zugriff verweigert“ beim Kopieren des Skripts nach SYSVOL | Schreibzugriff auf SYSVOL über das Netzwerk ist eingeschränkt | Auf einem Domänencontroller nach C:\Windows\SYSVOL\sysvol\<Ihre-Domäne>\scripts\ kopieren |
| Config error: listed twice / invalid | Tippfehler in der App-Liste | Die im Protokoll genannte Zeile in $Apps korrigieren |
Um zu sehen, was das Skript tut, ohne auf eine Anmeldung zu warten, starten Sie es von Hand in einem
normalen PowerShell-Fenster mit -Foreground – dann arbeitet es in diesem Fenster, statt
an den Hintergrund zu übergeben.
GPO-Skript, Intune oder Provisionierung?
| Methode | Wann sie passt | Der Haken |
|---|---|---|
| GPO-Anmeldeskript + winget (dieser Leitfaden) | Active-Directory-Domäne, kein Intune | Installiert pro Benutzer; das Skript pflegen Sie selbst |
| Microsoft Intune | Geräte sind bereits in Intune registriert | Braucht Intune-Lizenzen und Geräteregistrierung |
Provisionierung (Add-AppxProvisionedPackage) | Jedes Konto auf einem PC, Masterimages | Adminrechte, die Paketdatei und eine Offlinelizenz |
| Von Hand aus dem Store installieren | Eine Handvoll PCs | Skaliert nicht |
Dieser Ansatz installiert eine App für jeden Benutzer, für den die GPO gilt, bei dessen Anmeldung. Das
deckt den üblichen Fall ab – alle in der OU bekommen die App bei der ersten Anmeldung. Soll eine App
für jedes Konto auf einem Rechner da sein, bevor sich überhaupt jemand anmeldet, ist das
Provisionierung (Add-AppxProvisionedPackage): Sie läuft als Admin und braucht die
Paketdatei selbst und für Store-Apps eine Offlinelizenz – die früher aus dem inzwischen eingestellten
Microsoft Store für Unternehmen kam. Für die meisten kleinen und mittleren Organisationen ist das
Anmeldeskript pro Benutzer die praktische Lösung.
Das vollständige Skript
Frei verwendbar und anpassbar (MIT-Lizenz). Bearbeiten müssen Sie nur die Liste $Apps;
alles andere ist in der Datei kommentiert (auf Englisch, damit es in jedem Editor und jeder Konsole
gleich lesbar ist). Das Skript liegt auch auf
GitHub –
Issues und Verbesserungen sind dort willkommen.
<#
.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
Häufige Fragen
Lassen sich Microsoft-Store-Apps per Gruppenrichtlinie ohne Intune verteilen?
Ja. Eine eingebaute Einstellung „Store-App installieren“ gibt es in der Gruppenrichtlinie nicht, aber ein Anmeldeskript, das winget aufruft, erledigt das für Store-Pakete. Das Skript oben wurde in einer Active-Directory-Domäne von Anfang bis Ende getestet.
Brauchen Benutzer Administratorrechte?
Nicht für echte Store-Pakete (msstore): Keins der getesteten brauchte sie, auch wenn Richtlinien der Organisation Installationen einschränken können. Manche Einträge starten den eigenen Installer des Herstellers, und einige davon verlangen Erhöhung – das Skript markiert sie im Protokoll.
Aktualisiert das Skript bereits installierte Apps?
Nein. Es prüft zuerst, was installiert ist, und installiert nur Fehlendes. Installierte Apps aktualisiert der Microsoft Store selbst.
Verlangsamt es die Anmeldung?
Nein. Das Anmeldeskript übergibt seine Arbeit an einen versteckten Hintergrundprozess und ist nach etwa einer Sekunde fertig.
Kann es als Computer-Startskript laufen?
Nein. Startskripte laufen als SYSTEM, und der Store erlaubt in diesem Kontext keine Installation. Das Skript muss als Benutzer laufen.
Wo liegt das Protokoll?
Im Profil jedes Benutzers: %LOCALAPPDATA%\StoreAppInstall\install.log. Es wird automatisch geleert, sobald es größer als 100 KB wird.
Über die beiden Apps im Beispiel
Wir haben das Skript für unsere eigenen Apps geschrieben, und sie bleiben als funktionierendes Beispiel darin. OneMount bindet SharePoint-Online- und OneDrive-Bibliotheken als normale Windows-Laufwerke ein – ohne Synchronisierung, ohne lokale Kopien, mit intakter gemeinsamer Bearbeitung in Office; eine GPO, und jeder Benutzer in der OU hat es nach der nächsten Anmeldung. PostMount sorgt dafür, dass Scanner, Drucker, ERP-Systeme und ältere Mailprogramme auch nach dem Abschalten von Basic Auth weiter Mails über Microsoft 365 senden und empfangen. Passt eine davon zu Ihrer Organisation, ist das Skript schon dafür eingerichtet.
Weiterlesen: SharePoint als Netzlaufwerk verbinden · Eine SharePoint-Website für Ihre Organisation erstellen · Bereitstellung & Richtlinien für OneMount
Quellen
- Microsoft – Rückgabecodes von winget (winget-cli auf GitHub).
- Microsoft – Befehl winget export.
- Microsoft – Maximum wait time for Group Policy scripts (standardmäßig 600 Sekunden).
- Fleet – msstore-Installationen per winget werden unter dem SYSTEM-Konto nicht unterstützt.
- Microsoft – Zugriff auf den Microsoft Store konfigurieren.