Includes/PwSh.Fw.Build.Debian.psm1

# @var ConvertIsInstalled
# @brief $true if 'convert' from Image Magick package is installed
$Script:ConvertIsInstalled = Get-Command -Name "convert" -ErrorAction SilentlyContinue
$Script:ConvertNotInstalledMessage = "'convert' command not found. Automatic icon handling is disabled. Please read the FAQ if you need it."
$Script:Png2icnsIsInstalled = Get-Command -Name "png2icns" -ErrorAction SilentlyContinue
$Script:Png2icnsNotInstalledMessage = "'png2icns' command not found. Automatic icon handling is disabled. Please read the FAQ if you need it."
$Script:wslIsInstalled = Get-Command -Name "wsl.exe" -ErrorAction SilentlyContinue
$Script:wslNotInstalledMessage = "'wsl.exe' command not found. Building 'deb' package on Windows is disabled. Please read the FAQ if you need it."

<#
.SYNOPSIS
Convert a generic hashtable into useful ControlFields metadata
 
.DESCRIPTION
Extract from an object useful properties to use as DEBIAN/control fields
 
.PARAMETER Metadata
object filled with various properties
 
.EXAMPLE
$project = gc ./project.yml -raw | ConvertFrom-yaml
$project | ConvertTo-DebianCONTROLFileSettings
 
This example will convert a project definition file into a useable hashtable to inject into Out-DebianCONTROLFile
 
.NOTES
General notes
 
.LINK
https://www.debian.org/doc/debian-policy/ch-relationships.html
#>

function ConvertTo-DebianCONTROLFileSettings {
    [CmdletBinding()][OutputType([hashtable])]Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][object]$Metadata
    )
    Begin {
    }

    Process {
        $ControlFields = @{}
        if ($Metadata) {
            if ($Metadata.Name) { $ControlFields.Package = $Metadata.Name }
            if ($Metadata.Package) { $ControlFields.Package = $Metadata.Package }
            if ($Metadata.Version) { $ControlFields.Version = [string]$Metadata.Version }
            if ($Metadata.Section) { $ControlFields.Section = $Metadata.Section }
            if ($Metadata.Priority) { $ControlFields.Priority = $Metadata.Priority }
            if ($Metadata.ProcessorArchitecture) { $ControlFields.Architecture = $Metadata.ProcessorArchitecture }
            if ($Metadata.Arch) { $ControlFields.Architecture = $Metadata.Arch }
            if ($Metadata.Architecture) { $ControlFields.Architecture = $Metadata.Architecture }
            if ($Metadata.Essential) { $ControlFields.Essential = $Metadata.Essential }
            if ($Metadata.Depends) { $ControlFields.Depends = $Metadata.Depends -join "," }
            if ($Metadata.'Pre-Depends') { $ControlFields.'Pre-Depends' = $Metadata.'Pre-Depends' }
            if ($Metadata.Recommends) { $ControlFields.Recommends = $Metadata.Recommends }
            if ($Metadata.Suggests) { $ControlFields.Suggests = $Metadata.Suggests }
            if ($Metadata.Breaks) { $ControlFields.Breaks = $Metadata.Breaks }
            if ($Metadata.Conflicts) { $ControlFields.Conflicts = $Metadata.Conflicts }
            if ($Metadata.Provides) { $ControlFields.Provides = $Metadata.Provides }
            if ($Metadata.Replaces) { $ControlFields.Replaces = $Metadata.Replaces }
            if ($Metadata.Enhances) { $ControlFields.Enhances = $Metadata.Enhances }
            if ($Metadata.Size) { $ControlFields.'Installed-Size' = $Metadata.Size }
            if ($Metadata.'Installed-Size') { $ControlFields.'Installed-Size' = $Metadata.'Installed-Size' }
            if ($Metadata.Authors) { $ControlFields.Maintainer = $Metadata.Authors[0] }
            if ($Metadata.Author) { $ControlFields.Maintainer = $Metadata.Author }
            if ($Metadata.owner) { $ControlFields.Maintainer = $Metadata.owner }
            if ($Metadata.Maintainer) { $ControlFields.Maintainer = $Metadata.Maintainer }
            if ($Metadata.Description) { $ControlFields.Description = $Metadata.Description }
            if ($Metadata.ProjectUri) { $ControlFields.Homepage = $Metadata.ProjectUri }
            if ($Metadata.ProjectUrl) { $ControlFields.Homepage = $Metadata.ProjectUrl }
            # if we are in a prerelease :
            # - add '-preview' to name to differentiate 'preview' branches and 'release' branches
            # - concat Version and Build to get a 4-dotted version number for NSIS
            if ($Metadata.Prerelease) {
                $ControlFields.Package = "$($ControlFields.Package)-preview"
                $ControlFields.DisplayName = "$($ControlFields.DisplayName) (preview)"
                $ControlFields.Version = "$($Metadata.Version)~pre$($Metadata.Build)"
                $ControlFields.PreRelease = $Metadata.Prerelease
            }
        }

        return $ControlFields
    }

    End {
    }
}

