Includes/PwSh.Fw.Build.Windows.NSIS.psm1


if (!($Script:PWSHFW_BUILDHELPERS_DIR)) {
    $Script:PWSHFW_BUILDHELPERS_DIR = (Resolve-Path $PSScriptRoot/../).Path
}

# @var ConvertIsInstalled
# @brief $true if 'convert' from Image Magick package is installed
# $Script:ConvertIsInstalled = Get-Command -Name "convert" -ErrorAction SilentlyContinue
# on Windows, convert.exe is a Microsoft tool to convert partition filesystem. So 'Get-Command convert.exe' will always return something... we don't want
# instead we'll search for a convert.exe tool installed in ProgramFiles
# @(()) means force use of an array, even if only one is found. [-1] means take the last.
$Script:ConvertExe = @((Get-ChildItem -path "$env:ProgramFiles" -name "convert.exe" -Recurse | ForEach-Object { (Get-Item "$env:ProgramFiles\$_").FullName }))[-1]
$Script:ConvertIsInstalled = Test-FileExist "$Script:ConvertExe"
$Script:ConvertNotInstalledMessage = "'convert.exe' 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."

<#
.SYNOPSIS
Convert a generic hashtable into useful NSIS Settings metadata
 
.DESCRIPTION
Extract from an object useful properties to use as NSIS constants
 
.PARAMETER Metadata
object filled with various properties
 
.EXAMPLE
$project = gc ./project.yml -raw | convertfrom-yaml
$project | ConvertTo-NSISSettings
 
This example will convert a project definition file into a useable hashtable to inject into Out-NSISSetupFile
 
.NOTES
General notes
#>

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

    Process {
        $NSISSettings = @{}
        if ($Metadata) {
            if ($Metadata.name) { $NSISSettings.Name = $Metadata.name }
            # override package name with optional PackageName attribute
            if ($Metadata.PackageName) { $NSISSettings.Package = $Metadata.PackageName }
            if ($Metadata.DisplayName) { $NSISSettings.DisplayName = $Metadata.DisplayName }
            if ($Metadata.Codename) { $NSISSettings.CodeName = $Metadata.Codename }
            if ($Metadata.Version) { $NSISSettings.Version = $Metadata.Version }
            if ($Metadata.GUID) { $NSISSettings.GUID = $Metadata.GUID }
            if ($Metadata.Authors) { $NSISSettings.Author = $Metadata.Authors[0] }
            if ($Metadata.Author) { $NSISSettings.Author = $Metadata.Author }
            if ($Metadata.owner) { $NSISSettings.Author = $Metadata.owner }
            if ($Metadata.CompanyName) { $NSISSettings.CompanyName = $Metadata.CompanyName }
            if ($Metadata.Copyright) { $NSISSettings.Copyright = $Metadata.Copyright }
            if ($Metadata.Description) { $NSISSettings.Description = $Metadata.Description }
            if ($Metadata.ProcessorArchitecture) { $NSISSettings.Architecture = $Metadata.ProcessorArchitecture }
            if ($Metadata.Architecture) { $NSISSettings.Architecture = $Metadata.Architecture }
            if ($Metadata.Arch) { $NSISSettings.Architecture = $Metadata.Arch }
            if ($Metadata.ProjectUri) { $NSISSettings.ProjectUri = $Metadata.ProjectUri }
            if ($Metadata.ProjectUrl) { $NSISSettings.ProjectUri = $Metadata.ProjectUrl }
            if ($Metadata.LicenseFile) { $NSISSettings.LicenseFile = (Resolve-Path $Metadata.LicenseFile -Relative) }
            if ($Metadata.IconFile) {
                if (Test-FileExist $Metadata.IconFile) {
                    $NSISSettings.IconFile = (Resolve-Path $Metadata.IconFile -Relative)
                } else {
                    Write-Warning "Icon file '$($Metadata.IconFile) not found. Ignore icon setting."
                    $Metadata.IconFile = $null
                }
            }
            # if ([string]::IsNullOrEmpty($Metadata.IconFile)) {
            # # makensis seems to not like mess with bask-slashes and forward-slashes
            # # $NSISSettings.IconFile = "$($Script:PWSHFW_BUILDHELPERS_DIR)/Assets/application.png"
            # $NSISSettings.IconFile = (Resolve-Path "$($Script:PWSHFW_BUILDHELPERS_DIR)/Assets/application.png").Path
            # }
            if ($Metadata.IconBasename) { $NSISSettings.IconBasename = $Metadata.IconBasename }
            if ($Metadata.Namespace) { $NSISSettings.Namespace = $Metadata.Namespace }
            # $NSISSettings.DefaultInstallDir = "$($Metadata.Namespace)\$($NSISSettings.Name)"
            # } else {
            # $NSISSettings.DefaultInstallDir = "$($NSISSettings.Name)"
            # }
            # 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) {
                # already done at the Get-Project level
                # $NSISSettings.name = "$($NSISSettings.name)-preview"
                # $NSISSettings.DisplayName = "$($NSISSettings.DisplayName) (preview)"
                $NSISSettings.Version = "$($Metadata.Version).$($Metadata.Build)"
                $NSISSettings.PreRelease = $Metadata.Prerelease
            }
        }

        return $NSISSettings
    }

    End {
    }
}

