Respuesta corta. Coloca el script
en SYSVOL, añade tus aplicaciones por su Store ID y agrégalo a una GPO en
User Configuration → Windows Settings → Scripts (Logon/Logoff) → Logon, pestaña
Scripts, como powershell.exe con
-ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File "<ruta>".
En cada inicio de sesión comprueba qué aplicaciones tiene ya el usuario e instala solo las que
faltan con winget. En nuestras pruebas, los paquetes reales de la Store se instalaron sin
derechos de administrador.
Qué hace el script
El script se ejecuta en cada inicio de sesión, pero solo trabaja de verdad cuando falta algo. Una ejecución es así:
- Pasa el trabajo a segundo plano. El script de inicio de sesión termina en aproximadamente un segundo y lanza una copia oculta de sí mismo, así que nunca retrasa el inicio de sesión.
- Comprueba qué hay instalado: una sola llamada a
winget exportlista todas las aplicaciones de la Store del usuario en unos dos segundos. - Omite lo que ya está. Las aplicaciones instaladas no se tocan: ni se reinstalan ni se actualizan. La Store las mantiene al día por su cuenta.
- Instala lo que falta en silencio con
winget install --source msstore, vuelve a comprobar que la aplicación está realmente instalada y la abre una vez. - Escribe un registro breve en
%LOCALAPPDATA%\StoreAppInstall\install.logcon una línea de resultado por ejecución: OK, FAILED, POSTPONED, CONFIG_ERRORS.
De serie instala nuestras dos aplicaciones, OneMount y PostMount, como ejemplo funcional. Sustitúyelas por las aplicaciones de la Store que necesites: la lista es lo único que hay que editar.
Probado en un dominio real
Todas las cifras de abajo salen de un dominio de laboratorio (controlador de dominio con Windows Server, equipos con Windows 11 22H2 y un usuario de dominio estándar, sin derechos de administrador), no de la documentación.
| Qué ocurrió | Tiempo |
|---|---|
| El script de inicio de sesión devuelve el control a Windows | ~1 s |
| Primer inicio de sesión: OneMount instalado desde la Store y abierto | 2 min 14 s |
| Primer inicio de sesión: PostMount instalado y abierto | 1 min 58 s |
| WhatsApp, la aplicación más lenta que probamos | 5 min 12 s |
| Cada inicio de sesión posterior (ambas apps ya instaladas) | 4 s |
Casi todo el tiempo de la primera instalación es la Store obteniendo el paquete para el usuario, no el script. Por eso el script trabaja en segundo plano: una sola aplicación lenta chocaría si no con el límite de tiempo de los scripts de directiva de grupo.
Antes de empezar
- Windows 10 u 11 con winget (App Installer). Viene con las versiones actuales de Windows; en un perfil recién creado aparece más o menos un minuto después del primer inicio de sesión, y el script lo espera. Lo probamos en Windows 11 22H2; Windows 10 con un App Installer actualizado debería comportarse igual, pero no lo hemos verificado.
- winget puede acceder al origen
msstore. Compruébalo conwinget source listen el equipo de un usuario. Eso es lo que importa, no si se abre la aplicación Store: Microsoft indica que bloquear la aplicación Store no impide necesariamente las instalaciones con winget. - Equipos unidos al dominio y GPMC (
gpmc.msc) en un equipo con RSAT o en el controlador de dominio. - Una OU de prueba con un usuario de prueba. Vincula primero la GPO ahí y luego amplía el alcance.
Paso 1: busca el Store ID de cada aplicación
Abre la página de la aplicación en apps.microsoft.com. El Store ID es la última parte
de la dirección: para OneMount es 9P68LJCNCHND. También puedes ejecutar
winget search "nombre" --source msstore y tomar la columna Id. La mayoría de
los ID tienen la forma 9XXXXXXXXXXX; las fichas que ejecutan el instalador propio del
fabricante usan ID más largos que empiezan por XP.
Paso 2: añade tus aplicaciones al script
Al principio de StoreAppInstall.ps1 hay una sola lista. Cada línea es una aplicación:
un Name para el registro y el StoreId. Añade Launch = $false
si la aplicación no debe abrirse tras instalarse, o añade -NoLaunch a los parámetros
del script para que no se abra nada.
[object[]]$Apps = @(
@{ Name = 'OneMount'; StoreId = '9P68LJCNCHND' }
@{ Name = 'PostMount'; StoreId = '9PN0511DJX9K' }
@{ Name = 'WhatsApp'; StoreId = '9NKSQGP7F2NH'; Launch = $false }
)
No hay que editar nada más. El script revisa la lista por sí mismo: un Store ID mal escrito o repetido aparece en el registro como error de configuración en lugar de detener la ejecución.
Paso 3: coloca el script en SYSVOL
El script tiene que estar donde todos los usuarios del dominio puedan leerlo:
\\<tu-dominio>\SYSVOL\<tu-dominio>\scripts\. Copiarlo directamente a esa ruta
de red desde tu propio equipo probablemente fallará: la escritura en SYSVOL a través de la red está
restringida. Hazlo en uno de los controladores de dominio: copia el archivo a la carpeta local
C:\Windows\SYSVOL\sysvol\<tu-dominio>\scripts\ (en nuestro dominio de pruebas,
C:\Windows\SYSVOL\sysvol\skylflow.local\scripts\).
Esa carpeta local es justo lo que se comparte como SYSVOL, y Windows la replica por sí solo al resto de controladores de dominio. Después abre la ruta de red para comprobar que el archivo está ahí: es la dirección que usará la GPO.
Paso 4: crea una GPO y vincúlala a la OU
En Group Policy Management, haz clic derecho en la OU que contiene a tus usuarios →
Create a GPO in this domain, and Link it here… y dale un nombre claro; nosotros usamos
StoreAppInstall. Después, clic derecho en el nuevo vínculo → Edit.
Paso 5: agrégalo como script de inicio de sesión (pestaña Scripts)
En el editor ve a User Configuration → Policies → Windows Settings → Scripts (Logon/Logoff) y abre Logon. Quédate en la pestaña Scripts (no en PowerShell Scripts) y haz clic en Add:
Script Name: powershell.exe Script Parameters: -ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File "\\your-domain\SYSVOL\your-domain\scripts\StoreAppInstall.ps1"
¿Por qué no la pestaña PowerShell Scripts? Ejecuta los scripts con la directiva de ejecución
del equipo, que en la mayoría de los PC bloquea los scripts sin firmar a menos que añadas otra
directiva más (Turn on Script Execution). Llamar a powershell.exe con
-ExecutionPolicy Bypass desde la pestaña Scripts no necesita nada más, y
-WindowStyle Hidden evita que el usuario vea una ventana de consola.
-ExecutionPolicy Bypass.
Paso 6: revisa el alcance y prueba
En la pestaña Scope de la GPO, comprueba que está vinculada a la OU correcta y que
Security Filtering incluye a tus usuarios (de forma predeterminada, Authenticated
Users). Después, en un equipo de prueba, ejecuta gpupdate /force, cierra la sesión
e inicia sesión con un usuario de esa OU.
Uno o dos minutos después de iniciar sesión, las aplicaciones se abren solas. El registro muestra exactamente lo que pasó; este es un primer inicio de sesión real de nuestra prueba:
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
Y el siguiente inicio de sesión, cuatro segundos en total:
[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
Qué aplicaciones de la Store se pueden instalar así
No todas las fichas de la Store son el mismo tipo de paquete.
winget show --id <StoreId> --source msstore muestra el Installer Type, y
eso decide si un usuario normal puede instalarlo. Estas son las que probamos:
| Aplicación | Tipo de instalador | Usuario estándar, sin admin |
|---|---|---|
| OneMount, PostMount, Telegram Desktop, WhatsApp, ChatGPT, Pinterest | msstore: un paquete real de la Store | Se instala |
| Microsoft Teams, Zoom Workplace | exe: instalador del fabricante, por usuario | Se instala |
| Adobe Acrobat Reader, TeamViewer | exe: instalador del fabricante, para todo el equipo | Requiere admin (aviso UAC) |
| Microsoft 365 (Office) | — no es un paquete | No se puede instalar así |
La regla práctica: todas las fichas msstore que probamos se instalaron sin derechos de
administrador, aunque las directivas de tu organización pueden restringirlo. Las fichas
exe son el programa de instalación propio del fabricante detrás de una página de la
Store: algunas se instalan por usuario y otras piden elevación, que un usuario estándar no puede
aprobar. El script avisa de ellas en el registro antes de instalar. Microsoft 365 devuelve «No
package found»: su página en la Store es de suscripción, y Office se despliega con la Office
Deployment Tool.
Por qué el script está hecho así
Un script de inicio de sesión de usuario, no de inicio del equipo
Los paquetes de la Store se instalan para un usuario, y la Store rechaza las instalaciones desde la cuenta SYSTEM: winget devuelve «Device wide install for msstore type is not supported under admin context». Un script de inicio del equipo se ejecuta como SYSTEM, así que no sirve para esto. Un script de inicio de sesión se ejecuta como el usuario que debe recibir la aplicación, que es justo lo que hace falta.
Trabaja en segundo plano
Windows detiene los scripts de directiva de grupo a los 10 minutos de forma predeterminada (directiva Maximum wait time for Group Policy scripts). La primera instalación de varias aplicaciones puede durar más: solo WhatsApp tardó cinco minutos. Por eso el script de inicio de sesión lanza una copia oculta de sí mismo y termina de inmediato: el inicio de sesión no se retrasa y nada se corta a medias. Cada instalación tiene además su propio límite de 20 minutos, así que una descarga atascada en la Store no puede bloquear la ejecución para siempre.
Nunca actualiza aplicaciones
winget install sobre una aplicación ya instalada no se detiene ahí: intenta
actualizarla. Un script de inicio de sesión que hiciera eso actualizaría aplicaciones en silencio y
no cuando tú lo decidas. Por eso el script pregunta primero a winget export qué está
instalado y solo llama a install para lo que falta. Las actualizaciones siguen en manos
de la Store.
Una salvedad: winget export lista las aplicaciones que winget consigue asociar con el
origen de la Store. Una aplicación instalada de otra forma (por ejemplo, Zoom desde el MSI del
fabricante) puede no asociarse. Entonces el script intenta instalarla desde la Store y winget, al
encontrar la copia existente, intenta actualizarla. Deja fuera de la lista las aplicaciones que ya
despliegas de otra manera.
Sin archivos de estado, nada que limpiar
El script no guarda caché ni marcas: solo su registro. Lo instalado se lee de nuevo cada vez, así que un usuario que desinstala una aplicación la recupera en el siguiente inicio de sesión, y un equipo reinstalado simplemente empieza de cero. Nunca se ejecutan dos copias a la vez: un bloqueo por usuario detecta las sesiones en paralelo.
Solución de problemas
Cada problema aparece en %LOCALAPPDATA%\StoreAppInstall\install.log con una pista. Estos son los que encontramos:
| En el registro | Qué significa | Qué hacer |
|---|---|---|
0x8A150014: No package found | El Store ID no es un paquete instalable | Revisa el ID; algunas fichas (Microsoft 365) son solo páginas web |
0x800704C7: Canceled | El instalador pidió derechos de administrador y se cerró el aviso | Despliega esa aplicación de otra forma, o solo a usuarios con derechos de administrador |
0x8A15005E: certificado no coincide | winget no pudo confiar en el certificado del servidor de la Store | Una vez por equipo, como administrador: winget source reset --force; si no basta, busca un proxy que inspeccione TLS |
| Install did not finish in 20 min | Una descarga de la Store está atascada (aparece como pendiente en Store → Biblioteca) | Nada: se reintenta en el siguiente inicio de sesión |
| winget not available | Perfil recién creado, App Installer aún sin registrar | Suele resolverse solo en el siguiente inicio de sesión; si no, revisa App Installer |
| «Acceso denegado» al copiar el script a SYSVOL | La escritura en SYSVOL a través de la red está restringida | En un controlador de dominio, cópialo a C:\Windows\SYSVOL\sysvol\<tu-dominio>\scripts\ |
| Config error: listed twice / invalid | Una errata en la lista de aplicaciones | Corrige la línea de $Apps que indica el registro |
Para ver qué hace el script sin esperar a un inicio de sesión, ejecútalo a mano en una ventana normal
de PowerShell con -Foreground: así trabaja en esa ventana en lugar de pasar a segundo
plano.
¿Script de GPO, Intune o aprovisionamiento?
| Método | Cuándo encaja | El inconveniente |
|---|---|---|
| Script de inicio de sesión de GPO + winget (esta guía) | Dominio de Active Directory, sin Intune | Instala por usuario; el script lo mantienes tú |
| Microsoft Intune | Equipos ya inscritos en Intune | Necesita licencias de Intune e inscripción de dispositivos |
Aprovisionamiento (Add-AppxProvisionedPackage) | Todas las cuentas de un equipo, imágenes base | Derechos de administrador, el archivo del paquete y una licencia sin conexión |
| Instalar a mano desde la Store | Unos pocos equipos | No escala |
Este método instala la aplicación a cada usuario al que se aplica la GPO, en su inicio de sesión. Eso
cubre el caso habitual: todos los de la OU reciben la aplicación la primera vez que inician sesión.
Si necesitas que una aplicación esté para todas las cuentas de un equipo antes de que nadie inicie
sesión, eso es aprovisionamiento (Add-AppxProvisionedPackage): se ejecuta como
administrador y necesita el propio archivo del paquete y, para las apps de la Store, una licencia sin
conexión, que antes se obtenía de la ya retirada Microsoft Store para Empresas. Para la mayoría de
las organizaciones pequeñas y medianas, el script de inicio de sesión por usuario es la opción
práctica.
El script completo
Puedes usarlo y adaptarlo libremente (licencia MIT). Solo hay que editar la lista
$Apps; todo lo demás está comentado en el archivo (en inglés, para que se lea igual en
cualquier editor y consola). El script también está en
GitHub,
donde puedes dejar incidencias y mejoras.
<#
.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
Preguntas frecuentes
¿Se pueden instalar aplicaciones de Microsoft Store con una directiva de grupo sin Intune?
Sí. La directiva de grupo no tiene una opción integrada de «instalar aplicación de la Store», pero un script de inicio de sesión que llama a winget lo hace con los paquetes de la Store. El script de arriba se ha probado de principio a fin en un dominio de Active Directory.
¿Los usuarios necesitan derechos de administrador?
No para los paquetes reales de la Store (msstore): ninguno de los que probamos los necesitó, aunque las directivas de la organización pueden restringir las instalaciones. Algunas fichas ejecutan el instalador propio del fabricante, y unas pocas piden elevación; el script las marca en el registro.
¿El script actualiza las aplicaciones ya instaladas?
No. Primero comprueba qué está instalado y solo instala lo que falta. Microsoft Store actualiza por su cuenta las aplicaciones instaladas.
¿Retrasa el inicio de sesión?
No. El script de inicio de sesión pasa su trabajo a un proceso oculto en segundo plano y termina en aproximadamente un segundo.
¿Se puede ejecutar como script de inicio del equipo?
No. Los scripts de inicio se ejecutan como SYSTEM y la Store no permite instalaciones en ese contexto. Debe ejecutarse como el usuario.
¿Dónde está el registro?
En el perfil de cada usuario: %LOCALAPPDATA%\StoreAppInstall\install.log. Se vacía automáticamente cuando supera los 100 KB.
Sobre las dos aplicaciones del ejemplo
Escribimos este script para nuestras propias aplicaciones, y se quedan en él como ejemplo funcional. OneMount monta las bibliotecas de SharePoint Online y OneDrive como unidades normales de Windows: sin sincronización, sin copias locales y con la coautoría de Office intacta; una GPO y cada usuario de la OU lo tiene tras su siguiente inicio de sesión. PostMount permite que escáneres, impresoras, sistemas ERP y programas de correo antiguos sigan enviando y recibiendo correo a través de Microsoft 365 después de que se desactivara Basic Auth. Si alguna de las dos le sirve a tu organización, el script ya está preparado para ellas.
Relacionado: Conectar SharePoint como unidad de red · Crear un sitio de SharePoint para tu organización · Implementación y directivas de OneMount
Fuentes
- Microsoft: códigos de retorno de winget (winget-cli en GitHub).
- Microsoft: comando winget export.
- Microsoft: Maximum wait time for Group Policy scripts (600 segundos de forma predeterminada).
- Fleet: las instalaciones msstore con winget no se admiten con la cuenta SYSTEM.
- Microsoft: configurar el acceso a Microsoft Store.