<#
.SYNOPSIS
Write a debian control file
 
.DESCRIPTION
Output a fully-formatted control file based on build configuration.
 
.PARAMETER Metadata
The project's properties. Properties have to be filtered with ConvertTo-DebianCONTROLFileSettings first
 
.PARAMETER Destination
Directory in which to put the resulting control file
 
.PARAMETER PassThru
Use this switch to output the control content instead of its path
 
.OUTPUTS
Full path to control file
 
.OUTPUTS
control file content
 
.EXAMPLE
$project = gc ./project.yml | ConvertFrom-Yaml | ConvertTo-PSCustomObject
$project | Out-DebianCONTROLFile -Destination /tmp/DEBIAN/
 
This example use a project.yml file filled with "key: pair" values, convert it to an object, an use its properties to output a well-formatted debian control file.
The output of this example is "/tmp/DEBIAN/control"
 
.NOTES
    2020.03 - new version
 
.LINK
https://www.debian.org/doc/debian-policy/ch-controlfields.html
 
#>


function Out-DebianCONTROLFile {
    [CmdletBinding()][OutputType([String])]Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][object]$Metadata,
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$Destination,
        [switch]$PassThru
    )
    Begin {
        Write-EnterFunction
        if ($Destination) {
            if (!(dirExist($Destination))) { $null = New-Item $Destination -Force -ItemType Directory}
        }
    }

    Process {
        $ControlFields = ConvertTo-DebianCONTROLFileSettings -Metadata $Metadata

        if ($Destination) { $ControlFields | ConvertTo-Yaml | Out-File -Path "$Destination/control" -Encoding utf8 -NoNewline }

        if ($PassThru) {
            return $ControlFields
        } else {
            return (Resolve-Path -Path "$Destination/control").Path
        }
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Build the project to a debian package
 
.DESCRIPTION
Build the project to a full featured Debian package.
 
.EXAMPLE
New-DebianBuild -Project (Get-Project)
 
.NOTES
The resulting package will be named after project's data :
`$name-$version-$arch.deb`
 
#>

function New-DebianBuild {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low', DefaultParameterSetName = 'PROJECT')]
    [OutputType([Boolean], [String])]
    Param (
        # The project object as returned by Get-Project
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][hashtable]$Project,

        # An optional DEBIAN folder where debian package scripts are stored. Scripts can be 'pre', 'post', whatever described at @url https://www.debian.org/doc/debian-policy/ch-maintainerscripts.html
        [Alias('Configuration')]
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$DebianFolder = "./build/DEBIAN",

        # The source folder of your project. Files and directory structure will be kept as-is
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$Source = "./src",

        # Destination folder to create resulting package
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$Destination = "./releases",

        # Override output package filename.
        # It defaults to projectName-Version-Arch.deb
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$OutputFileName,

        # Force/override/overwrite things
        [switch]$Force
    )
    Begin {
        Write-EnterFunction
        if (!(Test-DirExist $Destination)) {
            $null = New-Item -Path $Destination -ItemType Directory -Confirm:$false -Force:$Force
        }
        $DebianFolder = ($DebianFolder | Resolve-Path).path
    }

    Process {
        if (Test-DirExist $DebianFolder) {
            # copy DEBIAN folder taking care of mustache template files
            Copy-Item $DebianFolder -Recurse $Source -Force:$Force -Exclude "*.mustache"
            Get-ChildItem $DebianFolder -Recurse -Filter "*.mustache" | ForEach-Object {
                $item = $_
                $destFilePath = $item.DirectoryName -replace "$DebianFolder", "$Source/DEBIAN"
                $destFileName = "$destFilePath/$($item.Basename)"
                ConvertFrom-MustacheTemplate -Template (Get-Content -Raw $item.fullname) -Values $Project | Out-File $destFileName
                # these are executable scripts (postinst / postrm) so ensure they are executable (Out-File create only regular files)
                $mode = (stat $item.fullname --printf %a)
                $null = Execute-Command -exe chmod -args "$mode $destFileName"
            }
        }
        # copy icon to proper location
        # Copy-Item "$($Project.Root)/$($Project.IconFile)" -Destination $Source -Force -Confirm:$false
        if ($Project.IconFile) {
            $null = ConvertTo-LinuxIcons -Image $Project.IconFile -Destination "$Source/usr/share/icons/hicolor"
        }
        $project.size = (Get-ChildItem $Source -Recurse | Measure-Object -Property Length -sum).Sum | Convert-Size -From bytes -To KB
        $ControlFields = $project | Out-DebianCONTROLFile -Destination $Source/DEBIAN -PassThru
        if ([string]::IsNullOrEmpty($OutputFileName)) {
            $Filename = "$($project.name)-$($project.Version)$($project.PreRelease)-$($ControlFields.architecture).deb"
        } else {
            $Filename = $OutputFileName
        }
        if ($PSCmdlet.ShouldProcess("$Destination/$Filename", "Create debian package")) {
            if ($IsWindows) {
                if ($Script:wslIsInstalled) {
                    $SourceWSL = Execute-Command -exe "wsl.exe" -args "wslpath $($Source.replace("\","/"))" -PassThru
                    $DestinationWSL = Execute-Command -exe "wsl.exe" -args "wslpath $($Destination.replace("\","/"))" -PassThru
                    # fix permissions
                    $rc = Execute-Command -exe "wsl.exe" -args "chmod -R 755 $DestinationWSL" -AsBool
                    $rc = Execute-Command -exe "wsl.exe" -args "fakeroot dpkg -b $SourceWSL $DestinationWSL/$Filename" -AsBool
                } else {
                    Write-Error $Script:wslNotInstalledMessage
                }
            }
            if ($IsLinux) {
                if (!(Get-Command -Name fakeroot)) { Write-Fatal "fakeroot command not found in system. Try to install the fakeroot package." }
                $rc = Execute-Command -exe fakeroot -args "dpkg -b $Source $Destination/$Filename" -AsBool
            }
            if ($IsMacOS) {
                Write-Error "macOS can not build debian package yet. Please file an issue if this is important for you."
            }
            Write-Devel "rc = $rc"
            if (Test-FileExist "$Destination/$Filename") {
                $value = (Resolve-Path "$Destination/$Filename").Path
            } else {
                $value = $false
            }
        } else {
            $value = "$Destination/$Filename"
        }

        Write-Devel "value = $value"
        return $value
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Create a linux desktop shortcut
 
.DESCRIPTION
Create a linux desktop shortcut.
Use it before the building process to create static desktop entry file.
Use it in the building process to create dynamic desktop entry file
 
.PARAMETER Project
Metadata of the project
 
.PARAMETER Destination
Destination folder
 
.PARAMETER Filename
Destination filename
 
.PARAMETER Exec
Fill shortcut's Exec key
 
.PARAMETER Icon
Attach Icon to shortcut. File must be present on target system. Icon must be an absolute pathname on the target.
 
.PARAMETER Terminal
Set to True if the shortcut must launch in a terminal
 
.PARAMETER Force
Force things
 
.EXAMPLE
New-DesktopFile -Project $project -Destination /usr/share/applications -filename project.desktop
 
.NOTES
help @url https://developer.gnome.org/integration-guide/stable/desktop-files.html.en
Desktop Entry Specifications https://specifications.freedesktop.org/desktop-entry-spec/latest/index.html
 
#>

function New-DesktopFile {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][hashtable]$Project,
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$Destination = "./usr/share/applications",
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$Filename,
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$Exec,
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$Icon,
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][switch]$Terminal,
        [switch]$Force
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if ($PSCmdlet.ShouldProcess("$Destination/$Filename", "Create linux desktop shortcut")) {
            if (!(Test-DirExist $Destination)) { $null = New-Item -Path "$Destination/$Filename" -ItemType Directory -Force:$Force }
            if ([string]::IsNullOrEmpty($Filename)) {
                $Filename = "$($Project.Name).desktop"
            }
            if ([string]::IsNullOrEmpty($Project.DisplayName)) {
                Write-Warning "DisplayName is not defined. Using Name instead. Please consider defining a DisplayName in project's yaml file."
                $Name = $Project.Name
            } else {
                $Name = $Project.DisplayName
            }

            @"
[Desktop Entry]
Type=Application
Encoding=UTF-8
Name=$Name
Comment=$($Project.Description)
Exec=$Exec
Icon=$Icon
Terminal=$Terminal
"@
 | Set-Content "$Destination/$Filename" -Encoding utf8NoBOM -Force:$Force

            return (Resolve-Path "$Destination/$Filename").Path
        } else {
            return "$Destination/$Filename"
        }
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Convert an image to different sizes to fit on linux desktop managers
 
.DESCRIPTION
Convert an image to multiple sizes at once for linux desktop
 
.PARAMETER Image
Full path to an image file
 
.PARAMETER Destination
Destination folder
 
.PARAMETER Filename
Optional. New filename of the image
 
.PARAMETER OutputStyle
Select output style structure :
- Flat : output images right under $Destination with files renamed after the size -> %filename-%size.%ext
- Scattered : create a subdirectory for each size under $Destination -> %size/%filename.%ext
Defaults to 'Scattered'
 
.EXAMPLE
ConvertTo-LinuxIcons -Image /path/to/favicon.png -Destination /usr/share/icons/hicolor
 
.NOTES
This function do not convert image into another format. It just convert size.
#>

function ConvertTo-LinuxIcons {
    [CmdletBinding()]
    [OutputType([string[]], [Boolean])]
    Param (
        [Parameter(Mandatory = $true, ValueFromPipeLine = $true)][string]$Image,
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$Destination,
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$Filename,
        [ValidateSet('Flat', 'Scattered')]
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$OutputStyle = 'Scattered'
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (!(Test-FileExist $Image)) {
            eerror "Image '$Image' not found."
            $rc = $false
        }
        # edebug "Image $Image found."
        $basename = (Get-Item $Image).BaseName
        $ext = (Get-Item $Image).Extension
        # edebug "Process image $basename$ext"
        if ([string]::IsNullOrEmpty($Filename)) {
            $Filename = $basename
        }
        if ([string]::IsNullOrEmpty($Destination)) {
            $Destination = (Get-Item $Image).DirectoryName
        }

        if ($Script:ConvertIsInstalled) {
            $IconSize= @('16x16', '32x32', '48x48', '64x64', '128x128', '256x256', '512x512')
            $files = @()
            foreach($s in $IconSize) {
                switch ($OutputStyle) {
                    'Flat' {
                        $null = New-Item -Path $Destination -Force -ItemType Directory -ErrorAction SilentlyContinue
                        $null = Execute-Command -exe "convert" -args "$Image -resize $s $Destination/$Filename-$s$ext"
                        $files += "$Destination/$Filename-$s$ext"
                    }
                    'Scattered' {
                        $null = New-Item -Path $Destination/$s -Force -ItemType Directory -ErrorAction SilentlyContinue
                        $null = Execute-Command -exe "convert" -args "$Image -resize $s $Destination/$s/$Filename$ext"
                        $files += "$Destination/$s/$Filename$ext"
                    }
                }
            }
            $rc = $files
        } else {
            eerror $Script:ConvertNotInstalledMessage
            $rc = $Image
        }

        return $rc
    }

    End {
        Write-LeaveFunction
    }
}