#
.SYNOPSIS
Common Microsoft/Surface driver helpers (cache index, SKU mapping).
.DESCRIPTION
This module contains Microsoft/Surface-specific functions used by the UI and scripts
to map Surface driver packs to System SKU values using:
- Source A: Surface System SKU reference (Learn)
- Source B: Support page model list
- Source C: Download Center details (window.__DLCDetails__)
#>
# --------------------------------------------------------------------------
# SECTION: Microsoft Surface Driver Index Cache (Sources A/B/C)
# --------------------------------------------------------------------------
function Get-SurfaceDriverIndexCachePath {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
# Store the cache under Drivers\Microsoft so it travels with the driver content
$microsoftDriversFolder = Join-Path -Path $DriversFolder -ChildPath 'Microsoft'
if (-not (Test-Path -Path $microsoftDriversFolder -PathType Container)) {
New-Item -Path $microsoftDriversFolder -ItemType Directory -Force | Out-Null
}
return (Join-Path -Path $microsoftDriversFolder -ChildPath 'SurfaceDriverIndex.json')
}
function Import-SurfaceDriverIndexCache {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
# Surface cache TTL (7 days): treat stale caches as missing so we re-download Sources A/B/C as needed.
$cacheTtlDays = 7
if (-not (Test-Path -Path $cachePath -PathType Leaf)) {
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
try {
$cacheAgeDays = ((Get-Date) - (Get-Item -Path $cachePath -ErrorAction Stop).LastWriteTime).TotalDays
if ($cacheAgeDays -ge $cacheTtlDays) {
WriteLog "Surface cache: Cache file '$cachePath' is older than $cacheTtlDays days ($([math]::Round($cacheAgeDays, 1)) days). Refreshing."
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
WriteLog "Surface cache: Loading cached SurfaceDriverIndex.json from '$cachePath' (age: $([math]::Round($cacheAgeDays, 1)) days)."
}
catch {
WriteLog "Surface cache: Failed to read cache timestamp for '$cachePath'. Refreshing. Error: $($_.Exception.Message)"
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
try {
$cache = Get-Content -Path $cachePath -Raw | ConvertFrom-Json -ErrorAction Stop
}
catch {
WriteLog "Warning: Could not read Surface driver cache '$cachePath'. Creating a new cache. Error: $($_.Exception.Message)"
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
if ($null -eq $cache) {
return [pscustomobject]@{
ModelIndex = @()
SkuIndex = @()
DownloadCenterDetails = @()
}
}
# Ensure expected properties exist (backward compatible with earlier cache shapes)
if (-not $cache.PSObject.Properties['ModelIndex']) {
$cache | Add-Member -NotePropertyName ModelIndex -NotePropertyValue @()
}
if (-not $cache.PSObject.Properties['SkuIndex']) {
$cache | Add-Member -NotePropertyName SkuIndex -NotePropertyValue @()
}
if (-not $cache.PSObject.Properties['DownloadCenterDetails']) {
$cache | Add-Member -NotePropertyName DownloadCenterDetails -NotePropertyValue @()
}
return $cache
}
function Save-SurfaceDriverIndexCache {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[psobject]$Cache,
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder
$Cache | ConvertTo-Json -Depth 10 | Set-Content -Path $cachePath -Encoding UTF8
}
function ConvertTo-SurfaceComparableName {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Text
)
# Normalize Surface marketing strings into a comparable family key.
# This intentionally strips consumer/commercial/processor qualifiers so we can join Sources A/B/C.
$value = [System.Net.WebUtility]::HtmlDecode($Text)
if ([string]::IsNullOrWhiteSpace($value)) {
return $null
}
$value = $value.Trim()
$value = $value -replace '\(', ' '
$value = $value -replace '\)', ' '
$value = $value -replace ',', ' '
# Normalize punctuation that frequently differs between Support/Learn pages
# (e.g. Wi‑Fi unicode hyphen, AT&T, Y!mobile)
$value = $value -replace '[-\u2010\u2011\u2012\u2013\u2014\u2212]', ' '
$value = $value -replace '&', ' '
$value = $value -replace '!', ' '
$value = $value -replace '™', ' '
$value = $value -replace '(?i)\bMicrosoft\b', ''
$value = $value -replace '(?i)\bfor\s+Business\b', ''
$value = $value -replace '(?i)\bConsumer\b', ''
$value = $value -replace '(?i)\bCommercial\b', ''
# Strip processor/connection qualifiers that cause mismatches between WMI, Learn, and Support naming.
$value = $value -replace '(?i)\bwith\s+Intel\b', ''
$value = $value -replace '(?i)\bIntel\s+processor\b', ''
$value = $value -replace '(?i)\bIntel\b', ''
$value = $value -replace '(?i)\bSnapdragon\s+processor\b', ''
$value = $value -replace '(?i)\bSnapdragon\b', ''
$value = $value -replace '(?i)\bwith\s+5G\b', ''
$value = $value -replace '(?i)\bLTE\b', ''
$value = $value -replace '(?i)\b4G\b', ''
$value = $value -replace '(?i)\bprocessor\b', ''
# Cleanup: remove orphaned "with" left behind by earlier removals (e.g., "Surface Pro 9 with Intel Processor")
$value = $value -replace '(?i)\bwith\b', ''
$value = $value -replace '\s+', ' '
return $value.Trim().ToUpperInvariant()
}
function ConvertTo-SurfaceHtmlText {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[AllowEmptyString()]
[string]$HtmlFragment
)
# Normalize HTML fragments from the Learn table into plain text values.
$textValue = $HtmlFragment -replace '
', ' '
$textValue = $textValue -replace '<[^>]+>', ' '
$textValue = [System.Net.WebUtility]::HtmlDecode($textValue)
$textValue = $textValue -replace '\s+', ' '
return $textValue.Trim()
}
function ConvertTo-SurfaceDownloadCenterLink {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$LinkValue
)
# Normalize Learn links down to the canonical Download Center details URL.
$decodedLink = [System.Net.WebUtility]::HtmlDecode($LinkValue).Trim()
if ([string]::IsNullOrWhiteSpace($decodedLink)) {
return $null
}
if ($decodedLink.StartsWith('/')) {
$decodedLink = "https://www.microsoft.com$decodedLink"
}
$downloadCenterMatch = [regex]::Match(
$decodedLink,
'https://www\.microsoft\.com(?:/en-us)?/download/details\.aspx\?id=\d+',
[System.Text.RegularExpressions.RegexOptions]::IgnoreCase
)
if (-not $downloadCenterMatch.Success) {
return $null
}
return ($downloadCenterMatch.Value -replace '/en-us/', '/')
}
function Get-SurfaceDriverModelIndex {
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$DriversFolder
)
$url = 'https://learn.microsoft.com/en-us/surface/manage-surface-driver-and-firmware-updates'
$minimumExpectedModelCount = 10
# Load the cached model list first to keep Microsoft model discovery fast.
try {
$cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder
if (@($cache.ModelIndex).Count -gt 0) {
WriteLog "Surface cache: Using cached Microsoft model list ($(@($cache.ModelIndex).Count) models)."
return @($cache.ModelIndex)
}
}
catch {
WriteLog "Surface cache: Failed to load cached Microsoft model list. Falling back to the Learn source. Error: $($_.Exception.Message)"
}
try {
# Download the Learn article that now contains the authoritative Surface package table.
WriteLog "Surface cache: Downloading Microsoft model index from $url"
$headers = @{
'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
}
$webContent = Invoke-WebRequest -Uri $url -UseBasicParsing -Headers $headers
$html = $webContent.Content
# Parse each table row and keep only Download Center package links.
$rowMatches = [regex]::Matches($html, '
]*>)?(.*?)(?:
)?\s*