diff --git a/.vscode/settings.json b/.vscode/settings.json index 5e2c64fa..f5ac6a66 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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", diff --git a/CHANGELOG.md b/CHANGELOG.md index aa1ba4c0..a97bd9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 65dd7941..4977026c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/fabric/version.go b/cmd/fabric/version.go index 3c391725..1bfee2ac 100644 --- a/cmd/fabric/version.go +++ b/cmd/fabric/version.go @@ -1,3 +1,3 @@ package main -var version = "v1.4.275" +var version = "v1.4.276" diff --git a/cmd/generate_changelog/changelog.db b/cmd/generate_changelog/changelog.db index f595ba6f..3c17b90f 100644 Binary files a/cmd/generate_changelog/changelog.db and b/cmd/generate_changelog/changelog.db differ diff --git a/cmd/generate_changelog/incoming/1679.txt b/cmd/generate_changelog/incoming/1679.txt new file mode 100644 index 00000000..9d6b78ef --- /dev/null +++ b/cmd/generate_changelog/incoming/1679.txt @@ -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 diff --git a/completions/_fabric b/completions/_fabric index e92fec7e..fa56c9f0 100644 --- a/completions/_fabric +++ b/completions/_fabric @@ -117,6 +117,8 @@ _fabric() { '(--think-start-tag)--think-start-tag[Start tag for thinking sections (default: )]:start tag:' \ '(--think-end-tag)--think-end-tag[End tag for thinking sections (default: )]: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:' } diff --git a/completions/fabric.bash b/completions/fabric.bash index 1acbc8db..afeb2c5c 100644 --- a/completions/fabric.bash +++ b/completions/fabric.bash @@ -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 ;; diff --git a/completions/fabric.fish b/completions/fabric.fish index 24ae5afc..eaaeaec5 100755 --- a/completions/fabric.fish +++ b/completions/fabric.fish @@ -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: )" complete -c fabric -l think-end-tag -d "End tag for thinking sections (default: )" 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" diff --git a/docs/Desktop-Notifications.md b/docs/Desktop-Notifications.md new file mode 100644 index 00000000..6bb6f76a --- /dev/null +++ b/docs/Desktop-Notifications.md @@ -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. diff --git a/docs/notification-config.yaml b/docs/notification-config.yaml new file mode 100644 index 00000000..a81a0a8c --- /dev/null +++ b/docs/notification-config.yaml @@ -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 diff --git a/internal/cli/chat.go b/internal/cli/chat.go index 8fad447a..a9726609 100644 --- a/internal/cli/chat.go +++ b/internal/cli/chat.go @@ -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) diff --git a/internal/cli/chat_test.go b/internal/cli/chat_test.go new file mode 100644 index 00000000..d2928ccd --- /dev/null +++ b/internal/cli/chat_test.go @@ -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) + } + }) + } +} diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 269788c5..d53562f0 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -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 @@ -427,25 +429,27 @@ func (o *Flags) BuildChatOptions() (ret *domain.ChatOptions, err error) { } ret = &domain.ChatOptions{ - Model: o.Model, - Temperature: o.Temperature, - TopP: o.TopP, - PresencePenalty: o.PresencePenalty, - FrequencyPenalty: o.FrequencyPenalty, - Raw: o.Raw, - Seed: o.Seed, - ModelContextLength: o.ModelContextLength, - Search: o.Search, - SearchLocation: o.SearchLocation, - ImageFile: o.ImageFile, - ImageSize: o.ImageSize, - ImageQuality: o.ImageQuality, - ImageCompression: o.ImageCompression, - ImageBackground: o.ImageBackground, - SuppressThink: o.SuppressThink, - ThinkStartTag: startTag, - ThinkEndTag: endTag, - Voice: o.Voice, + Model: o.Model, + Temperature: o.Temperature, + TopP: o.TopP, + PresencePenalty: o.PresencePenalty, + FrequencyPenalty: o.FrequencyPenalty, + Raw: o.Raw, + Seed: o.Seed, + ModelContextLength: o.ModelContextLength, + Search: o.Search, + SearchLocation: o.SearchLocation, + ImageFile: o.ImageFile, + ImageSize: o.ImageSize, + ImageQuality: o.ImageQuality, + ImageCompression: o.ImageCompression, + ImageBackground: o.ImageBackground, + SuppressThink: o.SuppressThink, + ThinkStartTag: startTag, + ThinkEndTag: endTag, + Voice: o.Voice, + Notification: o.Notification || o.NotificationCommand != "", + NotificationCommand: o.NotificationCommand, } return } diff --git a/internal/domain/domain.go b/internal/domain/domain.go index a904623c..c2eeb17f 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -25,28 +25,30 @@ type ChatRequest struct { } type ChatOptions struct { - Model string - Temperature float64 - TopP float64 - PresencePenalty float64 - FrequencyPenalty float64 - Raw bool - Seed int - ModelContextLength int - MaxTokens int - Search bool - SearchLocation string - ImageFile string - ImageSize string - ImageQuality string - ImageCompression int - ImageBackground string - SuppressThink bool - ThinkStartTag string - ThinkEndTag string - AudioOutput bool - AudioFormat string - Voice string + Model string + Temperature float64 + TopP float64 + PresencePenalty float64 + FrequencyPenalty float64 + Raw bool + Seed int + ModelContextLength int + MaxTokens int + Search bool + SearchLocation string + ImageFile string + ImageSize string + ImageQuality string + ImageCompression int + ImageBackground string + SuppressThink bool + ThinkStartTag string + ThinkEndTag string + AudioOutput bool + AudioFormat string + Voice string + Notification bool + NotificationCommand string } // NormalizeMessages remove empty messages and ensure messages order user-assist-user diff --git a/internal/tools/notifications/notifications.go b/internal/tools/notifications/notifications.go new file mode 100644 index 00000000..3b19dbc9 --- /dev/null +++ b/internal/tools/notifications/notifications.go @@ -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 +} diff --git a/internal/tools/notifications/notifications_test.go b/internal/tools/notifications/notifications_test.go new file mode 100644 index 00000000..7fad3f3d --- /dev/null +++ b/internal/tools/notifications/notifications_test.go @@ -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) + } +} diff --git a/nix/pkgs/fabric/version.nix b/nix/pkgs/fabric/version.nix index 3d8e3a99..6ce6273d 100644 --- a/nix/pkgs/fabric/version.nix +++ b/nix/pkgs/fabric/version.nix @@ -1 +1 @@ -"1.4.275" +"1.4.276"