Public/Install-MacOSD2XXDrivers.ps1

#Requires -Version 5.1
function Install-MacOSD2XXDrivers {
    <#
    .SYNOPSIS
    Downloads and installs the FTDI D2XX native library on macOS.

    .DESCRIPTION
    Downloads the official FTDI D2XX package for macOS, mounts the DMG, copies the
    versioned dylib to /usr/local/lib/, creates the libftd2xx.dylib symlink, and
    copies it into the PSGadget module's lib/net8/ directory so the module can load
    it directly on the next Import-Module.

    Requires Administrator (sudo) access. You will be prompted for your macOS password.

    This function only runs on macOS. Run Test-PsGadgetEnvironment after installation
    to verify the library is found.

    .PARAMETER Version
    D2XX library version to install. Defaults to the current known-good release (1.4.30).

    .PARAMETER SkipModuleCopy
    Skip copying the dylib into the module's lib/net8/ directory.

    .EXAMPLE
    Install-MacOSD2XXDrivers
    # Downloads D2XX 1.4.30, installs to /usr/local/lib/, copies into PSGadget module.

    .EXAMPLE
    Install-MacOSD2XXDrivers -WhatIf
    # Show what would be done without making any changes.

    .NOTES
    After installation, reimport the module:
        Import-Module PSGadget -Force
        Test-PsGadgetEnvironment

    If Get-FtdiDevice still shows no devices, the AppleUSBFTDI kext may be claiming
    the device before D2XX can open it. Unload it with:
        sudo kextunload -b com.apple.driver.AppleUSBFTDI
    #>


    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory = $false)]
        [string]$Version = '1.4.30',

        [Parameter(Mandatory = $false)]
        [switch]$SkipModuleCopy
    )

    # macOS-only gate
    $isMac = (-not ([System.Environment]::OSVersion.Platform -eq 'Win32NT')) -and
             (& { try { (& uname -s 2>$null).Trim() -eq 'Darwin' } catch { $false } })
    if (-not $isMac) {
        throw "Install-MacOSD2XXDrivers is for macOS only. For Linux, see: Get-Help Test-PsGadgetEnvironment -Full"
    }

    $dmgUrl      = "https://ftdichip.com/wp-content/uploads/2024/04/D2XX$Version.dmg"
    $dmgPath     = "/tmp/D2XX$Version.dmg"
    $libName     = "libftd2xx.$Version.dylib"
    $libDest     = "/usr/local/lib/$libName"
    $symlinkDest = '/usr/local/lib/libftd2xx.dylib'
    $mountPoint  = $null

    if (-not $PSCmdlet.ShouldProcess('/usr/local/lib', "Install FTDI D2XX $Version")) { return }

    # -- Already installed? ----------------------------------------------------------
    if ((Test-Path $libDest) -and (Test-Path $symlinkDest)) {
        Write-Host "D2XX $Version is already installed at $libDest"
        Write-Host "Run Test-PsGadgetEnvironment to verify, or delete $libDest to force reinstall."
        return
    }

    # -- Download --------------------------------------------------------------------
    Remove-Item $dmgPath -Force -ErrorAction SilentlyContinue
    Write-Host "Downloading FTDI D2XX $Version..."
    Write-Verbose " URL: $dmgUrl"
    & curl -fsSL $dmgUrl -o $dmgPath
    if ($LASTEXITCODE -ne 0) {
        throw "curl failed (exit $LASTEXITCODE). Check your internet connection and try again."
    }

    # -- Mount -----------------------------------------------------------------------
    # Use -mountpoint to bypass disk arbitration entirely -- avoids /Volumes/ naming
    # conflicts and the race condition where hdiutil returns before the volume registers.
    $mountPoint = "/tmp/psgadget_d2xx_$PID"
    New-Item -ItemType Directory -Path $mountPoint -Force | Out-Null

    Write-Host "Mounting DMG..."
    & hdiutil attach $dmgPath -nobrowse -readonly -mountpoint $mountPoint 2>&1 | Out-Null
    if ($LASTEXITCODE -ne 0) {
        Remove-Item $mountPoint -Force -ErrorAction SilentlyContinue
        throw "hdiutil attach failed (exit $LASTEXITCODE). The DMG may be corrupt -- delete '$dmgPath' and retry."
    }
    Write-Verbose " Mounted at: $mountPoint"

    try {
        # -- Find the dylib ----------------------------------------------------------
        # Try the known FTDI DMG layout first (avoids Get-ChildItem -Recurse which
        # can hang on macOS mounted volumes due to Spotlight/xattr enumeration).
        $knownPath = Join-Path $mountPoint 'release' 'build' "libftd2xx.$Version.dylib"
        $dylibFile = $null
        if (Test-Path $knownPath) {
            $dylibFile = Get-Item $knownPath
        } else {
            # Fallback: native find (faster and more reliable than PS recursive enum on volumes)
            $findResult = & find $mountPoint -name 'libftd2xx.*.dylib' -not -name '*.dSYM' 2>$null |
                          Select-Object -First 1
            if ($findResult) { $dylibFile = Get-Item $findResult }
        }
        if (-not $dylibFile) {
            throw "libftd2xx dylib not found inside mounted DMG at '$mountPoint'. Try running: find '$mountPoint' -name 'libftd2xx*.dylib'"
        }
        Write-Verbose " Found dylib: $($dylibFile.FullName)"

        # -- Install -----------------------------------------------------------------
        Write-Host "Installing (sudo required -- enter your macOS password if prompted)..."
        & sudo mkdir -p /usr/local/lib
        if ($LASTEXITCODE -ne 0) { throw "sudo mkdir /usr/local/lib failed" }

        & sudo cp $dylibFile.FullName $libDest
        if ($LASTEXITCODE -ne 0) { throw "sudo cp '$($dylibFile.FullName)' '$libDest' failed" }

        & sudo ln -sf $libDest $symlinkDest
        if ($LASTEXITCODE -ne 0) { throw "sudo ln -sf '$libDest' '$symlinkDest' failed" }

        Write-Verbose " Installed: $libDest"
        Write-Verbose " Symlink: $symlinkDest -> $libDest"

        # -- Copy into module lib/net8/ ----------------------------------------------
        if (-not $SkipModuleCopy) {
            $net8Dir  = Join-Path $PSScriptRoot '..' 'lib' 'net8'
            $net8Dest = Join-Path $net8Dir 'libftd2xx.dylib'
            if (Test-Path $net8Dir) {
                Copy-Item -Path $libDest -Destination $net8Dest -Force
                Write-Verbose " Module copy: $net8Dest"
            } else {
                Write-Warning "Module lib/net8/ directory not found at '$net8Dir' -- skipping module copy."
            }
        }

        # -- AppleUSBFTDI kext warning -----------------------------------------------
        try {
            $kextOut = & kextstat 2>$null
            if ($kextOut -match 'AppleUSBFTDI') {
                Write-Warning (
                    "AppleUSBFTDI kext is loaded. It may claim the FTDI device before D2XX can open it.`n" +
                    "If Get-FtdiDevice returns no results, unload it:`n" +
                    " sudo kextunload -b com.apple.driver.AppleUSBFTDI"
                )
            }
        } catch {}

        # -- Unmount before success message so output is ordered cleanly ------------
        if ($mountPoint) {
            Write-Verbose "Unmounting $mountPoint..."
            & hdiutil detach $mountPoint -quiet 2>$null | Out-Null
            Remove-Item $mountPoint -Force -ErrorAction SilentlyContinue
            $mountPoint = $null
        }

        # -- Done --------------------------------------------------------------------
        # Detect whether running from a local path or an installed module location
        # so the reimport instruction matches what will actually work.
        $psmPath  = Join-Path $PSScriptRoot '..' 'PSGadget.psm1'
        $importCmd = if (Test-Path $psmPath) {
            "Import-Module '$((Resolve-Path $psmPath).Path)' -Force"
        } else {
            'Import-Module PSGadget -Force'
        }

        Write-Host ""
        Write-Host "D2XX $Version installed successfully."
        Write-Host "Next steps in pwsh:"
        Write-Host " $importCmd"
        Write-Host " Test-PsGadgetEnvironment"

    } finally {
        if ($mountPoint) {
            & hdiutil detach $mountPoint -quiet 2>$null | Out-Null
            Remove-Item $mountPoint -Force -ErrorAction SilentlyContinue
        }
        Remove-Item $dmgPath -Force -ErrorAction SilentlyContinue
    }
}