Коротко. Покладіть скрипт у SYSVOL, впишіть у нього застосунки за їхніми Store ID і додайте його в GPO: User Configuration → Windows Settings → Scripts (Logon/Logoff) → Logon, вкладка Scripts, програма powershell.exe з параметрами -ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File "<шлях>". Під час кожного входу скрипт перевіряє, що в користувача вже є, і через winget ставить лише відсутнє. У наших тестах справжні пакети Store ставилися без прав адміністратора.

Що робить скрипт

Скрипт запускається при кожному вході, але справжню роботу робить лише тоді, коли чогось бракує. Один прогін виглядає так:

  1. Передає роботу у фон. Сам скрипт входу завершується приблизно за секунду й запускає приховану копію себе, тож вхід у систему не затримується.
  2. Дивиться, що вже встановлено, — один виклик winget export дає список усіх Store-застосунків користувача приблизно за дві секунди.
  3. Пропускає те, що є. Встановлені застосунки не чіпаються: ні перевстановлення, ні оновлення. Їх оновлює сам Store.
  4. Ставить відсутнє тихо через winget install --source msstore, ще раз перевіряє, що застосунок справді з'явився, і один раз його відкриває.
  5. Пише короткий лог у %LOCALAPPDATA%\StoreAppInstall\install.log з підсумковим рядком на кожен прогін: OK, FAILED, POSTPONED, CONFIG_ERRORS.

Одразу «з коробки» він ставить два наші застосунки, OneMount і PostMount, — як робочий приклад. Замініть їх будь-якими застосунками зі Store: список — єдине, що треба редагувати.

Перевірено в справжньому домені

Усі цифри нижче — з тестового домену (контролер домену на Windows Server, клієнти з Windows 11 22H2, звичайний доменний користувач без прав адміністратора), а не з документації.

Що відбувалосяЧас
Скрипт входу повертає керування Windows~1 с
Перший вхід: OneMount встановлено зі Store і відкрито2 хв 14 с
Перший вхід: PostMount встановлено і відкрито1 хв 58 с
WhatsApp — найповільніший із перевірених5 хв 12 с
Кожен наступний вхід (обидва застосунки вже стоять)4 с

Майже весь час першого встановлення — це Store, який отримує пакет для користувача, а не сам скрипт. Саме тому скрипт і працює у фоні: інакше один повільний застосунок упирався б у ліміт часу для скриптів групової політики.

Перед початком

  • Windows 10 або 11 з winget (App Installer). Він є в сучасній Windows; у щойно створеному профілі з'являється приблизно за хвилину після першого входу, і скрипт його дочекається. Ми тестували на Windows 11 22H2; Windows 10 з актуальним App Installer має поводитися так само, але цього ми не перевіряли.
  • winget має доступ до джерела msstore. Перевірте командою winget source list на ПК користувача. Важливо саме це, а не те, чи відкривається застосунок Store: Microsoft зазначає, що блокування застосунку Store не обов'язково зупиняє встановлення через winget.
  • Комп'ютери в домені й консоль GPMC (gpmc.msc) — на контролері домену або на ПК із RSAT.
  • Тестовий підрозділ (OU) з одним тестовим користувачем. Спершу прив'яжіть GPO туди, потім розширюйте.

Крок 1 — Знайдіть Store ID кожного застосунку

Відкрийте сторінку застосунку на apps.microsoft.com. Store ID — це остання частина адреси: для OneMount це 9P68LJCNCHND. Ще можна виконати winget search "назва" --source msstore і взяти значення зі стовпця Id. Більшість ID мають вигляд 9XXXXXXXXXXX; у записів, що запускають власний інсталятор видавця, ID довші й починаються з XP.

Сторінка OneMount на apps.microsoft.com; Store ID 9p68ljcnchnd — остання частина адреси
Store ID — останній сегмент адреси на apps.microsoft.com (регістр літер не важливий).

Крок 2 — Впишіть застосунки в скрипт

На початку StoreAppInstall.ps1 є один список. Кожен рядок — один застосунок: Name для логу і StoreId. Додайте Launch = $false, якщо застосунок не треба відкривати після встановлення, або додайте -NoLaunch до параметрів скрипта, щоб не відкривалося нічого.

[object[]]$Apps = @(
  @{ Name = 'OneMount';  StoreId = '9P68LJCNCHND' }
  @{ Name = 'PostMount'; StoreId = '9PN0511DJX9K' }
  @{ Name = 'WhatsApp';  StoreId = '9NKSQGP7F2NH'; Launch = $false }
)