<#
.SYNOPSIS
Write a NSIS setup header file
 
.DESCRIPTION
Output a fully-formated NSIS setup header file based on build configuration.
 
.PARAMETER Metadata
The project's properties. Properties have to be filtered with ConvertTo-NSISSettings first
 
.PARAMETER Destination
Directory in which to put the resulting header file. The filename will be named header.nsi
 
.PARAMETER PassThru
Use this switch to output the content of the resulting file instead of its path
 
.OUTPUTS
Full path to header file
 
.OUTPUTS
header file content
 
.EXAMPLE
$project = gc ./project.yml | ConvertFrom-Yaml | ConvertTo-PSCustomObject
$project | Out-NSISHeaderFile -Destination ./build/windows/
 
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-formated NSIS header file.
The output of this example is "./build/windows/header.nsi"
 
#>

function Out-NSISHeaderFile {
    [CmdletBinding()]Param (
        [Parameter(Mandatory = $true,ValueFromPipeLine = $true)][hashtable]$Metadata,
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$Destination,
        [switch]$PassThru
    )
    Begin {
        if (!(Test-DirExist $Destination)) {
            $null = New-Item -Path $Destination -ItemType Directory
        }
        $Filename = "$Destination/header.nsi"
    }

    Process {

        $NSISSettings = ConvertTo-NSISSettings -Metadata $Metadata

        "" | Set-Content $Filename -Encoding utf8
        foreach ($k in $NSISSettings.Keys) {
            "!define $($k.ToUpper()) `"$($NSISSettings.$k)`"" | Out-File -FilePath $Filename -Encoding utf8 -Append
        }

        if ($PassThru) {
            return $NSISSettings
        } else {
            return (Resolve-Path -Path "$Filename").Path
        }
    }

    End {
    }
}

<#
.SYNOPSIS
Test if a project is viable to build
 
.DESCRIPTION
Before launching a build of your project, you can use this function to forsee if requirements are met.
 
.PARAMETER Project
The project definition object
 
.EXAMPLE
Get-Project | Test-NSISBuild
 
.NOTES
General notes
#>

function Test-NSISBuild {
    [CmdletBinding()][OutputType([String])]Param (
        [Parameter(Mandatory = $true,ValueFromPipeLine = $true)][hashtable]$Project
    )
    Begin {
        # Write-EnterFunction
    }

    Process {
        $rc = $true
        foreach ($f in @('LICENSE', "$($Project.Root)/images/favicon.ico")) {
            if (Test-Path $Path/$f -PathType Leaf) {
                Write-Host -ForegroundColor Green "[+] $((Resolve-Path $Path/$f).Path) exist"
            } else {
                Write-Host -ForegroundColor Red "[-] $Path/$f does not exist"
                $rc = $false
            }
        }

        return $rc
    }

    End {
        # Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Build the project to a setup.exe
 
.DESCRIPTION
Build the project to a NullSoft Installer System setup.exe
 
.EXAMPLE
New-NSISBuild -Project (Get-Project)
 
.NOTES
The resulting setup will be named after project's data :
`$name-$version-$arch.exe` if all data is available
`$name-$version.exe` if $architecture is not available
 
#>

function New-NSISBuild {
    [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 header.nsi file to inject. If it is not used, a default one, based on $Project will be generated
        [Alias('Header')]
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$HeaderNSI,

        #An optional custom setup.nsi file
        [Alias('Configuration')]
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$SetupNSI,

        # An optional WINDOWS folder where windows package scripts are stored
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$WindowsFolder = './build/Windows',

        # 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 setup.exe
        [Parameter(Mandatory = $false, ValueFromPipeLine = $false)][string]$Destination = "./releases",

        # Override output package filename.
        # It defaults to projectName-Version-Arch.exe
        [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
        }
        # Convert every path from \ char to / char to let regular expression work normally
        $WindowsFolder = $WindowsFolder -replace "\\", "/"
        $Source = $Source -replace "\\", "/"
        $Destination = $Destination -replace "\\", "/"
        $OutputFileName = $OutputFileName -replace "\\", "/"
    }

    Process {
        if (Test-DirExist $WindowsFolder) {
            Write-Debug "Copy Windows folder '$WindowsFolder' to '$Source/Windows'"
            # copy WINDOWS folder taking care of mustache template files
            Copy-Item $WindowsFolder -Recurse -Destination "$Source/Windows" -Force:$Force -Exclude "*.mustache"
            Get-ChildItem $WindowsFolder -Recurse -Filter "*.mustache" | ForEach-Object {
                $item = $_
                # $destFilePath = $item.DirectoryName -replace "$WindowsFolder", "$Source/Windows"
                $destFileName = "$Source/Windows/$($item.Basename)"
                Write-Debug "Found mustache template file $($item.fullname)... convert it to '$destFilename'"
                ConvertFrom-MustacheTemplate -Template (Get-Content -Raw $item.fullname) -Values $Project | Out-File $destFileName
            }
        }
        # copy icon to proper location
        # Copy-Item "$($Project.Root)/$($Project.IconFile)" -Destination $Source -Force -Confirm:$false
        if (!$Project.IconFile) {
            $project.IconFile = (Resolve-Path "$($Script:PWSHFW_BUILDHELPERS_DIR)/Assets/application.ico").Path
        }
        if ($Project.IconFile) {
            $project.IconFile = ConvertTo-WindowsIcon -Image "$($Project.IconFile)" -Destination $Source
        }
        $Project.IconBasename = (Get-Item $project.IconFile).Name
        Write-Debug "Use Icon '$($project.IconFile)'"
        # Write-Host "Project = $Project"
        # Write-Host "HeaderNSI = $HeaderNSI"
        # Write-Host "SetupNSI = $SetupNSI"
        # Write-Host "PWSHFW_BUILDHELPERS_DIR = $PWSHFW_BUILDHELPERS_DIR"
        # Write-Host "PWSHFW_BUILDHELPERS_DIR = $Script:PWSHFW_BUILDHELPERS_DIR"
        # move to project's root
        Push-Location $Project.Root
        if (!($HeaderNSI)) {
            $HeaderNSI = Resolve-Path ($Project | Out-NSISHeaderFile -Destination $Project.Root) -Relative
        }
        if (!($SetupNSI)) {
            Copy-Item "$($Script:PWSHFW_BUILDHELPERS_DIR)/Assets/setup.nsi" -Destination $Project.Root
            $SetupNSI = Resolve-Path "$($Project.Root)/setup.nsi" -Relative
        }
        if ([string]::IsNullOrEmpty($OutputFileName)) {
            $Filename = "$($Project.Name)-$($Project.Version)$($Project.PreRelease)"
            if ($Project.Architecture) { $Filename += "-$($Project.Architecture)" }
            $Filename += ".exe"
        } else {
            $Filename = $OutputFileName
        }
        # clean filename from fancy characters
        # @see https://docs.microsoft.com/en-us/dotnet/standard/base-types/character-classes-in-regular-expressions#unicode-category-or-unicode-block-p
        # @see https://docs.microsoft.com/en-us/dotnet/standard/base-types/character-classes-in-regular-expressions#SupportedUnicodeGeneralCategories
        $Filename = $Filename -replace "[^\p{L}\p{Nd}-_.]" | Remove-StringLatinCharacters
        # discover where is nsis.exe
        if (fileExist($(${env:ProgramFiles(x86)} + "\NSIS\makensis.exe"))) { $MAKENSIS = "$(${env:ProgramFiles(x86)})\NSIS\makensis.exe" }
        if (fileExist($(${env:ProgramFiles} + "\NSIS\makensis.exe"))) { $MAKENSIS = "$($env:ProgramFiles)\NSIS\makensis.exe)" }
        if (!$MAKENSIS) {
            Write-Error "makensis.exe not found. Please install it first. Visit https://nsis.sourceforge.io/Main_Page"
            return $false
        }
        # compute debug level
        [uint16]$debugLevel = 0
        if (($INFO) -or ($InformationPreference -eq 'Continue')) { $debugLevel = 1 }
        if (($VERBOSE) -or ($VerbosePreference -eq 'Continue')) { $debugLevel = 2 }
        if (($DEBUG) -or ($DebugPreference -eq 'Continue')) { $debugLevel = 3 }
        if (($DEVEL) -or ($DevelPreference -eq 'Continue')) { $debugLevel = 4 }

        if ($DEVEL) {
            Write-Enter "-- header.nsi ($HeaderNSI) ---------------------------"
            Get-Content $HeaderNSI | ForEach-Object { Write-Devel $_ }
            Write-Leave "-- header.nsi ---------------------------"
            Write-Enter "-- setup.nsi ($SetupNSI) ---------------------------"
            Get-Content $SetupNSI | ForEach-Object { Write-Devel $_ }
            Write-Leave "-- setup.nsi ---------------------------"
        }

        if ($PSCmdlet.ShouldProcess("$Destination/$Filename", "Create NSIS setup file")) {
            # it seems this command-line is too long... so we have to shorten it at the maximum
            # $rc = Execute-Command -exe "$MAKENSIS" "/V$debugLevel /NOCD /INPUTCHARSET UTF8 /OUTPUTCHARSET UTF8 /D'SOURCE=$Source' /D'ROOT=$($Project.Root)' '$HeaderNSI' /X'OutFile $Destination/$Filename' '$SetupNSI'"
            $rc = Execute-Command -exe "$MAKENSIS" "/V$debugLevel /NOCD /INPUTCHARSET UTF8 /OUTPUTCHARSET UTF8 /D'SOURCE=$Source' /D'ROOT=.' /D'USER_BUILD_WINDOWS_FOLDER=$WindowsFolder' '$HeaderNSI' /X'OutFile $Destination/$Filename' '$SetupNSI'"
            Write-Devel "rc = $rc"
            if (Test-FileExist "$Destination/$Filename") {
                $value = (Resolve-Path "$Destination/$Filename").Path
            } else {
                $value = $false
            }
        } else {
            $value = "$Destination/$Filename"
        }

        Pop-Location
        return $value
    }

    End {
        Write-LeaveFunction
    }
}

<#
.SYNOPSIS
Convert an image to a Windows icon
 
.DESCRIPTION
Convert an image to Windows icon `ico` file format.
 
.PARAMETER Image
Full path to an image file
 
.PARAMETER Destination
Destination folder
 
.PARAMETER Filename
Optional. New filename of the image
 
.EXAMPLE
ConvertTo-WindowsIco -Image /path/to/favicon.png
 
.NOTES
This function do not convert image size. It just convert format.
#>

function ConvertTo-WindowsIcon {
    [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
    )
    Begin {
        Write-EnterFunction
    }

    Process {
        if (!(Test-FileExist $Image)) {
            eerror "Image '$Image' not found."
            return $null
        }

        $item = Get-Item $Image
        if ([string]::IsNullOrEmpty($Destination)) {
            $Destination = $item.DirectoryName
        }
        if ($item.Extension -eq '.ico') {
            $null = Copy-Item -Path "$($item.FullName)" -Destination "$Destination/$($item.name)"
            $rc = "$Destination/$($item.name)"
            Write-Debug "Icon found at '$rc'"
        } else {
            if ($Script:ConvertIsInstalled) {
                $basename = $item.BaseName
                # $ext = (Get-Item $Image).Extension
                if ([string]::IsNullOrEmpty($Filename)) {
                    $Filename = $basename
                }
                $null = Execute-Command -exe "$Script:ConvertEXE" -args "$Image -define icon:auto-resize=256,128,64,48,32,16 $Destination/$Filename.ico"
                $rc = "$Destination/$Filename.ico"
                Write-Debug "Icon converted in '$rc'"
            } else {
                Write-Warning $Script:ConvertNotInstalledMessage
                $rc = $Image
            }
        }

        try {
            $rc = (Resolve-Path $rc).Path
        } catch {
            eerror "Path '$rc' not found."
            $rc = $null
        }

        return $rc
    }

    End {
        Write-LeaveFunction
    }
}