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:

  1. 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.
  2. Sprawdza, co jest zainstalowane — jedno wywołanie winget export w około dwie sekundy zwraca listę wszystkich aplikacji użytkownika ze Sklepu.
  3. Pomija to, co już jest. Zainstalowanych aplikacji nie rusza — bez ponownej instalacji i bez aktualizacji. Aktualizuje je sam Store.
  4. Instaluje brakujące po cichu przez winget install --source msstore, sprawdza jeszcze raz, czy aplikacja naprawdę jest, i raz ją otwiera.
  5. Zapisuje krótki dziennik w %LOCALAPPDATA%\StoreAppInstall\install.log z 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łoCzas
Skrypt logowania oddaje sterowanie Windows~1 s
Pierwsze logowanie: OneMount zainstalowany ze Sklepu i otwarty2 min 14 s
Pierwsze logowanie: PostMount zainstalowany i otwarty1 min 58 s
WhatsApp — najwolniejsza z testowanych aplikacji5 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 poleceniem winget source list na 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.

Strona OneMount na apps.microsoft.com; Store ID 9p68ljcnchnd to ostatnia część adresu
Store ID to ostatni segment adresu na apps.microsoft.com (wielkość liter nie ma znaczenia).

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.

Eksplorator plików z StoreAppInstall.ps1 pod ścieżką sieciową skylflow.local\SYSVOL\skylflow.local\scripts
Skrypt pod ścieżką sieciową \\skylflow.local\SYSVOL\skylflow.local\scripts — tego adresu 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.

Konsola Group Policy Management z zaznaczoną jednostką organizacyjną HQ
OU z użytkownikami, którzy mają dostać aplikacje — tutaj HQ.
Okno New GPO z nazwą StoreAppInstall
Tworzymy GPO od razu na tej OU.
GPO StoreAppInstall połączone z OU HQ, w menu kontekstowym polecenie Edit
GPO jest połączone i włączone; Edit otwiera edytor zasad grupy.

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.

Traktuj go jak każdy wdrażany kod. Skrypt uruchamia się przy logowaniu każdego użytkownika, więc kto może zmienić plik, ten może uruchomić kod u wszystkich w OU. Przeczytaj go przed wdrożeniem i pozostaw prawo zapisu do folderu scripts w SYSVOL tylko administratorom domeny. Zasady wykonywania to siatka bezpieczeństwa, a nie granica bezpieczeństwa: jeśli Twoja organizacja wymaga podpisanych skryptów, podpisz ten i usuń -ExecutionPolicy Bypass.
Okno Add a Script w GPO StoreAppInstall: Script Name powershell.exe i wiersz parametrów
W Script Name jest powershell.exe; ścieżka do skryptu trafia do Script Parameters.
Logon Properties GPO StoreAppInstall z dodanym powershell.exe na karcie Scripts
Skrypt logowania jest na miejscu. Kliknij OK i zamknij edytor.

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.

Karta Scope GPO StoreAppInstall: połączenie z skylflow.local/HQ i Authenticated Users w filtrowaniu zabezpieczeń
Połączone z HQ, stosowane do Authenticated Users.

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
Okna PostMount i OneMount otwarte automatycznie po pierwszym logowaniu
Pierwsze logowanie zwykłego użytkownika: obie aplikacje zainstalowane ze Sklepu i otwarte — bez uprawnień administratora i bez żadnego kliknięcia.

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:

AplikacjaTyp instalatoraZwykły użytkownik, bez admina
OneMount, PostMount, Telegram Desktop, WhatsApp, ChatGPT, Pinterestmsstore — prawdziwy pakiet StoreInstaluje się
Microsoft Teams, Zoom Workplaceexe — instalator wydawcy, dla użytkownikaInstaluje się
Adobe Acrobat Reader, TeamViewerexe — instalator wydawcy, dla całego komputeraWymaga admina (monit UAC)
Microsoft 365 (Office)— to w ogóle nie jest pakietTak 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 dziennikuCo to oznaczaCo zrobić
0x8A150014 — No package foundPod tym Store ID nie ma pakietu do instalacjiSprawdź ID; niektóre wpisy (Microsoft 365) to tylko strony WWW
0x800704C7 — CanceledInstalator poprosił o uprawnienia administratora, a monit zamkniętoWdrażaj tę aplikację inaczej albo tylko użytkownikom z uprawnieniami administratora
0x8A15005E — niezgodny certyfikatwinget nie zaufał certyfikatowi serwera StoreRaz 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 minPobieranie 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 availableCałkiem nowy profil, App Installer jeszcze niezarejestrowanyZwykle samo mija do następnego logowania; w przeciwnym razie sprawdź App Installer
„Odmowa dostępu” przy kopiowaniu skryptu do SYSVOLZapis do SYSVOL przez sieć jest ograniczonyNa kontrolerze domeny skopiuj go do C:\Windows\SYSVOL\sysvol\<twoja-domena>\scripts\
Config error: listed twice / invalidLiterówka na liście aplikacjiPopraw 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?

MetodaKiedy pasujeHaczyk
Skrypt logowania GPO + winget (ten poradnik)Domena Active Directory, bez IntuneInstaluje dla każdego użytkownika; skrypt utrzymujesz Ty
Microsoft IntuneUrządzenia już zarejestrowane w IntuneWymaga licencji Intune i rejestracji urządzeń
Aprowizacja (Add-AppxProvisionedPackage)Każde konto na komputerze, obrazy wzorcoweUprawnienia administratora, plik pakietu i licencja offline
Ręczna instalacja ze SklepuKilka komputerówNie 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.

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