diff --git a/Assets/Intune.png b/Assets/Intune.png new file mode 100644 index 0000000..5f09e74 Binary files /dev/null and b/Assets/Intune.png differ diff --git a/IntuneWinAppUtilGUI.psd1 b/IntuneWinAppUtilGUI.psd1 index 02eb64b..8e7b645 100644 --- a/IntuneWinAppUtilGUI.psd1 +++ b/IntuneWinAppUtilGUI.psd1 @@ -1,33 +1,34 @@ @{ - RootModule = 'IntuneWinAppUtilGUI.psm1' - ModuleVersion = '1.0.3' - GUID = '7db79126-1b57-48d2-970a-4795692dfcfc' - Author = 'Giovanni Solone' - Description = 'GUI wrapper for IntuneWinAppUtil.exe with config file support and WPF interface.' + RootModule = 'IntuneWinAppUtilGUI.psm1' + ModuleVersion = '1.0.4' + GUID = '7db79126-1b57-48d2-970a-4795692dfcfc' + Author = 'Giovanni Solone' + Description = 'GUI wrapper for IntuneWinAppUtil.exe with config file support and WPF interface.' - PowerShellVersion = '5.1' + # Minimum required PowerShell (PS 5.1 works; better with PS 7+) + PowerShellVersion = '5.1' + CompatiblePSEditions = @('Desktop', 'Core') + RequiredAssemblies = @() + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() - RequiredAssemblies = @( - 'System.Windows.Forms', - 'PresentationFramework' - ) - - FunctionsToExport = @('Show-IntuneWinAppUtilGui') - - CmdletsToExport = @() - VariablesToExport = @() - AliasesToExport = @() - - PrivateData = @{ + PrivateData = @{ PSData = @{ - Tags = @('Intune', 'Win32', 'GUI', 'packaging', 'IntuneWinAppUtil', 'IntuneWinAppUtil.exe', 'Microsoft', 'PowerShell', 'PSADT', 'AppDeployToolkit') - License = 'MIT' + Tags = @('Intune', 'Win32', 'GUI', 'packaging', 'IntuneWinAppUtil', 'Microsoft', 'PowerShell', 'PSADT', 'AppDeployToolkit') ProjectUri = 'https://github.com/gioxx/IntuneWinAppUtilGUI' - Icon = 'icon.png' - Readme = 'README.md' + LicenseUri = 'https://opensource.org/licenses/MIT' + IconUri = 'https://raw.githubusercontent.com/gioxx/IntuneWinAppUtilGUI/main/Assets/icon.png' ReleaseNotes = @' - - NEW: PSADT - If Invoke-AppDeployToolkit.exe is detected in the source folder, it is proposed as the default setup file. If Invoke-AppDeployToolkit.ps1 is detected in the source folder, it is parsed to propose a name for the IntuneWin package. - - Improved: The version of the IntuneWinAppUtil.exe file in use is shown on the screen. You can also use the "Force download" button to download the latest version available from GitHub. The list of versions is available at the "Version history" link. +- Bugfix: Removed any reference to ZIP uploads as setup files. +- Bugfix: Fixed PS 5.1 incompatibility in relative-path resolution ([System.IO.Path]::GetRelativePath is PS 7+). +- Improved: Code cleanup, removed redundant GitHub download logic; refactoring. +- Improved: Validates setup file existence and type. +- Improved: Tries to create output folder when missing. +- Improved: Ensures exactly one ".intunewin" extension on output. +- Improved: If Source folder is not specified, it is inferred from Setup file. +- Improved: Added more inline comments for maintainability. '@ } } diff --git a/IntuneWinAppUtilGUI.psm1 b/IntuneWinAppUtilGUI.psm1 index 37dd24d..8757208 100644 --- a/IntuneWinAppUtilGUI.psm1 +++ b/IntuneWinAppUtilGUI.psm1 @@ -1,9 +1,37 @@ -# # Import helper functions -# Get-ChildItem -Path "$PSScriptRoot\Private" -Filter *.ps1 | ForEach-Object { -# . $_.FullName -# } - -# Import public functions -Get-ChildItem -Path "$PSScriptRoot\Public" -Filter *.ps1 | ForEach-Object { - . $_.FullName +# IntuneWinAppUtilGUI.psm1 + +# Load UI-related assemblies once at module import time +Add-Type -AssemblyName PresentationFramework +Add-Type -AssemblyName System.IO.Compression.FileSystem +Add-Type -AssemblyName System.Windows.Forms + +# Expose module root so public/private scripts can resolve resources (UI.xaml, Assets, etc.) +$script:ModuleRoot = $PSScriptRoot + +# --- Load Private helpers first (NOT exported) --- +$privateDir = Join-Path $PSScriptRoot 'Private' +if (Test-Path $privateDir) { + Get-ChildItem -Path $privateDir -Filter '*.ps1' -File | ForEach-Object { + try { + . $_.FullName # dot-source + } catch { + throw "Failed to load Private script '$($_.Name)': $($_.Exception.Message)" + } + } } + +# --- Load Public entry points (will be exported) --- +$publicDir = Join-Path $PSScriptRoot 'Public' +if (Test-Path $publicDir) { + Get-ChildItem -Path $publicDir -Filter '*.ps1' -File | ForEach-Object { + try { + . $_.FullName # dot-source + } catch { + throw "Failed to load Public script '$($_.Name)': $($_.Exception.Message)" + } + } +} + +# Export only the intended public functions +# (Add more names here as you create additional public commands) +Export-ModuleMember -Function 'Show-IntuneWinAppUtilGui' diff --git a/Private/Get-ExeVersion.ps1 b/Private/Get-ExeVersion.ps1 new file mode 100644 index 0000000..32656d8 --- /dev/null +++ b/Private/Get-ExeVersion.ps1 @@ -0,0 +1,28 @@ +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 + } +} diff --git a/Private/Get-RelativePath.ps1 b/Private/Get-RelativePath.ps1 new file mode 100644 index 0000000..6fc6d41 --- /dev/null +++ b/Private/Get-RelativePath.ps1 @@ -0,0 +1,50 @@ +function Get-RelativePath { + <# + .SYNOPSIS + Returns a relative Windows path from BasePath to TargetPath when possible; + otherwise returns the absolute, normalized TargetPath. + .DESCRIPTION + - Normalizes base and target paths via [System.IO.Path]::GetFullPath. + - If paths are on different roots (drive letters or UNC shares), falls back to absolute. + - Uses Uri.MakeRelativeUri to compute the relative portion. + - Decodes URL-encoded characters and converts forward slashes to backslashes. + .PARAMETER BasePath + The base directory you want to compute the relative path from. + .PARAMETER TargetPath + The file or directory path you want to compute the relative path to. + .OUTPUTS + [string] Relative path if computable, otherwise absolute normalized TargetPath. + #> + + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$BasePath, + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$TargetPath + ) + + try { + # Normalize and ensure BasePath ends with a directory separator so Uri treats it as a folder + $baseFull = [System.IO.Path]::GetFullPath(($BasePath.TrimEnd('\') + '\')) + $targetFull = [System.IO.Path]::GetFullPath($TargetPath) + + # If roots differ (e.g., C:\ vs D:\ or different UNC shares), relative path is not possible + $baseRoot = [System.IO.Path]::GetPathRoot($baseFull) + $targetRoot = [System.IO.Path]::GetPathRoot($targetFull) + if (-not [string]::Equals($baseRoot, $targetRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + return $targetFull + } + + # Compute the relative URI and convert it to a Windows path + $uriBase = [Uri]$baseFull + $uriTarget = [Uri]$targetFull + + $rel = $uriBase.MakeRelativeUri($uriTarget).ToString() + # Decode URL-encoded chars (e.g., spaces) and switch to backslashes + $relWin = [Uri]::UnescapeDataString($rel).Replace('/', '\') + + return $relWin + } catch { + # On any unexpected error, just return the original target (best-effort behavior) + return $TargetPath + } +} \ No newline at end of file diff --git a/Private/Initialize-IntuneWinAppUtil.ps1 b/Private/Initialize-IntuneWinAppUtil.ps1 new file mode 100644 index 0000000..4d209ed --- /dev/null +++ b/Private/Initialize-IntuneWinAppUtil.ps1 @@ -0,0 +1,33 @@ +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 + } +} diff --git a/Private/Invoke-DownloadIntuneTool.ps1 b/Private/Invoke-DownloadIntuneTool.ps1 new file mode 100644 index 0000000..7906df1 --- /dev/null +++ b/Private/Invoke-DownloadIntuneTool.ps1 @@ -0,0 +1,89 @@ +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 {} + } + } +} diff --git a/Private/Set-SetupFromSource.ps1 b/Private/Set-SetupFromSource.ps1 new file mode 100644 index 0000000..ac28ae7 --- /dev/null +++ b/Private/Set-SetupFromSource.ps1 @@ -0,0 +1,71 @@ +function Set-SetupFromSource { + <# + .SYNOPSIS + Suggests the setup file and (optionally) proposes the final package name from a given source folder. + .DESCRIPTION + - Recursively searches for 'Invoke-AppDeployToolkit.exe' under SourcePath. + - If found, populates the provided TextBox control (SetupFileControl) with a relative path + (via Get-RelativePath) when the exe resides under SourcePath. + - Does not overwrite SetupFileControl if it already points to an existing file (absolute + or relative to SourcePath). + - If 'Invoke-AppDeployToolkit.ps1' exists in the same folder, extracts AppName/AppVersion + and sets FinalFilenameControl.Text to 'AppName_Version' (sanitizing spaces and invalid + filename characters). + - Fails silently on parsing/IO errors. + .PARAMETER SourcePath + The source directory to inspect. Must exist. + .PARAMETER SetupFileControl + The TextBox to populate with the suggested setup path (relative when possible). + .PARAMETER FinalFilenameControl + The TextBox to populate with the proposed final filename (e.g., 'AppName_Version'). + .OUTPUTS + None. Mutates the provided TextBox controls. + .EXAMPLE + Set-SetupFromSource -SourcePath $SourceFolder.Text -SetupFileControl $SetupFile -FinalFilenameControl $FinalFilename + #> + + [CmdletBinding()] + param( + [Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$SourcePath, + [Parameter(Mandatory)][ValidateNotNull()][System.Windows.Controls.TextBox]$SetupFileControl, + [Parameter(Mandatory)][ValidateNotNull()][System.Windows.Controls.TextBox]$FinalFilenameControl + ) + + if (-not (Test-Path $SourcePath)) { return } + + # If current SetupFile value already points to an existing file (absolute or relative to source), do not override. + $current = $SetupFileControl.Text.Trim() + if ($current) { + if (Test-Path $current) { return } + $maybeRelative = Join-Path $SourcePath $current + if (Test-Path $maybeRelative) { return } + } + + # Search for Invoke-AppDeployToolkit.exe + $exeHit = Get-ChildItem -Path $SourcePath -Filter 'Invoke-AppDeployToolkit.exe' -File -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($exeHit) { + # Prefer a relative path when the file is inside the source folder + $SetupFileControl.Text = Get-RelativePath -BasePath $SourcePath -TargetPath $exeHit.FullName + + # Look for Invoke-AppDeployToolkit.ps1 in the same folder + $ps1Path = Join-Path $exeHit.Directory.FullName 'Invoke-AppDeployToolkit.ps1' + if (Test-Path $ps1Path) { + try { + $content = Get-Content $ps1Path -Raw + $appName = $null + $appVersion = $null + if ($content -match "AppName\s*=\s*'([^']+)'") { $appName = $matches[1] } + if ($content -match "AppVersion\s*=\s*'([^']+)'") { $appVersion = $matches[1] } + + if ($appName -and $appVersion) { + # Clean filename: remove spaces and invalid chars + $cleanName = ($appName -replace '\s+', '' -replace '[\\/:*?"<>|]', '-') + $cleanVer = ($appVersion -replace '\s+', '' -replace '[\\/:*?"<>|]', '-') + $FinalFilenameControl.Text = "${cleanName}_${cleanVer}" + } + } catch { + # fail silently + } + } + } +} \ No newline at end of file diff --git a/Private/Show-ToolVersion.ps1 b/Private/Show-ToolVersion.ps1 new file mode 100644 index 0000000..89e130d --- /dev/null +++ b/Private/Show-ToolVersion.ps1 @@ -0,0 +1,22 @@ +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)" + } +} \ No newline at end of file diff --git a/Public/Show-IntuneWinAppUtilGui.ps1 b/Public/Show-IntuneWinAppUtilGui.ps1 index d1874fa..980e7cc 100644 --- a/Public/Show-IntuneWinAppUtilGui.ps1 +++ b/Public/Show-IntuneWinAppUtilGui.ps1 @@ -1,151 +1,14 @@ -# Show-Gui.ps1 -Add-Type -AssemblyName PresentationFramework -Add-Type -AssemblyName System.Windows.Forms -Add-Type -AssemblyName System.IO.Compression.FileSystem - -# Returns a relative path from BasePath to TargetPath when possible; otherwise returns the absolute path. -function Get-RelativePath { - param( - [Parameter(Mandatory)] [string]$BasePath, - [Parameter(Mandatory)] [string]$TargetPath - ) - - try { - $baseFull = [System.IO.Path]::GetFullPath(($BasePath.TrimEnd('\') + '\')) - $targetFull = [System.IO.Path]::GetFullPath($TargetPath) - $uriBase = [Uri]$baseFull - $uriTarget = [Uri]$targetFull - return $uriBase.MakeRelativeUri($uriTarget).ToString().Replace('/','\') - } catch { - return $TargetPath - } -} - -# If Invoke-AppDeployToolkit.exe exists under SourcePath, suggest it into the Setup textbox -# and optionally populate FinalFilename if AppName/AppVersion are found in Invoke-AppDeployToolkit.ps1 -function Set-SetupFromSource { - param([string]$SourcePath) - - if ([string]::IsNullOrWhiteSpace($SourcePath) -or -not (Test-Path $SourcePath)) { return } - - # If current SetupFile value already points to an existing file (absolute or relative to source), do not override. - $current = $SetupFile.Text.Trim() - if ($current) { - if (Test-Path $current) { return } - $maybeRelative = Join-Path $SourcePath $current - if (Test-Path $maybeRelative) { return } - } - - # Search for Invoke-AppDeployToolkit.exe - $exeHit = Get-ChildItem -Path $SourcePath -Filter 'Invoke-AppDeployToolkit.exe' -File -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($exeHit) { - # Prefer a relative path when the file is inside the source folder - $relativeExe = Get-RelativePath -BasePath $SourcePath -TargetPath $exeHit.FullName - $SetupFile.Text = $relativeExe - - # Look for Invoke-AppDeployToolkit.ps1 in the same folder - $ps1Path = Join-Path $exeHit.Directory.FullName 'Invoke-AppDeployToolkit.ps1' - if (Test-Path $ps1Path) { - try { - $content = Get-Content $ps1Path -Raw - - $appName = if ($content -match "AppName\s*=\s*'([^']+)'") { $matches[1] } else { $null } - $appVersion = if ($content -match "AppVersion\s*=\s*'([^']+)'") { $matches[1] } else { $null } - - if ($appName -and $appVersion) { - # Clean filename: remove spaces and invalid chars - $cleanName = ($appName -replace '\s+', '' -replace '[\\/:*?"<>|]', '-') - $cleanVer = ($appVersion -replace '\s+', '' -replace '[\\/:*?"<>|]', '-') - $FinalFilename.Text = "${cleanName}_${cleanVer}" - } - } catch { - # Fail silently if parsing goes wrong - } - } - } -} - -# Returns file version (FileVersion preferred, then ProductVersion); $null if not available. -function Get-ExeVersion { - 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 - } -} - -# Updates the ToolVersion TextBlock with current version or a default message. -function Show-ToolVersion { - param([string]$Path) - - if (-not $ToolVersionText) { return } - $ver = if ($Path) { Get-ExeVersion -Path $Path } else { $null } - $ToolVersionText.Text = if ($ver) { - "IntuneWinAppUtil version: $ver" - } else { - "IntuneWinAppUtil version: (not detected)" - } -} - -# Downloads the latest IntuneWinAppUtil.exe by fetching the master zip from GitHub, -# extracting it, locating the EXE, and copying it into %APPDATA%\IntuneWinAppUtilGUI\bin. -function Invoke-RedownloadIntuneTool { - param() - - $appRoot = Join-Path $env:APPDATA 'IntuneWinAppUtilGUI' - $binDir = Join-Path $appRoot 'bin' - $exePath = Join-Path $binDir 'IntuneWinAppUtil.exe' - $zipUrl = 'https://github.com/microsoft/Microsoft-Win32-Content-Prep-Tool/archive/refs/heads/master.zip' - $zipPath = Join-Path $env:TEMP 'IntuneWinAppUtil-master.zip' - $extractTo = Join-Path $env:TEMP 'IntuneExtract' - - try { - # Clean previous temp - if (Test-Path $extractTo) { Remove-Item $extractTo -Recurse -Force } - - # Ensure bin dir exists (and clear old exe to avoid stale versions) - New-Item -ItemType Directory -Path $binDir -Force | Out-Null - if (Test-Path $exePath) { Remove-Item $exePath -Force -ErrorAction SilentlyContinue } - - # Download ZIP - Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing - - # Extract ZIP - [System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $extractTo, $true) - - # Find EXE in extracted content - $found = Get-ChildItem -Path $extractTo -Recurse -Filter 'IntuneWinAppUtil.exe' -File -ErrorAction SilentlyContinue | Select-Object -First 1 - if (-not $found) { - throw "IntuneWinAppUtil.exe not found in extracted archive." - } - - # Copy to bin - Copy-Item -Path $found.FullName -Destination $exePath -Force - - # Cleanup temp - if (Test-Path $zipPath) { Remove-Item $zipPath -Force } - if (Test-Path $extractTo) { Remove-Item $extractTo -Recurse -Force } - - return $exePath - } catch { - throw $_ - } -} - +# Show-IntuneWinAppUtilGui.ps1 # Show the main GUI window and handle all events. function Show-IntuneWinAppUtilGui { [CmdletBinding()] param () + $moduleRoot = Split-Path -Path $PSScriptRoot -Parent $configPath = Join-Path -Path $env:APPDATA -ChildPath "IntuneWinAppUtilGUI\config.json" - $xamlPath = Join-Path -Path $PSScriptRoot -ChildPath "..\UI\UI.xaml" - $iconPath = Join-Path -Path $PSScriptRoot -ChildPath "..\Assets\Intune.ico" + $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" @@ -163,7 +26,7 @@ function Show-IntuneWinAppUtilGui { $ToolVersion = $window.FindName("ToolVersion") $ToolVersionText = $window.FindName("ToolVersionText") $ToolVersionLink = $window.FindName("ToolVersionLink") - $RedownloadTool = $window.FindName("RedownloadTool") + $DownloadTool = $window.FindName("DownloadTool") $FinalFilename = $window.FindName("FinalFilename") @@ -180,7 +43,7 @@ function Show-IntuneWinAppUtilGui { $SourceFolder.Add_TextChanged({ param($sender, $e) $src = $SourceFolder.Text.Trim() - if ($src) { Set-SetupFromSource -SourcePath $src } + if ($src) { Set-SetupFromSource -SourcePath $src -SetupFileControl $SetupFile -FinalFilenameControl $FinalFilename } }) # Preload config.json if it exists @@ -189,7 +52,7 @@ function Show-IntuneWinAppUtilGui { $cfg = Get-Content $configPath -Raw | ConvertFrom-Json if ($cfg.ToolPath -and (Test-Path $cfg.ToolPath)) { $ToolPathBox.Text = $cfg.ToolPath - Show-ToolVersion -Path $cfg.ToolPath + Show-ToolVersion -Path $cfg.ToolPath -Target $ToolVersionText } } catch {} } @@ -200,7 +63,7 @@ function Show-IntuneWinAppUtilGui { if ($dialog.ShowDialog() -eq 'OK') { $SourceFolder.Text = $dialog.SelectedPath # Auto-suggest Invoke-AppDeployToolkit.exe when present in the selected source - Set-SetupFromSource -SourcePath $dialog.SelectedPath + Set-SetupFromSource -SourcePath $dialog.SelectedPath -SetupFileControl $SetupFile -FinalFilenameControl $FinalFilename } }) @@ -214,21 +77,21 @@ function Show-IntuneWinAppUtilGui { if (-not [string]::IsNullOrWhiteSpace($sourceRoot) -and (Test-Path $sourceRoot)) { try { - $relativePath = [System.IO.Path]::GetRelativePath($sourceRoot, $selectedPath) + $relativePath = Get-RelativePath -BasePath $sourceRoot -TargetPath $selectedPath if (-not ($relativePath.StartsWith(".."))) { - # File is inside source folder or subdir - $SetupFile.Text = $relativePath + $SetupFile.Text = $relativePath # File is inside source folder or subdir } else { - # Outside of source folder - $SetupFile.Text = $selectedPath + $SetupFile.Text = $selectedPath # Outside of source folder } } catch { - # If relative path fails (e.g. bad format), fallback - $SetupFile.Text = $selectedPath + $SetupFile.Text = $selectedPath # If relative path fails (e.g. bad format), fallback } + # } else { + # $SetupFile.Text = $selectedPath # Source folder not set or invalid, fallback + # } } else { - # Source folder not set or invalid, fallback - $SetupFile.Text = $selectedPath + $SourceFolder.Text = Split-Path $selectedPath -Parent # Source folder not set or invalid -> infer it from the selected setup path + $SetupFile.Text = [System.IO.Path]::GetFileName($selectedPath) # Store only the file name in SetupFile so it is relative to SourceFolder } } }) @@ -245,24 +108,24 @@ function Show-IntuneWinAppUtilGui { $dlg.Filter = "IntuneWinAppUtil.exe|IntuneWinAppUtil.exe" if ($dlg.ShowDialog() -eq 'OK') { $ToolPathBox.Text = $dlg.FileName - Show-ToolVersion -Path $dlg.FileName + Show-ToolVersion -Path $dlg.FileName -Target $ToolVersionText } }) # Force download the IntuneWinAppUtil.exe tool - $RedownloadTool.Add_Click({ + $DownloadTool.Add_Click({ $confirm = [System.Windows.MessageBox]::Show( - "This will re-download the latest IntuneWinAppUtil.exe and replace the one in your bin folder.`n`nProceed?", - "Confirm re-download", + "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-RedownloadIntuneTool + $newPath = Invoke-DownloadIntuneTool $ToolPathBox.Text = $newPath - Show-ToolVersion -Path $newPath + Show-ToolVersion -Path $newPath -Target $ToolVersionText [System.Windows.MessageBox]::Show( "IntuneWinAppUtil.exe has been refreshed.`n`nPath:`n$newPath", @@ -272,7 +135,7 @@ function Show-IntuneWinAppUtilGui { ) } catch { [System.Windows.MessageBox]::Show( - "Re-download failed:`n$($_.Exception.Message)", + "Download failed:`n$($_.Exception.Message)", "Error", [System.Windows.MessageBoxButton]::OK, [System.Windows.MessageBoxImage]::Error @@ -284,122 +147,189 @@ function Show-IntuneWinAppUtilGui { $ToolPathBox.Add_TextChanged({ param($sender, $e) $p = $ToolPathBox.Text.Trim() - if ($p) { Show-ToolVersion -Path $p } else { Show-ToolVersion -Path $null } + 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({ + $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() - $s = $SetupFile.Text.Trim() - $o = $OutputFolder.Text.Trim() - $f = $FinalFilename.Text.Trim() + $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 } } - if (-not (Test-Path $o)) { [System.Windows.MessageBox]::Show("Invalid output folder path.", "Error", "OK", "Error"); return } - - # IntuneWinAppUtil.exe path check (or download if not set) - $toolPath = $ToolPathBox.Text.Trim() - $downloadDir = Join-Path $env:APPDATA "IntuneWinAppUtilGUI\bin" - $exePath = Join-Path $downloadDir "IntuneWinAppUtil.exe" - if ([string]::IsNullOrWhiteSpace($toolPath) -or -not (Test-Path $toolPath)) { - if (-not (Test-Path $exePath)) { - try { - $url = "https://github.com/microsoft/Microsoft-Win32-Content-Prep-Tool/archive/refs/heads/master.zip" - $zipPath = Join-Path $env:TEMP "IntuneWinAppUtil-master.zip" - $extractPath = Join-Path $env:TEMP "IntuneExtract" - - if (Test-Path $extractPath) { Remove-Item $extractPath -Recurse -Force } + # 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 + } - Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing - [System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $extractPath, $true) - Remove-Item $zipPath -Force + # 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 + } + } - # Find the executable in the extracted structure - $sourceExe = Get-ChildItem -Path $extractPath -Recurse -Filter "IntuneWinAppUtil.exe" | Select-Object -First 1 + # 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 + } - if (-not $sourceExe) { - throw "IntuneWinAppUtil.exe not found in extracted archive." - } + # IntuneWinAppUtil.exe path check (or initialize/download if not set) + $toolPath = Initialize-IntuneWinAppUtil -UiToolPath ($ToolPathBox.Text.Trim()) - # Copy to destination - New-Item -ItemType Directory -Force -Path $downloadDir | Out-Null - Copy-Item -Path $sourceExe.FullName -Destination $exePath -Force + 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 + } - [System.Windows.MessageBox]::Show("Tool downloaded and extracted to:`n$exePath", "Download Complete", "OK", "Info") - } catch { - [System.Windows.MessageBox]::Show("Failed to download or extract the archive:`n$($_.Exception.Message)", "Download 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) - if (Test-Path $exePath) { - $toolPath = $exePath - $ToolPathBox.Text = $toolPath - Show-ToolVersion -Path $toolPath # equivalent -Path $exePath - } + # Launch IntuneWinAppUtil.exe, wait, and capture exit code (WorkingDirectory is set to the tool's folder to avoid relative path issues) + 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 (-not (Test-Path $toolPath)) { - [System.Windows.MessageBox]::Show("IntuneWinAppUtil.exe not found at:`n$toolPath", "Error", "OK", "Error") + # Fail early if tool returned non-zero + if ($proc.ExitCode -ne 0) { + [System.Windows.MessageBox]::Show( + "IntuneWinAppUtil exited with code $($proc.ExitCode).", + "Packaging failed", "OK", "Error" + ) return } - $IWAUtilargs = "-c `"$c`" -s `"$s`" -o `"$o`"" - Start-Process -FilePath $toolPath -ArgumentList $IWAUtilargs -WorkingDirectory (Split-Path $toolPath) -WindowStyle Normal -Wait - - Start-Sleep -Seconds 1 + # Wait a bit for the output file to appear (up to 10 seconds, checking every 250ms) + # Compute the default output filename that IntuneWinAppUtil generates. By default it matches the setup's base name + ".intunewin". $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) { - $newName = if ([string]::IsNullOrWhiteSpace($f)) { - (Split-Path $c -Leaf) + ".intunewin" + # Build desired name from $f (if any), ensuring exactly one ".intunewin": + # - If FinalFilename ($f) is blank, fallback to using the source folder name. + # - Otherwise use the provided FinalFilename. + if ([string]::IsNullOrWhiteSpace($f)) { + $desiredName = (Split-Path $c -Leaf) + ".intunewin" } else { - $f + ".intunewin" + $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 { + # Prepare collision-safe rename: + # If a file with the desired name already exists, append _1, _2, ... until unique. $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++ } - + + # Perform the rename operation from the tool's default output to our final target name. Rename-Item -Path $defaultPath -NewName $finalName -Force $fullPath = Join-Path $o $finalName - + + # Inform the user and optionally offer to open File Explorer with the file selected. $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 "Yes") { - Start-Process explorer.exe "/select,`"$fullPath`"" + Start-Process explorer.exe "/select,`"$fullPath`"" # Open Explorer with the new file pre-selected. } - + } catch { - [System.Windows.MessageBox]::Show("Renaming failed: $($_.Exception.Message)", "Warning", "OK", "Warning") + [System.Windows.MessageBox]::Show("Renaming failed: $($_.Exception.Message)", "Warning", "OK", "Warning") # If anything goes wrong during the rename, show a warning. } - + } else { - [System.Windows.MessageBox]::Show("Output file not found.", "Warning", "OK", "Warning") + [System.Windows.MessageBox]::Show( + "Output file not found:`n$defaultPath", + "Warning", "OK", "Warning" + ) # The expected output was not found; warn the user (the tool may have failed). } }) @@ -416,6 +346,7 @@ function Show-IntuneWinAppUtilGui { $window.Close() }) + # Keyboard shortcuts: Esc to exit (with confirmation), Enter to run packaging $window.Add_KeyDown({ param($sender, $e) switch ($e.Key) { @@ -430,6 +361,7 @@ function Show-IntuneWinAppUtilGui { } }) + # When the window is closed, save the ToolPath to config.json $window.Add_Closed({ if (-not (Test-Path (Split-Path $configPath))) { New-Item -Path (Split-Path $configPath) -ItemType Directory -Force | Out-Null @@ -438,10 +370,25 @@ function Show-IntuneWinAppUtilGui { $cfg | ConvertTo-Json | Set-Content $configPath -Encoding UTF8 }) + # 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)) } + # Find the Image control in XAML and load the PNG from disk and assign it to the Image.Source + $HeaderIcon = $window.FindName('HeaderIcon') + if ($HeaderIcon -and (Test-Path $iconPngPath)) { + # Use BitmapImage with OnLoad so the file is not locked after loading + $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 in the ToolVersionText to open the GitHub version history page (and other links if needed) $window.AddHandler([ System.Windows.Documents.Hyperlink]::RequestNavigateEvent, [System.Windows.Navigation.RequestNavigateEventHandler] { diff --git a/README.md b/README.md index 301f6d9..99c99bc 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This tool simplifies the packaging of Win32 apps for Microsoft Intune by providi ## 🔧 Features - Built with **WPF** (XAML) and **PowerShell** — no external dependencies. -- Automatically stores tool path and reuses it on next launch (saved in a JSON file, check [section "Configuration file"](#%EF%B8%8F-configuration-file)). +- Automatically stores tool path and reuses it on next launch (saved in a JSON file, check ["Configuration file"](#%EF%B8%8F-configuration-file)). - Graphical interface for all required options (`-c`, `-s`, `-o`). - **Auto-download** of the latest version of `IntuneWinAppUtil.exe` from GitHub (optional). - It detects the use of PSAppDeployToolkit and automatically proposes executable file and final IntuneWin package name. @@ -26,7 +26,7 @@ This tool simplifies the packaging of Win32 apps for Microsoft Intune by providi - Windows 10 or later. - PowerShell 5.1 or higher. -- .NET Framework (usually already installed on supported systems). +- .NET Framework 4.7.2 or higher (usually already installed on supported systems). --- diff --git a/UI/UI.xaml b/UI/UI.xaml index dbe4868..cd0fcdb 100644 --- a/UI/UI.xaml +++ b/UI/UI.xaml @@ -7,140 +7,159 @@ MinHeight="360" WindowStartupLocation="CenterScreen"> - - - - - - - - - - - - - - - - - - -