Move distributed bug fixing app from core/cli internal/bugseti/ and cmd/bugseti/ into its own module. Library code at package root, app entry point in cmd/, design docs in docs/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
252 lines
6.7 KiB
Go
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
|
|
}
|
|
}
|