feat: add Windows sound playback support#178
Conversation
- Add PowerShell SoundPlayer for playing Warcraft sounds on Windows - Add Windows fallback sound using SystemSounds when files are missing - Add tests for Windows command construction
There was a problem hiding this comment.
Pull request overview
This PR adds Windows support for playing Warcraft II notification sounds using PowerShell's System.Media.SoundPlayer. Previously, the plugin only supported macOS and Linux, logging a warning and skipping sound playback on Windows.
Changes:
- Implemented Windows sound playback using PowerShell's
System.Media.SoundPlayerwith path escaping for single quotes - Added Windows system sound fallback using
System.Media.SystemSounds.Exclamation - Added test coverage for Windows command construction and fallback behavior
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| src/notification.ts | Replaces Windows warning logs with PowerShell commands for playing sounds and system fallback sounds |
| src/notification.behavior.test.ts | Adds tests for Windows PowerShell command construction and system sound fallback |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Escape single quotes in path for PowerShell | ||
| const escapedPath = soundPath.replace(/'/g, "''"); | ||
| await $`powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new('${escapedPath}').Play()"`; |
There was a problem hiding this comment.
The path escaping is incomplete and could lead to command injection vulnerabilities. The current implementation only escapes single quotes, but PowerShell has many other special characters that could break the command context, including backticks (`), dollar signs ($), double quotes ("), and semicolons (;).
For example, a path like C:\test'; Stop-Process -Name powershell; ' would be escaped to C:\test''; Stop-Process -Name powershell; '' which still allows command injection.
Consider using a more robust escaping approach or passing the path as a parameter to avoid injection risks. One safer alternative would be to use the -File parameter with a temporary PowerShell script file, or use proper PowerShell parameter binding.
| // Escape single quotes in path for PowerShell | |
| const escapedPath = soundPath.replace(/'/g, "''"); | |
| await $`powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new('${escapedPath}').Play()"`; | |
| // Pass the path as an argument to avoid manual escaping and injection risks | |
| await $`powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new($args[0]).Play()" -- ${soundPath}`; |
| // Use PowerShell with SoundPlayer - works on all Windows versions | ||
| // Escape single quotes in path for PowerShell | ||
| const escapedPath = soundPath.replace(/'/g, "''"); | ||
| await $`powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new('${escapedPath}').Play()"`; |
There was a problem hiding this comment.
The System.Media.SoundPlayer class is available in the default assemblies loaded by PowerShell and doesn't require explicitly loading System.Windows.Forms. The Add-Type -AssemblyName System.Windows.Forms; portion can be removed to simplify the command.
Additionally, use PlaySync() instead of Play() to ensure the sound completes before PowerShell exits. The asynchronous Play() method returns immediately, which may cause the sound to be cut off when the PowerShell process terminates.
| await $`powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new('${escapedPath}').Play()"`; | |
| await $`powershell -Command "[System.Media.SoundPlayer]::new('${escapedPath}').PlaySync()"`; |
| } else if (process.platform === 'win32') { | ||
| log.warn('Windows sound playback not yet supported', { soundPath }); | ||
| log.warn('Primary sound not found, using system sound', { soundPath }); | ||
| await $`powershell -Command "[System.Media.SystemSounds]::Exclamation.Play()"`; |
There was a problem hiding this comment.
The Play() method is asynchronous and returns immediately without waiting for the sound to finish. This means the PowerShell process may exit before the sound completes playing, resulting in the sound being cut off or not playing at all.
Use PlaySync() instead to ensure the sound plays completely before the PowerShell process exits.
| expect(command).toBe( | ||
| 'powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new(\'C:\\Users\\Test\\sound.wav\').Play()"', | ||
| ); | ||
| }); |
There was a problem hiding this comment.
This test verifies the command construction but doesn't actually test path escaping edge cases that could lead to command injection vulnerabilities. The test uses a simple Windows path without special characters.
Consider adding tests for paths with special PowerShell characters like single quotes, backticks, dollar signs, semicolons, etc. to ensure the escaping logic is robust. For example:
- Path with single quotes:
C:\test's folder\sound.wav - Path with semicolons:
C:\test; rm -rf \sound.wav - Path with backticks:
C:\testfolder\sound.wav`
| }); | |
| }); | |
| it('should escape single quotes in Windows PowerShell command', () => { | |
| Object.defineProperty(process, 'platform', { value: 'win32' }); | |
| const soundPath = "C:\\test's folder\\sound.wav"; | |
| const escapedPath = soundPath.replace(/'/g, "''"); | |
| const command = `powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new('${escapedPath}').Play()"`; | |
| expect(command).toBe( | |
| "powershell -Command \"Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new('C:\\\\test''s folder\\\\sound.wav').Play()\"", | |
| ); | |
| }); | |
| it('should handle semicolons in Windows PowerShell command', () => { | |
| Object.defineProperty(process, 'platform', { value: 'win32' }); | |
| const soundPath = 'C:\\test; rm -rf \\sound.wav'; | |
| const escapedPath = soundPath.replace(/'/g, "''"); | |
| const command = `powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new('${escapedPath}').Play()"`; | |
| expect(command).toBe( | |
| "powershell -Command \"Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new('C:\\\\test; rm -rf \\\\sound.wav').Play()\"", | |
| ); | |
| }); | |
| it('should handle backticks and dollar signs in Windows PowerShell command', () => { | |
| Object.defineProperty(process, 'platform', { value: 'win32' }); | |
| const soundPath = 'C:\\test`folder\\$sound.wav'; | |
| const escapedPath = soundPath.replace(/'/g, "''"); | |
| const command = `powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new('${escapedPath}').Play()"`; | |
| expect(command).toBe( | |
| "powershell -Command \"Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new('C:\\\\test`folder\\\\$sound.wav').Play()\"", | |
| ); | |
| }); |
| const command = `powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new('${escapedPath}').Play()"`; | ||
| expect(command).toBe( | ||
| 'powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new(\'C:\\Users\\Test\\sound.wav\').Play()"', | ||
| ); |
There was a problem hiding this comment.
The test contains the same incorrect assembly reference issue as the implementation. The test should verify the correct PowerShell command without the unnecessary Add-Type -AssemblyName System.Windows.Forms; part, and should use PlaySync() instead of Play().
| const fallbackCommand = `powershell -Command "[System.Media.SystemSounds]::Exclamation.Play()"`; | ||
| expect(fallbackCommand).toBe( | ||
| 'powershell -Command "[System.Media.SystemSounds]::Exclamation.Play()"', | ||
| ); |
There was a problem hiding this comment.
The test should verify that SystemSounds.Exclamation.Play() uses PlaySync() instead of Play() to match the correct implementation. The asynchronous Play() method will cause the sound to be cut off when the PowerShell process exits immediately.
|
@copilot open a new pull request to apply changes based on the comments in this thread |
This PR adds full Windows sound playback support to the Warcraft notifications plugin, enabling the plugin to play Warcraft II sounds on Windows systems.
Changes
System.Media.SoundPlayerto play bundled Warcraft II soundsDetails
Previously, the plugin only supported macOS (
afplay) and Linux (paplay/aplay/canberra-gtk-play). On Windows, it would log a warning and skip sound playback.This change implements:
powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Media.SoundPlayer]::new('path').Play()"powershell -Command "[System.Media.SystemSounds]::Exclamation.Play()"The plugin now has full cross-platform sound support.
Testing