Коротко. Положите скрипт в 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 для бизнеса. Для большинства малых и средних организаций скрипт входа на уровне пользователя — практичный вариант.

Полный скрипт

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

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


Источники