Private/Get-FoPluginBundleMetadata.ps1

# Plugin bundle metadata — ps-file-optimizer-aux GitHub Release (plain .zip).
# CI cache keys in .github/workflows/ci.yml must be updated when Sha256 values below change.

$script:FoPluginBundleVersion = '1.1.0'
$script:FoMinimumPluginBundleVersion = '1.1.0'
$script:FoPluginBundleReleaseTag = 'plugins-v1.1.0'
$script:FoPluginBundleFormat = 'zip'

$script:FoPluginBundles = @{
    '64' = @{
        Url      = 'https://github.com/Aurocosh/ps-file-optimizer-aux/releases/download/plugins-v1.1.0/fo-plugins-win-x64-1.1.0.zip'
        FileName = 'fo-plugins-win-x64-1.1.0.zip'
        Sha256   = '64cbf3ab2c8bd2dbd097b77286dd439d19f9c37f9fadbc1420ccefcd968847b2'
        Folder   = 'Plugins64'
    }
    '32' = @{
        Url      = 'https://github.com/Aurocosh/ps-file-optimizer-aux/releases/download/plugins-v1.1.0/fo-plugins-win-x86-1.1.0.zip'
        FileName = 'fo-plugins-win-x86-1.1.0.zip'
        Sha256   = '7ae02081cc2baf7e4caa976bac0798beacf7feb42f3d1e54e1182e18f3960b0c'
        Folder   = 'Plugins32'
    }
}

# Legacy single-bundle aliases (x64)
$script:FoPluginBundleUrl = $script:FoPluginBundles['64'].Url
$script:FoPluginBundleFileName = $script:FoPluginBundles['64'].FileName
$script:FoPluginBundleSha256 = $script:FoPluginBundles['64'].Sha256

function Resolve-FoPluginBundleArchitecture {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [ValidateSet('Auto', '32', '64')]
        [string]$Architecture = 'Auto'
    )

    if ($Architecture -eq 'Auto') {
        if ([Environment]::Is64BitProcess) { return '64' }
        return '32'
    }

    return $Architecture
}

function Resolve-FoPluginArchitectureFromPath {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [string]$PluginPath
    )

    if ($PluginPath) {
        $leaf = Split-Path -Leaf ($PluginPath.TrimEnd('\', '/'))
        if ($leaf -ieq 'Plugins32') { return '32' }
        if ($leaf -ieq 'Plugins64') { return '64' }
    }

    return Resolve-FoPluginBundleArchitecture -Architecture Auto
}

function Get-FoPluginBundleFolderName {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [ValidateSet('32', '64')]
        [Parameter(Mandatory)]
        [string]$Architecture
    )

    if ($Architecture -eq '64') { return 'Plugins64' }
    return 'Plugins32'
}

function Get-FoPluginInstallRootPath {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [string]$ModuleRoot = $script:FoModuleRoot
    )

    if (-not $ModuleRoot) { return $null }
    return [System.IO.Path]::GetFullPath($ModuleRoot)
}

function Get-FoInstalledPluginArchitecturePaths {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [string]$ModuleRoot = $script:FoModuleRoot
    )

    $root = Get-FoPluginInstallRootPath -ModuleRoot $ModuleRoot
    if (-not $root) { return @() }

    $paths = @()
    foreach ($name in @('Plugins64', 'Plugins32', 'plugins')) {
        $candidate = Join-Path $root $name
        if (Test-Path -LiteralPath $candidate) {
            $paths += [PSCustomObject]@{
                Name = $name
                Path = $candidate
            }
        }
    }
    return $paths
}

function Remove-FoInstalledPluginArchitectures {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([object[]])]
    param(
        [string]$ModuleRoot = $script:FoModuleRoot,
        [ValidateSet('32', '64', 'All')]
        [string]$Scope = 'All',
        [string[]]$ExcludeFolderNames = @()
    )

    $root = Get-FoPluginInstallRootPath -ModuleRoot $ModuleRoot
    if (-not $root) { return @() }

    $targets = switch ($Scope) {
        'All' { @('Plugins64', 'Plugins32', 'plugins') }
        '64'  { @('Plugins32', 'plugins') }
        '32'  { @('Plugins64', 'plugins') }
    }

    $removed = @()
    foreach ($name in $targets) {
        if ($name -in $ExcludeFolderNames) { continue }
        $path = Join-Path $root $name
        if (-not (Test-Path -LiteralPath $path)) { continue }
        if ($PSCmdlet.ShouldProcess($path, 'Remove plugin directory')) {
            Remove-Item -LiteralPath $path -Recurse -Force
            $removed += $path
        }
    }
    return $removed
}

function Assert-FoBundleSha256Policy {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Url,
        [string]$Sha256,
        [switch]$AllowUnverifiedDownload,
        [Parameter(Mandatory)]
        [string]$BundleLabel
    )

    if ($Sha256) { return }

    if ($AllowUnverifiedDownload) {
        Write-Warning "Downloading $BundleLabel from '$Url' without SHA256 verification."
        return
    }

    throw "Custom $BundleLabel URL requires SHA256 verification. Supply -ArchiveSha256, set the matching FO_*_BUNDLE_SHA256 environment variable, or pass -AllowUnverifiedDownload to skip verification."
}

