feat(cli): add cross-platform desktop notifications with secure custom commands

CHANGES
- Integrate notification sending into chat processing workflow
- Add --notification and --notification-command CLI flags and help
- Provide cross-platform providers: macOS, Linux, Windows with fallbacks
- Escape shell metacharacters to prevent injection vulnerabilities
- Truncate Unicode output safely for notification message previews
- Update bash, zsh, fish completions with new notification options
- Add docs and YAML examples for configuration and customization
- Add unit tests for providers and notification integration paths
This commit is contained in:
github-actions[bot]
2025-08-08 02:24:57 +00:00
committed by Kayvan Sylvan
parent 056791233a
commit 21f258caa4
18 changed files with 803 additions and 45 deletions

View File

@@ -80,6 +80,7 @@
"Laomedeia",
"ldflags",
"libexec",
"libnotify",
"listcontexts",
"listextensions",
"listmodels",
@@ -93,6 +94,7 @@
"matplotlib",
"mattn",
"mbed",
"metacharacters",
"Miessler",
"nometa",
"numpy",
@@ -102,6 +104,7 @@
"opencode",
"openrouter",
"Orus",
"osascript",
"otiai",
"pdflatex",
"pipx",

View File

@@ -1,5 +1,16 @@
# Changelog
## v1.4.276 (2025-08-08)
### Direct commits
- Ci: add write permissions to update_release_notes job
- Add contents write permission to release notes job
- Enable GitHub Actions to modify repository contents
- Fix potential permission issues during release process
## v1.4.275 (2025-08-07)
### PR [#1676](https://github.com/danielmiessler/Fabric/pull/1676) by [ksylvan](https://github.com/ksylvan): Refactor authentication to support GITHUB_TOKEN and GH_TOKEN

View File

@@ -551,6 +551,9 @@ Application Options:
--voice= TTS voice name for supported models (e.g., Kore, Charon, Puck)
(default: Kore)
--list-gemini-voices List all available Gemini TTS voices
--notification Send desktop notification when command completes
--notification-command= Custom command to run for notifications (overrides built-in
notifications)
Help Options:
-h, --help Show this help message

View File

@@ -1,3 +1,3 @@
package main
var version = "v1.4.275"
var version = "v1.4.276"

Binary file not shown.

View File

@@ -0,0 +1,7 @@
### PR [#1679](https://github.com/danielmiessler/Fabric/pull/1679) by [ksylvan](https://github.com/ksylvan): Add cross-platform desktop notifications to Fabric CLI
- Add desktop notification support with cross-platform providers for macOS, Linux, and Windows
- Implement notification manager with fallback provider detection and configuration options
- Integrate notification sending into chat processing workflow with CLI interface flags
- Fix security vulnerabilities by hardening notification commands to prevent shell and script injection attacks
- Improve Unicode handling and environment variable inheritance in notification providers

View File

@@ -117,6 +117,8 @@ _fabric() {
'(--think-start-tag)--think-start-tag[Start tag for thinking sections (default: <think>)]:start tag:' \
'(--think-end-tag)--think-end-tag[End tag for thinking sections (default: </think>)]:end tag:' \
'(--disable-responses-api)--disable-responses-api[Disable OpenAI Responses API (default: false)]' \
'(--notification)--notification[Send desktop notification when command completes]' \
'(--notification-command)--notification-command[Custom command to run for notifications]:notification command:' \
'(-h --help)'{-h,--help}'[Show this help message]' \
'*:arguments:'
}

View File

