<#
.SYNOPSIS
    Detects the installation status and version of an application managed by Windows Package Manager (winget) for Microsoft Intune Win32 app deployment.

.DESCRIPTION
    This script is designed to be used as a detection method in Microsoft Intune for Win32 application deployments. 
    It leverages the Windows Package Manager (winget) to check if a specified application is installed, determines
     if it is the latest version available, and retrieves the installed version details. The script ensures that 
     the necessary dependencies for winget are present and verifies internet connectivity to essential resources
     required for winget operations.

    It is structured into three main functions:
    - Get-WingetAppDetails: Retrieves the installation status and version of the application.
    - Test-WingetAndDependencies: Checks for the presence of winget and its dependencies.
    - Test-InternetConnectivity: Ensures connectivity to resources needed by winget.

    The script outputs a detailed summary of the detection process and exits with a status code that can be used to inform Intune deployment workflows.

    Before deploying this script through Microsoft Intune, modify the $WingetAppID variable in the script 
    to match the ID of the WinGet application you wish to detect.

    .EXAMPLES OF MODIFYING $WingetAppID

    1. Detecting Microsoft Edge:
    If deploying Microsoft Edge, find its WinGet App ID (e.g., 'Microsoft.Edge'). 
    Modify the script as follows:
    $WingetAppID = "Microsoft.Edge"

    2. Detecting Visual Studio Code:
    For Visual Studio Code, the WinGet App ID might be 'Microsoft.VisualStudioCode'. 
    Modify the script like this:
    $WingetAppID = "Microsoft.VisualStudioCode"

    .FINDING WINGET APP IDS
    To find the WinGet App ID for a specific application, use the WinGet command: winget search <ApplicationName>
    This will list available packages and their IDs.

    .SCRIPT USAGE IN INTUNE
    After modifying the $WingetAppID, the script is ready to be used in Microsoft Intune as a detection rule 
    for a Win32 app. 

.PARAMETER WingetAppID
    NOT USED, that was the idea. Win32 Detect script does not take parameters.
    ....The application identifier for the Windows Package Manager (winget) to check the application.

.EXAMPLE
    .\Detect_WingetApp.ps1 
    $WingetAppID = "Microsoft.VisualStudioCode"
    Checks if Visual Studio Code is installed, verifies the version, and outputs the detection summary.

.NOTES
    This script is intended for use with Microsoft Intune and assumes that winget is installed and operational on the target system. It should be thoroughly tested in a non-production environment before being deployed.


.LAST MODIFIED
    Nove 9th, 2023

#>


# Modify the following variable with the WinGet App ID of the application you want to detect.
# Example: $WingetAppID = "Microsoft.Edge" for Microsoft Edge
$WingetAppID = "<Your-WinGet-App-ID-Here>"


#region Functions

