Private/Show-RBACSplash.ps1

function Show-RBACSplash {
    <#
    .SYNOPSIS
        Displays a small splash window with an indeterminate progress bar.
    .DESCRIPTION
        The splash runs in its own STA runspace so its UI keeps animating while
        the caller's thread is busy building the main window or importing modules.
 
        Returns an object exposing:
            .Update($message) - update the status line (thread-safe)
            .Close() - dismiss the splash and dispose the runspace
    #>

    [CmdletBinding()]
    param(
        [string]$InitialMessage = 'Loading…',
        [string]$Title          = 'Exchange RBAC Manager',
        [string]$Subtitle       = '',
        [string]$Version        = ''
    )

    Add-Type -AssemblyName PresentationFramework

    $sync = [hashtable]::Synchronized(@{
        Window  = $null
        Status  = $null
        Ready   = $false
        Closed  = $false
    })

    $runspace = [runspacefactory]::CreateRunspace()
    $runspace.ApartmentState = 'STA'
    $runspace.ThreadOptions  = 'ReuseThread'
    $runspace.Open()
    $runspace.SessionStateProxy.SetVariable('sync',           $sync)
    $runspace.SessionStateProxy.SetVariable('initialMessage', $InitialMessage)
    $runspace.SessionStateProxy.SetVariable('titleText',      $Title)
    $runspace.SessionStateProxy.SetVariable('subtitleText',   $Subtitle)
    $runspace.SessionStateProxy.SetVariable('versionText',    $Version)

    $ps = [powershell]::Create()
    $ps.Runspace = $runspace
    [void]$ps.AddScript({
        Add-Type -AssemblyName PresentationFramework

        $xaml = @'
<Window xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        WindowStyle="None" AllowsTransparency="True" Background="Transparent"
        WindowStartupLocation="CenterScreen" Width="460" Height="230"
        ShowInTaskbar="False" Topmost="True" SizeToContent="Manual" Opacity="0">
  <Window.Triggers>
    <EventTrigger RoutedEvent="Window.Loaded">
      <BeginStoryboard>
        <Storyboard>
          <DoubleAnimation Storyboard.TargetProperty="Opacity" From="0" To="1" Duration="0:0:0.18"/>
        </Storyboard>
      </BeginStoryboard>
    </EventTrigger>
  </Window.Triggers>
  <Border Background="#0078D4" CornerRadius="8" Padding="24">
    <Border.Effect>
      <DropShadowEffect BlurRadius="20" ShadowDepth="2" Opacity="0.35" Color="Black"/>
    </Border.Effect>
    <Grid>
      <Grid.RowDefinitions>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="*"/>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="Auto"/>
        <RowDefinition Height="Auto"/>
      </Grid.RowDefinitions>
      <Grid Grid.Row="0">
        <Grid.ColumnDefinitions>
          <ColumnDefinition Width="Auto"/>
          <ColumnDefinition Width="*"/>
          <ColumnDefinition Width="Auto"/>
        </Grid.ColumnDefinitions>
        <Image x:Name="LogoImage" Grid.Column="0" Width="40" Height="40" Margin="0,0,12,0"
               VerticalAlignment="Top"/>
        <StackPanel Grid.Column="1" VerticalAlignment="Top">
          <TextBlock x:Name="TitleText" Foreground="White" FontFamily="Segoe UI"
                     FontSize="20" FontWeight="SemiBold"/>
          <TextBlock x:Name="SubtitleText" Foreground="#DEECF9" FontFamily="Segoe UI"
                     FontSize="12" Margin="0,2,0,0"/>
        </StackPanel>
        <TextBlock x:Name="VersionText" Grid.Column="2" Foreground="White" Opacity="0.65"
                   FontFamily="Consolas" FontSize="11" VerticalAlignment="Top"/>
      </Grid>
      <TextBlock x:Name="StatusText" Grid.Row="2" Foreground="White" FontFamily="Segoe UI"
                 FontSize="12" Margin="0,0,0,8" TextWrapping="Wrap"/>
      <ProgressBar Grid.Row="3" IsIndeterminate="True" Height="6" Foreground="White"
                   Background="#106EBE" BorderThickness="0"/>
      <TextBlock Grid.Row="4" Text="by Clidsys - Bastien Perez" Foreground="White" Opacity="0.65"
                 FontFamily="Segoe UI" FontSize="10"
                 HorizontalAlignment="Right" Margin="0,8,0,0"/>
    </Grid>
  </Border>
</Window>
'@


        $reader  = [System.Xml.XmlReader]::Create([System.IO.StringReader]::new($xaml))
        $window  = [System.Windows.Markup.XamlReader]::Load($reader)
        $window.FindName('TitleText').Text = $titleText
        $subtitleBlock = $window.FindName('SubtitleText')
        if ([string]::IsNullOrWhiteSpace($subtitleText)) {
            $subtitleBlock.Visibility = 'Collapsed'
        }
        else {
            $subtitleBlock.Text = $subtitleText
        }
        $window.FindName('VersionText').Text = $versionText
        $status = $window.FindName('StatusText')
        $status.Text = $initialMessage

        # Render the hub-and-spoke logo (white-on-blue, transparent background) inline
        # so it lives in the splash thread's STA — same approach as the main window icon.
        try {
            $sz = 40
            [double]$cx = $sz / 2.0; [double]$cy = $sz / 2.0
            [double]$hubR = $sz * 0.16; [double]$spokeR = $sz * 0.10
            [double]$stroke = [Math]::Max(1.2, $sz * 0.05)
            $dv = [System.Windows.Media.DrawingVisual]::new()
            $ctx = $dv.RenderOpen()
            $whiteBrush = [System.Windows.Media.SolidColorBrush]::new(
                [System.Windows.Media.Colors]::White)
            $pen = [System.Windows.Media.Pen]::new($whiteBrush, $stroke)
            $spokes = @(
                ,@([double]($sz * 0.18), [double]($sz * 0.22))
                ,@([double]($sz * 0.82), [double]($sz * 0.22))
                ,@([double]($sz * 0.50), [double]($sz * 0.82))
            )
            foreach ($p in $spokes) {
                $ctx.DrawLine($pen,
                    [System.Windows.Point]::new($cx, $cy),
                    [System.Windows.Point]::new($p[0], $p[1]))
            }
            $ctx.DrawEllipse($whiteBrush, $null,
                [System.Windows.Point]::new($cx, $cy), $hubR, $hubR)
            foreach ($p in $spokes) {
                $ctx.DrawEllipse($whiteBrush, $null,
                    [System.Windows.Point]::new($p[0], $p[1]), $spokeR, $spokeR)
            }
            $ctx.Close()
            $rtb = [System.Windows.Media.Imaging.RenderTargetBitmap]::new(
                $sz, $sz, 96, 96, [System.Windows.Media.PixelFormats]::Pbgra32)
            $rtb.Render($dv)
            $rtb.Freeze()
            $window.FindName('LogoImage').Source =
                [System.Windows.Media.Imaging.BitmapFrame]::Create($rtb)
        }
        catch {
            Write-Verbose "Splash logo render failed: $($_.Exception.Message)"
        }

        $sync.Window = $window
        $sync.Status = $status
        $sync.Ready  = $true

        [void]$window.ShowDialog()
        $sync.Closed = $true
    })

    $async = $ps.BeginInvoke()

    # Wait until the splash window has been built (avoid race on first .Update call).
    $sw = [System.Diagnostics.Stopwatch]::StartNew()
    while (-not $sync.Ready -and $sw.ElapsedMilliseconds -lt 3000) {
        Start-Sleep -Milliseconds 25
    }

    $handle = [pscustomobject]@{
        _Sync       = $sync
        _Powershell = $ps
        _Async      = $async
        _Runspace   = $runspace
    }

    $handle | Add-Member -MemberType ScriptMethod -Name Update -Value {
        param([string]$Message)
        if (-not $this._Sync.Status -or $this._Sync.Closed) { return }
        $status = $this._Sync.Status
        $msg    = $Message
        try {
            $status.Dispatcher.Invoke([action]{ $status.Text = $msg })
        }
        catch { }
    }

    $handle | Add-Member -MemberType ScriptMethod -Name Close -Value {
        if ($this._Sync.Closed) { return }
        if ($this._Sync.Window) {
            try {
                $win = $this._Sync.Window
                $win.Dispatcher.Invoke([action]{ $win.Close() })
            }
            catch { }
        }
        try { [void]$this._Powershell.EndInvoke($this._Async) } catch { }
        try { $this._Powershell.Dispose() } catch { }
        try { $this._Runspace.Close(); $this._Runspace.Dispose() } catch { }
    }

    return $handle
}