Private/Find-Win32ToolkitIntuneApp.ps1

function Find-Win32ToolkitIntuneApp {
    <#
    .SYNOPSIS
        Finds published Win32 apps in the Intune tenant — by display name, by the winget id the toolkit
        stamps into `notes`, or simply lists them (for the TUI "pick a dependency from Intune" picker).
    .DESCRIPTION
        The dependency target of a mobileAppDependency must be an existing win32LobApp, referenced by its
        Intune app id — so a declared dependency has to be looked up in the tenant before it can be
        related. This is the module's only tenant read.
 
        Two subtleties:
 
        * ODATA STRING ESCAPING — a single quote in a $filter must be DOUBLED ('O''Reilly'). The repo
          already carries hostile apostrophe fixtures, and a naive filter breaks (or worse, injects) on
          an app called "O'Reilly Toolkit".
 
        * THE '(Update)' TWIN — the toolkit publishes an optional second "<name> (Update)" app whose
          requirement rule restricts it to devices that ALREADY have the app. Depending on that would
          produce a dependency that can never be satisfied on a clean device, so it is always excluded.
          Toolkit-published apps are identifiable by the `notes` stamp: 'win32-toolkit; [update;] [<wingetId>]'.
    .PARAMETER DisplayName
        Exact display name to match (server-side $filter).
    .PARAMETER WingetId
        Match the winget id the toolkit stamped into `notes` (client-side — `notes` is not reliably
        filterable server-side).
    .PARAMETER All
        Return every Win32 app (used by the TUI picker).
    .PARAMETER BaseUri
        Graph base. Defaults to the /beta deviceAppManagement root used elsewhere.
    .OUTPUTS
        PSCustomObject[]: Id, DisplayName, DisplayVersion, Publisher, Notes. Empty array if nothing matched.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param(
        [string]$DisplayName,
        [string]$WingetId,
        [switch]$All,
        [string]$BaseUri = 'https://graph.microsoft.com/beta/deviceAppManagement'
    )

    # $select is evaluated against the BASE type. `isof(...)` FILTERS the collection but does not CAST it,
    # so /mobileApps is still a collection of microsoft.graph.mobileApp — and selecting a property that only
    # exists on the derived win32LobApp is a hard 400:
    # "Could not find a property named 'displayVersion' on type 'microsoft.graph.mobileApp'"
    # id / displayName / publisher / notes are all mobileApp properties. displayVersion is NOT — and we do
    # not need it: dependency resolution is version-agnostic (any published version satisfies it).
    $select = 'id,displayName,publisher,notes'
    $uri    = "$BaseUri/mobileApps?`$filter=isof('microsoft.graph.win32LobApp')&`$select=$select"

    if ($DisplayName) {
        # OData: double every single quote. Without this, "O'Reilly" is a malformed (injectable) filter.
        $escaped = $DisplayName -replace "'", "''"
        $uri     = "$BaseUri/mobileApps?`$filter=isof('microsoft.graph.win32LobApp') and displayName eq '$escaped'&`$select=$select"
    }

    $found = [System.Collections.Generic.List[object]]::new()
    while ($uri) {
        $resp = Invoke-MgGraphRequest -Method GET -Uri $uri -OutputType PSObject
        foreach ($a in @($resp.value)) {
            $found.Add([pscustomobject]@{
                Id             = $a.id
                DisplayName    = $a.displayName
                DisplayVersion = $a.displayVersion
                Publisher      = $a.publisher
                Notes          = [string]$a.notes
            })
        }
        $uri = $resp.'@odata.nextLink'
    }

    # Never depend on the "(Update)" twin — its requirement rule gates it to devices that already have
    # the app, so as a dependency it could never be satisfied on a clean device.
    $result = @($found | Where-Object {
        -not ($_.DisplayName -like '* (Update)') -and ($_.Notes -notmatch '(^|;\s*)update(\s*;|$)')
    })

    if ($WingetId) {
        # `notes` carries 'win32-toolkit; <wingetId>' for toolkit-published apps — the only winget-id
        # breadcrumb that exists in the tenant. Match the whole segment, not a substring.
        $result = @($result | Where-Object {
            @($_.Notes -split ';' | ForEach-Object { $_.Trim() }) -contains $WingetId
        })
    }

    return @($result)
}