# install-menu.ps1 $ErrorActionPreference = "Stop" $SoftwareGroups = [ordered]@{ "System Tools" = @( @{ Type = "Winget"; Name = "7-Zip"; Id = "7zip.7zip" } @{ Type = "Winget"; Name = "Git"; Id = "Git.Git" } @{ Type = "Winget"; Name = "PowerShell 7"; Id = "Microsoft.PowerShell" } @{ Type = "Winget"; Name = "Windows Terminal"; Id = "Microsoft.WindowsTerminal" } @{ Type = "Winget"; Name = "PowerToys"; Id = "Microsoft.PowerToys" } @{ Type = "Winget"; Name = "Everything"; Id = "voidtools.Everything" } @{ Type = "Winget"; Name = "VC++ Runtime"; Id = "Microsoft.VCRedist.2015+.x64" } @{ Type = "Winget"; Name = "CrystalDiskInfo Aoi Edition"; Id = "CrystalDewWorld.CrystalDiskInfo.AoiEdition" } @{ Type = "Winget"; Name = "CPUID CPU-Z"; Id = "CPUID.CPU-Z" } @{ Type = "Winget"; Name = "HWiNFO"; Id = "REALiX.HWiNFO" } ) "Apps" = @( @{ Type = "Winget"; Name = "Google Chrome"; Id = "Google.Chrome" } @{ Type = "Winget"; Name = "WeChat"; Id = "Tencent.WeChat" } @{ Type = "Winget"; Name = "QQ"; Id = "Tencent.QQ" } @{ Type = "Winget"; Name = "Telegram"; Id = "Telegram.TelegramDesktop" } @{ Type = "Winget"; Name = "VLC"; Id = "VideoLAN.VLC" } @{ Type = "Winget"; Name = "Obsidian"; Id = "Obsidian.Obsidian" } @{ Type = "Winget"; Name = "Office Tool Plus"; Id = "Yerong.OfficeToolPlus" } @{ Type = "Winget"; Name = "Neovide"; Id = "Neovide.Neovide" } @{ Type = "Winget"; Name = "Syncthing"; Id = "Syncthing.Syncthing"; ProcessName = "syncthing"; AutostartName = "Syncthing" } @{ Type = "Winget"; Name = "Steam"; Id = "Valve.Steam" } @{ Type = "Winget"; Name = "v2rayN"; Id = "2dust.v2rayN" } ) "Command Line Tools" = @( @{ Type = "Winget"; Name = "Yazi"; Id = "sxyazi.yazi" } @{ Type = "Winget"; Name = "Neovim"; Id = "Neovim.Neovim" } @{ Type = "Winget"; Name = "eza"; Id = "eza-community.eza" } @{ Type = "Winget"; Name = "zoxide"; Id = "ajeetdsouza.zoxide" } @{ Type = "Winget"; Name = "fzf"; Id = "junegunn.fzf" } @{ Type = "Winget"; Name = "ripgrep"; Id = "BurntSushi.ripgrep.MSVC" } @{ Type = "Winget"; Name = "fd"; Id = "sharkdp.fd" } @{ Type = "Winget"; Name = "bat"; Id = "sharkdp.bat" } @{ Type = "Winget"; Name = "jq"; Id = "jqlang.jq" } @{ Type = "Winget"; Name = "curl"; Id = "cURL.cURL" } @{ Type = "Winget"; Name = "wget"; Id = "GNU.Wget2" } @{ Type = "Winget"; Name = "starship"; Id = "Starship.Starship" } @{ Type = "Winget"; Name = "fastfetch"; Id = "Fastfetch-cli.Fastfetch" } @{ Type = "Winget"; Name = "lazygit"; Id = "JesseDuffield.lazygit" } @{ Type = "Winget"; Name = "git-delta"; Id = "dandavison.delta" } @{ Type = "Winget"; Name = "bottom"; Id = "Clement.bottom" } @{ Type = "Winget"; Name = "FFmpeg"; Id = "Gyan.FFmpeg" } @{ Type = "Winget"; Name = "ImageMagick"; Id = "ImageMagick.ImageMagick" } @{ Type = "Winget"; Name = "Poppler"; Id = "oschwartz10612.Poppler" } ) "Dev Tools" = @( @{ Type = "Winget"; Name = "Visual Studio Code"; Id = "Microsoft.VisualStudioCode" } @{ Type = "Winget"; Name = "GitHub CLI"; Id = "GitHub.cli" } @{ Type = "Winget"; Name = "Python 3.13"; Id = "Python.Python.3.13" } @{ Type = "Winget"; Name = "Node.js LTS"; Id = "OpenJS.NodeJS.LTS" } @{ Type = "Winget"; Name = "uv"; Id = "astral-sh.uv" } @{ Type = "Winget"; Name = "Docker Desktop"; Id = "Docker.DockerDesktop" } ) "Fonts" = @( @{ Type = "Winget"; Name = "JetBrains Mono Nerd Font"; Id = "DEVCOM.JetBrainsMonoNerdFont" } @{ Type = "Winget"; Name = "Cascadia Code Nerd Font"; Id = "DEVCOM.CascadiaCodeNerdFont" } @{ Type = "Winget"; Name = "Cascadia Mono Nerd Font"; Id = "DEVCOM.CascadiaMonoNerdFont" } @{ Type = "Winget"; Name = "FiraCode Nerd Font"; Id = "DEVCOM.FiraCodeNerdFont" } @{ Type = "Winget"; Name = "Hack Nerd Font"; Id = "DEVCOM.HackNerdFont" } ) "WSL / Linux" = @( @{ Type = "WslBase"; Name = "WSL base only"; Exe = "wsl"; Args = @("--install", "--no-distribution") } @{ Type = "WslDistro"; Name = "Ubuntu"; Exe = "wsl"; Args = @("--install", "-d", "Ubuntu") } @{ Type = "WslDistro"; Name = "Debian"; Exe = "wsl"; Args = @("--install", "-d", "Debian") } @{ Type = "WslDistro"; Name = "Kali Linux"; Exe = "wsl"; Args = @("--install", "-d", "kali-linux") } @{ Type = "WslDistro"; Name = "Arch Linux"; Exe = "wsl"; Args = @("--install", "-d", "archlinux") } ) "Autostart" = @( @{ Type = "Autostart"; Name = "Syncthing"; Command = "syncthing.exe"; Args = @("--no-console", "--no-browser"); ShortcutName = "Syncthing.lnk"; Url = "http://localhost:8384" } ) "Package Downloads" = @( # Put direct download links here. Files are downloaded to Downloads\packages by default. # Example: # @{ Type = "Download"; Name = "Example ZIP Package"; Url = "https://example.com/package.zip"; FileName = "package.zip" } @{ Type = "Download"; Name = "HEU_KMS_Activator"; Url = "https://github.com/zbezj/HEU_KMS_Activator/releases/download/63.4.0/HEU_KMS_Activator_v63.4.0.rar"; FileName = "HEU_KMS_Activator_v63.4.0.zip" } ) "Maintenance" = @( @{ Type = "Command"; Name = "Update winget source"; Exe = "winget"; Args = @("source", "update") } @{ Type = "Command"; Name = "Upgrade all winget packages"; Exe = "winget"; Args = @("upgrade", "--all", "--accept-source-agreements", "--accept-package-agreements") } @{ Type = "Command"; Name = "List installed packages"; Exe = "winget"; Args = @("list") } @{ Type = "Command"; Name = "Export installed packages"; Exe = "winget"; Args = @("export", "-o", "winget-export.json", "--accept-source-agreements") } @{ Type = "Command"; Name = "WSL status"; Exe = "wsl"; Args = @("--status") } @{ Type = "Command"; Name = "List online WSL distros"; Exe = "wsl"; Args = @("--list", "--online") } ) } function Write-Line { Write-Host "----------------------------------------" -ForegroundColor DarkGray } function Write-Title { param ( [string]$Text ) Write-Line Write-Host " $Text" -ForegroundColor Cyan Write-Line } function Write-MenuItem { param ( [int]$Number, [string]$Name, [string]$Extra = "" ) Write-Host ("{0,2}. " -f $Number) -NoNewline -ForegroundColor Yellow Write-Host $Name -NoNewline -ForegroundColor White if ($Extra -ne "") { Write-Host " $Extra" -ForegroundColor DarkGray } else { Write-Host "" } } function Write-Option { param ( [string]$Key, [string]$Text ) Write-Host " $Key. " -NoNewline -ForegroundColor Yellow Write-Host $Text -ForegroundColor White } function Write-Hint { param ( [string]$Text ) Write-Host $Text -ForegroundColor DarkGray } function Write-Info { param ( [string]$Text ) Write-Host $Text -ForegroundColor Cyan } function Write-Success { param ( [string]$Text ) Write-Host $Text -ForegroundColor Green } function Write-Warn { param ( [string]$Text ) Write-Host $Text -ForegroundColor Yellow } function Write-ErrorText { param ( [string]$Text ) Write-Host $Text -ForegroundColor Red } function Write-Kv { param ( [string]$Key, [string]$Value ) Write-Host ("{0,-8}: " -f $Key) -NoNewline -ForegroundColor DarkGray Write-Host $Value -ForegroundColor White } function Wait-User { Write-Host "" Read-Host "Press Enter to continue" } function Test-Winget { if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { Write-ErrorText "winget not found. Please install App Installer first." return $false } return $true } function Test-WslCommand { if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) { Write-ErrorText "wsl command not found." Write-Warn "Please make sure your Windows version supports WSL." return $false } return $true } function Test-CommandExists { param ( [string]$CommandName ) if (-not (Get-Command $CommandName -ErrorAction SilentlyContinue)) { Write-ErrorText "$CommandName not found." return $false } return $true } function Test-WingetInstalled { param ( [string]$Id ) if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { return $false } $output = winget list --id $Id -e --accept-source-agreements 2>$null | Out-String if ($output -match [regex]::Escape($Id)) { return $true } return $false } function Test-WslReady { if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) { return $false } wsl --status *> $null if ($LASTEXITCODE -eq 0) { return $true } return $false } function Get-WslDistroName { param ( [hashtable]$App ) if (-not $App.Args) { return "" } $argsText = @($App.Args) for ($i = 0; $i -lt $argsText.Count; $i++) { if ($argsText[$i] -eq "-d" -and ($i + 1) -lt $argsText.Count) { return [string]$argsText[$i + 1] } } if ($argsText.Count -ge 2 -and $argsText[0] -eq "--install") { return [string]$argsText[1] } return "" } function Test-WslDistroInstalled { param ( [hashtable]$App ) if (-not (Test-WslReady)) { return $false } $distroName = Get-WslDistroName -App $App if ($distroName -eq "") { return $false } $distros = wsl --list --quiet 2>$null foreach ($distro in $distros) { if ($distro.Trim() -ieq $distroName) { return $true } } return $false } function Test-IsAdmin { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]$identity return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Restart-ScriptAsAdmin { $scriptPath = $PSCommandPath if (-not $scriptPath) { Write-Warn "Cannot find current script path." Write-Warn "Please run PowerShell as Administrator manually." Wait-User return } Write-Warn "Administrator permission is required." Write-Hint "A new Administrator PowerShell window will be opened." Write-Host "" $confirm = Read-Host "Restart this script as Administrator? Y/N" if ($confirm -notmatch "^[Yy]$") { Write-Warn "Cancelled." Wait-User return } $argumentList = "-ExecutionPolicy Bypass -File `"$scriptPath`"" Start-Process powershell -Verb RunAs -ArgumentList $argumentList exit } function Get-StartupFolder { return [Environment]::GetFolderPath("Startup") } function Get-AutostartShortcutPath { param ( [hashtable]$App ) $startupFolder = Get-StartupFolder $shortcutName = [string]$App.ShortcutName if ([string]::IsNullOrWhiteSpace($shortcutName)) { $shortcutName = "$($App.Name).lnk" } return (Join-Path $startupFolder $shortcutName) } function Get-AutostartArgumentsText { param ( [hashtable]$App ) if (-not $App.Args) { return "" } return (@($App.Args) -join " ") } function Get-AutostartLauncherFolder { $folder = Join-Path $env:LOCALAPPDATA "InstallMenu\Autostart" return $folder } function Get-AutostartLauncherPath { param ( [hashtable]$App ) $name = [string]$App.Name if ([string]::IsNullOrWhiteSpace($name)) { $name = "AutostartApp" } $safeName = ($name -replace '[^a-zA-Z0-9._-]', '_') return (Join-Path (Get-AutostartLauncherFolder) "$safeName-hidden.vbs") } function Get-AutostartCommandLine { param ( [string]$ExePath, [string]$ArgumentsText ) $commandLine = "`"$ExePath`"" if (-not [string]::IsNullOrWhiteSpace($ArgumentsText)) { $commandLine = "$commandLine $ArgumentsText" } return $commandLine } function New-AutostartHiddenLauncher { param ( [hashtable]$App, [string]$ExePath, [string]$ArgumentsText ) $launcherFolder = Get-AutostartLauncherFolder $launcherPath = Get-AutostartLauncherPath -App $App if (-not (Test-Path $launcherFolder)) { New-Item -ItemType Directory -Path $launcherFolder -Force | Out-Null } $commandLine = Get-AutostartCommandLine -ExePath $ExePath -ArgumentsText $ArgumentsText $vbsCommandLine = $commandLine.Replace('"', '""') $content = @" Set WshShell = CreateObject("WScript.Shell") WshShell.Run "$vbsCommandLine", 0, False "@ Set-Content -Path $launcherPath -Value $content -Encoding Unicode return $launcherPath } function Start-HiddenProgram { param ( [string]$ExePath, [string]$ArgumentsText ) $commandLine = Get-AutostartCommandLine -ExePath $ExePath -ArgumentsText $ArgumentsText $wsh = New-Object -ComObject WScript.Shell $wsh.Run($commandLine, 0, $false) | Out-Null } function Get-AutostartCommandInfo { param ( [hashtable]$App ) $commandName = [string]$App.Command if ([string]::IsNullOrWhiteSpace($commandName)) { return $null } return (Get-Command $commandName -ErrorAction SilentlyContinue) } function Get-AutostartExePath { param ( [hashtable]$App ) $commandInfo = Get-AutostartCommandInfo -App $App if ($null -eq $commandInfo) { return "" } if ($commandInfo.Source) { return [string]$commandInfo.Source } if ($commandInfo.Path) { return [string]$commandInfo.Path } return "" } function Get-AutostartProcessName { param ( [hashtable]$App ) if ($App.ProcessName) { return [string]$App.ProcessName } $commandName = [string]$App.Command if ([string]::IsNullOrWhiteSpace($commandName)) { return "" } return [System.IO.Path]::GetFileNameWithoutExtension($commandName) } function Test-AutostartEnabled { param ( [hashtable]$App ) $shortcutPath = Get-AutostartShortcutPath -App $App return (Test-Path $shortcutPath) } function Test-AutostartProcessRunning { param ( [hashtable]$App ) $processName = Get-AutostartProcessName -App $App if ([string]::IsNullOrWhiteSpace($processName)) { return $false } $process = Get-Process -Name $processName -ErrorAction SilentlyContinue return ($null -ne $process) } function Stop-AutostartAppNow { param ( [hashtable]$App, [switch]$NoPause, [switch]$Silent ) $processName = Get-AutostartProcessName -App $App if ([string]::IsNullOrWhiteSpace($processName)) { if (-not $Silent) { Write-Warn "No process name configured for $($App.Name)." } if (-not $NoPause) { Wait-User } return $false } $processes = Get-Process -Name $processName -ErrorAction SilentlyContinue if ($null -eq $processes) { if (-not $Silent) { Write-Warn "$($App.Name) is not running." } if (-not $NoPause) { Wait-User } return $true } try { $processes | Stop-Process -Force if (-not $Silent) { Write-Success "Stopped: $($App.Name)" } if (-not $NoPause) { Wait-User } return $true } catch { if (-not $Silent) { Write-ErrorText "Failed to stop $($App.Name): $($_.Exception.Message)" } if (-not $NoPause) { Wait-User } return $false } } function Enable-AutostartShortcut { param ( [hashtable]$App, [switch]$NoPause ) $exePath = Get-AutostartExePath -App $App if ([string]::IsNullOrWhiteSpace($exePath)) { Write-ErrorText "$($App.Name) command not found: $($App.Command)" Write-Warn "Please install $($App.Name) first, then enable autostart." if (-not $NoPause) { Wait-User } return $false } $startupFolder = Get-StartupFolder $shortcutPath = Get-AutostartShortcutPath -App $App $argumentsText = Get-AutostartArgumentsText -App $App if (-not (Test-Path $startupFolder)) { New-Item -ItemType Directory -Path $startupFolder -Force | Out-Null } $launcherPath = New-AutostartHiddenLauncher -App $App -ExePath $exePath -ArgumentsText $argumentsText $wscriptPath = Join-Path $env:WINDIR "System32\wscript.exe" $wsh = New-Object -ComObject WScript.Shell $shortcut = $wsh.CreateShortcut($shortcutPath) $shortcut.TargetPath = $wscriptPath $shortcut.Arguments = "//B //Nologo `"$launcherPath`"" $shortcut.WorkingDirectory = Split-Path $launcherPath $shortcut.Description = "Start $($App.Name) hidden at Windows user logon" $shortcut.WindowStyle = 7 $shortcut.Save() Write-Success "Autostart enabled: $($App.Name)" Write-Hint "Shortcut: $shortcutPath" Write-Hint "Launcher: $launcherPath" Write-Hint "Command : $exePath $argumentsText" if (-not $NoPause) { Wait-User } return $true } function Disable-AutostartShortcut { param ( [hashtable]$App, [switch]$NoPause ) $shortcutPath = Get-AutostartShortcutPath -App $App $launcherPath = Get-AutostartLauncherPath -App $App $removed = $false if (Test-Path $shortcutPath) { Remove-Item $shortcutPath -Force Write-Success "Removed shortcut: $shortcutPath" $removed = $true } if (Test-Path $launcherPath) { Remove-Item $launcherPath -Force Write-Success "Removed launcher: $launcherPath" $removed = $true } if ($removed) { Write-Success "Autostart disabled: $($App.Name)" } else { Write-Warn "Autostart is not enabled: $($App.Name)" Write-Hint "Shortcut not found: $shortcutPath" } if (-not $NoPause) { Wait-User } } function Start-AutostartAppNow { param ( [hashtable]$App, [switch]$NoPause ) $exePath = Get-AutostartExePath -App $App if ([string]::IsNullOrWhiteSpace($exePath)) { Write-ErrorText "$($App.Name) command not found: $($App.Command)" Write-Warn "Please install $($App.Name) first." if (-not $NoPause) { Wait-User } return } if (Test-AutostartProcessRunning -App $App) { Write-Warn "$($App.Name) is already running." if (-not $NoPause) { Wait-User } return } $argumentsText = Get-AutostartArgumentsText -App $App Start-HiddenProgram -ExePath $exePath -ArgumentsText $argumentsText Write-Success "Started hidden: $($App.Name)" if (-not $NoPause) { Wait-User } } function Open-AutostartAppUrl { param ( [hashtable]$App, [switch]$NoPause ) $url = [string]$App.Url if ([string]::IsNullOrWhiteSpace($url)) { Write-Warn "No URL configured for $($App.Name)." } else { Start-Process $url Write-Success "Opened: $url" } if (-not $NoPause) { Wait-User } } function Show-AutostartInfo { param ( [hashtable]$App ) Clear-Host Write-Title "Autostart Info" $shortcutPath = Get-AutostartShortcutPath -App $App $exePath = Get-AutostartExePath -App $App $argumentsText = Get-AutostartArgumentsText -App $App $enabledText = if (Test-AutostartEnabled -App $App) { "Enabled" } else { "Disabled" } $runningText = if (Test-AutostartProcessRunning -App $App) { "Running" } else { "Not running" } Write-Kv -Key "Name" -Value $App.Name Write-Kv -Key "Type" -Value "Startup folder shortcut + hidden launcher" Write-Kv -Key "Status" -Value $enabledText Write-Kv -Key "Running" -Value $runningText Write-Kv -Key "Startup" -Value (Get-StartupFolder) Write-Kv -Key "Shortcut" -Value $shortcutPath Write-Kv -Key "Launcher" -Value (Get-AutostartLauncherPath -App $App) Write-Kv -Key "Command" -Value "$($App.Command) $argumentsText" if ([string]::IsNullOrWhiteSpace($exePath)) { Write-Kv -Key "Exe" -Value "Not found" } else { Write-Kv -Key "Exe" -Value $exePath } if ($App.Url) { Write-Kv -Key "Web UI" -Value ([string]$App.Url) } if (Test-Path $shortcutPath) { try { $wsh = New-Object -ComObject WScript.Shell $shortcut = $wsh.CreateShortcut($shortcutPath) Write-Host "" Write-Info "Current shortcut content:" Write-Kv -Key "Target" -Value $shortcut.TargetPath Write-Kv -Key "Args" -Value $shortcut.Arguments Write-Kv -Key "WorkDir" -Value $shortcut.WorkingDirectory } catch { Write-Warn "Could not read shortcut details." } } $launcherPath = Get-AutostartLauncherPath -App $App if (Test-Path $launcherPath) { Write-Host "" Write-Info "Hidden launcher exists: $launcherPath" } Wait-User } function Invoke-AutostartAction { param ( [hashtable]$App, [switch]$SkipConfirm, [switch]$NoPause ) if ($SkipConfirm -and (Test-AutostartEnabled -App $App)) { Write-Warn "Autostart already enabled, skipped: $($App.Name)" return } if (-not $SkipConfirm) { Clear-Host Write-Title "Enable Autostart Confirmation" Write-Kv -Key "Name" -Value $App.Name Write-Kv -Key "Status" -Value (Get-AppStatusText -App $App) Write-Kv -Key "Command" -Value (Get-AppCommandText -App $App) Write-Host "" $confirm = Read-Host "Enable or recreate autostart shortcut? Y/N" if ($confirm -notmatch "^[Yy]$") { Write-Warn "Cancelled." if (-not $NoPause) { Wait-User } return } } $enabled = Enable-AutostartShortcut -App $App -NoPause if ($enabled) { Write-Host "" Start-AutostartAppNow -App $App -NoPause if ($App.Url) { Write-Hint "Web UI: $($App.Url)" } } if (-not $NoPause) { Wait-User } } function Show-AutostartActionMenu { param ( [hashtable]$App ) while ($true) { Clear-Host Write-Title "Autostart: $($App.Name)" Write-Kv -Key "Status" -Value (Get-AppStatusText -App $App) Write-Kv -Key "Shortcut" -Value (Get-AutostartShortcutPath -App $App) Write-Kv -Key "Command" -Value (Get-AppCommandText -App $App) Write-Host "" Write-Option -Key "1" -Text "Enable / recreate autostart + start now (hidden)" Write-Option -Key "2" -Text "Disable autostart" Write-Option -Key "3" -Text "Start now (hidden)" Write-Option -Key "4" -Text "Open Web UI" Write-Option -Key "5" -Text "Show details" Write-Option -Key "6" -Text "Stop now" Write-Option -Key "B" -Text "Back" Write-Option -Key "Q" -Text "Quit" Write-Host "" $choice = Read-Host "Choose" switch -Regex ($choice) { "^1$" { Invoke-AutostartAction -App $App } "^2$" { Disable-AutostartShortcut -App $App } "^3$" { Start-AutostartAppNow -App $App } "^4$" { Open-AutostartAppUrl -App $App } "^5$" { Show-AutostartInfo -App $App } "^6$" { Stop-AutostartAppNow -App $App } "^[Bb]$" { return } "^[Qq]$" { exit } default { Write-Warn "Invalid input." Wait-User } } } } function Get-PackageDownloadsFolder { $downloads = Join-Path $env:USERPROFILE "Downloads" $folder = Join-Path $downloads "packages" if (-not (Test-Path -LiteralPath $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null } return $folder } function Get-SafeFileName { param ( [string]$FileName ) $invalidChars = [System.IO.Path]::GetInvalidFileNameChars() $safeName = $FileName foreach ($char in $invalidChars) { $safeName = $safeName.Replace([string]$char, "_") } return $safeName } function Get-DownloadFileName { param ( [hashtable]$App ) if ($App.FileName -and -not [string]::IsNullOrWhiteSpace([string]$App.FileName)) { return (Get-SafeFileName -FileName ([string]$App.FileName)) } $url = [string]$App.Url try { $uri = [System.Uri]$url $fileName = [System.IO.Path]::GetFileName($uri.AbsolutePath) if (-not [string]::IsNullOrWhiteSpace($fileName)) { return (Get-SafeFileName -FileName $fileName) } } catch { # Fall through to name-based fallback. } return (Get-SafeFileName -FileName "$($App.Name).zip") } function Get-DownloadDestinationFolder { param ( [hashtable]$App ) if ($App.DestinationDir -and -not [string]::IsNullOrWhiteSpace([string]$App.DestinationDir)) { $folder = [Environment]::ExpandEnvironmentVariables([string]$App.DestinationDir) } else { $folder = Get-PackageDownloadsFolder } if (-not (Test-Path -LiteralPath $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null } return $folder } function Get-DownloadDestinationPath { param ( [hashtable]$App ) $folder = Get-DownloadDestinationFolder -App $App $fileName = Get-DownloadFileName -App $App return (Join-Path $folder $fileName) } function Test-DownloadFileExists { param ( [hashtable]$App ) $destinationPath = Get-DownloadDestinationPath -App $App return (Test-Path -LiteralPath $destinationPath -PathType Leaf) } function Invoke-DownloadPackage { param ( [hashtable]$App, [switch]$SkipConfirm, [switch]$NoPause ) $url = [string]$App.Url if ([string]::IsNullOrWhiteSpace($url)) { Write-ErrorText "Download URL is empty: $($App.Name)" if (-not $NoPause) { Wait-User } return $false } $destinationPath = Get-DownloadDestinationPath -App $App $destinationFolder = Split-Path -Parent $destinationPath $fileExists = Test-Path -LiteralPath $destinationPath -PathType Leaf if ($SkipConfirm -and $fileExists) { Write-Warn "Already downloaded, skipped: $($App.Name)" Write-Hint "Path: $destinationPath" return $true } if ((-not $SkipConfirm) -and $fileExists) { Write-Warn "File already exists: $destinationPath" $confirm = Read-Host "Overwrite it? Y/N" if ($confirm -notmatch "^[Yy]$") { Write-Warn "Cancelled." if (-not $NoPause) { Wait-User } return $false } } if (-not (Test-Path -LiteralPath $destinationFolder)) { New-Item -ItemType Directory -Path $destinationFolder -Force | Out-Null } $tempPath = "$destinationPath.download" if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force } Write-Host "" Write-Info "Downloading: $($App.Name)" Write-Hint "URL: $url" Write-Hint "Save to: $destinationPath" Write-Host "" try { Invoke-WebRequest -Uri $url -OutFile $tempPath -UseBasicParsing if (Test-Path -LiteralPath $destinationPath) { Remove-Item -LiteralPath $destinationPath -Force } Move-Item -LiteralPath $tempPath -Destination $destinationPath -Force Write-Success "Downloaded: $destinationPath" return $true } catch { Write-ErrorText "Download failed: $($_.Exception.Message)" if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue } return $false } finally { if (-not $NoPause) { Wait-User } } } function Remove-DownloadedPackage { param ( [hashtable]$App, [switch]$NoPause ) $destinationPath = Get-DownloadDestinationPath -App $App if (-not (Test-Path -LiteralPath $destinationPath -PathType Leaf)) { Write-Warn "Downloaded file not found: $destinationPath" if (-not $NoPause) { Wait-User } return $true } try { Remove-Item -LiteralPath $destinationPath -Force Write-Success "Deleted downloaded file: $destinationPath" return $true } catch { Write-ErrorText "Failed to delete file: $($_.Exception.Message)" return $false } finally { if (-not $NoPause) { Wait-User } } } function Get-AppTypeText { param ( [hashtable]$App ) switch ($App.Type) { "Winget" { return "Winget" } "WslBase" { return "WSL Base" } "WslDistro" { return "WSL Linux Distro" } "Command" { return "Command" } "Autostart" { return "Autostart" } "Download" { return "Download" } default { return "Unknown" } } } function Get-AppCommandText { param ( [hashtable]$App ) switch ($App.Type) { "Winget" { return "winget install --id $($App.Id) -e" } "WslBase" { return "$($App.Exe) $($App.Args -join ' ')" } "WslDistro" { return "$($App.Exe) $($App.Args -join ' ')" } "Command" { return "$($App.Exe) $($App.Args -join ' ')" } "Autostart" { return "$($App.Command) $(Get-AutostartArgumentsText -App $App)" } "Download" { return "Download to $(Get-DownloadDestinationPath -App $App)" } default { return "" } } } function Get-AppStatusText { param ( [hashtable]$App ) switch ($App.Type) { "Winget" { if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { return "Unknown, winget not found" } if (Test-WingetInstalled -Id ([string]$App.Id)) { return "Installed" } return "Not installed" } "WslBase" { if (Test-WslReady) { return "Enabled" } return "Not enabled" } "WslDistro" { if (-not (Test-WslReady)) { return "WSL not ready" } if (Test-WslDistroInstalled -App $App) { return "Installed" } return "Not installed" } "Command" { return "Runnable command" } "Autostart" { $enabledText = if (Test-AutostartEnabled -App $App) { "Enabled" } else { "Disabled" } $runningText = if (Test-AutostartProcessRunning -App $App) { "Running" } else { "Not running" } $exePath = Get-AutostartExePath -App $App if ([string]::IsNullOrWhiteSpace($exePath)) { return "$enabledText, command not found" } return "$enabledText, $runningText" } "Download" { if (Test-DownloadFileExists -App $App) { return "Downloaded" } return "Not downloaded" } default { return "Unknown" } } } function Get-PrimaryActionText { param ( [hashtable]$App ) switch ($App.Type) { "Winget" { if (Test-WingetInstalled -Id ([string]$App.Id)) { return "Upgrade / install again" } return "Install" } "WslBase" { if (Test-WslReady) { return "Already enabled" } return "Install" } "WslDistro" { if (Test-WslDistroInstalled -App $App) { return "Already installed" } return "Install" } "Command" { return "Run" } "Autostart" { if (Test-AutostartEnabled -App $App) { return "Recreate autostart shortcut" } return "Enable autostart" } "Download" { if (Test-DownloadFileExists -App $App) { return "Download again / overwrite" } return "Download" } default { return "Run" } } } function Convert-SelectionToNumbers { param ( [string]$InputText, [int]$MaxNumber ) $InputText = $InputText.Trim() if ($InputText -notmatch "^\d+(\s*-\s*\d+)?(\s*,\s*\d+(\s*-\s*\d+)?)*$") { return $null } $result = @() $parts = $InputText -split "," foreach ($part in $parts) { $part = $part.Trim() if ($part -match "^(\d+)\s*-\s*(\d+)$") { $start = [int]$Matches[1] $end = [int]$Matches[2] if ($start -gt $end) { Write-Warn "Invalid range: $part" return $null } for ($i = $start; $i -le $end; $i++) { if ($i -lt 1 -or $i -gt $MaxNumber) { Write-Warn "Invalid number: $i" return $null } if ($result -notcontains $i) { $result += $i } } } elseif ($part -match "^\d+$") { $number = [int]$part if ($number -lt 1 -or $number -gt $MaxNumber) { Write-Warn "Invalid number: $number" return $null } if ($result -notcontains $number) { $result += $number } } else { return $null } } return $result } function Install-WslBase { param ( [switch]$NoPause ) if (-not (Test-WslCommand)) { if (-not $NoPause) { Wait-User } return $false } if (Test-WslReady) { Write-Success "WSL is already installed or enabled." if (-not $NoPause) { Wait-User } return $true } if (-not (Test-IsAdmin)) { Restart-ScriptAsAdmin return $false } Write-Host "" Write-Info "Installing WSL base..." Write-Hint "Command: wsl --install --no-distribution" Write-Host "" wsl --install --no-distribution Write-Host "" Write-Success "WSL base installation command finished." Write-Warn "Please restart Windows, then run this script again to install Ubuntu/Debian/Kali/Arch." if (-not $NoPause) { Wait-User } return $false } function Ensure-WslReady { param ( [switch]$NoPause ) if (Test-WslReady) { return $true } Clear-Host Write-Title "WSL Required" Write-Warn "WSL is not installed or enabled." Write-Host "" $confirm = Read-Host "Install WSL first? Y/N" if ($confirm -notmatch "^[Yy]$") { Write-Host "" Write-Warn "Cancelled." if (-not $NoPause) { Wait-User } return $false } Install-WslBase -NoPause:$NoPause | Out-Null return $false } function Test-AppSupportsUninstall { param ( [hashtable]$App ) switch ($App.Type) { "Winget" { return $true } "WslDistro" { return $true } "Autostart" { return $true } "Download" { return $true } default { return $false } } } function Get-AppUninstallCommandText { param ( [hashtable]$App ) switch ($App.Type) { "Winget" { return "winget uninstall --id $($App.Id) -e" } "WslDistro" { $distroName = Get-WslDistroName -App $App if ([string]::IsNullOrWhiteSpace($distroName)) { return "wsl --unregister " } return "wsl --unregister $distroName" } "Autostart" { return "Remove startup shortcut and hidden launcher" } "Download" { return "Delete $(Get-DownloadDestinationPath -App $App)" } default { return "" } } } function Get-RelatedAutostartApp { param ( [hashtable]$App ) $name = "" if ($App.AutostartName) { $name = [string]$App.AutostartName } else { $name = [string]$App.Name } if ([string]::IsNullOrWhiteSpace($name)) { return $null } if (-not $SoftwareGroups.Contains("Autostart")) { return $null } foreach ($autostartApp in $SoftwareGroups["Autostart"]) { if ([string]$autostartApp.Name -ieq $name) { return $autostartApp } } return $null } function Stop-AppProcessIfConfigured { param ( [hashtable]$App, [switch]$Silent ) if (-not $App.ProcessName) { return $true } $processName = [string]$App.ProcessName $processes = Get-Process -Name $processName -ErrorAction SilentlyContinue if ($null -eq $processes) { if (-not $Silent) { Write-Hint "Process is not running: $processName" } return $true } try { $processes | Stop-Process -Force if (-not $Silent) { Write-Success "Stopped process: $processName" } return $true } catch { if (-not $Silent) { Write-Warn "Failed to stop process ${processName}: $($_.Exception.Message)" } return $false } } function Invoke-AppUninstall { param ( [hashtable]$App, [switch]$SkipConfirm, [switch]$NoPause ) if (-not (Test-AppSupportsUninstall -App $App)) { Write-Warn "Uninstall is not supported for this item: $($App.Name)" if (-not $NoPause) { Wait-User } return } $statusText = Get-AppStatusText -App $App $uninstallCommand = Get-AppUninstallCommandText -App $App if ($SkipConfirm) { if ($App.Type -eq "Winget" -and $statusText -ne "Installed") { Write-Warn "Not installed, skipped uninstall: $($App.Name)" return } if ($App.Type -eq "WslDistro") { Write-Warn "WSL distro uninstall requires single-item confirmation, skipped: $($App.Name)" return } if ($App.Type -eq "Autostart" -and -not (Test-AutostartEnabled -App $App)) { Write-Warn "Autostart not enabled, skipped: $($App.Name)" return } if ($App.Type -eq "Download" -and -not (Test-DownloadFileExists -App $App)) { Write-Warn "Downloaded file not found, skipped delete: $($App.Name)" return } } if (-not $SkipConfirm) { Clear-Host Write-Title "Uninstall Confirmation" Write-Kv -Key "Name" -Value $App.Name Write-Kv -Key "Type" -Value (Get-AppTypeText -App $App) Write-Kv -Key "Status" -Value $statusText Write-Kv -Key "Command" -Value $uninstallCommand Write-Host "" if ($App.Type -eq "Winget" -and $statusText -ne "Installed") { Write-Warn "$($App.Name) is not installed." Wait-User return } if ($App.Type -eq "Download" -and -not (Test-DownloadFileExists -App $App)) { Write-Warn "Downloaded file not found." Wait-User return } if ($App.Type -eq "WslDistro") { Write-Warn "This will unregister the WSL distro and delete its Linux files." Write-Warn "Only continue if you do not need data inside this distro." Write-Host "" } if ($App.Type -eq "Winget" -and $App.ProcessName) { Write-Hint "Before uninstalling, the script will stop its running process if needed." } if ($App.Type -eq "Winget" -and $App.AutostartName) { Write-Hint "Related autostart shortcut will also be removed if it exists." } Write-Host "" $confirm = Read-Host "Are you sure you want to uninstall $($App.Name)? Y/N" if ($confirm -notmatch "^[Yy]$") { Write-Warn "Cancelled." if (-not $NoPause) { Wait-User } return } } $success = $true if ($App.Type -eq "Winget") { if (-not (Test-Winget)) { if (-not $NoPause) { Wait-User } return } $relatedAutostartApp = Get-RelatedAutostartApp -App $App if ($null -ne $relatedAutostartApp) { Disable-AutostartShortcut -App $relatedAutostartApp -NoPause } Stop-AppProcessIfConfigured -App $App | Out-Null $id = [string]$App.Id Write-Host "" Write-Info "Uninstalling: $($App.Name)" Write-Hint "Winget ID: $id" Write-Host "" winget uninstall --id $id -e if ($LASTEXITCODE -ne 0) { $success = $false } } elseif ($App.Type -eq "WslDistro") { if (-not (Test-WslCommand)) { if (-not $NoPause) { Wait-User } return } $distroName = Get-WslDistroName -App $App if ([string]::IsNullOrWhiteSpace($distroName)) { Write-ErrorText "Could not determine WSL distro name." $success = $false } elseif (-not (Test-WslDistroInstalled -App $App)) { Write-Warn "WSL distro is not installed: $($App.Name)" $success = $true } else { Write-Host "" Write-Info "Unregistering WSL distro: $distroName" Write-Hint "Command: wsl --unregister $distroName" Write-Host "" wsl --unregister $distroName if ($LASTEXITCODE -ne 0) { $success = $false } } } elseif ($App.Type -eq "Autostart") { Disable-AutostartShortcut -App $App -NoPause $success = $true } elseif ($App.Type -eq "Download") { $success = Remove-DownloadedPackage -App $App -NoPause } Write-Host "" if ($success) { Write-Success "Uninstall finished: $($App.Name)" } else { Write-Warn "Uninstall finished with errors: $($App.Name)" Write-Hint "Exit code: $LASTEXITCODE" } if (-not $NoPause) { Wait-User } } function Show-AppInfo { param ( [hashtable]$App ) Clear-Host Write-Title "Software Info" Write-Kv -Key "Name" -Value $App.Name Write-Kv -Key "Type" -Value (Get-AppTypeText -App $App) Write-Kv -Key "Status" -Value (Get-AppStatusText -App $App) Write-Kv -Key "Command" -Value (Get-AppCommandText -App $App) if (Test-AppSupportsUninstall -App $App) { Write-Kv -Key "Uninstall" -Value (Get-AppUninstallCommandText -App $App) } Write-Host "" if ($App.Type -eq "Winget") { if (Test-Winget) { $id = [string]$App.Id winget show --id $id -e } } elseif ($App.Type -eq "WslBase" -or $App.Type -eq "WslDistro") { if (Test-WslCommand) { Write-Host "" Write-Info "WSL status:" wsl --status Write-Host "" Write-Info "Installed WSL distributions:" wsl --list --verbose Write-Host "" Write-Info "Available online distributions:" wsl --list --online } } elseif ($App.Type -eq "Command") { Write-Hint "This item runs a command directly." } elseif ($App.Type -eq "Autostart") { Write-Hint "This item creates or removes a shortcut in the current user's Startup folder." Write-Host "" Write-Info "Autostart details:" Write-Kv -Key "Startup" -Value (Get-StartupFolder) Write-Kv -Key "Shortcut" -Value (Get-AutostartShortcutPath -App $App) Write-Kv -Key "Running" -Value ($(if (Test-AutostartProcessRunning -App $App) { "Running" } else { "Not running" })) if ($App.Url) { Write-Kv -Key "Web UI" -Value ([string]$App.Url) } } elseif ($App.Type -eq "Download") { Write-Hint "This item downloads a file to Downloads\packages by default." Write-Host "" Write-Info "Download details:" Write-Kv -Key "URL" -Value ([string]$App.Url) Write-Kv -Key "File" -Value (Get-DownloadDestinationPath -App $App) Write-Kv -Key "Exists" -Value ($(if (Test-DownloadFileExists -App $App) { "Yes" } else { "No" })) } else { Write-Warn "Unknown app type." } Wait-User } function Invoke-AppAction { param ( [hashtable]$App, [switch]$SkipConfirm, [switch]$NoPause ) $actionText = Get-PrimaryActionText -App $App $statusText = Get-AppStatusText -App $App if ($SkipConfirm) { if ($App.Type -eq "Winget" -and $statusText -eq "Installed") { Write-Warn "Already installed, skipped: $($App.Name)" return } if ($App.Type -eq "WslBase" -and $statusText -eq "Enabled") { Write-Warn "WSL already enabled, skipped." return } if ($App.Type -eq "WslDistro" -and $statusText -eq "Installed") { Write-Warn "Already installed, skipped: $($App.Name)" return } if ($App.Type -eq "Autostart" -and (Test-AutostartEnabled -App $App)) { Write-Warn "Autostart already enabled, skipped: $($App.Name)" return } if ($App.Type -eq "Download" -and (Test-DownloadFileExists -App $App)) { Write-Warn "Already downloaded, skipped: $($App.Name)" Write-Hint "Path: $(Get-DownloadDestinationPath -App $App)" return } } if (-not $SkipConfirm) { Clear-Host Write-Title "$actionText Confirmation" Write-Kv -Key "Name" -Value $App.Name Write-Kv -Key "Type" -Value (Get-AppTypeText -App $App) Write-Kv -Key "Status" -Value $statusText Write-Kv -Key "Command" -Value (Get-AppCommandText -App $App) Write-Host "" if ($App.Type -eq "Winget" -and $statusText -eq "Installed") { $confirm = Read-Host "Already installed. Continue and run winget upgrade/install? Y/N" } elseif ($App.Type -eq "WslBase" -and $statusText -eq "Enabled") { Write-Success "WSL is already enabled." Wait-User return } elseif ($App.Type -eq "WslDistro" -and $statusText -eq "Installed") { Write-Success "$($App.Name) is already installed." Wait-User return } elseif ($App.Type -eq "Command") { $confirm = Read-Host "Are you sure you want to run $($App.Name)? Y/N" } elseif ($App.Type -eq "Download") { $confirm = Read-Host "Are you sure you want to download $($App.Name)? Y/N" } else { $confirm = Read-Host "Are you sure you want to install $($App.Name)? Y/N" } if ($confirm -notmatch "^[Yy]$") { Write-Host "" Write-Warn "Cancelled." if (-not $NoPause) { Wait-User } return } } $success = $true if ($App.Type -eq "Winget") { if (-not (Test-Winget)) { if (-not $NoPause) { Wait-User } return } $id = [string]$App.Id $isInstalled = Test-WingetInstalled -Id $id Write-Host "" if ($isInstalled) { Write-Info "Upgrading: $($App.Name)" Write-Hint "Winget ID: $id" Write-Host "" winget upgrade --id $id -e --accept-source-agreements --accept-package-agreements if ($LASTEXITCODE -ne 0) { Write-Warn "Upgrade command did not complete successfully. Trying install command..." winget install --id $id -e --accept-source-agreements --accept-package-agreements } } else { Write-Info "Installing: $($App.Name)" Write-Hint "Winget ID: $id" Write-Host "" winget install --id $id -e --accept-source-agreements --accept-package-agreements } if ($LASTEXITCODE -ne 0) { $success = $false } } elseif ($App.Type -eq "WslBase") { Install-WslBase -NoPause:$NoPause | Out-Null return } elseif ($App.Type -eq "WslDistro") { if (-not (Ensure-WslReady -NoPause:$NoPause)) { return } if (-not (Test-IsAdmin)) { Restart-ScriptAsAdmin return } $exe = [string]$App.Exe $cmdArgs = @($App.Args) Write-Host "" Write-Info "Installing: $($App.Name)" Write-Hint "Command: $(Get-AppCommandText -App $App)" Write-Host "" & $exe @cmdArgs if ($LASTEXITCODE -ne 0) { $success = $false } } elseif ($App.Type -eq "Autostart") { Invoke-AutostartAction -App $App -SkipConfirm:$SkipConfirm -NoPause:$NoPause return } elseif ($App.Type -eq "Download") { $success = Invoke-DownloadPackage -App $App -SkipConfirm:$SkipConfirm -NoPause:$NoPause } elseif ($App.Type -eq "Command") { $exe = [string]$App.Exe $cmdArgs = @($App.Args) if (-not (Test-CommandExists -CommandName $exe)) { if (-not $NoPause) { Wait-User } return } Write-Host "" Write-Info "Running: $($App.Name)" Write-Hint "Command: $(Get-AppCommandText -App $App)" Write-Host "" & $exe @cmdArgs if ($LASTEXITCODE -ne 0) { $success = $false } } else { Write-Warn "Unknown app type." $success = $false } Write-Host "" if ($success) { Write-Success "Finished: $($App.Name)" } else { Write-Warn "Command finished with errors: $($App.Name)" Write-Hint "Exit code: $LASTEXITCODE" } if (-not $NoPause) { Wait-User } } function Show-SingleAppActionMenu { param ( [hashtable]$App ) if ($App.Type -eq "Autostart") { Show-AutostartActionMenu -App $App return } while ($true) { Clear-Host Write-Title "Selected Software" Write-Kv -Key "Name" -Value $App.Name Write-Kv -Key "Type" -Value (Get-AppTypeText -App $App) Write-Kv -Key "Status" -Value (Get-AppStatusText -App $App) Write-Kv -Key "Command" -Value (Get-AppCommandText -App $App) Write-Host "" Write-Option -Key "1" -Text (Get-PrimaryActionText -App $App) Write-Option -Key "2" -Text "Show info" if (Test-AppSupportsUninstall -App $App) { Write-Option -Key "3" -Text "Uninstall" } Write-Option -Key "B" -Text "Back" Write-Option -Key "Q" -Text "Quit" Write-Host "" $choice = Read-Host "Choose" switch -Regex ($choice) { "^1$" { Invoke-AppAction -App $App } "^2$" { Show-AppInfo -App $App } "^3$" { if (Test-AppSupportsUninstall -App $App) { Invoke-AppUninstall -App $App } else { Write-Warn "Uninstall is not supported for this item." Wait-User } } "^[Bb]$" { return } "^[Qq]$" { exit } default { Write-Warn "Invalid input." Wait-User } } } } function Show-MultiAppActionMenu { param ( [array]$SelectedApps ) while ($true) { Clear-Host Write-Title "Selected Software List" for ($i = 0; $i -lt $SelectedApps.Count; $i++) { $app = $SelectedApps[$i] $typeText = Get-AppTypeText -App $app $statusText = Get-AppStatusText -App $app Write-MenuItem -Number ($i + 1) -Name $app.Name -Extra "[$typeText] [$statusText]" } Write-Host "" Write-Option -Key "1" -Text "Install selected, skip installed" Write-Option -Key "2" -Text "Show info one by one" Write-Option -Key "3" -Text "Uninstall selected, skip unsupported/not installed" Write-Option -Key "B" -Text "Back" Write-Option -Key "Q" -Text "Quit" Write-Host "" Write-Hint "Installed software / WSL distros / enabled autostart items / downloaded files will be skipped." Write-Hint "Maintenance commands will still run." Write-Hint "Batch uninstall skips WSL distros. Unregister WSL distros from the single-item menu." Write-Host "" $choice = Read-Host "Choose" switch -Regex ($choice) { "^1$" { Write-Host "" $confirm = Read-Host "Install selected items and skip installed ones? Y/N" if ($confirm -notmatch "^[Yy]$") { Write-Warn "Cancelled." Wait-User return } foreach ($app in $SelectedApps) { Invoke-AppAction -App $app -SkipConfirm -NoPause } Wait-User } "^2$" { foreach ($app in $SelectedApps) { Show-AppInfo -App $app } } "^3$" { Write-Host "" Write-Warn "This will uninstall selected winget packages, remove selected autostart items, and delete selected downloaded files." Write-Hint "WSL distros are skipped in batch uninstall to avoid accidental data loss." $confirm = Read-Host "Uninstall selected items? Y/N" if ($confirm -notmatch "^[Yy]$") { Write-Warn "Cancelled." Wait-User return } foreach ($app in $SelectedApps) { Invoke-AppUninstall -App $app -SkipConfirm -NoPause } Wait-User } "^[Bb]$" { return } "^[Qq]$" { exit } default { Write-Warn "Invalid input." Wait-User } } } } function Show-GroupMenu { param ( [string]$GroupName, [array]$Apps ) while ($true) { Clear-Host Write-Title "Category: $GroupName" for ($i = 0; $i -lt $Apps.Count; $i++) { $app = $Apps[$i] Write-MenuItem -Number ($i + 1) -Name $app.Name -Extra "[$(Get-AppTypeText -App $app)]" } Write-Host "" Write-Option -Key "B" -Text "Back" Write-Option -Key "Q" -Text "Quit" Write-Host "" Write-Hint "Input examples:" Write-Hint " 1 single software" Write-Hint " 1-3 range" Write-Hint " 2,3,6 multiple software" Write-Hint " 1-5,8,10 mixed range and numbers" Write-Host "" $choice = Read-Host "Choose software" switch -Regex ($choice) { "^[Qq]$" { exit } "^[Bb]$" { return } default { $numbers = Convert-SelectionToNumbers -InputText $choice -MaxNumber $Apps.Count if ($null -eq $numbers) { Write-Warn "Invalid input." Wait-User continue } $selectedApps = @() foreach ($number in $numbers) { $selectedApps += $Apps[$number - 1] } if ($selectedApps.Count -eq 1) { Show-SingleAppActionMenu -App $selectedApps[0] } else { Show-MultiAppActionMenu -SelectedApps $selectedApps } } } } } function Show-MainMenu { $groups = @($SoftwareGroups.Keys) while ($true) { Clear-Host Write-Title "Software Install Menu" for ($i = 0; $i -lt $groups.Count; $i++) { Write-MenuItem -Number ($i + 1) -Name $groups[$i] } Write-Host "" Write-Option -Key "Q" -Text "Quit" Write-Host "" $choice = Read-Host "Choose category" if ($choice -match "^[Qq]$") { exit } if ($choice -match "^\d+$") { $number = [int]$choice if ($number -ge 1 -and $number -le $groups.Count) { $groupName = $groups[$number - 1] Show-GroupMenu -GroupName $groupName -Apps $SoftwareGroups[$groupName] } else { Write-Warn "Invalid category number." Wait-User } } else { Write-Warn "Invalid input." Wait-User } } } Show-MainMenu