diff --git a/.github/workflows/GA_Close-Inactive-Issues.yml b/.github/workflows/GA_Close-Inactive-Issues.yml index f5dcc776..c14bc042 100644 --- a/.github/workflows/GA_Close-Inactive-Issues.yml +++ b/.github/workflows/GA_Close-Inactive-Issues.yml @@ -13,7 +13,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: days-before-issue-stale: 30 days-before-issue-close: 14 diff --git a/.github/workflows/GA_Mega-linter.yml b/.github/workflows/GA_Mega-linter.yml index 4b31eb42..2176a156 100644 --- a/.github/workflows/GA_Mega-linter.yml +++ b/.github/workflows/GA_Mega-linter.yml @@ -33,7 +33,7 @@ jobs: steps: # Git Checkout - name: Checkout Code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 @@ -43,7 +43,7 @@ jobs: id: ml # You can override MegaLinter flavor used to have faster performances # More info at https://megalinter.github.io/flavors/ - uses: oxsecurity/megalinter@e08c2b05e3dbc40af4c23f41172ef1e068a7d651 # v8.8.0 + uses: oxsecurity/megalinter@55a59b24a441e0e1943080d4a512d827710d4a9d # v9.2.0 env: # All available variables are described in documentation # https://megalinter.github.io/configuration/ @@ -59,7 +59,7 @@ jobs: # Upload MegaLinter artifacts - name: Archive production artifacts if: ${{ success() }} || ${{ failure() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: MegaLinter reports path: | @@ -71,7 +71,7 @@ jobs: - name: Create Pull Request with applied fixes id: cpr if: steps.ml.outputs.has_updated_sources == 1 && (env.APPLY_FIXES_EVENT == 'all' || env.APPLY_FIXES_EVENT == github.event_name) && env.APPLY_FIXES_MODE == 'pull_request' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && !contains(github.event.head_commit.message, 'skip fix') - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} commit-message: "[MegaLinter] Apply linters automatic fixes" @@ -90,7 +90,7 @@ jobs: run: sudo chown -Rc $UID .git/ - name: Commit and push applied linter fixes if: steps.ml.outputs.has_updated_sources == 1 && (env.APPLY_FIXES_EVENT == 'all' || env.APPLY_FIXES_EVENT == github.event_name) && env.APPLY_FIXES_MODE == 'commit' && github.ref != 'refs/heads/main' && (github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository) && !contains(github.event.head_commit.message, 'skip fix') - uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 # v6.0.1 + uses: stefanzweifel/git-auto-commit-action@28e16e81777b558cc906c8750092100bbb34c5e3 # v7.0.0 with: branch: ${{ github.event.pull_request.head.ref || github.head_ref || github.ref }} commit_message: "[MegaLinter] Apply linters fixes" diff --git a/.github/workflows/GitFlow_Check-pull-request-source-branch.yml b/.github/workflows/GitFlow_Check-pull-request-source-branch.yml index 495b3d50..7ae6fc3c 100644 --- a/.github/workflows/GitFlow_Check-pull-request-source-branch.yml +++ b/.github/workflows/GitFlow_Check-pull-request-source-branch.yml @@ -59,7 +59,7 @@ jobs: # Step 2: If the branch pattern is invalid, add a label and comment to the PR - name: Handle invalid branch (label + comment) if: failure() - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -122,7 +122,7 @@ jobs: # Step 3: If the branch pattern is corrected, remove label and comment - name: Clean up if branch is corrected if: success() - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/GitFlow_Create-Release-Branch-and-PR.yaml b/.github/workflows/GitFlow_Create-Release-Branch-and-PR.yaml index 53010296..b4e4720b 100644 --- a/.github/workflows/GitFlow_Create-Release-Branch-and-PR.yaml +++ b/.github/workflows/GitFlow_Create-Release-Branch-and-PR.yaml @@ -35,7 +35,7 @@ jobs: # Step 2: Checkout the develop branch which contains all approved features - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: ref: develop # Always create releases from develop branch fetch-depth: 0 diff --git a/.github/workflows/GitFlow_Make-Release-and-Sync-to-Dev.yml b/.github/workflows/GitFlow_Make-Release-and-Sync-to-Dev.yml index b1621aca..41ab9253 100644 --- a/.github/workflows/GitFlow_Make-Release-and-Sync-to-Dev.yml +++ b/.github/workflows/GitFlow_Make-Release-and-Sync-to-Dev.yml @@ -29,7 +29,7 @@ jobs: steps: # Step 1: Checkout the code from the main branch after merge - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: token: ${{ secrets.GH_PAT_SYNC }} lfs: "true" @@ -121,7 +121,7 @@ jobs: # Step 4: Create stable GitHub release with all artifacts - name: Create release - uses: ncipollo/release-action@bcfe5470707e8832e12347755757cec0eb3c22af # v1.18.0 + uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 with: tag: v${{ steps.release_version.outputs.NextSemVer }} prerelease: false # This is a stable release @@ -141,7 +141,7 @@ jobs: needs: build steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: fetch-depth: 0 token: ${{ secrets.GH_PAT_SYNC }} diff --git a/.github/workflows/GitFlow_Nightly-builds.yml b/.github/workflows/GitFlow_Nightly-builds.yml index 4a5d1dea..14923a7e 100644 --- a/.github/workflows/GitFlow_Nightly-builds.yml +++ b/.github/workflows/GitFlow_Nightly-builds.yml @@ -28,7 +28,7 @@ jobs: steps: # Step 1: Checkout the develop branch for nightly builds - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v6 with: lfs: "true" fetch-depth: 0 @@ -180,7 +180,7 @@ jobs: # Step 6: Create GitHub release with all artifacts - name: Create release - uses: ncipollo/release-action@bcfe5470707e8832e12347755757cec0eb3c22af # v1.18.0 + uses: ncipollo/release-action@b7eabc95ff50cbeeedec83973935c8f306dfcd0b # v1.20.0 if: steps.check_prs.outputs.BUILD_NEEDED == 'true' with: tag: v${{ steps.format_version.outputs.NextSemVer }} diff --git a/README.md b/README.md index 36132841..7e200754 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ You can update only pre-selected apps. To do so, create an "included_apps.txt" w > The lists can contain Wildcard (*). For instance ```Mozilla.Firefox*``` will take care of all Firefox channels. -List and Mods folder content will be copied to WAU install location: -![explorer](https://github.com/user-attachments/assets/a37837b0-b61e-4ce7-b23c-fd8661585e40) +List and Mods folder content will be copied to WAU install location: +![image](https://github.com/user-attachments/assets/a37837b0-b61e-4ce7-b23c-fd8661585e40) ### Notification Level @@ -242,17 +242,63 @@ Share your mods with the community:
### Winget native parameters -Another finess is the **AppID** followed by the `-override` suffix as a **text file** (.**txt**) that you can place under the **mods** folder. +You can customize winget behavior per-app using **text files** (.**txt**) placed in the **mods** folder: + +#### Override (Full installer control) +Use **AppID**`-override.txt` to replace ALL installer arguments (without `-h` silent mode). > Example:
**Adobe.Acrobat.Reader.64-bit-override.txt** with the content `"-sfx_nu /sAll /rs /msi EULA_ACCEPT=YES DISABLEDESKTOPSHORTCUT=1"` -This will use the **content** of the text file as a native **winget --override** parameter when upgrading. +This uses the **content** as a native **winget --override** parameter when upgrading. -Likewise you can use the **AppID** followed by the `-custom` suffix as a **text file** (.**txt**) that you can place under the **mods** folder (*Arguments to be passed on to the installer in addition to the defaults*). +#### Custom (Add installer arguments) +Use **AppID**`-custom.txt` to add extra arguments to the installer (with `-h` silent mode). > Example:
**Adobe.Acrobat.Reader.64-bit-custom.txt** with the content `"DISABLEDESKTOPSHORTCUT=1"` -This will use the **content** of the text file as a native **winget --custom** parameter when upgrading. +This uses the **content** as a native **winget --custom** parameter when upgrading. + +#### Arguments (Winget-level parameters) ⭐ NEW +Use **AppID**`-arguments.txt` to pass **winget parameters** (not installer arguments, with `-h` silent mode). + +💡 **Locale Tip:** Many applications revert to English or system default language during WAU upgrades because winget doesn't remember the original installation locale. To prevent this: +- **Best solution:** Use locale-specific package IDs in `included_apps.txt` (e.g., `Mozilla.Firefox.sv-SE` instead of `Mozilla.Firefox`) +- **Alternative:** Create `{AppID}-arguments.txt` with `--locale` parameter to force language on every upgrade + +> Example for language control ([#1073](https://github.com/Romanitho/Winget-AutoUpdate/issues/1073)):
+**Mozilla.Firefox-arguments.txt** with the content `--locale pl`
+*This prevents Firefox from reverting to English after WAU upgrades.*
+*Better solution: Use `Mozilla.Firefox.pl` in included_apps.txt instead of `Mozilla.Firefox`.* + +> Example for dependency issues ([#1075](https://github.com/Romanitho/Winget-AutoUpdate/issues/1075)):
+**Cloudflare.Warp-arguments.txt** with the content `--skip-dependencies` + +> Example with multiple parameters:
+**Microsoft.VisualStudio.2022.Community-arguments.txt** with the content `--locale en-US --architecture x64` + +**Common use cases:** +- `--locale ` - Force application language (e.g., `pl-PL`, `en-US`, `de-DE`) + - 💡 **Recommended alternative:** Use locale-specific package IDs when available (e.g., `Mozilla.Firefox.sv-SE`, `Mozilla.Firefox.de`, `Mozilla.Firefox.ESR.pl`) to get latest versions +- `--skip-dependencies` - Skip dependency installations when they conflict +- `--architecture ` - Force architecture (`x86`, `x64`, `arm64`) +- `--version ` - Pin to specific version +- `--ignore-security-hash` - Bypass hash verification +- `--ignore-local-archive-malware-scan` - Skip AV scanning + +⚠️ **Important:** When combining `--locale` and `--version`, the specific version must have an installer available for that locale. Not all versions support all locales. Check available versions with `winget show --id --versions`. + +💡 **Locale Best Practice:** Search for locale-specific packages with `winget search ` to see if your language has a dedicated package ID (e.g., `Mozilla.Firefox.sv-SE` for Swedish Firefox). These packages are maintained with the latest versions in your preferred language. + +**Command-line usage:** You can also pass arguments when calling `Winget-Install.ps1`: +```powershell +.\winget-install.ps1 -AppIDs "Mozilla.Firefox --locale sv-SE" +.\winget-install.ps1 -AppIDs "7zip.7zip, Notepad++.Notepad++" +.\winget-install.ps1 -AppIDs "Adobe.Acrobat.Reader.64-bit --scope machine --override \"-sfx_nu /sAll /msi EULA_ACCEPT=YES\"" +``` + +**Priority:** Override > Custom > Arguments (file) > Arguments (command-line) > Default + +See [_AppID-arguments-template.txt](Sources/Winget-AutoUpdate/mods/_AppID-arguments-template.txt) for more examples. ## Known issues diff --git a/Sources/Winget-AutoUpdate/User-Run.ps1 b/Sources/Winget-AutoUpdate/User-Run.ps1 index 094e1325..6531ce87 100644 --- a/Sources/Winget-AutoUpdate/User-Run.ps1 +++ b/Sources/Winget-AutoUpdate/User-Run.ps1 @@ -1,73 +1,84 @@ <# .SYNOPSIS -Handle user interaction from shortcuts and show a Toast notification + Handles user-initiated WAU update checks via shortcut. .DESCRIPTION -Act on shortcut run + Provides a user-facing interface to manually trigger WAU update checks. + Displays toast notifications for status updates (starting, running, + completed, or error). Waits for the scheduled task to complete and + shows the result. .EXAMPLE -.\user-run.ps1 + .\User-Run.ps1 +.NOTES + Triggered by desktop shortcut or Start menu entry. + Uses the Winget-AutoUpdate scheduled task. + Shows error details from logs\error.txt if update check fails. #> +# Check if WAU is currently running function Test-WAUisRunning { - If (((Get-ScheduledTask -TaskName 'Winget-AutoUpdate').State -eq 'Running') -or ((Get-ScheduledTask -TaskName 'Winget-AutoUpdate-UserContext').State -eq 'Running')) { - Return $True - } + If (((Get-ScheduledTask -TaskName 'Winget-AutoUpdate').State -eq 'Running') -or ((Get-ScheduledTask -TaskName 'Winget-AutoUpdate-UserContext').State -eq 'Running')) { + Return $True + } } <# MAIN #> -#Get Working Dir +# Set working directory $Script:WorkingDir = $PSScriptRoot -#Load external functions +# Load required functions . $WorkingDir\functions\Get-NotifLocale.ps1 . $WorkingDir\functions\Start-NotifTask.ps1 -#Get Toast Locale function +# Load notification locale Get-NotifLocale -#Set common variables +# Set common notification parameters $OnClickAction = "$WorkingDir\logs\updates.log" $Button1Text = $NotifLocale.local.outputs.output[11].message try { - #Check if WAU is currently running - if (Test-WAUisRunning) { - $Message = $NotifLocale.local.outputs.output[8].message - $MessageType = "warning" - Start-NotifTask -Message $Message -MessageType $MessageType -Button1Text $Button1Text -Button1Action $OnClickAction -ButtonDismiss -UserRun - break - } - #Run scheduled task - Get-ScheduledTask -TaskName "Winget-AutoUpdate" -ErrorAction Stop | Start-ScheduledTask -ErrorAction Stop - #Starting check - Send notification - $Message = $NotifLocale.local.outputs.output[6].message - $MessageType = "info" - Start-NotifTask -Message $Message -MessageType $MessageType -Button1Text $Button1Text -Button1Action $OnClickAction -ButtonDismiss -UserRun - #Sleep until the task is done - While (Test-WAUisRunning) { - Start-Sleep 3 - } + # Check if WAU is already running + if (Test-WAUisRunning) { + $Message = $NotifLocale.local.outputs.output[8].message + $MessageType = "warning" + Start-NotifTask -Message $Message -MessageType $MessageType -Button1Text $Button1Text -Button1Action $OnClickAction -ButtonDismiss -UserRun + break + } - #Test if there was a list_/winget_error - if (Test-Path "$WorkingDir\logs\error.txt") { - $MessageType = "error" - $Critical = Get-Content "$WorkingDir\logs\error.txt" -Raw - $Critical = $Critical.Trim() - $Critical = $Critical.Substring(0, [Math]::Min($Critical.Length, 50)) - $Message = "Critical:`n$Critical..." - } - else { - $MessageType = "success" - $Message = $NotifLocale.local.outputs.output[9].message - } - Start-NotifTask -Message $Message -MessageType $MessageType -Button1Text $Button1Text -Button1Action $OnClickAction -ButtonDismiss -UserRun + # Start the WAU scheduled task + Get-ScheduledTask -TaskName "Winget-AutoUpdate" -ErrorAction Stop | Start-ScheduledTask -ErrorAction Stop + + # Send "starting" notification + $Message = $NotifLocale.local.outputs.output[6].message + $MessageType = "info" + Start-NotifTask -Message $Message -MessageType $MessageType -Button1Text $Button1Text -Button1Action $OnClickAction -ButtonDismiss -UserRun + + # Wait for task completion + While (Test-WAUisRunning) { + Start-Sleep 3 + } + + # Check for errors in the update process + if (Test-Path "$WorkingDir\logs\error.txt") { + $MessageType = "error" + $Critical = Get-Content "$WorkingDir\logs\error.txt" -Raw + $Critical = $Critical.Trim() + $Critical = $Critical.Substring(0, [Math]::Min($Critical.Length, 50)) + $Message = "Critical:`n$Critical..." + } + else { + $MessageType = "success" + $Message = $NotifLocale.local.outputs.output[9].message + } + Start-NotifTask -Message $Message -MessageType $MessageType -Button1Text $Button1Text -Button1Action $OnClickAction -ButtonDismiss -UserRun } catch { - #Check failed - Just send notification - $Message = $NotifLocale.local.outputs.output[7].message - $MessageType = "error" - Start-NotifTask -Message $Message -MessageType $MessageType -Button1Text $Button1Text -Button1Action $OnClickAction -ButtonDismiss -UserRun + # Handle task start failure + $Message = $NotifLocale.local.outputs.output[7].message + $MessageType = "error" + Start-NotifTask -Message $Message -MessageType $MessageType -Button1Text $Button1Text -Button1Action $OnClickAction -ButtonDismiss -UserRun } diff --git a/Sources/Winget-AutoUpdate/WAU-Notify.ps1 b/Sources/Winget-AutoUpdate/WAU-Notify.ps1 index f34a4348..754b2a9f 100644 --- a/Sources/Winget-AutoUpdate/WAU-Notify.ps1 +++ b/Sources/Winget-AutoUpdate/WAU-Notify.ps1 @@ -1,24 +1,38 @@ -#Send Notify Script +<# +.SYNOPSIS + Displays a toast notification to the logged-in user. -#get xml notif config +.DESCRIPTION + Reads notification configuration from an XML file and displays + a Windows toast notification using the ToastNotificationManager API. + This script is called by a scheduled task when WAU runs in system context. + +.NOTES + Configuration file: config\notif.xml + Launcher ID: Windows.SystemToast.WAU.Notification +#> + +# Get WAU installation path from registry $WAUinstalledPath = Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Romanitho\Winget-AutoUpdate\" -Name InstallLocation + +# Load notification XML configuration [xml]$NotifConf = Get-Content "$WAUinstalledPath\config\notif.xml" -Encoding UTF8 -ErrorAction SilentlyContinue if (!($NotifConf)) { break } -#Load Assemblies +# Load Windows notification assemblies [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null -#Prepare XML +# Parse notification XML $ToastXml = [Windows.Data.Xml.Dom.XmlDocument]::New() $ToastXml.LoadXml($NotifConf.OuterXml) -#Specify Launcher App ID +# Specify toast launcher ID $LauncherID = "Windows.SystemToast.WAU.Notification" -#Prepare and Create Toast +# Create and display the notification $ToastMessage = [Windows.UI.Notifications.ToastNotification]::New($ToastXML) $ToastMessage.Tag = $NotifConf.toast.tag [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($LauncherID).Show($ToastMessage) diff --git a/Sources/Winget-AutoUpdate/WAU-Policies.ps1 b/Sources/Winget-AutoUpdate/WAU-Policies.ps1 index af5e17d3..411424d5 100644 --- a/Sources/Winget-AutoUpdate/WAU-Policies.ps1 +++ b/Sources/Winget-AutoUpdate/WAU-Policies.ps1 @@ -1,23 +1,31 @@ <# .SYNOPSIS -Handle GPO/Polices + Applies Group Policy settings to WAU scheduled tasks. .DESCRIPTION -Daily update settings from policies + Reads WAU configuration from GPO registry keys and updates the + Winget-AutoUpdate scheduled task triggers accordingly. + Handles daily, bi-daily, weekly, bi-weekly, and monthly schedules, + as well as logon triggers and time delays. + +.NOTES + This script is executed by the WAU-Policies scheduled task. + GPO registry path: HKLM:\SOFTWARE\Policies\Romanitho\Winget-AutoUpdate + Logs applied settings to: logs\LatestAppliedSettings.txt #> -#Import functions +# Import configuration function . "$PSScriptRoot\functions\Get-WAUConfig.ps1" -#Check if GPO Management is detected +# Check if GPO management is enabled $GPOManagementDetected = Get-ItemProperty "HKLM:\SOFTWARE\Policies\Romanitho\Winget-AutoUpdate" -ErrorAction SilentlyContinue if ($GPOManagementDetected) { - #Get WAU settings + # Load WAU configuration (with GPO overrides) $WAUConfig = Get-WAUConfig - #Log init + # Initialize logging $GPOLogDirectory = Join-Path -Path $WAUConfig.InstallLocation -ChildPath "logs" if (!(Test-Path -Path $GPOLogDirectory)) { New-Item -ItemType Directory -Path $GPOLogDirectory -Force | Out-Null @@ -25,21 +33,19 @@ if ($GPOManagementDetected) { $GPOLogFile = Join-Path -Path $GPOLogDirectory -ChildPath "LatestAppliedSettings.txt" Set-Content -Path $GPOLogFile -Value "### POLICY CYCLE - $(Get-Date) ###`n" - #Get Winget-AutoUpdate scheduled task + # Get current scheduled task configuration $WAUTask = Get-ScheduledTask -TaskName 'Winget-AutoUpdate' -ErrorAction SilentlyContinue - - #Update 'Winget-AutoUpdate' scheduled task settings $currentTriggers = $WAUTask.Triggers $configChanged = $false - #Check if LogOn trigger setting has changed + # === Check if LogOn trigger setting has changed === $hasLogonTrigger = $currentTriggers | Where-Object { $_.CimClass.CimClassName -eq "MSFT_TaskLogonTrigger" } if (($WAUConfig.WAU_UpdatesAtLogon -eq 1 -and -not $hasLogonTrigger) -or ($WAUConfig.WAU_UpdatesAtLogon -ne 1 -and $hasLogonTrigger)) { $configChanged = $true } - #Check if schedule type has changed + # === Detect current schedule type === $currentIntervalType = "None" foreach ($trigger in $currentTriggers) { if ($trigger.CimClass.CimClassName -eq "MSFT_TaskDailyTrigger" -and $trigger.DaysInterval -eq 1) { @@ -72,7 +78,7 @@ if ($GPOManagementDetected) { $configChanged = $true } - #Check if delay has changed + # === Check if delay has changed === $randomDelay = [TimeSpan]::ParseExact($WAUConfig.WAU_UpdatesTimeDelay, "hh\:mm", $null) $timeTrigger = $currentTriggers | Where-Object { $_.CimClass.CimClassName -ne "MSFT_TaskLogonTrigger" } | Select-Object -First 1 if ($timeTrigger.RandomDelay -match '^PT(?:(\d+)H)?(?:(\d+)M)?$') { @@ -84,7 +90,7 @@ if ($GPOManagementDetected) { $configChanged = $true } - #Check if schedule time has changed + # === Check if schedule time has changed === if ($currentIntervalType -ne "None" -and $currentIntervalType -ne "Never") { if ($timeTrigger) { $currentTime = [DateTime]::Parse($timeTrigger.StartBoundary).ToString("HH:mm:ss") @@ -94,12 +100,16 @@ if ($GPOManagementDetected) { } } - #Only update triggers if configuration has changed + # === Update triggers if configuration changed === if ($configChanged) { $taskTriggers = @() + + # Add logon trigger if enabled if ($WAUConfig.WAU_UpdatesAtLogon -eq 1) { $tasktriggers += New-ScheduledTaskTrigger -AtLogOn } + + # Add time-based trigger based on interval type if ($WAUConfig.WAU_UpdatesInterval -eq "Daily") { $tasktriggers += New-ScheduledTaskTrigger -Daily -At $WAUConfig.WAU_UpdatesAtTime -RandomDelay $randomDelay } @@ -116,20 +126,18 @@ if ($GPOManagementDetected) { $tasktriggers += New-ScheduledTaskTrigger -Weekly -At $WAUConfig.WAU_UpdatesAtTime -DaysOfWeek 2 -WeeksInterval 4 -RandomDelay $randomDelay } - #If trigger(s) set + # Apply new triggers or disable task if ($taskTriggers) { - #Edit scheduled task Set-ScheduledTask -TaskPath $WAUTask.TaskPath -TaskName $WAUTask.TaskName -Trigger $taskTriggers | Out-Null } - #If not, remove trigger(s) else { - #Remove by setting past due date + # Disable by setting a past due date $tasktriggers = New-ScheduledTaskTrigger -Once -At "01/01/1970" Set-ScheduledTask -TaskPath $WAUTask.TaskPath -TaskName $WAUTask.TaskName -Trigger $tasktriggers | Out-Null } } - #Log latest applied config + # Log applied configuration Add-Content -Path $GPOLogFile -Value "`nLatest applied settings:" $WAUConfig.PSObject.Properties | Where-Object { $_.Name -like "WAU_*" } | Select-Object Name, Value | Out-File -Encoding default -FilePath $GPOLogFile -Append diff --git a/Sources/Winget-AutoUpdate/Winget-Install.ps1 b/Sources/Winget-AutoUpdate/Winget-Install.ps1 index 02bdb672..f769aad0 100644 --- a/Sources/Winget-AutoUpdate/Winget-Install.ps1 +++ b/Sources/Winget-AutoUpdate/Winget-Install.ps1 @@ -71,6 +71,7 @@ else { . "$realPath\functions\Write-ToLog.ps1" . "$realPath\functions\Confirm-Installation.ps1" . "$realPath\functions\Compare-SemVer.ps1" +. "$realPath\functions\ConvertTo-WingetArgumentArray.ps1" #Check if App exists in Winget Repository function Confirm-Exist ($AppID) { @@ -100,6 +101,15 @@ function Test-ModsInstall ($AppID) { if (Test-Path "$Mods\$AppID-custom.txt") { $ModsCustom = (Get-Content "$Mods\$AppID-custom.txt" -Raw).Trim() } + if (Test-Path "$Mods\$AppID-arguments.txt") { + # Read file and filter out comments and empty lines + $lines = Get-Content "$Mods\$AppID-arguments.txt" | Where-Object { + $_.Trim() -ne "" -and -not $_.TrimStart().StartsWith("#") + } + if ($lines) { + $ModsArguments = ($lines -join " ").Trim() + } + } if (Test-Path "$Mods\$AppID-install.ps1") { $ModsInstall = "$Mods\$AppID-install.ps1" } @@ -108,7 +118,7 @@ function Test-ModsInstall ($AppID) { } } - return $ModsPreInstall, $ModsOverride, $ModsCustom, $ModsInstall, $ModsInstalled + return $ModsPreInstall, $ModsOverride, $ModsCustom, $ModsArguments, $ModsInstall, $ModsInstalled } #Check if uninstall modifications exist in "mods" directory @@ -132,8 +142,8 @@ function Test-ModsUninstall ($AppID) { function Install-App ($AppID, $AppArgs) { $IsInstalled = Confirm-Installation $AppID if (!($IsInstalled) -or $AllowUpgrade ) { - #Check if mods exist (or already exist) for preinstall/override/custom/install/installed - $ModsPreInstall, $ModsOverride, $ModsCustom, $ModsInstall, $ModsInstalled = Test-ModsInstall $($AppID) + #Check if mods exist (or already exist) for preinstall/override/custom/arguments/install/installed + $ModsPreInstall, $ModsOverride, $ModsCustom, $ModsArguments, $ModsInstall, $ModsInstalled = Test-ModsInstall $($AppID) #If PreInstall script exist if ($ModsPreInstall) { @@ -155,8 +165,15 @@ function Install-App ($AppID, $AppArgs) { Write-ToLog "-> Arguments (customizing default): $ModsCustom" # With -h (user customizes default) $WingetArgs = "install --id $AppID -e --accept-package-agreements --accept-source-agreements -s winget -h --custom $ModsCustom" -split " " } + elseif ($ModsArguments -or (-not [string]::IsNullOrWhiteSpace($AppArgs))) { + # Prioritize ModsArguments from file over AppArgs from command line + $finalArgs = if ($ModsArguments) { $ModsArguments } else { $AppArgs } + Write-ToLog "-> Arguments (winget-level): $finalArgs" # Winget parameters with -h + $argArray = ConvertTo-WingetArgumentArray $finalArgs + $WingetArgs = @("install", "--id", $AppID, "-e", "--accept-package-agreements", "--accept-source-agreements", "-s", "winget") + $argArray + @("-h") + } else { - $WingetArgs = "install --id $AppID -e --accept-package-agreements --accept-source-agreements -s winget -h $AppArgs" -split " " + $WingetArgs = "install --id $AppID -e --accept-package-agreements --accept-source-agreements -s winget -h" -split " " } Write-ToLog "-> Running: `"$Winget`" $WingetArgs" @@ -210,7 +227,14 @@ function Uninstall-App ($AppID, $AppArgs) { #Uninstall App Write-ToLog "-> Uninstalling $AppID..." "DarkYellow" - $WingetArgs = "uninstall --id $AppID -e --accept-source-agreements -h $AppArgs" -split " " + if (-not [string]::IsNullOrWhiteSpace($AppArgs)) { + Write-ToLog "-> Arguments (winget-level): $AppArgs" + $argArray = ConvertTo-WingetArgumentArray $AppArgs + $WingetArgs = @("uninstall", "--id", $AppID, "-e", "--accept-source-agreements") + $argArray + @("-h") + } + else { + $WingetArgs = "uninstall --id $AppID -e --accept-source-agreements -h" -split " " + } Write-ToLog "-> Running: `"$Winget`" $WingetArgs" & "$Winget" $WingetArgs | Where-Object { $_ -notlike " *" } | Tee-Object -file $LogFile -Append diff --git a/Sources/Winget-AutoUpdate/config/WAU-MSI_Actions.ps1 b/Sources/Winget-AutoUpdate/config/WAU-MSI_Actions.ps1 index affaccdc..2f68e710 100644 --- a/Sources/Winget-AutoUpdate/config/WAU-MSI_Actions.ps1 +++ b/Sources/Winget-AutoUpdate/config/WAU-MSI_Actions.ps1 @@ -1,13 +1,36 @@ +<# +.SYNOPSIS + MSI installer post-actions script for WAU. + +.DESCRIPTION + Handles installation and uninstallation tasks for the WAU MSI package. + Creates scheduled tasks and configures permissions. + +.PARAMETER AppListPath + Path to the app list file (excluded_apps.txt or included_apps.txt). + +.PARAMETER InstallPath + WAU installation directory. + +.PARAMETER CurrentDir + Current working directory for the installer. + +.PARAMETER Upgrade + Upgrade product code when performing an upgrade. + +.PARAMETER Uninstall + Switch to trigger uninstallation instead of installation. +#> [CmdletBinding()] param( - [Parameter(Mandatory = $false)] [string] $AppListPath, - [Parameter(Mandatory = $false)] [string] $InstallPath, - [Parameter(Mandatory = $false)] [string] $CurrentDir, - [Parameter(Mandatory = $false)] [string] $Upgrade, - [Parameter(Mandatory = $False)] [Switch] $Uninstall = $false + [string]$AppListPath, + [string]$InstallPath, + [string]$CurrentDir, + [string]$Upgrade, + [switch]$Uninstall ) -#For troubleshooting +# Debug output Write-Output "AppListPath: $AppListPath" Write-Output "InstallPath: $InstallPath" Write-Output "CurrentDir: $CurrentDir" @@ -32,179 +55,164 @@ function Add-ACLRule { } function Install-WingetAutoUpdate { - Write-Host "### Post install actions ###" try { - - # Clean potential old v1 install + # Clean old v1 installation if present $OldConfRegPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Winget-AutoUpdate" $OldWAUConfig = Get-ItemProperty $OldConfRegPath -ErrorAction SilentlyContinue if ($OldWAUConfig.InstallLocation) { - Write-Host "-> Cleanning old v1 WAU version ($($OldWAUConfig.DisplayVersion))" + Write-Host "-> Cleaning old v1 WAU version ($($OldWAUConfig.DisplayVersion))" Start-Process powershell.exe -ArgumentList "-ExecutionPolicy Bypass -WindowStyle Hidden -File ""$($OldWAUConfig.InstallLocation)\WAU-Uninstall.ps1""" -Wait } - #Get WAU config + # Get WAU config from registry $WAUconfig = Get-ItemProperty "HKLM:\SOFTWARE\Romanitho\Winget-AutoUpdate" Write-Output "-> WAU Config:" Write-Output $WAUconfig - # Settings for the scheduled task for Updates (System) + # Create scheduled tasks Write-Host "-> Installing WAU scheduled tasks" - $taskAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$($InstallPath)winget-upgrade.ps1`"" + + # Main update task (System context) + $taskAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"${InstallPath}winget-upgrade.ps1`"" $taskTriggers = @() + if ($WAUconfig.WAU_UpdatesAtLogon -eq 1) { - $tasktriggers += New-ScheduledTaskTrigger -AtLogOn - } - if ($WAUconfig.WAU_UpdatesInterval -eq "Daily") { - $tasktriggers += New-ScheduledTaskTrigger -Daily -At $WAUconfig.WAU_UpdatesAtTime -RandomDelay $WAUconfig.WAU_UpdatesTimeDelay - } - elseif ($WAUconfig.WAU_UpdatesInterval -eq "BiDaily") { - $tasktriggers += New-ScheduledTaskTrigger -Daily -At $WAUconfig.WAU_UpdatesAtTime -DaysInterval 2 -RandomDelay $WAUconfig.WAU_UpdatesTimeDelay - } - elseif ($WAUconfig.WAU_UpdatesInterval -eq "Weekly") { - $tasktriggers += New-ScheduledTaskTrigger -Weekly -At $WAUconfig.WAU_UpdatesAtTime -DaysOfWeek 2 -RandomDelay $WAUconfig.WAU_UpdatesTimeDelay + $taskTriggers += New-ScheduledTaskTrigger -AtLogOn } - elseif ($WAUconfig.WAU_UpdatesInterval -eq "BiWeekly") { - $tasktriggers += New-ScheduledTaskTrigger -Weekly -At $WAUconfig.WAU_UpdatesAtTime -DaysOfWeek 2 -WeeksInterval 2 -RandomDelay $WAUconfig.WAU_UpdatesTimeDelay - } - elseif ($WAUconfig.WAU_UpdatesInterval -eq "Monthly") { - $tasktriggers += New-ScheduledTaskTrigger -Weekly -At $WAUconfig.WAU_UpdatesAtTime -DaysOfWeek 2 -WeeksInterval 4 -RandomDelay $WAUconfig.WAU_UpdatesTimeDelay + + # Interval-based trigger + $time = $WAUconfig.WAU_UpdatesAtTime + $delay = $WAUconfig.WAU_UpdatesTimeDelay + switch ($WAUconfig.WAU_UpdatesInterval) { + "Daily" { $taskTriggers += New-ScheduledTaskTrigger -Daily -At $time -RandomDelay $delay } + "BiDaily" { $taskTriggers += New-ScheduledTaskTrigger -Daily -At $time -DaysInterval 2 -RandomDelay $delay } + "Weekly" { $taskTriggers += New-ScheduledTaskTrigger -Weekly -At $time -DaysOfWeek 2 -RandomDelay $delay } + "BiWeekly" { $taskTriggers += New-ScheduledTaskTrigger -Weekly -At $time -DaysOfWeek 2 -WeeksInterval 2 -RandomDelay $delay } + "Monthly" { $taskTriggers += New-ScheduledTaskTrigger -Weekly -At $time -DaysOfWeek 2 -WeeksInterval 4 -RandomDelay $delay } } - $taskUserPrincipal = New-ScheduledTaskPrincipal -UserId S-1-5-18 -RunLevel Highest + + $taskPrincipal = New-ScheduledTaskPrincipal -UserId S-1-5-18 -RunLevel Highest $taskSettings = New-ScheduledTaskSettingsSet -Compatibility Win8 -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit 03:00:00 - # Set up the task, and register it - if ($taskTriggers) { - $task = New-ScheduledTask -Action $taskAction -Principal $taskUserPrincipal -Settings $taskSettings -Trigger $taskTriggers - } - else { - $task = New-ScheduledTask -Action $taskAction -Principal $taskUserPrincipal -Settings $taskSettings + + $taskParams = @{ + Action = $taskAction + Principal = $taskPrincipal + Settings = $taskSettings } + if ($taskTriggers) { $taskParams.Trigger = $taskTriggers } + + $task = New-ScheduledTask @taskParams Register-ScheduledTask -TaskName 'Winget-AutoUpdate' -TaskPath 'WAU' -InputObject $task -Force | Out-Null - # Settings for the scheduled task in User context + # User context task $taskAction = New-ScheduledTaskAction -Execute "conhost.exe" -Argument "--headless powershell.exe -NoProfile -ExecutionPolicy Bypass -File winget-upgrade.ps1" -WorkingDirectory $InstallPath - $taskUserPrincipal = New-ScheduledTaskPrincipal -GroupId S-1-5-11 + $taskPrincipal = New-ScheduledTaskPrincipal -GroupId S-1-5-11 $taskSettings = New-ScheduledTaskSettingsSet -Compatibility Win8 -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit 03:00:00 - # Set up the task for user apps - $task = New-ScheduledTask -Action $taskAction -Principal $taskUserPrincipal -Settings $taskSettings + $task = New-ScheduledTask -Action $taskAction -Principal $taskPrincipal -Settings $taskSettings Register-ScheduledTask -TaskName 'Winget-AutoUpdate-UserContext' -TaskPath 'WAU' -InputObject $task -Force | Out-Null - # Settings for the scheduled task for Notifications + # Notification task $taskAction = New-ScheduledTaskAction -Execute "conhost.exe" -Argument "--headless powershell.exe -NoProfile -ExecutionPolicy Bypass -File WAU-Notify.ps1" -WorkingDirectory $InstallPath - $taskUserPrincipal = New-ScheduledTaskPrincipal -GroupId S-1-5-11 $taskSettings = New-ScheduledTaskSettingsSet -Compatibility Win8 -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit 00:05:00 - # Set up the task, and register it - $task = New-ScheduledTask -Action $taskAction -Principal $taskUserPrincipal -Settings $taskSettings + $task = New-ScheduledTask -Action $taskAction -Principal $taskPrincipal -Settings $taskSettings Register-ScheduledTask -TaskName 'Winget-AutoUpdate-Notify' -TaskPath 'WAU' -InputObject $task -Force | Out-Null - # Settings for the GPO scheduled task - $taskAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"$($InstallPath)WAU-Policies.ps1`"" - $tasktrigger = New-ScheduledTaskTrigger -Daily -At 6am - $taskUserPrincipal = New-ScheduledTaskPrincipal -UserId S-1-5-18 -RunLevel Highest + # GPO policies task + $taskAction = New-ScheduledTaskAction -Execute "powershell.exe" -Argument "-NoProfile -ExecutionPolicy Bypass -File `"${InstallPath}WAU-Policies.ps1`"" + $taskTrigger = New-ScheduledTaskTrigger -Daily -At 6am + $taskPrincipal = New-ScheduledTaskPrincipal -UserId S-1-5-18 -RunLevel Highest $taskSettings = New-ScheduledTaskSettingsSet -Compatibility Win8 -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit 00:05:00 - # Set up the task, and register it - $task = New-ScheduledTask -Action $taskAction -Principal $taskUserPrincipal -Settings $taskSettings -Trigger $taskTrigger + $task = New-ScheduledTask -Action $taskAction -Principal $taskPrincipal -Settings $taskSettings -Trigger $taskTrigger Register-ScheduledTask -TaskName 'Winget-AutoUpdate-Policies' -TaskPath 'WAU' -InputObject $task -Force | Out-Null - #Set task readable/runnable for all users + # Set task permissions for all users $scheduler = New-Object -ComObject "Schedule.Service" $scheduler.Connect() $task = $scheduler.GetFolder("WAU").GetTask("Winget-AutoUpdate") - $sec = $task.GetSecurityDescriptor(0xF) - $sec = $sec + '(A;;GRGX;;;AU)' + $sec = $task.GetSecurityDescriptor(0xF) + '(A;;GRGX;;;AU)' $task.SetSecurityDescriptor($sec, 0) - #Copy App list to install folder (exept on self update) + # Copy app list (except on self-update) if ($AppListPath -and ($AppListPath -notlike "$InstallPath*")) { Write-Output "-> Copying $AppListPath to $InstallPath" Copy-Item -Path $AppListPath -Destination $InstallPath } - #Copy Mods to install folder + # Copy mods folder if present $ModsFolder = Join-Path $CurrentDir "Mods" if (Test-Path $ModsFolder) { Write-Output "-> Copying $ModsFolder to $InstallPath" - Copy-Item -Path $ModsFolder -Destination "$InstallPath" -Recurse + Copy-Item -Path $ModsFolder -Destination $InstallPath -Recurse } - #Secure folders if not installed to ProgramFiles + # Secure folders if not in Program Files if ($InstallPath -notlike "$env:ProgramFiles*") { - Write-Output "-> Securing functions and mods folders" - $directories = @("$InstallPath\functions", "$InstallPath\mods") - foreach ($directory in $directories) { + foreach ($dir in @("$InstallPath\functions", "$InstallPath\mods")) { try { - #Get dir - $dirPath = Get-Item -Path $directory - #Get ACL + $dirPath = Get-Item -Path $dir $acl = Get-Acl -Path $dirPath.FullName - #Disable inheritance - $acl.SetAccessRuleProtection($True, $True) - #Remove any existing rules + $acl.SetAccessRuleProtection($true, $true) $acl.Access | ForEach-Object { $acl.RemoveAccessRule($_) } - # Add new ACL rules - Add-ACLRule -acl $acl -sid "S-1-5-18" -access "FullControl" # SYSTEM Full - Add-ACLRule -acl $acl -sid "S-1-5-32-544" -access "FullControl" # Administrators Full - Add-ACLRule -acl $acl -sid "S-1-5-32-545" -access "ReadAndExecute" # Local Users ReadAndExecute - Add-ACLRule -acl $acl -sid "S-1-5-11" -access "ReadAndExecute" # Authenticated Users ReadAndExecute + # Add permissions: SYSTEM, Admins = Full; Users, Authenticated = ReadAndExecute + Add-ACLRule -acl $acl -sid "S-1-5-18" -access "FullControl" + Add-ACLRule -acl $acl -sid "S-1-5-32-544" -access "FullControl" + Add-ACLRule -acl $acl -sid "S-1-5-32-545" -access "ReadAndExecute" + Add-ACLRule -acl $acl -sid "S-1-5-11" -access "ReadAndExecute" - # Save the updated ACL to the directory Set-Acl -Path $dirPath.FullName -AclObject $acl - - Write-Host "Permissions for '$directory' have been updated successfully." + Write-Host "Permissions for '$dir' updated successfully." } catch { - Write-Host "Error setting ACL for '$directory' : $($_.Exception.Message)" + Write-Host "Error setting ACL for '$dir': $($_.Exception.Message)" } } - } Write-Host "### WAU MSI Post actions succeeded! ###" - } catch { - Write-Host "### WAU Installation failed! Error $_. ###" - return $False + Write-Host "### WAU Installation failed! Error: $_. ###" + return $false } } function Uninstall-WingetAutoUpdate { - Write-Host "### Uninstalling WAU started! ###" + # Remove scheduled tasks Write-Host "-> Removing scheduled tasks." - Get-ScheduledTask -TaskName "Winget-AutoUpdate" -ErrorAction SilentlyContinue | Unregister-ScheduledTask -Confirm:$False - Get-ScheduledTask -TaskName "Winget-AutoUpdate-Notify" -ErrorAction SilentlyContinue | Unregister-ScheduledTask -Confirm:$False - Get-ScheduledTask -TaskName "Winget-AutoUpdate-UserContext" -ErrorAction SilentlyContinue | Unregister-ScheduledTask -Confirm:$False - Get-ScheduledTask -TaskName "Winget-AutoUpdate-Policies" -ErrorAction SilentlyContinue | Unregister-ScheduledTask -Confirm:$False + @("Winget-AutoUpdate", "Winget-AutoUpdate-Notify", "Winget-AutoUpdate-UserContext", "Winget-AutoUpdate-Policies") | ForEach-Object { + Get-ScheduledTask -TaskName $_ -ErrorAction SilentlyContinue | Unregister-ScheduledTask -Confirm:$false + } - #If upgrade, keep app list and mods. Else, remove. + # Keep app lists and mods on upgrade, remove on full uninstall if ($Upgrade -like "#{*}") { Write-Output "-> Upgrade detected. Keeping *.txt and mods app lists" } else { - $AppLists = Get-Item (Join-Path "$InstallPath" "*_apps.txt") + $AppLists = Get-Item (Join-Path $InstallPath "*_apps.txt") -ErrorAction SilentlyContinue if ($AppLists) { Write-Output "-> Removing items: $AppLists" Remove-Item $AppLists -Force } - Remove-Item "$InstallPath\mods" -Recurse -Force + Remove-Item "$InstallPath\mods" -Recurse -Force -ErrorAction SilentlyContinue } - $ConfFolder = Get-Item (Join-Path "$InstallPath" "config") -ErrorAction SilentlyContinue + # Remove config folder + $ConfFolder = Get-Item (Join-Path $InstallPath "config") -ErrorAction SilentlyContinue if ($ConfFolder) { Write-Output "-> Removing item: $ConfFolder" Remove-Item $ConfFolder -Force -Recurse } Write-Host "### Uninstallation done! ###" - Start-sleep 1 + Start-Sleep 1 } @@ -213,12 +221,9 @@ function Uninstall-WingetAutoUpdate { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 $Script:ProgressPreference = 'SilentlyContinue' - -# Uninstall if ($Uninstall) { Uninstall-WingetAutoUpdate } -# Install else { Install-WingetAutoUpdate } diff --git a/Sources/Winget-AutoUpdate/functions/Add-ScopeMachine.ps1 b/Sources/Winget-AutoUpdate/functions/Add-ScopeMachine.ps1 index 5b659448..c19c98e8 100644 --- a/Sources/Winget-AutoUpdate/functions/Add-ScopeMachine.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Add-ScopeMachine.ps1 @@ -1,7 +1,23 @@ -#Function to configure the preferred scope option as Machine +<# +.SYNOPSIS + Configures WinGet to prefer machine-scope installations. + +.DESCRIPTION + Updates the WinGet settings file to set the installation scope + preference to "Machine". This ensures applications are installed + for all users when running in system context. + +.EXAMPLE + Add-ScopeMachine + +.NOTES + Settings file location varies by context: + - System: %WINDIR%\System32\config\systemprofile\AppData\Local\Microsoft\WinGet\Settings + - User: %LOCALAPPDATA%\Packages\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\LocalState +#> function Add-ScopeMachine { - #Get Settings path for system or current user + # Determine settings path based on execution context if ([System.Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) { $SettingsPath = "$Env:windir\System32\config\systemprofile\AppData\Local\Microsoft\WinGet\Settings\defaultState\settings.json" } @@ -11,22 +27,26 @@ function Add-ScopeMachine { $ConfigFile = @{} - #Check if setting file exist, if not create it + # Load existing settings or create new file if (Test-Path $SettingsPath) { + # Parse JSON, excluding comment lines $ConfigFile = Get-Content -Path $SettingsPath | Where-Object { $_ -notmatch '//' } | ConvertFrom-Json } else { New-Item -Path $SettingsPath -Force | Out-Null } + # Add or update the installBehavior.preferences.scope setting if ($ConfigFile.installBehavior.preferences) { Add-Member -InputObject $ConfigFile.installBehavior.preferences -MemberType NoteProperty -Name "scope" -Value "Machine" -Force } else { + # Create the nested structure if it doesn't exist $Scope = New-Object PSObject -Property $(@{scope = "Machine" }) $Preference = New-Object PSObject -Property $(@{preferences = $Scope }) Add-Member -InputObject $ConfigFile -MemberType NoteProperty -Name "installBehavior" -Value $Preference -Force } + # Save the updated settings $ConfigFile | ConvertTo-Json -Depth 100 | Out-File $SettingsPath -Encoding utf8 -Force } diff --git a/Sources/Winget-AutoUpdate/functions/Compare-SemVer.ps1 b/Sources/Winget-AutoUpdate/functions/Compare-SemVer.ps1 index eff441e0..a3235a78 100644 --- a/Sources/Winget-AutoUpdate/functions/Compare-SemVer.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Compare-SemVer.ps1 @@ -1,40 +1,75 @@ +<# +.SYNOPSIS + Compares two semantic version strings. + +.DESCRIPTION + Compares two version strings following semantic versioning rules. + Handles pre-release versions (e.g., "1.0.0-beta1"). + Pre-release versions are considered less than their release counterparts. + +.PARAMETER Version1 + The first version string to compare. + +.PARAMETER Version2 + The second version string to compare. + +.OUTPUTS + Integer: -1 if Version1 < Version2, 0 if equal, 1 if Version1 > Version2 + +.EXAMPLE + Compare-SemVer -Version1 "1.0.0" -Version2 "1.1.0" # Returns -1 + +.EXAMPLE + Compare-SemVer -Version1 "2.0.0" -Version2 "2.0.0-beta" # Returns 1 +#> function Compare-SemVer { param ( [string]$Version1, [string]$Version2 ) - - # Split version and pre-release parts + + # Split version and pre-release parts (e.g., "1.0.0-beta1" -> ["1.0.0", "beta1"]) $v1Parts = $Version1 -split '-' $v2Parts = $Version2 -split '-' - + + # Parse main version numbers $v1 = [Version]$v1Parts[0] $v2 = [Version]$v2Parts[0] - - # Compare main version parts + + # Compare major version if ($v1.Major -ne $v2.Major) { return [Math]::Sign($v1.Major - $v2.Major) } + + # Compare minor version if ($v1.Minor -ne $v2.Minor) { return [Math]::Sign($v1.Minor - $v2.Minor) } + + # Compare build version if ($v1.Build -ne $v2.Build) { return [Math]::Sign($v1.Build - $v2.Build) } + + # Compare revision if ($v1.Revision -ne $v2.Revision) { return [Math]::Sign($v1.Revision - $v2.Revision) } - - # Compare pre-release parts if they exist + + # Handle pre-release comparison if ($v1Parts.Length -eq 2 -and $v2Parts.Length -eq 2) { + # Both have pre-release tags, compare them lexically return [String]::Compare($v1Parts[1], $v2Parts[1]) } elseif ($v1Parts.Length -eq 2) { + # Version1 has pre-release, Version2 doesn't (pre-release < release) return -1 } elseif ($v2Parts.Length -eq 2) { + # Version2 has pre-release, Version1 doesn't (release > pre-release) return 1 } - + + # Versions are equal return 0 } \ No newline at end of file diff --git a/Sources/Winget-AutoUpdate/functions/Confirm-Installation.ps1 b/Sources/Winget-AutoUpdate/functions/Confirm-Installation.ps1 index 98ca81ba..0d195b82 100644 --- a/Sources/Winget-AutoUpdate/functions/Confirm-Installation.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Confirm-Installation.ps1 @@ -1,24 +1,23 @@ -Function Confirm-Installation ($AppName, $AppVer) { +<# +.SYNOPSIS + Verifies application installation at expected version. - #Set json export file - $JsonFile = "$env:TEMP\InstalledApps.json" +.PARAMETER AppName + WinGet package identifier. - #Get installed apps and version in json file - & $Winget export -s winget -o $JsonFile --include-versions | Out-Null +.PARAMETER AppVer + Expected version prefix. - #Get json content - $Json = Get-Content $JsonFile -Raw | ConvertFrom-Json +.OUTPUTS + Boolean: True if installed at version. +#> +Function Confirm-Installation ($AppName, $AppVer) { - #Get apps and version in hashtable - $Packages = $Json.Sources.Packages + $JsonFile = "$env:TEMP\InstalledApps.json" + & $Winget export -s winget -o $JsonFile --include-versions | Out-Null - # Search for specific app and version - $Apps = $Packages | Where-Object { $_.PackageIdentifier -eq $AppName -and $_.Version -like "$AppVer*" } + $Packages = (Get-Content $JsonFile -Raw | ConvertFrom-Json).Sources.Packages + $match = $Packages | Where-Object { $_.PackageIdentifier -eq $AppName -and $_.Version -like "$AppVer*" } - if ($Apps) { - return $true - } - else { - return $false - } + return [bool]$match } diff --git a/Sources/Winget-AutoUpdate/functions/ConvertTo-WingetArgumentArray.ps1 b/Sources/Winget-AutoUpdate/functions/ConvertTo-WingetArgumentArray.ps1 new file mode 100644 index 00000000..dd862a3d --- /dev/null +++ b/Sources/Winget-AutoUpdate/functions/ConvertTo-WingetArgumentArray.ps1 @@ -0,0 +1,90 @@ +<# +.SYNOPSIS + Parses a string of winget arguments respecting quotes and spaces. + +.DESCRIPTION + Splits a string of winget command-line arguments into an array, + properly handling both single and double quotes, multiple spaces, + and quoted values containing spaces. + +.PARAMETER ArgumentString + The raw argument string to parse (e.g., "--locale en-US --skip-dependencies") + +.OUTPUTS + Array of individual argument strings + +.EXAMPLE + ConvertTo-WingetArgumentArray "--skip-dependencies" + Returns: @("--skip-dependencies") + +.EXAMPLE + ConvertTo-WingetArgumentArray '--locale "en-US" --architecture x64' + Returns: @("--locale", "en-US", "--architecture", "x64") + +.EXAMPLE + ConvertTo-WingetArgumentArray "--override '-sfx_nu /sAll /msi EULA_ACCEPT=YES'" + Returns: @("--override", "-sfx_nu /sAll /msi EULA_ACCEPT=YES") +#> +function ConvertTo-WingetArgumentArray { + [CmdletBinding()] + [OutputType([System.Object[]])] + param( + [Parameter(Mandatory = $false)] + [string]$ArgumentString + ) + + # Return empty array if input is null or whitespace + if ([string]::IsNullOrWhiteSpace($ArgumentString)) { + return @() + } + + $ArgumentString = $ArgumentString.Trim() + + # Regex pattern that handles: + # - Double quoted strings: "value with spaces" + # - Single quoted strings: 'value with spaces' + # - Unquoted arguments: --flag or value + # Pattern explanation: + # "([^"]*)" - Captures content between double quotes (group 1) + # '([^']*)' - Captures content between single quotes (group 2) + # (\S+) - Captures non-whitespace sequences (group 3) + $pattern = '(?:"([^"]*)"|''([^'']*)''|(\S+))' + + try { + # Use [regex]::Matches() to find all argument tokens + $regex = [regex]::new($pattern) + $regexMatches = $regex.Matches($ArgumentString) + + $result = @() + foreach ($match in $regexMatches) { + # Get the captured value from whichever group matched + $value = if ($match.Groups[1].Success) { + # Double-quoted value + $match.Groups[1].Value + } + elseif ($match.Groups[2].Success) { + # Single-quoted value + $match.Groups[2].Value + } + else { + # Unquoted value + $match.Groups[3].Value + } + + # Only add non-empty values + if (-not [string]::IsNullOrWhiteSpace($value)) { + $result += $value + } + } + + return $result + } + catch { + Write-ToLog "Warning: Failed to parse arguments '$ArgumentString' - Error: $($_.Exception.Message)" "Yellow" + Write-ToLog "Falling back to simple space split" "Yellow" + + # Fallback to simple split if regex parsing fails + $fallbackResult = $ArgumentString.Trim() -split '\s+' + return $fallbackResult + } +} diff --git a/Sources/Winget-AutoUpdate/functions/Get-AZCopy.ps1 b/Sources/Winget-AutoUpdate/functions/Get-AZCopy.ps1 index c94b175f..4512715e 100644 --- a/Sources/Winget-AutoUpdate/functions/Get-AZCopy.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Get-AZCopy.ps1 @@ -1,32 +1,57 @@ -#Function to get AZCopy, if it doesn't exist and update it, if it does +<# +.SYNOPSIS + Downloads or updates AzCopy for Azure Blob storage operations. +.DESCRIPTION + Checks for the latest version of AzCopy and downloads/updates it + if a newer version is available. AzCopy is used for syncing mods + from Azure Blob Storage. + +.PARAMETER WingetUpdatePath + The WAU installation directory where azcopy.exe will be stored. + +.EXAMPLE + Get-AZCopy "C:\Program Files\Winget-AutoUpdate" + +.NOTES + Downloads from Microsoft's official AzCopy distribution. + Extracts and copies only the azcopy.exe executable. +#> Function Get-AZCopy ($WingetUpdatePath) { + # Get latest AzCopy version from Microsoft redirect $AZCopyLink = (Invoke-WebRequest -Uri https://aka.ms/downloadazcopy-v10-windows -UseBasicParsing -MaximumRedirection 0 -ErrorAction SilentlyContinue).headers.location $AZCopyVersionRegex = [regex]::new("(\d+\.\d+\.\d+)") $AZCopyLatestVersion = $AZCopyVersionRegex.Match($AZCopyLink).Value + # Default to 0.0.0 if version detection fails if ($null -eq $AZCopyLatestVersion -or "" -eq $AZCopyLatestVersion) { $AZCopyLatestVersion = "0.0.0" } + # Check current installed version if (Test-Path -Path "$WingetUpdatePath\azcopy.exe" -PathType Leaf) { $AZCopyCurrentVersion = & "$WingetUpdatePath\azcopy.exe" -v $AZCopyCurrentVersion = $AZCopyVersionRegex.Match($AZCopyCurrentVersion).Value - Write-ToLog "AZCopy version $AZCopyCurrentVersion found" + Write-ToLog "AZCopy version $AZCopyCurrentVersion found" } else { - Write-ToLog "AZCopy not already installed" + Write-ToLog "AZCopy not already installed" $AZCopyCurrentVersion = "0.0.0" } + # Download and install if newer version available if (([version] $AZCopyCurrentVersion) -lt ([version] $AZCopyLatestVersion)) { - Write-ToLog "Installing version $AZCopyLatestVersion of AZCopy" + Write-ToLog "Installing version $AZCopyLatestVersion of AZCopy" + + # Download AzCopy zip Invoke-WebRequest -Uri $AZCopyLink -UseBasicParsing -OutFile "$WingetUpdatePath\azcopyv10.zip" - Write-ToLog "Extracting AZCopy zip file" + Write-ToLog "Extracting AZCopy zip file" + # Extract archive Expand-archive -Path "$WingetUpdatePath\azcopyv10.zip" -Destinationpath "$WingetUpdatePath" -Force + # Find extracted folder (handles version-specific folder names) $AZCopyPathSearch = Resolve-Path -path "$WingetUpdatePath\azcopy_*" if ($AZCopyPathSearch -is [array]) { @@ -36,15 +61,18 @@ Function Get-AZCopy ($WingetUpdatePath) { $AZCopyEXEPath = $AZCopyPathSearch } - Write-ToLog "Copying 'azcopy.exe' to main folder" + # Copy executable to main folder + Write-ToLog "Copying 'azcopy.exe' to main folder" Copy-Item "$AZCopyEXEPath\azcopy.exe" -Destination "$WingetUpdatePath\" - Write-ToLog "Removing temporary AZCopy files" + # Cleanup temporary files + Write-ToLog "Removing temporary AZCopy files" Remove-Item -Path $AZCopyEXEPath -Recurse Remove-Item -Path "$WingetUpdatePath\azcopyv10.zip" + # Verify installation $AZCopyCurrentVersion = & "$WingetUpdatePath\azcopy.exe" -v $AZCopyCurrentVersion = $AZCopyVersionRegex.Match($AZCopyCurrentVersion).Value - Write-ToLog "AZCopy version $AZCopyCurrentVersion installed" + Write-ToLog "AZCopy version $AZCopyCurrentVersion installed" } } diff --git a/Sources/Winget-AutoUpdate/functions/Get-AppInfo.ps1 b/Sources/Winget-AutoUpdate/functions/Get-AppInfo.ps1 index 747a8188..c226725f 100644 --- a/Sources/Winget-AutoUpdate/functions/Get-AppInfo.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Get-AppInfo.ps1 @@ -1,12 +1,30 @@ -#Get the winget App Information +<# +.SYNOPSIS + Retrieves application release notes URL from WinGet. +.DESCRIPTION + Queries WinGet for application metadata and extracts the + release notes URL if available. + +.PARAMETER AppID + The WinGet package identifier to query. + +.OUTPUTS + String containing the release notes URL, or empty if not found. + +.EXAMPLE + $releaseUrl = Get-AppInfo "Microsoft.PowerShell" + +.NOTES + Uses WinGet show command with source agreements accepted. +#> Function Get-AppInfo ($AppID) { - #Get AppID Info + + # Query WinGet for application details $String = & $winget show $AppID --accept-source-agreements -s winget | Out-String - #Search for Release Note info + # Extract Release Notes URL using regex $ReleaseNote = [regex]::match($String, "(?<=Release Notes Url: )(.*)(?=\n)").Groups[0].Value - #Return Release Note return $ReleaseNote } diff --git a/Sources/Winget-AutoUpdate/functions/Get-ExcludedApps.ps1 b/Sources/Winget-AutoUpdate/functions/Get-ExcludedApps.ps1 index 15dbc7a3..690e4a4c 100644 --- a/Sources/Winget-AutoUpdate/functions/Get-ExcludedApps.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Get-ExcludedApps.ps1 @@ -1,34 +1,37 @@ -#Function to get the Block List apps +<# +.SYNOPSIS + Retrieves the list of excluded (blacklisted) applications. +.DESCRIPTION + Returns application IDs to exclude from automatic updates. + Priority: GPO registry > local file > default file. + +.OUTPUTS + Array of application IDs to exclude. +#> function Get-ExcludedApps { - $AppIDs = @() + $GPOPath = "HKLM:\SOFTWARE\Policies\Romanitho\Winget-AutoUpdate\BlackList" + $LocalFile = "$WorkingDir\excluded_apps.txt" + $DefaultFile = "$WorkingDir\config\default_excluded_apps.txt" - #blacklist in Policies registry - if (Test-Path "HKLM:\SOFTWARE\Policies\Romanitho\Winget-AutoUpdate\BlackList") { + # GPO takes priority + if (Test-Path $GPOPath) { Write-ToLog "-> Excluded apps from GPO is activated" - $ValueNames = (Get-Item -Path "HKLM:\SOFTWARE\Policies\Romanitho\Winget-AutoUpdate\BlackList").Property - foreach ($ValueName in $ValueNames) { - $AppIDs += (Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Policies\Romanitho\Winget-AutoUpdate\BlackList" -Name $ValueName).Trim() - } - foreach ($app in $AppIDs) { - Write-ToLog "Exclude app $app" + $AppIDs = (Get-Item $GPOPath).Property | ForEach-Object { + $id = (Get-ItemPropertyValue $GPOPath -Name $_).Trim() + Write-ToLog "Exclude app $id" + $id } } - #blacklist pulled from local file - elseif (Test-Path "$WorkingDir\excluded_apps.txt") { - - $AppIDs = (Get-Content -Path "$WorkingDir\excluded_apps.txt").Trim() - Write-ToLog "-> Successsfully loaded local excluded apps list." - + elseif (Test-Path $LocalFile) { + Write-ToLog "-> Successfully loaded local excluded apps list." + $AppIDs = (Get-Content $LocalFile).Trim() } - #blacklist pulled from default file - elseif (Test-Path "$WorkingDir\config\default_excluded_apps.txt") { - - $AppIDs = (Get-Content -Path "$WorkingDir\config\default_excluded_apps.txt").Trim() - Write-ToLog "-> Successsfully loaded default excluded apps list." - + elseif (Test-Path $DefaultFile) { + Write-ToLog "-> Successfully loaded default excluded apps list." + $AppIDs = (Get-Content $DefaultFile).Trim() } - return $AppIDs | Where-Object { $_.length -gt 0 } + return $AppIDs | Where-Object { $_ } } diff --git a/Sources/Winget-AutoUpdate/functions/Get-IncludedApps.ps1 b/Sources/Winget-AutoUpdate/functions/Get-IncludedApps.ps1 index 4bb5f132..d297d411 100644 --- a/Sources/Winget-AutoUpdate/functions/Get-IncludedApps.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Get-IncludedApps.ps1 @@ -1,28 +1,32 @@ -#Function to get the allow List apps +<# +.SYNOPSIS + Retrieves the list of included (whitelisted) applications. +.DESCRIPTION + Returns application IDs to include in automatic updates (whitelist mode). + Priority: GPO registry > local file. + +.OUTPUTS + Array of application IDs to include. +#> function Get-IncludedApps { - $AppIDs = @() + $GPOPath = "HKLM:\SOFTWARE\Policies\Romanitho\Winget-AutoUpdate\WhiteList" + $LocalFile = "$WorkingDir\included_apps.txt" - #whitelist in Policies registry - if (Test-Path "HKLM:\SOFTWARE\Policies\Romanitho\Winget-AutoUpdate\WhiteList") { + # GPO takes priority + if (Test-Path $GPOPath) { Write-ToLog "-> Included apps from GPO is activated" - $ValueNames = (Get-Item -Path "HKLM:\SOFTWARE\Policies\Romanitho\Winget-AutoUpdate\WhiteList").Property - foreach ($ValueName in $ValueNames) { - $AppIDs += (Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Policies\Romanitho\Winget-AutoUpdate\WhiteList" -Name $ValueName).Trim() - } - foreach ($app in $AppIDs) { - Write-ToLog "Include app $app" + $AppIDs = (Get-Item $GPOPath).Property | ForEach-Object { + $id = (Get-ItemPropertyValue $GPOPath -Name $_).Trim() + Write-ToLog "Include app $id" + $id } } - #whitelist pulled from local file - elseif (Test-Path "$WorkingDir\included_apps.txt") { - - $AppIDs = (Get-Content -Path "$WorkingDir\included_apps.txt").Trim() - Write-ToLog "-> Successsfully loaded local included apps list." - + elseif (Test-Path $LocalFile) { + Write-ToLog "-> Successfully loaded local included apps list." + $AppIDs = (Get-Content $LocalFile).Trim() } - return $AppIDs | Where-Object { $_.length -gt 0 } - + return $AppIDs | Where-Object { $_ } } diff --git a/Sources/Winget-AutoUpdate/functions/Get-NotifLocale.ps1 b/Sources/Winget-AutoUpdate/functions/Get-NotifLocale.ps1 index f0bd3a2d..a16067a0 100644 --- a/Sources/Winget-AutoUpdate/functions/Get-NotifLocale.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Get-NotifLocale.ps1 @@ -1,28 +1,44 @@ -#Function to get the locale file for notifications +<# +.SYNOPSIS + Retrieves the notification locale file for the current OS language. +.DESCRIPTION + Loads the appropriate XML locale file for toast notifications based + on the operating system's UI culture. Falls back to English if the + OS locale is not available. + +.OUTPUTS + String containing the display name of the selected locale. + +.EXAMPLE + $localeName = Get-NotifLocale + +.NOTES + Sets the script-scoped $NotifLocale variable with the loaded XML content. + Locale files are stored in the locale subfolder. +#> Function Get-NotifLocale { - #Get OS locale + # Get the OS UI culture (parent culture for regional variants) $OSLocale = (Get-UICulture).Parent - #Test if OS locale notif file exists + # Build path to locale-specific notification file $TestOSLocalPath = "$WorkingDir\locale\$($OSLocale.Name).xml" - #Set OS Local if file exists + # Use OS locale if file exists, otherwise fall back to English if (Test-Path $TestOSLocalPath) { $LocaleDisplayName = $OSLocale.DisplayName $LocaleFile = $TestOSLocalPath } - #Set English if file doesn't exist else { $LocaleDisplayName = "English" $LocaleFile = "$WorkingDir\locale\en.xml" } - #Get locale XML file content + # Load the locale XML file into script scope for notification messages [xml]$Script:NotifLocale = Get-Content $LocaleFile -Encoding UTF8 -ErrorAction SilentlyContinue - #Rerturn langague display name - Return $LocaleDisplayName + # Return the language display name + return $LocaleDisplayName } diff --git a/Sources/Winget-AutoUpdate/functions/Get-WAUAvailableVersion.ps1 b/Sources/Winget-AutoUpdate/functions/Get-WAUAvailableVersion.ps1 index b07eac70..5229d866 100644 --- a/Sources/Winget-AutoUpdate/functions/Get-WAUAvailableVersion.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Get-WAUAvailableVersion.ps1 @@ -1,19 +1,36 @@ -#Function to get the latest WAU available version on Github +<# +.SYNOPSIS + Retrieves the latest available version of WAU from GitHub. +.DESCRIPTION + Queries the GitHub API to determine the latest available version + of Winget-AutoUpdate. Supports both stable releases and pre-releases + based on configuration. + +.OUTPUTS + String containing the version number (without 'v' prefix). + +.EXAMPLE + $version = Get-WAUAvailableVersion + +.NOTES + Requires WAUConfig with WAU_UpdatePrerelease setting. + Falls back to web scraping if API fails. +#> function Get-WAUAvailableVersion { - #Get Github latest version + # Check if pre-release versions should be considered if ($WAUConfig.WAU_UpdatePrerelease -eq 1) { - #Log Write-ToLog "WAU AutoUpdate Pre-release versions is Enabled" "Cyan" try { - #Get latest pre-release info + # Query GitHub API for all releases (pre-releases included) $WAUurl = "https://api.github.com/repos/Romanitho/$($GitHub_Repo)/releases" $WAUAvailableVersion = ((Invoke-WebRequest $WAUurl -UseBasicParsing | ConvertFrom-Json)[0].tag_name).Replace("v", "") } catch { + # Fallback: Parse version from GitHub releases page $url = "https://github.com/Romanitho/$($GitHub_Repo)/releases" $link = ((Invoke-WebRequest $url -UseBasicParsing).Links.href -match "/$($GitHub_Repo)/releases/tag/v*")[0] $WAUAvailableVersion = $link.Trim().Split("v")[-1] @@ -23,11 +40,12 @@ function Get-WAUAvailableVersion { else { try { - #Get latest stable info + # Query GitHub API for latest stable release only $WAUurl = "https://api.github.com/repos/Romanitho/$($GitHub_Repo)/releases/latest" $WAUAvailableVersion = ((Invoke-WebRequest $WAUurl -UseBasicParsing | ConvertFrom-Json)[0].tag_name).Replace("v", "") } catch { + # Fallback: Parse version from GitHub releases page $url = "https://github.com/Romanitho/$($GitHub_Repo)/releases/latest" $link = ((Invoke-WebRequest $url -UseBasicParsing).Links.href -match "/$($GitHub_Repo)/releases/tag/v*")[0] $WAUAvailableVersion = $link.Trim().Split("v")[-1] @@ -35,7 +53,6 @@ function Get-WAUAvailableVersion { } - #Return version return $WAUAvailableVersion } diff --git a/Sources/Winget-AutoUpdate/functions/Get-WAUConfig.ps1 b/Sources/Winget-AutoUpdate/functions/Get-WAUConfig.ps1 index 8481d223..dba767ba 100644 --- a/Sources/Winget-AutoUpdate/functions/Get-WAUConfig.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Get-WAUConfig.ps1 @@ -1,24 +1,26 @@ -#Function to get the WAU settings, including Domain/Local Policies (GPO) +<# +.SYNOPSIS + Gets WAU configuration including GPO overrides. -Function Get-WAUConfig { - - #Get WAU Configurations from install config - $WAUConfig_64_86 = Get-ItemProperty -Path "HKLM:\SOFTWARE\Romanitho\Winget-AutoUpdate*", "HKLM:\SOFTWARE\WOW6432Node\Romanitho\Winget-AutoUpdate*" -ErrorAction SilentlyContinue | Sort-Object { $_.ProductVersion } -Descending - $WAUConfig = $WAUConfig_64_86[0] +.DESCRIPTION + Reads settings from registry, applying GPO policies if present. - #Check if GPO policies exist - $WAUPolicies = Get-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Romanitho\Winget-AutoUpdate" -ErrorAction SilentlyContinue +.OUTPUTS + PSCustomObject with WAU configuration properties. +#> +Function Get-WAUConfig { - #If GPO policies exist, apply them (regardless of ActivateGPOManagement value) - if ($WAUPolicies) { - Write-ToLog "GPO policies detected - applying GPO configuration" "Yellow" + # Get base config (newest version from registry) + $WAUConfig = Get-ItemProperty "HKLM:\SOFTWARE\Romanitho\Winget-AutoUpdate*", "HKLM:\SOFTWARE\WOW6432Node\Romanitho\Winget-AutoUpdate*" -ErrorAction SilentlyContinue | + Sort-Object { $_.ProductVersion } -Descending | + Select-Object -First 1 - #Replace loaded configurations by ones from Policies - $WAUPolicies.PSObject.Properties | ForEach-Object { - $WAUConfig.PSObject.Properties.add($_) - } + # Apply GPO overrides if present + $GPO = Get-ItemProperty "HKLM:\SOFTWARE\Policies\Romanitho\Winget-AutoUpdate" -ErrorAction SilentlyContinue + if ($GPO) { + Write-ToLog "GPO policies detected - applying" "Yellow" + $GPO.PSObject.Properties | ForEach-Object { $WAUConfig.PSObject.Properties.add($_) } } - #Return config return $WAUConfig } diff --git a/Sources/Winget-AutoUpdate/functions/Get-WingetCmd.ps1 b/Sources/Winget-AutoUpdate/functions/Get-WingetCmd.ps1 index 2f4d41a1..e7b48218 100644 --- a/Sources/Winget-AutoUpdate/functions/Get-WingetCmd.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Get-WingetCmd.ps1 @@ -1,30 +1,38 @@ -#Function to get the winget command regarding execution context (User, System...) +<# +.SYNOPSIS + Retrieves the path to the Winget executable. -Function Get-WingetCmd -{ - [OutputType([String])] - $WingetCmd = [string]::Empty; +.DESCRIPTION + Locates winget.exe from system context (WindowsApps) or user context. + Returns the most recent version when multiple exist. - #Get WinGet Path - # default winget path (in system context) - [string]$ps = "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller_*_8wekyb3d8bbwe\winget.exe"; +.OUTPUTS + String: Full path to winget.exe, or empty if not found. +#> +Function Get-WingetCmd { + [OutputType([String])] - #default winget path (in user context) - [string]$pu = "$env:LocalAppData\Microsoft\WindowsApps\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\winget.exe"; + $systemPath = "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller_*_8wekyb3d8bbwe\winget.exe" + $userPath = "$env:LocalAppData\Microsoft\WindowsApps\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe\winget.exe" - #Get Admin Context Winget Location - $WingetInfo = (Get-Item -Path $ps -ErrorAction Stop).VersionInfo | Sort-Object -Property FileVersionRaw -Descending | Select-Object -First 1; - #If multiple versions, pick most recent one - $WingetCmd = $WingetInfo.FileName; + # Try system context first (newest version) + try { + $WingetInfo = (Get-Item $systemPath -ErrorAction Stop).VersionInfo | + Sort-Object FileVersionRaw -Descending | + Select-Object -First 1 - if ([String]::IsNullOrEmpty($WingetCmd)) - { - #Get User context Winget Location - if (Test-Path -Path $pu -PathType Leaf) - { - $WingetCmd = $pu; + if ($WingetInfo.FileName) { + return $WingetInfo.FileName } } + catch { + # System context not found, try user context + } + + # Fall back to user context + if (Test-Path $userPath) { + return $userPath + } - return $WingetCmd; + return [string]::Empty } \ No newline at end of file diff --git a/Sources/Winget-AutoUpdate/functions/Get-WingetOutdatedApps.ps1 b/Sources/Winget-AutoUpdate/functions/Get-WingetOutdatedApps.ps1 index b4f9bba6..909aaf6f 100644 --- a/Sources/Winget-AutoUpdate/functions/Get-WingetOutdatedApps.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Get-WingetOutdatedApps.ps1 @@ -1,5 +1,26 @@ -#Function to get the outdated app list, in formatted array +<# +.SYNOPSIS + Retrieves the list of applications with available updates from WinGet. +.DESCRIPTION + Queries WinGet for applications with available updates and returns + a structured list. Parses WinGet's tabular output and handles + non-Latin characters and text formatting issues. + +.PARAMETER src + The WinGet source repository name to query (e.g., "winget"). + +.OUTPUTS + Array of Software objects with Name, Id, Version, and AvailableVersion. + Returns descriptive string if no updates are found. + +.EXAMPLE + $outdated = Get-WingetOutdatedApps -src "winget" + +.NOTES + Excludes system-installed apps when running in user context. + Results are randomized to prevent update ordering bias. +#> function Get-WingetOutdatedApps { Param( @@ -7,6 +28,8 @@ function Get-WingetOutdatedApps { [ValidateNotNullorEmpty()] [string]$src ) + + # Define class for structured output class Software { [string]$Name [string]$Id @@ -14,79 +37,78 @@ function Get-WingetOutdatedApps { [string]$AvailableVersion } - #Get list of available upgrades on winget format + # Get upgrade list from WinGet try { $upgradeResult = & $Winget upgrade --source $src | Where-Object { $_ -notlike " *" } | Out-String } catch { - Write-ToLog "Error while recieving winget upgrade list: $_" "Red" + Write-ToLog "Error while receiving winget upgrade list: $_" "Red" $upgradeResult = $null } - #Start Conversion of winget format to an array. Check if "-----" exists (Winget Error Handling) + # Check if output contains valid data (header separator line) if (!($upgradeResult -match "-----")) { - return "No update found. 'Winget upgrade' output:`n$upgradeResult" - } else { - #Split winget output to lines + # Split output into lines, removing empty lines $lines = $upgradeResult.Split([Environment]::NewLine) | Where-Object { $_ } - # Find the line that starts with "------" + # Find the separator line (starts with "-----") $fl = 0 while (-not $lines[$fl].StartsWith("-----")) { $fl++ } - #Get header line + # Get header line (one line before separator) $fl = $fl - 1 - #Get header titles [without remove separator] + # Split header into columns (preserving trailing spaces for positioning) $index = $lines[$fl] -split '(?<=\s)(?!\s)' - # Line $fl has the header, we can find char where we find ID and Version [and manage non latin characters] + # Calculate column positions (handle non-Latin characters by replacing with **) $idStart = $($index[0] -replace '[\u4e00-\u9fa5]', '**').Length $versionStart = $idStart + $($index[1] -replace '[\u4e00-\u9fa5]', '**').Length $availableStart = $versionStart + $($index[2] -replace '[\u4e00-\u9fa5]', '**').Length - # Now cycle in real package and split accordingly + # Parse each data line $upgradeList = @() For ($i = $fl + 2; $i -lt $lines.Length; $i++) { - $line = $lines[$i] -replace "[\u2026]", " " #Fix "..." in long names + # Fix ellipsis character in long names + $line = $lines[$i] -replace "[\u2026]", " " + + # Handle multiple tables (new header encountered) if ($line.StartsWith("-----")) { - #Get header line $fl = $i - 1 - - #Get header titles [without remove separator] $index = $lines[$fl] -split '(?<=\s)(?!\s)' - - # Line $fl has the header, we can find char where we find ID and Version [and manage non latin characters] $idStart = $($index[0] -replace '[\u4e00-\u9fa5]', '**').Length $versionStart = $idStart + $($index[1] -replace '[\u4e00-\u9fa5]', '**').Length $availableStart = $versionStart + $($index[2] -replace '[\u4e00-\u9fa5]', '**').Length } - #(Alphanumeric | Literal . | Alphanumeric) - the only unique thing in common for lines with applications + + # Check if line contains an application entry (has format word.word) if ($line -match "\w\.\w") { $software = [Software]::new() - #Manage non latin characters + + # Calculate name declination for non-Latin character handling $nameDeclination = $($line.Substring(0, $idStart) -replace '[\u4e00-\u9fa5]', '**').Length - $line.Substring(0, $idStart).Length $software.Name = $line.Substring(0, $idStart - $nameDeclination).TrimEnd() $software.Id = $line.Substring($idStart - $nameDeclination, $versionStart - $idStart).TrimEnd() $software.Version = $line.Substring($versionStart - $nameDeclination, $availableStart - $versionStart).TrimEnd() $software.AvailableVersion = $line.Substring($availableStart - $nameDeclination).TrimEnd() - #add formatted soft to list + $upgradeList += $software } } - #If current user is not system, remove system apps from list + # In user context, filter out system-installed apps if ($IsSystem -eq $false) { $SystemApps = Get-Content -Path "$WorkingDir\config\winget_system_apps.txt" -ErrorAction SilentlyContinue $upgradeList = $upgradeList | Where-Object { $SystemApps -notcontains $_.Id } } + # Return randomized list to prevent update ordering bias return $upgradeList | Sort-Object { Get-Random } } diff --git a/Sources/Winget-AutoUpdate/functions/Get-WingetSystemApps.ps1 b/Sources/Winget-AutoUpdate/functions/Get-WingetSystemApps.ps1 index 953b01a8..3cd5e9b3 100644 --- a/Sources/Winget-AutoUpdate/functions/Get-WingetSystemApps.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Get-WingetSystemApps.ps1 @@ -1,22 +1,42 @@ +<# +.SYNOPSIS + Exports a list of system-installed WinGet applications. + +.DESCRIPTION + Retrieves the list of applications installed in the system context + and saves their package identifiers to a configuration file. This + list is used to exclude system apps from user-context updates. + +.PARAMETER src + The WinGet source repository name to query. + +.EXAMPLE + Get-WingetSystemApps -src "winget" + +.NOTES + Output file: $WorkingDir\config\winget_system_apps.txt + Used by Get-WingetOutdatedApps to filter user-context updates. +#> function Get-WingetSystemApps { -Param( - [Parameter(Position=0,Mandatory=$True,HelpMessage="You MUST supply value for winget repo, we need it")] - [ValidateNotNullorEmpty()] - [string]$src -) - #Json File, where to export system installed apps + Param( + [Parameter(Position = 0, Mandatory = $True, HelpMessage = "You MUST supply value for winget repo, we need it")] + [ValidateNotNullorEmpty()] + [string]$src + ) + + # Output file for system apps list $jsonFile = "$WorkingDir\config\winget_system_apps.txt" - #Get list of installed Winget apps to json file + # Export installed apps from WinGet to JSON format & $Winget export -o $jsonFile --accept-source-agreements -s $src | Out-Null - #Convert json file to txt file with app ids + # Parse JSON and extract package identifiers $InstalledApps = get-content $jsonFile | ConvertFrom-Json - #Save app list + # Write app identifiers to text file Set-Content $InstalledApps.Sources.Packages.PackageIdentifier -Path $jsonFile - #Sort app list + # Sort the list for consistency Get-Content $jsonFile | Sort-Object | Set-Content $jsonFile } diff --git a/Sources/Winget-AutoUpdate/functions/Install-Prerequisites.ps1 b/Sources/Winget-AutoUpdate/functions/Install-Prerequisites.ps1 index 84ee4fc1..57a064e2 100644 --- a/Sources/Winget-AutoUpdate/functions/Install-Prerequisites.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Install-Prerequisites.ps1 @@ -1,18 +1,38 @@ +<# +.SYNOPSIS + Ensures all WinGet prerequisites are installed and up-to-date. + +.DESCRIPTION + Checks and installs the following prerequisites for WinGet: + - Microsoft Visual C++ 2015-2022 Redistributable + - Microsoft.VCLibs.140.00.UWPDesktop (UWP dependency) + - Microsoft.UI.Xaml.2.8 (UI framework dependency) + - WinGet CLI (App Installer) itself + +.EXAMPLE + Install-Prerequisites + +.NOTES + Must run with administrative privileges. + Downloads installers from Microsoft's official sources. + Falls back to Store update if WinGet installation fails. +#> function Install-Prerequisites { try { Write-ToLog "Checking prerequisites..." "Yellow" - #Check if Visual C++ 2022 is installed + # === Check Visual C++ 2015-2022 Redistributable === $Visual2022 = "Microsoft Visual C++ 2015-2022 Redistributable*" $VisualMinVer = "14.40.0.0" $path = Get-Item HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*, HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\* | Where-Object { $_.GetValue("DisplayName") -like $Visual2022 -and $_.GetValue("DisplayVersion") -gt $VisualMinVer } + if (!($path)) { try { Write-ToLog "MS Visual C++ 2015-2022 is not installed" "Red" - #Get proc architecture + # Determine processor architecture for correct installer if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { $OSArch = "arm64" } @@ -23,7 +43,7 @@ function Install-Prerequisites { $OSArch = "x86" } - #Download and install + # Download and install VC++ Redistributable $SourceURL = "https://aka.ms/vs/17/release/VC_redist.$OSArch.exe" $Installer = "$env:TEMP\VC_redist.$OSArch.exe" Write-ToLog "-> Downloading $SourceURL..." @@ -40,16 +60,18 @@ function Install-Prerequisites { } } - #Check if Microsoft.VCLibs.140.00.UWPDesktop is installed + # === Check Microsoft.VCLibs.140.00.UWPDesktop === if (!(Get-AppxPackage -Name 'Microsoft.VCLibs.140.00.UWPDesktop' -AllUsers)) { try { Write-ToLog "Microsoft.VCLibs.140.00.UWPDesktop is not installed" "Red" - #Download + + # Download VCLibs package $VCLibsUrl = "https://aka.ms/Microsoft.VCLibs.x64.14.00.Desktop.appx" $VCLibsFile = "$env:TEMP\Microsoft.VCLibs.x64.14.00.Desktop.appx" Write-ToLog "-> Downloading Microsoft.VCLibs.140.00.UWPDesktop..." Invoke-WebRequest -Uri $VCLibsUrl -OutFile $VCLibsFile -UseBasicParsing - #Install + + # Install package Write-ToLog "-> Installing Microsoft.VCLibs.140.00.UWPDesktop..." Add-AppxProvisionedPackage -Online -PackagePath $VCLibsFile -SkipLicense | Out-Null Write-ToLog "-> Microsoft.VCLibs.140.00.UWPDesktop installed successfully." "Green" @@ -62,16 +84,18 @@ function Install-Prerequisites { } } - #Check if Microsoft.UI.Xaml.2.8 is installed + # === Check Microsoft.UI.Xaml.2.8 === if (!(Get-AppxPackage -Name 'Microsoft.UI.Xaml.2.8' -AllUsers)) { try { Write-ToLog "Microsoft.UI.Xaml.2.8 is not installed" "Red" - #Download + + # Download UI.Xaml package $UIXamlUrl = "https://github.com/microsoft/microsoft-ui-xaml/releases/download/v2.8.6/Microsoft.UI.Xaml.2.8.x64.appx" $UIXamlFile = "$env:TEMP\Microsoft.UI.Xaml.2.8.x64.appx" Write-ToLog "-> Downloading Microsoft.UI.Xaml.2.8..." Invoke-WebRequest -Uri $UIXamlUrl -OutFile $UIXamlFile -UseBasicParsing - #Install + + # Install package Write-ToLog "-> Installing Microsoft.UI.Xaml.2.8..." Add-AppxProvisionedPackage -Online -PackagePath $UIXamlFile -SkipLicense | Out-Null Write-ToLog "-> Microsoft.UI.Xaml.2.8 installed successfully." "Green" @@ -84,71 +108,68 @@ function Install-Prerequisites { } } - #Check if Winget is installed (and up to date) + # === Check WinGet CLI === try { - #Get latest WinGet info + # Get latest WinGet version from GitHub $WinGeturl = 'https://api.github.com/repos/microsoft/winget-cli/releases/latest' $WinGetAvailableVersion = ((Invoke-WebRequest $WinGeturl -UseBasicParsing | ConvertFrom-Json)[0].tag_name).TrimStart("v") } catch { - #If fail set version to the latest version as of 2025-08-12 + # Fallback to known version if API fails $WinGetAvailableVersion = "1.11.430" } + try { - #Get Admin Context Winget Location + # Get currently installed WinGet version $WingetInfo = (Get-Item "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller_*_8wekyb3d8bbwe\winget.exe").VersionInfo | Sort-Object -Property FileVersionRaw - #If multiple versions, pick most recent one $WingetCmd = $WingetInfo[-1].FileName - #Get current Winget Version $WingetInstalledVersion = (& $WingetCmd -v).Trim().TrimStart("v") } catch { Write-ToLog "WinGet is not installed" "Red" $WinGetInstalledVersion = "0.0.0" } + Write-ToLog "WinGet installed version: $WinGetInstalledVersion | WinGet available version: $WinGetAvailableVersion" - #Check if the currently installed version is less than the available version + + # Install WinGet if outdated if ((Compare-SemVer -Version1 $WinGetInstalledVersion -Version2 $WinGetAvailableVersion) -lt 0) { - #Install WinGet MSIXBundle in SYSTEM context try { - #Download WinGet MSIXBundle + # Download WinGet MSIXBundle Write-ToLog "-> Downloading WinGet MSIXBundle for App Installer..." $WinGetURL = "https://github.com/microsoft/winget-cli/releases/download/v$WinGetAvailableVersion/Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle" $WingetInstaller = "$env:TEMP\Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle" Invoke-WebRequest -Uri $WinGetURL -OutFile $WingetInstaller -UseBasicParsing - #Install + # Install WinGet Write-ToLog "-> Installing WinGet MSIXBundle for App Installer..." Add-AppxProvisionedPackage -Online -PackagePath $WingetInstaller -SkipLicense | Out-Null Write-ToLog "-> WinGet MSIXBundle (v$WinGetAvailableVersion) for App Installer installed successfully!" "green" - #Reset WinGet Sources + # Reset WinGet sources after installation $WingetInfo = (Get-Item "$env:ProgramFiles\WindowsApps\Microsoft.DesktopAppInstaller_*_8wekyb3d8bbwe\winget.exe").VersionInfo | Sort-Object -Property FileVersionRaw - #If multiple versions, pick most recent one $WingetCmd = $WingetInfo[-1].FileName & $WingetCmd source reset --force Write-ToLog "-> WinGet sources reset." "green" } catch { Write-ToLog "-> Failed to install WinGet MSIXBundle for App Installer..." "red" - #Force Store Apps to update + # Try to update via Microsoft Store as fallback Update-StoreApps } - #Remove WinGet MSIXBundle + # Cleanup installer Remove-Item -Path $WingetInstaller -Force -ErrorAction SilentlyContinue } else { Write-ToLog "-> WinGet is up to date." "Green" } + Write-ToLog "Prerequisites checked. OK" "Green" } catch { - - Write-ToLog "Prerequisites checked failed" "Red" - + Write-ToLog "Prerequisites check failed" "Red" } - } diff --git a/Sources/Winget-AutoUpdate/functions/Invoke-LogRotation.ps1 b/Sources/Winget-AutoUpdate/functions/Invoke-LogRotation.ps1 index 65bb2e4c..14a00a5e 100644 --- a/Sources/Winget-AutoUpdate/functions/Invoke-LogRotation.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Invoke-LogRotation.ps1 @@ -1,47 +1,73 @@ -#Function to rotate the logs +<# +.SYNOPSIS + Manages log file rotation and archival. +.DESCRIPTION + Rotates log files when they exceed the maximum size, archiving old + logs with timestamps. Maintains a configurable maximum number of + archived log files by deleting the oldest when necessary. + +.PARAMETER LogFile + Full path to the log file to manage. + +.PARAMETER MaxLogFiles + Maximum number of log files to keep (0 = unlimited, 1 = no rotation). + +.PARAMETER MaxLogSize + Maximum log file size in bytes before rotation occurs. + +.OUTPUTS + Boolean: True on success, False if an error occurred. + +.EXAMPLE + Invoke-LogRotation "C:\logs\updates.log" 3 1048576 + +.NOTES + Archived logs are named with timestamp: filename_yyyyMMddHHmmss.ext +#> function Invoke-LogRotation { [OutputType([Bool])] param( - [string]$LogFile, - [Int32]$MaxLogFiles, + [string]$LogFile, + [Int32]$MaxLogFiles, [Int64]$MaxLogSize ) - # if MaxLogFiles is 1 just keep the original one and let it grow + # If MaxLogFiles is 1, keep original file without rotation (let it grow) if (-not($MaxLogFiles -eq 1)) { try { - # get current size of log file + # Get current log file size $currentSize = (Get-Item $LogFile).Length - # get log name + # Parse log file path components $logFileName = Split-Path $LogFile -Leaf $logFilePath = Split-Path $LogFile $logFileNameWithoutExtension = [System.IO.Path]::GetFileNameWithoutExtension($logFileName) $logFileNameExtension = [System.IO.Path]::GetExtension($logFileName) + # Check if rotation is needed if ($currentSize -ge $MaxLogSize) { - # construct name of archived log file + # Create archived filename with timestamp $newLogFileName = $logFileNameWithoutExtension + (Get-Date -Format 'yyyyMMddHHmmss').ToString() + $logFileNameExtension - # rename old log file + + # Rename current log to archived name Rename-Item -Path $LogFile -NewName $newLogFileName -Force -Confirm:$false - # create new file + # Create new empty log file Write-ToLog "New log file created" - # if MaxLogFiles is 0 don't delete any old archived log files + # Clean up old archives if MaxLogFiles > 0 if (-not($MaxLogFiles -eq 0)) { - # set filter to search for archived log files + # Build filter pattern for archived log files $archivedLogFileFilter = $logFileNameWithoutExtension + '??????????????' + $logFileNameExtension - # get archived log files + # Find all archived log files $oldLogFiles = Get-Item -Path "$(Join-Path -Path $logFilePath -ChildPath $archivedLogFileFilter)" if ([bool]$oldLogFiles) { - # compare found log files to MaxLogFiles parameter of the log object, and delete oldest until we are - # back to the correct number + # Delete oldest files if count exceeds maximum if (($oldLogFiles.Count + 1) -gt $MaxLogFiles) { [int]$numTooMany = (($oldLogFiles.Count) + 1) - $MaxLogFiles $oldLogFiles | Sort-Object 'LastWriteTime' | Select-Object -First $numTooMany | Remove-Item @@ -49,15 +75,15 @@ function Invoke-LogRotation { } } - #Log Header + # Log rotation event Write-ToLog -LogMsg "CHECK FOR APP UPDATES (System context)" -IsHeader Write-ToLog -LogMsg "Max Log Size reached: $MaxLogSize bytes - Rotated Logs" } - # end of try block - Return $true; + + Return $true } catch { - Return $false; + Return $false } } } diff --git a/Sources/Winget-AutoUpdate/functions/Start-NotifTask.ps1 b/Sources/Winget-AutoUpdate/functions/Start-NotifTask.ps1 index 6e8312ca..d4ec75dc 100644 --- a/Sources/Winget-AutoUpdate/functions/Start-NotifTask.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Start-NotifTask.ps1 @@ -1,5 +1,50 @@ -#Function to send the notifications to user +<# +.SYNOPSIS + Displays a Windows toast notification to the user. +.DESCRIPTION + Creates and displays a Windows toast notification with customizable + title, message, icon, and action buttons. Handles both system context + (via scheduled task) and user context (direct notification). + +.PARAMETER Title + The notification title. Defaults to "Winget-AutoUpdate". + +.PARAMETER Message + The main notification message text. + +.PARAMETER MessageType + The notification type (info, success, warning, error). + Determines the icon displayed. + +.PARAMETER Balise + Unique tag for the notification (for replacement logic). + +.PARAMETER OnClickAction + URL or action to execute when notification is clicked. + +.PARAMETER Body + Optional body text displayed below the message. + +.PARAMETER Button1Text + Text for the primary action button. + +.PARAMETER Button1Action + URL or action for the primary button. + +.PARAMETER ButtonDismiss + When specified, adds a dismiss button. + +.PARAMETER UserRun + When specified, forces notification display regardless of notification level. + +.EXAMPLE + Start-NotifTask -Title "Update Available" -Message "Firefox update ready" -MessageType "info" + +.NOTES + Respects WAU_NotificationLevel setting (Full, SuccessOnly, ErrorsOnly, None). + In system context, saves notification to XML and triggers scheduled task. +#> function Start-NotifTask { param( @@ -15,25 +60,26 @@ function Start-NotifTask { [Switch]$UserRun = $false ) + # Check if notification should be displayed based on notification level settings if (($WAUConfig.WAU_NotificationLevel -eq "Full") -or ($WAUConfig.WAU_NotificationLevel -eq "SuccessOnly" -and $MessageType -eq "Success") -or ($WAUConfig.WAU_NotificationLevel -eq "ErrorsOnly" -and $MessageType -eq "Error") -or ($UserRun)) { - # XML Toast template creation + # Create base XML toast template [xml]$ToastTemplate = New-Object system.Xml.XmlDocument $ToastTemplate.LoadXml("") - # Creation of visual node + # Create visual container node $XMLvisual = $ToastTemplate.CreateElement("visual") - # Creation of a binding node + # Create binding node with generic template $XMLbinding = $ToastTemplate.CreateElement("binding") $XMLvisual.AppendChild($XMLbinding) | Out-Null $XMLbindingAtt1 = ($ToastTemplate.CreateAttribute("template")) $XMLbindingAtt1.Value = "ToastGeneric" $XMLbinding.Attributes.Append($XMLbindingAtt1) | Out-Null + # Add icon image if available $XMLimagepath = "$WorkingDir\icons\$MessageType.png" if (Test-Path $XMLimagepath) { - # Creation of an image node $XMLimage = $ToastTemplate.CreateElement("image") $XMLbinding.AppendChild($XMLimage) | Out-Null $XMLimageAtt1 = $ToastTemplate.CreateAttribute("placement") @@ -44,32 +90,30 @@ function Start-NotifTask { $XMLimage.Attributes.Append($XMLimageAtt2) | Out-Null } + # Add title text if provided if ($Title) { - # Creation of a text node $XMLtitle = $ToastTemplate.CreateElement("text") $XMLtitleText = $ToastTemplate.CreateTextNode($Title) $XMLtitle.AppendChild($XMLtitleText) | Out-Null $XMLbinding.AppendChild($XMLtitle) | Out-Null } + # Add message text if provided if ($Message) { - # Creation of a text node $XMLtext = $ToastTemplate.CreateElement("text") $XMLtextText = $ToastTemplate.CreateTextNode($Message) $XMLtext.AppendChild($XMLtextText) | Out-Null $XMLbinding.AppendChild($XMLtext) | Out-Null } + # Add body text in a group/subgroup structure if provided if ($Body) { - # Creation of a group node $XMLgroup = $ToastTemplate.CreateElement("group") $XMLbinding.AppendChild($XMLgroup) | Out-Null - # Creation of a subgroup node $XMLsubgroup = $ToastTemplate.CreateElement("subgroup") $XMLgroup.AppendChild($XMLsubgroup) | Out-Null - # Creation of a text node $XMLcontent = $ToastTemplate.CreateElement("text") $XMLcontentText = $ToastTemplate.CreateTextNode($Body) $XMLcontent.AppendChild($XMLcontentText) | Out-Null @@ -82,11 +126,11 @@ function Start-NotifTask { $XMLcontent.Attributes.Append($XMLcontentAtt2) | Out-Null } - # Creation of actions node + # Create actions container for buttons $XMLactions = $ToastTemplate.CreateElement("actions") + # Add primary button if text is provided if ($Button1Text) { - # Creation of action node $XMLaction = $ToastTemplate.CreateElement("action") $XMLactions.AppendChild($XMLaction) | Out-Null $XMLactionAtt1 = $ToastTemplate.CreateAttribute("content") @@ -102,8 +146,8 @@ function Start-NotifTask { } } + # Add dismiss button if requested if ($ButtonDismiss) { - # Creation of action node $XMLaction = $ToastTemplate.CreateElement("action") $XMLactions.AppendChild($XMLaction) | Out-Null $XMLactionAtt1 = $ToastTemplate.CreateAttribute("content") @@ -117,54 +161,47 @@ function Start-NotifTask { $XMLaction.Attributes.Append($XMLactionAtt3) | Out-Null } - # Creation of tag node + # Add tag for notification identification/replacement $XMLtag = $ToastTemplate.CreateElement("tag") $XMLtagText = $ToastTemplate.CreateTextNode($Balise) $XMLtag.AppendChild($XMLtagText) | Out-Null - # Add the visual node to the xml + # Assemble the XML structure $ToastTemplate.LastChild.AppendChild($XMLvisual) | Out-Null $ToastTemplate.LastChild.AppendChild($XMLactions) | Out-Null $ToastTemplate.LastChild.AppendChild($XMLtag) | Out-Null + # Add click action if provided if ($OnClickAction) { $ToastTemplate.toast.SetAttribute("activationType", "Protocol") | Out-Null $ToastTemplate.toast.SetAttribute("launch", $OnClickAction) | Out-Null } - #if running as System, run Winget-AutoUpdate-Notify scheduled task + # Display notification based on execution context if ($IsSystem) { - - #Save XML to File + # System context: Save XML and trigger notification task $ToastTemplateLocation = "$($WAUConfig.InstallLocation)\config\" $ToastTemplate.Save("$ToastTemplateLocation\notif.xml") - #Run Notify scheduled task to notify conneted users + # Run scheduled task to display notification to logged-in users Get-ScheduledTask -TaskName "Winget-AutoUpdate-Notify" -ErrorAction SilentlyContinue | Start-ScheduledTask -ErrorAction SilentlyContinue - } - #else, run as connected user else { - - #Load Assemblies + # User context: Display notification directly [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null - #Prepare XML $ToastXml = [Windows.Data.Xml.Dom.XmlDocument]::New() $ToastXml.LoadXml($ToastTemplate.OuterXml) - #Specify Launcher App ID $LauncherID = "Windows.SystemToast.WAU.Notification" - #Prepare and Create Toast $ToastMessage = [Windows.UI.Notifications.ToastNotification]::New($ToastXml) $ToastMessage.Tag = $ToastTemplate.toast.tag [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($LauncherID).Show($ToastMessage) - } - #Wait for notification to display + # Wait for notification to display Start-Sleep 3 } diff --git a/Sources/Winget-AutoUpdate/functions/Test-ListPath.ps1 b/Sources/Winget-AutoUpdate/functions/Test-ListPath.ps1 index 88482317..ff7682e8 100644 --- a/Sources/Winget-AutoUpdate/functions/Test-ListPath.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Test-ListPath.ps1 @@ -1,94 +1,76 @@ -#Function to check Block/Allow List External Path +<# +.SYNOPSIS + Syncs app list from external source. -function Test-ListPath ($ListPath, $UseWhiteList, $WingetUpdatePath) { - # URL, UNC or Local Path - if ($UseWhiteList) { - $ListType = "included_apps.txt" - } - else { - $ListType = "excluded_apps.txt" - } +.DESCRIPTION + Downloads included/excluded apps list from URL, UNC, or local path if newer. - # Get local and external list paths - $LocalList = -join ($WingetUpdatePath, "\", $ListType) - $ExternalList = -join ($ListPath, "\", $ListType) +.PARAMETER ListPath + External path (URL, UNC, or local). - # Check if a list exists - if (Test-Path "$LocalList") { - $dateLocal = (Get-Item "$LocalList").LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss") - } +.PARAMETER UseWhiteList + True for included_apps.txt, false for excluded_apps.txt. - # If path is URL - if ($ListPath -like "http*") { - $ExternalList = -join ($ListPath, "/", $ListType) +.PARAMETER WingetUpdatePath + Local WAU installation directory. - # Test if $ListPath contains the character "?" (testing for SAS token) - if ($Listpath -match "\?") { - # Split the URL into two strings at the "?" substring - $splitPath = $ListPath.Split("`?") +.OUTPUTS + Boolean: True if updated, False otherwise. +#> +function Test-ListPath ($ListPath, $UseWhiteList, $WingetUpdatePath) { - # Assign the first string (up to "?") to the variable $resourceURI - $resourceURI = $splitPath[0] + $ListType = if ($UseWhiteList) { "included_apps.txt" } else { "excluded_apps.txt" } + $LocalList = Join-Path $WingetUpdatePath $ListType + $dateLocal = $null + if (Test-Path $LocalList) { + $dateLocal = (Get-Item $LocalList).LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss") + } - # Assign the second string (after "?" to the end) to the variable $sasToken - $sasToken = $splitPath[1] + # URL path + if ($ListPath -like "http*") { + $ExternalList = "$ListPath/$ListType" - # Join the parts and add "/$ListType?" in between the parts - $ExternalList = -join ($resourceURI, "/$ListType`?", $sasToken) + # Handle SAS token URLs + if ($ListPath -match "\?") { + $parts = $ListPath.Split("?") + $ExternalList = "$($parts[0])/$ListType`?$($parts[1])" } - $wc = New-Object System.Net.WebClient try { - $wc.OpenRead("$ExternalList").Close() | Out-Null + $wc = New-Object System.Net.WebClient + $wc.OpenRead($ExternalList).Close() | Out-Null $dateExternal = ([DateTime]$wc.ResponseHeaders['Last-Modified']).ToString("yyyy-MM-dd HH:mm:ss") - if ($dateExternal -and $dateExternal -gt $dateLocal) { - try { - $wc.DownloadFile($ExternalList, $LocalList) - } - catch { - $Script:ReachNoPath = $True - return $False - } + + if (-not $dateLocal -or $dateExternal -gt $dateLocal) { + $wc.DownloadFile($ExternalList, $LocalList) return $true } } catch { try { - $wc.OpenRead("$ExternalList").Close() | Out-Null $wc.DownloadFile($ExternalList, $LocalList) - $Script:AlwaysDownloaded = $True + $Script:AlwaysDownloaded = $true return $true } catch { - $Script:ReachNoPath = $True - return $False + $Script:ReachNoPath = $true + return $false } } } - # If path is UNC or local + # UNC or local path else { - if (Test-Path -Path $ExternalList) { - try { - $dateExternal = (Get-Item "$ExternalList").LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss") - } - catch { - $Script:ReachNoPath = $True - return $False - } - if ($dateExternal -gt $dateLocal) { - try { - Copy-Item $ExternalList -Destination $LocalList -Force - } - catch { - $Script:ReachNoPath = $True - return $False - } - return $True + $ExternalList = Join-Path $ListPath $ListType + if (Test-Path $ExternalList) { + $dateExternal = (Get-Item $ExternalList).LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss") + if (-not $dateLocal -or $dateExternal -gt $dateLocal) { + Copy-Item $ExternalList -Destination $LocalList -Force + return $true } } else { - $Script:ReachNoPath = $True - return $False + $Script:ReachNoPath = $true + return $false } } } diff --git a/Sources/Winget-AutoUpdate/functions/Test-Mods.ps1 b/Sources/Winget-AutoUpdate/functions/Test-Mods.ps1 index a3c6938e..bd8c93a9 100644 --- a/Sources/Winget-AutoUpdate/functions/Test-Mods.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Test-Mods.ps1 @@ -1,47 +1,58 @@ -#Function to check if modification exists within 'mods' directory +<# +.SYNOPSIS + Checks for application modification scripts in the mods folder. -function Test-Mods ($app) { +.DESCRIPTION + Searches for app-specific scripts that customize the install/upgrade process. + Hooks: preinstall, override, custom, arguments, upgrade, install, installed, notinstalled. + +.PARAMETER app + The WinGet application ID to check. - #Takes care of a null situation - $ModsPreInstall = $null - $ModsOverride = $null - $ModsCustom = $null - $ModsUpgrade = $null - $ModsInstall = $null - $ModsInstalled = $null - $ModsNotInstalled = $null +.OUTPUTS + Array: [PreInstall, Override, Custom, Arguments, Upgrade, Install, Installed, NotInstalled] +#> +function Test-Mods ($app) { $Mods = "$WorkingDir\mods" + $result = @{ + PreInstall = $null + Override = $null + Custom = $null + Arguments = $null + Upgrade = $null + Install = $null + Installed = $null + NotInstalled = $null + } + # Global fallback for failed installs if (Test-Path "$Mods\_WAU-notinstalled.ps1") { - $ModsNotInstalled = "$Mods\_WAU-notinstalled.ps1" + $result.NotInstalled = "$Mods\_WAU-notinstalled.ps1" } + # App-specific mods if (Test-Path "$Mods\$app-*") { - if (Test-Path "$Mods\$app-preinstall.ps1") { - $ModsPreInstall = "$Mods\$app-preinstall.ps1" - } - if (Test-Path "$Mods\$app-override.txt") { - $ModsOverride = (Get-Content "$Mods\$app-override.txt" -Raw).Trim() - } - if (Test-Path "$Mods\$app-custom.txt") { - $ModsCustom = (Get-Content "$Mods\$app-custom.txt" -Raw).Trim() + if (Test-Path "$Mods\$app-preinstall.ps1") { $result.PreInstall = "$Mods\$app-preinstall.ps1" } + if (Test-Path "$Mods\$app-override.txt") { $result.Override = (Get-Content "$Mods\$app-override.txt" -Raw).Trim() } + if (Test-Path "$Mods\$app-custom.txt") { $result.Custom = (Get-Content "$Mods\$app-custom.txt" -Raw).Trim() } + if (Test-Path "$Mods\$app-arguments.txt") { + # Read file and filter out comments and empty lines + $lines = Get-Content "$Mods\$app-arguments.txt" | Where-Object { + $_.Trim() -ne "" -and -not $_.TrimStart().StartsWith("#") + } + if ($lines) { + $result.Arguments = ($lines -join " ").Trim() + } } if (Test-Path "$Mods\$app-install.ps1") { - $ModsInstall = "$Mods\$app-install.ps1" - $ModsUpgrade = "$Mods\$app-install.ps1" - } - if (Test-Path "$Mods\$app-upgrade.ps1") { - $ModsUpgrade = "$Mods\$app-upgrade.ps1" - } - if (Test-Path "$Mods\$app-installed.ps1") { - $ModsInstalled = "$Mods\$app-installed.ps1" - } - if (Test-Path "$Mods\$app-notinstalled.ps1") { - $ModsNotInstalled = "$Mods\$app-notinstalled.ps1" + $result.Install = "$Mods\$app-install.ps1" + $result.Upgrade = "$Mods\$app-install.ps1" } + if (Test-Path "$Mods\$app-upgrade.ps1") { $result.Upgrade = "$Mods\$app-upgrade.ps1" } + if (Test-Path "$Mods\$app-installed.ps1") { $result.Installed = "$Mods\$app-installed.ps1" } + if (Test-Path "$Mods\$app-notinstalled.ps1") { $result.NotInstalled = "$Mods\$app-notinstalled.ps1" } } - return $ModsPreInstall, $ModsOverride, $ModsCustom, $ModsUpgrade, $ModsInstall, $ModsInstalled, $ModsNotInstalled - + return $result.PreInstall, $result.Override, $result.Custom, $result.Arguments, $result.Upgrade, $result.Install, $result.Installed, $result.NotInstalled } diff --git a/Sources/Winget-AutoUpdate/functions/Test-ModsPath.ps1 b/Sources/Winget-AutoUpdate/functions/Test-ModsPath.ps1 index f917757b..4ce7b39f 100644 --- a/Sources/Winget-AutoUpdate/functions/Test-ModsPath.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Test-ModsPath.ps1 @@ -1,21 +1,52 @@ -#Function to check mods External Path +<# +.SYNOPSIS + Syncs modification scripts from an external source. +.DESCRIPTION + Compares local mods folder with an external source and syncs changes. + Supports three source types: + - HTTP/HTTPS URLs (requires directory listing) + - Azure Blob Storage (uses AzCopy) + - UNC/local paths + + Downloads newer files and removes local files that don't exist externally. + +.PARAMETER ModsPath + The external mods path (URL, "AzureBlob", UNC, or local path). + +.PARAMETER WingetUpdatePath + The local WAU installation directory. + +.PARAMETER AzureBlobSASURL + Optional Azure Blob Storage URL with SAS token for AzureBlob mode. + +.OUTPUTS + Array: [ModsUpdated count, DeletedMods count] + +.EXAMPLE + $result = Test-ModsPath "https://myserver.com/mods" "C:\Program Files\Winget-AutoUpdate" + +.NOTES + Sets script-scoped $ReachNoPath to True if external path is unreachable. + Handles both .ps1/.txt mod files and .exe binaries in bins subfolder. +#> function Test-ModsPath ($ModsPath, $WingetUpdatePath, $AzureBlobSASURL) { - # URL, UNC or Local Path - # Get local and external Mods paths + + # Build local and external paths $LocalMods = -join ($WingetUpdatePath, "\", "mods") $ExternalMods = "$ModsPath" - #Get File Names Locally + # Get list of local mod files and binaries $InternalModsNames = Get-ChildItem -Path $LocalMods -Name -Recurse -Include *.ps1, *.txt $InternalBinsNames = Get-ChildItem -Path $LocalMods"\bins" -Name -Recurse -Include *.exe - # If path is URL + # === Handle HTTP/HTTPS URLs === if ($ExternalMods -like "http*") { - # ADD TLS 1.2 and TLS 1.1 to list of currently used protocols - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12; #DevSkim: ignore DS440020,DS440020 Hard-coded SSL/TLS Protocol - [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls11; #DevSkim: ignore DS440020,DS440020 Hard-coded SSL/TLS Protocol - #Get Index of $ExternalMods (or index page with href listing of all the Mods) + # Enable TLS 1.2 and TLS 1.1 for secure connections + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 #DevSkim: ignore DS440020,DS440020 Hard-coded SSL/TLS Protocol + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls11 #DevSkim: ignore DS440020,DS440020 Hard-coded SSL/TLS Protocol + + # Get directory listing from web server try { $WebResponse = Invoke-WebRequest -Uri $ExternalMods -UseBasicParsing } @@ -24,15 +55,16 @@ function Test-ModsPath ($ModsPath, $WingetUpdatePath, $AzureBlobSASURL) { return $False } - #Check for bins, download if newer. Delete if not external + # --- Handle bins subfolder (executables) --- $ExternalBins = "$ModsPath/bins" if ($WebResponse -match "bins/") { $BinResponse = Invoke-WebRequest -Uri $ExternalBins -UseBasicParsing - # Collect the external list of href links $BinLinks = $BinResponse.Links | Select-Object -ExpandProperty HREF - #If there's a directory path in the HREF:s, delete it (IIS) + + # Clean directory paths from HREFs (IIS compatibility) $CleanBinLinks = $BinLinks -replace "/.*/", "" - #Modify strings to HREF:s + + # Build HREF strings for comparison $index = 0 foreach ($Bin in $CleanBinLinks) { if ($Bin) { @@ -40,21 +72,19 @@ function Test-ModsPath ($ModsPath, $WingetUpdatePath, $AzureBlobSASURL) { } $index++ } - #Delete Local Bins that don't exist Externally - $index = 0 + + # Delete local bins that don't exist externally $CleanLinks = $BinLinks -replace "/.*/", "" foreach ($Bin in $InternalBinsNames) { If ($CleanLinks -notcontains "$Bin") { Remove-Item $LocalMods\bins\$Bin -Force -ErrorAction SilentlyContinue | Out-Null } - $index++ } + + # Download newer external bins $CleanBinLinks = $BinLinks -replace "/.*/", "" - $Bin = "" - #Loop through all links $wc = New-Object System.Net.WebClient $CleanBinLinks | ForEach-Object { - #Check for .exe in listing/HREF:s in an index page pointing to .exe if ($_ -like "*.exe") { $dateExternalBin = "" $dateLocalBin = "" @@ -71,13 +101,11 @@ function Test-ModsPath ($ModsPath, $WingetUpdatePath, $AzureBlobSASURL) { } } - # Collect the external list of href links + # --- Handle mod files (.ps1, .txt) --- $ModLinks = $WebResponse.Links | Select-Object -ExpandProperty HREF - - #If there's a directory path in the HREF:s, delete it (IIS) $CleanLinks = $ModLinks -replace "/.*/", "" - #Modify strings to HREF:s + # Build HREF strings for comparison $index = 0 foreach ($Mod in $CleanLinks) { if ($Mod) { @@ -86,24 +114,20 @@ function Test-ModsPath ($ModsPath, $WingetUpdatePath, $AzureBlobSASURL) { $index++ } - #Delete Local Mods that don't exist Externally + # Delete local mods that don't exist externally $DeletedMods = 0 - $index = 0 $CleanLinks = $ModLinks -replace "/.*/", "" foreach ($Mod in $InternalModsNames) { If ($CleanLinks -notcontains "$Mod") { Remove-Item $LocalMods\$Mod -Force -ErrorAction SilentlyContinue | Out-Null $DeletedMods++ } - $index++ } + # Download newer external mods $CleanLinks = $ModLinks -replace "/.*/", "" - - #Loop through all links $wc = New-Object System.Net.WebClient $CleanLinks | ForEach-Object { - #Check for .ps1/.txt in listing/HREF:s in an index page pointing to .ps1/.txt if (($_ -like "*.ps1") -or ($_ -like "*.txt")) { try { $dateExternalMod = "" @@ -135,25 +159,28 @@ function Test-ModsPath ($ModsPath, $WingetUpdatePath, $AzureBlobSASURL) { } return $ModsUpdated, $DeletedMods } - # If Path is Azure Blob + # === Handle Azure Blob Storage === elseif ($ExternalMods -like "AzureBlob") { Write-ToLog "Azure Blob Storage set as mod source" Write-ToLog "Checking AZCopy" Get-AZCopy $WingetUpdatePath - #Safety check to make sure we really do have azcopy.exe and a Blob URL + + # Verify AzCopy and SAS URL are available if ((Test-Path -Path "$WingetUpdatePath\azcopy.exe" -PathType Leaf) -and ($null -ne $AzureBlobSASURL)) { Write-ToLog "Syncing Blob storage with local storage" + # Run AzCopy sync with delete option $AZCopySyncOutput = & $WingetUpdatePath\azcopy.exe sync "$AzureBlobSASURL" "$LocalMods" --from-to BlobLocal --delete-destination=true $AZCopyOutputLines = $AZCopySyncOutput.Split([Environment]::NewLine) - foreach ( $_ in $AZCopyOutputLines) { + # Parse AzCopy output for statistics + foreach ($line in $AZCopyOutputLines) { $AZCopySyncAdditionsRegex = [regex]::new("(?<=Number of Copy Transfers Completed:\s+)\d+") $AZCopySyncDeletionsRegex = [regex]::new("(?<=Number of Deletions at Destination:\s+)\d+") $AZCopySyncErrorRegex = [regex]::new("^Cannot perform sync due to error:") - $AZCopyAdditions = [int] $AZCopySyncAdditionsRegex.Match($_).Value - $AZCopyDeletions = [int] $AZCopySyncDeletionsRegex.Match($_).Value + $AZCopyAdditions = [int] $AZCopySyncAdditionsRegex.Match($line).Value + $AZCopyDeletions = [int] $AZCopySyncDeletionsRegex.Match($line).Value if ($AZCopyAdditions -ne 0) { $ModsUpdated = $AZCopyAdditions @@ -163,8 +190,8 @@ function Test-ModsPath ($ModsPath, $WingetUpdatePath, $AzureBlobSASURL) { $DeletedMods = $AZCopyDeletions } - if ($AZCopySyncErrorRegex.Match($_).Value) { - Write-ToLog "AZCopy Sync Error! $_" + if ($AZCopySyncErrorRegex.Match($line).Value) { + Write-ToLog "AZCopy Sync Error! $line" } } } @@ -174,18 +201,21 @@ function Test-ModsPath ($ModsPath, $WingetUpdatePath, $AzureBlobSASURL) { return $ModsUpdated, $DeletedMods } - # If path is UNC or local + # === Handle UNC or local paths === else { + # --- Handle bins subfolder --- $ExternalBins = "$ModsPath\bins" if (Test-Path -Path $ExternalBins"\*.exe") { $ExternalBinsNames = Get-ChildItem -Path $ExternalBins -Name -Recurse -Include *.exe - #Delete Local Bins that don't exist Externally + + # Delete local bins that don't exist externally foreach ($Bin in $InternalBinsNames) { If ($Bin -notin $ExternalBinsNames ) { Remove-Item $LocalMods\bins\$Bin -Force -ErrorAction SilentlyContinue | Out-Null } } - #Copy newer external bins + + # Copy newer external bins foreach ($Bin in $ExternalBinsNames) { $dateExternalBin = "" $dateLocalBin = "" @@ -199,11 +229,11 @@ function Test-ModsPath ($ModsPath, $WingetUpdatePath, $AzureBlobSASURL) { } } + # --- Handle mod files --- if ((Test-Path -Path $ExternalMods"\*.ps1") -or (Test-Path -Path $ExternalMods"\*.txt")) { - #Get File Names Externally $ExternalModsNames = Get-ChildItem -Path $ExternalMods -Name -Recurse -Include *.ps1, *.txt - #Delete Local Mods that don't exist Externally + # Delete local mods that don't exist externally $DeletedMods = 0 foreach ($Mod in $InternalModsNames) { If ($Mod -notin $ExternalModsNames ) { @@ -212,7 +242,7 @@ function Test-ModsPath ($ModsPath, $WingetUpdatePath, $AzureBlobSASURL) { } } - #Copy newer external mods + # Copy newer external mods foreach ($Mod in $ExternalModsNames) { $dateExternalMod = "" $dateLocalMod = "" diff --git a/Sources/Winget-AutoUpdate/functions/Test-Network.ps1 b/Sources/Winget-AutoUpdate/functions/Test-Network.ps1 index 1321b4cc..2a22ff3a 100644 --- a/Sources/Winget-AutoUpdate/functions/Test-Network.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Test-Network.ps1 @@ -1,18 +1,24 @@ -#Function to check the connectivity +<# +.SYNOPSIS + Tests internet connectivity and metered connection status. -function Test-Network { +.DESCRIPTION + Verifies network using NCSI probes with 30-minute timeout. + Respects WAU_DoNotRunOnMetered setting. - # Init - $timeout = 0 +.OUTPUTS + Boolean: True if connected and allowed to proceed. +#> +function Test-Network { - #Test connectivity during 30 min then timeout Write-ToLog "Checking internet connection..." "Yellow" + # Get NCSI settings try { $NlaRegKey = "HKLM:\SYSTEM\CurrentControlSet\Services\NlaSvc\Parameters\Internet" - $ncsiHost = Get-ItemPropertyValue -Path $NlaRegKey -Name ActiveWebProbeHost - $ncsiPath = Get-ItemPropertyValue -Path $NlaRegKey -Name ActiveWebProbePath - $ncsiContent = Get-ItemPropertyValue -Path $NlaRegKey -Name ActiveWebProbeContent + $ncsiHost = Get-ItemPropertyValue $NlaRegKey -Name ActiveWebProbeHost + $ncsiPath = Get-ItemPropertyValue $NlaRegKey -Name ActiveWebProbePath + $ncsiContent = Get-ItemPropertyValue $NlaRegKey -Name ActiveWebProbeContent } catch { $ncsiHost = "www.msftconnecttest.com" @@ -20,71 +26,53 @@ function Test-Network { $ncsiContent = "Microsoft Connect Test" } - while ($timeout -lt 1800) { + # Test connectivity (30 min timeout) + for ($timeout = 0; $timeout -lt 1800; $timeout += 10) { try { - $ncsiResponse = Invoke-WebRequest -Uri "http://$($ncsiHost)/$($ncsiPath)" -UseBasicParsing -UserAgent ([Microsoft.PowerShell.Commands.PSUserAgent]::Chrome); # DevSkim: ignore DS137138 Insecure URL - } - catch { - $ncsiResponse = $false + $response = Invoke-WebRequest -Uri "http://${ncsiHost}/${ncsiPath}" -UseBasicParsing -UserAgent ([Microsoft.PowerShell.Commands.PSUserAgent]::Chrome) # DevSkim: ignore DS137138 } + catch { $response = $null } - if (($ncsiResponse) -and ($ncsiResponse.StatusCode -eq 200) -and ($ncsiResponse.content -eq $ncsiContent)) { - Write-ToLog "Connected !" "Green" + if ($response -and $response.StatusCode -eq 200 -and $response.Content -eq $ncsiContent) { + Write-ToLog "Connected!" "Green" - # Check for metered connection + # Check metered connection try { [void][Windows.Networking.Connectivity.NetworkInformation, Windows, ContentType = WindowsRuntime] $cost = [Windows.Networking.Connectivity.NetworkInformation]::GetInternetConnectionProfile().GetConnectionCost() - - $networkCostTypeName = [Windows.Networking.Connectivity.NetworkCostType]::GetName( - [Windows.Networking.Connectivity.NetworkCostType], - $cost.NetworkCostType - ) + $networkCostTypeName = [Windows.Networking.Connectivity.NetworkCostType]::GetName([Windows.Networking.Connectivity.NetworkCostType], $cost.NetworkCostType) } catch { - Write-ToLog "Could not evaluate metered connection status - skipping check." "Gray" + Write-ToLog "Could not check metered status - continuing" "Gray" return $true } - if ($cost.ApproachingDataLimit -or $cost.OverDataLimit -or $cost.Roaming -or $cost.BackgroundDataUsageRestricted -or ($networkCostTypeName -ne "Unrestricted")) { - Write-ToLog "Metered connection detected." "Yellow" + $isMetered = $cost.ApproachingDataLimit -or $cost.OverDataLimit -or $cost.Roaming -or $cost.BackgroundDataUsageRestricted -or ($networkCostTypeName -ne "Unrestricted" -and $networkCostTypeName -ne "Unknown") + if ($isMetered) { + Write-ToLog "Metered connection detected." "Yellow" if ($WAUConfig.WAU_DoNotRunOnMetered -eq 1) { - Write-ToLog "WAU is configured to bypass update checking on metered connection" + Write-ToLog "WAU configured to skip on metered connection" return $false } - else { - Write-ToLog "WAU is configured to force update checking on metered connection" - return $true - } - } - else { - return $true + Write-ToLog "WAU configured to continue on metered connection" } + return $true } - else { - Start-Sleep 10 - $timeout += 10 - if ($timeout -eq 300) { - Write-ToLog "Notify 'No connection' sent." "Yellow" + Start-Sleep 10 - $Title = $NotifLocale.local.outputs.output[0].title - $Message = $NotifLocale.local.outputs.output[0].message - $MessageType = "warning" - $Balise = "Connection" - Start-NotifTask -Title $Title -Message $Message -MessageType $MessageType -Balise $Balise - } + # Notify after 5 minutes + if ($timeout -eq 300) { + Write-ToLog "No connection notification sent." "Yellow" + Start-NotifTask -Title $NotifLocale.local.outputs.output[0].title ` + -Message $NotifLocale.local.outputs.output[0].message -MessageType "warning" -Balise "Connection" } } - Write-ToLog "Timeout. No internet connection !" "Red" - - $Title = $NotifLocale.local.outputs.output[1].title - $Message = $NotifLocale.local.outputs.output[1].message - $MessageType = "error" - $Balise = "Connection" - Start-NotifTask -Title $Title -Message $Message -MessageType $MessageType -Balise $Balise - + # Timeout + Write-ToLog "Timeout - No internet connection!" "Red" + Start-NotifTask -Title $NotifLocale.local.outputs.output[1].title ` + -Message $NotifLocale.local.outputs.output[1].message -MessageType "error" -Balise "Connection" return $false -} \ No newline at end of file +} diff --git a/Sources/Winget-AutoUpdate/functions/Test-PendingReboot.ps1 b/Sources/Winget-AutoUpdate/functions/Test-PendingReboot.ps1 index 962d30bd..34909d82 100644 --- a/Sources/Winget-AutoUpdate/functions/Test-PendingReboot.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Test-PendingReboot.ps1 @@ -1,25 +1,35 @@ -#Function to check if there is a Pending Reboot +<# +.SYNOPSIS + Checks for pending Windows reboot. +.OUTPUTS + Boolean: True if reboot pending. +#> function Test-PendingReboot { $Computer = $env:COMPUTERNAME - $PendingReboot = $false + $HKLM = [UInt32]"0x80000002" + $WMI = [WMIClass]"\\$Computer\root\default:StdRegProv" - $HKLM = [UInt32] "0x80000002" - $WMI_Reg = [WMIClass] "\\$Computer\root\default:StdRegProv" + if (-not $WMI) { return $false } - if ($WMI_Reg) { - if (($WMI_Reg.EnumKey($HKLM, "SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\")).sNames -contains 'RebootPending') { $PendingReboot = $true } - if (($WMI_Reg.EnumKey($HKLM, "SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\")).sNames -contains 'RebootRequired') { $PendingReboot = $true } - - #Checking for SCCM namespace (can't get it done with Get-CimInstance, using deprecated Get-WmiObject) - $SCCM_Namespace = Get-WmiObject -Namespace ROOT\CCM\ClientSDK -List -ComputerName $Computer -ErrorAction Ignore - if ($SCCM_Namespace) { - if (([WmiClass]"\\$Computer\ROOT\CCM\ClientSDK:CCM_ClientUtilities").DetermineIfRebootPending().RebootPending -eq $true) { $PendingReboot = $true } - } + # Check CBS + if (($WMI.EnumKey($HKLM, "SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\")).sNames -contains 'RebootPending') { + return $true + } + # Check Windows Update + if (($WMI.EnumKey($HKLM, "SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\")).sNames -contains 'RebootRequired') { + return $true } - return $PendingReboot + # Check SCCM + $SCCM = Get-WmiObject -Namespace ROOT\CCM\ClientSDK -List -ComputerName $Computer -ErrorAction Ignore + if ($SCCM) { + if (([WmiClass]"\\$Computer\ROOT\CCM\ClientSDK:CCM_ClientUtilities").DetermineIfRebootPending().RebootPending) { + return $true + } + } + return $false } diff --git a/Sources/Winget-AutoUpdate/functions/Test-WAUMods.ps1 b/Sources/Winget-AutoUpdate/functions/Test-WAUMods.ps1 index aaf34691..59e0eade 100644 --- a/Sources/Winget-AutoUpdate/functions/Test-WAUMods.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Test-WAUMods.ps1 @@ -1,3 +1,35 @@ +<# +.SYNOPSIS + Executes and processes WAU mods script with action-based responses. + +.DESCRIPTION + Runs the _WAU-mods.ps1 script and handles its output to control WAU behavior. + Supports two modes: + - Legacy: Exit code 1 triggers WAU re-run + - Action-based: JSON output with instructions (Rerun, Abort, Postpone, Reboot, Continue) + +.PARAMETER WorkingDir + The WAU installation directory. + +.PARAMETER WAUConfig + The WAU configuration object. + +.PARAMETER GitHub_Repo + The GitHub repository name for WAU. Defaults to "Winget-AutoUpdate". + +.EXAMPLE + Test-WAUMods -WorkingDir "C:\Program Files\Winget-AutoUpdate" -WAUConfig $config + +.NOTES + Supported JSON actions: + - Rerun: Restart WAU immediately + - Abort: Stop WAU execution + - Postpone: Create scheduled task to run WAU later + - Reboot: Trigger system reboot (supports SCCM integration) + - Continue: Continue normal WAU execution + + JSON format: { "Action": "Rerun", "Message": "text", "ExitCode": 0 } +#> function Test-WAUMods { param ( [Parameter(Mandatory=$true)] diff --git a/Sources/Winget-AutoUpdate/functions/Update-App.ps1 b/Sources/Winget-AutoUpdate/functions/Update-App.ps1 index d3e48580..c6850c79 100644 --- a/Sources/Winget-AutoUpdate/functions/Update-App.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Update-App.ps1 @@ -1,201 +1,115 @@ -#Function to update an App +<# +.SYNOPSIS + Updates a single application using WinGet. +.DESCRIPTION + Performs the complete update process: notification, pre-install mods, + WinGet upgrade/install with retry logic, post-install mods, result notification. + +.PARAMETER app + PSCustomObject with Name, Id, Version, AvailableVersion properties. +#> Function Update-App ($app) { - #Get App Info - $ReleaseNoteURL = Get-AppInfo $app.Id - if ($ReleaseNoteURL) { - $Button1Text = $NotifLocale.local.outputs.output[10].message + # Helper function to build winget command parameters + function Get-WingetParams ($Command, $ModsOverride, $ModsCustom, $ModsArguments) { + $params = @($Command, "--id", $app.Id, "-e", "--accept-package-agreements", "--accept-source-agreements", "-s", "winget") + if ($Command -eq "install") { $params += "--force" } + + if ($ModsOverride) { + return @{ Params = $params + @("--override", $ModsOverride); Log = "$Command (override): $ModsOverride" } + } + elseif ($ModsCustom) { + return @{ Params = $params + @("-h", "--custom", $ModsCustom); Log = "$Command (custom): $ModsCustom" } + } + elseif ($ModsArguments) { + # Parse arguments respecting quotes and spaces + $argArray = ConvertTo-WingetArgumentArray $ModsArguments + return @{ Params = $params + $argArray + @("-h"); Log = "$Command (arguments): $ModsArguments" } + } + return @{ Params = $params + "-h"; Log = $Command } } - #Send available update notification + # Get release notes for notification button + $ReleaseNoteURL = Get-AppInfo $app.Id + $Button1Text = if ($ReleaseNoteURL) { $NotifLocale.local.outputs.output[10].message } else { $null } + + # Send "updating" notification Write-ToLog "Updating $($app.Name) from $($app.Version) to $($app.AvailableVersion)..." "Cyan" - $Title = $NotifLocale.local.outputs.output[2].title -f $($app.Name) - $Message = $NotifLocale.local.outputs.output[2].message -f $($app.Version), $($app.AvailableVersion) - $MessageType = "info" - $Balise = $($app.Name) - Start-NotifTask -Title $Title -Message $Message -MessageType $MessageType -Balise $Balise -Button1Action $ReleaseNoteURL -Button1Text $Button1Text + Start-NotifTask -Title ($NotifLocale.local.outputs.output[2].title -f $app.Name) ` + -Message ($NotifLocale.local.outputs.output[2].message -f $app.Version, $app.AvailableVersion) ` + -MessageType "info" -Balise $app.Name -Button1Action $ReleaseNoteURL -Button1Text $Button1Text - #Check if mods exist for preinstall/override/upgrade/install/installed/notinstalled - $ModsPreInstall, $ModsOverride, $ModsCustom, $ModsUpgrade, $ModsInstall, $ModsInstalled, $ModsNotInstalled = Test-Mods $($app.Id) + # Load mods + $ModsPreInstall, $ModsOverride, $ModsCustom, $ModsArguments, $ModsUpgrade, $ModsInstall, $ModsInstalled, $ModsNotInstalled = Test-Mods $app.Id - #Winget upgrade - Write-ToLog "########## WINGET UPGRADE PROCESS STARTS FOR APPLICATION ID '$($App.Id)' ##########" "Gray" + Write-ToLog "########## WINGET UPGRADE: $($app.Id) ##########" "Gray" - #If PreInstall script exist + # Pre-install mod if ($ModsPreInstall) { - Write-ToLog "Modifications for $($app.Id) before upgrade are being applied..." "DarkYellow" - $preInstallResult = & "$ModsPreInstall" - if ($preInstallResult -eq $false) { - Write-ToLog "PreInstall script for $($app.Id) requested to skip this update" "Yellow" - continue # Skip to next app in the parent loop + Write-ToLog "Running pre-install mod for $($app.Id)..." "DarkYellow" + if ((& $ModsPreInstall) -eq $false) { + Write-ToLog "Pre-install requested skip" "Yellow" + return } } - # Define upgrade base parameters - $baseParams = @( - "upgrade", - "--id", "$($app.Id)", - "-e", - "--accept-package-agreements", - "--accept-source-agreements", - "-s", "winget" - ) - - # Define base log message - $baseLogMessage = "Winget upgrade --id $($app.Id) -e --accept-package-agreements --accept-source-agreements -s winget" - - # Determine which parameters and log message to use - if ($ModsOverride) { - $allParams = $baseParams + @("--override", "$ModsOverride") - $logPrefix = "Running (overriding default):" - $logSuffix = "--override $ModsOverride" - } - elseif ($ModsCustom) { - $allParams = $baseParams + @("-h", "--custom", "$ModsCustom") - $logPrefix = "Running (customizing default):" - $logSuffix = "-h --custom $ModsCustom" - } - else { - $allParams = $baseParams + @("-h") - $logPrefix = "Running:" - $logSuffix = "-h" - } - - # Build the log message - $logMessage = "$logPrefix $baseLogMessage $logSuffix" - - # Log the command - Write-ToLog "-> $logMessage" - - # Execute command and log results - & $Winget $allParams | Where-Object { $_ -notlike " *" } | - Tee-Object -file $LogFile -Append + # Try upgrade first + $cmd = Get-WingetParams "upgrade" $ModsOverride $ModsCustom $ModsArguments + Write-ToLog "-> $($cmd.Log)" + & $Winget $cmd.Params | Where-Object { $_ -notlike " *" } | Tee-Object -file $LogFile -Append if ($ModsUpgrade) { - Write-ToLog "Modifications for $($app.Id) during upgrade are being applied..." "DarkYellow" - & "$ModsUpgrade" + Write-ToLog "Running upgrade mod..." "DarkYellow" + & $ModsUpgrade } - #Check if application updated properly - $ConfirmInstall = Confirm-Installation $($app.Id) $($app.AvailableVersion) - - if ($ConfirmInstall -ne $true) { - #Upgrade failed! - #Test for a Pending Reboot (Component Based Servicing/WindowsUpdate/CCM_ClientUtilities) - $PendingReboot = Test-PendingReboot - if ($PendingReboot -eq $true) { - Write-ToLog "-> A Pending Reboot lingers and probably prohibited $($app.Name) from upgrading...`n-> ...limiting to 1 install attempt instead of 2" "Yellow" - $retry = 2 - } - else { - #If app failed to upgrade, run Install command (2 tries max - some apps get uninstalled after single "Install" command.) - $retry = 1 - } - - While (($ConfirmInstall -eq $false) -and ($retry -le 2)) { - - Write-ToLog "-> An upgrade for $($app.Name) failed, now trying an install instead... ($retry/2)" "DarkYellow" - - # Define install base parameters - $baseParams = @( - "install", - "--id", "$($app.Id)", - "-e", - "--accept-package-agreements", - "--accept-source-agreements", - "-s", "winget", - "--force" - ) - - # Define base log message - $baseLogMessage = "Winget install --id $($app.Id) -e --accept-package-agreements --accept-source-agreements -s winget --force" - - # Determine which parameters and log message to use - if ($ModsOverride) { - $allParams = $baseParams + @("--override", "$ModsOverride") - $logPrefix = "Running (overriding default):" - $logSuffix = "--override $ModsOverride" - } - elseif ($ModsCustom) { - $allParams = $baseParams + @("-h", "--custom", "$ModsCustom") - $logPrefix = "Running (customizing default):" - $logSuffix = "-h --custom $ModsCustom" - } - else { - $allParams = $baseParams + @("-h") - $logPrefix = "Running:" - $logSuffix = "-h" - } + $ConfirmInstall = Confirm-Installation $app.Id $app.AvailableVersion - # Build the log message - $logMessage = "$logPrefix $baseLogMessage $logSuffix" + # Fallback to install if upgrade failed + if (-not $ConfirmInstall) { + $maxRetry = if (Test-PendingReboot) { Write-ToLog "-> Pending reboot detected, limiting retries" "Yellow"; 1 } else { 2 } - # Log the command - Write-ToLog "-> $logMessage" + for ($retry = 1; $retry -le $maxRetry -and -not $ConfirmInstall; $retry++) { + Write-ToLog "-> Upgrade failed, trying install ($retry/$maxRetry)..." "DarkYellow" - # Execute command and log results - & $Winget $allParams | Where-Object { $_ -notlike " *" } | - Tee-Object -file $LogFile -Append + $cmd = Get-WingetParams "install" $ModsOverride $ModsCustom $ModsArguments + Write-ToLog "-> $($cmd.Log)" + & $Winget $cmd.Params | Where-Object { $_ -notlike " *" } | Tee-Object -file $LogFile -Append if ($ModsInstall) { - Write-ToLog "Modifications for $($app.Id) during install are being applied..." "DarkYellow" - & "$ModsInstall" + Write-ToLog "Running install mod..." "DarkYellow" + & $ModsInstall } - #Check if application installed properly - $ConfirmInstall = Confirm-Installation $($app.Id) $($app.AvailableVersion) - $retry += 1 + $ConfirmInstall = Confirm-Installation $app.Id $app.AvailableVersion } } - switch ($ConfirmInstall) { - # Upgrade/install was successful - $true { - if ($ModsInstalled) { - Write-ToLog "Modifications for $($app.Id) after upgrade/install are being applied..." "DarkYellow" - & "$ModsInstalled" - } - } - # Upgrade/install was unsuccessful - $false { - if ($ModsNotInstalled) { - Write-ToLog "Modifications for $($app.Id) after a failed upgrade/install are being applied..." "DarkYellow" - & "$ModsNotInstalled" - } - } + # Post-install mods + if ($ConfirmInstall -and $ModsInstalled) { + Write-ToLog "Running post-install mod..." "DarkYellow" + & $ModsInstalled + } + elseif (-not $ConfirmInstall -and $ModsNotInstalled) { + Write-ToLog "Running failure mod..." "DarkYellow" + & $ModsNotInstalled } - Write-ToLog "########## WINGET UPGRADE PROCESS FINISHED FOR APPLICATION ID '$($App.Id)' ##########" "Gray" - - #Notify installation - if ($ConfirmInstall -eq $true) { - - #Send success updated app notification - Write-ToLog "$($app.Name) updated to $($app.AvailableVersion) !" "Green" - - #Send Notif - $Title = $NotifLocale.local.outputs.output[3].title -f $($app.Name) - $Message = $NotifLocale.local.outputs.output[3].message -f $($app.AvailableVersion) - $MessageType = "success" - $Balise = $($app.Name) - Start-NotifTask -Title $Title -Message $Message -MessageType $MessageType -Balise $Balise -Button1Action $ReleaseNoteURL -Button1Text $Button1Text + Write-ToLog "########## FINISHED: $($app.Id) ##########" "Gray" + # Result notification + if ($ConfirmInstall) { + Write-ToLog "$($app.Name) updated to $($app.AvailableVersion)!" "Green" + Start-NotifTask -Title ($NotifLocale.local.outputs.output[3].title -f $app.Name) ` + -Message ($NotifLocale.local.outputs.output[3].message -f $app.AvailableVersion) ` + -MessageType "success" -Balise $app.Name -Button1Action $ReleaseNoteURL -Button1Text $Button1Text $Script:InstallOK += 1 - } else { - - #Send failed updated app notification Write-ToLog "$($app.Name) update failed." "Red" - - #Send Notif - $Title = $NotifLocale.local.outputs.output[4].title -f $($app.Name) - $Message = $NotifLocale.local.outputs.output[4].message - $MessageType = "error" - $Balise = $($app.Name) - Start-NotifTask -Title $Title -Message $Message -MessageType $MessageType -Balise $Balise -Button1Action $ReleaseNoteURL -Button1Text $Button1Text - + Start-NotifTask -Title ($NotifLocale.local.outputs.output[4].title -f $app.Name) ` + -Message $NotifLocale.local.outputs.output[4].message ` + -MessageType "error" -Balise $app.Name -Button1Action $ReleaseNoteURL -Button1Text $Button1Text } - } diff --git a/Sources/Winget-AutoUpdate/functions/Update-StoreApps.ps1 b/Sources/Winget-AutoUpdate/functions/Update-StoreApps.ps1 index 1eae91ba..61b7f8a5 100644 --- a/Sources/Winget-AutoUpdate/functions/Update-StoreApps.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Update-StoreApps.ps1 @@ -1,36 +1,56 @@ -#Function to force an upgrade of Store Apps +<# +.SYNOPSIS + Triggers an update scan for Microsoft Store applications. +.DESCRIPTION + Uses MDM (Mobile Device Management) API to force a scan for + Microsoft Store app updates. This is useful as a fallback when + WinGet MSIXBundle installation fails. + +.OUTPUTS + Boolean: True on success, False on failure. + +.EXAMPLE + Update-StoreApps + +.NOTES + Not available on Windows Server or Windows Sandbox (WSB). + Uses MDM_EnterpriseModernAppManagement WMI class. +#> Function Update-StoreApps { - $info_string = "-> Forcing an upgrade of Store Apps..." - $action_string = "-> ...this can take a minute!" - $fail_string = "-> ...something went wrong!" - $irrelevant_string = "-> ...WAU is running on WSB (Windows Sandbox) or Windows Server - Microsoft Store is not available!" - - Write-ToLog $info_string "yellow" - - #If not WSB or Server, upgrade Microsoft Store Apps! - if (!(Test-Path "${env:SystemDrive}\Users\WDAGUtilityAccount") -and (Get-CimInstance Win32_OperatingSystem).Caption -notmatch "Windows Server") { - - try { - $namespaceName = "root\cimv2\mdm\dmmap" - $className = "MDM_EnterpriseModernAppManagement_AppManagement01" - $methodName = 'UpdateScanMethod' - $cimObj = Get-CimInstance -Namespace $namespaceName -ClassName $className -Shallow - Write-ToLog $action_string "green" - $result = $cimObj | Invoke-CimMethod -MethodName $methodName - if ($result.ReturnValue -ne 0) { - Write-ToLog $fail_string "red" - return $false - } - return $true - } - catch { - Write-ToLog $fail_string "red" - return $false - } - } - else { - Write-ToLog $irrelevant_string "yellow" - } + $info_string = "-> Forcing an upgrade of Store Apps..." + $action_string = "-> ...this can take a minute!" + $fail_string = "-> ...something went wrong!" + $irrelevant_string = "-> ...WAU is running on WSB (Windows Sandbox) or Windows Server - Microsoft Store is not available!" + + Write-ToLog $info_string "yellow" + + # Check if running on Windows Sandbox or Windows Server (Store not available) + if (!(Test-Path "${env:SystemDrive}\Users\WDAGUtilityAccount") -and (Get-CimInstance Win32_OperatingSystem).Caption -notmatch "Windows Server") { + + try { + # Use MDM WMI class to trigger store app update scan + $namespaceName = "root\cimv2\mdm\dmmap" + $className = "MDM_EnterpriseModernAppManagement_AppManagement01" + $methodName = 'UpdateScanMethod' + $cimObj = Get-CimInstance -Namespace $namespaceName -ClassName $className -Shallow + + Write-ToLog $action_string "green" + $result = $cimObj | Invoke-CimMethod -MethodName $methodName + + if ($result.ReturnValue -ne 0) { + Write-ToLog $fail_string "red" + return $false + } + return $true + } + catch { + Write-ToLog $fail_string "red" + return $false + } + } + else { + Write-ToLog $irrelevant_string "yellow" + } } diff --git a/Sources/Winget-AutoUpdate/functions/Update-WAU.ps1 b/Sources/Winget-AutoUpdate/functions/Update-WAU.ps1 index 030e6096..27ab9465 100644 --- a/Sources/Winget-AutoUpdate/functions/Update-WAU.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Update-WAU.ps1 @@ -1,52 +1,67 @@ +<# +.SYNOPSIS + Downloads the latest WAU release and performs self-update. + +.DESCRIPTION + Downloads the WAU MSI package from GitHub and installs it to update + WAU to the latest version. Sends notifications before and after + the update process. + +.EXAMPLE + Update-WAU + +.NOTES + Exits the script after update to allow the new version to run. + Uses MSI installer with silent installation parameters. +#> function Update-WAU { + # Setup notification action and button $OnClickAction = "https://github.com/Romanitho/$($GitHub_Repo)/releases" $Button1Text = $NotifLocale.local.outputs.output[10].message - #Send available update notification + # Send "update available" notification $Title = $NotifLocale.local.outputs.output[2].title -f "Winget-AutoUpdate" $Message = $NotifLocale.local.outputs.output[2].message -f $WAUCurrentVersion, $WAUAvailableVersion $MessageType = "info" Start-NotifTask -Title $Title -Message $Message -MessageType $MessageType -Button1Action $OnClickAction -Button1Text $Button1Text - #Run WAU update + # Download and install update try { Write-ToLog "Downloading the GitHub Repository version $WAUAvailableVersion" "Cyan" - #Create an unpredictable temp folder for security reasons + # Create temporary folder with timestamp for security $MsiFolder = "$env:temp\WAU_$(Get-Date -Format yyyyMMddHHmmss)" New-Item -ItemType Directory -Path $MsiFolder - #Download the msi + # Download the MSI package $MsiFile = Join-Path $MsiFolder "WAU.msi" Invoke-RestMethod -Uri "https://github.com/Romanitho/Winget-AutoUpdate/releases/download/v$($WAUAvailableVersion)/WAU.msi" -OutFile $MsiFile - #Update WAU + # Install the update Write-ToLog "Updating WAU..." "Yellow" Start-Process msiexec.exe -ArgumentList "/i $MsiFile /qn /L*v ""$WorkingDir\logs\WAU-Installer.log"" RUN_WAU=YES INSTALLDIR=""$WorkingDir""" -Wait - #Send success Notif + # Send success notification Write-ToLog "WAU Update completed. Rerunning WAU..." "Green" $Title = $NotifLocale.local.outputs.output[3].title -f "Winget-AutoUpdate" $Message = $NotifLocale.local.outputs.output[3].message -f $WAUAvailableVersion $MessageType = "success" Start-NotifTask -Title $Title -Message $Message -MessageType $MessageType -Button1Action $OnClickAction -Button1Text $Button1Text - #Remove temp folder and content + # Cleanup temporary files Remove-Item $MsiFolder -Recurse -Force exit 0 } catch { - - #Send Error Notif + # Send error notification $Title = $NotifLocale.local.outputs.output[4].title -f "Winget-AutoUpdate" $Message = $NotifLocale.local.outputs.output[4].message $MessageType = "error" Start-NotifTask -Title $Title -Message $Message -MessageType $MessageType -Button1Action $OnClickAction -Button1Text $Button1Text Write-ToLog "WAU Update failed" "Red" - } } \ No newline at end of file diff --git a/Sources/Winget-AutoUpdate/functions/Write-ToLog.ps1 b/Sources/Winget-AutoUpdate/functions/Write-ToLog.ps1 index f84316df..66e6c711 100644 --- a/Sources/Winget-AutoUpdate/functions/Write-ToLog.ps1 +++ b/Sources/Winget-AutoUpdate/functions/Write-ToLog.ps1 @@ -1,41 +1,45 @@ -#Write to Log Function +<# +.SYNOPSIS + Writes a timestamped message to console and log file. -function Write-ToLog { +.PARAMETER LogMsg + Message to log. + +.PARAMETER LogColor + Console color (default: White). +.PARAMETER IsHeader + Format as section header. +#> +function Write-ToLog { [CmdletBinding()] param( - [Parameter()] [String] $LogMsg, - [Parameter()] [String] $LogColor = "White", - [Parameter()] [Switch] $IsHeader = $false + [String]$LogMsg, + [String]$LogColor = "White", + [Switch]$IsHeader ) - #Create file if doesn't exist + # Create log file with proper ACL if needed if (!(Test-Path $LogFile)) { New-Item -ItemType File -Path $LogFile -Force | Out-Null - - #Set ACL for users on logfile - $NewAcl = Get-Acl -Path $LogFile - $identity = New-Object System.Security.Principal.SecurityIdentifier S-1-5-11 - $fileSystemRights = "Modify" - $type = "Allow" - $fileSystemAccessRuleArgumentList = $identity, $fileSystemRights, $type - $fileSystemAccessRule = New-Object -TypeName System.Security.AccessControl.FileSystemAccessRule -ArgumentList $fileSystemAccessRuleArgumentList - $NewAcl.SetAccessRule($fileSystemAccessRule) - Set-Acl -Path $LogFile -AclObject $NewAcl + $acl = Get-Acl $LogFile + $rule = New-Object System.Security.AccessControl.FileSystemAccessRule( + (New-Object System.Security.Principal.SecurityIdentifier("S-1-5-11")), + "Modify", "Allow" + ) + $acl.SetAccessRule($rule) + Set-Acl $LogFile $acl } - #If header requested - if ($IsHeader) { - $Log = "#" * 65 + "`n# $(Get-Date -Format (Get-culture).DateTimeFormat.ShortDatePattern) - $LogMsg`n" + "#" * 65 + # Format log entry + $Log = if ($IsHeader) { + $date = Get-Date -Format (Get-culture).DateTimeFormat.ShortDatePattern + "#" * 65 + "`n# $date - $LogMsg`n" + "#" * 65 } else { - $Log = "$(Get-Date -UFormat "%T") - $LogMsg" + "$(Get-Date -UFormat '%T') - $LogMsg" } - #Echo log - $Log | Write-host -ForegroundColor $LogColor - - #Write log to file + Write-Host $Log -ForegroundColor $LogColor $Log | Out-File -FilePath $LogFile -Append - } diff --git a/Sources/Winget-AutoUpdate/locale/de.xml b/Sources/Winget-AutoUpdate/locale/de.xml index 4b5a12c5..0bbd1a9b 100644 --- a/Sources/Winget-AutoUpdate/locale/de.xml +++ b/Sources/Winget-AutoUpdate/locale/de.xml @@ -2,13 +2,13 @@ - Prüfen Sie Ihre Internet Verbindung. + Prüfen Sie Ihre Internetverbindung. Es konnte nicht nach Updates gesucht werden! - Keine Internet Verbindung! + Keine Internetverbindung! Updates konnten nicht verifiziert werden. @@ -28,7 +28,7 @@ {0} konnte nicht aktualisiert werden! - Bitte wenden sie sich an den Support. + Bitte wenden Sie sich an den Support. diff --git a/Sources/Winget-AutoUpdate/mods/README.md b/Sources/Winget-AutoUpdate/mods/README.md index f5d83f5c..d6098a1f 100644 --- a/Sources/Winget-AutoUpdate/mods/README.md +++ b/Sources/Winget-AutoUpdate/mods/README.md @@ -30,11 +30,68 @@ A script **Template** for an all-purpose mod (`_WAU-notinstalled-template.ps1`) Name it `_WAU-notinstalled.ps1` for activation ### Winget native parameters: -Another finess is the **AppID** followed by the `-override` or `-custom` suffix as a **text file** (**.txt**). +You can customize winget behavior per-app using **text files** (**.txt**) with specific suffixes: + +#### 1. **AppID-override.txt** (Full installer control) +Replaces ALL default installer arguments. Does NOT use `-h` (silent mode). +> Example: +> **Adobe.Acrobat.Reader.64-bit-override.txt** with the content: +> ``` +> "-sfx_nu /sAll /rs /msi EULA_ACCEPT=YES DISABLEDESKTOPSHORTCUT=1" +> ``` + +#### 2. **AppID-custom.txt** (Add to installer arguments) +Adds extra arguments to the default installer arguments. Uses `-h` (silent mode). +> Example: +> **ShareX.ShareX-custom.txt** with the content: +> ``` +> /MERGETASKS=!CreateDesktopIcon +> ``` + +#### 3. **AppID-arguments.txt** (Winget-level parameters) ⭐ NEW +Passes additional **winget parameters** (not installer arguments). Uses `-h` (silent mode). +> Example: +> **Mozilla.Firefox-arguments.txt** with the content: +> ``` +> --locale pl + > Example: -> **Adobe.Acrobat.Reader.64-bit-override.txt** with the content `"-sfx_nu /sAll /rs /msi EULA_ACCEPT=YES DISABLEDESKTOPSHORTCUT=1"` +> **Cloudflare.Warp-arguments.txt** with the content: +> ``` +> --skip-dependencies +> ``` > Example: -> **ShareX.ShareX-custom.txt** with the content `/MERGETASKS=!CreateDesktopIcon` +> **Microsoft.VisualStudio.2022.Community-arguments.txt** with multiple arguments: +> ``` +> --locale en-US --architecture x64 --skip-dependencies +> ``` + +**Common use cases for `-arguments.txt`:** +- `--locale ` - Set application language (e.g., `pl-PL`, `en-US`, `de-DE`) +- `--skip-dependencies` - Skip dependency installations +- `--architecture ` - Force specific architecture (`x86`, `x64`, `arm64`) +- `--version ` - Pin to specific version +- `--ignore-security-hash` - Bypass hash verification +- `--ignore-local-archive-malware-scan` - Skip malware scanning + +💡 **Locale Tip:** For applications with locale-specific package IDs (e.g., `Mozilla.Firefox.sv-SE`, `Mozilla.Firefox.de`), use the locale-specific package ID in your `included_apps.txt` instead of the base ID. This prevents WAU from reverting the application language to English during upgrades. + +If you must use the base package ID (e.g., `Mozilla.Firefox`), create a `{AppID}-arguments.txt` with `--locale` parameter to force the language during every upgrade. + +⚠️ **Important:** When combining `--locale` and `--version`, the specific version must have an installer available for that locale in the winget manifest. Not all versions support all locales. + +**Priority order:** `Override` > `Custom` > `Arguments (file)` > `Arguments (command-line)` > `Default` +- If `-override.txt` exists, both `-custom.txt` and `-arguments.txt` are ignored +- If `-custom.txt` exists, `-arguments.txt` is ignored +- If `-arguments.txt` exists, command-line arguments are ignored +- `-arguments.txt` is only used if neither override nor custom exists + +**Command-line usage:** You can also pass arguments when calling `Winget-Install.ps1`: +```powershell +.\winget-install.ps1 -AppIDs "Mozilla.Firefox --locale sv-SE" +.\winget-install.ps1 -AppIDs "7zip.7zip --version 23.01 --architecture x64" +``` +Note: File-based `-arguments.txt` has priority over command-line arguments. -This will use the **content** of the text file as a native **winget --override** respectively **winget --custom** parameter in **WAU upgrading**. +**Template:** See `_AppID-arguments-template.txt` for more examples and documentation. diff --git a/Sources/Winget-AutoUpdate/mods/_AppID-arguments-template.txt b/Sources/Winget-AutoUpdate/mods/_AppID-arguments-template.txt new file mode 100644 index 00000000..8f601c61 --- /dev/null +++ b/Sources/Winget-AutoUpdate/mods/_AppID-arguments-template.txt @@ -0,0 +1,105 @@ +# AppID-arguments.txt Template + +# This file allows you to specify additional winget-level arguments for a specific application. +# These arguments affect winget's behavior (not the installer itself). +# +# Priority order: Override > Custom > Arguments (file) > Arguments (command-line) > Default +# - If {AppID}-override.txt exists, this file is IGNORED +# - If {AppID}-custom.txt exists, this file is IGNORED +# - If this file exists, command-line arguments are IGNORED +# - Arguments are added BEFORE the -h (silent) flag +# +# Naming: Replace {AppID} with the actual WinGet App ID +# Example: Mozilla.Firefox-arguments.txt +# Cloudflare.Warp-arguments.txt +# Microsoft.VisualStudio.2022.Community-arguments.txt + +# COMMON USE CASES: + +# 1. Set application locale (Issue #1073) +# PROBLEM: When WAU upgrades applications, winget may revert the language to +# English or system default, ignoring the user's previous installation choice. +# This happens because winget doesn't remember the locale used during initial install. +# +# RECOMMENDED SOLUTION: Use locale-specific package IDs when available +# Example: Mozilla.Firefox.sv-SE, Mozilla.Firefox.pl, Mozilla.Firefox.de +# Add the locale-specific App ID to WAU's included_apps.txt instead of the base ID. +# Then WAU will upgrade the correct language version automatically. +# +# ALTERNATIVE SOLUTION: Use --locale parameter in {AppID}-arguments.txt +# This forces the locale during every upgrade, even if the base package ID is used. +# Example for Polish Firefox (if using Mozilla.Firefox base ID): +# --locale pl +# +# Example for English Visual Studio: +# --locale en-US +# +# NOTE: Locale-specific package IDs are preferred as they always have +# the latest version. The --locale parameter only works if that specific +# version has an installer for the requested locale. + +# 2. Skip dependency installations (Issue #1075) +# Useful when dependency versions conflict: +# --skip-dependencies + +# 3. Force specific architecture +# On multi-architecture systems: +# --architecture x64 +# --architecture x86 +# --architecture arm64 + +# 4. Pin to specific version (but WAU will still try upgrading...) +# Prevent auto-upgrade to latest: +# --version 120.0.1 +# WARNING: When combining --locale and --version, the specific version +# must have an installer available for that locale in the winget manifest. + +# 5. Ignore security hash mismatches +# When winget manifest hash is outdated: +# --ignore-security-hash + +# 6. Ignore malware scan +# On restrictive systems with AV issues: +# --ignore-local-archive-malware-scan + +# 7. Force uninstall before install +# For problematic upgrades: +# --uninstall-previous + +# 8. Custom repository authentication +# For private repos: +# --header "Authorization: Bearer TOKEN123" + +# MULTIPLE ARGUMENTS: +# You can combine multiple arguments on one line or separate lines: + +# Single line example: +# --locale en-US --skip-dependencies --architecture x64 + +# Multi-line example (all will be combined): +# --locale en-US +# --skip-dependencies +# --architecture x64 + +# QUOTED VALUES: +# Use quotes for values containing spaces: +# --locale "en-US" +# --custom "SETTING=Value With Spaces" + +# COMMAND-LINE USAGE: +# You can also pass arguments directly when calling Winget-Install.ps1: +# winget-install.ps1 -AppIDs "Mozilla.Firefox --locale sv-SE" +# winget-install.ps1 -AppIDs "7zip.7zip --version 23.01 --architecture x64" +# +# Note: File-based arguments have priority over command-line arguments. +# If this file exists, command-line arguments for this app are ignored. +# +# IMPORTANT: When combining --locale and --version, ensure the specific +# version has an installer for that locale. Not all versions support all locales. + +# NOTES: +# - These are WINGET parameters, not installer arguments +# - For installer arguments, use {AppID}-custom.txt (adds to defaults) +# - For full installer control, use {AppID}-override.txt (replaces all defaults) +# - Arguments are parsed respecting quotes (both single and double) +# - Empty lines and lines starting with # are ignored diff --git a/Sources/Winget-AutoUpdate/mods/_AppID-template.ps1 b/Sources/Winget-AutoUpdate/mods/_AppID-template.ps1 index 4aec7f8d..aff0948e 100644 --- a/Sources/Winget-AutoUpdate/mods/_AppID-template.ps1 +++ b/Sources/Winget-AutoUpdate/mods/_AppID-template.ps1 @@ -51,6 +51,16 @@ $AllVersions = $False # Beginning of Desktop Link Name to Remove - optional wildcard (*) after, without .lnk, multiple: "lnk1*","lnk2" $Lnk = @("") +# Create Start Menu Shortcuts, without .lnk, multiple: "lnk1","lnk2". Example: +# - Supports subdirectories in shortcut names (e.g., "Folder\ShortcutName") +# - $Shortcuts and $ShortcutsTargets arrays must match in length and order +# - Shortcuts are only created if the target file exists +# Example: +# $Shortcuts = @("dnGrep\dnGrep") +# $ShortcutsTargets = @("${env:ProgramFiles}\dnGrep\dnGrep.exe") +$Shortcuts = @("") +$ShortcutsTargets = @("") # Must match the order of $Shortcuts + # Registry _value_ (DWord/String) to add in existing registry Key (Key created if not existing). Example: # $AddKey = "HKLM:\SOFTWARE\Romanitho\Winget-AutoUpdate" # $AddValue = "WAU_BypassListForUsers" @@ -129,6 +139,9 @@ if ($AppUninst) { if ($Lnk) { Remove-ModsLnk $Lnk } +if ($Shortcuts -and $Shortcuts[0] -and $ShortcutsTargets -and $ShortcutsTargets[0]) { + Add-ProgramsShortcuts $Shortcuts $ShortcutsTargets +} if ($AddKey -and $AddValue -and $AddTypeData -and $AddType) { Add-ModsReg $AddKey $AddValue $AddTypeData $AddType } diff --git a/Sources/Winget-AutoUpdate/mods/_Mods-Functions.ps1 b/Sources/Winget-AutoUpdate/mods/_Mods-Functions.ps1 index e532dc93..9410e680 100644 --- a/Sources/Winget-AutoUpdate/mods/_Mods-Functions.ps1 +++ b/Sources/Winget-AutoUpdate/mods/_Mods-Functions.ps1 @@ -185,6 +185,57 @@ function Remove-ModsLnk ($Lnk) { Return $removedCount } +function Add-ProgramsShortcuts ($Shortcuts, $ShortcutsTargets) { + $programsPath = "${env:ProgramData}\Microsoft\Windows\Start Menu\Programs" + $createdCount = 0 + + # Validate arrays match in length and are not just empty placeholders + if ($Shortcuts.Count -ne $ShortcutsTargets.Count -or ($Shortcuts.Count -eq 1 -and [string]::IsNullOrEmpty($Shortcuts[0]))) { + Return $createdCount + } + + # Create WScript.Shell COM object + $WshShell = New-Object -ComObject WScript.Shell -ErrorAction SilentlyContinue + if (!$WshShell) { + Return $createdCount + } + + # Iterate through shortcuts + for ($i = 0; $i -lt $Shortcuts.Count; $i++) { + $shortcutName = $Shortcuts[$i] + $targetPath = $ShortcutsTargets[$i] + + # Skip empty entries + if ([string]::IsNullOrEmpty($shortcutName) -or [string]::IsNullOrEmpty($targetPath)) { + continue + } + + # Construct full shortcut path + $shortcutPath = Join-Path $programsPath "$shortcutName.lnk" + + # Create parent directory if it doesn't exist + $shortcutDir = Split-Path $shortcutPath -Parent + if (!(Test-Path $shortcutDir)) { + New-Item -Path $shortcutDir -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null + } + + # Verify target exists + if (Test-Path $targetPath) { + # Create shortcut + $shortcut = $WshShell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = $targetPath + $parentPath = Split-Path $targetPath -Parent + if (![string]::IsNullOrEmpty($parentPath)) { + $shortcut.WorkingDirectory = $parentPath + } + $shortcut.Save() + $createdCount++ + } + } + + Return $createdCount +} + function Add-ModsReg ($AddKey, $AddValue, $AddTypeData, $AddType) { if ($AddKey -like "HKEY_LOCAL_MACHINE*") { $AddKey = $AddKey.replace("HKEY_LOCAL_MACHINE", "HKLM:") diff --git a/Sources/Wix/build.wxs b/Sources/Wix/build.wxs index 101c25d5..07b00a20 100644 --- a/Sources/Wix/build.wxs +++ b/Sources/Wix/build.wxs @@ -296,76 +296,36 @@ - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - -