From efe801e7f6764180b5417616b0697a18b37fa7de Mon Sep 17 00:00:00 2001 From: Josh King Date: Thu, 24 Jul 2025 20:56:15 +1200 Subject: [PATCH 1/2] (vibe)(#256) Deduplicate event handlers using ScriptBlock hash - Add Get-BTScriptBlockHash to create normalized hashes for ScriptBlocks - Prevent duplicate ObjectEvent registrations by hash in Submit-BTNotification - Add user warnings and robust try/catch logic for duplicates - Update comment-based and markdown help with deduplication approach - Expanded Pester coverage, including tests for hash function, event deduplication, warning, and unique registration --- Help/Submit-BTNotification.md | 2 + Tests/PrivateHelpers.Tests.ps1 | 31 +++++++++++++++ Tests/Submit-BTNotification.Tests.ps1 | 31 +++++++++++++++ src/Private/Get-BTScriptBlockHash.ps1 | 35 +++++++++++++++++ src/Public/Submit-BTNotification.ps1 | 55 ++++++++++++++++++++++++--- 5 files changed, 149 insertions(+), 5 deletions(-) create mode 100644 src/Private/Get-BTScriptBlockHash.ps1 diff --git a/Help/Submit-BTNotification.md b/Help/Submit-BTNotification.md index 62acc55..173f68e 100644 --- a/Help/Submit-BTNotification.md +++ b/Help/Submit-BTNotification.md @@ -9,6 +9,8 @@ Submits a completed toast notification for display. The `Submit-BTNotification` function submits a completed toast notification to the operating system's notification manager for display. This function supports advanced scenarios such as event callbacks for user actions or toast dismissal, sequence numbering to ensure correct update order, unique identification for toast replacement, expiration control, and direct Action Center delivery. +When a script block is supplied for any of the event actions (`ActivatedAction`, `DismissedAction`, or `FailedAction`), the function ensures that only one registration for a logically identical handler is allowed per notification event type. This is accomplished by normalizing and hashing the script block; the resulting hash uniquely identifies the action for event registration purposes. Attempting to register the same handler multiple times for a given event will not create a duplicate subscription, but instead will produce an informative warning. + If the `-ReturnEventData` switch is used and any event action scriptblocks are supplied (`ActivatedAction`, `DismissedAction`, `FailedAction`), the `$Event` automatic variable from the event will be assigned to `$global:ToastEvent` before invoking your script block. You can override the variable name used for event data by specifying `-EventDataVariable`. diff --git a/Tests/PrivateHelpers.Tests.ps1 b/Tests/PrivateHelpers.Tests.ps1 index a525e6e..694ef09 100644 --- a/Tests/PrivateHelpers.Tests.ps1 +++ b/Tests/PrivateHelpers.Tests.ps1 @@ -8,6 +8,7 @@ BeforeAll { . "$PSScriptRoot/../src/Private/Add-PipelineObject.ps1" . "$PSScriptRoot/../src/Private/Optimize-BTImageSource.ps1" + . "$PSScriptRoot/../src/Private/Get-BTScriptBlockHash.ps1" } Describe 'Add-PipelineObject' { @@ -69,4 +70,34 @@ Describe 'Optimize-BTImageSource' { } } } +} +Describe 'Get-BTScriptBlockHash' { + It 'returns a consistent hash for identical scriptblocks' { + $sb1 = { Write-Host 'foo'; "bar" } + $sb2 = { Write-Host 'foo'; "bar" } + $hash1 = Get-BTScriptBlockHash $sb1 + $hash2 = Get-BTScriptBlockHash $sb2 + $hash1 | Should -BeExactly $hash2 + } + It 'is whitespace-agnostic for logically identical scriptblocks' { + $sb1 = {Write-Host 'foo'; "bar"} + $sb2 = { + Write-Host + 'foo' + ;"bar" + } + $hash1 = Get-BTScriptBlockHash $sb1 + $hash2 = Get-BTScriptBlockHash $sb2 + $hash1 | Should -BeExactly $hash2 + } + It 'distinguishes truly different scriptblocks' { + $sb1 = { Write-Host 'foo' } + $sb2 = { Write-Host 'bar' } + Get-BTScriptBlockHash $sb1 | Should -Not -BeExactly (Get-BTScriptBlockHash $sb2) + } + It 'hashes scriptblocks containing only whitespace the same as empty' { + $sb1 = { } + $sb2 = { } + Get-BTScriptBlockHash $sb1 | Should -BeExactly (Get-BTScriptBlockHash $sb2) + } } \ No newline at end of file diff --git a/Tests/Submit-BTNotification.Tests.ps1 b/Tests/Submit-BTNotification.Tests.ps1 index b7d8297..03fcfe8 100644 --- a/Tests/Submit-BTNotification.Tests.ps1 +++ b/Tests/Submit-BTNotification.Tests.ps1 @@ -16,4 +16,35 @@ Describe 'Submit-BTNotification' { { Submit-BTNotification -Content $mockContent -UniqueIdentifier 'Toast001' -WhatIf } | Should -Not -Throw } } + Context 'event handler deduplication/registration' { + It 'registers a unique action event without error' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + $action = { Write-Host "Activated!" } + { Submit-BTNotification -Content $mockContent -ActivatedAction $action -WhatIf } | Should -Not -Throw + } + It 'warns and does not throw on duplicate action registration' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + $action = { Write-Host "Activated!" } + + $transcriptPath = "$env:TEMP\BTTranscript-$PID.txt" + Start-Transcript -Path $transcriptPath -Force | Out-Null + try { + $null = Submit-BTNotification -Content $mockContent -ActivatedAction $action -WhatIf 2>&1 + } finally { + Stop-Transcript | Out-Null + } + + $transcript = Get-Content $transcriptPath -Raw + $transcript | Should -Match "Duplicate or conflicting OnActivated ScriptBlock event detected" + Remove-Item $transcriptPath -Force + } + It 'allows distinct handler actions without deduplication warning' { + $mockContent = [Activator]::CreateInstance([Microsoft.Toolkit.Uwp.Notifications.ToastContent]) + $a1 = { Write-Host "A" } + $a2 = { Write-Host "B" } + $null = Submit-BTNotification -Content $mockContent -ActivatedAction $a1 -WhatIf 2>&1 + $output = Submit-BTNotification -Content $mockContent -ActivatedAction $a2 -WhatIf 2>&1 + $output | Should -Not -Contain "Duplicate or conflicting OnActivated ScriptBlock event detected" + } + } } \ No newline at end of file diff --git a/src/Private/Get-BTScriptBlockHash.ps1 b/src/Private/Get-BTScriptBlockHash.ps1 new file mode 100644 index 0000000..61ef88e --- /dev/null +++ b/src/Private/Get-BTScriptBlockHash.ps1 @@ -0,0 +1,35 @@ +function Get-BTScriptBlockHash { + <# + .SYNOPSIS + Returns a normalized SHA256 hash for a ScriptBlock. + + .DESCRIPTION + Converts the ScriptBlock to string, collapses whitespace, trims, lowercases, and returns SHA256 hash. + Used to uniquely identify ScriptBlocks for event registration scenarios. + + .PARAMETER ScriptBlock + The [scriptblock] to hash. + + .INPUTS + System.Management.Automation.ScriptBlock + + .OUTPUTS + String (SHA256 hex) + + .EXAMPLE + $hash = Get-BTScriptBlockHash { Write-Host 'Hello' } + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory, ValueFromPipeline)] + [scriptblock]$ScriptBlock + ) + process { + # Remove all whitespace and semicolons for robust logical identity + $normalized = ($ScriptBlock.ToString() -replace '[\s;]+', '').ToLowerInvariant() + $bytes = [System.Text.Encoding]::UTF8.GetBytes($normalized) + $sha = [System.Security.Cryptography.SHA256]::Create() + $hashBytes = $sha.ComputeHash($bytes) + -join ($hashBytes | ForEach-Object { $_.ToString('x2') }) + } +} \ No newline at end of file diff --git a/src/Public/Submit-BTNotification.ps1 b/src/Public/Submit-BTNotification.ps1 index 74c176e..387a30a 100644 --- a/src/Public/Submit-BTNotification.ps1 +++ b/src/Public/Submit-BTNotification.ps1 @@ -7,6 +7,9 @@ The Submit-BTNotification function submits a completed toast notification to the operating system's notification manager for display. This function supports advanced scenarios such as event callbacks for user actions or toast dismissal, sequence numbering to ensure correct update order, unique identification for toast replacement, expiration control, and direct Action Center delivery. + When an action ScriptBlock is supplied (Activated, Dismissed, or Failed), a normalized SHA256 hash of its content is used to generate a unique SourceIdentifier for event registration. + This prevents duplicate handler registration for the same ScriptBlock, warning if a duplicate registration is attempted. + If the -ReturnEventData switch is used and any event action scriptblocks are supplied (ActivatedAction, DismissedAction, FailedAction), the $Event automatic variable from the event will be assigned to $global:ToastEvent before invoking your script block. You can override the variable name used for event data by specifying -EventDataVariable. If supplied, the event data will be assigned to the chosen global variable in your event handler (e.g., -EventDataVariable 'CustomEvent' results in $global:CustomEvent). @@ -174,7 +177,6 @@ if ($ReturnEventData -or $EventDataVariable -ne 'ToastEvent') { $EventReturn = '$global:{0} = $Event' -f $EventDataVariable - if ($ActivatedAction) { $Action_Activated = [ScriptBlock]::Create($EventReturn + "`n" + $Action_Activated.ToString()) } @@ -187,15 +189,58 @@ } if ($Action_Activated) { - Register-ObjectEvent -InputObject $CompatMgr -EventName OnActivated -Action $Action_Activated | Out-Null + try { + $ActivatedHash = Get-BTScriptBlockHash $Action_Activated + $activatedParams = @{ + InputObject = $CompatMgr + EventName = 'OnActivated' + Action = $Action_Activated + SourceIdentifier = "BT_Activated_$ActivatedHash" + ErrorAction = 'Stop' + } + Register-ObjectEvent @activatedParams | Out-Null + } catch { + Write-Warning "Duplicate or conflicting OnActivated ScriptBlock event detected: Activation action not registered. $_" + } + <# + EDGE CASES / NOTES + - Hash collisions: In the rare event that two different ScriptBlocks normalize to the same text, they will share a SourceIdentifier and not both be registered. + - Only ScriptBlocks are handled: if a non-ScriptBlock is supplied where an action is expected, registration will fail. + - Actions with dynamic or closure content: If `ToString()` outputs identical strings for two blocks with different closure state, only one event will register. + - User warnings: Any error during event registration (including duplicate) triggers a user-facing warning instead of otherwise disrupting notification flow. + #> } - if ($Action_Dismissed -or $Action_Dismissed) { + if ($Action_Dismissed -or $Action_Failed) { if ($Script:ActionsSupported) { if ($Action_Dismissed) { - Register-ObjectEvent -InputObject $Toast -EventName Dismissed -Action $Action_Dismissed | Out-Null + try { + $DismissedHash = Get-BTScriptBlockHash $Action_Dismissed + $dismissedParams = @{ + InputObject = $Toast + EventName = 'Dismissed' + Action = $Action_Dismissed + SourceIdentifier = "BT_Dismissed_$DismissedHash" + ErrorAction = 'Stop' + } + Register-ObjectEvent @dismissedParams | Out-Null + } catch { + Write-Warning "Duplicate or conflicting Dismissed ScriptBlock event detected: Dismissed action not registered. $_" + } } if ($Action_Failed) { - Register-ObjectEvent -InputObject $Toast -EventName Failed -Action $Action_Failed | Out-Null + try { + $FailedHash = Get-BTScriptBlockHash $Action_Failed + $failedParams = @{ + InputObject = $Toast + EventName = 'Failed' + Action = $Action_Failed + SourceIdentifier = "BT_Failed_$FailedHash" + ErrorAction = 'Stop' + } + Register-ObjectEvent @failedParams | Out-Null + } catch { + Write-Warning "Duplicate or conflicting Failed ScriptBlock event detected: Failed action not registered. $_" + } } } else { Write-Warning $Script:UnsupportedEvents From d6005d732dbbf7d08107849e67f1b84617b3c6eb Mon Sep 17 00:00:00 2001 From: Josh King Date: Thu, 24 Jul 2025 21:31:15 +1200 Subject: [PATCH 2/2] (release) v1.0.1 --- CHANGES.md | 8 ++++++++ README.md | 8 ++++++++ src/BurntToast.psd1 | 10 ++++++++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 23b17bd..14ca2ce 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,13 @@ # Full Change Log +- [v1.0.1](https://github.com/Windos/BurntToast/releases/download/v1.0.1/BurntToast.zip) + + - Bug Fixes + + - OnActivated events are "sticky" + + - See #256 by [Windos](https://github.com/Windos) + - [1.0.0](https://github.com/Windos/BurntToast/releases/download/v1.0.0/BurntToast.zip) - Breaking Changes diff --git a/README.md b/README.md index 0ee0078..b14907c 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,14 @@ See the [Chocolatey community package](https://chocolatey.org/packages/burnttoas ## Releases +### [v1.0.1](https://github.com/Windos/BurntToast/releases/download/v1.0.1/BurntToast.zip) + +#### Bug Fixes + +- OnActivated events are "sticky" + + - See #256 by [Windos](https://github.com/Windos) + ### [v1.0.0](https://github.com/Windos/BurntToast/releases/download/v1.0.0/BurntToast.zip) #### Breaking Changes diff --git a/src/BurntToast.psd1 b/src/BurntToast.psd1 index 24fe4dd..4a4bca0 100644 --- a/src/BurntToast.psd1 +++ b/src/BurntToast.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'BurntToast.psm1' - ModuleVersion = '1.0.0' + ModuleVersion = '1.0.1' # Can only use CompatiblePSEditions if PowerShellVersion is set to 5.1, not sure about limiting this to that version yet. # CompatiblePSEditions = @('Desktop') GUID = '751a2aeb-a68f-422e-a2ea-376bdd81612a' @@ -38,7 +38,13 @@ LicenseUri = 'https://github.com/Windos/BurntToast/blob/main/LICENSE' ProjectUri = 'https://github.com/Windos/BurntToast' IconUri = 'https://rawcdn.githack.com/Windos/BurntToast/3dd8dd7457552056da4bbd27880f8283e1116395/Media/BurntToast-Logo.png' - ReleaseNotes = '# 1.0.0 + ReleaseNotes = '# 1.0.1 + +* Bug Fixes + * OnActivated events are "sticky" + * See #256 by [Windos](https://github.com/Windos) + +# 1.0.0 * Breaking Changes * Custom Audio Path Removed: Support for custom audio file sources has been eliminated.