#requires -Version 7.0 #requires -PSEdition Core <# .SYNOPSIS Helpers for Community desktop pack plugins (Windows MSI, Linux Flatpak). #> function ConvertTo-WixIdentifier { param( [Parameter(Mandatory = $true)] [string]$Value, [Parameter(Mandatory = $false)] [string]$Prefix = 'id' ) $bytes = [System.Text.Encoding]::UTF8.GetBytes($Value) $sha = [System.Security.Cryptography.SHA256]::Create() try { $hash = [System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '') } finally { $sha.Dispose() } return ($Prefix + $hash.Substring(0, 16)) } function Get-DesktopInstallFolderName { param( [Parameter(Mandatory = $true)] [string]$AppName, [Parameter(Mandatory = $false)] [string]$Manufacturer, [Parameter(Mandatory = $false)] [string]$InstallFolderName ) if (-not [string]::IsNullOrWhiteSpace($InstallFolderName)) { return $InstallFolderName.Trim() } $name = $AppName.Trim() $mfr = if ([string]::IsNullOrWhiteSpace($Manufacturer)) { '' } else { $Manufacturer.Trim() } if ($mfr.Length -gt 0 -and $name.StartsWith($mfr, [System.StringComparison]::OrdinalIgnoreCase)) { $rest = $name.Substring($mfr.Length).TrimStart([char[]]@(' ', '-', '.')) if (-not [string]::IsNullOrWhiteSpace($rest)) { return $rest } } return $name } function Get-WixArchitectureFromRuntimeIdentifier { param( [Parameter(Mandatory = $false)] [string]$RuntimeIdentifier = '' ) $rid = [string]$RuntimeIdentifier if ($rid -match '(?i)arm64') { return 'arm64' } if ($rid -match '(?i)(^|-)x86($|-)') { return 'x86' } return 'x64' } function Get-WixPerMachineProgramFilesFolderId { param( [Parameter(Mandatory = $false)] [string]$Architecture = 'x64' ) if ($Architecture -eq 'x86') { return 'ProgramFilesFolder' } return 'ProgramFiles64Folder' } function Get-MsiProductVersion { param( [Parameter(Mandatory = $true)] [string]$Version ) $numeric = $Version $plusIndex = $numeric.IndexOf('+') if ($plusIndex -ge 0) { $numeric = $numeric.Substring(0, $plusIndex) } $hyphenIndex = $numeric.IndexOf('-') if ($hyphenIndex -ge 0) { $numeric = $numeric.Substring(0, $hyphenIndex) } $parts = @($numeric.Split('.') | Where-Object { $_ -match '^\d+$' }) if ($parts.Count -lt 2) { throw "Cannot derive an MSI ProductVersion from '$Version'. Expected semver with at least major.minor." } while ($parts.Count -lt 3) { $parts += '0' } if ($parts.Count -gt 4) { $parts = $parts[0..3] } return ($parts -join '.') } function Get-PluginPropertyValue { param( [Parameter(Mandatory = $true)] $PluginSettings, [Parameter(Mandatory = $true)] [string]$Name, [Parameter(Mandatory = $false)] $Default = $null ) if ($PluginSettings.PSObject.Properties[$Name] -and $null -ne $PluginSettings.$Name) { $value = $PluginSettings.$Name if ($value -is [string] -and [string]::IsNullOrWhiteSpace($value)) { return $Default } return $value } return $Default } function Resolve-DesktopPublishDirectory { param( [Parameter(Mandatory = $true)] [psobject]$Context, [Parameter(Mandatory = $true)] [string]$RuntimeIdentifier, [Parameter(Mandatory = $false)] [string]$PublishDir, [Parameter(Mandatory = $false)] [string]$ScriptDir ) if (-not [string]::IsNullOrWhiteSpace($PublishDir)) { $resolved = $PublishDir if (-not [System.IO.Path]::IsPathRooted($resolved) -and -not [string]::IsNullOrWhiteSpace($ScriptDir)) { $resolved = [System.IO.Path]::GetFullPath((Join-Path $ScriptDir $PublishDir)) } if (-not (Test-Path -LiteralPath $resolved -PathType Container)) { throw "Desktop publish directory not found: $resolved" } return $resolved } $outputs = @(Get-EngineFact -Context $Context -Namespace 'dotnet' -Name 'publishOutputs' -Default @()) if ($outputs.Count -gt 0) { $exact = @( $outputs | Where-Object { [string]$_.runtimeIdentifier -eq $RuntimeIdentifier } ) if ($exact.Count -gt 0) { return [string]$exact[0].directory } $family = $RuntimeIdentifier.Split('-')[0] if (-not [string]::IsNullOrWhiteSpace($family)) { $matchedFamily = @( $outputs | Where-Object { ([string]$_.runtimeIdentifier).StartsWith("$family-", [System.StringComparison]::OrdinalIgnoreCase) } ) if ($matchedFamily.Count -gt 0) { return [string]$matchedFamily[0].directory } } } $archiveInputs = @(Get-EngineFact -Context $Context -Namespace 'release' -Name 'archiveInputs' -Default @() -LegacyProperty @('releaseArchiveInputs')) $dirs = @( $archiveInputs | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) -and (Test-Path -LiteralPath ([string]$_) -PathType Container) } ) if ($dirs.Count -eq 1) { return [string]$dirs[0] } throw "Could not resolve a '$RuntimeIdentifier' publish directory. Run DotNetPublish with runtimeIdentifiers (or set publishDir)." } function Resolve-DesktopExecutablePath { param( [Parameter(Mandatory = $true)] [string]$PublishDirectory, [Parameter(Mandatory = $false)] [string]$ExecutableName, [Parameter(Mandatory = $false)] [switch]$Windows ) if (-not [string]::IsNullOrWhiteSpace($ExecutableName)) { $direct = Join-Path $PublishDirectory $ExecutableName if (Test-Path -LiteralPath $direct -PathType Leaf) { return $direct } $withExe = Join-Path $PublishDirectory ($ExecutableName + '.exe') if ($Windows -and (Test-Path -LiteralPath $withExe -PathType Leaf)) { return $withExe } throw "Executable '$ExecutableName' was not found in: $PublishDirectory" } $candidates = @() if ($Windows) { $candidates = @(Get-ChildItem -LiteralPath $PublishDirectory -File -Filter '*.exe' | Where-Object { $_.Name -notin @('createdump.exe') }) } else { $candidates = @( Get-ChildItem -LiteralPath $PublishDirectory -File | Where-Object { [string]::IsNullOrWhiteSpace($_.Extension) } ) } if ($candidates.Count -eq 1) { return $candidates[0].FullName } if ($candidates.Count -gt 1) { throw "Multiple executables found in '$PublishDirectory'. Set executableName." } throw "No executable found in '$PublishDirectory'. Set executableName." } function New-WixPackageXml { param( [Parameter(Mandatory = $true)] [string]$AppName, [Parameter(Mandatory = $true)] [string]$Manufacturer, [Parameter(Mandatory = $true)] [string]$ProductVersion, [Parameter(Mandatory = $true)] [guid]$UpgradeCode, [Parameter(Mandatory = $true)] [string]$PublishDirectory, [Parameter(Mandatory = $true)] [string]$ExecutablePath, [Parameter(Mandatory = $false)] [string]$InstallScope = 'perMachine', [Parameter(Mandatory = $false)] [string]$InstallFolderName, [Parameter(Mandatory = $false)] [string]$Architecture = 'x64', [Parameter(Mandatory = $false)] [string]$IconPath ) $ns = 'http://wixtoolset.org/schemas/v4/wxs' $xml = New-Object System.Xml.XmlDocument $null = $xml.AppendChild($xml.CreateXmlDeclaration('1.0', 'utf-8', $null)) $wix = $xml.CreateElement('Wix', $ns) $null = $xml.AppendChild($wix) $scope = if ($InstallScope -eq 'perMachine') { 'perMachine' } else { 'perUser' } $package = $xml.CreateElement('Package', $ns) $null = $package.SetAttribute('Name', $AppName) $null = $package.SetAttribute('Manufacturer', $Manufacturer) $null = $package.SetAttribute('Version', $ProductVersion) $null = $package.SetAttribute('UpgradeCode', $UpgradeCode.ToString('D')) $null = $package.SetAttribute('Scope', $scope) $null = $wix.AppendChild($package) $major = $xml.CreateElement('MajorUpgrade', $ns) $null = $major.SetAttribute('DowngradeErrorMessage', 'A newer version is already installed.') $null = $package.AppendChild($major) $media = $xml.CreateElement('MediaTemplate', $ns) $null = $media.SetAttribute('EmbedCab', 'yes') $null = $package.AppendChild($media) if (-not [string]::IsNullOrWhiteSpace($IconPath) -and (Test-Path -LiteralPath $IconPath -PathType Leaf)) { $icon = $xml.CreateElement('Icon', $ns) $null = $icon.SetAttribute('Id', 'AppIcon') $null = $icon.SetAttribute('SourceFile', $IconPath) $null = $package.AppendChild($icon) $prop = $xml.CreateElement('Property', $ns) $null = $prop.SetAttribute('Id', 'ARPPRODUCTICON') $null = $prop.SetAttribute('Value', 'AppIcon') $null = $package.AppendChild($prop) } $desktopProp = $xml.CreateElement('Property', $ns) $null = $desktopProp.SetAttribute('Id', 'INSTALLDESKTOPSHORTCUT') $null = $desktopProp.SetAttribute('Value', '0') $null = $package.AppendChild($desktopProp) $productFolder = Get-DesktopInstallFolderName ` -AppName $AppName ` -Manufacturer $Manufacturer ` -InstallFolderName $InstallFolderName $stdLocal = $xml.CreateElement('StandardDirectory', $ns) $rootFolderId = if ($scope -eq 'perMachine') { Get-WixPerMachineProgramFilesFolderId -Architecture $Architecture } else { 'LocalAppDataFolder' } $null = $stdLocal.SetAttribute('Id', $rootFolderId) $null = $package.AppendChild($stdLocal) $installParent = $stdLocal if (-not [string]::IsNullOrWhiteSpace($Manufacturer)) { $mfrDir = $xml.CreateElement('Directory', $ns) $null = $mfrDir.SetAttribute('Id', 'ManufacturerFolder') $null = $mfrDir.SetAttribute('Name', $Manufacturer) $null = $stdLocal.AppendChild($mfrDir) $installParent = $mfrDir } $installFolder = $xml.CreateElement('Directory', $ns) $null = $installFolder.SetAttribute('Id', 'INSTALLFOLDER') $null = $installFolder.SetAttribute('Name', $productFolder) $null = $installParent.AppendChild($installFolder) $stdMenu = $xml.CreateElement('StandardDirectory', $ns) $null = $stdMenu.SetAttribute('Id', 'ProgramMenuFolder') $null = $package.AppendChild($stdMenu) $menuDir = $xml.CreateElement('Directory', $ns) $null = $menuDir.SetAttribute('Id', 'AppShortcutFolder') $menuName = if ([string]::IsNullOrWhiteSpace($Manufacturer)) { $productFolder } else { $Manufacturer } $null = $menuDir.SetAttribute('Name', $menuName) $null = $stdMenu.AppendChild($menuDir) $stdDesktop = $xml.CreateElement('StandardDirectory', $ns) $null = $stdDesktop.SetAttribute('Id', 'DesktopFolder') $null = $package.AppendChild($stdDesktop) $dirNodes = @{ '' = $installFolder } $componentIds = [System.Collections.Generic.List[string]]::new() $publishRoot = [System.IO.Path]::GetFullPath($PublishDirectory) $files = @(Get-ChildItem -LiteralPath $publishRoot -Recurse -File) if ($files.Count -eq 0) { throw "WindowsInstaller found no files to harvest in: $publishRoot" } foreach ($file in $files) { $relative = $file.FullName.Substring($publishRoot.Length).TrimStart('\', '/') $relativeDir = [System.IO.Path]::GetDirectoryName($relative) if ($null -eq $relativeDir) { $relativeDir = '' } $parent = $installFolder if (-not [string]::IsNullOrWhiteSpace($relativeDir)) { $parts = $relativeDir.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries) $walk = '' foreach ($part in $parts) { $walk = if ($walk) { Join-Path $walk $part } else { $part } if (-not $dirNodes.ContainsKey($walk)) { $child = $xml.CreateElement('Directory', $ns) $null = $child.SetAttribute('Id', (ConvertTo-WixIdentifier -Value $walk -Prefix 'd')) $null = $child.SetAttribute('Name', $part) $null = $parent.AppendChild($child) $dirNodes[$walk] = $child } $parent = $dirNodes[$walk] } } $component = $xml.CreateElement('Component', $ns) $componentId = ConvertTo-WixIdentifier -Value $relative -Prefix 'c' $null = $component.SetAttribute('Id', $componentId) $null = $component.SetAttribute('Guid', '*') $componentIds.Add($componentId) $fileNode = $xml.CreateElement('File', $ns) $null = $fileNode.SetAttribute('Id', (ConvertTo-WixIdentifier -Value $relative -Prefix 'f')) $null = $fileNode.SetAttribute('Source', $file.FullName) $null = $fileNode.SetAttribute('Name', $file.Name) $null = $fileNode.SetAttribute('KeyPath', 'yes') $null = $component.AppendChild($fileNode) $null = $parent.AppendChild($component) } $exeName = [System.IO.Path]::GetFileName($ExecutablePath) $shortcut = $xml.CreateElement('Component', $ns) $null = $shortcut.SetAttribute('Id', 'StartMenuShortcut') $null = $shortcut.SetAttribute('Directory', 'AppShortcutFolder') $null = $shortcut.SetAttribute('Guid', '*') $shortcutNode = $xml.CreateElement('Shortcut', $ns) $null = $shortcutNode.SetAttribute('Id', 'AppStartMenuShortcut') $null = $shortcutNode.SetAttribute('Name', $productFolder) $null = $shortcutNode.SetAttribute('Target', "[INSTALLFOLDER]$exeName") $null = $shortcutNode.SetAttribute('WorkingDirectory', 'INSTALLFOLDER') if (-not [string]::IsNullOrWhiteSpace($IconPath) -and (Test-Path -LiteralPath $IconPath -PathType Leaf)) { $null = $shortcutNode.SetAttribute('Icon', 'AppIcon') } $null = $shortcut.AppendChild($shortcutNode) $remove = $xml.CreateElement('RemoveFolder', $ns) $null = $remove.SetAttribute('Id', 'AppShortcutFolder') $null = $remove.SetAttribute('On', 'uninstall') $null = $shortcut.AppendChild($remove) $reg = $xml.CreateElement('RegistryValue', $ns) $null = $reg.SetAttribute('Root', 'HKCU') $null = $reg.SetAttribute('Key', "Software\$Manufacturer\$AppName") $null = $reg.SetAttribute('Name', 'installed') $null = $reg.SetAttribute('Type', 'integer') $null = $reg.SetAttribute('Value', '1') $null = $reg.SetAttribute('KeyPath', 'yes') $null = $shortcut.AppendChild($reg) $null = $package.AppendChild($shortcut) $desktop = $xml.CreateElement('Component', $ns) $null = $desktop.SetAttribute('Id', 'DesktopShortcut') $null = $desktop.SetAttribute('Directory', 'DesktopFolder') $null = $desktop.SetAttribute('Guid', '*') $null = $desktop.SetAttribute('Condition', 'INSTALLDESKTOPSHORTCUT = 1') $desktopShortcut = $xml.CreateElement('Shortcut', $ns) $null = $desktopShortcut.SetAttribute('Id', 'AppDesktopShortcut') $null = $desktopShortcut.SetAttribute('Name', $productFolder) $null = $desktopShortcut.SetAttribute('Target', "[INSTALLFOLDER]$exeName") $null = $desktopShortcut.SetAttribute('WorkingDirectory', 'INSTALLFOLDER') if (-not [string]::IsNullOrWhiteSpace($IconPath) -and (Test-Path -LiteralPath $IconPath -PathType Leaf)) { $null = $desktopShortcut.SetAttribute('Icon', 'AppIcon') } $null = $desktop.AppendChild($desktopShortcut) $desktopReg = $xml.CreateElement('RegistryValue', $ns) $null = $desktopReg.SetAttribute('Root', 'HKCU') $null = $desktopReg.SetAttribute('Key', "Software\$Manufacturer\$AppName") $null = $desktopReg.SetAttribute('Name', 'desktop') $null = $desktopReg.SetAttribute('Type', 'integer') $null = $desktopReg.SetAttribute('Value', '1') $null = $desktopReg.SetAttribute('KeyPath', 'yes') $null = $desktop.AppendChild($desktopReg) $null = $package.AppendChild($desktop) $feature = $xml.CreateElement('Feature', $ns) $null = $feature.SetAttribute('Id', 'Main') $null = $feature.SetAttribute('Title', $AppName) $null = $feature.SetAttribute('Level', '1') $refShortcut = $xml.CreateElement('ComponentRef', $ns) $null = $refShortcut.SetAttribute('Id', 'StartMenuShortcut') $null = $feature.AppendChild($refShortcut) $refDesktop = $xml.CreateElement('ComponentRef', $ns) $null = $refDesktop.SetAttribute('Id', 'DesktopShortcut') $null = $feature.AppendChild($refDesktop) foreach ($id in $componentIds) { $cref = $xml.CreateElement('ComponentRef', $ns) $null = $cref.SetAttribute('Id', $id) $null = $feature.AppendChild($cref) } $null = $package.AppendChild($feature) return $xml } function Get-DefaultFlatpakFinishArgs { return @( '--share=ipc', '--share=network', '--socket=x11', '--socket=wayland', '--device=dri', '--socket=pulseaudio', '--filesystem=home' ) } function New-FlatpakDesktopEntry { param( [Parameter(Mandatory = $true)] [string]$AppId, [Parameter(Mandatory = $true)] [string]$AppName, [Parameter(Mandatory = $true)] [string]$Command, [Parameter(Mandatory = $false)] [string]$Categories = 'Utility;' ) $lines = @( '[Desktop Entry]', 'Type=Application', "Name=$AppName", "Exec=$Command", "Icon=$AppId", "StartupWMClass=$AppId", 'StartupNotify=true', 'Terminal=false', "Categories=$Categories" ) return (($lines -join "`n") + "`n") } function Copy-FlatpakHicolorIcons { param( [Parameter(Mandatory = $true)] [string]$AppId, [Parameter(Mandatory = $true)] [string]$HicolorRoot, [Parameter(Mandatory = $false)] [string]$IconPath, [Parameter(Mandatory = $false)] [string]$SvgIconPath ) $pngSource = $null $svgSource = $null if (-not [string]::IsNullOrWhiteSpace($IconPath) -and (Test-Path -LiteralPath $IconPath -PathType Leaf)) { $ext = [System.IO.Path]::GetExtension($IconPath) if ($ext -ieq '.png') { $pngSource = $IconPath } elseif ($ext -ieq '.svg') { $svgSource = $IconPath } } if (-not [string]::IsNullOrWhiteSpace($SvgIconPath) -and (Test-Path -LiteralPath $SvgIconPath -PathType Leaf)) { $svgSource = $SvgIconPath } if ([string]::IsNullOrWhiteSpace($svgSource) -and -not [string]::IsNullOrWhiteSpace($pngSource)) { $siblingSvg = [System.IO.Path]::ChangeExtension($pngSource, '.svg') if (Test-Path -LiteralPath $siblingSvg -PathType Leaf) { $svgSource = $siblingSvg } } if ([string]::IsNullOrWhiteSpace($pngSource) -and -not [string]::IsNullOrWhiteSpace($svgSource)) { $siblingPng = [System.IO.Path]::ChangeExtension($svgSource, '.png') if (Test-Path -LiteralPath $siblingPng -PathType Leaf) { $pngSource = $siblingPng } } $installed = $false if (-not [string]::IsNullOrWhiteSpace($pngSource)) { foreach ($size in @('48x48', '64x64', '128x128', '256x256')) { $pngDir = Join-Path $HicolorRoot "$size\apps" New-Item -ItemType Directory -Path $pngDir -Force | Out-Null Copy-Item -LiteralPath $pngSource -Destination (Join-Path $pngDir "$AppId.png") -Force } $installed = $true } if (-not [string]::IsNullOrWhiteSpace($svgSource)) { $svgDir = Join-Path $HicolorRoot 'scalable\apps' New-Item -ItemType Directory -Path $svgDir -Force | Out-Null Copy-Item -LiteralPath $svgSource -Destination (Join-Path $svgDir "$AppId.svg") -Force $installed = $true } return $installed } function ConvertTo-FlatpakMetainfoDescriptionXml { param( [Parameter(Mandatory = $true)] [string]$AppName, [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Summary, [Parameter(Mandatory = $false)] [AllowEmptyString()] [string]$Description ) $text = $Description if ([string]::IsNullOrWhiteSpace($text)) { $text = "$AppName is a desktop application. $Summary".Trim() } $paragraphs = [System.Collections.Generic.List[string]]::new() foreach ($chunk in @($text -split '(\r?\n){2,}')) { $trimmed = $chunk.Trim() if ([string]::IsNullOrWhiteSpace($trimmed)) { continue } $paragraphs.Add('
' + [System.Security.SecurityElement]::Escape($trimmed) + '
') } if ($paragraphs.Count -eq 0) { $paragraphs.Add('' + [System.Security.SecurityElement]::Escape("$AppName is a desktop application.") + '
') } return ($paragraphs -join "`n") } function New-FlatpakMetainfoXml { param( [Parameter(Mandatory = $true)] [string]$AppId, [Parameter(Mandatory = $true)] [string]$AppName, [Parameter(Mandatory = $true)] [string]$Summary, [Parameter(Mandatory = $false)] [AllowEmptyString()] [string]$Description = '', [Parameter(Mandatory = $false)] [string]$ProjectLicense = 'MIT', [Parameter(Mandatory = $false)] [string]$MetadataLicense = 'CC0-1.0', [Parameter(Mandatory = $false)] [string]$Version = '' ) $escapedName = [System.Security.SecurityElement]::Escape($AppName) $escapedSummary = [System.Security.SecurityElement]::Escape($Summary) $descriptionXml = ConvertTo-FlatpakMetainfoDescriptionXml -AppName $AppName -Summary $Summary -Description $Description $xml = @"