function Invoke-Ensure64bitEnvironment {
    <#
    .SYNOPSIS
        Check if the script is running in a 32-bit or 64-bit environment, and relaunch using 64-bit PowerShell if necessary, including original arguments.

    .NOTES
        This script checks the processor architecture to determine the environment.
        If it's running in a 32-bit environment on a 64-bit system (WOW64), 
        it will relaunch using the 64-bit version of PowerShell, preserving the original arguments.
        Place the function at the beginning of the script to ensure a switch to 64-bit when necessary.
    #>

    # Capture the original arguments
    $scriptArguments = $MyInvocation.Line.replace($MyInvocation.InvocationName,'').Trim()

    if ($ENV:PROCESSOR_ARCHITECTURE -eq "x86" -and $ENV:PROCESSOR_ARCHITEW6432 -eq "AMD64") {
        Write-Output "Detected 32-bit PowerShell on 64-bit system. Relaunching script in 64-bit environment with original arguments..."
        Start-Process -FilePath "$ENV:WINDIR\SysNative\WindowsPowershell\v1.0\PowerShell.exe" -ArgumentList "-WindowStyle Hidden -NonInteractive -File `"$($PSCommandPath)`" $scriptArguments" -Wait -NoNewWindow
        exit # Terminate the 32-bit process
    } elseif ($ENV:PROCESSOR_ARCHITECTURE -eq "x86") {
        Write-Output "Detected 32-bit PowerShell on a 32-bit system. Stopping script execution."
        exit # Terminate the script if it's a pure 32-bit system
    }
}

function Find-WingetPath {
    <#
    .SYNOPSIS
        Locates and verifies the accessibility of the winget.exe executable within a Windows system.

    .DESCRIPTION
        This function is designed to find the path of the `winget.exe` executable on a Windows system 
         and ensure it is accessible for execution. 
        Used for scripts that need to interact with the Windows Package Manager (winget) 
         in environments where the script may be running under different user contexts, including SYSTEM and USER.

        The Windows Package Manager (winget) is a command-line utility that simplifies the installation,
         upgrade, configuration, and removal of software packages. Accurately locating `winget.exe` and
         verifying its accessibility have probed to be crucail for enabling automated software management tasks, 
         especially when executed under the SYSTEM context.

        Methodology:
        1. Defining Potential Paths:
        - The function defines a list of potential file paths where `winget.exe` might be located. 
          These paths include:
            - The standard Program Files directory, typically used on 64-bit systems.
            - The 32-bit Program Files directory, for 32-bit applications on 64-bit systems.
            - The Local Application Data directory.
            - The Current User's Local Application Data directory.
        - These paths may contain wildcards (*) to accommodate flexible directory naming, such as version-specific folder names.

        2. Iterating Through Paths and Verifying Accessibility:
            - The function iterates over each potential location, resolving any paths with wildcards to their actual directories.
            - For each resolved path, it uses `Get-ChildItem` to search for `winget.exe`.
            - Upon locating `winget.exe`, the function checks if the current context has execution permissions for the file.
            - If necessary, it attempts to modify the file's Access Control List (ACL) to grant execute permissions.
            - This step ensures that the located `winget.exe` is not only present but also executable by the script.

        3. Returning Results:
            - If `winget.exe` is found and is accessible, the function returns the full path to the executable.
            - If `winget.exe` is not found or cannot be made accessible, it outputs an error message and returns `$null`.

    .EXAMPLE
        $wingetLocation = Find-WingetPath
        if ($wingetLocation) {
            Write-Output "Winget found and accessible at: $wingetLocation"
        } else {
            Write-Error "Winget was not found or is not accessible on this system."
        }

    .NOTES
 
    .DISCLAIMER
        This function is provided 'as-is' with no warranties or guarantees. It should be thoroughly tested in a controlled environment before any production use. The design and robustness of this function have been enhanced with the assistance of ChatGPT. However, as with all automated tools and scripts, it is essential to review and test them within their specific application context.

    #>

    # Define potential locations for winget.exe
    # These locations are the most common paths where winget.exe might be installed.
    $possibleLocations = @(
        "${env:ProgramFiles}\WindowsApps\Microsoft.DesktopAppInstaller*_x64__8wekyb3d8bbwe\winget.exe", 
        "${env:ProgramFiles(x86)}\WindowsApps\Microsoft.DesktopAppInstaller*_8wekyb3d8bbwe\winget.exe",
        "${env:LOCALAPPDATA}\Microsoft\WindowsApps\winget.exe",
        "${env:USERPROFILE}\AppData\Local\Microsoft\WindowsApps\winget.exe"
    )

    # Function to check and modify permissions
    # This function attempts to add execute permissions to the winget.exe file.
    function CheckAndModifyPermissions {
        param (
            [string]$filePath
        )
        try {
            Write-Host "Verifying execution permissions for $filePath"
            # Get the current Access Control List (ACL) of the file
            $acl = Get-Acl $filePath
            # Define the execution permission
            $executionPermission = [System.Security.AccessControl.FileSystemRights]::ReadAndExecute
    
            # Get the current user's account
            $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
            $currentUserAccount = New-Object System.Security.Principal.NTAccount($currentUser)
    
            # Check if the current user already has execute permissions
            $accessRules = $acl.Access | Where-Object { $_.IdentityReference -eq $currentUserAccount }
            $hasExecutePermission = $accessRules | Where-Object { $_.FileSystemRights -match 'Execute' -or $_.FileSystemRights -match 'FullControl' }
    
            if ($hasExecutePermission) {
                Write-Host "$currentUser already has execute permissions."
                return $true
            }
    
            # If the current user does not have the necessary permissions, attempt to modify the ACL
            $accessRule = New-Object System.Security.AccessControl.FileSystemAccessRule($currentUserAccount, $executionPermission, 'Allow')
            $acl.AddAccessRule($accessRule)
            Set-Acl -Path $filePath -AclObject $acl
            return $true
        }
        catch {
            Write-Warning "Failed to modify permissions for: $filePath"
            return $false
        }
    }
       

    # Iterate through potential locations to find and verify winget.exe
    # This loop checks each location in the list for the presence of winget.exe.
    foreach ($location in $possibleLocations) {
        try {
            # Check if the location contains a wildcard and resolve it
            if ($location -like '*`**') {
                # Resolve the wildcard path to actual directory paths
                $resolvedPaths = Resolve-Path $location -ErrorAction SilentlyContinue
                # Update the location with the resolved path if available
                if ($resolvedPaths) {
                    $location = $resolvedPaths.Path
                }
                else {
                    # If path couldn't be resolved, inform via write warning
                    Write-Warning "Couldn't resolve path for: $location"
                    # Skip to the next location if the path cannot be resolved
                    continue
                }
            }

            # Search for winget.exe in the resolved location
            # Get-ChildItem is used to find the winget.exe file in the specified directory.
            $items = Get-ChildItem -Path $location -ErrorAction Stop
            # Iterate through each found item to check and modify permissions
            if ($items -and $items.Count -gt 0) {
                # Found a path, saving to variable and informing
                $wingetPath = $items[0].FullName
                Write-Host "Found Winget at: $wingetPath"
                # Check and modify permissions if necessary
                $hasPermission = CheckAndModifyPermissions -filePath $wingetPath
                # If the file is accessible, return its path
                if ($hasPermission) {
                    Write-Host "Winget found and accessible at: $wingetPath"
                    return $wingetPath
                } else {
                    Write-Host "Unable to confirm Winget execution permissions."
                }
            }
        }
        catch {
            # Catch any exceptions during the search and output a warning
            Write-Warning "Couldn't search for winget.exe at: $location"
        }
    }

    # If winget.exe is not found in any of the locations, output an error
    Write-Error "Winget wasn't located or accessible in any of the specified locations."
    return $null
}

