Private/IWAPG-Hlp-Tool.ps1
|
function Get-ExeVersion { <# .SYNOPSIS Returns file version (FileVersion preferred, then ProductVersion); $null if not available. .DESCRIPTION - Uses [System.Diagnostics.FileVersionInfo] to read version metadata. - Prefers FileVersion, falls back to ProductVersion. .PARAMETER Path Absolute path to the executable file. .OUTPUTS [string] or $null #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path ) try { if (-not (Test-Path $Path)) { return $null } $vi = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($Path) if ($vi.FileVersion -and $vi.FileVersion.Trim()) { return $vi.FileVersion.Trim() } if ($vi.ProductVersion -and $vi.ProductVersion.Trim()) { return $vi.ProductVersion.Trim() } return $null } catch { return $null } } function Show-ToolVersion { <# .SYNOPSIS Updates the provided TextBlock with IntuneWinAppUtil version text. .PARAMETER Path Full path to IntuneWinAppUtil.exe (can be $null/empty). .PARAMETER Target WPF TextBlock (or any object with a 'Text' property) to update. #> param( [string]$Path, [Parameter(Mandatory)][object]$Target ) $ver = if ($Path) { Get-ExeVersion -Path $Path } else { $null } $Target.Text = if ($ver) { "IntuneWinAppUtil version: $ver" } else { "IntuneWinAppUtil version: (not detected)" } } function Invoke-DownloadIntuneTool { <# .SYNOPSIS Downloads the latest IntuneWinAppUtil.exe from GitHub and caches it under %APPDATA%\IntuneWinAppUtilGUI\bin. .DESCRIPTION - Forces TLS 1.2 for GitHub downloads. - Creates (or ensures) the bin directory under $env:APPDATA\IntuneWinAppUtilGUI\bin. - Removes any stale IntuneWinAppUtil.exe in the bin directory. - Downloads the repository master ZIP, extracts it to a unique temp folder, locates IntuneWinAppUtil.exe, and copies it into the bin directory. - Cleans up all temp files/folders in a finally block. - Returns the full path to the cached IntuneWinAppUtil.exe. - Throws on failure (caller can catch and show a message box). .PARAMETER DestinationRoot Optional base path for the cache (default: $env:APPDATA\IntuneWinAppUtilGUI). .PARAMETER RepoZipUrl Optional ZIP URL (default: master branch of Microsoft-Win32-Content-Prep-Tool). .OUTPUTS [string] Full path to IntuneWinAppUtil.exe. .EXAMPLE $exe = Invoke-DownloadIntuneTool # $exe now points to %APPDATA%\IntuneWinAppUtilGUI\bin\IntuneWinAppUtil.exe #> [CmdletBinding()] param( [string]$DestinationRoot = (Join-Path $env:APPDATA 'IntuneWinAppUtilGUI'), [string]$RepoZipUrl = 'https://github.com/microsoft/Microsoft-Win32-Content-Prep-Tool/archive/refs/heads/master.zip' ) # Ensure TLS 1.2 for GitHub try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} $binDir = Join-Path $DestinationRoot 'bin' $exePath = Join-Path $binDir 'IntuneWinAppUtil.exe' $tempZip = Join-Path $env:TEMP ("IntuneWinAppUtil-{0}.zip" -f ([guid]::NewGuid())) $tempDir = Join-Path $env:TEMP ("IntuneExtract-{0}" -f ([guid]::NewGuid())) try { # Prepare target folder and clean stale exe New-Item -ItemType Directory -Path $binDir -Force | Out-Null if (Test-Path $exePath) { Remove-Item $exePath -Force -ErrorAction SilentlyContinue } # Download Invoke-WebRequest -Uri $RepoZipUrl -OutFile $tempZip -UseBasicParsing -ErrorAction Stop # Extract (fallback if overwrite overload isn't available) New-Item -ItemType Directory -Path $tempDir -Force | Out-Null $zipType = [System.IO.Compression.ZipFile] $hasOverwrite = $zipType.GetMethod( 'ExtractToDirectory', [Reflection.BindingFlags]'Public, Static', $null, @([string], [string], [bool]), $null ) if ($hasOverwrite) { [System.IO.Compression.ZipFile]::ExtractToDirectory($tempZip, $tempDir, $true) } else { [System.IO.Compression.ZipFile]::ExtractToDirectory($tempZip, $tempDir) } # Locate exe $found = Get-ChildItem -Path $tempDir -Recurse -Filter 'IntuneWinAppUtil.exe' -File -ErrorAction SilentlyContinue | Select-Object -First 1 if (-not $found) { throw "IntuneWinAppUtil.exe not found in the extracted archive." } # Copy into cache Copy-Item -Path $found.FullName -Destination $exePath -Force return $exePath } catch { throw $_ } finally { # Best-effort cleanup foreach ($p in @($tempZip, $tempDir)) { try { if (Test-Path $p) { if (Test-Path $p -PathType Container) { Remove-Item $p -Recurse -Force -ErrorAction SilentlyContinue } else { Remove-Item $p -Force -ErrorAction SilentlyContinue } } } catch {} } } } function Get-IntuneWinAppUtilExitMessage { <# .SYNOPSIS Builds a readable error message for a non-zero IntuneWinAppUtil.exe exit code. .DESCRIPTION IntuneWinAppUtil returns HRESULTs such as -2147024891 (0x80070005). For Win32 HRESULTs (0x8007xxxx) the Windows error text is added, plus a hint for access-denied and sharing violations, which are usually caused by sync clients or antivirus locking files. .PARAMETER ExitCode The process exit code. .OUTPUTS [string] #> [CmdletBinding()] param( [Parameter(Mandatory)][int]$ExitCode ) $hex = '0x{0:X8}' -f $ExitCode $msg = "IntuneWinAppUtil exited with code $ExitCode ($hex)." if (($ExitCode -band 0xFFFF0000) -eq (0x80070000 -as [int])) { $win32Code = $ExitCode -band 0xFFFF $msg += "`n`n" + ([System.ComponentModel.Win32Exception]::new($win32Code)).Message # 5 = ERROR_ACCESS_DENIED, 32 = ERROR_SHARING_VIOLATION, 33 = ERROR_LOCK_VIOLATION if ($win32Code -in 5, 32, 33) { $msg += "`n`nA file in the source folder may be locked or not readable. Close programs that use it, and pause sync clients (Dropbox, OneDrive) or antivirus scans on the source folder, then try again." } } $msg } function Publish-IntuneWinPackage { <# .SYNOPSIS Moves a finished .intunewin package to its final path, retrying while the file is locked. .DESCRIPTION With -CopyFirst (package built in a staging folder, possibly on another volume), the file is copied next to the destination as '~iwa-<guid>.partial' and then renamed, so sync clients never see a half-written file under the final name; the source is deleted only after the rename succeeds. Without -CopyFirst the file is simply moved (same-folder rename). If the destination name is taken (also by another GUI instance publishing at the same time), the first free '<name>_1', '<name>_2', ... is used; each attempt picks a free name again. Throws when all attempts fail; the source file is never removed in that case. .PARAMETER Path Full path of the generated package. .PARAMETER Destination Desired final path of the package. .OUTPUTS [string] The path the package was published to. .PARAMETER CopyFirst Copy under a temporary name, then rename. Use when Path is in a staging folder. .PARAMETER MaxAttempts Attempts for each step (default 10), with an increasing pause between them. .PARAMETER OnProgress Optional scriptblock called as & $OnProgress <bytesCopied> <totalBytes> after each 4 MB block of the copy (e.g. to update a progress bar and let the UI repaint during a slow copy). #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Destination, [switch]$CopyFirst, [int]$MaxAttempts = 10, [scriptblock]$OnProgress ) # Runs $Action until it succeeds or attempts run out; rethrows the last error. $retry = { param([scriptblock]$Action) for ($attempt = 1; ; $attempt++) { try { & $Action; return } catch { if ($attempt -ge $MaxAttempts) { throw } Start-Sleep -Milliseconds (250 * $attempt) } } } # Moves $From to the first free name derived from $Destination; the chosen path is kept in $state. $state = @{ Final = $null } $moveToFreeName = { param([string]$From) $dir = Split-Path $Destination -Parent $base = [System.IO.Path]::GetFileNameWithoutExtension($Destination) $ext = [System.IO.Path]::GetExtension($Destination) $candidate = $Destination for ($n = 1; Test-Path -LiteralPath $candidate; $n++) { # Keep '<base>_<n><ext>' within the 255-character file name limit by shortening the base. $suffix = "_$n$ext" $candidate = Join-Path $dir ($base.Substring(0, [Math]::Min($base.Length, 255 - $suffix.Length)) + $suffix) } Move-Item -LiteralPath $From -Destination $candidate -ErrorAction Stop $state.Final = $candidate } if (-not $CopyFirst) { & $retry { & $moveToFreeName $Path } return $state.Final } # Unique per run, so two GUI instances publishing the same name never share a partial file; # short and independent of the final name, so it never exceeds the 255-character name limit. $partial = Join-Path (Split-Path $Destination -Parent) ("~iwa-{0}.partial" -f [guid]::NewGuid().ToString('N')) try { & $retry { # Block copy so OnProgress can run during large or slow copies; a failed attempt starts over. Remove-Item -LiteralPath $partial -Force -ErrorAction SilentlyContinue $in = [System.IO.File]::OpenRead($Path) try { $out = [System.IO.File]::Open($partial, 'CreateNew', 'Write', 'None') try { $buffer = New-Object byte[] (4MB) $total = $in.Length $copied = [long]0 while (($read = $in.Read($buffer, 0, $buffer.Length)) -gt 0) { $out.Write($buffer, 0, $read) $copied += $read if ($OnProgress) { & $OnProgress $copied $total } } } finally { $out.Dispose() } } finally { $in.Dispose() } } & $retry { & $moveToFreeName $partial } } catch { Remove-Item -LiteralPath $partial -Force -ErrorAction SilentlyContinue throw } Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue $state.Final } function Get-IntuneWinAppUtilWorkFolder { <# .SYNOPSIS Returns the working folder a run of IntuneWinAppUtil.exe used, read from its console output. .DESCRIPTION The tool compresses the source into '<temp>\<guid>\IntuneWinPackage\Contents\IntunePackage.intunewin' and does not always remove <guid>. Only the GUID is read from the output (so quotes or apostrophes in the source path cannot break the match) and joined to the temp folder the tool inherits from us (which may be printed in long or 8.3 form), so only this run's folder is ever returned. .PARAMETER ToolOutput Captured standard output of the tool. .PARAMETER TempPath The temp folder the tool used (default: this process's temp folder). .OUTPUTS [string] Full path of the work folder, or $null when it cannot be determined. #> [CmdletBinding()] param( [AllowEmptyString()][string]$ToolOutput, [string]$TempPath = [System.IO.Path]::GetTempPath() ) # Anchored to the end of the "Compressing the source folder" line (the destination field), so a # similar-looking segment inside the source path can never be picked. if ($ToolOutput -match "(?m)Compressing the source folder .*\\(?<guid>[0-9a-fA-F]{8}-(?:[0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})\\IntuneWinPackage\\Contents\\IntunePackage\.intunewin'\s*$") { return (Join-Path $TempPath $Matches['guid']) } $null } function Read-SharedTextFile { <# .SYNOPSIS Reads a text file that another process may still be writing; returns '' when it cannot be read. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Path ) try { $fs = [System.IO.FileStream]::new($Path, 'Open', 'Read', ([System.IO.FileShare]::ReadWrite -bor [System.IO.FileShare]::Delete)) try { $reader = [System.IO.StreamReader]::new($fs) $reader.ReadToEnd() } finally { $fs.Dispose() } } catch { '' } } function Get-IntuneWinAppUtilProgress { <# .SYNOPSIS Estimates packaging progress (0-95%) from the console output IntuneWinAppUtil.exe has written so far. .DESCRIPTION The tool reports steps, not percentages. Fixed steps get fixed values; the two compression steps (the slow ones) advance with the size of the archive being written compared with the folder size the tool prints just before ("Calculated size for folder ... is N"). The remaining 5% is left for publishing the package to the output folder. .PARAMETER ToolOutput Output captured so far. .OUTPUTS [pscustomobject] with Percent ([int]) and Step ([string]). #> [CmdletBinding()] param( [AllowEmptyString()][string]$ToolOutput ) # Progress inside a compression step, from the size of the archive being written. $compressionShare = { param([string]$Archive) $total = 0 $sizes = [regex]::Matches($ToolOutput, 'Calculated size for folder .* is (\d+)') if ($sizes.Count) { $total = [double]$sizes[$sizes.Count - 1].Groups[1].Value } $written = 0 try { if ($Archive -and (Test-Path -LiteralPath $Archive)) { $written = (Get-Item -LiteralPath $Archive).Length } } catch { } if ($total -le 0) { return 0 } [Math]::Min(1.0, $written / $total) # 1.0, not 1: an int literal selects Min(int, int) and truncates } $lines = @($ToolOutput -split "`r?`n" | Where-Object { $_ -match '\S' }) $last = if ($lines.Count) { $lines[-1] } else { '' } switch -Regex ($last) { 'Done!!!|has been generated successfully' { return [pscustomobject]@{ Percent = 95; Step = 'Finishing' } } 'Removing temporary files|Removed temporary files' { return [pscustomobject]@{ Percent = 94; Step = 'Removing temporary files' } } "Compressing folder '.*' to '|Calculated size for folder .*IntuneWinPackage|Compressed folder '.*IntuneWinPackage'" { $archive = if ($ToolOutput -match "(?m)Compressing folder '.*' to '(?<out>[^']+)'\s*$") { $Matches['out'] } else { $null } return [pscustomobject]@{ Percent = 85 + [int](8 * (& $compressionShare $archive)); Step = 'Building the .intunewin package' } } 'detection XML' { return [pscustomobject]@{ Percent = 82; Step = 'Generating the detection metadata' } } 'SHA256|Copying encrypted file|got updated' { return [pscustomobject]@{ Percent = 78; Step = 'Verifying the encrypted content' } } 'Encrypting|encrypted successfully|Checking file type|Checked file type' { return [pscustomobject]@{ Percent = 72; Step = 'Encrypting the content' } } "Compressing the source folder|Calculated size for folder|Compressed folder" { $archive = if ($ToolOutput -match "Compressing the source folder '.*' to '(?<out>[^']+IntunePackage\.intunewin)'") { $Matches['out'] } else { $null } return [pscustomobject]@{ Percent = 5 + [int](65 * (& $compressionShare $archive)); Step = 'Compressing the source folder' } } 'Validat' { return [pscustomobject]@{ Percent = 3; Step = 'Validating parameters' } } } [pscustomobject]@{ Percent = 2; Step = 'Starting IntuneWinAppUtil' } } function Initialize-IntuneWinAppUtil { <# .SYNOPSIS Returns a valid IntuneWinAppUtil.exe path or $null on failure. .DESCRIPTION - If a UI-provided path is valid, use it. - Else, use cached copy under %APPDATA%\IntuneWinAppUtilGUI\bin. - Else, download the latest via Invoke-DownloadIntuneTool (private helper). .PARAMETER UiToolPath Optional path provided by the UI (textbox). .OUTPUTS [string] or $null #> [CmdletBinding()] param( [Parameter(Mandatory = $false)][string]$UiToolPath ) try { $appRoot = Join-Path $env:APPDATA 'IntuneWinAppUtilGUI' $binDir = Join-Path $appRoot 'bin' $exePath = Join-Path $binDir 'IntuneWinAppUtil.exe' if (-not [string]::IsNullOrWhiteSpace($UiToolPath) -and (Test-Path $UiToolPath)) { return $UiToolPath } if (Test-Path $exePath) { return $exePath } # Fallback: download latest tool return (Invoke-DownloadIntuneTool) } catch { return $null } } |