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:

  1. Ü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.
  2. Prüft, was installiert ist – ein einziger Aufruf von winget export listet in rund zwei Sekunden alle Store-Apps des Benutzers.
  3. Überspringt Vorhandenes. Installierte Apps werden nicht angefasst – keine Neuinstallation, kein Update. Aktuell hält sie der Store selbst.
  4. Installiert Fehlendes still per winget install --source msstore, prüft erneut, ob die App wirklich da ist, und öffnet sie einmal.
  5. Schreibt ein kurzes Protokoll nach %LOCALAPPDATA%\StoreAppInstall\install.log mit 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 passierteZeit
Anmeldeskript gibt die Kontrolle an Windows zurück~1 s
Erste Anmeldung: OneMount aus dem Store installiert und geöffnet2 min 14 s
Erste Anmeldung: PostMount installiert und geöffnet1 min 58 s
WhatsApp, die langsamste getestete App5 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 mit winget source list auf 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.

OneMount-Seite auf apps.microsoft.com; die Store-ID 9p68ljcnchnd ist der letzte Teil der Adresse
Die Store-ID ist das letzte Segment der Adresse auf apps.microsoft.com (Groß-/Kleinschreibung egal).

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.

Explorer mit StoreAppInstall.ps1 im Netzwerkpfad skylflow.local\SYSVOL\skylflow.local\scripts
Das Skript im Netzwerkpfad \\skylflow.local\SYSVOL\skylflow.local\scripts – 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.

Konsole Group Policy Management mit ausgewählter Organisationseinheit HQ
Die OU mit den Benutzern, die die Apps bekommen sollen – hier HQ.
Dialog New GPO mit dem Namen StoreAppInstall
Die GPO wird direkt auf dieser OU erstellt.
GPO StoreAppInstall mit der OU HQ verknüpft, im Kontextmenü der Befehl Edit
Die GPO ist verknüpft und aktiviert; Edit öffnet den Gruppenrichtlinienverwaltungs-Editor.

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.

Behandeln Sie es wie jeden Code, den Sie verteilen. Das Skript läuft bei jeder Benutzeranmeldung – wer die Datei ändern kann, kann also Code für alle in der OU ausführen. Lesen Sie es vor dem Rollout und erlauben Sie Schreibzugriff auf den SYSVOL-Ordner scripts nur Domänenadministratoren. Die Ausführungsrichtlinie ist ein Sicherheitsnetz, keine Sicherheitsgrenze: Verlangt Ihre Organisation signierte Skripte, signieren Sie dieses und lassen Sie -ExecutionPolicy Bypass weg.
Dialog Add a Script in der GPO StoreAppInstall: Script Name powershell.exe und die Parameterzeile
Unter Script Name steht powershell.exe; der Pfad zum Skript kommt in Script Parameters.
Logon Properties der GPO StoreAppInstall mit powershell.exe auf der Registerkarte Scripts
Das Anmeldeskript ist eingetragen. Mit OK bestätigen und den Editor schließen.

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.

Registerkarte Scope der GPO StoreAppInstall: verknüpft mit skylflow.local/HQ, Authenticated Users in der Sicherheitsfilterung
Mit HQ verknüpft, angewendet auf Authenticated Users.

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
Fenster von PostMount und OneMount, die sich nach der ersten Anmeldung automatisch geöffnet haben
Erste Anmeldung eines Standardbenutzers: beide Apps aus dem Store installiert und geöffnet – ohne Adminrechte, ohne einen Klick.

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:

AppInstallertypStandardbenutzer ohne Adminrechte
OneMount, PostMount, Telegram Desktop, WhatsApp, ChatGPT, Pinterestmsstore – echtes Store-PaketWird installiert
Microsoft Teams, Zoom Workplaceexe – Installer des Herstellers, pro BenutzerWird installiert
Adobe Acrobat Reader, TeamViewerexe – Installer des Herstellers, pro RechnerAdminrechte nötig (UAC)
Microsoft 365 (Office)– überhaupt kein PaketSo 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 ProtokollWas es bedeutetWas zu tun ist
0x8A150014 – No package foundDie Store-ID ist kein installierbares PaketID prüfen; manche Einträge (Microsoft 365) sind nur Webseiten
0x800704C7 – CanceledDer Installer wollte Adminrechte, die Abfrage wurde geschlossenDiese App anders verteilen oder nur an Benutzer mit Adminrechten
0x8A15005E – Zertifikat passt nichtwinget konnte dem Zertifikat des Store-Servers nicht vertrauenEinmal 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 minEin Store-Download hängt (in Store → Bibliothek als ausstehend sichtbar)Nichts – bei der nächsten Anmeldung neuer Versuch
winget not availableGanz neues Profil, App Installer noch nicht registriertErledigt sich meist bis zur nächsten Anmeldung; sonst App Installer prüfen
„Zugriff verweigert“ beim Kopieren des Skripts nach SYSVOLSchreibzugriff auf SYSVOL über das Netzwerk ist eingeschränktAuf einem Domänencontroller nach C:\Windows\SYSVOL\sysvol\<Ihre-Domäne>\scripts\ kopieren
Config error: listed twice / invalidTippfehler in der App-ListeDie 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?

MethodeWann sie passtDer Haken
GPO-Anmeldeskript + winget (dieser Leitfaden)Active-Directory-Domäne, kein IntuneInstalliert pro Benutzer; das Skript pflegen Sie selbst
Microsoft IntuneGeräte sind bereits in Intune registriertBraucht Intune-Lizenzen und Geräteregistrierung
Provisionierung (Add-AppxProvisionedPackage)Jedes Konto auf einem PC, MasterimagesAdminrechte, die Paketdatei und eine Offlinelizenz
Von Hand aus dem Store installierenEine Handvoll PCsSkaliert 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.

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