Public/Get-MacRosettaAudit.ps1

function Get-MacRosettaAudit {
    <#
    .SYNOPSIS
        Scans macOS for Intel-only apps, LaunchAgents/Daemons, and running processes to identify Rosetta 2 usage.

    .DESCRIPTION
        Get-MacRosettaAudit inspects application bundles, launch items, and running processes on an Apple Silicon
        Mac to determine which binaries require Rosetta 2 (Intel-only), are Universal, or run natively.
        Results include code signature information, version, vendor, and dependency data.
        Output objects are pipeline-compatible and can be piped directly into Export-MacRosettaAuditReport.

    .PARAMETER IncludeSystemPaths
        Also scans /System/Applications, /System/Library/CoreServices, and system-level launch item directories.
        Requires Full Disk Access for complete results.

    .PARAMETER IncludeDependencies
        Uses otool -L to resolve shared library dependencies for each binary and flags Intel-only dependencies.
        Significantly increases scan time.

    .PARAMETER IncludeRunningProcesses
        Scans currently running processes via ps and detects active Rosetta usage.
        Enabled by default. Set to $false to skip process scanning.

    .PARAMETER IncludeLaunchItems
        Scans /Library/LaunchAgents, /Library/LaunchDaemons, and ~/Library/LaunchAgents.
        Enabled by default. Set to $false to skip launch item scanning.

    .OUTPUTS
        [pscustomobject] with properties: Category, DisplayName, Vendor, Version, BundleId,
        Status, RosettaNeeded, CurrentlyUsingRosetta, Architectures, Signed, and more.

    .EXAMPLE
        Get-MacRosettaAudit

        Full scan: applications, launch items, and running processes.

    .EXAMPLE
        Get-MacRosettaAudit -IncludeSystemPaths -IncludeDependencies

        Full scan including system paths and shared library dependency analysis.

    .EXAMPLE
        Get-MacRosettaAudit | Where-Object { $_.RosettaNeeded }

        Show only Intel-only binaries.

    .EXAMPLE
        Get-MacRosettaAudit | Export-MacRosettaAuditReport -Format All -OpenReport

        Full scan, then export HTML, CSV, and JSON reports and open the HTML report.

    .EXAMPLE
        Get-MacRosettaAudit -IncludeRunningProcesses $false -IncludeLaunchItems $false

        Scan applications only.

    .NOTES
        Requires macOS with PowerShell 7+.
        Uses only macOS built-in tools: file, lipo, otool, codesign, mdls, PlistBuddy, ps, sysctl.
        Full Disk Access may be required to scan all application directories and launch items.
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [switch]$IncludeSystemPaths,
        [switch]$IncludeDependencies,

        [bool]$IncludeRunningProcesses = $true,
        [bool]$IncludeLaunchItems      = $true
    )

    $ErrorActionPreference = 'SilentlyContinue'

    # Versions-Banner
    $moduleVersion = (Get-Module -Name MacRosettaAudit).Version.ToString()
    Write-Host "[MacRosettaAudit] v$moduleVersion — macOS Rosetta Audit" -ForegroundColor Cyan
    Write-Host ""

    # Privilege check — LaunchDaemons and system paths require root on macOS
    $isRoot = ((& /usr/bin/id -u) -eq '0')

    Write-Verbose "Apple Silicon detected: $(Test-IsAppleSilicon)"
    Write-Verbose "Running as root: $isRoot"
    Write-Verbose "IncludeSystemPaths: $($IncludeSystemPaths.IsPresent) IncludeDependencies: $($IncludeDependencies.IsPresent) IncludeLaunchItems: $IncludeLaunchItems IncludeRunningProcesses: $IncludeRunningProcesses"

    if (-not $isRoot) {
        Write-Host "[MacRosettaAudit] Laeuft ohne root-Rechte." -ForegroundColor Yellow
        Write-Host " LaunchDaemons koennen unvollstaendig sein — einige Plists sind nur als root lesbar." -ForegroundColor Yellow
        Write-Host " Tipp fuer vollstaendige Ergebnisse:" -ForegroundColor DarkYellow
        Write-Host " sudo pwsh -NoProfile -Command 'Import-Module /pfad/MacRosettaAudit.psd1; Get-MacRosettaAudit'" -ForegroundColor DarkYellow
        Write-Host ""
    } else {
        Write-Verbose "Privileged (root) — alle Pfade erreichbar."
    }

    $records = [System.Collections.Generic.List[object]]::new()

    $appRoots = @(
        "/Applications",
        "/Library/Application Support"
    )

    if ($IncludeSystemPaths) {
        $appRoots += @(
            "/System/Applications",
            "/System/Library/CoreServices"
        )
    }

    # Pfad zu den privaten Funktionen fuer die Weitergabe an parallele Runspaces
    $moduleBase  = (Get-Module -Name MacRosettaAudit).ModuleBase
    $privateDir  = Join-Path $moduleBase 'Private'
    $includeDeps = $IncludeDependencies.IsPresent

    # Private Funktionen einmalig als Scriptblocks laden (kein Disk-I/O pro Item im Parallel-Block)
    $privateScripts = @(Get-ChildItem -Path $privateDir -Filter "*.ps1" |
        ForEach-Object { [scriptblock]::Create((Get-Content $_.FullName -Raw)) })

    # Phase 1: Filesystem-Scan (alle Roots sequentiell — schnell)
    Write-Host "[MacRosettaAudit] Suche App-Bundles..." -ForegroundColor Cyan

    $allAppPaths     = [System.Collections.Generic.List[string]]::new()
    $rootBundleCount = [ordered]@{}

    foreach ($root in $appRoots) {
        if (-not (Test-Path -LiteralPath $root)) {
            Write-Verbose " Pfad nicht vorhanden, wird uebersprungen: $root"
            continue
        }
        # -Depth 4: findet /Applications/App.app (Tiefe 1), Helper-Apps in Bundles (bis 4)
        $paths = @(Get-ChildItem -LiteralPath $root -Recurse -Depth 4 -Directory -Filter "*.app" -Force |
            Select-Object -ExpandProperty FullName)
        $rootBundleCount[$root] = $paths.Count
        foreach ($p in $paths) { $allAppPaths.Add($p) }
        Write-Verbose " $root : $($paths.Count) App-Bundles"
    }

    $totalApps = $allAppPaths.Count
    Write-Host "[MacRosettaAudit] $totalApps App-Bundles gefunden — starte Analyse ($([Math]::Min(8, $totalApps)) Threads)..." -ForegroundColor Cyan

    # Phase 2: Binary-Analyse (parallel, max. 8 Threads, mit Fortschrittsanzeige)
    # Hinweis: ForEach-Object -Parallel unterstuetzt keine -ErrorAction;
    # Fehlerbehandlung via try/catch im Block. Verbose-Ausgabe im Sammel-Loop.
    $processed = 0

    $allAppPaths | ForEach-Object -Parallel {
        $appPath        = $_
        $privateScripts = $using:privateScripts
        $includeDeps    = $using:includeDeps

        # Verbose-Overhead in Runspaces unterdrücken — Ausgabe erfolgt im Sammel-Loop
        $VerbosePreference = 'SilentlyContinue'

        # Private-Funktionen nur einmal pro Runspace laden (Runspaces werden wiederverwendet)
        if (-not (Get-Command Get-AppMainExecutable -ErrorAction SilentlyContinue)) {
            foreach ($sb in $privateScripts) { . $sb }
        }

        $name = Split-Path $appPath -Leaf
        try {
            $exe = Get-AppMainExecutable -AppPath $appPath
            if ($exe) {
                New-AuditRecord `
                    -Category            "Application" `
                    -Name                $name `
                    -SourcePath          $appPath `
                    -BundlePath          $appPath `
                    -ExecutablePath      $exe `
                    -IncludeDependencies $includeDeps
            }
        }
        catch { <# Einzelne Fehler still ignorieren #> }
    } -ThrottleLimit 8 |
        Where-Object { $null -ne $_ } |
        ForEach-Object {
            $processed++
            Write-Progress -Activity "MacRosettaAudit — App-Analyse" `
                -Status    "Analysiert: $processed / $totalApps" `
                -PercentComplete ([int]($processed * 100 / [Math]::Max($totalApps, 1))) `
                -CurrentOperation $_.DisplayName
            Write-Verbose " $($_.DisplayName) — $($_.Status) / $($_.Architectures)"
            $records.Add($_)
        }

    Write-Progress -Activity "MacRosettaAudit — App-Analyse" -Completed

    # Ergebnis-Zusammenfassung pro Verzeichnis
    foreach ($root in $rootBundleCount.Keys) {
        Write-Host " $root — $($rootBundleCount[$root]) Bundle(s)" -ForegroundColor Gray
    }
    Write-Host " Ausfuehrbare Binaries analysiert: $processed" -ForegroundColor Gray

    if ($IncludeLaunchItems) {
        Write-Host "[MacRosettaAudit] Scanne LaunchAgents und LaunchDaemons..." -ForegroundColor Cyan
        Write-Verbose "Scanning LaunchAgents and LaunchDaemons..."

        $launchRoots = @(
            "/Library/LaunchAgents",
            "/Library/LaunchDaemons",
            "$HOME/Library/LaunchAgents"
        )

        if ($IncludeSystemPaths) {
            $launchRoots += @(
                "/System/Library/LaunchAgents",
                "/System/Library/LaunchDaemons"
            )
        }

        foreach ($root in $launchRoots) {
            $isDaemonPath = $root -match 'LaunchDaemon'

            if (-not $isRoot -and $isDaemonPath) {
                Write-Verbose " [root empfohlen] $root — ohne root ggf. unvollstaendig"
            }

            if (-not (Test-Path -LiteralPath $root)) {
                Write-Verbose " Pfad nicht vorhanden, wird uebersprungen: $root"
                continue
            }

            $plists = @(Get-ChildItem -LiteralPath $root -File -Filter "*.plist" -Force)
            $totalPlists  = $plists.Count
            $plistIdx     = 0
            $beforeCount  = $records.Count
            Write-Verbose " $root : $totalPlists Plist(s)"

            foreach ($plistFile in $plists) {
                $plistIdx++
                Write-Progress -Activity "MacRosettaAudit — LaunchItems" `
                    -Status    "$plistIdx / $totalPlists" `
                    -PercentComplete ([int]($plistIdx * 100 / [Math]::Max($totalPlists, 1))) `
                    -CurrentOperation $plistFile.Name

                $plist = $plistFile.FullName
                Write-Verbose " -> $($plistFile.Name)"
                $exe   = Get-LaunchItemExecutable -PlistPath $plist

                if (-not [string]::IsNullOrWhiteSpace($exe)) {
                    $record = New-AuditRecord `
                        -Category            "LaunchItem" `
                        -Name                $plistFile.Name `
                        -SourcePath          $plist `
                        -PlistPath           $plist `
                        -ExecutablePath      $exe `
                        -IncludeDependencies $IncludeDependencies.IsPresent
                    $records.Add($record)
                    Write-Verbose " Executable: $exe Status: $($record.Status)"
                }
            }

            Write-Progress -Activity "MacRosettaAudit — LaunchItems" -Completed

            $launchFound = $records.Count - $beforeCount
            $rootHint    = if (-not $isRoot -and $isDaemonPath) { ' (ggf. unvollstaendig ohne root)' } else { '' }
            Write-Host " $root — $launchFound Item(s)$rootHint" -ForegroundColor Gray
        }
    }

    if ($IncludeRunningProcesses) {
        Write-Host "[MacRosettaAudit] Scanne laufende Prozesse..." -ForegroundColor Cyan
        Write-Verbose "Scanning running processes and detecting active Rosetta usage..."
        $beforeCount = $records.Count

        # Pruefe ob ps den 'arch' Keyword kennt.
        # Achtung: bei unbekanntem Keyword gibt ps die Keyword-Liste auf STDOUT aus (kein Fehler auf stderr!).
        # Deshalb auf konkreten Arch-Wert pruefen, nicht auf non-empty.
        $currentPid = [System.Diagnostics.Process]::GetCurrentProcess().Id
        $archTest   = (/bin/ps -o arch= -p $currentPid 2>$null)
        $archKwdOk  = ($archTest -match '^(arm64|x86_64|i386)$')
        Write-Verbose " ps arch-Keyword verfuegbar: $archKwdOk"

        if ($archKwdOk) {
            # Optimale Erkennung: arch-Spalte zeigt direkt x86_64 (Rosetta) vs arm64
            $processLines = /bin/ps -axo pid=,user=,arch=,comm= 2>$null

            foreach ($line in $processLines) {
                if ([string]::IsNullOrWhiteSpace($line)) { continue }

                $parts = $line.Trim() -split "\s+", 4
                if ($parts.Count -lt 4) { continue }

                $pid         = $parts[0]
                $user        = $parts[1]
                $processArch = $parts[2]
                $comm        = $parts[3]

                if (-not (Test-Path -LiteralPath $comm)) { continue }

                $records.Add(
                    (New-AuditRecord `
                        -Category            "RunningProcess" `
                        -Name                "PID $pid" `
                        -SourcePath          $comm `
                        -ExecutablePath      $comm `
                        -ProcessId           $pid `
                        -ProcessUser         $user `
                        -ProcessArchitecture $processArch `
                        -IncludeDependencies $IncludeDependencies.IsPresent)
                )
                Write-Verbose " PID $pid ($user) [$processArch]: $comm"
            }
        }
        else {
            # Fallback: kein arch-Keyword — nur Intel-only Binaries werden gemeldet (via RosettaNeeded).
            # Performance: Binaries deduplizieren — jede unique Binary nur einmal analysieren.
            Write-Verbose " Fallback: ps ohne arch — nur Intel-only Prozesse (dedupliziert nach Binary)"
            $processLines = /bin/ps -axo pid=,user=,comm= 2>$null

            $procByComm = [System.Collections.Generic.Dictionary[string,System.Collections.Generic.List[pscustomobject]]]::new()
            foreach ($line in $processLines) {
                if ([string]::IsNullOrWhiteSpace($line)) { continue }
                $parts = $line.Trim() -split "\s+", 3
                if ($parts.Count -lt 3) { continue }
                $comm = $parts[2]
                if (-not (Test-Path -LiteralPath $comm)) { continue }
                if (-not $procByComm.ContainsKey($comm)) {
                    $procByComm[$comm] = [System.Collections.Generic.List[pscustomobject]]::new()
                }
                $procByComm[$comm].Add([pscustomobject]@{ Pid = $parts[0]; User = $parts[1] })
            }
            Write-Verbose " $($procByComm.Count) unique Binaries aus $($processLines.Count) Prozessen"

            foreach ($comm in $procByComm.Keys) {
                $archInfo = Get-BinaryArchitecture -Path $comm
                if (-not $archInfo.RosettaNeeded) { continue }   # native / universal: ueberspringen

                foreach ($entry in $procByComm[$comm]) {
                    $records.Add(
                        (New-AuditRecord `
                            -Category            "RunningProcess" `
                            -Name                "PID $($entry.Pid)" `
                            -SourcePath          $comm `
                            -ExecutablePath      $comm `
                            -ProcessId           $entry.Pid `
                            -ProcessUser         $entry.User `
                            -ProcessArchitecture "" `
                            -IncludeDependencies $IncludeDependencies.IsPresent)
                    )
                    Write-Verbose " PID $($entry.Pid) ($($entry.User)) [intel-only]: $comm"
                }
            }
        }

        $procFound   = $records.Count - $beforeCount
        $rosettaProc = @($records | Select-Object -Last $procFound | Where-Object { $_.CurrentlyUsingRosetta }).Count
        Write-Host " Prozesse — $procFound gefunden, $rosettaProc aktiv unter Rosetta" -ForegroundColor Gray
    }

    # Summary output
    $total        = $records.Count
    $rosettaCount = @($records | Where-Object { $_.RosettaNeeded }).Count
    $activeCount  = @($records | Where-Object { $_.CurrentlyUsingRosetta }).Count
    $nativeCount  = @($records | Where-Object { $_.Status -eq 'native' }).Count
    $uniCount     = @($records | Where-Object { $_.Status -eq 'universal' }).Count

    Write-Host ""
    Write-Host "[MacRosettaAudit] Scan abgeschlossen — $total Eintraege gesamt" -ForegroundColor Cyan
    Write-Host (" Rosetta erforderlich : {0,4}" -f $rosettaCount) -ForegroundColor $(if ($rosettaCount -gt 0) { 'Yellow' } else { 'Green' })
    Write-Host (" Aktuell unter Rosetta : {0,4}" -f $activeCount)  -ForegroundColor $(if ($activeCount  -gt 0) { 'Red'    } else { 'Green' })
    Write-Host (" Native arm64 : {0,4}" -f $nativeCount)  -ForegroundColor Gray
    Write-Host (" Universal : {0,4}" -f $uniCount)     -ForegroundColor Gray

    if (-not $isRoot) {
        $psd1Path = Join-Path $moduleBase 'MacRosettaAudit.psd1'
        Write-Host ""
        Write-Host " [!] Fuer vollstaendige LaunchDaemon-Abdeckung als root ausfuehren:" -ForegroundColor Yellow
        Write-Host " sudo pwsh -NoProfile -Command `"Import-Module '$psd1Path'; Get-MacRosettaAudit`"" -ForegroundColor DarkYellow
    }

    Write-Host ""

    # Deduplicate and emit to pipeline
    $records |
        Sort-Object Category, DisplayName, ExecutablePath, ProcessId -Unique
}