684 lines
27 KiB
PowerShell
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
|