function Get-WingetAppDetails {
    <#
    .SYNOPSIS
        Retrieves details about an application's installation status and version using Windows Package Manager (winget).

    .DESCRIPTION
        This function checks if a specified application is installed on the system, determines if the installed version is the latest available version, and retrieves the specific installed version number. It utilizes the 'winget' command-line tool to query the local package repository.

    .PARAMETER AppID
        The application identifier (ID) for the application to check, as recognized by winget.

    .PARAMETER WingetPath
        Optional parameter to specify a complete path to the winget executable.

    .EXAMPLE
        $appDetails = Get-WingetAppDetails -AppID "Microsoft.VisualStudioCode"
        This command will check if Visual Studio Code is installed, if it's the latest version, and what the installed version is.

    .OUTPUTS
        PSCustomObject with the following properties:
        - IsInstalled: [bool] Indicates if the application is installed.
        - IsLatestVersion: [bool] Indicates if the installed version is the latest.
        - InstalledVersion: [string] The version number of the installed application.

    .NOTES
        Requires Windows Package Manager (winget) to be installed and accessible in the system PATH.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [string]$AppID,

        [string]$WingetPath
    )

    # Initialize the result object with default values
    $result = New-Object PSObject -Property @{
        IsInstalled = $false
        IsLatestVersion = $false
        InstalledVersion = $null
    }

    try {
        # If WingetPath is not provided, attempt to locate winget automatically
        if (-not $WingetPath) {
            $WingetPath = (Get-Command 'winget').Source
        }

        # Query the installed application using winget and clean up the output
        $installedApp = & $wingetPath list --id $AppID -e | Out-String

        # Remove non-alphanumeric characters (except for periods, hyphens, and new lines) from the output
        $cleanOutput = $installedApp -replace '[^\w.\-\r\n]', ' ' -replace '\s+', ' ' -replace '^\s+|\s+$', ''

        # Split the output into lines and remove empty lines
        $outputLines = ($cleanOutput -split '\r?\n').Trim() | Where-Object { $_ -ne '' }

        # Find the line with the application details using regex to match the version number pattern
        $appDetailsLine = $outputLines | Where-Object { $_ -match "$AppID\s+(\d+(\.\d+)+)" }

        if ($appDetailsLine) {
            $result.IsInstalled = $true

            # Extract the version number using the regex match
            $matches = [regex]::Matches($appDetailsLine, "$AppID\s+(\d+(\.\d+)+)")
            $result.InstalledVersion = $matches[0].Groups[1].Value

            if ($result.IsInstalled) {
                # Use winget search to find the latest version available in the repository
                $latestApp = & $wingetPath search --id $AppID -e | Out-String
                # Clean up the output for the latest version
                $latestCleanOutput = $latestApp -replace '[^\w.\-\r\n]', ' ' -replace '\s+', ' ' -replace '^\s+|\s+$', ''
                $latestOutputLines = ($latestCleanOutput -split '\r?\n').Trim() | Where-Object { $_ -ne '' }
                $latestVersionLine = $latestOutputLines | Where-Object { $_ -match "$AppID\s+(\d+(\.\d+)+)" }
    
                if ($latestVersionLine) {
                    # Extract the latest version number using regex
                    $latestMatches = [regex]::Matches($latestVersionLine, "$AppID\s+(\d+(\.\d+)+)")
                    $latestVersion = $latestMatches[0].Groups[1].Value
    
                    # Check if the installed version matches the latest available version
                    if ($result.InstalledVersion -eq $latestVersion) {
                        $result.IsLatestVersion = $true
                    }
                }
            }
        }
    }
    catch {
        Write-Error "Error occurred while attempting to retrieve application details: $_"
    }

    return $result
}