Більше нічого міняти не потрібно. Скрипт сам перевіряє список: неправильний чи повторений Store ID потрапить у лог як помилка конфігурації, а не зламає прогін.

Крок 3 — Покладіть скрипт у SYSVOL

Скрипт має лежати там, звідки його може прочитати кожен користувач домену: \\<ваш-домен>\SYSVOL\<ваш-домен>\scripts\. Скопіювати його одразу за цим мережевим шляхом зі свого ПК найімовірніше не вийде — запис у SYSVOL через мережу обмежений. Зробіть це на одному з контролерів домену: покладіть файл у локальну теку C:\Windows\SYSVOL\sysvol\<ваш-домен>\scripts\ (у нашому тестовому домені — C:\Windows\SYSVOL\sysvol\skylflow.local\scripts\).

Ця локальна тека — саме те, що відкрито в мережі як SYSVOL, і Windows сама реплікує її на інші контролери домену. Після копіювання відкрийте мережевий шлях і перевірте, що файл там є: саме цю адресу використовуватиме GPO.

Провідник: StoreAppInstall.ps1 за мережевим шляхом skylflow.local\SYSVOL\skylflow.local\scripts
Скрипт за мережевим шляхом \\skylflow.local\SYSVOL\skylflow.local\scripts — цю адресу використовує GPO.

Крок 4 — Створіть GPO і прив'яжіть його до OU

У Group Policy Management клацніть правою кнопкою на OU з вашими користувачами → Create a GPO in this domain, and Link it here… і дайте зрозумілу назву — ми назвали його StoreAppInstall. Потім правою кнопкою на новому посиланні → Edit.

Консоль Group Policy Management з виділеним підрозділом HQ
OU з користувачами, які мають отримати застосунки, — тут HQ.
Вікно New GPO з назвою StoreAppInstall
Створюємо GPO одразу на цьому OU.
GPO StoreAppInstall прив'язаний до OU HQ, у контекстному меню — команда Edit
GPO прив'язано й увімкнено; Edit відкриває редактор групової політики.

Крок 5 — Додайте його як скрипт входу (вкладка Scripts)

У редакторі перейдіть у User Configuration → Policies → Windows Settings → Scripts (Logon/Logoff) і відкрийте Logon. Залишайтеся на вкладці Scripts — не PowerShell Scripts — і натисніть Add:

Script Name:       powershell.exe
Script Parameters: -ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File "\\your-domain\SYSVOL\your-domain\scripts\StoreAppInstall.ps1"

Чому не вкладка PowerShell Scripts? Вона запускає скрипти за політикою виконання комп'ютера, а на більшості ПК вона блокує непідписані скрипти, поки не додати ще одну політику (Turn on Script Execution). Виклик powershell.exe з -ExecutionPolicy Bypass із вкладки Scripts нічого додаткового не потребує, а -WindowStyle Hidden ховає вікно консолі від користувача.

Ставтеся до нього як до будь-якого коду, що розгортаєте. Скрипт виконується при вході кожного користувача, тож той, хто може змінити файл, запускає код для всіх в OU. Прочитайте його перед розгортанням і залиште право запису в теку scripts у SYSVOL лише адміністраторам домену. Політика виконання — це запобіжник, а не межа безпеки: якщо у вашій організації вимагають підписаних скриптів, підпишіть і цей і приберіть -ExecutionPolicy Bypass.
Вікно Add a Script у GPO StoreAppInstall: Script Name powershell.exe і рядок параметрів
У Script Name — powershell.exe; шлях до скрипта — у Script Parameters.
Logon Properties GPO StoreAppInstall з доданим powershell.exe на вкладці Scripts
Скрипт входу на місці. Натисніть OK і закрийте редактор.

Крок 6 — Перевірте область дії й протестуйте

На вкладці Scope переконайтеся, що GPO прив'язаний до потрібного OU, а в Security Filtering є ваші користувачі (типово — Authenticated Users). Потім на тестовому ПК виконайте gpupdate /force, вийдіть і увійдіть користувачем із цього OU.

Вкладка Scope GPO StoreAppInstall: прив'язка до skylflow.local/HQ і Authenticated Users у фільтрі безпеки
Прив'язано до HQ, застосовується до Authenticated Users.

За хвилину-дві після входу застосунки відкриються самі. У лозі видно все, що сталося, — це справжній перший вхід із нашого тесту:

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

