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í:

  1. 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.
  2. Comprueba qué hay instalado: una sola llamada a winget export lista todas las aplicaciones de la Store del usuario en unos dos segundos.
  3. 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.
  4. 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.
  5. Escribe un registro breve en %LOCALAPPDATA%\StoreAppInstall\install.log con 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 abierto2 min 14 s
Primer inicio de sesión: PostMount instalado y abierto1 min 58 s
WhatsApp, la aplicación más lenta que probamos5 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 con winget source list en 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.

Página de OneMount en apps.microsoft.com; el Store ID 9p68ljcnchnd es la última parte de la dirección
El Store ID es el último segmento de la dirección de apps.microsoft.com (da igual mayúsculas o minúsculas).

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.

Explorador de archivos con StoreAppInstall.ps1 en la ruta de red skylflow.local\SYSVOL\skylflow.local\scripts
El script en la ruta de red \\skylflow.local\SYSVOL\skylflow.local\scripts, la dirección que usa 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.

Consola Group Policy Management con la unidad organizativa HQ seleccionada
La OU con los usuarios que deben recibir las aplicaciones; aquí, HQ.
Cuadro de diálogo New GPO con el nombre StoreAppInstall
Creamos la GPO directamente en esa OU.
La GPO StoreAppInstall vinculada a la OU HQ, con el comando Edit en su menú contextual
La GPO está vinculada y habilitada; Edit abre el editor de directivas de grupo.

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.

Trátalo como cualquier código que despliegas. El script se ejecuta en el inicio de sesión de cada usuario, así que quien pueda modificar el archivo puede ejecutar código para todos los de la OU. Léelo antes de desplegarlo y deja la carpeta scripts de SYSVOL con permiso de escritura solo para los administradores del dominio. La directiva de ejecución es una red de seguridad, no una barrera de seguridad: si tu organización exige scripts firmados, firma este y quita -ExecutionPolicy Bypass.
Cuadro Add a Script en la GPO StoreAppInstall: Script Name powershell.exe y la línea de parámetros
En Script Name va powershell.exe; la ruta del script va en Script Parameters.
Logon Properties de la GPO StoreAppInstall con powershell.exe añadido en la pestaña Scripts
El script de inicio de sesión está listo. Haz clic en OK y cierra el editor.

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.

Pestaña Scope de la GPO StoreAppInstall: vinculada a skylflow.local/HQ, con Authenticated Users en el filtrado de seguridad
Vinculada a HQ y aplicada a Authenticated Users.

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
Ventanas de PostMount y OneMount abiertas automáticamente tras el primer inicio de sesión
Primer inicio de sesión de un usuario estándar: ambas aplicaciones instaladas desde la Store y abiertas, sin derechos de administrador y sin hacer clic en nada.

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ónTipo de instaladorUsuario estándar, sin admin
OneMount, PostMount, Telegram Desktop, WhatsApp, ChatGPT, Pinterestmsstore: un paquete real de la StoreSe instala
Microsoft Teams, Zoom Workplaceexe: instalador del fabricante, por usuarioSe instala
Adobe Acrobat Reader, TeamViewerexe: instalador del fabricante, para todo el equipoRequiere admin (aviso UAC)
Microsoft 365 (Office)— no es un paqueteNo 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 registroQué significaQué hacer
0x8A150014: No package foundEl Store ID no es un paquete instalableRevisa el ID; algunas fichas (Microsoft 365) son solo páginas web
0x800704C7: CanceledEl instalador pidió derechos de administrador y se cerró el avisoDespliega esa aplicación de otra forma, o solo a usuarios con derechos de administrador
0x8A15005E: certificado no coincidewinget no pudo confiar en el certificado del servidor de la StoreUna vez por equipo, como administrador: winget source reset --force; si no basta, busca un proxy que inspeccione TLS
Install did not finish in 20 minUna descarga de la Store está atascada (aparece como pendiente en Store → Biblioteca)Nada: se reintenta en el siguiente inicio de sesión
winget not availablePerfil recién creado, App Installer aún sin registrarSuele resolverse solo en el siguiente inicio de sesión; si no, revisa App Installer
«Acceso denegado» al copiar el script a SYSVOLLa escritura en SYSVOL a través de la red está restringidaEn un controlador de dominio, cópialo a C:\Windows\SYSVOL\sysvol\<tu-dominio>\scripts\
Config error: listed twice / invalidUna errata en la lista de aplicacionesCorrige 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étodoCuándo encajaEl inconveniente
Script de inicio de sesión de GPO + winget (esta guía)Dominio de Active Directory, sin IntuneInstala por usuario; el script lo mantienes tú
Microsoft IntuneEquipos ya inscritos en IntuneNecesita licencias de Intune e inscripción de dispositivos
Aprovisionamiento (Add-AppxProvisionedPackage)Todas las cuentas de un equipo, imágenes baseDerechos de administrador, el archivo del paquete y una licencia sin conexión
Instalar a mano desde la StoreUnos pocos equiposNo 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.

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

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