function Test-WingetAndDependencies {
    <#
    .SYNOPSIS
    Tests for the presence of Winget and required dependencies on the system.

    .DESCRIPTION
    Checks if the Windows Package Manager (Winget) is installed and verifies necessary dependencies, 
    including the Desktop App Installer, Microsoft.UI.Xaml, and the Visual C++ Redistributable. 
    Returns a string with unique identifiers indicating the result of the check and outputs feedback to the console.
    This allows for precise identification of which components are missing.

    .EXAMPLE
    $checkResult = Test-WingetAndDependencies
    if ($checkResult -eq "0") {
        Write-Host "Winget and all dependencies are present."
    } else {
        Write-Host "Missing components: $checkResult"
    }
    This example calls the Test-WingetAndDependencies function and acts based on the returned status string.

    .OUTPUTS
    String
    Returns a string value with concatenated identifiers indicating the status of the check:
    "0" - Winget and all dependencies are detected successfully.
    "W" - Winget is not detected.
    "D" - Desktop App Installer is not detected.
    "U" - Microsoft.UI.Xaml is not detected.
    "V" - Visual C++ Redistributable is not detected.
    Concatenated string for multiple missing components, e.g., "DU" for missing Desktop App Installer and Microsoft.UI.Xaml.

    .NOTES
    Date: November 9, 2023
    The function does not attempt to install Winget or its dependencies. It only checks for their presence, reports the findings, and outputs feedback to the console.

    .LINK
    Documentation for Winget: https://docs.microsoft.com/en-us/windows/package-manager/winget/
    #>

    # Initialize an array to hold missing component identifiers
    $missingComponents = @()

    # Check if Winget is installed
    $wingetPath = (Get-Command -Name winget -ErrorAction SilentlyContinue).Source
    if (-not $wingetPath) {
        $missingComponents += "W" # Add 'W' to the array if Winget is missing
        Write-Host "Winget is NOT installed."
    } else {
        Write-Host "Winget is installed."
    }

    # Check for Desktop App Installer
    $desktopAppInstaller = Get-AppxPackage -Name Microsoft.DesktopAppInstaller -ErrorAction SilentlyContinue
    if (-not $desktopAppInstaller) {
        $missingComponents += "D" # Add 'D' to the array if Desktop App Installer is missing
        Write-Host "Desktop App Installer is NOT installed."
    } else {
        Write-Host "Desktop App Installer is installed."
    }

    # Check for Microsoft.UI.Xaml
    $uiXaml = Get-AppxPackage -Name Microsoft.UI.Xaml.2* -ErrorAction SilentlyContinue # Assuming version 2.x is required
    if (-not $uiXaml) {
        $missingComponents += "U" # Add 'U' to the array if Microsoft.UI.Xaml is missing
        Write-Host "Microsoft.UI.Xaml is NOT installed."
    } else {
        Write-Host "Microsoft.UI.Xaml is installed."
    }

    # Check for Visual C++ Redistributable
    $vcDisplayName = "Microsoft Visual C++ 2015-2022 Redistributable (x64)"
    $vcInstalled = Get-ChildItem HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall, 
                                  HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall |
                   Get-ItemProperty |
                   Where-Object { $_.DisplayName -like "*$vcDisplayName*" } -ErrorAction SilentlyContinue
    if (-not $vcInstalled) {
        $missingComponents += "V" # Add 'V' to the array if Visual C++ Redistributable is missing
        Write-Host "Visual C++ Redistributable is NOT installed."
    } else {
        Write-Host "Visual C++ Redistributable is installed."
    }

    # Return a concatenated string of missing component identifiers
    # If no components are missing, return '0'
    if ($missingComponents.Length -eq 0) {
        return "0"
    } else {
        return [String]::Join('', $missingComponents)
    }
}