А наступний вхід — чотири секунди загалом:

[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
Вікна PostMount і OneMount відкрилися самі після першого входу
Перший вхід звичайного користувача: обидва застосунки встановилися зі Store і відкрилися — без прав адміністратора й без жодного кліку.

Які застосунки Store можна так розгортати

Не кожен запис у Store — пакет одного типу. winget show --id <StoreId> --source msstore показує Installer Type, і саме він вирішує, чи зможе звичайний користувач встановити застосунок. Ось що ми перевірили:

ЗастосунокТип інсталятораЗвичайний користувач, без прав адміна
OneMount, PostMount, Telegram Desktop, WhatsApp, ChatGPT, Pinterestmsstore — справжній пакет StoreВстановлюється
Microsoft Teams, Zoom Workplaceexe — інсталятор видавця, для користувачаВстановлюється
Adobe Acrobat Reader, TeamViewerexe — інсталятор видавця, для всього ПКПотрібен адмін (запит UAC)
Microsoft 365 (Office)— взагалі не пакетТак не встановити

Просте правило: усі записи msstore, які ми пробували, ставилися без прав адміністратора, хоча політики вашої організації можуть це обмежувати. Записи exe — це власна програма встановлення видавця за сторінкою в Store: одні ставляться для користувача, інші просять підвищення прав, яке звичайний користувач підтвердити не може. Скрипт попереджає про такі записи в лозі ще до встановлення. Microsoft 365 повертає «No package found»: його сторінка в Store — це сторінка оформлення підписки, а Office розгортають через Office Deployment Tool.

Чому скрипт зроблено саме так

Скрипт входу користувача, а не скрипт запуску комп'ютера

Пакети Store встановлюються для користувача, а від імені облікового запису SYSTEM Store встановлення відхиляє — winget повідомляє «Device wide install for msstore type is not supported under admin context». Скрипт запуску комп'ютера працює як SYSTEM, тож цю задачу не виконає. Скрипт входу працює від імені саме того користувача, який має отримати застосунок, — те, що треба.

Він працює у фоні

Windows типово зупиняє скрипти групової політики через 10 хвилин (політика Maximum wait time for Group Policy scripts). Перше встановлення кількох застосунків може тривати довше — лише WhatsApp ставився п'ять хвилин. Тому скрипт входу запускає приховану копію себе й одразу завершується: вхід не затримується, і нічого не обривається посередині. Кожне встановлення має ще й власний ліміт у 20 хвилин, тож завантаження, що зависло в Store, не повісить прогін назавжди.

Він ніколи не оновлює застосунки

winget install для вже встановленого застосунку на цьому не зупиняється — він пробує його оновити. Скрипт входу, що так робить, тихо оновлював би застосунки не за вашим графіком. Тому скрипт спершу питає winget export, що вже встановлено, і викликає install лише для відсутнього. Оновлення лишаються за Store.

Одне застереження: winget export показує ті застосунки, які winget зміг зіставити з джерелом Store. Застосунок, встановлений інакше — скажімо, Zoom з MSI видавця, — може не зіставитися. Тоді скрипт спробує поставити його зі Store, а winget, знайшовши наявну копію, спробує її оновити. Застосунки, які ви вже розгортаєте іншим способом, у список не додавайте.

Без файлів стану, нічого прибирати

Скрипт не тримає ні кешу, ні позначок — лише лог. Що встановлено, він щоразу читає наново, тож користувач, який видалив застосунок, отримає його знову при наступному вході, а перевстановлений ПК просто почне спочатку. Дві копії одночасно не запускаються: їх відсікає блокування на рівні користувача.

Якщо щось пішло не так

Кожна проблема видна в %LOCALAPPDATA%\StoreAppInstall\install.log разом із підказкою. Ось ті, що траплялися нам:

У лозіЩо це означаєЩо робити
0x8A150014 — No package foundЗа цим Store ID немає пакета для встановленняПеревірте ID; деякі записи (Microsoft 365) — лише вебсторінки
0x800704C7 — CanceledІнсталятор попросив права адміністратора, і запит закрилиРозгортайте цей застосунок іншим способом або лише для користувачів із правами адміна
0x8A15005E — невідповідність сертифікатаwinget не довірився сертифікату сервера StoreОдин раз на ПК, від адміністратора: winget source reset --force; якщо не допоможе — перевірте, чи немає проксі з перевіркою TLS
Install did not finish in 20 minЗавантаження в Store зависло (у Store → Бібліотека видно як «очікує»)Нічого — при наступному вході буде нова спроба
winget not availableЩойно створений профіль, App Installer ще не зареєстрованийЗазвичай минає до наступного входу; інакше перевірте App Installer
«Відмовлено в доступі» при копіюванні скрипта в SYSVOLЗапис у SYSVOL через мережу обмеженийНа контролері домену покладіть його в C:\Windows\SYSVOL\sysvol\<ваш-домен>\scripts\
Config error: listed twice / invalidПомилка в списку застосунківВиправте рядок $Apps, названий у лозі

Щоб побачити роботу скрипта без очікування входу, запустіть його вручну у звичайному вікні PowerShell з -Foreground — тоді він працює в цьому вікні, а не передає роботу у фон.

Скрипт GPO, Intune чи provisioning?

СпосібКоли підходитьНюанс
Скрипт входу GPO + winget (цей посібник)Домен Active Directory, без IntuneСтавить для кожного користувача; скрипт підтримуєте ви
Microsoft IntuneПристрої вже зареєстровані в IntuneПотрібні ліцензії Intune і реєстрація пристроїв
Provisioning (Add-AppxProvisionedPackage)Для кожного облікового запису на ПК, еталонні образиПрава адміністратора, файл пакета й офлайн-ліцензія
Вручну зі StoreКілька ПКНе масштабується

Цей підхід ставить застосунок кожному користувачу, на якого діє GPO, під час його входу. Це покриває звичайний випадок: усі в OU отримують застосунок при першому вході. Якщо застосунок має бути в кожного облікового запису на комп'ютері ще до того, як хтось увійде, — це вже provisioning (Add-AppxProvisionedPackage): він виконується від адміністратора й потребує самого файла пакета, а для застосунків Store — ще й офлайн-ліцензії, яку раніше давав закритий тепер Microsoft Store for Business. Для більшості малих і середніх організацій скрипт входу на рівні користувача — практичний варіант.

Повний скрипт

Можна вільно використовувати й адаптувати (ліцензія MIT). Скрипт також є на GitHub — там зручно лишати зауваження й пропонувати покращення. Редагувати треба лише список $Apps, усе інше прокоментовано у файлі (коментарі англійською — так скрипт однаково читається в будь-якому редакторі й консолі).

StoreAppInstall.ps1 · PowerShell 5.1 GitHub Завантажити .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

Питання й відповіді

Чи можна розгортати застосунки Microsoft Store через групову політику без Intune?

Так. Окремого параметра «встановити застосунок зі Store» у груповій політиці немає, але скрипт входу, що викликає winget, робить це для пакетів Store. Скрипт вище перевірено від початку до кінця в домені Active Directory.

Чи потрібні користувачам права адміністратора?

Не для справжніх пакетів Store (msstore): жодному з перевірених вони не знадобилися, хоча політики організації можуть обмежувати встановлення. Деякі записи запускають власний інсталятор видавця, і частина з них просить підвищення прав — скрипт позначає такі в лозі.

Чи оновлюватиме скрипт уже встановлені застосунки?

Ні. Він спершу перевіряє, що встановлено, і ставить лише відсутнє. Встановлені застосунки оновлює сам Microsoft Store.

Чи сповільнює він вхід у систему?

Ні. Скрипт входу передає роботу прихованому фоновому процесу й завершується приблизно за секунду.

Чи можна запускати його як скрипт запуску комп'ютера?

Ні. Скрипти запуску працюють як SYSTEM, а Store не дозволяє встановлення в такому контексті. Скрипт має працювати від імені користувача.

Де шукати лог?

У профілі кожного користувача: %LOCALAPPDATA%\StoreAppInstall\install.log. Він автоматично очищується, коли перевищує 100 КБ.

Про два застосунки з прикладу

Ми писали цей скрипт для власних застосунків, і вони лишаються в ньому як робочий приклад. OneMount підключає бібліотеки SharePoint Online і OneDrive як звичайні диски Windows — без синхронізації, без локальних копій, зі збереженим спільним редагуванням в Office; один GPO — і кожен користувач в OU має його після наступного входу. PostMount дає сканерам, принтерам, ERP-системам і старим поштовим програмам і далі надсилати й отримувати пошту через Microsoft 365 після вимкнення Basic Auth. Якщо якийсь із них потрібен вашій організації, скрипт уже налаштований під них.

Також читайте: SharePoint як мережевий диск · Як створити сайт SharePoint для організації · Розгортання й політики OneMount


Джерела