go/internal/bugseti/notify.go
Snider 00c011bd39 feat(bugseti): add marketplace MCP root
- add MarketplaceMCPRoot config and UI setting\n- prefer config root before env or auto-discovery\n- thread config root into ethics guard usage
2026-02-05 22:07:24 +00:00

252 lines
6.7 KiB
Go

// Package bugseti provides services for the BugSETI distributed bug fixing application.
package bugseti
import (
"context"
"fmt"
"log"
"os/exec"
"runtime"
"time"
)
// NotifyService handles desktop notifications.
type NotifyService struct {
enabled bool
sound bool
config *ConfigService
}
// NewNotifyService creates a new NotifyService.
func NewNotifyService(config *ConfigService) *NotifyService {
return &NotifyService{
enabled: true,
sound: true,
config: config,
}
}
// ServiceName returns the service name for Wails.
func (n *NotifyService) ServiceName() string {
return "NotifyService"
}
// SetEnabled enables or disables notifications.
func (n *NotifyService) SetEnabled(enabled bool) {
n.enabled = enabled
}
// SetSound enables or disables notification sounds.
func (n *NotifyService) SetSound(sound bool) {
n.sound = sound
}
// Notify sends a desktop notification.
func (n *NotifyService) Notify(title, message string) error {
if !n.enabled {
return nil
}
guard := getEthicsGuardWithRoot(context.Background(), n.getMarketplaceRoot())
safeTitle := guard.SanitizeNotification(title)
safeMessage := guard.SanitizeNotification(message)
log.Printf("Notification: %s - %s", safeTitle, safeMessage)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var err error
switch runtime.GOOS {
case "darwin":
err = n.notifyMacOS(ctx, safeTitle, safeMessage)
case "linux":
err = n.notifyLinux(ctx, safeTitle, safeMessage)
case "windows":
err = n.notifyWindows(ctx, safeTitle, safeMessage)
default:
err = fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
if err != nil {
log.Printf("Notification error: %v", err)
}
return err
}
func (n *NotifyService) getMarketplaceRoot() string {
if n == nil || n.config == nil {
return ""
}
return n.config.GetMarketplaceMCPRoot()
}
// NotifyIssue sends a notification about a new issue.
func (n *NotifyService) NotifyIssue(issue *Issue) error {
title := "New Issue Available"
message := fmt.Sprintf("%s: %s", issue.Repo, issue.Title)
return n.Notify(title, message)
}
// NotifyPRStatus sends a notification about a PR status change.
func (n *NotifyService) NotifyPRStatus(repo string, prNumber int, status string) error {
title := "PR Status Update"
message := fmt.Sprintf("%s #%d: %s", repo, prNumber, status)
return n.Notify(title, message)
}
// notifyMacOS sends a notification on macOS using osascript.
func (n *NotifyService) notifyMacOS(ctx context.Context, title, message string) error {
script := fmt.Sprintf(`display notification "%s" with title "%s"`, escapeAppleScript(message), escapeAppleScript(title))
if n.sound {
script += ` sound name "Glass"`
}
cmd := exec.CommandContext(ctx, "osascript", "-e", script)
return cmd.Run()
}
// notifyLinux sends a notification on Linux using notify-send.
func (n *NotifyService) notifyLinux(ctx context.Context, title, message string) error {
args := []string{
"--app-name=BugSETI",
"--urgency=normal",
title,
message,
}
cmd := exec.CommandContext(ctx, "notify-send", args...)
return cmd.Run()
}
// notifyWindows sends a notification on Windows using PowerShell.
func (n *NotifyService) notifyWindows(ctx context.Context, title, message string) error {
title = escapePowerShellXML(title)
message = escapePowerShellXML(message)
script := fmt.Sprintf(`
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
$template = @"
<toast>
<visual>
<binding template="ToastText02">
<text id="1">%s</text>
<text id="2">%s</text>
</binding>
</visual>
</toast>
"@
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
$xml.LoadXml($template)
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("BugSETI").Show($toast)
`, title, message)
cmd := exec.CommandContext(ctx, "powershell", "-Command", script)
return cmd.Run()
}
// NotifyWithAction sends a notification with an action button (platform-specific).
func (n *NotifyService) NotifyWithAction(title, message, actionLabel string) error {
if !n.enabled {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
switch runtime.GOOS {
case "darwin":
// macOS: Use terminal-notifier if available for actions
if _, err := exec.LookPath("terminal-notifier"); err == nil {
cmd := exec.CommandContext(ctx, "terminal-notifier",
"-title", title,
"-message", message,
"-appIcon", "NSApplication",
"-actions", actionLabel,
"-group", "BugSETI")
return cmd.Run()
}
return n.notifyMacOS(ctx, title, message)
case "linux":
// Linux: Use notify-send with action
args := []string{
"--app-name=BugSETI",
"--urgency=normal",
"--action=open=" + actionLabel,
title,
message,
}
cmd := exec.CommandContext(ctx, "notify-send", args...)
return cmd.Run()
default:
return n.Notify(title, message)
}
}
// NotifyProgress sends a notification with a progress indicator.
func (n *NotifyService) NotifyProgress(title, message string, progress int) error {
if !n.enabled {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
switch runtime.GOOS {
case "linux":
// Linux supports progress hints
args := []string{
"--app-name=BugSETI",
"--hint=int:value:" + fmt.Sprintf("%d", progress),
title,
message,
}
cmd := exec.CommandContext(ctx, "notify-send", args...)
return cmd.Run()
default:
// Other platforms: include progress in message
messageWithProgress := fmt.Sprintf("%s (%d%%)", message, progress)
return n.Notify(title, messageWithProgress)
}
}
// PlaySound plays a notification sound.
func (n *NotifyService) PlaySound() error {
if !n.sound {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
switch runtime.GOOS {
case "darwin":
cmd := exec.CommandContext(ctx, "afplay", "/System/Library/Sounds/Glass.aiff")
return cmd.Run()
case "linux":
// Try paplay (PulseAudio), then aplay (ALSA)
if _, err := exec.LookPath("paplay"); err == nil {
cmd := exec.CommandContext(ctx, "paplay", "/usr/share/sounds/freedesktop/stereo/complete.oga")
return cmd.Run()
}
if _, err := exec.LookPath("aplay"); err == nil {
cmd := exec.CommandContext(ctx, "aplay", "-q", "/usr/share/sounds/alsa/Front_Center.wav")
return cmd.Run()
}
return nil
case "windows":
script := `[console]::beep(800, 200)`
cmd := exec.CommandContext(ctx, "powershell", "-Command", script)
return cmd.Run()
default:
return nil
}
}