diff --git a/FFUDevelopment/BuildFFUVM_UI.ps1 b/FFUDevelopment/BuildFFUVM_UI.ps1 index c282020..fc7eefb 100644 --- a/FFUDevelopment/BuildFFUVM_UI.ps1 +++ b/FFUDevelopment/BuildFFUVM_UI.ps1 @@ -133,6 +133,9 @@ $window.Add_Loaded({ Initialize-DynamicUIElements -State $script:uiState Register-EventHandlers -State $script:uiState + # Populate the Home page build and release status after the window initializes + Start-HomeStatusRefresh -State $script:uiState + # Attempt automatic load of previous environment (silent) try { Invoke-AutoLoadPreviousEnvironment -State $script:uiState diff --git a/FFUDevelopment/BuildFFUVM_UI.xaml b/FFUDevelopment/BuildFFUVM_UI.xaml index e849064..373f668 100644 --- a/FFUDevelopment/BuildFFUVM_UI.xaml +++ b/FFUDevelopment/BuildFFUVM_UI.xaml @@ -190,9 +190,125 @@ - + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + View all discussions + + + + + + + + + + + FFU Builder Quick Start + + + Documentation Home + + + GitHub Repository + + + GitHub Releases + + + Change Log + + + FFU Builder Quick Start Video + + + + @@ -929,38 +1045,6 @@ - - - - - - - - GitHub Repository - - - GitHub Releases - - - Change Log - - - Documentation - - - - - FFU Builder Overview - - - FFU Builder Deep Dive - - - FFU Deployment Walkthrough - - - - diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 index 1e0cba0..71d57c7 100644 --- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Handlers.psm1 @@ -167,8 +167,21 @@ function Register-EventHandlers { }) } - # Hyperlink navigation handlers for Settings page links - $hyperlinkNames = @('linkGitHub', 'linkReleases', 'linkChangelog', 'linkDocs', 'linkVideo1', 'linkVideo2', 'linkVideo3') + # Hyperlink navigation handlers for Home page links + $hyperlinkNames = @( + 'linkQuickStart', + 'linkDocs', + 'linkGitHub', + 'linkReleases', + 'linkChangelog', + 'linkVideo1', + 'linkDiscussion1', + 'linkDiscussion2', + 'linkDiscussion3', + 'linkDiscussion4', + 'linkDiscussion5', + 'linkDiscussions' + ) foreach ($linkName in $hyperlinkNames) { $link = $State.Window.FindName($linkName) if ($null -ne $link) { diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 index 3f95b64..b853cd9 100644 --- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.Initialize.psm1 @@ -277,6 +277,34 @@ function Initialize-UIControls { $State.Controls.btnRestoreDefaults = $window.FindName('btnRestoreDefaults') $State.Controls.btnBuildConfig = $window.FindName('btnBuildConfig') + # Home page + $State.Controls.txtHomeCurrentBuildValue = $window.FindName('txtHomeCurrentBuildValue') + $State.Controls.txtHomeLatestReleaseValue = $window.FindName('txtHomeLatestReleaseValue') + $State.Controls.txtHomeReleaseStatusValue = $window.FindName('txtHomeReleaseStatusValue') + $State.Controls.spHomeReleaseNotesSections = $window.FindName('spHomeReleaseNotesSections') + $State.Controls.ellipseHomeDiskSpaceStatus = $window.FindName('ellipseHomeDiskSpaceStatus') + $State.Controls.txtHomeDiskSpaceStatusValue = $window.FindName('txtHomeDiskSpaceStatusValue') + $State.Controls.ellipseHomeHyperVStatus = $window.FindName('ellipseHomeHyperVStatus') + $State.Controls.txtHomeHyperVStatusValue = $window.FindName('txtHomeHyperVStatusValue') + $State.Controls.txtHomeDiscussionsStatusValue = $window.FindName('txtHomeDiscussionsStatusValue') + $State.Controls.tbDiscussion1 = $window.FindName('tbDiscussion1') + $State.Controls.linkDiscussion1 = $window.FindName('linkDiscussion1') + $State.Controls.runDiscussion1 = $window.FindName('runDiscussion1') + $State.Controls.tbDiscussion2 = $window.FindName('tbDiscussion2') + $State.Controls.linkDiscussion2 = $window.FindName('linkDiscussion2') + $State.Controls.runDiscussion2 = $window.FindName('runDiscussion2') + $State.Controls.tbDiscussion3 = $window.FindName('tbDiscussion3') + $State.Controls.linkDiscussion3 = $window.FindName('linkDiscussion3') + $State.Controls.runDiscussion3 = $window.FindName('runDiscussion3') + $State.Controls.tbDiscussion4 = $window.FindName('tbDiscussion4') + $State.Controls.linkDiscussion4 = $window.FindName('linkDiscussion4') + $State.Controls.runDiscussion4 = $window.FindName('runDiscussion4') + $State.Controls.tbDiscussion5 = $window.FindName('tbDiscussion5') + $State.Controls.linkDiscussion5 = $window.FindName('linkDiscussion5') + $State.Controls.runDiscussion5 = $window.FindName('runDiscussion5') + $State.Controls.tbDiscussionsLink = $window.FindName('tbDiscussionsLink') + $State.Controls.linkDiscussions = $window.FindName('linkDiscussions') + # Settings page $State.Controls.cmbThemeMode = $window.FindName('cmbThemeMode') diff --git a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 index c9c2e10..8cba575 100644 --- a/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 +++ b/FFUDevelopment/FFUUI.Core/FFUUI.Core.psm1 @@ -471,6 +471,777 @@ function Update-DriverDownloadPanelVisibility { } } +# -------------------------------------------------------------------------- +# SECTION: Home Page Build Status +# -------------------------------------------------------------------------- + +# Function to normalize release strings so local builds and GitHub tags compare consistently +function ConvertTo-NormalizedReleaseVersion { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$Version + ) + + if ([string]::IsNullOrWhiteSpace($Version)) { + return $null + } + + $normalizedVersion = $Version.Trim().ToLowerInvariant() + $normalizedVersion = $normalizedVersion -replace '^[v]', '' + return $normalizedVersion +} + +# Function to read the current FFU Builder build from the main build script +function Get-FFUBuilderCurrentBuild { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$FFUDevelopmentPath + ) + + $buildScriptPath = Join-Path -Path $FFUDevelopmentPath -ChildPath 'BuildFFUVM.ps1' + if (-not (Test-Path -Path $buildScriptPath)) { + return 'Unknown' + } + + try { + $buildScriptContent = Get-Content -Path $buildScriptPath -Raw -ErrorAction Stop + $versionMatch = [regex]::Match($buildScriptContent, '(?m)^\$version\s*=\s*''([^'']+)''') + if ($versionMatch.Success) { + return $versionMatch.Groups[1].Value + } + } + catch { + WriteLog "Unable to read the current FFU Builder build version: $($_.Exception.Message)" + } + + return 'Unknown' +} + +# Function to query GitHub for the latest published FFU Builder release +function Get-FFUBuilderLatestRelease { + [CmdletBinding()] + param() + + $releaseApiUri = 'https://api.github.com/repos/rbalsleyMSFT/FFU/releases/latest' + $releaseHeaders = @{ + 'User-Agent' = 'FFUBuilderUI' + 'Accept' = 'application/vnd.github+json' + } + + $releaseResponse = Invoke-RestMethod -Uri $releaseApiUri -Headers $releaseHeaders -TimeoutSec 5 -ErrorAction Stop + $releaseVersion = if (-not [string]::IsNullOrWhiteSpace([string]$releaseResponse.tag_name)) { + [string]$releaseResponse.tag_name + } + elseif (-not [string]::IsNullOrWhiteSpace([string]$releaseResponse.name)) { + [string]$releaseResponse.name + } + else { + $null + } + + return [PSCustomObject]@{ + Version = $releaseVersion + HtmlUrl = [string]$releaseResponse.html_url + Body = [string]$releaseResponse.body + } +} + +# Function to build a user-friendly release status message for the Home page +function Get-FFUBuilderReleaseStatusMessage { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$CurrentBuild, + [Parameter(Mandatory = $true)] + [string]$LatestRelease + ) + + # Format the release string for Home page display while keeping compare logic normalized + $displayLatestRelease = if ([string]::IsNullOrWhiteSpace($LatestRelease)) { + $LatestRelease + } + else { + $LatestRelease -replace '^[vV]', '' + } + + $normalizedCurrentBuild = ConvertTo-NormalizedReleaseVersion -Version $CurrentBuild + $normalizedLatestRelease = ConvertTo-NormalizedReleaseVersion -Version $LatestRelease + + if ([string]::IsNullOrWhiteSpace($normalizedCurrentBuild)) { + return 'Installed build information is unavailable.' + } + + if ([string]::IsNullOrWhiteSpace($normalizedLatestRelease)) { + return 'Unable to compare the installed build with the latest release.' + } + + if ($normalizedCurrentBuild -eq $normalizedLatestRelease) { + return 'You are running the latest published build.' + } + + $currentVersionMatch = [regex]::Match($normalizedCurrentBuild, '^\d+(?:\.\d+){0,3}') + $latestVersionMatch = [regex]::Match($normalizedLatestRelease, '^\d+(?:\.\d+){0,3}') + + if ($currentVersionMatch.Success -and $latestVersionMatch.Success) { + try { + $currentVersion = [version]$currentVersionMatch.Value + $latestVersion = [version]$latestVersionMatch.Value + + if ($currentVersion -lt $latestVersion) { + return "A newer release is available: $displayLatestRelease." + } + + if ($currentVersion -gt $latestVersion) { + return "This build is newer than the latest published release: $displayLatestRelease." + } + } + catch { + WriteLog "Unable to compare FFU Builder release versions numerically: $($_.Exception.Message)" + } + } + + return "Installed build $CurrentBuild differs from the latest published release $displayLatestRelease." +} + +# Function to normalize a markdown heading for release-notes display +function ConvertTo-ReleaseNotesHeadingText { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$Line + ) + + if ([string]::IsNullOrWhiteSpace($Line)) { + return '' + } + + $cleanLine = $Line.Trim() + $cleanLine = $cleanLine -replace '^#+\s*', '' + $cleanLine = [regex]::Replace($cleanLine, '\[([^\]]+)\]\([^)]+\)', '$1') + $cleanLine = $cleanLine -replace '\*\*', '' + $cleanLine = $cleanLine -replace '`', '' + return $cleanLine.Trim() +} + +# Function to clean plain text segments before rendering markdown-aware inlines +function ConvertTo-ReleaseNotesPlainText { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$Text + ) + + if ([string]::IsNullOrWhiteSpace($Text)) { + return '' + } + + $cleanText = $Text + $cleanText = $cleanText -replace '\*\*', '' + $cleanText = $cleanText -replace '`', '' + return $cleanText +} + +# Function to add markdown-aware inline content to a TextBlock +function Add-ReleaseNotesInlinesToTextBlock { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [System.Windows.Controls.TextBlock]$TextBlock, + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$Text + ) + + if ([string]::IsNullOrWhiteSpace($Text)) { + return + } + + $matchPattern = '(?\[(?[^\]]+)\]\((?https?://[^)\s]+)\))|(?https?://[^\s)]+)|(?\*\*(?.+?)\*\*)' + $currentIndex = 0 + + foreach ($match in [regex]::Matches($Text, $matchPattern)) { + if ($match.Index -gt $currentIndex) { + $plainText = ConvertTo-ReleaseNotesPlainText -Text $Text.Substring($currentIndex, $match.Index - $currentIndex) + if (-not [string]::IsNullOrWhiteSpace($plainText)) { + $TextBlock.Inlines.Add([System.Windows.Documents.Run]::new($plainText)) | Out-Null + } + } + + if ($match.Groups['MarkdownLink'].Success) { + $hyperlink = [System.Windows.Documents.Hyperlink]::new() + $hyperlink.NavigateUri = [System.Uri]$match.Groups['LinkUrl'].Value + $hyperlink.ToolTip = $match.Groups['LinkUrl'].Value + $hyperlink.Inlines.Add([System.Windows.Documents.Run]::new($match.Groups['LinkText'].Value)) | Out-Null + $hyperlink.Add_RequestNavigate({ + param($eventSource, $requestNavigateEventArgs) + Start-Process $requestNavigateEventArgs.Uri.AbsoluteUri + $requestNavigateEventArgs.Handled = $true + }) + $TextBlock.Inlines.Add($hyperlink) | Out-Null + } + elseif ($match.Groups['BareUrl'].Success) { + $bareUrl = $match.Groups['BareUrl'].Value.TrimEnd('.', ',', ';', ':') + $hyperlink = [System.Windows.Documents.Hyperlink]::new() + $hyperlink.NavigateUri = [System.Uri]$bareUrl + $hyperlink.ToolTip = $bareUrl + $hyperlink.Inlines.Add([System.Windows.Documents.Run]::new($bareUrl)) | Out-Null + $hyperlink.Add_RequestNavigate({ + param($eventSource, $requestNavigateEventArgs) + Start-Process $requestNavigateEventArgs.Uri.AbsoluteUri + $requestNavigateEventArgs.Handled = $true + }) + $TextBlock.Inlines.Add($hyperlink) | Out-Null + + $trailingCharactersLength = $match.Groups['BareUrl'].Value.Length - $bareUrl.Length + if ($trailingCharactersLength -gt 0) { + $trailingCharacters = $match.Groups['BareUrl'].Value.Substring($bareUrl.Length, $trailingCharactersLength) + $TextBlock.Inlines.Add([System.Windows.Documents.Run]::new($trailingCharacters)) | Out-Null + } + } + elseif ($match.Groups['Bold'].Success) { + $boldRun = [System.Windows.Documents.Run]::new((ConvertTo-ReleaseNotesPlainText -Text $match.Groups['BoldText'].Value)) + $boldRun.FontWeight = 'SemiBold' + $TextBlock.Inlines.Add($boldRun) | Out-Null + } + + $currentIndex = $match.Index + $match.Length + } + + if ($currentIndex -lt $Text.Length) { + $remainingText = ConvertTo-ReleaseNotesPlainText -Text $Text.Substring($currentIndex) + if (-not [string]::IsNullOrWhiteSpace($remainingText)) { + $TextBlock.Inlines.Add([System.Windows.Documents.Run]::new($remainingText)) | Out-Null + } + } +} + +# Function to build a formatted UI element for a release-notes section body +function New-ReleaseNotesSectionContent { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$Content + ) + + $contentPanel = New-Object System.Windows.Controls.StackPanel + $contentPanel.Margin = '0,2,0,2' + + foreach ($contentLine in ($Content -split "`r?`n")) { + $trimmedLine = $contentLine.Trim() + if ([string]::IsNullOrWhiteSpace($trimmedLine)) { + continue + } + + $isFirstRenderedLine = ($contentPanel.Children.Count -eq 0) + + $textBlock = New-Object System.Windows.Controls.TextBlock + $textBlock.TextWrapping = 'Wrap' + $textBlock.Margin = if ($isFirstRenderedLine) { '0,2,0,0' } else { '0,12,0,0' } + + $lineContent = $trimmedLine + $listItemMatch = [regex]::Match($trimmedLine, '^(?:[-*]|\d+\.)\s+(.+)$') + if ($listItemMatch.Success) { + $textBlock.Margin = if ($isFirstRenderedLine) { '0,2,0,0' } else { '0,10,0,0' } + $textBlock.Inlines.Add([System.Windows.Documents.Run]::new([string][char]0x2022 + ' ')) | Out-Null + $lineContent = $listItemMatch.Groups[1].Value + } + + Add-ReleaseNotesInlinesToTextBlock -TextBlock $textBlock -Text $lineContent + $contentPanel.Children.Add($textBlock) | Out-Null + } + + if ($contentPanel.Children.Count -eq 0) { + $fallbackTextBlock = New-Object System.Windows.Controls.TextBlock + $fallbackTextBlock.Text = 'No additional details were published for this section.' + $fallbackTextBlock.TextWrapping = 'Wrap' + $fallbackTextBlock.Margin = '0,2,0,0' + $contentPanel.Children.Add($fallbackTextBlock) | Out-Null + } + + return $contentPanel +} + +# Function to parse the full GitHub release notes into UI sections +function Get-FFUBuilderReleaseNotesSections { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$ReleaseNotesBody + ) + + $releaseNoteSections = [System.Collections.Generic.List[object]]::new() + + if ([string]::IsNullOrWhiteSpace($ReleaseNotesBody)) { + $releaseNoteSections.Add([PSCustomObject]@{ + Title = 'Release Notes' + Content = 'No release notes were published for this release.' + UseExpander = $false + IsExpanded = $true + }) + return $releaseNoteSections + } + + $currentTitle = 'Release Overview' + $currentLines = [System.Collections.Generic.List[string]]::new() + + foreach ($releaseNotesLine in ($ReleaseNotesBody -split "`r?`n")) { + $trimmedLine = $releaseNotesLine.Trim() + + if ($trimmedLine -match '^#+\s*(.+)$') { + $sectionContent = ($currentLines -join [Environment]::NewLine).Trim() + if (-not [string]::IsNullOrWhiteSpace($sectionContent)) { + $useExpander = (($sectionContent -split "`r?`n").Count -gt 2 -or $sectionContent.Length -gt 220) + $releaseNoteSections.Add([PSCustomObject]@{ + Title = $currentTitle + Content = $sectionContent + UseExpander = $useExpander + IsExpanded = ($releaseNoteSections.Count -eq 0) + }) + } + + $currentTitle = ConvertTo-ReleaseNotesHeadingText -Line $matches[1] + $currentLines = [System.Collections.Generic.List[string]]::new() + continue + } + + if ([string]::IsNullOrWhiteSpace($trimmedLine)) { + if ($currentLines.Count -gt 0 -and $currentLines[$currentLines.Count - 1] -ne '') { + $currentLines.Add('') + } + continue + } + + $currentLines.Add($trimmedLine) + } + + $finalSectionContent = ($currentLines -join [Environment]::NewLine).Trim() + if (-not [string]::IsNullOrWhiteSpace($finalSectionContent)) { + $useExpander = (($finalSectionContent -split "`r?`n").Count -gt 2 -or $finalSectionContent.Length -gt 220) + $releaseNoteSections.Add([PSCustomObject]@{ + Title = $currentTitle + Content = $finalSectionContent + UseExpander = $useExpander + IsExpanded = ($releaseNoteSections.Count -eq 0) + }) + } + + if ($releaseNoteSections.Count -eq 0) { + $releaseNoteSections.Add([PSCustomObject]@{ + Title = 'Release Notes' + Content = 'No release notes were published for this release.' + UseExpander = $false + IsExpanded = $true + }) + } + + return $releaseNoteSections +} + +# Function to render formatted release notes into the Home page +function Set-HomeReleaseNotesContent { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [PSCustomObject]$State, + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$ReleaseNotesBody + ) + + $releaseNotesPanel = $State.Controls.spHomeReleaseNotesSections + if ($null -eq $releaseNotesPanel) { + return + } + + $releaseNotesPanel.Children.Clear() + $releaseNoteSections = @(Get-FFUBuilderReleaseNotesSections -ReleaseNotesBody $ReleaseNotesBody) + + foreach ($releaseNoteSection in $releaseNoteSections) { + $sectionContent = New-ReleaseNotesSectionContent -Content $releaseNoteSection.Content + + if ($releaseNoteSection.UseExpander) { + $headerTextBlock = New-Object System.Windows.Controls.TextBlock + $headerTextBlock.Text = $releaseNoteSection.Title + $headerTextBlock.TextWrapping = 'Wrap' + $headerTextBlock.FontWeight = 'SemiBold' + + $releaseNotesExpander = New-Object System.Windows.Controls.Expander + $releaseNotesExpander.Header = $headerTextBlock + $releaseNotesExpander.IsExpanded = [bool]$releaseNoteSection.IsExpanded + $releaseNotesExpander.Margin = '0,0,0,8' + $releaseNotesExpander.Content = $sectionContent + + $releaseNotesPanel.Children.Add($releaseNotesExpander) | Out-Null + } + else { + $releaseNotesSectionPanel = New-Object System.Windows.Controls.StackPanel + $releaseNotesSectionPanel.Margin = '0,0,0,8' + + if (-not [string]::IsNullOrWhiteSpace($releaseNoteSection.Title)) { + $titleTextBlock = New-Object System.Windows.Controls.TextBlock + $titleTextBlock.Text = $releaseNoteSection.Title + $titleTextBlock.FontWeight = 'SemiBold' + $titleTextBlock.TextWrapping = 'Wrap' + $releaseNotesSectionPanel.Children.Add($titleTextBlock) | Out-Null + } + + $releaseNotesSectionPanel.Children.Add($sectionContent) | Out-Null + $releaseNotesPanel.Children.Add($releaseNotesSectionPanel) | Out-Null + } + } +} + +# Function to return a Home page status light brush for environment checks +function Get-HomeStatusBrush { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Green', 'Yellow', 'Red')] + [string]$Level + ) + + switch ($Level) { + 'Green' { return [System.Windows.Media.Brushes]::LimeGreen } + 'Yellow' { return [System.Windows.Media.Brushes]::Gold } + 'Red' { return [System.Windows.Media.Brushes]::IndianRed } + } +} + +# Function to evaluate free disk space on the drive hosting the FFU development path +function Get-FFUBuilderDiskSpaceStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$FFUDevelopmentPath + ) + + try { + $resolvedPath = if (Test-Path -Path $FFUDevelopmentPath) { + (Resolve-Path -Path $FFUDevelopmentPath -ErrorAction Stop).Path + } + else { + $FFUDevelopmentPath + } + + $driveRoot = [System.IO.Path]::GetPathRoot($resolvedPath) + if ([string]::IsNullOrWhiteSpace($driveRoot)) { + throw "Unable to determine a drive root for path $FFUDevelopmentPath" + } + + $driveInfo = [System.IO.DriveInfo]::new($driveRoot) + $freeSpaceGb = [math]::Round($driveInfo.AvailableFreeSpace / 1GB, 2) + + if ($freeSpaceGb -lt 50) { + return [PSCustomObject]@{ + Level = 'Red' + Message = "$freeSpaceGb GB free on $driveRoot. FFU Builder is likely to run out of disk space and should have at least 100 GB free." + } + } + + if ($freeSpaceGb -lt 100) { + return [PSCustomObject]@{ + Level = 'Yellow' + Message = "$freeSpaceGb GB free on $driveRoot. FFU Builder recommends at least 100 GB free space." + } + } + + return [PSCustomObject]@{ + Level = 'Green' + Message = "$freeSpaceGb GB free on $driveRoot. Free space is within the recommended range." + } + } + catch { + WriteLog "Unable to determine free disk space for FFUDevelopmentPath: $($_.Exception.Message)" + return [PSCustomObject]@{ + Level = 'Red' + Message = 'Unable to determine free disk space for the FFUDevelopmentPath drive.' + } + } +} + +# Function to evaluate the local Hyper-V installation state +function Get-FFUBuilderHyperVStatus { + [CmdletBinding()] + param() + + try { + $hyperVFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -ErrorAction Stop + switch ([string]$hyperVFeature.State) { + 'Enabled' { + return [PSCustomObject]@{ + Level = 'Green' + Message = 'Hyper-V is installed and ready.' + } + } + 'EnablePending' { + return [PSCustomObject]@{ + Level = 'Yellow' + Message = 'Hyper-V is installed, but a reboot is required before it is ready.' + } + } + default { + return [PSCustomObject]@{ + Level = 'Red' + Message = "Hyper-V is not installed. Current feature state: $($hyperVFeature.State)." + } + } + } + } + catch { + WriteLog "Unable to determine Hyper-V installation state: $($_.Exception.Message)" + return [PSCustomObject]@{ + Level = 'Red' + Message = 'Unable to determine the Hyper-V installation state.' + } + } +} + +# Function to update the Home page release status fields +function Update-HomeReleaseStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [PSCustomObject]$State, + [Parameter(Mandatory = $true)] + [string]$CurrentBuild, + [Parameter(Mandatory = $true)] + [string]$LatestRelease, + [Parameter(Mandatory = $true)] + [string]$StatusMessage, + [Parameter(Mandatory = $true)] + [string]$ReleaseNotesBody + ) + + if ($null -ne $State.Controls.txtHomeCurrentBuildValue) { + $State.Controls.txtHomeCurrentBuildValue.Text = $CurrentBuild + } + + if ($null -ne $State.Controls.txtHomeLatestReleaseValue) { + $State.Controls.txtHomeLatestReleaseValue.Text = $LatestRelease + } + + if ($null -ne $State.Controls.txtHomeReleaseStatusValue) { + $State.Controls.txtHomeReleaseStatusValue.Text = $StatusMessage + } + + # Render the full release notes into structured sections on the Home page + Set-HomeReleaseNotesContent -State $State -ReleaseNotesBody $ReleaseNotesBody +} + +# Function to update the Home page environment check fields +function Update-HomeEnvironmentStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [PSCustomObject]$State, + [Parameter(Mandatory = $true)] + [PSCustomObject]$DiskSpaceStatus, + [Parameter(Mandatory = $true)] + [PSCustomObject]$HyperVStatus + ) + + if ($null -ne $State.Controls.ellipseHomeDiskSpaceStatus) { + $State.Controls.ellipseHomeDiskSpaceStatus.Fill = Get-HomeStatusBrush -Level $DiskSpaceStatus.Level + } + + if ($null -ne $State.Controls.txtHomeDiskSpaceStatusValue) { + $State.Controls.txtHomeDiskSpaceStatusValue.Text = $DiskSpaceStatus.Message + } + + if ($null -ne $State.Controls.ellipseHomeHyperVStatus) { + $State.Controls.ellipseHomeHyperVStatus.Fill = Get-HomeStatusBrush -Level $HyperVStatus.Level + } + + if ($null -ne $State.Controls.txtHomeHyperVStatusValue) { + $State.Controls.txtHomeHyperVStatusValue.Text = $HyperVStatus.Message + } +} + +# Function to retrieve latest public GitHub discussions for Home page display +function Get-FFUBuilderLatestDiscussions { + [CmdletBinding()] + param() + + $discussionUri = 'https://github.com/rbalsleyMSFT/FFU/discussions' + $discussionHeaders = @{ + 'User-Agent' = 'FFUBuilderUI' + 'Accept' = 'text/html,application/xhtml+xml' + } + + $discussionResponse = Invoke-WebRequest -Uri $discussionUri -Headers $discussionHeaders -TimeoutSec 5 -ErrorAction Stop + $discussionContent = [string]$discussionResponse.Content + $latestDiscussions = New-Object System.Collections.Generic.List[PSCustomObject] + $seenDiscussionUrls = @{} + + # Parse the raw HTML instead of Invoke-WebRequest Links because GitHub's page structure + # does not reliably surface the discussion topic anchors through the Links collection. + $discussionMatches = [regex]::Matches( + $discussionContent, + ']+href="(?/rbalsleyMSFT/FFU/discussions/(?\d+))"[^>]*>(?.*?)', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline + ) + + foreach ($discussionMatch in $discussionMatches) { + $discussionHref = [string]$discussionMatch.Groups['Href'].Value + $discussionUrl = "https://github.com$discussionHref" + + if ($seenDiscussionUrls.ContainsKey($discussionUrl)) { + continue + } + + $discussionInnerHtml = [string]$discussionMatch.Groups['InnerHtml'].Value + $discussionTitle = [regex]::Replace($discussionInnerHtml, '<[^>]+>', ' ') + $discussionTitle = [System.Net.WebUtility]::HtmlDecode($discussionTitle) + $discussionTitle = [regex]::Replace($discussionTitle, '\s+', ' ').Trim() + + if ([string]::IsNullOrWhiteSpace($discussionTitle)) { + continue + } + + # Skip links that resolve to comment counts or other numeric-only link text. + if ($discussionTitle -match '^\d+$') { + continue + } + + $seenDiscussionUrls[$discussionUrl] = $true + $latestDiscussions.Add([PSCustomObject]@{ + Title = $discussionTitle + Url = $discussionUrl + }) + + if ($latestDiscussions.Count -ge 5) { + break + } + } + + return $latestDiscussions +} + +# Function to update the Home page discussions card +function Update-HomeDiscussions { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [PSCustomObject]$State, + [Parameter(Mandatory = $true)] + [string]$StatusMessage, + [Parameter(Mandatory = $false)] + [AllowNull()] + [System.Collections.IEnumerable]$Discussions + ) + + if ($null -ne $State.Controls.txtHomeDiscussionsStatusValue) { + $State.Controls.txtHomeDiscussionsStatusValue.Text = $StatusMessage + } + + $discussionItems = @($Discussions) + for ($index = 1; $index -le 5; $index++) { + $container = $State.Controls["tbDiscussion$index"] + $link = $State.Controls["linkDiscussion$index"] + $run = $State.Controls["runDiscussion$index"] + + if ($null -eq $container -or $null -eq $link -or $null -eq $run) { + continue + } + + if ($index -le $discussionItems.Count -and $null -ne $discussionItems[$index - 1]) { + $discussionItem = $discussionItems[$index - 1] + $run.Text = $discussionItem.Title + $link.NavigateUri = [System.Uri]$discussionItem.Url + $container.Visibility = 'Visible' + } + else { + $run.Text = '' + $link.NavigateUri = [System.Uri]'https://github.com/rbalsleyMSFT/FFU/discussions' + $container.Visibility = 'Collapsed' + } + } + + if ($null -ne $State.Controls.tbDiscussionsLink) { + $State.Controls.tbDiscussionsLink.Visibility = 'Visible' + } +} + +# Function to populate the Home page build status after the window has rendered +function Start-HomeStatusRefresh { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [PSCustomObject]$State + ) + + # Populate local status checks immediately so Home is useful even before network requests complete + $currentBuild = Get-FFUBuilderCurrentBuild -FFUDevelopmentPath $State.FFUDevelopmentPath + $diskSpaceStatus = Get-FFUBuilderDiskSpaceStatus -FFUDevelopmentPath $State.FFUDevelopmentPath + $hyperVStatus = Get-FFUBuilderHyperVStatus + + Update-HomeReleaseStatus -State $State -CurrentBuild $currentBuild -LatestRelease 'Checking GitHub...' -StatusMessage 'Checking whether this build is current...' -ReleaseNotesBody 'Checking latest release notes...' + Update-HomeEnvironmentStatus -State $State -DiskSpaceStatus $diskSpaceStatus -HyperVStatus $hyperVStatus + Update-HomeDiscussions -State $State -StatusMessage 'Checking latest discussions...' -Discussions @() + + if ($null -eq $State.Window) { + return + } + + # Capture the state values before dispatching to avoid losing them in the deferred callback + $refreshState = $State + $refreshCurrentBuild = $currentBuild + $refreshAction = { + $latestReleaseDisplay = 'Unable to check' + $statusMessage = 'Unable to check the latest release right now. Check GitHub Releases when you are back online.' + $releaseNotesBody = 'Unable to load the latest release notes right now.' + $discussionsStatusMessage = 'Unable to load the latest GitHub discussions right now.' + $latestDiscussions = @() + + try { + $latestRelease = Get-FFUBuilderLatestRelease + if ($null -ne $latestRelease -and -not [string]::IsNullOrWhiteSpace($latestRelease.Version)) { + # Strip the GitHub tag prefix so Home shows the same style as the installed build + $latestReleaseDisplay = $latestRelease.Version -replace '^[vV]', '' + $statusMessage = Get-FFUBuilderReleaseStatusMessage -CurrentBuild $refreshCurrentBuild -LatestRelease $latestRelease.Version + $releaseNotesBody = if ([string]::IsNullOrWhiteSpace($latestRelease.Body)) { + 'No release notes were published for this release.' + } + else { + $latestRelease.Body + } + } + } + catch { + WriteLog "Unable to retrieve the latest FFU Builder release: $($_.Exception.Message)" + } + + try { + $latestDiscussions = @(Get-FFUBuilderLatestDiscussions) + if ($latestDiscussions.Count -gt 0) { + $discussionsStatusMessage = 'Latest public GitHub discussions.' + } + else { + $discussionsStatusMessage = 'No recent public discussion topics were found.' + } + } + catch { + WriteLog "Unable to retrieve the latest FFU Builder discussions: $($_.Exception.Message)" + } + + Update-HomeReleaseStatus -State $refreshState -CurrentBuild $refreshCurrentBuild -LatestRelease $latestReleaseDisplay -StatusMessage $statusMessage -ReleaseNotesBody $releaseNotesBody + Update-HomeDiscussions -State $refreshState -StatusMessage $discussionsStatusMessage -Discussions $latestDiscussions + }.GetNewClosure() + + # Queue the network checks after the UI renders so startup remains responsive + $null = $State.Window.Dispatcher.BeginInvoke( + [System.Action]$refreshAction, + [System.Windows.Threading.DispatcherPriority]::Background + ) +} + # -------------------------------------------------------------------------- # SECTION: Module Export # --------------------------------------------------------------------------