diff --git a/FFUDevelopment/FFU.Common/FFU.Common.Drivers.Microsoft.psm1 b/FFUDevelopment/FFU.Common/FFU.Common.Drivers.Microsoft.psm1 new file mode 100644 index 0000000..2151752 --- /dev/null +++ b/FFUDevelopment/FFU.Common/FFU.Common.Drivers.Microsoft.psm1 @@ -0,0 +1,591 @@ +<# +.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 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 = '