#Requires -Version 5.1 <# .SYNOPSIS Tower Networking Inc - WPF Mod Manager v3.7 .DESCRIPTION A Windows Presentation Foundation GUI for managing TNI mods. Downloads mods from GitHub releases, manages local mods, and configures parameters. Supports mod.jsonc metadata format alongside legacy metadata.yaml. .NOTES Author: CJFWeatherhead Version: 3.7.12 Requires: PowerShell 5.1+, .NET Framework 4.5+ #> Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase Add-Type -AssemblyName System.Windows.Forms # Configuration $script:AppDataPath = [Environment]::GetFolderPath('ApplicationData') $script:GameDataPath = Join-Path $script:AppDataPath "Godot\app_userdata\Tower Networking Inc" $script:ModsDirectory = Join-Path $script:GameDataPath "Mods" $script:DisabledModsDirectory = Join-Path $script:GameDataPath "Mods_Disabled" $script:SettingsPath = Join-Path $script:GameDataPath "settings.json" $script:ModCachePath = Join-Path $script:GameDataPath "mod_cache.json" $script:ConfigFileName = "entry.lua" $script:LuaJitModFolder = Join-Path $script:ModsDirectory "luajit-support" $script:LuaJitPath = Join-Path $script:LuaJitModFolder "entry.elf" $script:ModManagerVersion = "3.7.12" $script:SupaModLoaderFolder = Join-Path $script:ModsDirectory "supa-mod-loader" $script:ManagedMarkerFileName = "mod.managed" # GitHub Configuration $script:GitHubRepo = "CJFWeatherhead/TNI-Mods" $script:GitHubApiBase = "https://api.github.com/repos/$script:GitHubRepo" $script:LuaJitReleaseTag = "continuous-gnu-main" $script:LuaJitZipUrl = "https://github.com/$script:GitHubRepo/releases/download/$script:LuaJitReleaseTag/luajit-support.zip" # Startup logging Write-Host "======================================" -ForegroundColor Cyan Write-Host "TNI Mod Manager v3.7.12 - Starting up..." -ForegroundColor Cyan Write-Host "======================================" -ForegroundColor Cyan Write-Host "" Write-Host "Configuration:" -ForegroundColor Yellow Write-Host " Game Data Path: " -NoNewline -ForegroundColor Gray Write-Host $script:GameDataPath -ForegroundColor White Write-Host " Mods Directory: " -NoNewline -ForegroundColor Gray Write-Host $script:ModsDirectory -ForegroundColor White Write-Host " GitHub Repository: " -NoNewline -ForegroundColor Gray Write-Host $script:GitHubRepo -ForegroundColor White Write-Host "" # Verify paths exist if (Test-Path $script:ModsDirectory) { Write-Host "[OK] Mods directory found" -ForegroundColor Green } else { Write-Host "[WARN] Mods directory not found - will be created when needed" -ForegroundColor Yellow } Write-Host "" # Global state $script:Config = $null $script:InstalledMods = @() $script:AllMods = @() $script:GitHubReleases = @{} $script:CurrentMod = $null $script:Settings = $null $script:CmdAliases = @{} $script:ModCache = @{} $script:IsDownloading = $false # Mod source types $script:ModSourceType = @{ Downloaded = "Downloaded" Manual = "Manual" Available = "Available" } #region LuaJIT Management function Test-LuaJitInstalled { <# .SYNOPSIS Checks if luajit.elf is installed in the mods directory #> return (Test-Path $script:LuaJitPath) } function Install-LuaJit { <# .SYNOPSIS Downloads and installs luajit.elf from GitHub releases #> param( [System.Windows.Controls.ProgressBar]$ProgressBar = $null, [System.Windows.Controls.TextBlock]$StatusText = $null ) try { Write-Host "Downloading LuaJIT..." -ForegroundColor Cyan if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "Downloading LuaJIT..." }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.IsIndeterminate = $false $ProgressBar.Value = 0 $ProgressBar.Visibility = "Visible" }) } $tempZipPath = Join-Path $env:TEMP "luajit-support.zip" # Ensure mods directory exists if (-not (Test-Path $script:ModsDirectory)) { New-Item -Path $script:ModsDirectory -ItemType Directory -Force | Out-Null } # Download with progress using WebClient $webClient = New-Object System.Net.WebClient $webClient.Headers.Add("User-Agent", "TNI-ModManager/3.0") # Get file size $totalBytes = 0 $downloadedBytes = 0 try { $request = [System.Net.WebRequest]::Create($script:LuaJitZipUrl) $request.Method = "HEAD" $request.UserAgent = "TNI-ModManager/3.0" $response = $request.GetResponse() $totalBytes = $response.ContentLength $response.Close() } catch { Write-Host " Could not determine file size, continuing anyway..." -ForegroundColor Yellow } # Download with progress tracking $stream = $webClient.OpenRead($script:LuaJitZipUrl) $fileStream = [System.IO.File]::Create($tempZipPath) $buffer = New-Object byte[] 8192 $lastProgress = 0 while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { $fileStream.Write($buffer, 0, $read) $downloadedBytes += $read if ($totalBytes -gt 0 -and $ProgressBar) { $progress = [int](($downloadedBytes / $totalBytes) * 100) if ($progress -ne $lastProgress) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.Value = $progress }) $lastProgress = $progress } } # Allow UI to update [System.Windows.Forms.Application]::DoEvents() } $stream.Close() $fileStream.Close() $webClient.Dispose() if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "Extracting LuaJIT..." }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.IsIndeterminate = $true }) } # Extract luajit-support folder from zip Add-Type -AssemblyName System.IO.Compression.FileSystem # Remove existing luajit-support folder if exists if (Test-Path $script:LuaJitModFolder) { Remove-Item $script:LuaJitModFolder -Recurse -Force } # Extract to mods directory (zip contains luajit-support/ folder) [System.IO.Compression.ZipFile]::ExtractToDirectory($tempZipPath, $script:ModsDirectory) if (Test-Path $script:LuaJitPath) { Write-Host " [OK] LuaJIT support mod installed successfully" -ForegroundColor Green $success = $true # Write mod.managed marker so source detection works without a cache entry $markerPath = Join-Path $script:LuaJitModFolder $script:ManagedMarkerFileName $markerData = @{ managedBy = "TNI-ModManager"; modManagerVersion = $script:ModManagerVersion; installedVersion = "luajit"; installedAt = (Get-Date).ToString("o") } | ConvertTo-Json -Compress $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($markerPath, $markerData, $utf8NoBom) } else { Write-Host " [ERROR] entry.elf not found after extraction in luajit-support" -ForegroundColor Red $success = $false } # Clean up temp file Remove-Item $tempZipPath -Force -ErrorAction SilentlyContinue if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = if ($success) { "LuaJIT installed successfully!" } else { "LuaJIT installation failed" } }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.Visibility = "Collapsed" }) } return $success } catch { Write-Host "[ERROR] LuaJIT installation failed: $_" -ForegroundColor Red if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "LuaJIT installation failed: $_" }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.Visibility = "Collapsed" }) } return $false } } function Show-LuaJitPrompt { <# .SYNOPSIS Shows a prompt to the user about missing LuaJIT and offers to download it #> param( [System.Windows.Controls.ProgressBar]$ProgressBar = $null, [System.Windows.Controls.TextBlock]$StatusText = $null ) $message = @" LuaJIT Runtime Missing The LuaJIT support mod (luajit-support) is required to load Lua mods. It was not found in your mods directory. Would you like to download and install it now? Location: $script:LuaJitPath "@ $result = [System.Windows.MessageBox]::Show( $message, "LuaJIT Required", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question ) if ($result -eq "Yes") { $installSuccess = Install-LuaJit -ProgressBar $ProgressBar -StatusText $StatusText if ($installSuccess) { [System.Windows.MessageBox]::Show( "LuaJIT has been installed successfully!`n`nYour mods should now load correctly.", "Installation Complete", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information ) } else { [System.Windows.MessageBox]::Show( "Failed to install LuaJIT.`n`nPlease download luajit-support.zip manually from:`n$script:LuaJitZipUrl`n`nExtract to:`n$script:LuaJitModFolder", "Installation Failed", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error ) } return $installSuccess } else { $warning = @" Warning: Without LuaJIT, mods are unlikely to load correctly. You can install it later by downloading luajit-support.zip from: $script:LuaJitZipUrl Extract to: $script:LuaJitModFolder "@ [System.Windows.MessageBox]::Show( $warning, "LuaJIT Not Installed", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning ) return $false } } #endregion #region Supa Mod Loader Management function Test-SupaModLoaderInstalled { <# .SYNOPSIS Checks if the supa-mod-loader mod is present in the mods directory #> return (Test-Path (Join-Path $script:SupaModLoaderFolder "mod.jsonc")) } function Get-SupaModLoaderLatestRelease { <# .SYNOPSIS Fetches the latest supa-mod-loader release info from GitHub #> try { Write-Host "Fetching latest supa-mod-loader release from GitHub..." -ForegroundColor Cyan $uri = "$script:GitHubApiBase/releases" $headers = @{ "Accept" = "application/vnd.github.v3+json" "User-Agent" = "TNI-ModManager/3.0" } $releases = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get -TimeoutSec 30 foreach ($release in $releases) { if ($release.tag_name -match '^supa-mod-loader-v(\d+\.\d+\.\d+)$') { $version = $Matches[1] $asset = $release.assets | Where-Object { $_.name -like "*.zip" } | Select-Object -First 1 if ($asset) { return @{ ModId = "supa-mod-loader" Version = $version TagName = $release.tag_name DownloadUrl = $asset.browser_download_url AssetName = $asset.name Size = $asset.size PublishedAt = $release.published_at ReleaseNotes = $release.body HtmlUrl = $release.html_url } } } } Write-Host " [WARN] No supa-mod-loader release found on GitHub" -ForegroundColor Yellow return $null } catch { Write-Host "[ERROR] Failed to fetch supa-mod-loader release: $_" -ForegroundColor Red return $null } } function Show-SupaModLoaderPrompt { <# .SYNOPSIS Asks the user whether they want to install the supa-mod-loader mod, then downloads and installs it from the latest GitHub release if they agree. #> param( [System.Windows.Controls.ProgressBar]$ProgressBar = $null, [System.Windows.Controls.TextBlock]$StatusText = $null ) $message = @" Supa Mod Loader The Mod Manager can install a small companion mod called 'Supa Mod Loader'. It has no gameplay effects. Its purpose is informational: it lets other mods know they were installed via the Mod Manager (rather than manually), and tracks which version of the Mod Manager set things up. Would you like to install it? (Skipping this is completely fine — your mods will work normally either way.) "@ $result = [System.Windows.MessageBox]::Show( $message, "Supa Mod Loader", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question ) if ($result -eq "Yes") { if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "Fetching supa-mod-loader release..." }) } $releaseInfo = Get-SupaModLoaderLatestRelease if ($releaseInfo) { $installSuccess = Download-ModFromGitHub -ReleaseInfo $releaseInfo -ProgressBar $ProgressBar -StatusText $StatusText if ($installSuccess) { [System.Windows.MessageBox]::Show( "Supa Mod Loader v$($releaseInfo.Version) installed successfully!", "Installation Complete", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information ) } else { [System.Windows.MessageBox]::Show( "Failed to install Supa Mod Loader.`n`nYou can install it manually from:`n$($releaseInfo.HtmlUrl)", "Installation Failed", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error ) } return $installSuccess } else { [System.Windows.MessageBox]::Show( "Could not find a Supa Mod Loader release on GitHub.`n`nPlease try again later or install it manually from the releases page.", "Release Not Found", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Warning ) return $false } } else { Write-Host "[INFO] User declined supa-mod-loader installation" -ForegroundColor Yellow return $false } } #endregion #region GitHub API Functions function Get-GitHubReleases { <# .SYNOPSIS Fetches all mod releases from GitHub (paginated) #> try { Write-Host "Fetching releases from GitHub..." -ForegroundColor Cyan $headers = @{ "Accept" = "application/vnd.github.v3+json" "User-Agent" = "TNI-ModManager/3.0" } $modReleases = @{} $page = 1 $perPage = 100 $maxPages = 5 # Safety limit: 500 releases max do { $uri = "$script:GitHubApiBase/releases?per_page=$perPage&page=$page" $response = Invoke-RestMethod -Uri $uri -Headers $headers -Method Get -TimeoutSec 30 if ($response.Count -eq 0) { break } foreach ($release in $response) { # Parse release tag: format is -v if ($release.tag_name -match '^(.+)-v(\d+\.\d+\.\d+)$') { $modId = $Matches[1] $version = $Matches[2] # Find the zip asset $asset = $release.assets | Where-Object { $_.name -like "*.zip" } | Select-Object -First 1 if ($asset) { $releaseInfo = @{ ModId = $modId Version = $version TagName = $release.tag_name DownloadUrl = $asset.browser_download_url AssetName = $asset.name Size = $asset.size PublishedAt = $release.published_at ReleaseNotes = $release.body HtmlUrl = $release.html_url } # Keep only the latest version for each mod if (-not $modReleases.ContainsKey($modId) -or (Compare-SemanticVersion $version $modReleases[$modId].Version) -gt 0) { $modReleases[$modId] = $releaseInfo } } } } Write-Host " Page ${page}: fetched $($response.Count) releases" -ForegroundColor Gray $page++ } while ($response.Count -eq $perPage -and $page -le $maxPages) Write-Host " Found $($modReleases.Count) mod releases" -ForegroundColor Green return $modReleases } catch { Write-Host "[ERROR] Failed to fetch GitHub releases: $_" -ForegroundColor Red return @{} } } function Compare-SemanticVersion { <# .SYNOPSIS Compares two semantic versions. Returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal #> param( [string]$Version1, [string]$Version2 ) try { $v1Parts = $Version1 -split '\.' | ForEach-Object { [int]$_ } $v2Parts = $Version2 -split '\.' | ForEach-Object { [int]$_ } for ($i = 0; $i -lt 3; $i++) { $v1 = if ($i -lt $v1Parts.Count) { $v1Parts[$i] } else { 0 } $v2 = if ($i -lt $v2Parts.Count) { $v2Parts[$i] } else { 0 } if ($v1 -gt $v2) { return 1 } if ($v1 -lt $v2) { return -1 } } return 0 } catch { return 0 } } function Download-ModFromGitHub { <# .SYNOPSIS Downloads and installs a mod from GitHub releases #> param( [hashtable]$ReleaseInfo, [System.Windows.Controls.ProgressBar]$ProgressBar = $null, [System.Windows.Controls.TextBlock]$StatusText = $null ) $script:IsDownloading = $true try { $modId = $ReleaseInfo.ModId $downloadUrl = $ReleaseInfo.DownloadUrl $tempZipPath = Join-Path $env:TEMP "$modId-$($ReleaseInfo.Version).zip" $modTargetPath = Join-Path $script:ModsDirectory $modId Write-Host "Downloading $modId v$($ReleaseInfo.Version)..." -ForegroundColor Cyan if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "Downloading $modId..." }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.IsIndeterminate = $false $ProgressBar.Value = 0 $ProgressBar.Visibility = "Visible" }) } # Download with progress using WebClient $webClient = New-Object System.Net.WebClient $webClient.Headers.Add("User-Agent", "TNI-ModManager/3.0") # Simple synchronous download with progress callback $totalBytes = 0 $downloadedBytes = 0 # First, get the file size try { $request = [System.Net.WebRequest]::Create($downloadUrl) $request.Method = "HEAD" $request.UserAgent = "TNI-ModManager/3.0" $response = $request.GetResponse() $totalBytes = $response.ContentLength $response.Close() } catch { Write-Host " Could not determine file size, continuing anyway..." -ForegroundColor Yellow } # Download with progress tracking $stream = $webClient.OpenRead($downloadUrl) $fileStream = [System.IO.File]::Create($tempZipPath) $buffer = New-Object byte[] 8192 $lastProgress = 0 while (($read = $stream.Read($buffer, 0, $buffer.Length)) -gt 0) { $fileStream.Write($buffer, 0, $read) $downloadedBytes += $read if ($totalBytes -gt 0 -and $ProgressBar) { $progress = [int](($downloadedBytes / $totalBytes) * 100) if ($progress -ne $lastProgress) { $lastProgress = $progress $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.Value = $progress }) } } # Allow UI to update [System.Windows.Forms.Application]::DoEvents() } $stream.Close() $fileStream.Close() $webClient.Dispose() if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "Extracting $modId..." }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.IsIndeterminate = $true }) } # Remove existing mod folder if exists if (Test-Path $modTargetPath) { Remove-Item $modTargetPath -Recurse -Force } # Create mods directory if needed if (-not (Test-Path $script:ModsDirectory)) { New-Item -Path $script:ModsDirectory -ItemType Directory -Force | Out-Null } # Extract zip to a temp directory first, then move the mod folder into place. # New packaging wraps files as / inside the zip; # legacy zips have files flat at the root. Add-Type -AssemblyName System.IO.Compression.FileSystem $tempExtractPath = Join-Path $env:TEMP "tni-mod-extract-$modId" if (Test-Path $tempExtractPath) { Remove-Item $tempExtractPath -Recurse -Force } [System.IO.Compression.ZipFile]::ExtractToDirectory($tempZipPath, $tempExtractPath) # Detect zip layout: check for mods// or / wrapper $innerModsPath = Join-Path $tempExtractPath "mods\$modId" $innerDirectPath = Join-Path $tempExtractPath $modId if (Test-Path $innerModsPath) { # New format: mods//... → move inner folder to target if (Test-Path $modTargetPath) { Remove-Item $modTargetPath -Recurse -Force } Move-Item -Path $innerModsPath -Destination $modTargetPath -Force } elseif (Test-Path $innerDirectPath) { # Alternate format: /... → move inner folder to target if (Test-Path $modTargetPath) { Remove-Item $modTargetPath -Recurse -Force } Move-Item -Path $innerDirectPath -Destination $modTargetPath -Force } else { # Legacy flat format: files at zip root → move whole extracted dir if (Test-Path $modTargetPath) { Remove-Item $modTargetPath -Recurse -Force } Move-Item -Path $tempExtractPath -Destination $modTargetPath -Force } # Clean up temp extraction directory if (Test-Path $tempExtractPath) { Remove-Item $tempExtractPath -Recurse -Force -ErrorAction SilentlyContinue } # Clean up temp file Remove-Item $tempZipPath -Force -ErrorAction SilentlyContinue # Write mod.managed marker file into the mod folder — this is the primary # source-of-truth for "was this installed by the mod manager" checks. # Using a folder-local file avoids cache key mismatches between the release/ # folder name and the id declared inside mod.jsonc. $markerPath = Join-Path $modTargetPath $script:ManagedMarkerFileName $markerData = @{ managedBy = "TNI-ModManager"; modManagerVersion = $script:ModManagerVersion; folderId = $modId; installedVersion = $ReleaseInfo.Version; installedAt = (Get-Date).ToString("o") } | ConvertTo-Json -Compress $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($markerPath, $markerData, $utf8NoBom) Write-Host " [OK] Wrote mod.managed marker to $modId" -ForegroundColor Gray # Also update the cache (kept for supplementary tracking / backwards compat) $script:ModCache[$modId] = @{ Source = $script:ModSourceType.Downloaded Version = $ReleaseInfo.Version InstalledAt = (Get-Date).ToString("o") } Save-ModCache if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "$modId installed successfully!" }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.Visibility = "Collapsed" }) } Write-Host " [OK] Installed $modId v$($ReleaseInfo.Version)" -ForegroundColor Green return $true } catch { Write-Host "[ERROR] Download failed: $_" -ForegroundColor Red if ($StatusText) { $StatusText.Dispatcher.Invoke([Action]{ $StatusText.Text = "Download failed: $_" }) } if ($ProgressBar) { $ProgressBar.Dispatcher.Invoke([Action]{ $ProgressBar.Visibility = "Collapsed" }) } return $false } finally { $script:IsDownloading = $false } } function Remove-DownloadedMod { <# .SYNOPSIS Completely removes a downloaded mod from the filesystem #> param([string]$ModId) try { $modPath = Join-Path $script:ModsDirectory $ModId if (Test-Path $modPath) { Remove-Item $modPath -Recurse -Force Write-Host " [OK] Removed downloaded mod: $ModId" -ForegroundColor Green } # Remove from cache if ($script:ModCache.ContainsKey($ModId)) { $script:ModCache.Remove($ModId) Save-ModCache } return $true } catch { Write-Host "[ERROR] Failed to remove mod: $_" -ForegroundColor Red return $false } } #endregion #region Cache Management function Load-ModCache { <# .SYNOPSIS Loads the mod cache that tracks downloaded vs manual mods #> if (Test-Path $script:ModCachePath) { try { $content = Get-Content $script:ModCachePath -Raw -Encoding UTF8 $jsonObj = $content | ConvertFrom-Json # Convert PSObject to hashtable $script:ModCache = @{} foreach ($prop in $jsonObj.PSObject.Properties) { $script:ModCache[$prop.Name] = @{ Source = $prop.Value.Source Version = $prop.Value.Version InstalledAt = $prop.Value.InstalledAt } } Write-Host " Loaded mod cache with $($script:ModCache.Count) entries" -ForegroundColor Gray } catch { Write-Host "[WARN] Failed to load mod cache: $_" -ForegroundColor Yellow $script:ModCache = @{} } } else { $script:ModCache = @{} } } function Save-ModCache { <# .SYNOPSIS Saves the mod cache to disk #> try { $cacheDir = Split-Path $script:ModCachePath -Parent if (-not (Test-Path $cacheDir)) { New-Item -Path $cacheDir -ItemType Directory -Force | Out-Null } $json = $script:ModCache | ConvertTo-Json -Depth 10 $utf8NoBom = New-Object System.Text.UTF8Encoding $false [System.IO.File]::WriteAllText($script:ModCachePath, $json, $utf8NoBom) } catch { Write-Host "[WARN] Failed to save mod cache: $_" -ForegroundColor Yellow } } #endregion #region Helper Functions function Convert-MarkdownToTextBlock { <# .SYNOPSIS Converts basic markdown (bold and links) to WPF TextBlock with Inlines #> param( [string]$Text, [System.Windows.Controls.TextBlock]$TextBlock ) if ([string]::IsNullOrWhiteSpace($Text)) { return } $TextBlock.Inlines.Clear() $TextBlock.TextWrapping = "Wrap" # Split by markdown patterns $pattern = '(\*\*.*?\*\*|\[.*?\]\(.*?\))' $parts = [regex]::Split($Text, $pattern) foreach ($part in $parts) { if ([string]::IsNullOrEmpty($part)) { continue } # Handle bold: **text** if ($part -match '^\*\*(.*?)\*\*$') { $run = New-Object System.Windows.Documents.Run $run.Text = $Matches[1] $run.FontWeight = "Bold" $TextBlock.Inlines.Add($run) } # Handle links: [text](url) elseif ($part -match '^\[(.*?)\]\((.*?)\)$') { $linkText = $Matches[1] $linkUrl = $Matches[2] $hyperlink = New-Object System.Windows.Documents.Hyperlink $hyperlink.NavigateUri = $linkUrl $run = New-Object System.Windows.Documents.Run $run.Text = $linkText $hyperlink.Inlines.Add($run) $hyperlink.Foreground = "#FF0078D4" $hyperlink.Add_RequestNavigate({ param($sender, $e) Start-Process $e.Uri.AbsoluteUri $e.Handled = $true }) $TextBlock.Inlines.Add($hyperlink) } # Plain text else { $run = New-Object System.Windows.Documents.Run $run.Text = $part $TextBlock.Inlines.Add($run) } } } function Get-ModStatusColor { param( [string]$Status, [bool]$Enabled ) $colors = @{ 'Active' = @{ Enabled = '#FF4CAF50'; Disabled = '#FF2E7D32' } 'Maintenance' = @{ Enabled = '#FFFFD54F'; Disabled = '#FFF9A825' } 'Discontinued' = @{ Enabled = '#FFFF9800'; Disabled = '#FFE65100' } 'Unsupported' = @{ Enabled = '#FFF44336'; Disabled = '#FFC62828' } 'Defunct' = @{ Enabled = '#FFF44336'; Disabled = '#FFC62828' } } if ($colors.ContainsKey($Status)) { if ($Enabled) { return $colors[$Status]['Enabled'] } else { return $colors[$Status]['Disabled'] } } if ($Enabled) { return '#FF808080' } else { return '#FF505050' } } function Get-ModSourceColor { param([string]$Source) switch ($Source) { "Downloaded" { return "#FF0078D4" } # Blue "Manual" { return "#FF9C27B0" } # Purple "Available" { return "#FF607D8B" } # Gray default { return "#FF808080" } } } function Get-ModSourceIcon { param([string]$Source) switch ($Source) { "Downloaded" { return [char]0x2601 } # Cloud "Manual" { return [char]0x1F4C1 } # Folder "Available" { return [char]0x2B07 } # Down arrow default { return "?" } } } #endregion #region XAML UI Definition $xaml = @"