function Get-FoPluginBundleSettings {
    [CmdletBinding()]
    param(
        [ValidateSet('Auto', '32', '64')]
        [string]$Architecture = 'Auto',
        [string]$ArchiveUrl,
        [string]$ArchiveSha256,
        [switch]$AllowUnverifiedDownload
    )

    if ($env:FO_PLUGIN_BUNDLE_URL) {
        $fileName = if ($env:FO_PLUGIN_BUNDLE_FILENAME) {
            $env:FO_PLUGIN_BUNDLE_FILENAME
        }
        else {
            [System.IO.Path]::GetFileName(($env:FO_PLUGIN_BUNDLE_URL -split '\?')[0])
        }

        $sha256 = if ($env:FO_PLUGIN_BUNDLE_SHA256) { $env:FO_PLUGIN_BUNDLE_SHA256 } else { $ArchiveSha256 }
        Assert-FoBundleSha256Policy -Url $env:FO_PLUGIN_BUNDLE_URL -Sha256 $sha256 `
            -AllowUnverifiedDownload:$AllowUnverifiedDownload -BundleLabel 'plugin bundle'

        $arch = Resolve-FoPluginBundleArchitecture -Architecture $(if ($env:FO_PLUGIN_BUNDLE_ARCH) { $env:FO_PLUGIN_BUNDLE_ARCH } else { $Architecture })
        return [PSCustomObject]@{
            Architecture = $arch
            Url          = $env:FO_PLUGIN_BUNDLE_URL
            FileName     = $fileName
            Sha256       = $sha256
            Format       = if ($env:FO_PLUGIN_BUNDLE_FORMAT) { $env:FO_PLUGIN_BUNDLE_FORMAT } else { 'zip' }
            Folder       = if ($env:FO_PLUGIN_BUNDLE_FOLDER) { $env:FO_PLUGIN_BUNDLE_FOLDER } else { Get-FoPluginBundleFolderName -Architecture $arch }
        }
    }

    if ($ArchiveUrl) {
        Assert-FoBundleSha256Policy -Url $ArchiveUrl -Sha256 $ArchiveSha256 `
            -AllowUnverifiedDownload:$AllowUnverifiedDownload -BundleLabel 'plugin bundle'

        $arch = Resolve-FoPluginBundleArchitecture -Architecture $Architecture
        return [PSCustomObject]@{
            Architecture = $arch
            Url          = $ArchiveUrl
            FileName     = [System.IO.Path]::GetFileName(($ArchiveUrl -split '\?')[0])
            Sha256       = $ArchiveSha256
            Format       = 'zip'
            Folder       = Get-FoPluginBundleFolderName -Architecture $arch
        }
    }

    $resolvedArch = Resolve-FoPluginBundleArchitecture -Architecture $Architecture
    $entry = $script:FoPluginBundles[$resolvedArch]
    return [PSCustomObject]@{
        Architecture = $resolvedArch
        Url          = $entry.Url
        FileName     = $entry.FileName
        Sha256       = $entry.Sha256
        Format       = $script:FoPluginBundleFormat
        Folder       = $entry.Folder
        BundleVersion = $script:FoPluginBundleVersion
        MinimumVersion = $script:FoMinimumPluginBundleVersion
    }
}

function Test-FoDownloadedFileSha256 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Path,
        [string]$ExpectedSha256,
        [switch]$AllowUnverifiedDownload
    )

    if (-not $ExpectedSha256) {
        if ($AllowUnverifiedDownload) { return }
        throw 'Downloaded bundle SHA256 is required but was not provided.'
    }

    $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
    $expected = $ExpectedSha256.ToLowerInvariant()
    if ($actual -ne $expected) {
        throw "Downloaded bundle SHA256 mismatch. Expected $expected, got $actual."
    }
}

# Executables present in Plugins64 but absent from FO Plugins32 (not required for x86 bundle install).
$script:FoPlugin64OnlyExecutables = @(
    'minify.exe'
    'optivorbis.exe'
    'tinydng-cli.exe'
)

# Ghostscript is chosen at runtime in PDF.ps1 via -Executable $gs (not a string literal).
function Get-FoGhostscriptExecutableName {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [ValidateSet('32', '64')]
        [string]$Architecture = $(if ([Environment]::Is64BitProcess) { '64' } else { '32' })
    )

    if ($Architecture -eq '64') { return 'gswin64c.exe' }
    return 'gswin32c.exe'
}

function Get-FoRequiredPluginExecutables {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [ValidateSet('32', '64')]
        [string]$Architecture = $(if ([Environment]::Is64BitProcess) { '64' } else { '32' })
    )

    $exes = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)

    foreach ($e in (Get-FoPipelineDeclaredExecutables -Architecture $Architecture)) {
        [void]$exes.Add($e)
    }

    if ($Architecture -eq '32') {
        foreach ($only64 in $script:FoPlugin64OnlyExecutables) {
            [void]$exes.Remove($only64)
        }
    }

    return @($exes | Sort-Object)
}

function Test-FoPluginFilePresent {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)]
        [string]$PluginPath,
        [Parameter(Mandatory)]
        [string]$FileName
    )

    if (-not $PluginPath -or -not (Test-Path -LiteralPath $PluginPath)) {
        return $false
    }

    $nameLower = $FileName.ToLowerInvariant()
    return [bool](
        Get-ChildItem -LiteralPath $PluginPath -File -ErrorAction SilentlyContinue |
            Where-Object { $_.Name.ToLowerInvariant() -eq $nameLower } |
            Select-Object -First 1
    )
}

function Get-FoPluginSupportFilesForExecutables {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)]
        [string[]]$Executables,
        [Parameter(Mandatory)]
        [string]$SourcePluginDir
    )

    $support = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    $sourceFiles = @()
    if (Test-Path -LiteralPath $SourcePluginDir) {
        $sourceFiles = Get-ChildItem -LiteralPath $SourcePluginDir -File -ErrorAction SilentlyContinue
    }

    function Add-IfExists([string]$Name) {
        if ($sourceFiles | Where-Object { $_.Name -ieq $Name }) {
            [void]$support.Add($Name)
        }
    }

    function Add-Glob([string]$Pattern) {
        foreach ($f in ($sourceFiles | Where-Object { $_.Name -like $Pattern })) {
            [void]$support.Add($f.Name)
        }
    }

    foreach ($exe in $Executables) {
        switch -Regex ($exe) {
            '^flac\.exe$' {
                Add-IfExists 'libFLAC.dll'
            }
            '^gzip\.exe$' {
                Add-IfExists 'zlib.dll'
            }
            '^gswin64c\.exe$' {
                Add-IfExists 'gsdll64.dll'
            }
            '^gswin32c\.exe$' {
                Add-IfExists 'gsdll32.dll'
            }
            '^qpdf\.exe$' {
                Add-Glob 'qpdf*.dll'
            }
            '^m7zrepacker\.exe$' {
                Add-IfExists 'm7zRepacker.ini'
                Add-IfExists '7z.exe'
                Add-IfExists '7z.dll'
            }
            '^tidy\.exe$' {
                Add-IfExists 'tidy.config'
            }
            '^magick\.exe$' {
                foreach ($f in ($sourceFiles | Where-Object { $_.Extension -ieq '.dll' })) {
                    [void]$support.Add($f.Name)
                }
            }
            '^cwebp\.exe$' {
                Add-Glob 'libwebp*.dll'
                Add-Glob 'libsharpyuv*.dll'
            }
            '^mutool\.exe$' {
                Add-Glob 'mupdf*.dll'
            }
            '^strip\.exe$' {
                Add-Glob 'libwinpthread*.dll'
                Add-Glob 'libgcc*.dll'
                Add-Glob 'libstdc*.dll'
            }
        }
    }

    return @($support | Sort-Object)
}

function Get-FoPluginInstallFilePlan {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)]
        [string[]]$Executables,
        [Parameter(Mandatory)]
        [string]$SourcePluginDir
    )

    $files = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
    foreach ($exe in $Executables) {
        [void]$files.Add($exe)
    }
    foreach ($f in (Get-FoPluginSupportFilesForExecutables -Executables $Executables -SourcePluginDir $SourcePluginDir)) {
        [void]$files.Add($f)
    }
    return @($files | Sort-Object)
}

function Get-FoMissingPluginExecutables {
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)]
        [string]$PluginPath,
        [string[]]$RequiredExecutables
    )

    if (-not $RequiredExecutables) {
        $RequiredExecutables = Get-FoRequiredPluginExecutables
    }

    $missing = [System.Collections.Generic.List[string]]::new()
    foreach ($exe in $RequiredExecutables) {
        if (-not (Test-FoPluginFilePresent -PluginPath $PluginPath -FileName $exe)) {
            $missing.Add($exe)
        }
    }
    return @($missing)
}

function Resolve-FoBundledPluginDirectory {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ExtractRoot,
        [string]$ExpectedFolder
    )

    $candidates = @()
    if ($ExpectedFolder) {
        $candidates += Join-Path $ExtractRoot $ExpectedFolder
    }
    $candidates += @(
        $ExtractRoot
        (Join-Path $ExtractRoot 'plugins')
        (Join-Path $ExtractRoot 'Plugins64')
        (Join-Path $ExtractRoot 'Plugins32')
    )

    foreach ($dir in $candidates) {
        if (-not (Test-Path -LiteralPath $dir)) {
            continue
        }
        if (Test-FoPluginFilePresent -PluginPath $dir -FileName 'magick.exe') {
            return ([System.IO.Path]::GetFullPath($dir))
        }
    }

    throw "Could not find plugin executables in extracted bundle at '$ExtractRoot'."
}