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
@@ -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
}

View File

@@ -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

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"