Public/Show-IntuneWinAppUtilGui.ps1
|
# Show-IntuneWinAppUtilGUI.ps1 # Show the main GUI window and handle all events. function Show-IntuneWinAppUtilGUI { [CmdletBinding()] param ( [Parameter(Mandatory = $false, HelpMessage = "Show diagnostic information")][switch] $Diag, [Parameter(Mandatory = $false, HelpMessage = "Show installed and latest module versions")][switch] $ShowVersion, [Parameter(Mandatory = $false, HelpMessage = "Force update banner for testing")][switch] $ForceUpdateBanner ) $moduleRoot = Split-Path -Path $PSScriptRoot -Parent $configPath = Join-Path -Path $env:APPDATA -ChildPath "IntuneWinAppUtilGUI\config.json" $xamlPath = Join-Path $moduleRoot 'UI\UI.xaml' $iconPath = Join-Path $moduleRoot 'Assets\Intune.ico' $iconPngPath = Join-Path $moduleRoot 'Assets\Intune.png' if (-not (Test-Path $xamlPath)) { Write-Error "XAML file not found: $xamlPath" return } # Relaunch in STA if needed if ([System.Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') { $modulePath = $MyInvocation.MyCommand.Module.Path $shell = if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh' } else { 'powershell' } $cmd = "Import-Module `"$modulePath`"; Show-IntuneWinAppUtilGUI" if ($PSBoundParameters.ContainsKey('Debug')) { $cmd += " -Debug" } Start-Process $shell -ArgumentList @( '-NoProfile', '-STA', '-Command', $cmd ) -NoNewWindow | Out-Null return } if ($Diag) { # Diagnostics: print handles/memory when the GUI starts try { $p = Get-Process -Id $PID Write-Verbose ("[Diagnostics/Start] Handles: {0}, GDI: {1}, WS: {2:N0} KB" -f $p.HandleCount, $p.GDIHandles, ($p.WorkingSet64/1KB)) -Verbose Write-Verbose "This PowerShell process will be available again when IntuneWinAppUtil GUI closes." -Verbose } catch { } } # Prefer software rendering to avoid GPU/driver glitches try { [System.Windows.Media.RenderOptions]::ProcessRenderMode = [System.Windows.Interop.RenderMode]::SoftwareOnly } catch { } # Ensure there is a single Application for the whole PowerShell session $app = [System.Windows.Application]::Current if (-not $app) { $app = New-Object System.Windows.Application # Keep the dispatcher alive between runs $app.ShutdownMode = 'OnExplicitShutdown' } # Register global WPF dispatcher handler only once if (-not $app.Resources.Contains('IntuneGUI_HandlersRegistered')) { $app.add_DispatcherUnhandledException({ param($evtSender, $e) [System.Windows.MessageBox]::Show( "Unexpected UI error:`n$($e.Exception.Message)", "UI Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error ) $e.Handled = $true }) $null = $app.Resources.Add('IntuneGUI_HandlersRegistered', $true) } # Parse XAML and get the main window $xaml = Get-Content $xamlPath -Raw $window = [Windows.Markup.XamlReader]::Parse($xaml) # Grab controls $SourceFolder = $window.FindName("SourceFolder") $SetupFile = $window.FindName("SetupFile") $OutputFolder = $window.FindName("OutputFolder") $SourceFolderPathLength = $window.FindName("SourceFolderPathLength") $OutputFolderPathLength = $window.FindName("OutputFolderPathLength") $UpdateAvailableText = $window.FindName("UpdateAvailableText") $ToolPathBox = $window.FindName("ToolPathBox") $ToolVersionText = $window.FindName("ToolVersionText") $DownloadTool = $window.FindName("DownloadTool") $FinalFilename = $window.FindName("FinalFilename") $BrowseSource = $window.FindName("BrowseSource") $BrowseSetup = $window.FindName("BrowseSetup") $BrowseOutput = $window.FindName("BrowseOutput") $BrowseTool = $window.FindName("BrowseTool") $RunButton = $window.FindName("RunButton") $ClearButton = $window.FindName("ClearButton") $ExitButton = $window.FindName("ExitButton") $PathLengthLimit = 260 Update-PathLengthIndicator -PathText $SourceFolder.Text -Indicator $SourceFolderPathLength -Limit $PathLengthLimit Update-PathLengthIndicator -PathText $OutputFolder.Text -Indicator $OutputFolderPathLength -Limit $PathLengthLimit # When user types/pastes the source path manually, try to auto-suggest the setup file if found. $SourceFolder.Add_TextChanged({ param($evtSender, $e) $src = $SourceFolder.Text.Trim() if ($src) { Set-SetupFromSource -SourcePath $src -SetupFileControl $SetupFile -FinalFilenameControl $FinalFilename } Update-PathLengthIndicator -PathText $SourceFolder.Text -Indicator $SourceFolderPathLength -Limit $PathLengthLimit }) $updateCheckEnabled = $true # Preload config.json if it exists if (Test-Path $configPath) { try { $cfg = Get-Content $configPath -Raw | ConvertFrom-Json if ($cfg.ToolPath -and (Test-Path $cfg.ToolPath)) { $ToolPathBox.Text = $cfg.ToolPath Show-ToolVersion -Path $cfg.ToolPath -Target $ToolVersionText } if ($null -ne $cfg.UpdateCheckEnabled) { $updateCheckEnabled = [bool]$cfg.UpdateCheckEnabled } } catch {} } # Browse for Source Folder (dispose dialog via finally) $BrowseSource.Add_Click({ $dialog = New-Object System.Windows.Forms.FolderBrowserDialog try { if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $SourceFolder.Text = $dialog.SelectedPath } } finally { $dialog.Dispose() } }) $updateCheckJob = $null $updateCheckTimer = $null $shouldCheckUpdates = ($updateCheckEnabled -or $ShowVersion) -and $UpdateAvailableText if ($shouldCheckUpdates) { $currentVersion = $MyInvocation.MyCommand.Module.Version if ($ForceUpdateBanner) { $currentVersion = [version]'1.0.0' } $modulePath = $MyInvocation.MyCommand.Module.Path $moduleName = 'IntuneWinAppUtilGUI' $timeoutSeconds = 10 $updateCheckJob = Start-Job -ArgumentList $modulePath, $moduleName, $currentVersion, $timeoutSeconds, [bool]$ShowVersion -ScriptBlock { param($modulePath, $moduleName, $currentVersion, $timeoutSeconds, $showVersion) $moduleRoot = Split-Path $modulePath -Parent $helperPath = Join-Path $moduleRoot 'Private\IWAPG-Hlp-UpdateCheck.ps1' if (Test-Path $helperPath) { . $helperPath } else { if ($showVersion) { return [PSCustomObject]@{ Current = $currentVersion Latest = $null Error = "Helper not found: $helperPath" } } return $null } $result = Get-PowerShellGalleryModuleVersion -ModuleName $moduleName -TimeoutSeconds $timeoutSeconds -Detailed $latest = $result.Latest $errMsg = $result.Error if ($showVersion) { return [PSCustomObject]@{ Current = $currentVersion Latest = $latest Error = $errMsg } } if ($latest -and ($latest -gt $currentVersion)) { return $latest } } $updateCheckTimer = New-Object System.Windows.Threading.DispatcherTimer $updateCheckTimer.Interval = [TimeSpan]::FromMilliseconds(300) $updateCheckTimer.Add_Tick({ if (-not $updateCheckJob) { $updateCheckTimer.Stop(); return } if ($updateCheckJob.State -in @('Completed','Failed','Stopped')) { $updateCheckTimer.Stop() $latest = $null try { $latest = Receive-Job $updateCheckJob -ErrorAction SilentlyContinue } catch { } try { Remove-Job $updateCheckJob -Force -ErrorAction SilentlyContinue } catch { } $updateCheckJob = $null if ($ForceUpdateBanner) { $UpdateAvailableText.Text = "Update available: $($latest.Latest) (run 'Update-Module IntuneWinAppUtilGUI' in your PowerShell session)" $UpdateAvailableText.Visibility = [System.Windows.Visibility]::Visible } elseif ($ShowVersion) { $latestText = if ($latest -and $latest.Latest) { $latest.Latest } else { 'unknown' } if ($latest -and $latest.Error) { $UpdateAvailableText.Text = "Installed: $($latest.Current) | Latest: $latestText (error: $($latest.Error))" } else { $UpdateAvailableText.Text = "Installed: $($latest.Current) | Latest: $latestText" } $UpdateAvailableText.Visibility = [System.Windows.Visibility]::Visible } elseif ($latest -is [version]) { $UpdateAvailableText.Text = "Update available: $latest (run 'Update-Module IntuneWinAppUtilGUI' in your PowerShell session)" $UpdateAvailableText.Visibility = [System.Windows.Visibility]::Visible } } }) $updateCheckTimer.Start() } # Browse for Setup File $BrowseSetup.Add_Click({ $dialog = New-Object System.Windows.Forms.OpenFileDialog try { $dialog.Filter = "Executable or MSI (*.exe;*.msi)|*.exe;*.msi" if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $selectedPath = $dialog.FileName $sourceRoot = $SourceFolder.Text.Trim() if (-not [string]::IsNullOrWhiteSpace($sourceRoot) -and (Test-Path $sourceRoot)) { try { $relativePath = Get-RelativePath -BasePath $sourceRoot -TargetPath $selectedPath if (-not ($relativePath.StartsWith(".."))) { $SetupFile.Text = $relativePath } else { $SetupFile.Text = $selectedPath } } catch { $SetupFile.Text = $selectedPath } } else { $SourceFolder.Text = Split-Path $selectedPath -Parent $SetupFile.Text = [System.IO.Path]::GetFileName($selectedPath) } } } finally { $dialog.Dispose() } }) # Browse for Output Folder $BrowseOutput.Add_Click({ $dialog = New-Object System.Windows.Forms.FolderBrowserDialog try { if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $OutputFolder.Text = $dialog.SelectedPath } } finally { $dialog.Dispose() } }) $OutputFolder.Add_TextChanged({ param($evtSender, $e) Update-PathLengthIndicator -PathText $OutputFolder.Text -Indicator $OutputFolderPathLength -Limit $PathLengthLimit }) # Browse for IntuneWinAppUtil.exe $BrowseTool.Add_Click({ $dialog = New-Object System.Windows.Forms.OpenFileDialog try { $dialog.Filter = "IntuneWinAppUtil.exe|IntuneWinAppUtil.exe" if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $ToolPathBox.Text = $dialog.FileName Show-ToolVersion -Path $dialog.FileName -Target $ToolVersionText } } finally { $dialog.Dispose() } }) # Force download the IntuneWinAppUtil.exe tool $DownloadTool.Add_Click({ $confirm = [System.Windows.MessageBox]::Show( "This will download the latest IntuneWinAppUtil.exe and replace (if already exists) the one in your bin folder.`n`nProceed?", "Confirm force download", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question ) if ($confirm -ne [System.Windows.MessageBoxResult]::Yes) { return } try { $newPath = Invoke-DownloadIntuneTool $ToolPathBox.Text = $newPath Show-ToolVersion -Path $newPath -Target $ToolVersionText [System.Windows.MessageBox]::Show( "IntuneWinAppUtil.exe has been refreshed.`n`nPath:`n$newPath", "Download complete", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information ) } catch { [System.Windows.MessageBox]::Show( "Download failed:`n$($_.Exception.Message)", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error ) } }) # When user types/pastes the tool path manually, show version if valid $ToolPathBox.Add_TextChanged({ param($evtSender, $e) $p = $ToolPathBox.Text.Trim() if ($p) { Show-ToolVersion -Path $p -Target $ToolVersionText } else { Show-ToolVersion -Path $null -Target $ToolVersionText } }) # If the user typed/pasted an absolute setup path before setting SourceFolder, # infer SourceFolder from that path and convert SetupFile to a relative file name. $SetupFile.Add_LostFocus({ param($evtSender, $e) $sText = $SetupFile.Text.Trim() # Only act if SourceFolder is empty and SetupFile looks like an absolute existing path if ([string]::IsNullOrWhiteSpace($SourceFolder.Text) -and -not [string]::IsNullOrWhiteSpace($sText) -and [System.IO.Path]::IsPathRooted($sText) -and (Test-Path $sText)) { $SourceFolder.Text = Split-Path $sText -Parent $SetupFile.Text = [System.IO.Path]::GetFileName($sText) # Note: SourceFolder.Text change will NOT override SetupFile because Set-SetupFromSource # early-returns if SetupFile already points to an existing file (absolute or relative). } }) # Run button: validate inputs, run IntuneWinAppUtil.exe, rename output if needed $RunButton.Add_Click({ $c = $SourceFolder.Text.Trim() # Source folder $s = $SetupFile.Text.Trim() # Setup file (relative or absolute) $o = $OutputFolder.Text.Trim() # Output folder $f = $FinalFilename.Text.Trim()# Final filename # Clean FinalFilename from invalid chars $f = -join ($f.ToCharArray() | Where-Object { [System.IO.Path]::GetInvalidFileNameChars() -notcontains $_ }) # Validate source folder if (-not (Test-Path $c)) { [System.Windows.MessageBox]::Show("Invalid source folder path.", "Error", "OK", "Error"); return } # Validate setup file if (-not (Test-Path $s)) { $s = Join-Path $c $s if (-not (Test-Path $s)) { [System.Windows.MessageBox]::Show("Setup file not found.", "Error", "OK", "Error"); return } } # Validate extension before running the tool $extSetup = [System.IO.Path]::GetExtension($s).ToLowerInvariant() if ($extSetup -notin @(".exe", ".msi")) { [System.Windows.MessageBox]::Show( "Setup file must be .exe or .msi (got '$extSetup').", "Invalid setup type", "OK", "Error" ) return } # Validate output folder if (-not (Test-Path $o)) { try { New-Item -Path $o -ItemType Directory -Force | Out-Null } catch { [System.Windows.MessageBox]::Show("Output folder path is invalid and could not be created.", "Error", "OK", "Error") return } } # Normalize all paths to absolute try { $c = [System.IO.Path]::GetFullPath($c) $s = [System.IO.Path]::GetFullPath($s) $o = [System.IO.Path]::GetFullPath($o) } catch { [System.Windows.MessageBox]::Show("Invalid path format: $($_.Exception.Message)", "Error", "OK", "Error") return } $pathWarnings = @() if ($c.Length -gt $PathLengthLimit) { $pathWarnings += "Source folder: $($c.Length)/$PathLengthLimit" } $maxFileInfo = Get-MaxFilePathInfo -RootPath $c if ($null -ne $maxFileInfo -and $maxFileInfo.Length -gt $PathLengthLimit) { $pathWarnings += "Max file path in source: $($maxFileInfo.Length)/$PathLengthLimit" $pathWarnings += "Longest path: $($maxFileInfo.Path)" } if ($o.Length -gt $PathLengthLimit) { $pathWarnings += "Output folder: $($o.Length)/$PathLengthLimit" } if ($pathWarnings.Count -gt 0) { $msg = "One or more paths exceed $PathLengthLimit characters.`n`n" + ($pathWarnings -join "`n") + "`n`nThis can cause IntuneWinAppUtil to fail on Windows.`nProceed anyway?" $confirm = [System.Windows.MessageBox]::Show( $msg, "Path length warning", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Warning ) if ($confirm -ne [System.Windows.MessageBoxResult]::Yes) { return } } # IntuneWinAppUtil.exe path check (or initialize/download if not set) $toolPath = Initialize-IntuneWinAppUtil -UiToolPath ($ToolPathBox.Text.Trim()) if (-not $toolPath -or -not (Test-Path $toolPath)) { [System.Windows.MessageBox]::Show( "IntuneWinAppUtil.exe not found and could not be initialized.", "Error", "OK", "Error" ) return } # Keep UI in sync and show version $ToolPathBox.Text = $toolPath Show-ToolVersion -Path $toolPath -Target $ToolVersionText # Build a single, properly-quoted argument string # -c = source folder, -s = setup file (EXE/MSI), -o = output folder. $iwaArgs = ('-c "{0}" -s "{1}" -o "{2}"' -f $c, $s, $o) # Launch IntuneWinAppUtil.exe try { $proc = Start-Process -FilePath $toolPath ` -ArgumentList $iwaArgs ` -WorkingDirectory (Split-Path $toolPath) ` -WindowStyle Normal ` -PassThru } catch { [System.Windows.MessageBox]::Show( "Failed to start IntuneWinAppUtil.exe:`n$($_.Exception.Message)", "Execution error", "OK", "Error" ) return } $proc.WaitForExit() if ($proc.ExitCode -ne 0) { [System.Windows.MessageBox]::Show( "IntuneWinAppUtil exited with code $($proc.ExitCode).", "Packaging failed", "OK", "Error" ) return } # Wait for the output file to appear (up to 10 seconds) $defaultName = [System.IO.Path]::GetFileNameWithoutExtension($s) + ".intunewin" $defaultPath = Join-Path $o $defaultName $timeoutSec = 10 $elapsed = 0 while (-not (Test-Path $defaultPath) -and $elapsed -lt $timeoutSec) { Start-Sleep -Milliseconds 250 $elapsed += 0.25 } if (Test-Path $defaultPath) { # Build desired name from $f (if any), ensuring exactly one ".intunewin" if ([string]::IsNullOrWhiteSpace($f)) { $desiredName = (Split-Path $c -Leaf) + ".intunewin" } else { $extF = [System.IO.Path]::GetExtension($f).ToLowerInvariant() $baseF = if ($extF -eq ".intunewin") { [System.IO.Path]::GetFileNameWithoutExtension($f) } else { $f } $desiredName = $baseF + ".intunewin" } $newName = $desiredName try { # Collision-safe rename (_1, _2, ...) $baseName = [System.IO.Path]::GetFileNameWithoutExtension($newName) $ext = [System.IO.Path]::GetExtension($newName) $finalName = $newName $counter = 1 while (Test-Path (Join-Path $o $finalName)) { $finalName = "$baseName" + "_$counter" + "$ext" $counter++ } Rename-Item -Path $defaultPath -NewName $finalName -Force $fullPath = Join-Path $o $finalName $msg = "Package created and renamed to:`n$finalName" if ($finalName -ne $newName) { $msg += "`n(Note: original name '$newName' already existed.)" } $msg += "`n`nOpen folder?" $resp = [System.Windows.MessageBox]::Show( $msg, "Success", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Information ) if ($resp -eq [System.Windows.MessageBoxResult]::Yes) { Start-Process explorer.exe "/select,`"$fullPath`"" } } catch { [System.Windows.MessageBox]::Show("Renaming failed: $($_.Exception.Message)", "Warning", "OK", "Warning") } } else { [System.Windows.MessageBox]::Show( "Output file not found:`n$defaultPath", "Warning", "OK", "Warning" ) } }) # Clear button: reset all except ToolPath if loaded from config $ClearButton.Add_Click({ $SourceFolder.Clear() $SetupFile.Clear() $OutputFolder.Clear() $FinalFilename.Clear() }) # Exit button: close the window $ExitButton.Add_Click({ $window.Close() }) # Keyboard shortcuts: Esc to exit (with confirmation), Enter to run packaging $window.Add_KeyDown({ param($evtSender, $e) switch ($e.Key) { 'Escape' { if ([System.Windows.MessageBox]::Show("Exit the tool?", "Confirm", "YesNo", "Question") -eq [System.Windows.MessageBoxResult]::Yes) { $window.Close() } } 'Return' { $RunButton.RaiseEvent((New-Object System.Windows.RoutedEventArgs ([System.Windows.Controls.Button]::ClickEvent))) } } }) # When the window is closed, save the ToolPath to config.json $window.Add_Closed({ try { if ($updateCheckTimer) { $updateCheckTimer.Stop() } if ($updateCheckJob) { Remove-Job $updateCheckJob -Force -ErrorAction SilentlyContinue } if (-not (Test-Path (Split-Path $configPath))) { New-Item -Path (Split-Path $configPath) -ItemType Directory -Force | Out-Null } $cfg = @{ ToolPath = $ToolPathBox.Text.Trim() UpdateCheckEnabled = $updateCheckEnabled } $cfg | ConvertTo-Json | Set-Content $configPath -Encoding UTF8 } catch { } }) # Set window icon if available if (Test-Path $iconPath) { $window.Icon = [System.Windows.Media.Imaging.BitmapFrame]::Create((New-Object System.Uri $iconPath, [System.UriKind]::Absolute)) } # Load PNG header icon without locking the file $HeaderIcon = $window.FindName('HeaderIcon') if ($HeaderIcon -and (Test-Path $iconPngPath)) { $bmp = New-Object System.Windows.Media.Imaging.BitmapImage $bmp.BeginInit() $bmp.CacheOption = [System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad $bmp.UriSource = [Uri]::new($iconPngPath, [UriKind]::Absolute) $bmp.EndInit() $HeaderIcon.Source = $bmp } # Hyperlink navigate handler (handles any Hyperlink in the XAML) $window.AddHandler( [System.Windows.Documents.Hyperlink]::RequestNavigateEvent, [System.Windows.Navigation.RequestNavigateEventHandler]{ param($evtSender, $e) Start-Process $e.Uri.AbsoluteUri $e.Handled = $true } ) # Show the window (modal) $window.ShowDialog() | Out-Null # Proactively release WPF/GDI/USER resources after window closes try { [System.GC]::Collect() [System.GC]::WaitForPendingFinalizers() [System.GC]::Collect() } catch { } if ($Diag) { # Diagnostics: print handles/memory after the GUI closes try { $p2 = Get-Process -Id $PID Write-Verbose ("[Diagnostics/End] Handles: {0}, GDI: {1}, WS: {2:N0} KB" -f $p2.HandleCount, $p2.GDIHandles, ($p2.WorkingSet64/1KB)) -Verbose } catch { } } } Export-ModuleMember -Function Show-IntuneWinAppUtilGUI |