function Test-InternetConnectivity {
    <#
    .SYNOPSIS
    Confirms internet connectivity to download content from github.com and nuget.org.

    .DESCRIPTION
    Tests the TCP connection to github.com and nuget.org on port 443 (HTTPS) to confirm internet connectivity.
    Returns a string of characters that clearly identifies if there is a connectivity issue, and if so, to which URL or site.
    Additionally, outputs simplified but clear feedback to the console.

    .EXAMPLE
    Test-InternetConnectivity
    This example calls the Test-InternetConnectivity function and outputs the result to the console.

    .OUTPUTS
    String
    Returns a string of characters indicating the connectivity status:
    '0' - No connectivity issues.
    'G' - Connectivity issue with github.com.
    'N' - Connectivity issue with nuget.org.
    'GN' - Connectivity issues with both sites.

    .NOTES
    Date: November 2, 2023
    #>

    # Initialize a variable to hold the connectivity status
    $connectivityStatus = ''

    # Test connectivity to github.com
    $githubTest = Test-NetConnection -ComputerName 'github.com' -Port 443 -ErrorAction SilentlyContinue
    if (-not $githubTest.TcpTestSucceeded) {
        $connectivityStatus += 'G'
        Write-Host "Connectivity issue with github.com."
    } else {
        Write-Host "Successfully connected to github.com."
    }

    # Test connectivity to nuget.org
    $nugetTest = Test-NetConnection -ComputerName 'nuget.org' -Port 443 -ErrorAction SilentlyContinue
    if (-not $nugetTest.TcpTestSucceeded) {
        $connectivityStatus += 'N'
        Write-Host "Connectivity issue with nuget.org."
    } else {
        Write-Host "Successfully connected to nuget.org."
    }

    # Determine the return value based on the tests
    if ($connectivityStatus -eq '') {
        Write-Host "Internet connectivity to both github.com and nuget.org is confirmed."
        return '0' # No issues
    } else {
        Write-Host "Connectivity test completed with issues: $connectivityStatus"
        return $connectivityStatus # Return the specific issue(s)
    }
}

