W skrócie. Umieść skrypt
w SYSVOL, wpisz aplikacje według ich Store ID i dodaj go do GPO w
User Configuration → Windows Settings → Scripts (Logon/Logoff) → Logon, na karcie
Scripts, jako powershell.exe z parametrami
-ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File "<ścieżka>".
Przy każdym logowaniu sprawdza, co użytkownik już ma, i przez winget instaluje tylko
brakujące aplikacje. W naszych testach prawdziwe pakiety Store instalowały się bez uprawnień
administratora.
Co robi skrypt
Skrypt uruchamia się przy każdym logowaniu, ale naprawdę pracuje tylko wtedy, gdy czegoś brakuje. Jedno uruchomienie wygląda tak:
- Przekazuje pracę do tła. Sam skrypt logowania kończy się po mniej więcej sekundzie i uruchamia ukrytą kopię siebie, więc nigdy nie opóźnia logowania.
- Sprawdza, co jest zainstalowane — jedno wywołanie
winget exportw około dwie sekundy zwraca listę wszystkich aplikacji użytkownika ze Sklepu. - Pomija to, co już jest. Zainstalowanych aplikacji nie rusza — bez ponownej instalacji i bez aktualizacji. Aktualizuje je sam Store.
- Instaluje brakujące po cichu przez
winget install --source msstore, sprawdza jeszcze raz, czy aplikacja naprawdę jest, i raz ją otwiera. - Zapisuje krótki dziennik w
%LOCALAPPDATA%\StoreAppInstall\install.logz jednym wierszem wyniku na uruchomienie: OK, FAILED, POSTPONED, CONFIG_ERRORS.
Domyślnie instaluje nasze dwie aplikacje, OneMount i PostMount, jako działający przykład. Zastąp je dowolnymi aplikacjami ze Sklepu — lista to jedyne, co edytujesz.
Przetestowane w prawdziwej domenie
Wszystkie liczby poniżej pochodzą z domeny testowej (kontroler domeny z Windows Server, komputery z Windows 11 22H2, zwykły użytkownik domeny bez uprawnień administratora), a nie z dokumentacji.
| Co się działo | Czas |
|---|---|
| Skrypt logowania oddaje sterowanie Windows | ~1 s |
| Pierwsze logowanie: OneMount zainstalowany ze Sklepu i otwarty | 2 min 14 s |
| Pierwsze logowanie: PostMount zainstalowany i otwarty | 1 min 58 s |
| WhatsApp — najwolniejsza z testowanych aplikacji | 5 min 12 s |
| Każde kolejne logowanie (obie aplikacje już są) | 4 s |
Większość czasu pierwszej instalacji to Store pobierający pakiet dla użytkownika, a nie sam skrypt. Właśnie dlatego skrypt działa w tle: inaczej jedna wolna aplikacja trafiłaby na limit czasu dla skryptów zasad grupy.
Zanim zaczniesz
- Windows 10 lub 11 z winget (App Installer). Jest w aktualnym Windows; w całkiem nowym profilu pojawia się mniej więcej minutę po pierwszym logowaniu, a skrypt na niego poczeka. Testowaliśmy na Windows 11 22H2; Windows 10 z aktualnym App Installer powinien działać tak samo, ale tego nie sprawdzaliśmy.
- winget ma dostęp do źródła
msstore. Sprawdź to poleceniemwinget source listna komputerze użytkownika. Liczy się właśnie to, a nie to, czy otwiera się aplikacja Store: Microsoft zaznacza, że zablokowanie aplikacji Store nie musi zatrzymać instalacji przez winget. - Komputery w domenie i konsola GPMC (
gpmc.msc) — na kontrolerze domeny albo na komputerze z RSAT. - Testowa jednostka organizacyjna (OU) z jednym użytkownikiem testowym. Najpierw połącz z nią GPO, potem rozszerzaj.
Krok 1 — znajdź Store ID każdej aplikacji
Otwórz stronę aplikacji na apps.microsoft.com. Store ID to ostatnia część adresu — dla
OneMount to 9P68LJCNCHND. Możesz też uruchomić
winget search "nazwa" --source msstore i wziąć wartość z kolumny Id. Większość
identyfikatorów wygląda jak 9XXXXXXXXXXX; wpisy uruchamiające własny instalator wydawcy
mają dłuższe ID zaczynające się od XP.
Krok 2 — wpisz aplikacje do skryptu
Na początku StoreAppInstall.ps1 jest jedna lista. Każdy wiersz to jedna aplikacja:
Name do dziennika i StoreId. Dodaj Launch = $false, jeśli
aplikacja nie ma się otwierać po instalacji, albo dodaj -NoLaunch do parametrów skryptu,
żeby nic się nie otwierało.
[object[]]$Apps = @(
@{ Name = 'OneMount'; StoreId = '9P68LJCNCHND' }
@{ Name = 'PostMount'; StoreId = '9PN0511DJX9K' }
@{ Name = 'WhatsApp'; StoreId = '9NKSQGP7F2NH'; Launch = $false }
)
Nic więcej nie trzeba zmieniać. Skrypt sam sprawdza listę: błędny lub powtórzony Store ID trafi do dziennika jako błąd konfiguracji, zamiast przerwać działanie.
Krok 3 — umieść skrypt w SYSVOL
Skrypt musi leżeć tam, skąd każdy użytkownik domeny może go odczytać:
\\<twoja-domena>\SYSVOL\<twoja-domena>\scripts\. Skopiowanie go od razu pod tę
ścieżkę sieciową z własnego komputera najpewniej się nie uda — zapis do SYSVOL przez sieć jest
ograniczony. Zrób to na jednym z kontrolerów domeny: skopiuj plik do lokalnego folderu
C:\Windows\SYSVOL\sysvol\<twoja-domena>\scripts\ (w naszej domenie testowej
C:\Windows\SYSVOL\sysvol\skylflow.local\scripts\).
Ten lokalny folder to dokładnie to, co jest udostępnione jako SYSVOL, a Windows sam replikuje go na pozostałe kontrolery domeny. Potem otwórz ścieżkę sieciową i sprawdź, czy plik tam jest — tego adresu będzie używać GPO.
Krok 4 — utwórz GPO i połącz je z OU
W Group Policy Management kliknij prawym przyciskiem OU z użytkownikami →
Create a GPO in this domain, and Link it here… i nadaj czytelną nazwę — my użyliśmy
StoreAppInstall. Następnie prawym przyciskiem na nowym łączu → Edit.
Krok 5 — dodaj go jako skrypt logowania (karta Scripts)
W edytorze przejdź do User Configuration → Policies → Windows Settings → Scripts (Logon/Logoff) i otwórz Logon. Zostań na karcie Scripts — nie PowerShell Scripts — i kliknij Add:
Script Name: powershell.exe Script Parameters: -ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File "\\your-domain\SYSVOL\your-domain\scripts\StoreAppInstall.ps1"
Dlaczego nie karta PowerShell Scripts? Uruchamia skrypty zgodnie z zasadami wykonywania
komputera, które na większości komputerów blokują niepodpisane skrypty, dopóki nie dodasz kolejnej
zasady (Turn on Script Execution). Wywołanie powershell.exe z
-ExecutionPolicy Bypass z karty Scripts nie wymaga niczego więcej, a
-WindowStyle Hidden ukrywa przed użytkownikiem okno konsoli.
-ExecutionPolicy Bypass.
Krok 6 — sprawdź zakres i przetestuj
Na karcie Scope GPO upewnij się, że jest połączone z właściwą OU, a Security
Filtering obejmuje Twoich użytkowników (domyślnie Authenticated Users). Potem na
komputerze testowym uruchom gpupdate /force, wyloguj się i zaloguj jako użytkownik z tej
OU.
Minutę lub dwie po zalogowaniu aplikacje otwierają się same. Dziennik pokazuje dokładnie, co się stało — to prawdziwe pierwsze logowanie z naszego testu:
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
A kolejne logowanie — w sumie cztery sekundy:
[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
Które aplikacje ze Sklepu da się tak wdrożyć
Nie każdy wpis w Sklepie to pakiet tego samego rodzaju.
winget show --id <StoreId> --source msstore pokazuje Installer Type i to on
decyduje, czy zwykły użytkownik może zainstalować aplikację. Oto co sprawdziliśmy:
| Aplikacja | Typ instalatora | Zwykły użytkownik, bez admina |
|---|---|---|
| OneMount, PostMount, Telegram Desktop, WhatsApp, ChatGPT, Pinterest | msstore — prawdziwy pakiet Store | Instaluje się |
| Microsoft Teams, Zoom Workplace | exe — instalator wydawcy, dla użytkownika | Instaluje się |
| Adobe Acrobat Reader, TeamViewer | exe — instalator wydawcy, dla całego komputera | Wymaga admina (monit UAC) |
| Microsoft 365 (Office) | — to w ogóle nie jest pakiet | Tak się nie zainstaluje |
Prosta zasada: wszystkie wpisy msstore, które próbowaliśmy, instalowały się bez uprawnień
administratora, choć zasady Twojej organizacji mogą to ograniczać. Wpisy exe to własny
program instalacyjny wydawcy za stroną w Sklepie — jedne instalują się dla użytkownika, inne proszą o
podniesienie uprawnień, którego zwykły użytkownik nie zatwierdzi. Skrypt ostrzega o nich w dzienniku
jeszcze przed instalacją. Microsoft 365 zwraca „No package found”: jego strona w Sklepie służy do
zakupu subskrypcji, a Office wdraża się narzędziem Office Deployment Tool.
Dlaczego skrypt jest zbudowany właśnie tak
Skrypt logowania użytkownika, a nie skrypt startowy komputera
Pakiety Store instaluje się dla użytkownika, a Store odrzuca instalacje z konta SYSTEM — winget zgłasza „Device wide install for msstore type is not supported under admin context”. Skrypt startowy działa jako SYSTEM, więc tego zadania nie wykona. Skrypt logowania działa jako ten użytkownik, który ma dostać aplikację — czyli dokładnie tak, jak trzeba.
Działa w tle
Windows domyślnie zatrzymuje skrypty zasad grupy po 10 minutach (zasada Maximum wait time for Group Policy scripts). Pierwsza instalacja kilku aplikacji może trwać dłużej — sam WhatsApp instalował się pięć minut. Dlatego skrypt logowania uruchamia ukrytą kopię siebie i od razu się kończy: logowanie nie jest opóźniane, a nic nie zostaje przerwane w połowie. Każda instalacja ma dodatkowo własny limit 20 minut, więc zawieszone pobieranie w Sklepie nie zablokuje działania na zawsze.
Nigdy nie aktualizuje aplikacji
winget install dla już zainstalowanej aplikacji na tym się nie zatrzymuje — próbuje ją
zaktualizować. Skrypt logowania, który by tak robił, po cichu aktualizowałby aplikacje nie według
Twojego harmonogramu. Dlatego skrypt najpierw pyta winget export, co jest zainstalowane, i
wywołuje install tylko dla brakujących. Aktualizacje zostają po stronie Sklepu.
Jedno zastrzeżenie: winget export pokazuje aplikacje, które winget potrafi dopasować do
źródła Store. Aplikacja zainstalowana inaczej — na przykład Zoom z pliku MSI wydawcy — może nie zostać
dopasowana. Wtedy skrypt spróbuje ją zainstalować ze Sklepu, a winget, znajdując istniejącą kopię,
spróbuje ją zaktualizować. Aplikacji, które już wdrażasz w inny sposób, nie dodawaj do listy.
Bez plików stanu, nic do sprzątania
Skrypt nie trzyma pamięci podręcznej ani znaczników — tylko dziennik. To, co jest zainstalowane, odczytuje za każdym razem od nowa, więc użytkownik, który odinstaluje aplikację, dostanie ją z powrotem przy następnym logowaniu, a przeinstalowany komputer po prostu zacznie od początku. Dwie kopie nigdy nie działają naraz: blokada na poziomie użytkownika wyłapuje równoległe sesje.
Rozwiązywanie problemów
Każdy problem widać w %LOCALAPPDATA%\StoreAppInstall\install.log razem ze wskazówką. Oto te, na które trafiliśmy:
| W dzienniku | Co to oznacza | Co zrobić |
|---|---|---|
0x8A150014 — No package found | Pod tym Store ID nie ma pakietu do instalacji | Sprawdź ID; niektóre wpisy (Microsoft 365) to tylko strony WWW |
0x800704C7 — Canceled | Instalator poprosił o uprawnienia administratora, a monit zamknięto | Wdrażaj tę aplikację inaczej albo tylko użytkownikom z uprawnieniami administratora |
0x8A15005E — niezgodny certyfikat | winget nie zaufał certyfikatowi serwera Store | Raz na komputer, jako administrator: winget source reset --force; jeśli nie pomoże, sprawdź, czy nie ma proxy inspekcji TLS |
| Install did not finish in 20 min | Pobieranie w Sklepie się zawiesiło (w Store → Biblioteka widać je jako oczekujące) | Nic — przy następnym logowaniu będzie nowa próba |
| winget not available | Całkiem nowy profil, App Installer jeszcze niezarejestrowany | Zwykle samo mija do następnego logowania; w przeciwnym razie sprawdź App Installer |
| „Odmowa dostępu” przy kopiowaniu skryptu do SYSVOL | Zapis do SYSVOL przez sieć jest ograniczony | Na kontrolerze domeny skopiuj go do C:\Windows\SYSVOL\sysvol\<twoja-domena>\scripts\ |
| Config error: listed twice / invalid | Literówka na liście aplikacji | Popraw wiersz $Apps wskazany w dzienniku |
Żeby zobaczyć działanie skryptu bez czekania na logowanie, uruchom go ręcznie w zwykłym oknie
PowerShell z -Foreground — wtedy pracuje w tym oknie, zamiast przekazywać pracę do tła.
Skrypt GPO, Intune czy aprowizacja?
| Metoda | Kiedy pasuje | Haczyk |
|---|---|---|
| Skrypt logowania GPO + winget (ten poradnik) | Domena Active Directory, bez Intune | Instaluje dla każdego użytkownika; skrypt utrzymujesz Ty |
| Microsoft Intune | Urządzenia już zarejestrowane w Intune | Wymaga licencji Intune i rejestracji urządzeń |
Aprowizacja (Add-AppxProvisionedPackage) | Każde konto na komputerze, obrazy wzorcowe | Uprawnienia administratora, plik pakietu i licencja offline |
| Ręczna instalacja ze Sklepu | Kilka komputerów | Nie skaluje się |
To podejście instaluje aplikację każdemu użytkownikowi objętemu GPO podczas jego logowania. Obejmuje
to typowy przypadek: wszyscy w OU dostają aplikację przy pierwszym logowaniu. Jeśli aplikacja ma być
na każdym koncie komputera, zanim ktokolwiek się zaloguje, to już aprowizacja
(Add-AppxProvisionedPackage): działa jako administrator i wymaga samego pliku pakietu, a dla
aplikacji ze Sklepu także licencji offline, którą dawniej zapewniał wycofany już Microsoft Store dla
Firm. Dla większości małych i średnich organizacji skrypt logowania dla każdego użytkownika to
rozwiązanie praktyczne.
Pełny skrypt
Możesz go swobodnie używać i dostosowywać (licencja MIT). Edytować trzeba tylko listę
$Apps; reszta jest skomentowana w pliku (po angielsku, żeby czytała się tak samo w każdym
edytorze i konsoli). Skrypt jest też na
GitHub —
tam możesz zgłaszać uwagi i proponować ulepszenia.
<#
.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
Pytania i odpowiedzi
Czy można instalować aplikacje Microsoft Store przez zasady grupy bez Intune?
Tak. Zasady grupy nie mają wbudowanego ustawienia „zainstaluj aplikację ze Sklepu”, ale skrypt logowania, który wywołuje winget, robi to dla pakietów Store. Skrypt powyżej przetestowaliśmy od początku do końca w domenie Active Directory.
Czy użytkownicy potrzebują uprawnień administratora?
Nie dla prawdziwych pakietów Store (msstore): żaden z przetestowanych ich nie potrzebował, choć zasady organizacji mogą ograniczać instalacje. Niektóre wpisy uruchamiają własny instalator wydawcy, a część z nich prosi o podniesienie uprawnień — skrypt oznacza je w dzienniku.
Czy skrypt aktualizuje już zainstalowane aplikacje?
Nie. Najpierw sprawdza, co jest zainstalowane, i instaluje tylko brakujące. Zainstalowane aplikacje aktualizuje sam Microsoft Store.
Czy spowalnia logowanie?
Nie. Skrypt logowania przekazuje pracę ukrytemu procesowi w tle i kończy się po około sekundzie.
Czy można go uruchamiać jako skrypt startowy komputera?
Nie. Skrypty startowe działają jako SYSTEM, a Store nie pozwala na instalację w tym kontekście. Skrypt musi działać jako użytkownik.
Gdzie jest dziennik?
W profilu każdego użytkownika: %LOCALAPPDATA%\StoreAppInstall\install.log. Jest automatycznie czyszczony, gdy przekroczy 100 KB.
O dwóch aplikacjach z przykładu
Napisaliśmy ten skrypt dla własnych aplikacji i zostają w nim jako działający przykład. OneMount podłącza biblioteki SharePoint Online i OneDrive jako zwykłe dyski Windows — bez synchronizacji, bez lokalnych kopii, z zachowaną współpracą w Office; jedno GPO i każdy użytkownik w OU ma go po następnym logowaniu. PostMount pozwala skanerom, drukarkom, systemom ERP i starszym programom pocztowym dalej wysyłać i odbierać pocztę przez Microsoft 365 po wyłączeniu Basic Auth. Jeśli któraś z nich przyda się Twojej organizacji, skrypt jest już pod nie przygotowany.
Zobacz też: SharePoint jako dysk sieciowy · Jak utworzyć witrynę SharePoint dla organizacji · Wdrożenie i zasady OneMount
Źródła
- Microsoft — kody powrotu winget (winget-cli na GitHub).
- Microsoft — polecenie winget export.
- Microsoft — Maximum wait time for Group Policy scripts (domyślnie 600 sekund).
- Fleet — instalacje msstore przez winget nie są obsługiwane z konta SYSTEM.
- Microsoft — konfigurowanie dostępu do Microsoft Store.