jh_PwaTools.psm1

# PwaTools.psm1
# Chrome-grade AutoIcon-enabled PWA shortcut generator

Add-Type -AssemblyName System.Drawing

#region Helpers

function Get-PWTBrowserInfo {
    [CmdletBinding()]
    param([switch]$Edge)

    if ($Edge) {
        $exe = "$Env:ProgramFiles(x86)\Microsoft\Edge\Application\msedge.exe"
        if (-not (Test-Path $exe)) { $exe = "$Env:ProgramFiles\Microsoft\Edge\Application\msedge.exe" }
        $name = 'Edge'
    }
    else {
        $exe = "$Env:ProgramFiles\Google\Chrome\Application\chrome.exe"
        if (-not (Test-Path $exe)) { $exe = "$Env:ProgramFiles(x86)\Google\Chrome\Application\chrome.exe" }
        $name = 'Chrome'
    }

    if (-not (Test-Path $exe)) {
        throw "Browser executable not found for $name. Checked: $exe"
    }

    [pscustomobject]@{ Name = $name; Path = $exe }
}

function New-PWTShortcut {
    [CmdletBinding()]
    param(
        [string]$Name,
        [string]$TargetPath,
        [string]$Arguments,
        [string]$ShortcutPath,
        [string]$IconPath
    )

    $shell = New-Object -ComObject WScript.Shell
    $shortcut = $shell.CreateShortcut($ShortcutPath)
    $shortcut.TargetPath = $TargetPath
    $shortcut.Arguments  = $Arguments
    $shortcut.WorkingDirectory = Split-Path $TargetPath -Parent

    if ($IconPath -and (Test-Path $IconPath)) {
        $shortcut.IconLocation = $IconPath
    }

    $shortcut.Save()
}

function Get-PWTStartMenuFolder {
    $startMenu = [Environment]::GetFolderPath('Programs')
    $pwaFolder = Join-Path $startMenu 'PWAs'

    if (-not (Test-Path $pwaFolder)) {
        New-Item -ItemType Directory -Path $pwaFolder | Out-Null
    }

    return $pwaFolder
}

function Convert-PWTPngToIco {
    param(
        [string]$PngPath,
        [string]$IcoPath
    )

    try {
        $bmp = [System.Drawing.Bitmap]::FromFile($PngPath)
        $iconStream = New-Object System.IO.FileStream($IcoPath, 'Create')
        $iconWriter = New-Object System.IO.BinaryWriter($iconStream)

        # ICO header
        $iconWriter.Write([UInt16]0)
        $iconWriter.Write([UInt16]1)
        $iconWriter.Write([UInt16]1)

        $width  = [byte]($bmp.Width)
        $height = [byte]($bmp.Height)

        $iconWriter.Write($width)
        $iconWriter.Write($height)
        $iconWriter.Write([byte]0)
        $iconWriter.Write([byte]0)
        $iconWriter.Write([UInt16]1)
        $iconWriter.Write([UInt16]32)

        $pngBytes = [System.IO.File]::ReadAllBytes($PngPath)
        $iconWriter.Write([UInt32]$pngBytes.Length)
        $iconWriter.Write([UInt32]22)
        $iconWriter.Write($pngBytes)

        $iconWriter.Close()
        $iconStream.Close()
    }
    catch {
        Write-Warning "Failed to convert PNG to ICO: $($_.Exception.Message)"
    }
}

function Convert-PWTSvgToPng {
    param(
        [string]$SvgUrl,
        [string]$PngPath
    )

    try {
        $svgBytes = Invoke-WebRequest -Uri $SvgUrl -ErrorAction Stop -UseBasicParsing
        $svgTemp = [System.IO.Path]::GetTempFileName() + ".svg"
        [System.IO.File]::WriteAllBytes($svgTemp, $svgBytes.Content)

        # Requires rsvg-convert (Linux) or Inkscape (Windows)
        # For Windows, use Inkscape CLI if installed
        $inkscape = "C:\Program Files\Inkscape\bin\inkscape.exe"
        if (Test-Path $inkscape) {
            & $inkscape $svgTemp --export-type=png --export-filename=$PngPath | Out-Null
            return $PngPath
        }

        Write-Warning "SVG conversion skipped: no converter available"
        return $null
    }
    catch {
        Write-Warning "Failed to convert SVG: $($_.Exception.Message)"
        return $null
    }
}