@@ -13,7 +13,7 @@ _fabric() {
_get_comp_words_by_ref -n : cur prev words cword
# Define all possible options/flags
local opts="--pattern -p --variable -v --context -C --session --attachment -a --setup -S --temperature -t --topp -T --stream -s --presencepenalty -P --raw -r --frequencypenalty -F --listpatterns -l --listmodels -L --listcontexts -x --listsessions -X --updatepatterns -U --copy -c --model -m --modelContextLength --output -o --output-session --latest -n --changeDefaultModel -d --youtube -y --playlist --transcript --transcript-with-timestamps --comments --metadata --language -g --scrape_url -u --scrape_question -q --seed -e --wipecontext -w --wipesession -W --printcontext --printsession --readability --input-has-vars --dry-run --serve --serveOllama --address --api-key --config --search --search-location --image-file --image-size --image-quality --image-compression --image-background --suppress-think --think-start-tag --think-end-tag --disable-responses-api --voice --list-gemini-voices --version --listextensions --addextension --rmextension --strategy --liststrategies --listvendors --shell-complete-list --help -h"
local opts="--pattern -p --variable -v --context -C --session --attachment -a --setup -S --temperature -t --topp -T --stream -s --presencepenalty -P --raw -r --frequencypenalty -F --listpatterns -l --listmodels -L --listcontexts -x --listsessions -X --updatepatterns -U --copy -c --model -m --modelContextLength --output -o --output-session --latest -n --changeDefaultModel -d --youtube -y --playlist --transcript --transcript-with-timestamps --comments --metadata --language -g --scrape_url -u --scrape_question -q --seed -e --wipecontext -w --wipesession -W --printcontext --printsession --readability --input-has-vars --dry-run --serve --serveOllama --address --api-key --config --search --search-location --image-file --image-size --image-quality --image-compression --image-background --suppress-think --think-start-tag --think-end-tag --disable-responses-api --voice --list-gemini-voices --notification --notification-command --version --listextensions --addextension --rmextension --strategy --liststrategies --listvendors --shell-complete-list --help -h"
# Helper function for dynamic completions
_fabric_get_list() {
@@ -85,7 +85,7 @@ _fabric() {
return 0
;;
# Options requiring simple arguments (no specific completion logic here)
-v | --variable | -t | --temperature | -T | --topp | -P | --presencepenalty | -F | --frequencypenalty | --modelContextLength | -n | --latest | -y | --youtube | -g | --language | -u | --scrape_url | -q | --scrape_question | -e | --seed | --address | --api-key | --search-location | --image-compression | --think-start-tag | --think-end-tag)
-v | --variable | -t | --temperature | -T | --topp | -P | --presencepenalty | -F | --frequencypenalty | --modelContextLength | -n | --latest | -y | --youtube | -g | --language | -u | --scrape_url | -q | --scrape_question | -e | --seed | --address | --api-key | --search-location | --image-compression | --think-start-tag | --think-end-tag | --notification-command)
# No specific completion suggestions, user types the value
return 0
;;

View File

@@ -76,6 +76,7 @@ complete -c fabric -l strategy -d "Choose a strategy from the available strategi
complete -c fabric -l think-start-tag -d "Start tag for thinking sections (default: <think>)"
complete -c fabric -l think-end-tag -d "End tag for thinking sections (default: </think>)"
complete -c fabric -l voice -d "TTS voice name for supported models (e.g., Kore, Charon, Puck)" -a "(__fabric_get_gemini_voices)"
complete -c fabric -l notification-command -d "Custom command to run for notifications (overrides built-in notifications)"
# Boolean flags (no arguments)
complete -c fabric -s S -l setup -d "Run setup for all reconfigurable parts of fabric"
@@ -108,4 +109,5 @@ complete -c fabric -l list-gemini-voices -d "List all available Gemini TTS voice
complete -c fabric -l shell-complete-list -d "Output raw list without headers/formatting (for shell completion)"
complete -c fabric -l suppress-think -d "Suppress text enclosed in thinking tags"
complete -c fabric -l disable-responses-api -d "Disable OpenAI Responses API (default: false)"
complete -c fabric -l notification -d "Send desktop notification when command completes"
complete -c fabric -s h -l help -d "Show this help message"

View File

@@ -0,0 +1,183 @@
# Desktop Notifications
Fabric supports desktop notifications to alert you when commands complete, which is especially useful for long-running tasks or when you're multitasking.
## Quick Start
Enable notifications with the `--notification` flag:
```bash
fabric --pattern summarize --notification < article.txt
```
## Configuration
### Command Line Options
- `--notification`: Enable desktop notifications when command completes
- `--notification-command`: Use a custom notification command instead of built-in notifications
### YAML Configuration
Add notification settings to your `~/.config/fabric/config.yaml`:
```yaml
# Enable notifications by default
notification: true
# Optional: Custom notification command
notificationCommand: 'notify-send --urgency=normal "$1" "$2"'
```
## Platform Support
### macOS
- **Default**: Uses `osascript` (built into macOS)
- **Enhanced**: Install `terminal-notifier` for better notifications:
```bash
brew install terminal-notifier
```
### Linux
- **Requirement**: Install `notify-send`:
```bash
# Ubuntu/Debian
sudo apt install libnotify-bin
# Fedora
sudo dnf install libnotify
```
### Windows
- **Default**: Uses PowerShell message boxes (built-in)
## Custom Notification Commands
The `--notification-command` flag allows you to use custom notification scripts or commands. The command receives the title as `$1` and message as `$2` as shell positional arguments.
**Security Note**: The title and message content are properly escaped to prevent command injection attacks from AI-generated output containing shell metacharacters.
### Examples
**macOS with custom sound:**
```bash
fabric --pattern analyze_claims --notification-command 'osascript -e "display notification \"$2\" with title \"$1\" sound name \"Ping\""' < document.txt
```
**Linux with urgency levels:**
```bash
fabric --pattern extract_wisdom --notification-command 'notify-send --urgency=critical "$1" "$2"' < video-transcript.txt
```
**Custom script:**
```bash
fabric --pattern summarize --notification-command '/path/to/my-notification-script.sh "$1" "$2"' < report.pdf
```
**Testing your custom command:**
```bash
# Test that $1 and $2 are passed correctly
fabric --pattern raw_query --notification-command 'echo "Title: $1, Message: $2"' "test input"
```
## Notification Content
Notifications include:
- **Title**: "Fabric Command Complete" or "Fabric: [pattern] Complete"
- **Message**: Brief summary of the output (first 100 characters)
For long outputs, the message is truncated with "..." to fit notification display limits.
## Use Cases
### Long-Running Tasks
```bash
# Process large document with notifications
fabric --pattern analyze_paper --notification < research-paper.pdf
# Extract wisdom from long video with alerts
fabric -y "https://youtube.com/watch?v=..." --pattern extract_wisdom --notification
```
### Background Processing
```bash
# Process multiple files and get notified when each completes
for file in *.txt; do
fabric --pattern summarize --notification < "$file" &
done
```
### Integration with Other Tools
```bash
# Combine with other commands
curl -s "https://api.example.com/data" | \
fabric --pattern analyze_data --notification --output results.md
```
## Troubleshooting
### No Notifications Appearing
1. **Check system notifications are enabled** for Terminal/your shell
2. **Verify notification tools are installed**:
- macOS: `which osascript` (should exist)
- Linux: `which notify-send`
- Windows: `where.exe powershell`
3. **Test with simple command**:
```bash
echo "test" | fabric --pattern raw_query --notification --dry-run
```
### Notification Permission Issues
On some systems, you may need to grant notification permissions to your terminal application:
- **macOS**: System Preferences → Security & Privacy → Privacy → Notifications → Enable for Terminal
- **Linux**: Depends on desktop environment; usually automatic
- **Windows**: Usually works by default
### Custom Commands Not Working
- Ensure your custom notification command is executable
- Test the command manually with sample arguments
- Check that all required dependencies are installed
## Advanced Configuration
### Environment-Specific Settings
Create different configuration files for different environments:
```bash
# Work computer (quieter notifications)
fabric --config ~/.config/fabric/work-config.yaml --notification
# Personal computer (with sound)
fabric --config ~/.config/fabric/personal-config.yaml --notification
```
### Integration with Task Management
```bash
# Custom script that also logs to task management system
notificationCommand: '/usr/local/bin/fabric-notify-and-log.sh "$1" "$2"'
```
## Examples
See `docs/notification-config.yaml` for a complete configuration example with various notification command options.

View File

@@ -0,0 +1,21 @@
# Example Fabric configuration with notification support
# Save this to ~/.config/fabric/config.yaml to use as defaults
# Enable notifications by default for all commands
notification: true
# Optional: Use a custom notification command
# Examples:
# macOS with custom sound:
# notificationCommand: 'osascript -e "display notification \"$2\" with title \"$1\" sound name \"Ping\""'
#
# Linux with custom urgency:
# notificationCommand: 'notify-send --urgency=normal "$1" "$2"'
#
# Custom script:
# notificationCommand: '/path/to/custom-notification-script.sh "$1" "$2"'
# Other common settings
model: "gpt-4o"
temperature: 0.7
stream: true

View File

@@ -3,12 +3,14 @@ package cli
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/danielmiessler/fabric/internal/core"
"github.com/danielmiessler/fabric/internal/domain"
"github.com/danielmiessler/fabric/internal/plugins/db/fsdb"
"github.com/danielmiessler/fabric/internal/tools/notifications"
)
// handleChatProcessing handles the main chat processing logic
@@ -115,9 +117,65 @@ func handleChatProcessing(currentFlags *Flags, registry *core.PluginRegistry, me
}
}
}
// Send notification if requested
if chatOptions.Notification {
if err = sendNotification(chatOptions, chatReq.PatternName, result); err != nil {
// Log notification error but don't fail the main command
fmt.Fprintf(os.Stderr, "Failed to send notification: %v\n", err)
}
}
return
}
// sendNotification sends a desktop notification about command completion.
//
// When truncating the result for notification display, this function counts Unicode code points,
// not grapheme clusters. As a result, complex emoji or accented characters with multiple combining
// characters may be truncated improperly. This is a limitation of the current implementation.
func sendNotification(options *domain.ChatOptions, patternName, result string) error {
title := "Fabric Command Complete"
if patternName != "" {
title = fmt.Sprintf("Fabric: %s Complete", patternName)
}
// Limit message length for notification display (counts Unicode code points)
message := "Command completed successfully"
if result != "" {
maxLength := 100
runes := []rune(result)
if len(runes) > maxLength {
message = fmt.Sprintf("Output: %s...", string(runes[:maxLength]))
} else {
message = fmt.Sprintf("Output: %s", result)
}
// Clean up newlines for notification display
message = strings.ReplaceAll(message, "\n", " ")
}
// Use custom notification command if provided
if options.NotificationCommand != "" {
// SECURITY: Pass title and message as proper shell positional arguments $1 and $2
// This matches the documented interface where custom commands receive title and message as shell variables
cmd := exec.Command("sh", "-c", options.NotificationCommand+" \"$1\" \"$2\"", "--", title, message)
// For debugging: capture and display output from custom commands
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// Use built-in notification system
notificationManager := notifications.NewNotificationManager()
if !notificationManager.IsAvailable() {
return fmt.Errorf("no notification system available")
}
return notificationManager.Send(title, message)
}
// isTTSModel checks if the model is a text-to-speech model
func isTTSModel(modelName string) bool {
lowerModel := strings.ToLower(modelName)

166
internal/cli/chat_test.go Normal file
View File

@@ -0,0 +1,166 @@
package cli
import (
"strings"
"testing"
"github.com/danielmiessler/fabric/internal/domain"
)
func TestSendNotification_SecurityEscaping(t *testing.T) {
tests := []struct {
name string
title string
message string
command string
expectError bool
description string
}{
{
name: "Normal content",
title: "Test Title",
message: "Test message content",
command: `echo "Title: $1, Message: $2"`,
expectError: false,
description: "Normal content should work fine",
},
{
name: "Content with backticks",
title: "Test Title",
message: "Test `whoami` injection",
command: `echo "Title: $1, Message: $2"`,
expectError: false,
description: "Backticks should be escaped and not executed",
},
{
name: "Content with semicolon injection",
title: "Test Title",
message: "Test; echo INJECTED; echo end",
command: `echo "Title: $1, Message: $2"`,
expectError: false,
description: "Semicolon injection should be prevented",
},
{
name: "Content with command substitution",
title: "Test Title",
message: "Test $(whoami) injection",
command: `echo "Title: $1, Message: $2"`,
expectError: false,
description: "Command substitution should be escaped",
},
{
name: "Content with quote injection",
title: "Test Title",
message: "Test ' || echo INJECTED || echo ' end",
command: `echo "Title: $1, Message: $2"`,
expectError: false,
description: "Quote injection should be prevented",
},
{
name: "Content with newlines",
title: "Test Title",
message: "Line 1\nLine 2\nLine 3",
command: `echo "Title: $1, Message: $2"`,
expectError: false,
description: "Newlines should be handled safely",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
options := &domain.ChatOptions{
NotificationCommand: tt.command,
Notification: true,
}
// This test mainly verifies that the function doesn't panic
// and properly escapes dangerous content. The actual command
// execution is tested separately in integration tests.
err := sendNotification(options, "test_pattern", tt.message)
if tt.expectError && err == nil {
t.Errorf("Expected error for %s, but got none", tt.description)
}
if !tt.expectError && err != nil {
t.Errorf("Unexpected error for %s: %v", tt.description, err)
}
})
}
}
func TestSendNotification_TitleGeneration(t *testing.T) {
tests := []struct {
name string
patternName string
expected string
}{
{
name: "No pattern name",
patternName: "",
expected: "Fabric Command Complete",
},
{
name: "With pattern name",
patternName: "summarize",
expected: "Fabric: summarize Complete",
},
{
name: "Pattern with special characters",
patternName: "test_pattern-v2",
expected: "Fabric: test_pattern-v2 Complete",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
options := &domain.ChatOptions{
NotificationCommand: `echo "Title: $1"`,
Notification: true,
}
// We're testing the title generation logic
// The actual notification command would echo the title
err := sendNotification(options, tt.patternName, "test message")
// The function should not error for valid inputs
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
}
func TestSendNotification_MessageTruncation(t *testing.T) {
longMessage := strings.Repeat("A", 150) // 150 characters
shortMessage := "Short message"
tests := []struct {
name string
message string
expected string
}{
{
name: "Short message",
message: shortMessage,
},
{
name: "Long message truncation",
message: longMessage,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
options := &domain.ChatOptions{
NotificationCommand: `echo "Message: $2"`,
Notification: true,
}
err := sendNotification(options, "test", tt.message)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
})
}
}

View File

@@ -91,6 +91,8 @@ type Flags struct {
DisableResponsesAPI bool `long:"disable-responses-api" yaml:"disableResponsesAPI" description:"Disable OpenAI Responses API (default: false)"`
Voice string `long:"voice" yaml:"voice" description:"TTS voice name for supported models (e.g., Kore, Charon, Puck)" default:"Kore"`
ListGeminiVoices bool `long:"list-gemini-voices" description:"List all available Gemini TTS voices"`
Notification bool `long:"notification" yaml:"notification" description:"Send desktop notification when command completes"`
NotificationCommand string `long:"notification-command" yaml:"notificationCommand" description:"Custom command to run for notifications (overrides built-in notifications)"`
}
var debug = false
@@ -446,6 +448,8 @@ func (o *Flags) BuildChatOptions() (ret *domain.ChatOptions, err error) {
ThinkStartTag: startTag,
ThinkEndTag: endTag,
Voice: o.Voice,
Notification: o.Notification || o.NotificationCommand != "",
NotificationCommand: o.NotificationCommand,
}
return
}

View File

@@ -47,6 +47,8 @@ type ChatOptions struct {
AudioOutput bool
AudioFormat string
Voice string
Notification bool
NotificationCommand string
}
// NormalizeMessages remove empty messages and ensure messages order user-assist-user

View File

@@ -0,0 +1,128 @@
package notifications
import (
"fmt"
"os"
"os/exec"
"runtime"
)
// NotificationProvider interface for different notification backends
type NotificationProvider interface {
Send(title, message string) error
IsAvailable() bool
}
// NotificationManager handles cross-platform notifications
type NotificationManager struct {
provider NotificationProvider
}
// NewNotificationManager creates a new notification manager with the best available provider
func NewNotificationManager() *NotificationManager {
var provider NotificationProvider
switch runtime.GOOS {
case "darwin":
// Try terminal-notifier first, then fall back to osascript
provider = &TerminalNotifierProvider{}
if !provider.IsAvailable() {
provider = &OSAScriptProvider{}
}
case "linux":
provider = &NotifySendProvider{}
case "windows":
provider = &PowerShellProvider{}
default:
provider = &NoopProvider{}
}
return &NotificationManager{provider: provider}
}
// Send sends a notification using the configured provider
func (nm *NotificationManager) Send(title, message string) error {
if nm.provider == nil {
return fmt.Errorf("no notification provider available")
}
return nm.provider.Send(title, message)
}
// IsAvailable checks if notifications are available
func (nm *NotificationManager) IsAvailable() bool {
return nm.provider != nil && nm.provider.IsAvailable()
}
// macOS terminal-notifier implementation
type TerminalNotifierProvider struct{}
func (t *TerminalNotifierProvider) Send(title, message string) error {
cmd := exec.Command("terminal-notifier", "-title", title, "-message", message, "-sound", "Glass")
return cmd.Run()
}
func (t *TerminalNotifierProvider) IsAvailable() bool {
_, err := exec.LookPath("terminal-notifier")
return err == nil
}
// macOS osascript implementation
type OSAScriptProvider struct{}
func (o *OSAScriptProvider) Send(title, message string) error {
// SECURITY: Use separate arguments instead of string interpolation to prevent AppleScript injection
script := `display notification (system attribute "FABRIC_MESSAGE") with title (system attribute "FABRIC_TITLE") sound name "Glass"`
cmd := exec.Command("osascript", "-e", script)
// Set environment variables for the AppleScript to read safely
cmd.Env = append(os.Environ(), "FABRIC_TITLE="+title, "FABRIC_MESSAGE="+message)
return cmd.Run()
}
func (o *OSAScriptProvider) IsAvailable() bool {
_, err := exec.LookPath("osascript")
return err == nil
}
// Linux notify-send implementation
type NotifySendProvider struct{}
func (n *NotifySendProvider) Send(title, message string) error {
cmd := exec.Command("notify-send", title, message)
return cmd.Run()
}
func (n *NotifySendProvider) IsAvailable() bool {
_, err := exec.LookPath("notify-send")
return err == nil
}
// Windows PowerShell implementation
type PowerShellProvider struct{}
func (p *PowerShellProvider) Send(title, message string) error {
// SECURITY: Use environment variables to avoid PowerShell injection attacks
script := `Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show($env:FABRIC_MESSAGE, $env:FABRIC_TITLE)`
cmd := exec.Command("powershell", "-Command", script)
// Set environment variables for PowerShell to read safely
cmd.Env = append(os.Environ(), "FABRIC_TITLE="+title, "FABRIC_MESSAGE="+message)
return cmd.Run()
}
func (p *PowerShellProvider) IsAvailable() bool {
_, err := exec.LookPath("powershell")
return err == nil
}
// NoopProvider for unsupported platforms
type NoopProvider struct{}
func (n *NoopProvider) Send(title, message string) error {
// Silent no-op for unsupported platforms
return nil
}
func (n *NoopProvider) IsAvailable() bool {
return false
}

View File

@@ -0,0 +1,168 @@
package notifications
import (
"os/exec"
"runtime"
"testing"
)
func TestNewNotificationManager(t *testing.T) {
manager := NewNotificationManager()
if manager == nil {
t.Fatal("NewNotificationManager() returned nil")
}
if manager.provider == nil {
t.Fatal("NotificationManager provider is nil")
}
}
func TestNotificationManagerIsAvailable(t *testing.T) {
manager := NewNotificationManager()
// Should not panic
_ = manager.IsAvailable()
}
func TestNotificationManagerSend(t *testing.T) {
manager := NewNotificationManager()
// Test sending notification - this may fail on systems without notification tools
// but should not panic
err := manager.Send("Test Title", "Test Message")
if err != nil {
t.Logf("Notification send failed (expected on systems without notification tools): %v", err)
}
}
func TestTerminalNotifierProvider(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("Skipping macOS terminal-notifier test on non-macOS platform")
}
provider := &TerminalNotifierProvider{}
// Test availability - depends on whether terminal-notifier is installed
available := provider.IsAvailable()
t.Logf("terminal-notifier available: %v", available)
if available {
err := provider.Send("Test", "Test message")
if err != nil {
t.Logf("terminal-notifier send failed: %v", err)
}
}
}
func TestOSAScriptProvider(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("Skipping macOS osascript test on non-macOS platform")
}
provider := &OSAScriptProvider{}
// osascript should always be available on macOS
if !provider.IsAvailable() {
t.Error("osascript should be available on macOS")
}
// Test sending (may show actual notification)
err := provider.Send("Test", "Test message")
if err != nil {
t.Errorf("osascript send failed: %v", err)
}
}
func TestNotifySendProvider(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("Skipping Linux notify-send test on non-Linux platform")
}
provider := &NotifySendProvider{}
// Test availability - depends on whether notify-send is installed
available := provider.IsAvailable()
t.Logf("notify-send available: %v", available)
if available {
err := provider.Send("Test", "Test message")
if err != nil {
t.Logf("notify-send send failed: %v", err)
}
}
}
func TestPowerShellProvider(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Skipping Windows PowerShell test on non-Windows platform")
}
provider := &PowerShellProvider{}
// PowerShell should be available on Windows
if !provider.IsAvailable() {
t.Error("PowerShell should be available on Windows")
}
// Note: This will show a message box if run
// In CI/CD, this might not work properly
err := provider.Send("Test", "Test message")
if err != nil {
t.Logf("PowerShell send failed (expected in headless environments): %v", err)
}
}
func TestNoopProvider(t *testing.T) {
provider := &NoopProvider{}
// Should always report as not available
if provider.IsAvailable() {
t.Error("NoopProvider should report as not available")
}
// Should never error
err := provider.Send("Test", "Test message")
if err != nil {
t.Errorf("NoopProvider send should never error, got: %v", err)
}
}
func TestProviderIsAvailable(t *testing.T) {
tests := []struct {
name string
provider NotificationProvider
command string
}{
{"TerminalNotifier", &TerminalNotifierProvider{}, "terminal-notifier"},
{"OSAScript", &OSAScriptProvider{}, "osascript"},
{"NotifySend", &NotifySendProvider{}, "notify-send"},
{"PowerShell", &PowerShellProvider{}, "powershell"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
available := tt.provider.IsAvailable()
// Cross-check with actual command availability
_, err := exec.LookPath(tt.command)
expectedAvailable := err == nil
if available != expectedAvailable {
t.Logf("Provider %s availability mismatch: provider=%v, command=%v",
tt.name, available, expectedAvailable)
// This is informational, not a failure, since system setup varies
}
})
}
}
func TestSendWithSpecialCharacters(t *testing.T) {
manager := NewNotificationManager()
// Test with special characters that might break shell commands
specialTitle := `Title with "quotes" and 'apostrophes'`
specialMessage := `Message with \backslashes and $variables and "quotes"`
err := manager.Send(specialTitle, specialMessage)
if err != nil {
t.Logf("Send with special characters failed (may be expected): %v", err)
}
}

View File

@@ -1 +1 @@
"1.4.275"
"1.4.276"