commit 0a2b4c64da6d2e17b29954fbe2792e5ab53890e0 Author: gt Date: Sat Jul 4 13:37:02 2026 +0800 first commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5c69296 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,32 @@ +# Default: normalize text files in repo +* text=auto + +# Windows scripts +*.ps1 text eol=crlf +*.psm1 text eol=crlf +*.psd1 text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf + +# Unix/Linux scripts and configs +*.sh text eol=lf +*.bash text eol=lf +*.zsh text eol=lf +*.nix text eol=lf + +# Common text files +*.md text eol=lf +*.json text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf + +# Binary files +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.exe binary +*.zip binary +*.7z binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a47765d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# Local files +*.log +*.tmp + +# OS files +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..81ed829 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# win-setup-menu + +A PowerShell menu for Windows setup and software installation. \ No newline at end of file diff --git a/setup-menu.ps1 b/setup-menu.ps1 new file mode 100644 index 0000000..8cafcdb --- /dev/null +++ b/setup-menu.ps1 @@ -0,0 +1,2258 @@ +# 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" } + ) + + "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