function Get-PWTAutoIcon {
    [CmdletBinding()]
    param(
        [string]$Url,
        [string]$AppName
    )

    $cacheRoot = Join-Path $Env:LOCALAPPDATA "PwaTools\Icons"
    if (-not (Test-Path $cacheRoot)) {
        New-Item -ItemType Directory -Path $cacheRoot | Out-Null
    }

    $cachedIco = Join-Path $cacheRoot "$AppName.ico"
    if (Test-Path $cachedIco) {
        return $cachedIco
    }

    $baseUri = [System.Uri]$Url
    $candidates = @()

    # Helper to resolve relative URLs
    function Resolve-Url($href) {
        try { return (New-Object System.Uri($baseUri, $href)).AbsoluteUri }
        catch { return $null }
    }

    # Fetch HTML
    try {
        $html = Invoke-WebRequest -Uri $Url -UseBasicParsing -ErrorAction Stop
    }
    catch {
        Write-Warning "AutoIcon: Failed to fetch $Url"
        return $null
    }

    # 1. Collect <link rel="icon"> candidates
    foreach ($link in $html.Links) {
        if ($link.rel -match 'icon') {
            $resolved = Resolve-Url $link.href
            if ($resolved) { $candidates += $resolved }
        }
    }

    # 2. Check for manifest.json
    $manifestLink = $html.Links | Where-Object { $_.rel -eq 'manifest' } | Select-Object -First 1
    if ($manifestLink) {
        $manifestUrl = Resolve-Url $manifestLink.href
        try {
            $manifest = Invoke-RestMethod -Uri $manifestUrl -ErrorAction Stop
            foreach ($icon in $manifest.icons) {
                $resolved = Resolve-Url $icon.src
                if ($resolved) { $candidates += $resolved }
            }
        }
        catch {}
    }

    # 3. Add Chrome-style fallback paths
    $fallbacks = @(
        "/favicon.ico",
        "/favicon.png",
        "/favicon.svg",
        "/logo.png",
        "/icon.png",
        "/static/favicon.ico",
        "/static/img/favicon.ico"
    )

    foreach ($f in $fallbacks) {
        $candidates += "$($baseUri.Scheme)://$($baseUri.Host)$f"
    }

    # Deduplicate
    $candidates = $candidates | Select-Object -Unique

    # 4. Download all candidates and pick the largest
    $best = $null
    $bestSize = 0

    foreach ($iconUrl in $candidates) {
        try {
            $ext = [System.IO.Path]::GetExtension($iconUrl).ToLower()
            $tmp = Join-Path $cacheRoot "$AppName-temp$ext"

            Invoke-WebRequest -Uri $iconUrl -OutFile $tmp -ErrorAction Stop

            if ($ext -eq ".svg") {
                $pngTmp = Join-Path $cacheRoot "$AppName-temp.png"
                $converted = Convert-PWTSvgToPng -SvgUrl $iconUrl -PngPath $pngTmp
                if ($converted) { $tmp = $pngTmp }
            }

            $img = [System.Drawing.Bitmap]::FromFile($tmp)
            $size = $img.Width * $img.Height

            if ($size -gt $bestSize) {
                $best = $tmp
                $bestSize = $size
            }
        }
        catch {}
    }

    if ($best) {
        Convert-PWTPngToIco -PngPath $best -IcoPath $cachedIco
        return $cachedIco
    }

    return $null
}

#endregion Helpers