#endregion Functions

#region Main

#region Variables
$WingetAppID = $WingetAppID     # Winget Application ID. 
                                #  $WingetAppID HAS to be manually updated, Win32 detect script does not accept parameters.
$appWinget = $null              # Stores App details
$WingetAppVer = $null           # For App version
$WingetAppIsLatest = $null        # To confirm that latest version is Installed
$detectSummary = ""             # Summary of script execution
$result = 0                     # Script execution result
#endregion Variables

# Clear errors
$Error.Clear()

# Make the log easier to read
Write-Host `n`n

# Invoke the function to ensure we're running in a 64-bit environment if available
Invoke-Ensure64bitEnvironment
Write-Host "Script running in 64-bit environment."

$wingetPath = Find-WingetPath

# Check if Winget Application is installed
$appWinget = Get-WingetAppDetails -AppID $WingetAppID -WingetPath $wingetPath

# Some spaces to make it easier to read in log file
Write-Host `n`n

if ($appWinget.IsInstalled) {
    # Get the current version of he Winget Application
    $WingetAppVer = $appWinget.InstalledVersion
    $WingetAppIsLatest = $appWinget.IsLatestVersion
    Write-Host "Found $WingetAppID version $WingetAppVer."
    $detectSummary += "App $WingetAppID version $WingetAppVer. " 
    if (-not $WingetAppIsLatest) {
        Write-Host "There is a newer $WingetAppID version available."
        $detectSummary += "Newer $WingetAppID version available. " 
    } else {
        Write-Host "It is newest available version for $WingetAppID."
    }
}
else {
    Write-Host "$WingetAppID not installed on device."
    $detectSummary += "$WingetAppID not found on device. "

    # If Winget Application not installed, check Winget and dependencies
    $wingetCheckResult = Test-WingetAndDependencies
    # Adjust the switch to handle string identifiers
    switch -Regex ($wingetCheckResult) {
        '0' { 
            $detectSummary = "Winget and all dependencies detected successfully. " # Set summary exclusively for this case
            break # Exit the switch to avoid processing other cases
        }
        'W' { $detectSummary += "Winget NOT detected. " }
        'D' { $detectSummary += "Desktop App Installer NOT detected. " }
        'U' { $detectSummary += "Microsoft.UI.Xaml NOT detected. " }
        'V' { $detectSummary += "Visual C++ Redistributable NOT detected. " }
        Default { $detectSummary += "Unknown dependency check result: $wingetCheckResult " }
    }

    # Check internet connectivity to github.com and nuget.org
    $internetConnectivityResult = Test-InternetConnectivity
    # Adjust the switch to handle string identifiers for connectivity results
    switch -Regex ($internetConnectivityResult) {
        '0' { 
            $detectSummary += "Connectivity to github.com and nuget.org confirmed. "
        }
        'G' { $detectSummary += "Connectivity issue with github.com. " }
        'N' { $detectSummary += "Connectivity issue with nuget.org. " }
        'GN' { $detectSummary += "Connectivity issues with both github.com and nuget.org. " }
        Default { $detectSummary += "Unknown connectivity check result: $internetConnectivityResult " }
    }
    
    $result = 1
}

# Some spaces to make it easier to read in log file
Write-Host `n`n


#Return result
if ($result -eq 0) {
    Write-Host "OK $([datetime]::Now) : $detectSummary"
    Exit 0
}
elseif ($result -eq 1) {
    Write-Host "FAIL $([datetime]::Now) : $detectSummary"
    Exit 1
}
else {
    Write-Host "NOTE $([datetime]::Now) : $detectSummary"
    Exit 0
}