<# .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, ']*>(.*?)', [System.Text.RegularExpressions.RegexOptions]::Singleline) $models = [System.Collections.Generic.List[pscustomobject]]::new() $seenModelKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($rowMatch in $rowMatches) { $rowContent = $rowMatch.Groups[1].Value $cellMatches = [regex]::Matches($rowContent, ']*>\s*(.*?)\s*', [System.Text.RegularExpressions.RegexOptions]::Singleline) if ($cellMatches.Count -lt 2) { continue } $rowLabel = ConvertTo-SurfaceHtmlText -HtmlFragment $cellMatches[0].Groups[1].Value if ([string]::IsNullOrWhiteSpace($rowLabel) -or $rowLabel -notmatch '(?i)^Surface') { continue } $downloadCellContent = $cellMatches[1].Groups[1].Value $linkMatches = [regex]::Matches( $downloadCellContent, ']+href="([^"]+)"[^>]*>(.*?)', [System.Text.RegularExpressions.RegexOptions]::Singleline -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase ) foreach ($linkMatch in $linkMatches) { $modelName = ConvertTo-SurfaceHtmlText -HtmlFragment $linkMatch.Groups[2].Value $modelLink = ConvertTo-SurfaceDownloadCenterLink -LinkValue $linkMatch.Groups[1].Value if ([string]::IsNullOrWhiteSpace($modelName) -or [string]::IsNullOrWhiteSpace($modelLink)) { continue } $modelKey = "$modelName`n$modelLink" if (-not $seenModelKeys.Add($modelKey)) { continue } $models.Add([pscustomobject]@{ Make = 'Microsoft' Model = $modelName Link = $modelLink }) } } if ($models.Count -eq 0) { throw "No Microsoft driver models were found in the Learn table." } if ($models.Count -lt $minimumExpectedModelCount) { WriteLog "Surface cache: Warning - Learn parsing returned only $($models.Count) Microsoft model entries." } else { WriteLog "Surface cache: Parsed $($models.Count) Microsoft model entries from Learn." } # Save the refreshed model list into the shared cache for both UI and CLI use. try { $cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder $cache.ModelIndex = @($models) Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder WriteLog "Surface cache: Saved Microsoft model list to cache." } catch { WriteLog "Surface cache: Failed to save Microsoft model list. Error: $($_.Exception.Message)" } return @($models) } catch { WriteLog "Surface cache: Failed to build Microsoft model list from Learn. Error: $($_.Exception.Message)" # Fall back to the last cached model list even if it is stale when the live request fails. try { $cachePath = Get-SurfaceDriverIndexCachePath -DriversFolder $DriversFolder if (Test-Path -Path $cachePath -PathType Leaf) { $staleCache = Get-Content -Path $cachePath -Raw | ConvertFrom-Json -ErrorAction Stop if (@($staleCache.ModelIndex).Count -gt 0) { WriteLog "Surface cache: Using stale Microsoft model list ($(@($staleCache.ModelIndex).Count) models) because the live Learn request failed." return @($staleCache.ModelIndex) } } } catch { WriteLog "Surface cache: Failed to load stale Microsoft model list fallback. Error: $($_.Exception.Message)" } throw "Failed to retrieve Microsoft Surface models." } } function Get-SurfaceSystemSkuReferenceIndex { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$DriversFolder ) # Source A: Learn page with authoritative Device / System Model / System SKU table $cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder if ($cache.SkuIndex -and $cache.SkuIndex.Count -gt 0) { return @($cache.SkuIndex) } $url = 'https://learn.microsoft.com/en-us/surface/surface-system-sku-reference' WriteLog "Surface cache: Downloading System SKU reference table 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 $skuRows = [System.Collections.Generic.List[pscustomobject]]::new() $rowMatches = [regex]::Matches($html, ']*>(.*?)', [System.Text.RegularExpressions.RegexOptions]::Singleline) foreach ($rowMatch in $rowMatches) { $rowContent = $rowMatch.Groups[1].Value $cellMatches = [regex]::Matches($rowContent, ']*>\s*(?:]*>)?(.*?)(?:

)?\s*', [System.Text.RegularExpressions.RegexOptions]::Singleline) if ($cellMatches.Count -lt 3) { continue } $device = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[0].Groups[1].Value).Trim())) $systemModel = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[1].Groups[1].Value).Trim())) $systemSkuRaw = ([System.Net.WebUtility]::HtmlDecode(($cellMatches[2].Groups[1].Value).Trim())) if ([string]::IsNullOrWhiteSpace($device) -or [string]::IsNullOrWhiteSpace($systemSkuRaw)) { continue } $skuList = @($systemSkuRaw) foreach ($sku in $skuList) { if ([string]::IsNullOrWhiteSpace($sku)) { continue } $skuRows.Add([pscustomobject]@{ Device = $device SystemModel = $systemModel SystemSku = $sku.Trim().ToUpperInvariant() }) } } $cache.SkuIndex = @($skuRows) Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder WriteLog "Surface cache: Stored $($skuRows.Count) SKU entries." return @($skuRows) } function Get-SurfaceDownloadCenterDetails { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$DriversFolder, [Parameter(Mandatory = $true)] [string]$ModelLink, [Parameter()] [string]$ModelName = $null ) # Source C: Download Center details page (window.__DLCDetails__) containing file names + direct URLs $cache = Import-SurfaceDriverIndexCache -DriversFolder $DriversFolder $existing = @($cache.DownloadCenterDetails | Where-Object { $_.Link -eq $ModelLink } | Select-Object -First 1) if ($existing.Count -gt 0 -and $existing[0].Files -and $existing[0].Files.Count -gt 0) { # Backfill Model into cache when available if (-not [string]::IsNullOrWhiteSpace($ModelName)) { if (-not $existing[0].PSObject.Properties['Model'] -or [string]::IsNullOrWhiteSpace($existing[0].Model)) { try { $existing[0] | Add-Member -NotePropertyName Model -NotePropertyValue $ModelName -Force $newDetails = [System.Collections.Generic.List[pscustomobject]]::new() foreach ($item in @($cache.DownloadCenterDetails)) { if ($null -ne $item -and $item.PSObject.Properties['Link'] -and $item.Link -ne $ModelLink) { $newDetails.Add($item) } } $newDetails.Add($existing[0]) $cache.DownloadCenterDetails = @($newDetails) Save-SurfaceDriverIndexCache -Cache $cache -DriversFolder $DriversFolder } catch { WriteLog "Surface cache: Failed to backfill Model for DownloadCenterDetails entry '$ModelLink'. Error: $($_.Exception.Message)" } } } return @($existing[0].Files) } WriteLog "Surface cache: Downloading Download Center details from $ModelLink" $headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' } $downloadPageContent = Invoke-WebRequest -Uri $ModelLink -UseBasicParsing -Headers $headers $scriptPattern = '