Vestibule/scripts/provision-kiosk.ps1

684 lines
27 KiB
PowerShell

# provision-kiosk.ps1 — Windows kiosk provisioning wizard
#
# Turns a Windows Pro/Enterprise/Education machine into a Vestibule
# kiosk without manual OS configuration:
#
# 1. Stage install files to C:\Program Files\Vestibule (if not already
# installed there by the Inno Setup installer)
# 2. Create the dedicated kiosk local account
# 3. Build the extension XPI and deploy a merged policies.json to the
# browser's distribution directory (policy force-installs the
# extension, so no about:debugging step on the kiosk) — works for
# LibreWolf and Firefox alike
# 4. Write C:\ProgramData\Vestibule\kiosk.env (home URL + browser)
# 5. Create the Start Menu shortcut with a stable AppUserModelID
# 6. Apply AssignedAccess single-app kiosk config via the MDM WMI
# bridge; on Enterprise/Education, step down to Shell Launcher if
# the bridge rejects the XML
# 7. Configure automatic logon for the kiosk account
#
# Exit codes:
# 0 success (no reboot needed)
# 10 success, reboot required to activate
# 2 unsupported platform (not Windows / Home edition / not elevated)
# 3 prerequisite missing (browser, usher binary, install files)
# 4 kiosk account error
# 5 lockdown apply failed (AssignedAccess AND Shell Launcher)
# 6 invalid parameters
#
# Unattended example:
# powershell -ExecutionPolicy Bypass -File provision-kiosk.ps1 `
# -KioskUser Kiosk -KioskPassword 'S3cure!' `
# -HomeUrl https://checkin.example.org -Quiet -Restart
#
# Interactive (wizard prompts):
# powershell -ExecutionPolicy Bypass -File provision-kiosk.ps1
param(
[ValidateSet("auto", "librewolf", "firefox")]
[string]$Browser = "auto", # auto: step-down order LibreWolf -> Firefox
[string]$KioskUser = "VestibuleKiosk",
[string]$KioskPassword, # generated + printed if omitted
[string]$HomeUrl, # default about:blank
[string]$InstallRoot, # default: detected below
[switch]$Quiet, # no prompts (unattended)
[switch]$Restart, # auto-reboot when required
[switch]$Check, # validate only, change nothing
[switch]$ShellLauncher, # force Shell Launcher (skip bridge)
[switch]$NoAutoLogon, # skip automatic logon config
[switch]$InstallOverridesCfg # also deploy librewolf.overrides.cfg
)
$ErrorActionPreference = "Stop"
$AUMID = "Vestibule.Kiosk"
$ExtId = "vestibule@vestibule.kiosk"
$ProgramDataDir = Join-Path $env:ProgramData "Vestibule"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
# ─── Small helpers ────────────────────────────────────────────────────
function Fail([int]$code, [string]$msg) {
Write-Host ""
Write-Host "ERROR: $msg" -ForegroundColor Red
Write-Host " exiting with code $code"
exit $code
}
function Info($msg) { Write-Host $msg }
function Ok($msg) { Write-Host " [ok] $msg" -ForegroundColor Green }
function Step($msg) { Write-Host ""; Write-Host "==> $msg" -ForegroundColor Cyan }
function Test-Elevated {
$id = [Security.Principal.WindowsIdentity]::GetCurrent()
return ([Security.Principal.WindowsPrincipal]$id).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Get-WindowsEdition {
return (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion").EditionID
}
# ─── Browser discovery (pipeline, first hit wins) ───────────────────
$librewolfSearchPaths = @(
"${env:ProgramFiles}\LibreWolf\librewolf.exe",
"${env:ProgramFiles(x86)}\LibreWolf\librewolf.exe",
"${env:LOCALAPPDATA}\Programs\LibreWolf\librewolf.exe"
)
$firefoxSearchPaths = @(
"${env:ProgramFiles}\Mozilla Firefox\firefox.exe",
"${env:ProgramFiles}\Mozilla Firefox ESR\firefox.exe",
"${env:ProgramFiles(x86)}\Mozilla Firefox\firefox.exe",
"${env:ProgramFiles(x86)}\Mozilla Firefox ESR\firefox.exe",
"${env:LOCALAPPDATA}\Mozilla Firefox\firefox.exe"
)
function Find-InPaths($paths, $exeName) {
$hit = $paths | Where-Object { Test-Path $_ } | Select-Object -First 1
if ($hit) { return $hit }
# Registry App Paths step-down: per-machine first, then per-user.
$regRoots = @("HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths",
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths")
return $regRoots |
ForEach-Object { (Get-ItemProperty (Join-Path $_ $exeName) -ErrorAction SilentlyContinue)."(default)" } |
Where-Object { $_ -and (Test-Path $_) } |
Select-Object -First 1
}
function Find-LibreWolf { Find-InPaths $librewolfSearchPaths "librewolf.exe" }
function Find-Firefox {
# Firefox and Firefox ESR. Both read the same distribution/policies
# mechanism; ESR is recommended for kiosks (slower release cadence).
Find-InPaths $firefoxSearchPaths "firefox.exe"
}
function Resolve-Browser {
# Returns @{ Kind = 'librewolf'|'firefox'; Exe = path } or $null.
# Explicit -Browser wins; auto steps down LibreWolf -> Firefox
# (privacy defaults + trademark-safe, then fully supported Firefox).
$lw = Find-LibreWolf
$ff = Find-Firefox
switch ($Browser) {
"librewolf" {
if ($lw) { return @{ Kind = "librewolf"; Exe = $lw } }
return $null
}
"firefox" {
if ($ff) { return @{ Kind = "firefox"; Exe = $ff } }
return $null
}
default {
if ($lw) { return @{ Kind = "librewolf"; Exe = $lw } }
if ($ff) { return @{ Kind = "firefox"; Exe = $ff } }
return $null
}
}
}
function New-RandomPassword {
# 20 chars, unambiguous classes — strong enough for a locked-down
# kiosk account that is never typed by a human.
$chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!#%+"
$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
$bytes = New-Object byte[] 20
$rng.GetBytes($bytes)
return (-join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] }))
}
# ─── Pre-flight checks ────────────────────────────────────────────────
Step "Pre-flight checks"
if ($env:OS -ne "Windows_NT") {
Fail 2 "this script configures Windows kiosk lockdown; on Linux use scripts/provision-kiosk.sh"
}
if (-not (Test-Elevated)) {
Fail 2 "must run elevated (right-click PowerShell -> Run as administrator)"
}
$edition = Get-WindowsEdition
Info "Windows edition: $edition"
if ($edition -like "Core*") {
Fail 2 ("Windows Home (edition '$edition') does not support AssignedAccess or " +
"Shell Launcher. Use Windows Pro, Enterprise, or Education, or deploy " +
"the Linux (cage) variant.")
}
$shellLauncherCapable = ($edition -like "Enterprise*" -or
$edition -like "Education*" -or
$edition -like "IoTEnterprise*")
# Install root: explicit param > already-staged install > repo checkout.
if (-not $InstallRoot) {
if (Test-Path (Join-Path $scriptDir "..\bin\usher.exe")) {
$InstallRoot = (Resolve-Path (Join-Path $scriptDir "..")).Path
} else {
$InstallRoot = "C:\Program Files\Vestibule"
}
}
Info "install root: $InstallRoot"
$browser = Resolve-Browser
if ($browser) {
Ok "browser ($($browser.Kind)): $($browser.Exe)"
$browserExe = $browser.Exe
} else {
$browserExe = $null
Write-Host " [!!] No LibreWolf or Firefox found in standard locations" -ForegroundColor Yellow
}
$repoRoot = if (Test-Path (Join-Path $scriptDir "..\extension\manifest.json")) {
(Resolve-Path (Join-Path $scriptDir "..")).Path
} else { $null }
$usherStaged = Test-Path (Join-Path $InstallRoot "bin\usher.exe")
# ─── Interactive prompts (skipped with -Quiet) ────────────────────────
if (-not $Quiet -and -not $Check) {
if (-not $HomeUrl) {
$HomeUrl = Read-Host "Kiosk home URL [about:blank]"
if (-not $HomeUrl) { $HomeUrl = "about:blank" }
}
if (-not $KioskPassword) {
$KioskPassword = New-RandomPassword
Write-Host ""
Write-Host "Generated kiosk account password (needed for auto-logon; save it now):" -ForegroundColor Yellow
Write-Host " $KioskUser / $KioskPassword" -ForegroundColor Yellow
Write-Host ""
}
}
if (-not $HomeUrl) { $HomeUrl = "about:blank" }
if ($Check) {
Step "Check complete (no changes made)"
Info "edition : $edition ($(
if ($shellLauncherCapable) {'AssignedAccess + Shell Launcher step-down'} else {'AssignedAccess only'}))"
Info "browser : $(if ($browser) {"$($browser.Kind) ($($browser.Exe))"} else {'MISSING -> would exit 3'})"
Info "install root : $InstallRoot (usher staged: $usherStaged)"
Info "kiosk user : $KioskUser"
Info "home URL : $HomeUrl"
Info "auto-logon : $(if ($NoAutoLogon) {'disabled'} else {'enabled'})"
if (-not $browser) { Fail 3 "LibreWolf/Firefox not found" }
if (-not $usherStaged -and -not $repoRoot) { Fail 3 "no staged install and no repo checkout with a built usher" }
Ok "all prerequisites satisfied — re-run without -Check to provision"
exit 0
}
if (-not $KioskPassword) {
$KioskPassword = New-RandomPassword
Write-Host "Generated kiosk account password (save it now): $KioskUser / $KioskPassword" -ForegroundColor Yellow
}
if (-not $browser) { Fail 3 "No supported browser found — install LibreWolf (librewolf.net) or Firefox (mozilla.org), then re-run" }
# ─── 1. Stage install files ───────────────────────────────────────────
Step "Stage install files ($InstallRoot)"
if (-not $usherStaged) {
if (-not $repoRoot) {
Fail 3 ("usher.exe not found at '$InstallRoot\bin'. Install via the " +
"Vestibule Setup .exe, or build from source: cd helper; cargo build --release")
}
New-Item -ItemType Directory -Force -Path "$InstallRoot\bin" | Out-Null
New-Item -ItemType Directory -Force -Path "$InstallRoot\scripts" | Out-Null
Copy-Item (Join-Path $repoRoot "helper\target\release\usher.exe") "$InstallRoot\bin\usher.exe" -Force
Copy-Item (Join-Path $repoRoot "scripts\*.ps1") "$InstallRoot\scripts\" -Force
Ok "staged usher.exe + scripts"
}
if (-not (Test-Path "$InstallRoot\extension\manifest.json") -and $repoRoot) {
New-Item -ItemType Directory -Force -Path "$InstallRoot\extension" | Out-Null
Copy-Item (Join-Path $repoRoot "extension\*") "$InstallRoot\extension\" -Recurse -Force
Ok "staged extension source"
}
if (-not (Test-Path "$InstallRoot\extension\manifest.json")) {
Fail 3 "extension files missing under '$InstallRoot\extension'"
}
# ─── 2. Kiosk account ─────────────────────────────────────────────────
Step "Kiosk account '$KioskUser'"
$secure = ConvertTo-SecureString $KioskPassword -AsPlainText -Force
if (Get-LocalUser -Name $KioskUser -ErrorAction SilentlyContinue) {
Set-LocalUser -Name $KioskUser -Password $secure -PasswordNeverExpires $true
Ok "account exists — password reset, never expires"
} else {
try {
New-LocalUser -Name $KioskUser -Password $secure `
-AccountNeverExpires -PasswordNeverExpires `
-PasswordChangeNotAllowed `
-Description "Vestibule kiosk account (auto-provisioned)" | Out-Null
Ok "created"
} catch {
Fail 4 "could not create kiosk account: $($_.Exception.Message)"
}
}
# ─── 3. XPI + policies.json ───────────────────────────────────────────
Step "Extension XPI + $($browser.Kind) policies"
$xpiPath = Join-Path $InstallRoot "extension\vestibule.xpi"
$xpiTmp = Join-Path $env:TEMP "vestibule-xpi.zip"
if (Test-Path $xpiTmp) { Remove-Item $xpiTmp -Force }
Compress-Archive -Path (Join-Path $InstallRoot "extension\*") `
-DestinationPath $xpiTmp -Force
Move-Item $xpiTmp $xpiPath -Force
Ok "built $(Split-Path -Leaf $xpiPath)"
# Distribution dir sits next to the browser executable (LibreWolf and
# Firefox share the mechanism).
$browserDir = Split-Path -Parent $browserExe
$distDir = Join-Path $browserDir "distribution"
New-Item -ItemType Directory -Force -Path $distDir | Out-Null
$policiesPath = Join-Path $distDir "policies.json"
# The shipped distribution/policies.json is backed up once, then ours
# is deep-merged on top so the browser's own hardening survives.
if (Test-Path $policiesPath) {
$bak = "$policiesPath.vestibule-bak"
if (-not (Test-Path $bak)) { Copy-Item $policiesPath $bak -Force }
Ok "backed up existing policies.json -> vestibule-bak"
}
# ConvertFrom-Json in Windows PowerShell 5.1 yields PSCustomObjects and
# has no -AsHashtable; walk the tree into real hashtables so the deep
# merge below can mutate in place.
function ConvertTo-HashtableDeep($node) {
if ($node -is [System.Management.Automation.PSCustomObject]) {
$h = @{}
foreach ($p in $node.PSObject.Properties) { $h[$p.Name] = ConvertTo-HashtableDeep $p.Value }
return $h
}
if ($node -is [System.Collections.IEnumerable] -and $node -isnot [string]) {
$arr = @()
foreach ($item in $node) { $arr += ,(ConvertTo-HashtableDeep $item) }
return $arr
}
return $node
}
function Merge-Policy([hashtable]$base, [hashtable]$overlay) {
foreach ($k in $overlay.Keys) {
if ($base.ContainsKey($k) -and $base[$k] -is [hashtable] -and $overlay[$k] -is [hashtable]) {
Merge-Policy $base[$k] $overlay[$k]
} else {
$base[$k] = $overlay[$k]
}
}
}
$policies = @{ policies = @{} }
if (Test-Path $policiesPath) {
try {
$existing = ConvertTo-HashtableDeep (Get-Content $policiesPath -Raw | ConvertFrom-Json)
if ($existing -is [hashtable] -and $existing.Count -gt 0) { $policies = $existing }
} catch { $policies = @{ policies = @{} } }
}
$canonical = ConvertTo-HashtableDeep (Get-Content (Join-Path $scriptDir "..\config\policies.json") -Raw | ConvertFrom-Json)
Merge-Policy $policies $canonical
# Policy-install the extension: force_installed survives the "*" blocked
# wildcard in ExtensionSettings and re-installs itself on every startup.
$xpiUrl = ([uri]$xpiPath).AbsoluteUri
$policies.policies.ExtensionSettings = @{
"*" = @{
blocked_install_message = "Extensions are not allowed on this kiosk."
install_sources = @()
installation_mode = "blocked"
}
$ExtId = @{
installation_mode = "force_installed"
install_url = $xpiUrl
}
}
# WriteAllText = UTF-8 without BOM. Set-Content -Encoding UTF8 in
# Windows PowerShell 5.1 emits a BOM, which Gecko's policy loader is not
# guaranteed to tolerate.
[System.IO.File]::WriteAllText($policiesPath, ($policies | ConvertTo-Json -Depth 10))
Ok "deployed policies.json (extension force-installed from $xpiUrl)"
if ($InstallOverridesCfg) {
# librewolf.overrides.cfg is a LibreWolf-specific autoconfig file; it
# has no effect on Firefox (Firefox ignores it safely).
if ($browser.Kind -eq "librewolf") {
$src = Join-Path $scriptDir "..\config\librewolf.overrides.cfg"
if (Test-Path $src) {
Copy-Item $src (Join-Path $browserDir "librewolf.overrides.cfg") -Force
Ok "deployed librewolf.overrides.cfg (SSO/telehealth compat)"
}
} else {
Info "skipped librewolf.overrides.cfg (LibreWolf-only; browser is $($browser.Kind))"
}
}
# ─── 4. ProgramData config ────────────────────────────────────────────
Step "Deployment config"
New-Item -ItemType Directory -Force -Path $ProgramDataDir | Out-Null
@"
VESTIBULE_HOME_URL=$HomeUrl
VESTIBULE_BROWSER=$($browser.Kind)
"@ | Set-Content (Join-Path $ProgramDataDir "kiosk.env") -Encoding UTF8
Ok "kiosk.env written (home URL: $HomeUrl, browser: $($browser.Kind))"
# ─── 5. Start Menu shortcut with AUMID ────────────────────────────────
Step "Kiosk shortcut (AUMID: $AUMID)"
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
// Sets the System.AppUserModel.ID property on a .lnk file. AssignedAccess
// single-app kiosk mode launches desktop apps by AUMID, so the shortcut
// must carry a stable explicit AUMID.
public static class ShortcutAumid {
[ComImport, Guid("00021401-0000-0000-C000-000000000046")]
private class ShellLinkCoClass {}
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown),
Guid("0000010B-0000-0000-C000-000000000046")]
private interface IPersistFile {
void GetClassID(out Guid pClassID);
[PreserveSig] int IsDirty();
void Load([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, uint dwMode);
void Save([MarshalAs(UnmanagedType.LPWStr)] string pszFileName,
[MarshalAs(UnmanagedType.Bool)] bool fRemember);
void SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string pszFileName);
void GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string ppszFileName);
}
[ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"),
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IPropertyStore {
int GetCount(out uint cProps);
int GetAt(uint iProp, out PropertyKey pkey);
int GetValue(ref PropertyKey key, out PropVariant pv);
int SetValue(ref PropertyKey key, ref PropVariant pv);
int Commit();
}
[StructLayout(LayoutKind.Sequential)]
private struct PropertyKey { public Guid fmtid; public uint pid; }
[StructLayout(LayoutKind.Explicit)]
private struct PropVariant {
[FieldOffset(0)] public ushort vt;
[FieldOffset(8)] public IntPtr pointerValue;
}
[DllImport("ole32.dll")]
private static extern int CoInitialize(IntPtr reserved);
[DllImport("ole32.dll")]
private static extern void CoUninitialize();
[DllImport("ole32.dll")]
private static extern int CoCreateInstance(ref Guid clsid, IntPtr outer,
uint context, ref Guid iid, [MarshalAs(UnmanagedType.IUnknown)] out object obj);
[DllImport("ole32.dll")]
private static extern IntPtr CoTaskMemAlloc(uint bytes);
[DllImport("ole32.dll")]
private static extern void CoTaskMemFree(IntPtr p);
private const ushort VT_LPWSTR = 31;
private const uint STGM_READWRITE = 2;
private static readonly Guid ClsidShellLink =
new Guid("00021401-0000-0000-C000-000000000046");
private static readonly Guid IidPersistFile =
new Guid("0000010B-0000-0000-C000-000000000046");
private static readonly Guid IidPropertyStore =
new Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99");
private static readonly PropertyKey PkeyAppUserModelId =
new PropertyKey {
fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"),
pid = 5
};
public static int SetLnkAumid(string lnkPath, string aumid) {
int initHr = CoInitialize(IntPtr.Zero); // S_OK (0) or S_FALSE (1)
try {
Guid clsid = ClsidShellLink, iidPf = IidPersistFile;
object pfObj;
int hr = CoCreateInstance(ref clsid, IntPtr.Zero, 1 /*CLSCTX_INPROC_SERVER*/,
ref iidPf, out pfObj);
if (hr != 0) return hr;
IPersistFile persist = (IPersistFile)pfObj;
persist.Load(lnkPath, STGM_READWRITE);
IPropertyStore store = (IPropertyStore)pfObj;
PropVariant pv = new PropVariant();
pv.vt = VT_LPWSTR;
pv.pointerValue = Marshal.StringToCoTaskMemUni(aumid);
try {
hr = store.SetValue(ref PkeyAppUserModelId, ref pv);
if (hr != 0) return hr;
hr = store.Commit();
if (hr != 0) return hr;
} finally {
CoTaskMemFree(pv.pointerValue);
}
persist.Save(lnkPath, true);
return 0;
} finally {
if (initHr == 0) CoUninitialize();
}
}
}
"@
$startMenuDir = Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs"
$lnkPath = Join-Path $startMenuDir "Vestibule Kiosk.lnk"
$launcher = Join-Path $InstallRoot "scripts\kiosk-launch.ps1"
$ws = New-Object -ComObject WScript.Shell
$sc = $ws.CreateShortcut($lnkPath)
$sc.TargetPath = "powershell.exe"
$sc.Arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$launcher`""
$sc.WorkingDirectory = $InstallRoot
$sc.IconLocation = "$browserExe,0"
$sc.Description = "Vestibule kiosk (AssignedAccess shell)"
$sc.Save()
$hr = [ShortcutAumid]::SetLnkAumid($lnkPath, $AUMID)
if ($hr -ne 0) { Fail 5 "could not set AUMID on shortcut (HRESULT 0x$($hr.ToString('X8')))" }
Ok "shortcut: $lnkPath"
# Verify the shell can resolve the AUMID (Get-StartApps indexes the
# Start Menu; retry briefly because indexing is asynchronous).
$aumidFound = $false
for ($i = 0; $i -lt 3 -and -not $aumidFound; $i++) {
Start-Sleep -Seconds 2
$aumidFound = [bool](Get-StartApps | Where-Object { $_.AppID -eq $AUMID })
}
if (-not $aumidFound) {
Fail 5 "AUMID '$AUMID' not visible to Get-StartApps — AssignedAccess would reject it. Reboot and re-run."
}
Ok "AUMID resolves via Get-StartApps"
# ─── 6. Lockdown: AssignedAccess (Shell Launcher step-down) ────────
$shellLauncherArgs = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$launcher`""
$lockdownApplied = $false
$rebootNeeded = $false
function Test-ShellLauncherClass {
return [bool](Get-CimClass -Namespace "root\standardcimv2\embedded" `
-ClassName "WESL_UserSetting" -ErrorAction SilentlyContinue)
}
function Enable-ShellLauncherFeature {
try {
Enable-WindowsOptionalFeature -Online `
-FeatureName "Client-DeviceLockdown" -All -NoRestart -ErrorAction Stop | Out-Null
return $true
} catch {
try {
Enable-WindowsOptionalFeature -Online `
-FeatureName "Client-EmbeddedShellLauncher" -All -NoRestart -ErrorAction Stop | Out-Null
return $true
} catch { return $false }
}
}
function Invoke-ShellLauncher {
if (-not (Test-ShellLauncherClass)) {
if (-not (Enable-ShellLauncherFeature)) { return $false }
if (-not (Test-ShellLauncherClass)) {
# Feature enabled but class appears after reboot.
$script:rebootNeeded = $true
return $false
}
}
$wesl = [wmiclass]"\\.\root\standardcimv2\embedded:WESL_UserSetting"
# SetCustomShell's parameter list has drifted across Windows builds
# (4-arg v1 and 5-arg variants with a custom return-code map). Try each
# known shape; the first that returns 0 wins.
$r = $null
foreach ($call in @(
{ $wesl.SetCustomShell($KioskUser, "powershell.exe", $shellLauncherArgs, $null, 0) },
{ $wesl.SetCustomShell($KioskUser, "powershell.exe", $shellLauncherArgs, 0) },
{ $wesl.SetCustomShell($KioskUser, "powershell.exe", $shellLauncherArgs) }
)) {
try {
$r = & $call
if ($r.ReturnValue -eq 0) { break }
} catch { $r = $null }
}
if ($r -and $r.ReturnValue -eq 0) {
$script:lockdownApplied = $true
$script:shellLauncherMode = $true
Ok "Shell Launcher custom shell set for '$KioskUser'"
return $true
}
Write-Host " [!!] SetCustomShell failed (last result: $(if ($r) {$r.ReturnValue} else {'exception'}))" -ForegroundColor Yellow
return $false
}
function Invoke-AssignedAccess {
$profileId = "{$([guid]::NewGuid().ToString())}"
$xml = @"
<?xml version="1.0" encoding="utf-8"?>
<AssignedAccessConfiguration xmlns="http://schemas.microsoft.com/AssignedAccess/2017/config">
<Profiles>
<Profile Id="$profileId">
<KioskModeApp AppUserModelId="$AUMID"/>
</Profile>
</Profiles>
<Configs>
<Config>
<Account>$KioskUser</Account>
<DefaultProfile Id="$profileId"/>
</Config>
</Configs>
</AssignedAccessConfiguration>
"@
try {
$instances = @(Get-CimInstance -Namespace "root\cimv2\mdm\dmmap" `
-ClassName "MDM_AssignedAccess" -ErrorAction Stop)
} catch {
Write-Host " [!!] MDM WMI bridge unavailable: $($_.Exception.Message)" -ForegroundColor Yellow
return $false
}
foreach ($inst in $instances) {
try {
$res = Invoke-CimMethod -InputObject $inst `
-MethodName "SetSingleAppKiosk" `
-Arguments @{ AssignedAccessConfiguration = $xml } -ErrorAction Stop
if ($res.ReturnValue -eq 0) {
$script:lockdownApplied = $true
$script:shellLauncherMode = $false
Ok "AssignedAccess single-app kiosk configured via MDM bridge"
return $true
}
Write-Host " [!!] SetSingleAppKiosk returned $($res.ReturnValue) on one enrollment" -ForegroundColor Yellow
} catch {
Write-Host " [!!] bridge call failed: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
return $false
}
Step "OS-level lockdown"
if ($ShellLauncher) {
Info "mode: Shell Launcher (forced by parameter)"
[void](Invoke-ShellLauncher)
} else {
Info "mode: AssignedAccess via MDM WMI bridge"
if (-not (Invoke-AssignedAccess)) {
if ($shellLauncherCapable) {
Info "bridge failed — stepping down to Shell Launcher (supported on $edition)"
[void](Invoke-ShellLauncher)
}
}
}
if (-not $lockdownApplied) {
Fail 5 ("could not apply AssignedAccess or Shell Launcher. Apply manually: " +
"Settings > Accounts > Other users > Set up kiosk, or use -ShellLauncher on Enterprise/Education.")
}
# ─── 7. Automatic logon ───────────────────────────────────────────────
Step "Automatic logon"
if ($NoAutoLogon) {
Info "skipped (-NoAutoLogon) — kiosk starts after manual logon as '$KioskUser'"
} else {
$wl = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"
Set-ItemProperty $wl -Name "AutoAdminLogon" -Value "1" -Type String
Set-ItemProperty $wl -Name "DefaultUserName" -Value $KioskUser -Type String
Set-ItemProperty $wl -Name "DefaultDomainName" -Value $env:COMPUTERNAME -Type String
# NOTE: DefaultPassword is stored in plaintext in the registry. On a
# locked-down kiosk appliance this is an accepted trade-off (documented
# in DEPLOYMENT.md); hold Shift during boot to bypass auto-logon.
Set-ItemProperty $wl -Name "DefaultPassword" -Value $KioskPassword -Type String
Ok "auto-logon configured for '$KioskUser' (Shift at logon bypasses it)"
}
# ─── Summary ──────────────────────────────────────────────────────────
Step "Provisioning complete"
Info "kiosk user : $KioskUser"
Info "home URL : $HomeUrl"
Info "lockdown : $(if ($shellLauncherMode) {'Shell Launcher'} else {'AssignedAccess'})"
Info "browser : $($browser.Kind) ($($browser.Exe))"
Info "policies : $policiesPath"
$rebootNeeded = $rebootNeeded -or (-not $NoAutoLogon)
if ($rebootNeeded) {
Info "reboot required to activate the kiosk."
if ($Restart) {
Info "restarting in 10 seconds (-Restart)..."
shutdown.exe /r /t 10 /c "Vestibule kiosk activation"
exit 0
}
exit 10
}
Ok "kiosk will start the next time '$KioskUser' logs on"
exit 0