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" foreach ($switchName in @('Diag', 'ShowVersion', 'ForceUpdateBanner', 'Debug', 'Verbose')) { if ($PSBoundParameters.ContainsKey($switchName)) { $cmd += " -$switchName" } } Start-Process $shell -ArgumentList @( '-NoProfile', '-STA', '-Command', $cmd ) -NoNewWindow | Out-Null return } # -Diag also writes a detailed log of every packaging run (read by Write-IwaDebugLog). $script:IwaDebugLogPath = $null if ($Diag) { try { $logDir = Join-Path $env:APPDATA 'IntuneWinAppUtilGUI\logs' New-Item -Path $logDir -ItemType Directory -Force -ErrorAction Stop | Out-Null $script:IwaDebugLogPath = Join-Path $logDir ("diag-{0}-{1}.log" -f (Get-Date -Format 'yyyyMMdd-HHmmss'), $PID) Write-Verbose "[Diagnostics] Log file: $script:IwaDebugLogPath" -Verbose } catch { Write-Warning "Unable to create the diagnostic log: $($_.Exception.Message)" } Write-IwaDebugLog ("Start: module {0}, PowerShell {1} ({2}), {3}, temp '{4}'" -f $MyInvocation.MyCommand.Module.Version, $PSVersionTable.PSVersion, $PSVersionTable.PSEdition, [System.Environment]::OSVersion.VersionString, [System.IO.Path]::GetTempPath()) # Diagnostics: print handles/memory when the GUI starts try { $p = Get-Process -Id $PID $diagLine = "[Diagnostics/Start] Handles: {0}, GDI: {1}, WS: {2:N0} KB" -f $p.HandleCount, $p.GDIHandles, ($p.WorkingSet64/1KB) Write-Verbose $diagLine -Verbose Write-IwaDebugLog $diagLine 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") $SetupFileHint = $window.FindName("SetupFileHint") $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") $ProgressOverlay = $window.FindName("ProgressOverlay") $ProgressStep = $window.FindName("ProgressStep") $ProgressBar = $window.FindName("ProgressBar") $ProgressPercent = $window.FindName("ProgressPercent") # Packaging runs on the UI thread; while it runs, the overlay blocks mouse input and the UI is kept # responsive by letting the dispatcher render between steps. Busy blocks re-entry (Enter), Esc and closing. $runState = [pscustomobject]@{ Busy = $false } # WPF "DoEvents": run a nested message loop until all pending work down to Background priority # (layout, rendering, input, UI Automation) has been processed. $pumpUi = { $frame = New-Object System.Windows.Threading.DispatcherFrame $null = $window.Dispatcher.BeginInvoke( [System.Windows.Threading.DispatcherPriority]::Background, [System.Windows.Threading.DispatcherOperationCallback]{ param($f) $f.Continue = $false; $null }, $frame) [System.Windows.Threading.Dispatcher]::PushFrame($frame) } $showProgress = { param([int]$Percent, [string]$Step) $ProgressOverlay.Visibility = [System.Windows.Visibility]::Visible $ProgressBar.Value = $Percent $ProgressPercent.Text = "$Percent%" $ProgressStep.Text = $Step & $pumpUi } $hideProgress = { $ProgressOverlay.Visibility = [System.Windows.Visibility]::Collapsed $ProgressBar.Value = 0 $ProgressPercent.Text = '0%' } # Result overlay: in-window replacement for the "Package created" message box. $ResultOverlay = $window.FindName("ResultOverlay") $ResultFileName = $window.FindName("ResultFileName") $ResultFolder = $window.FindName("ResultFolder") $ResultNote = $window.FindName("ResultNote") $ResultOpenFolder = $window.FindName("ResultOpenFolder") $ResultClose = $window.FindName("ResultClose") $resultState = [pscustomobject]@{ Path = $null } $showResult = { param([string]$PackagePath, [string]$Note) $resultState.Path = $PackagePath $ResultFileName.Text = Split-Path $PackagePath -Leaf $ResultFolder.Text = Split-Path $PackagePath -Parent $ResultNote.Text = $Note $ResultNote.Visibility = if ($Note) { [System.Windows.Visibility]::Visible } else { [System.Windows.Visibility]::Collapsed } $ResultOverlay.Visibility = [System.Windows.Visibility]::Visible $null = $ResultOpenFolder.Focus() # Success sound from the user's Windows sound scheme (error message boxes already play their own). try { [System.Media.SystemSounds]::Asterisk.Play() } catch { } } $hideResult = { $ResultOverlay.Visibility = [System.Windows.Visibility]::Collapsed $resultState.Path = $null } $openResultFolder = { if ($resultState.Path) { Start-Process explorer.exe "/select,`"$($resultState.Path)`"" } & $hideResult } $ResultOpenFolder.Add_Click({ & $openResultFolder }) $ResultClose.Add_Click({ & $hideResult }) $HelpButton = $window.FindName("HelpButton") $ClearButton = $window.FindName("ClearButton") $ExitButton = $window.FindName("ExitButton") $PathLengthLimit = 260 $DefaultSetupFileHint = "Valid MSI or EXE. PSADT packages are detected automatically; MSI metadata is used when script metadata is missing." Update-PathLengthIndicator -PathText $SourceFolder.Text -Indicator $SourceFolderPathLength -Limit $PathLengthLimit Update-PathLengthIndicator -PathText $OutputFolder.Text -Indicator $OutputFolderPathLength -Limit $PathLengthLimit # Updates the hint below the Setup File field: warns when the selected file isn't EXE/MSI, # since the Intune install command then needs to be adjusted to match it (e.g. a .ps1 script). $UpdateSetupFileHint = { param($Path) if (-not $SetupFileHint) { return } if ([string]::IsNullOrWhiteSpace($Path)) { $SetupFileHint.Text = $DefaultSetupFileHint return } # GetExtension throws on a path with characters .NET rejects (e.g. a stray '"' while # mid-edit); since this handler runs on every keystroke, treat that as "no extension yet" # rather than letting it surface as an unhandled dispatcher error dialog. try { $ext = [System.IO.Path]::GetExtension($Path) } catch { $ext = '' } if ($ext -notin @('.exe', '.msi')) { # Extensionless files (e.g. "install") also need the warning: they aren't EXE/MSI either. $extLabel = if ($ext) { "'$ext'" } else { 'no extension' } $SetupFileHint.Text = "Note: $extLabel is not EXE/MSI. Make sure your install command in Intune matches this setup file." } else { $SetupFileHint.Text = $DefaultSetupFileHint } } $SetupFile.Add_TextChanged({ param($evtSender, $e) & $UpdateSetupFileHint $SetupFile.Text.Trim() }) # When user types/pastes the source path manually, try to auto-suggest the setup file if found. # The lookup (Get-SetupSuggestion) recurses the source folder, which can be slow on large/network # folders, so it is debounced (runs once typing pauses) AND run on a background job, so a slow scan # never freezes the window; only the resulting control updates are marshaled back to the UI thread. # # $sourceFolderScanState is a shared, mutable reference (not a scalar variable) so that both plain # event handlers and the .GetNewClosure()'d poll-timer handler below observe the same RequestId: # GetNewClosure() rebinds a scriptblock into its own dynamic module, where a plain "$script:" lookup # would resolve against that private module instead of this one, silently breaking invalidation. $sourceFolderScanState = [pscustomobject]@{ RequestId = 0 } # Each entry pairs a background job with the DispatcherTimer polling it, so cancelling can # stop the timer too. Leaving an orphaned timer running after its job is force-removed/disposed # made it poll a dead job object forever, throwing on the dispatcher thread on its next tick. $sourceFolderScanJobs = New-Object System.Collections.Generic.List[object] # Kills every scan still in flight and forgets it. Called whenever an edit makes their # in-progress results stale, so a slow/large-folder scan doesn't keep running (and consuming # a background PowerShell process) after it can no longer be applied. $sourceFolderScanCancelAll = { foreach ($activeScan in $sourceFolderScanJobs.ToArray()) { try { $activeScan.Timer.Stop() } catch {} try { Remove-Job $activeScan.Job -Force -ErrorAction SilentlyContinue } catch {} } $sourceFolderScanJobs.Clear() } $sourceFolderScanTimer = New-Object System.Windows.Threading.DispatcherTimer $sourceFolderScanTimer.Interval = [TimeSpan]::FromMilliseconds(350) $sourceFolderScanTimer.Add_Tick({ $sourceFolderScanTimer.Stop() $src = $SourceFolder.Text.Trim() if (-not $src) { return } $sourceFolderScanState.RequestId++ $requestId = $sourceFolderScanState.RequestId $currentSetupFile = $SetupFile.Text $currentFinalFilename = $FinalFilename.Text $job = Start-Job -ArgumentList $moduleRoot, $src, $currentSetupFile, $currentFinalFilename -ScriptBlock { param($moduleRoot, $src, $currentSetupFile, $currentFinalFilename) $helperPath = Join-Path $moduleRoot 'Private\IWAPG-Hlp-Setup.ps1' if (-not (Test-Path $helperPath)) { return $null } # A fresh job process hasn't loaded PresentationFramework, but the helper file also # defines Set-SetupFromSource, whose param block references [System.Windows.Controls.TextBox]; # without this, dot-sourcing fails before Get-SetupSuggestion can be defined. Add-Type -AssemblyName PresentationFramework -ErrorAction SilentlyContinue . $helperPath Get-SetupSuggestion -SourcePath $src -CurrentSetupFile $currentSetupFile -CurrentFinalFilename $currentFinalFilename } $pollTimer = New-Object System.Windows.Threading.DispatcherTimer $scanEntry = [pscustomobject]@{ Job = $job; Timer = $pollTimer } $sourceFolderScanJobs.Add($scanEntry) # GetNewClosure() below only reaches variables local to THIS scope (one level up from the # closure) — it does not walk further up into the enclosing function's scope. Without these # aliases, $sourceFolderScanJobs/$sourceFolderScanState/$SetupFile/$FinalFilename would all # resolve to $null inside the poll-timer closure. $scanJobsRef = $sourceFolderScanJobs $scanStateRef = $sourceFolderScanState $setupFileRef = $SetupFile $finalFilenameRef = $FinalFilename $pollTimer.Interval = [TimeSpan]::FromMilliseconds(100) $pollTimer.Add_Tick({ try { if ($job.State -notin @('Completed', 'Failed', 'Stopped')) { return } } catch { # Job was force-removed/disposed elsewhere (e.g. cancelAll raced this tick); stop polling it. $pollTimer.Stop() return } $pollTimer.Stop() $suggestion = $null try { $suggestion = Receive-Job $job -ErrorAction SilentlyContinue } catch {} try { Remove-Job $job -Force -ErrorAction SilentlyContinue } catch {} $scanJobsRef.Remove($scanEntry) | Out-Null # Discard results from a scan superseded by newer typing. if ($suggestion -and $requestId -eq $scanStateRef.RequestId) { if ($suggestion.SetupFile -is [string]) { $setupFileRef.Text = $suggestion.SetupFile } if ($suggestion.FinalFilename -is [string]) { $finalFilenameRef.Text = $suggestion.FinalFilename } } }.GetNewClosure()) $pollTimer.Start() }) $SourceFolder.Add_TextChanged({ param($evtSender, $e) Update-PathLengthIndicator -PathText $SourceFolder.Text -Indicator $SourceFolderPathLength -Limit $PathLengthLimit # Invalidate (and kill) any scan already in flight: its result was computed against the # old inputs and must not overwrite what the user is typing now. $sourceFolderScanState.RequestId++ & $sourceFolderScanCancelAll $sourceFolderScanTimer.Stop() $sourceFolderScanTimer.Start() }) # Cancel the pending auto-detect scan if the user starts editing Setup File manually, # so it doesn't overwrite their in-progress edit once the timer fires. Also invalidates # (and kills) any scan already running, so its result can't clobber this manual edit either. $SetupFile.Add_TextChanged({ param($evtSender, $e) $sourceFolderScanState.RequestId++ & $sourceFolderScanCancelAll $sourceFolderScanTimer.Stop() }) # Editing Final Filename manually must also invalidate (and kill) an in-flight scan, so it # can't overwrite this field once the scan completes. $FinalFilename.Add_TextChanged({ param($evtSender, $e) $sourceFolderScanState.RequestId++ & $sourceFolderScanCancelAll }) $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 $modulePath = $MyInvocation.MyCommand.Module.Path $moduleName = 'IntuneWinAppUtilGUI' $timeoutSeconds = 10 if ($ForceUpdateBanner) { $UpdateAvailableText.Text = "Update available: test banner (run 'Update-Module IntuneWinAppUtilGUI' in your PowerShell session)" $UpdateAvailableText.Visibility = [System.Windows.Visibility]::Visible } elseif ($ShowVersion) { $UpdateAvailableText.Text = "Installed: $currentVersion | Latest: checking..." $UpdateAvailableText.Visibility = [System.Windows.Visibility]::Visible } $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) { return } 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|Script files (*.ps1;*.bat;*.cmd)|*.ps1;*.bat;*.cmd|All files (*.*)|*.*" 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( $window, "Download the latest IntuneWinAppUtil.exe now?`n`nIf a cached copy already exists in the local bin folder, it will be replaced.", "Refresh IntuneWinAppUtil.exe", [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( $window, "IntuneWinAppUtil.exe has been refreshed.`n`nPath:`n$newPath", "Download complete", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information ) } catch { [System.Windows.MessageBox]::Show( $window, "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 $runPackaging = { $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 $s) { [System.Windows.MessageBox]::Show("Setup file not found.", "Error", "OK", "Error"); return } if (-not (Test-Path $s -PathType Leaf)) { $s = Join-Path $c $s if (-not (Test-Path $s -PathType Leaf)) { [System.Windows.MessageBox]::Show("Setup file not found.", "Error", "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 } $runStart = Get-Date Write-IwaDebugLog "Run: source '$c', setup '$s', output '$o', final filename '$f'" & $showProgress 1 'Checking the source folder' $pathWarnings = @() if ($c.Length -gt $PathLengthLimit) { $pathWarnings += "Source folder: $($c.Length)/$PathLengthLimit" } $maxFileInfo = Get-MaxFilePathInfo -RootPath $c -OnProgress $pumpUi 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) { Write-IwaDebugLog ("Path length warnings:`n" + ($pathWarnings -join "`n")) $msg = "Some paths exceed the recommended $PathLengthLimit character limit.`n`n" + ($pathWarnings -join "`n") + "`n`nIntuneWinAppUtil may fail on Windows when paths are too long.`n`nContinue packaging anyway?" $confirm = [System.Windows.MessageBox]::Show( $window, $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) & $showProgress 1 'Preparing IntuneWinAppUtil' $toolPath = Initialize-IntuneWinAppUtil -UiToolPath ($ToolPathBox.Text.Trim()) Write-IwaDebugLog ("Tool: '{0}', version {1}" -f $toolPath, $(if ($toolPath) { Get-ExeVersion -Path $toolPath })) 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 # Package into a new, empty staging folder, then publish the finished file to the output folder, # so sync clients (Dropbox, OneDrive) never see a partial package. The staging folder is normally # under %TEMP%; if the temp volume lacks space (see below) it goes to the root of the output volume, # outside any synchronized folder, or as a last resort to a hidden subfolder of the output folder. $runId = [guid]::NewGuid().ToString('N') $tempRoot = [System.IO.Path]::GetTempPath() $stagingDir = Join-Path $tempRoot "IntuneWinAppUtilGUI-$runId" $stagingInTemp = $true try { $tempFree = ([System.IO.DriveInfo]::new([System.IO.Path]::GetPathRoot($tempRoot))).AvailableFreeSpace $sourceBytes = if ($maxFileInfo) { $maxFileInfo.TotalBytes } else { 0 } # from the path-length scan above # IntuneWinAppUtil keeps its own zip and encrypted copy under %TEMP% while writing the # package, so staging needs room for about three source-sized files (incompressible worst case). if ($tempFree -lt (3 * $sourceBytes + 100MB)) { $stagingDir = Join-Path ([System.IO.Path]::GetPathRoot($o)) "IntuneWinAppUtilGUI-staging-$runId" $stagingInTemp = $false } } catch { # Free space unknown (e.g. temp on a UNC path): keep staging in %TEMP%. } try { $stagingItem = $null try { $stagingItem = New-Item -Path $stagingDir -ItemType Directory -Force -ErrorAction Stop } catch { if ($stagingInTemp) { throw } # Volume root not writable (or a share root): last resort, hidden subfolder of the output folder. $stagingDir = Join-Path $o "~iwa-staging-$runId" $stagingItem = New-Item -Path $stagingDir -ItemType Directory -Force -ErrorAction Stop $stagingItem.Attributes = $stagingItem.Attributes -bor [System.IO.FileAttributes]::Hidden } } catch { [System.Windows.MessageBox]::Show("Unable to create the staging folder:`n$stagingDir`n`n$($_.Exception.Message)", "Error", "OK", "Error") return } $packageDir = $stagingDir Write-IwaDebugLog ("Staging folder: '{0}' ({1}); temp free {2:N0} MB, source {3:N0} MB" -f $stagingDir, $(if ($stagingInTemp) { 'temp' } else { 'output volume' }), ($tempFree / 1MB), ($sourceBytes / 1MB)) # Build a single, properly-quoted argument string # -c = source folder, -s = setup file, -o = staging folder. # -q avoids an "overwrite existing output (Y/N)?" prompt the hidden tool could not show; # nothing can be overwritten because the staging folder is new and empty. $iwaArgs = ('-c "{0}" -s "{1}" -o "{2}" -q' -f $c, $s, $stagingDir) # Launch IntuneWinAppUtil.exe without its own console window, capturing its output: started in a # new console window, the tool can fail with 0x80070005 (access denied) while replacing its # temporary package, a failure that does not occur with redirected output. $toolOut = Join-Path $tempRoot "IntuneWinAppUtilGUI-$runId.out.txt" $toolErr = Join-Path $tempRoot "IntuneWinAppUtilGUI-$runId.err.txt" Write-IwaDebugLog "Command: `"$toolPath`" $iwaArgs" try { $proc = Start-Process -FilePath $toolPath ` -ArgumentList $iwaArgs ` -WorkingDirectory (Split-Path $toolPath) ` -NoNewWindow ` -RedirectStandardOutput $toolOut ` -RedirectStandardError $toolErr ` -PassThru } catch { Write-IwaDebugLog "Failed to start the tool: $($_.Exception.Message)" Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue [System.Windows.MessageBox]::Show( "Failed to start IntuneWinAppUtil.exe:`n$($_.Exception.Message)", "Execution error", "OK", "Error" ) return } # Wait for the tool while updating the progress overlay from its output. while (-not $proc.HasExited) { $progress = Get-IntuneWinAppUtilProgress -ToolOutput (Read-SharedTextFile -Path $toolOut) & $showProgress $progress.Percent $progress.Step Start-Sleep -Milliseconds 150 } $proc.WaitForExit() $toolOutput = '' foreach ($stream in @($toolOut, $toolErr)) { $content = Get-Content -LiteralPath $stream -Raw -ErrorAction SilentlyContinue if ($content) { $toolOutput += $content.TrimEnd() + "`n" } Remove-Item -LiteralPath $stream -Force -ErrorAction SilentlyContinue } if ($script:IwaDebugLogPath) { Write-IwaDebugLog ("Tool exit code: {0} (0x{0:X8})" -f $proc.ExitCode) if ($toolOutput) { Write-IwaDebugLog ("Tool output:`n" + $toolOutput.TrimEnd()) } # Only the staging folder: the output folder may hold many unrelated files. if ($proc.ExitCode -ne 0) { Write-IwaDebugFileState -Since $runStart -Folder $stagingDir } } # On success, remove the working folder this run of the tool may leave in %TEMP% # (kept on failure for troubleshooting). Its path is read from the tool's own output. if ($proc.ExitCode -eq 0) { $toolWork = Get-IntuneWinAppUtilWorkFolder -ToolOutput $toolOutput if ($toolWork -and (Test-Path -LiteralPath $toolWork)) { Remove-Item -LiteralPath $toolWork -Recurse -Force -ErrorAction SilentlyContinue Write-IwaDebugLog ("Removed tool work folder '{0}': {1}" -f $toolWork, (-not (Test-Path -LiteralPath $toolWork))) } } if ($proc.ExitCode -ne 0) { if ($stagingDir) { Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue } $failMsg = Get-IntuneWinAppUtilExitMessage -ExitCode $proc.ExitCode $lastLines = @($toolOutput -split "`r?`n" | Where-Object { $_.Trim() } | Select-Object -Last 8) if ($lastLines.Count) { $failMsg += "`n`nLast lines of the tool output:`n" + ($lastLines -join "`n") } & $hideProgress [System.Windows.MessageBox]::Show($failMsg, "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 $packageDir $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 { # Publish-IntuneWinPackage picks a free name (_1, _2, ...) if this one is taken. $fullPath = Join-Path $o $newName try { & $showProgress 96 'Copying the package to the output folder' # Staging in %TEMP%: copy-then-rename, since a cross-volume move would expose a partial # final file. Fallback staging on the output volume: plain rename, because that volume # may not have room for a second copy. # shortcut: if the output folder is a junction/mount point to another volume, the fallback # rename becomes a copy+delete; detect the backing volume if that setup ever matters. $fullPath = Publish-IntuneWinPackage -Path $defaultPath -Destination $fullPath -CopyFirst:$stagingInTemp -OnProgress { param($copied, $total) & $showProgress (96 + [int](3 * $copied / [Math]::Max(1, $total))) 'Copying the package to the output folder' } $finalName = Split-Path $fullPath -Leaf } catch { # Keep the staging folder: it holds the only copy of the package. throw "Unable to move the package to '$fullPath' after several attempts. Last error: $($_.Exception.Message)`n`nThe package is still available at:`n$defaultPath" } if ($stagingDir) { Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue } Write-IwaDebugLog "Package created: '$fullPath'" & $showProgress 100 'Package created' & $hideProgress $note = if ($finalName -ne $newName) { "'$newName' already existed, so an incremental suffix was added." } else { $null } & $showResult $fullPath $note } catch { Write-IwaDebugLog "Moving the package failed: $($_.Exception.Message)" if ($script:IwaDebugLogPath) { foreach ($pkgFile in @($defaultPath, $fullPath)) { if ($pkgFile -and (Test-Path -LiteralPath $pkgFile)) { Write-IwaDebugLog (" {0} held by: {1}" -f $pkgFile, ((@(Get-FileLockingProcess -Path $pkgFile)) -join ', ')) } } } & $hideProgress [System.Windows.MessageBox]::Show("Moving the package failed: $($_.Exception.Message)", "Warning", "OK", "Warning") } } else { Write-IwaDebugLog "Expected package not found: '$defaultPath'" Write-IwaDebugFileState -Since $runStart -Folder $stagingDir if ($stagingDir) { Remove-Item $stagingDir -Recurse -Force -ErrorAction SilentlyContinue } & $hideProgress [System.Windows.MessageBox]::Show( "IntuneWinAppUtil finished, but the expected package was not created:`n$defaultName", "Warning", "OK", "Warning" ) } } $RunButton.Add_Click({ if ($runState.Busy) { return } $runState.Busy = $true try { & $runPackaging } finally { & $hideProgress $runState.Busy = $false } }) # Clear button: reset all except ToolPath if loaded from config $ClearButton.Add_Click({ $SourceFolder.Clear() $SetupFile.Clear() $OutputFolder.Clear() $FinalFilename.Clear() }) # Help button: explain command-line switches and keyboard shortcuts $HelpButton.Add_Click({ $helpText = @" Command-line switches Show-IntuneWinAppUtilGUI -ShowVersion Shows the installed module version and the latest available version in the header banner. Show-IntuneWinAppUtilGUI -ForceUpdateBanner Simulates an available update banner for testing the notification area. Show-IntuneWinAppUtilGUI -Diag Writes startup and shutdown diagnostics (handles, GDI handles, memory usage) and a detailed log of every packaging run, including the tool output and any locked files, to %APPDATA%\IntuneWinAppUtilGUI\logs. Standard PowerShell switches -Verbose and -Debug are preserved when the GUI relaunches itself in STA mode. Keyboard shortcuts Enter: run packaging. Esc: ask before closing the window. "@ [System.Windows.MessageBox]::Show( $window, $helpText, "Help", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Information ) | Out-Null }) # 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) if ($runState.Busy) { $e.Handled = $true; return } if ($ResultOverlay.Visibility -eq [System.Windows.Visibility]::Visible) { # Result overlay open: Enter opens the folder, Esc closes it; nothing else reaches the form. switch ($e.Key) { 'Return' { & $openResultFolder } 'Escape' { & $hideResult } } $e.Handled = $true return } switch ($e.Key) { 'Escape' { $confirmExit = [System.Windows.MessageBox]::Show( $window, "Close IntuneWinAppUtil GUI?`n`nCurrent field values will not be kept, except the saved IntuneWinAppUtil path.", "Close IntuneWinAppUtil GUI", [System.Windows.MessageBoxButton]::YesNo, [System.Windows.MessageBoxImage]::Question ) if ($confirmExit -eq [System.Windows.MessageBoxResult]::Yes) { $window.Close() } } 'Return' { $RunButton.RaiseEvent((New-Object System.Windows.RoutedEventArgs ([System.Windows.Controls.Button]::ClickEvent))) } } }) # Do not close while packaging: the tool and the staging folder would be left behind. $window.Add_Closing({ param($evtSender, $e) if ($runState.Busy) { $e.Cancel = $true } }) # 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 ($sourceFolderScanTimer) { $sourceFolderScanTimer.Stop() } if ($sourceFolderScanJobs) { foreach ($pendingScan in $sourceFolderScanJobs.ToArray()) { try { $pendingScan.Timer.Stop() } catch {} try { Remove-Job $pendingScan.Job -Force -ErrorAction SilentlyContinue } catch {} } $sourceFolderScanJobs.Clear() } 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 $diagLine = "[Diagnostics/End] Handles: {0}, GDI: {1}, WS: {2:N0} KB" -f $p2.HandleCount, $p2.GDIHandles, ($p2.WorkingSet64/1KB) Write-Verbose $diagLine -Verbose Write-IwaDebugLog $diagLine } catch { } } } Export-ModuleMember -Function Show-IntuneWinAppUtilGUI, Get-IntuneFileVersion |