LoudToast.psm1

#Requires -Version 5.1
# LoudToast - Windows toast and balloon notifications that get through Do Not Disturb.
# https://github.com/dooblpls/LoudToast (MIT License)
Set-StrictMode -Version 2

if ($PSVersionTable.PSEdition -ne 'Desktop') {
    throw 'LoudToast requires Windows PowerShell 5.1 (powershell.exe). PowerShell 7 has no WinRT projection.'
}

# ---- Native core: compiled once per session against the WinRT metadata shipped with Windows ----
if (-not ('LoudToast.Toasts' -as [type])) {
    $null = [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime]
    $null = [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime]
    $wm = "$env:windir\System32\WinMetadata"
    $fx = [Runtime.InteropServices.RuntimeEnvironment]::GetRuntimeDirectory()
    $cp = New-Object -TypeName CodeDom.Compiler.CompilerParameters
    $cp.GenerateInMemory = $true
    $cp.ReferencedAssemblies.AddRange([string[]]@('System.dll', "$wm\Windows.UI.winmd", "$wm\Windows.Foundation.winmd", "$wm\Windows.Data.winmd",
            "$fx\System.Runtime.WindowsRuntime.dll", "$fx\System.Runtime.dll", "$fx\System.Runtime.InteropServices.WindowsRuntime.dll", "$fx\System.Threading.dll"))
    $res = (New-Object -TypeName Microsoft.CSharp.CSharpCodeProvider).CompileAssemblyFromFile($cp, "$PSScriptRoot\LoudToast.cs")
    if ($res.Errors.HasErrors) { throw "LoudToast.cs: $(($res.Errors | ForEach-Object ToString) -join "`n")" }
}
Add-Type -Name Native -Namespace LoudToast -ErrorAction SilentlyContinue -MemberDefinition @'
[DllImport("shell32.dll", CharSet = CharSet.Unicode)] public static extern int SetCurrentProcessExplicitAppUserModelID(string id);
[DllImport("shell32.dll", CharSet = CharSet.Unicode)] public static extern int GetCurrentProcessExplicitAppUserModelID(out IntPtr id);
[DllImport("user32.dll", SetLastError = true)] public static extern bool SystemParametersInfo(uint action, uint p, IntPtr v, uint flags);
[DllImport("user32.dll", SetLastError = true)] public static extern bool SystemParametersInfo(uint action, uint p, ref uint v, uint flags);
'@

Add-Type -AssemblyName System.Windows.Forms, System.Drawing

# Windows PowerShell ships a Start menu shortcut with this AUMID, so toasts work without any registration.
$script:DefaultAppId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe'
$script:DefaultGroup = 'LoudToast'  # Windows rejects Remove/Update with an empty group
$script:Profiles = @{ Off = [LoudToast.Dnd]::Off; PriorityOnly = [LoudToast.Dnd]::PriorityOnly; AlarmsOnly = [LoudToast.Dnd]::AlarmsOnly }

function Get-ProfileName([string]$Id) {
    foreach ($k in $script:Profiles.Keys) { if ($script:Profiles[$k] -eq $Id) { return $k } }
    ''
}

#region App identity

function Register-ToastApp {
    <#
    .SYNOPSIS
        Registers an app identity (name and icon shown in the toast header) for unpackaged scripts.
    .DESCRIPTION
        Writes HKCU:\Software\Classes\AppUserModelId\<AppId>, the documented way for unpackaged apps to get their own
        notification identity. The app then also appears in Settings > System > Notifications.
    .EXAMPLE
        Register-ToastApp -AppId Contoso.Monitor -DisplayName 'Server Monitor' -IconPath .\icon.png -SetDefault
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Application User Model ID, e.g. 'Contoso.Monitor'.
        [Parameter(Mandatory)][string]$AppId,
        # Name shown in the toast header and in Windows settings.
        [Parameter(Mandatory)][string]$DisplayName,
        # Path to a PNG/ICO used as app icon.
        [string]$IconPath,
        # Icon background as ARGB hex, e.g. 'FF0078D4'.
        [string]$IconBackgroundColor,
        # Also make this the default AppId for all LoudToast commands in this session (see Set-ToastDefaultAppId).
        [switch]$SetDefault
    )
    $key = "HKCU:\Software\Classes\AppUserModelId\$AppId"
    if ($PSCmdlet.ShouldProcess($key, 'Register notification app')) {
        New-Item -Path $key -Force | Out-Null
        Set-ItemProperty -Path $key -Name DisplayName -Value $DisplayName
        if ($IconPath) { Set-ItemProperty -Path $key -Name IconUri -Value (Resolve-Path -Path $IconPath).ProviderPath }
        if ($IconBackgroundColor) { Set-ItemProperty -Path $key -Name IconBackgroundColor -Value $IconBackgroundColor }
        if ($SetDefault) { Set-ToastDefaultAppId -AppId $AppId }
    }
}

function Unregister-ToastApp {
    <#
    .SYNOPSIS
        Removes an app identity created with Register-ToastApp and clears its notifications.
    .EXAMPLE
        Unregister-ToastApp -AppId Contoso.Monitor
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Application User Model ID to remove.
        [Parameter(Mandatory)][string]$AppId
    )
    if ($PSCmdlet.ShouldProcess($AppId, 'Unregister notification app')) {
        Remove-Item -Path "HKCU:\Software\Classes\AppUserModelId\$AppId" -Recurse -ErrorAction SilentlyContinue
        [LoudToast.Toasts]::Remove($AppId, $null, $null)
    }
}

function Set-ToastDefaultAppId {
    <#
    .SYNOPSIS
        Sets the AppId used by all LoudToast commands in this session.
    .DESCRIPTION
        Also sets it as the explicit AppUserModelID of the current process. Windows uses that identity for balloon
        notifications too, so balloons share name, settings and priority-list membership with your toasts.
    .EXAMPLE
        Set-ToastDefaultAppId -AppId Contoso.Monitor
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Session-only state')]
    [CmdletBinding()]
    param(
        # Application User Model ID.
        [Parameter(Mandatory)][string]$AppId
    )
    $script:DefaultAppId = $AppId
    [void][LoudToast.Native]::SetCurrentProcessExplicitAppUserModelID($AppId)
}

function Get-ToastDefaultAppId {
    <#
    .SYNOPSIS
        Returns the AppId used by LoudToast commands in this session.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()
    $script:DefaultAppId
}

function Get-ToastAppSetting {
    <#
    .SYNOPSIS
        Shows the per-app notification settings (as in Settings > System > Notifications > <app>).
    .EXAMPLE
        Get-ToastAppSetting
    #>

    [CmdletBinding()]
    param(
        # Application User Model ID. Defaults to Get-ToastDefaultAppId.
        [string]$AppId = $script:DefaultAppId
    )
    $h = [LoudToast.AppSettings]::Get($AppId)
    [pscustomobject]@{
        AppId                 = $AppId
        Setting               = [LoudToast.Toasts]::Setting($AppId)
        Enabled               = $h.Enabled
        ShowBanner            = $h.ShowBanner
        ShowInActionCenter    = $h.ShowInActionCenter
        AllowContentAboveLock = $h.AllowContentAboveLock
        AllowUrgent           = $h.AllowUrgent
        OnPriorityList        = (Get-DndPriorityApp) -contains $AppId
    }
}

function Set-ToastAppSetting {
    <#
    .SYNOPSIS
        Changes per-app notification settings.
    .DESCRIPTION
        -AllowUrgent $true has the same effect as clicking "Allow" on the Windows prompt "Allow important notifications
        from <app>?". Toasts with -Scenario Urgent then show while Do Not Disturb is on.
    .EXAMPLE
        Set-ToastAppSetting -AllowUrgent $true -ShowBanner $true
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Application User Model ID. Defaults to Get-ToastDefaultAppId.
        [string]$AppId = $script:DefaultAppId,
        # Notifications on/off for this app.
        [Nullable[bool]]$Enabled,
        # Show banners (pop-ups).
        [Nullable[bool]]$ShowBanner,
        # Keep notifications in the notification center.
        [Nullable[bool]]$ShowInActionCenter,
        # Show content on the lock screen.
        [Nullable[bool]]$AllowContentAboveLock,
        # Let -Scenario Urgent toasts through Do Not Disturb.
        [Nullable[bool]]$AllowUrgent
    )
    foreach ($name in 'Enabled', 'ShowBanner', 'ShowInActionCenter', 'AllowContentAboveLock', 'AllowUrgent') {
        $value = Get-Variable -Name $name -ValueOnly
        if ($null -ne $value -and $PSCmdlet.ShouldProcess($AppId, "Set $name = $value")) { [LoudToast.AppSettings]::Set($AppId, $name, $value) }
    }
    Get-ToastAppSetting -AppId $AppId
}

#endregion

#region Do Not Disturb

function Get-DndState {
    <#
    .SYNOPSIS
        Shows the Do Not Disturb state, its priority settings and the priority list.
    .DESCRIPTION
        Active is what is in effect right now. UserSelected is the toggle in Settings/notification center. QuietMoment
        is an active automatic rule (schedule, game, full screen, presentation, focus).
    .EXAMPLE
        Get-DndState
    #>

    [CmdletBinding()]
    param()
    $null = [Windows.UI.Shell.FocusSessionManager, Windows.UI.Shell, ContentType = WindowsRuntime]
    $p = [LoudToast.Dnd]::PriorityOnly
    [pscustomobject]@{
        Active            = Get-ProfileName ([LoudToast.Dnd]::Active)
        UserSelected      = Get-ProfileName ([LoudToast.Dnd]::UserSelected)
        QuietMoment       = Get-ProfileName ([LoudToast.Dnd]::QuietMomentProfile)
        FocusSession      = [Windows.UI.Shell.FocusSessionManager]::GetDefault().IsFocusActive
        AllowCalls        = [LoudToast.Dnd]::GetFlag($p, 0)
        AllowPhoneSms     = [LoudToast.Dnd]::GetFlag($p, 1)
        AllowReminders    = [LoudToast.Dnd]::GetFlag($p, 2)
        PriorityApps      = [LoudToast.Dnd]::PriorityApps($p)
        AlwaysAllowedApps = [LoudToast.Dnd]::AlwaysAllowedApps()
    }
}

function Set-DndMode {
    <#
    .SYNOPSIS
        Switches Do Not Disturb.
    .DESCRIPTION
        PriorityOnly is the Windows 11 "Do not disturb" toggle. AlarmsOnly is the stricter legacy Focus Assist mode,
        still supported by Windows but no longer exposed in Settings.
    .EXAMPLE
        Set-DndMode -Mode PriorityOnly
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Off, PriorityOnly or AlarmsOnly.
        [Parameter(Mandatory)][ValidateSet('Off', 'PriorityOnly', 'AlarmsOnly')][string]$Mode
    )
    if ($PSCmdlet.ShouldProcess('Do Not Disturb', "Set mode $Mode")) { [LoudToast.Dnd]::Set($script:Profiles[$Mode]) }
}

function Disable-Dnd {
    <#
    .SYNOPSIS
        Turns Do Not Disturb completely off, including an active automatic rule. Returns the previous mode.
    .EXAMPLE
        $previous = Disable-Dnd; ...; Set-DndMode $previous
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([string])]
    param()
    $prev = [LoudToast.Dnd]::UserSelected
    if (-not $PSCmdlet.ShouldProcess('Do Not Disturb', 'Turn off')) { return Get-ProfileName $prev }
    if ($prev -ne [LoudToast.Dnd]::Off) { [LoudToast.Dnd]::Set([LoudToast.Dnd]::Off) }
    if ([LoudToast.Dnd]::Active -ne [LoudToast.Dnd]::Off) { [LoudToast.Dnd]::TurnOffQuietMoment() }
    if ([LoudToast.Dnd]::Active -ne [LoudToast.Dnd]::Off) { throw "Do Not Disturb is still active: $(Get-DndState | Out-String)" }
    Get-ProfileName $prev
}

function Get-DndPriorityApp {
    <#
    .SYNOPSIS
        Lists the AppIds on the Do Not Disturb priority list.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()
    [LoudToast.Dnd]::PriorityApps([LoudToast.Dnd]::PriorityOnly)
}

function Add-DndPriorityApp {
    <#
    .SYNOPSIS
        Adds an app to the Do Not Disturb priority list, so its toasts show while DND is on (not in AlarmsOnly).
    .EXAMPLE
        Add-DndPriorityApp -AppId Contoso.Monitor
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Application User Model ID. Defaults to Get-ToastDefaultAppId.
        [string]$AppId = $script:DefaultAppId
    )
    if ($PSCmdlet.ShouldProcess($AppId, 'Add to Do Not Disturb priority list')) {
        [LoudToast.Dnd]::AddPriorityApp([LoudToast.Dnd]::PriorityOnly, $AppId)
        Start-Sleep -Seconds 2  # the service reloads the list asynchronously; right after a mode switch this took 1-2 s
    }
}

function Remove-DndPriorityApp {
    <#
    .SYNOPSIS
        Removes an app from the Do Not Disturb priority list.
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Application User Model ID. Defaults to Get-ToastDefaultAppId.
        [string]$AppId = $script:DefaultAppId
    )
    if ($PSCmdlet.ShouldProcess($AppId, 'Remove from Do Not Disturb priority list')) {
        [LoudToast.Dnd]::RemovePriorityApp([LoudToast.Dnd]::PriorityOnly, $AppId)
        Start-Sleep -Seconds 2
    }
}

function Test-ToastBreakThrough {
    <#
    .SYNOPSIS
        Predicts whether a toast would show as a banner under the current Do Not Disturb state.
    .DESCRIPTION
        Re-implements the decision of the Windows notification filter (QuietHoursToastFilterSink, build 26200).
    .EXAMPLE
        Test-ToastBreakThrough -Scenario reminder
    #>

    [CmdletBinding()]
    param(
        # Application User Model ID. Defaults to Get-ToastDefaultAppId.
        [string]$AppId = $script:DefaultAppId,
        # Toast scenario: default, reminder, alarm, incomingCall, callProgress, urgent, systemDialog.
        [string]$Scenario = 'default',
        # Toast tag (some tags are special-cased by Windows).
        [string]$Tag = ''
    )
    $active = [LoudToast.Dnd]::Active
    $result = { param($ok, $why) [pscustomobject]@{ BreaksThrough = $ok; Reason = $why; Profile = (Get-ProfileName $active) } }
    if ($active -eq [LoudToast.Dnd]::Off) { return & $result $true 'Do Not Disturb is off' }
    if ($Tag -eq 'QUIET_MOMENT_START_AND_END_TOAST') { return & $result $true 'system tag' }
    if ($Scenario -in 'alarm', 'systemDialog') { return & $result $true "scenario $Scenario always shows" }
    if ($active -eq [LoudToast.Dnd]::PriorityOnly) {
        $p = [LoudToast.Dnd]::PriorityOnly
        # The decompiled filter would also allow urgent in AlarmsOnly; measured it does not (another stage filters it).
        if ($Scenario -eq 'urgent' -and [LoudToast.AppSettings]::Get($AppId).AllowUrgent) { return & $result $true 'urgent toasts allowed for app' }
        if ($Scenario -eq 'reminder' -and [LoudToast.Dnd]::GetFlag($p, 2)) { return & $result $true 'reminders allowed' }
        if (($Scenario -in 'incomingCall', 'callProgress' -or $Tag -match 'XDeviceIncomingCall|XDeviceMissedCall') -and [LoudToast.Dnd]::GetFlag($p, 0)) { return & $result $true 'calls allowed' }
        if ($Tag -match 'XDeviceIncomingSMS' -and [LoudToast.Dnd]::GetFlag($p, 1)) { return & $result $true 'phone SMS allowed' }
        if (([LoudToast.Dnd]::PriorityApps($p) + [LoudToast.Dnd]::AlwaysAllowedApps()) -contains $AppId) { return & $result $true 'app is on the priority list' }
    }
    & $result $false 'suppressed by Do Not Disturb'
}

# Turns DND off now and restores it after $Seconds from a hidden process (survives the caller exiting).
function Invoke-BreakThrough([int]$Seconds) {
    $prev = Disable-Dnd
    if ($prev -eq 'Off') { return }  # only an automatic rule was active; it stays off until it triggers again
    $cmd = "Start-Sleep $Seconds; Import-Module '$PSScriptRoot\LoudToast.psd1'; if ([LoudToast.Dnd]::UserSelected -eq [LoudToast.Dnd]::Off) { Set-DndMode -Mode $prev }"
    Start-Process -FilePath powershell.exe -WindowStyle Hidden -ArgumentList '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $cmd
}

#endregion

#region Banner display time
# Windows reads SPI_MESSAGEDURATION ("Dismiss notifications after this amount of time") when it builds a banner.
# duration="long" is a fixed 25 s and ignores it. Effective minimum is 5 s.

function Get-ToastDisplayDuration {
    <#
    .SYNOPSIS
        Returns the system-wide banner display time in seconds ("Dismiss notifications after this amount of time").
    #>

    [CmdletBinding()]
    [OutputType([uint32])]
    param()
    $v = [uint32]0
    if (-not [LoudToast.Native]::SystemParametersInfo(0x2016, 0, [ref]$v, 0)) { throw [ComponentModel.Win32Exception]::new() }  # SPI_GETMESSAGEDURATION
    $v
}

function Set-ToastDisplayDuration {
    <#
    .SYNOPSIS
        Sets the system-wide banner display time for all apps.
    .DESCRIPTION
        Same as Settings > Accessibility > Visual effects > "Dismiss notifications after this amount of time", but any
        value from 5 seconds up. For a single toast use Show-Toast -DisplaySeconds instead.
    .EXAMPLE
        Set-ToastDisplayDuration -Seconds 15
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Display time in seconds.
        [Parameter(Mandatory)][ValidateRange(5, 3600)][int]$Seconds,
        # Change it for this logon session only, without saving it.
        [switch]$NoPersist
    )
    $flags = if ($NoPersist) { 0 } else { 3 }  # SPIF_UPDATEINIFILE | SPIF_SENDCHANGE
    if ($PSCmdlet.ShouldProcess('Notification display time', "Set to $Seconds s")) {
        if (-not [LoudToast.Native]::SystemParametersInfo(0x2017, 0, [IntPtr]$Seconds, $flags)) { throw [ComponentModel.Win32Exception]::new() }  # SPI_SETMESSAGEDURATION
    }
}

# Runs $Show with a temporary banner display time, then restores the system value.
function Invoke-WithDisplayDuration([int]$Seconds, [scriptblock]$Show) {
    if (-not $Seconds) { return & $Show }
    if ($Seconds -lt 5) { Write-Warning 'Windows shows banners for at least 5 seconds.' }
    $old = Get-ToastDisplayDuration
    Set-ToastDisplayDuration -Seconds ([Math]::Max(5, $Seconds)) -NoPersist -WhatIf:$false -Confirm:$false
    try { & $Show; Start-Sleep -Milliseconds 1000 }  # time for the service to build the banner (measured sufficient)
    finally { Set-ToastDisplayDuration -Seconds $old -NoPersist -WhatIf:$false -Confirm:$false }
}

#endregion

#region Toast content

function ConvertTo-ToastUri([string]$Path) {
    if ($Path -match '^(https?|ms-appx|ms-appdata|file):') { return $Path }
    ([Uri](Resolve-Path -Path $Path).ProviderPath).AbsoluteUri
}

function New-ToastButton {
    <#
    .SYNOPSIS
        Creates a button for Show-Toast -Buttons.
    .EXAMPLE
        New-ToastButton -Content 'Open log' -Arguments 'file:///C:/build/log.txt' -ActivationType Protocol
    .EXAMPLE
        New-ToastButton Send send -InputId reply -Style Success
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an object only')]
    [CmdletBinding()]
    param(
        # Button text.
        [Parameter(Mandatory, Position = 0)][string]$Content,
        # Returned in the result of Show-Toast -Wait. With -ActivationType Protocol: the URL or file to open.
        [Parameter(Position = 1)][string]$Arguments = $Content,
        # Foreground/Background raise the Activated event; Protocol opens Arguments (works after your script ended).
        [ValidateSet('Foreground', 'Background', 'Protocol')][string]$ActivationType = 'Foreground',
        # Small icon on the button.
        [string]$ImageUri,
        # Place the button next to the text box with this id.
        [string]$InputId,
        # Green (Success) or red (Critical) button. Requires Show-Toast -ButtonStyles.
        [ValidateSet('Success', 'Critical')][string]$Style,
        # Tooltip, useful for icon-only buttons.
        [string]$ToolTip,
        # Show the entry in the toast's context menu instead of as a button.
        [switch]$ContextMenu,
        # Keep the toast open after the click (for in-place updates).
        [switch]$PendingUpdate
    )
    $a = [ordered]@{ content = $Content; arguments = $Arguments; activationType = $ActivationType.ToLower() }
    if ($ImageUri) { $a.imageUri = ConvertTo-ToastUri $ImageUri }
    if ($InputId) { $a.'hint-inputId' = $InputId }
    if ($Style) { $a.'hint-buttonStyle' = $Style }
    if ($ToolTip) { $a.'hint-toolTip' = $ToolTip }
    if ($ContextMenu) { $a.placement = 'contextMenu' }
    if ($PendingUpdate) { $a.afterActivationBehavior = 'pendingUpdate' }
    [pscustomobject]@{ Kind = 'action'; Attributes = $a }
}

function New-ToastSystemButton {
    <#
    .SYNOPSIS
        Creates a Snooze or Dismiss button handled by Windows itself.
    .EXAMPLE
        New-ToastSystemButton -Action Snooze -SelectionBoxId snooze
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an object only')]
    [CmdletBinding()]
    param(
        # Snooze or Dismiss.
        [Parameter(Mandatory, Position = 0)][ValidateSet('Snooze', 'Dismiss')][string]$Action,
        # Button text. Empty = localized Windows default.
        [string]$Content = '',
        # Snooze only: id of a New-ToastSelectionBox whose keys are minutes.
        [string]$SelectionBoxId
    )
    $a = [ordered]@{ content = $Content; arguments = $Action.ToLower(); activationType = 'system' }
    if ($SelectionBoxId) { $a.'hint-inputId' = $SelectionBoxId }
    [pscustomobject]@{ Kind = 'action'; Attributes = $a }
}

function New-ToastTextBox {
    <#
    .SYNOPSIS
        Creates a text input for Show-Toast -Inputs. The value is returned in the result of Show-Toast -Wait.
    .EXAMPLE
        New-ToastTextBox -Id reply -PlaceholderContent 'Type a reply'
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an object only')]
    [CmdletBinding()]
    param(
        # Key of the value in UserInput.
        [Parameter(Mandatory, Position = 0)][string]$Id,
        # Label above the box.
        [string]$Title,
        # Grey hint text.
        [string]$PlaceholderContent,
        # Prefilled text.
        [string]$DefaultInput
    )
    $a = [ordered]@{ id = $Id; type = 'text' }
    if ($Title) { $a.title = $Title }
    if ($PlaceholderContent) { $a.placeHolderContent = $PlaceholderContent }
    if ($DefaultInput) { $a.defaultInput = $DefaultInput }
    [pscustomobject]@{ Kind = 'input'; Attributes = $a; Items = $null }
}

function New-ToastSelectionBox {
    <#
    .SYNOPSIS
        Creates a drop-down for Show-Toast -Inputs.
    .EXAMPLE
        New-ToastSelectionBox -Id snooze -Items ([ordered]@{ '5' = '5 minutes'; '60' = '1 hour' })
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an object only')]
    [CmdletBinding()]
    param(
        # Key of the value in UserInput.
        [Parameter(Mandatory, Position = 0)][string]$Id,
        # Entries: key = returned value, value = displayed text. Use [ordered] to keep the order.
        [Parameter(Mandatory, Position = 1)][Collections.IDictionary]$Items,
        # Label above the box.
        [string]$Title,
        # Key of the preselected entry.
        [string]$DefaultSelection
    )
    $a = [ordered]@{ id = $Id; type = 'selection' }
    if ($Title) { $a.title = $Title }
    if ($DefaultSelection) { $a.defaultInput = $DefaultSelection }
    [pscustomobject]@{ Kind = 'input'; Attributes = $a; Items = $Items }
}

function New-ToastProgressBar {
    <#
    .SYNOPSIS
        Creates a progress bar for Show-Toast -ProgressBar. Update it later with Update-ToastProgress.
    .EXAMPLE
        New-ToastProgressBar -Status 'Downloading' -Value 0.25 -Title 'setup.exe' -ValueDisplay '25 / 100 MB'
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an object only')]
    [CmdletBinding()]
    param(
        # Text below the bar.
        [Parameter(Position = 0)][string]$Status = '',
        # 0 to 1, or 'indeterminate'.
        [Parameter(Position = 1)][string]$Value = '0',
        # Text above the bar.
        [string]$Title,
        # Replaces the percentage shown on the right.
        [string]$ValueDisplay
    )
    [pscustomobject]@{ Kind = 'progress'; Status = $Status; Value = $Value; Title = $Title; ValueDisplay = $ValueDisplay }
}

function New-ToastHeader {
    <#
    .SYNOPSIS
        Creates a header that groups toasts in the notification center.
    .EXAMPLE
        New-ToastHeader -Id builds -Title 'Builds'
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates an object only')]
    [CmdletBinding()]
    param(
        # Toasts with the same header id are grouped.
        [Parameter(Mandatory, Position = 0)][string]$Id,
        # Header text.
        [Parameter(Mandatory, Position = 1)][string]$Title,
        # Arguments when the header is clicked.
        [string]$Arguments = '',
        # Foreground or Protocol (Arguments = URL).
        [ValidateSet('Foreground', 'Protocol')][string]$ActivationType = 'Foreground'
    )
    [pscustomobject]@{ Kind = 'header'; Attributes = [ordered]@{ id = $Id; title = $Title; arguments = $Arguments; activationType = $ActivationType.ToLower() } }
}

function New-ToastXml {
    <#
    .SYNOPSIS
        Builds toast XML (ToastGeneric template). Takes the content parameters of Show-Toast.
    .EXAMPLE
        New-ToastXml -Title Hello -Body World
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Creates a string only')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        # First line, bold.
        [Parameter(Position = 0)][string]$Title,
        # Further lines (up to two are shown).
        [Parameter(Position = 1)][string[]]$Body,
        # Small grey source line at the bottom.
        [string]$Attribution,
        # Image replacing the app icon.
        [string]$AppLogo,
        # Crop the app logo to a circle.
        [switch]$AppLogoCircle,
        # Wide image at the top.
        [string]$HeroImage,
        # Inline images below the text.
        [string[]]$Image,
        # Default, Reminder, Alarm, IncomingCall or Urgent. Reminder/Alarm/IncomingCall stay until dismissed and need a button.
        [ValidateSet('Default', 'Reminder', 'Alarm', 'IncomingCall', 'Urgent')][string]$Scenario = 'Default',
        # Short (system default) or Long (25 s).
        [ValidateSet('Short', 'Long')][string]$Duration,
        # Arguments when the toast body is clicked.
        [string]$Launch,
        # Activation type of the toast body.
        [ValidateSet('Foreground', 'Background', 'Protocol')][string]$ActivationType,
        # Custom time shown in the toast.
        [datetime]$Timestamp,
        # Windows notification sound. Alarm* and Call* are looping sounds.
        [ValidateSet('Default', 'IM', 'Mail', 'Reminder', 'SMS', 'Alarm', 'Alarm2', 'Alarm3', 'Alarm4', 'Alarm5', 'Alarm6', 'Alarm7', 'Alarm8', 'Alarm9', 'Alarm10',
            'Call', 'Call2', 'Call3', 'Call4', 'Call5', 'Call6', 'Call7', 'Call8', 'Call9', 'Call10')][string]$Sound,
        # Custom sound file.
        [string]$SoundFile,
        # Repeat the sound until the toast is dismissed (needs -Duration Long or a persistent scenario).
        [switch]$LoopSound,
        # No sound.
        [switch]$Silent,
        # Objects from New-ToastButton / New-ToastSystemButton (max. 5).
        [object[]]$Buttons,
        # Objects from New-ToastTextBox / New-ToastSelectionBox.
        [object[]]$Inputs,
        # Objects from New-ToastProgressBar.
        [object[]]$ProgressBar,
        # Object from New-ToastHeader.
        [object]$Header,
        # Enable colored buttons (New-ToastButton -Style).
        [switch]$ButtonStyles
    )
    $x = New-Object -TypeName Xml.XmlDocument
    $el = {
        param($parent, $name, $attrs)
        $e = $x.CreateElement($name)
        if ($attrs) { foreach ($k in $attrs.Keys) { if ($null -ne $attrs[$k]) { $e.SetAttribute($k, [string]$attrs[$k]) } } }
        [void]$parent.AppendChild($e)
        $e
    }

    $t = & $el $x 'toast' $null
    if ($Scenario -ne 'Default') { $t.SetAttribute('scenario', $Scenario.Substring(0, 1).ToLower() + $Scenario.Substring(1)) }
    if ($Duration) { $t.SetAttribute('duration', $Duration.ToLower()) }
    if ($Launch) { $t.SetAttribute('launch', $Launch) }
    if ($ActivationType) { $t.SetAttribute('activationType', $ActivationType.ToLower()) }
    if ($PSBoundParameters.ContainsKey('Timestamp')) { $t.SetAttribute('displayTimestamp', $Timestamp.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")) }
    if ($ButtonStyles) { $t.SetAttribute('useButtonStyle', 'true') }
    if ($Header) { [void](& $el $t 'header' $Header.Attributes) }

    $b = & $el (& $el $t 'visual' $null) 'binding' @{ template = 'ToastGeneric' }
    foreach ($line in @($Title) + @($Body) | Where-Object { $_ }) { (& $el $b 'text' $null).InnerText = $line }
    if ($Attribution) { (& $el $b 'text' @{ placement = 'attribution' }).InnerText = $Attribution }
    if ($AppLogo) { [void](& $el $b 'image' ([ordered]@{ placement = 'appLogoOverride'; src = (ConvertTo-ToastUri $AppLogo); 'hint-crop' = $(if ($AppLogoCircle) { 'circle' }) })) }
    if ($HeroImage) { [void](& $el $b 'image' @{ placement = 'hero'; src = (ConvertTo-ToastUri $HeroImage) }) }
    foreach ($i in @($Image) | Where-Object { $_ }) { [void](& $el $b 'image' @{ src = (ConvertTo-ToastUri $i) }) }
    $n = 0
    foreach ($p in @($ProgressBar) | Where-Object { $_ }) {
        # bound values ({progressValue}, ...) so Update-ToastProgress can change them
        $sfx = if ($n) { "$n" } else { '' }; $n++
        $a = [ordered]@{ status = "{progressStatus$sfx}"; value = "{progressValue$sfx}" }
        if ($p.Title) { $a.title = "{progressTitle$sfx}" }
        if ($p.ValueDisplay) { $a.valueStringOverride = "{progressValueString$sfx}" }
        [void](& $el $b 'progress' $a)
    }

    if ($Silent -or $Sound -or $SoundFile -or $LoopSound) {
        $src = if ($SoundFile) { ConvertTo-ToastUri $SoundFile } elseif ($Sound -match '^(Alarm|Call)') { "ms-winsoundevent:Notification.Looping.$Sound" } elseif ($Sound) { "ms-winsoundevent:Notification.$Sound" }
        [void](& $el $t 'audio' ([ordered]@{ src = $src; loop = $(if ($LoopSound) { 'true' }); silent = $(if ($Silent) { 'true' }) }))
    }

    if ($Buttons -or $Inputs) {
        $acts = & $el $t 'actions' $null
        foreach ($in in @($Inputs) | Where-Object { $_ }) {
            $ie = & $el $acts 'input' $in.Attributes
            if ($in.Items) { foreach ($k in $in.Items.Keys) { [void](& $el $ie 'selection' ([ordered]@{ id = $k; content = $in.Items[$k] })) } }
        }
        foreach ($btn in @($Buttons) | Where-Object { $_ }) { [void](& $el $acts 'action' $btn.Attributes) }
    }
    $x.OuterXml
}

function Get-ProgressData([object[]]$ProgressBar) {
    $d = @{}; $n = 0
    foreach ($p in @($ProgressBar) | Where-Object { $_ }) {
        $sfx = if ($n) { "$n" } else { '' }; $n++
        $d["progressStatus$sfx"] = $p.Status; $d["progressValue$sfx"] = $p.Value
        if ($p.Title) { $d["progressTitle$sfx"] = $p.Title }
        if ($p.ValueDisplay) { $d["progressValueString$sfx"] = $p.ValueDisplay }
    }
    $d
}

#endregion

#region Show, update, remove

function Show-Toast {
    <#
    .SYNOPSIS
        Shows a toast notification. Gets through Do Not Disturb if needed.
    .DESCRIPTION
        Supports every option of the ToastGeneric schema plus tag/group, expiration, priority, scheduling, progress
        updates, events and banner display time. With -BreakThrough Auto (default) LoudToast predicts whether Do Not
        Disturb would suppress the banner (Test-ToastBreakThrough). If so, it turns DND off for the banner's lifetime
        and restores it from a hidden process afterwards.
    .EXAMPLE
        Show-Toast 'Build finished' 'All tests passed'
    .EXAMPLE
        $r = Show-Toast 'Deploy?' -Inputs (New-ToastTextBox note) -Buttons (New-ToastButton Yes yes), (New-ToastButton No no) -Wait
        $r.Arguments; $r.UserInput.note
    .EXAMPLE
        Show-Toast 'Download' -ProgressBar (New-ToastProgressBar 'Starting' 0) -Tag dl
        Update-ToastProgress -Tag dl -Value 0.5 -Status 'Half way'
    .EXAMPLE
        Show-Toast 'Stand-up' 'in 5 minutes' -Scenario Reminder -Inputs (New-ToastSelectionBox snooze ([ordered]@{ '5' = '5 min'; '15' = '15 min' })) `
            -Buttons (New-ToastSystemButton Snooze -SelectionBoxId snooze), (New-ToastSystemButton Dismiss)
    .OUTPUTS
        With -Wait: an object with Result (Activated, Dismissed, Failed, Timeout), Arguments, UserInput, Reason, ErrorCode, Tag.
    #>

    [CmdletBinding()]
    param(
        # First line, bold.
        [Parameter(Position = 0)][string]$Title,
        # Further lines (up to two are shown).
        [Parameter(Position = 1)][string[]]$Body,
        # Small grey source line at the bottom.
        [string]$Attribution,
        # Image replacing the app icon.
        [string]$AppLogo,
        # Crop the app logo to a circle.
        [switch]$AppLogoCircle,
        # Wide image at the top.
        [string]$HeroImage,
        # Inline images below the text.
        [string[]]$Image,
        # Default, Reminder, Alarm, IncomingCall or Urgent. Reminder/Alarm/IncomingCall stay until dismissed and need a button.
        [ValidateSet('Default', 'Reminder', 'Alarm', 'IncomingCall', 'Urgent')][string]$Scenario = 'Default',
        # Short (system default) or Long (25 s). Ignored with -DisplaySeconds.
        [ValidateSet('Short', 'Long')][string]$Duration,
        # Arguments when the toast body is clicked.
        [string]$Launch,
        # Activation type of the toast body.
        [ValidateSet('Foreground', 'Background', 'Protocol')][string]$ActivationType,
        # Custom time shown in the toast.
        [datetime]$Timestamp,
        # Windows notification sound. Alarm* and Call* are looping sounds.
        [ValidateSet('Default', 'IM', 'Mail', 'Reminder', 'SMS', 'Alarm', 'Alarm2', 'Alarm3', 'Alarm4', 'Alarm5', 'Alarm6', 'Alarm7', 'Alarm8', 'Alarm9', 'Alarm10',
            'Call', 'Call2', 'Call3', 'Call4', 'Call5', 'Call6', 'Call7', 'Call8', 'Call9', 'Call10')][string]$Sound,
        # Custom sound file.
        [string]$SoundFile,
        # Repeat the sound until the toast is dismissed.
        [switch]$LoopSound,
        # No sound.
        [switch]$Silent,
        # Objects from New-ToastButton / New-ToastSystemButton (max. 5).
        [object[]]$Buttons,
        # Objects from New-ToastTextBox / New-ToastSelectionBox.
        [object[]]$Inputs,
        # Objects from New-ToastProgressBar.
        [object[]]$ProgressBar,
        # Object from New-ToastHeader.
        [object]$Header,
        # Enable colored buttons (New-ToastButton -Style).
        [switch]$ButtonStyles,
        # Your own toast XML instead of the content parameters.
        [string]$Xml,
        # Application User Model ID. Defaults to Get-ToastDefaultAppId.
        [string]$AppId = $script:DefaultAppId,
        # Identifier for updating/removing this toast. Same tag and group replaces an existing toast.
        [string]$Tag,
        # Group of the tag.
        [string]$Group = $script:DefaultGroup,
        # Remove the toast from the notification center at this time.
        [datetime]$ExpirationTime,
        # Only add to the notification center, no banner.
        [switch]$SuppressPopup,
        # Sort to the top of the notification center.
        [switch]$HighPriority,
        # Remove the toast on reboot.
        [switch]$ExpiresOnReboot,
        # Schedule the toast for this time. Do Not Disturb handling does not apply.
        [datetime]$At,
        # How long the banner stays on screen, in seconds (minimum 5). Default: Get-ToastDisplayDuration.
        [ValidateRange(1, 3600)][int]$DisplaySeconds,
        # Auto: turn Do Not Disturb off temporarily if it would hide the banner. Never: respect it. KeepOff: turn it off and leave it off.
        [ValidateSet('Auto', 'Never', 'KeepOff')][string]$BreakThrough = 'Auto',
        # When to restore Do Not Disturb. Default: banner display time + 2 s.
        [int]$BreakThroughSeconds,
        # Block until the toast is clicked, dismissed or times out, and return the result.
        [switch]$Wait,
        # Timeout for -Wait.
        [int]$TimeoutSeconds = 300,
        # Return the Windows.UI.Notifications.ToastNotification object.
        [switch]$PassThru,
        # Return the generated XML without showing anything.
        [switch]$AsXml
    )
    if (-not $Xml) {
        $xp = @{}
        foreach ($k in (Get-Command -Name New-ToastXml).Parameters.Keys) { if ($PSBoundParameters.ContainsKey($k)) { $xp[$k] = $PSBoundParameters[$k] } }
        if ($DisplaySeconds) { [void]$xp.Remove('Duration') }  # duration="long" is fixed and would override DisplaySeconds
        $Xml = New-ToastXml @xp
    }
    if ($AsXml) { return $Xml }
    if (-not $Tag -and ($ProgressBar -or $Wait)) { $Tag = [guid]::NewGuid().ToString('n').Substring(0, 16) }

    if ($PSBoundParameters.ContainsKey('At')) {
        [LoudToast.Toasts]::Schedule($AppId, $Xml, $At, $Tag, $Group, [bool]$SuppressPopup)
        return
    }

    if ($BreakThrough -ne 'Never' -and -not $SuppressPopup) {
        $scenarioAttr = ([xml]$Xml).toast.GetAttribute('scenario')
        if (-not (Test-ToastBreakThrough -AppId $AppId -Scenario $(if ($scenarioAttr) { $scenarioAttr } else { 'default' }) -Tag $Tag).BreaksThrough) {
            if (-not $BreakThroughSeconds) {
                $banner = if ($DisplaySeconds) { [Math]::Max(5, $DisplaySeconds) } elseif ($Duration -eq 'Long') { 25 } else { Get-ToastDisplayDuration }
                $BreakThroughSeconds = $banner + 2
            }
            if ($BreakThrough -eq 'KeepOff') { [void](Disable-Dnd -Confirm:$false) } else { Invoke-BreakThrough $BreakThroughSeconds }
        }
    }

    $exp = if ($PSBoundParameters.ContainsKey('ExpirationTime')) { $ExpirationTime } else { $null }
    $t = [LoudToast.Toasts]::Create($Xml, $Tag, $Group, $exp, [bool]$SuppressPopup, [bool]$HighPriority, [bool]$ExpiresOnReboot, (Get-ProgressData $ProgressBar))
    $w = Invoke-WithDisplayDuration $DisplaySeconds { [LoudToast.Toasts]::Show($AppId, $t, [bool]$Wait) }
    if ($Wait) { $w.Wait($TimeoutSeconds * 1000) | Add-Member -NotePropertyName Tag -NotePropertyValue $Tag -PassThru }
    elseif ($PassThru) { $t }
}

function Update-ToastProgress {
    <#
    .SYNOPSIS
        Updates the progress bar of a toast that is still shown or in the notification center.
    .EXAMPLE
        Update-ToastProgress -Tag dl -Value 0.8 -Status 'Almost done' -ValueDisplay '80 / 100 MB'
    .OUTPUTS
        Succeeded or NotificationNotFound.
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Updates own notification only')]
    [CmdletBinding()]
    [OutputType([string])]
    param(
        # Tag passed to Show-Toast.
        [Parameter(Mandatory)][string]$Tag,
        # Group passed to Show-Toast.
        [string]$Group = $script:DefaultGroup,
        # 0 to 1, or 'indeterminate'.
        [string]$Value,
        # Text below the bar.
        [string]$Status,
        # Text above the bar.
        [string]$Title,
        # Replaces the percentage shown on the right.
        [string]$ValueDisplay,
        # Which progress bar, if the toast has several (0-based).
        [int]$Index = 0,
        # Application User Model ID. Defaults to Get-ToastDefaultAppId.
        [string]$AppId = $script:DefaultAppId
    )
    $sfx = if ($Index) { "$Index" } else { '' }
    $d = @{}
    if ($PSBoundParameters.ContainsKey('Value')) { $d["progressValue$sfx"] = $Value }
    if ($PSBoundParameters.ContainsKey('Status')) { $d["progressStatus$sfx"] = $Status }
    if ($PSBoundParameters.ContainsKey('Title')) { $d["progressTitle$sfx"] = $Title }
    if ($PSBoundParameters.ContainsKey('ValueDisplay')) { $d["progressValueString$sfx"] = $ValueDisplay }
    # Sequence numbers must increase, also across processes: tenths of a second since 2025
    $seq = [uint32](([DateTime]::UtcNow - [DateTime]'2025-01-01').TotalMilliseconds / 100)
    if ((Test-Path -Path variable:script:Seq) -and $seq -le $script:Seq) { $seq = $script:Seq + 1 }
    $script:Seq = $seq
    [LoudToast.Toasts]::Update($AppId, $Tag, $Group, $d, $seq)
}

function Remove-Toast {
    <#
    .SYNOPSIS
        Removes toasts from the notification center, or scheduled toasts.
    .EXAMPLE
        Remove-Toast -Tag dl
    .EXAMPLE
        Remove-Toast -All
    #>

    [CmdletBinding(SupportsShouldProcess)]
    param(
        # Tag of the toast.
        [string]$Tag,
        # Group of the tag. Without -Tag: removes the whole group.
        [string]$Group = $script:DefaultGroup,
        # Remove all toasts of the app.
        [switch]$All,
        # Application User Model ID. Defaults to Get-ToastDefaultAppId.
        [string]$AppId = $script:DefaultAppId,
        # Act on scheduled toasts instead of shown ones.
        [switch]$Scheduled
    )
    $t = if ($Tag) { $Tag } else { $null }
    $g = if ($All -or ($Scheduled -and -not $PSBoundParameters.ContainsKey('Group'))) { $null } else { $Group }
    $target = if ($All) { "all toasts of $AppId" } else { "tag '$Tag' group '$g' of $AppId" }
    if (-not $PSCmdlet.ShouldProcess($target, $(if ($Scheduled) { 'Remove scheduled toasts' } else { 'Remove toasts' }))) { return }
    if ($Scheduled) { [LoudToast.Toasts]::Unschedule($AppId, $t, $g) }
    elseif ($All) { [LoudToast.Toasts]::Remove($AppId, $null, $null) }
    else { [LoudToast.Toasts]::Remove($AppId, $t, $g) }
}

function Get-ToastHistory {
    <#
    .SYNOPSIS
        Lists the app's toasts in the notification center, or its scheduled toasts.
    .EXAMPLE
        Get-ToastHistory -Scheduled
    #>

    [CmdletBinding()]
    param(
        # Application User Model ID. Defaults to Get-ToastDefaultAppId.
        [string]$AppId = $script:DefaultAppId,
        # List scheduled toasts.
        [switch]$Scheduled
    )
    if ($Scheduled) {
        [LoudToast.Toasts]::Scheduled($AppId) | ForEach-Object { $p = $_ -split '\|'; [pscustomobject]@{ Tag = $p[0]; Group = $p[1]; DeliveryTime = [datetime]$p[2] } }
    }
    else { [LoudToast.Toasts]::History($AppId) | ForEach-Object { [pscustomobject]$_ } }
}

function Show-Balloon {
    <#
    .SYNOPSIS
        Shows a classic notification area balloon. Gets through Do Not Disturb if needed.
    .DESCRIPTION
        Windows turns balloons into banner-only toasts under the process AppUserModelID (set it with
        Set-ToastDefaultAppId) or a generated NotifyIconGeneratedAumid_* identity. The command blocks while the
        balloon is visible, because the tray icon has to stay alive.
    .EXAMPLE
        Show-Balloon 'Disk almost full' 'C: has 2 GB left' -Icon Warning -Seconds 10
    #>

    [CmdletBinding()]
    param(
        # Bold first line.
        [Parameter(Mandatory, Position = 0)][string]$Title,
        # Message text.
        [Parameter(Position = 1)][string]$Text = ' ',
        # None, Info, Warning, Error, or a path to an .ico file.
        [string]$Icon = 'Info',
        # How long the balloon stays on screen (minimum 5).
        [ValidateRange(1, 3600)][int]$Seconds = 6,
        # Auto: turn Do Not Disturb off temporarily if it would hide the balloon. Never: respect it. KeepOff: turn it off and leave it off.
        [ValidateSet('Auto', 'Never', 'KeepOff')][string]$BreakThrough = 'Auto'
    )
    if ($BreakThrough -ne 'Never') {
        $ptr = [IntPtr]::Zero
        $id = if ([LoudToast.Native]::GetCurrentProcessExplicitAppUserModelID([ref]$ptr) -eq 0) { [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) } else { '' }
        if (-not (Test-ToastBreakThrough -AppId $id).BreaksThrough) {
            if ($BreakThrough -eq 'KeepOff') { [void](Disable-Dnd -Confirm:$false) } else { Invoke-BreakThrough ([Math]::Max(5, $Seconds) + 2) }
        }
    }
    $ni = New-Object -TypeName Windows.Forms.NotifyIcon
    try {
        $tip = [Windows.Forms.ToolTipIcon]::None
        if ($Icon -in 'None', 'Info', 'Warning', 'Error') { $tip = [Windows.Forms.ToolTipIcon]$Icon; $ni.Icon = [Drawing.SystemIcons]::Information }
        else { $ni.Icon = New-Object -TypeName Drawing.Icon -ArgumentList (Resolve-Path -Path $Icon).ProviderPath }
        $ni.Visible = $true
        Invoke-WithDisplayDuration $Seconds { $ni.ShowBalloonTip($Seconds * 1000, $Title, $Text, $tip) }  # Windows ignores the timeout argument
        Start-Sleep -Seconds ([Math]::Max(5, $Seconds) - 1)
    }
    finally { $ni.Dispose() }
}

#endregion