function New-PWAShortcut {
    [CmdletBinding()]
    param(
        [string]$Name,
        [string]$Url,
        [string]$Profile,
        [switch]$Desktop,
        [string]$Icon,
        [switch]$Edge,
        [switch]$NoAutoIcon
    )

    $browser = Get-PWTBrowserInfo -Edge:$Edge

    $args = @("--app=`"$Url`"")
    if ($Profile) {
        $args += "--profile-directory=`"$Profile`""
    }

    $startMenuFolder = Get-PWTStartMenuFolder
    $shortcutPath = Join-Path $startMenuFolder "$Name.lnk"

    $iconPath = $Icon

    if (-not $iconPath -and -not $NoAutoIcon) {
        $auto = Get-PWTAutoIcon -Url $Url -AppName $Name
        if ($auto) { $iconPath = $auto }
    }

    New-PWTShortcut -Name $Name `
                    -TargetPath $browser.Path `
                    -Arguments ($args -join ' ') `
                    -ShortcutPath $shortcutPath `
                    -IconPath $iconPath

    if ($Desktop) {
        $desktop = [Environment]::GetFolderPath('Desktop')
        Copy-Item $shortcutPath (Join-Path $desktop "$Name.lnk") -Force
    }

    [pscustomobject]@{
        Name         = $Name
        Url          = $Url
        Browser      = $browser.Name
        Profile      = $Profile
        Desktop      = [bool]$Desktop
        Icon         = $iconPath
        ShortcutPath = $shortcutPath
    }
}

function Import-PWAShortcutsFromCsv {
    [CmdletBinding()]
    param(
        [string]$Path,
        [switch]$Edge,
        [switch]$NoAutoIcon
    )

    $rows = Import-Csv -Path $Path
    $results = @()

    foreach ($row in $rows) {
        $useEdge = $Edge -or ($row.Browser -eq 'Edge')

        $result = New-PWAShortcut -Name $row.Name `
                                  -Url $row.Url `
                                  -Profile $row.Profile `
                                  -Desktop:([bool]($row.Desktop -match '^(true|1|yes)$')) `
                                  -Icon $row.IconOverride `
                                  -Edge:$useEdge `
                                  -NoAutoIcon:$NoAutoIcon

        $results += $result
    }

    return $results
}

function New-PWACsvTemplate {
    param([string]$Path)

    @(
        [pscustomobject]@{
            Name='netbox'; Url='https://netbox.example/'; Browser='Chrome'; Profile='Default'; Desktop='true'; IconOverride=''
        }
    ) | Export-Csv -Path $Path -NoTypeInformation
}

function New-PWAFromTraefik {
    [CmdletBinding()]
    param(
        [string]$TraefikApiBaseUrl,
        [switch]$Desktop,
        [string]$Profile,
        [string]$IconDirectory,
        [switch]$Edge,
        [switch]$NoAutoIcon
    )

    $base = $TraefikApiBaseUrl.TrimEnd('/')
    $routersUrl = "$base/routers"

    $routers = Invoke-RestMethod -Uri $routersUrl -SkipCertificateCheck

    $results = @()

    foreach ($router in $routers) {

        if ($router.provider -ne 'docker') { continue }
        if ($router.rule -notmatch 'Host\(`(?<host>[^`]+)`\)') { continue }

        $hostname = $Matches['host']
        $name = ($hostname -split '\.')[0]
        $url = "https://$hostname/"

        $iconPath = $null

        if ($IconDirectory -and (Test-Path $IconDirectory)) {
            $candidate = Join-Path $IconDirectory "$name.ico"
            if (Test-Path $candidate) { $iconPath = $candidate }
        }

        $result = New-PWAShortcut -Name $name `
                                  -Url $url `
                                  -Profile $Profile `
                                  -Desktop:$Desktop `
                                  -Icon $iconPath `
                                  -Edge:$Edge `
                                  -NoAutoIcon:$NoAutoIcon

        $results += $result
    }

    return $results
}

Export-ModuleMember -Function `
    New-PWAShortcut, `
    Import-PWAShortcutsFromCsv, `
    New-PWACsvTemplate, `
    New-PWAFromTraefik