1package notification
2
3import (
4 "encoding/base64"
5 "fmt"
6 "log/slog"
7 "strings"
8
9 "github.com/charmbracelet/x/ansi"
10
11 tea "charm.land/bubbletea/v2"
12)
13
14const osc99QueryID = "crush-osc99-query"
15
16// notifySeq is a counter for generating unique notification IDs.
17var notifySeq uint64
18
19// DetectOSC99Support parses an OSC response sequence and returns true if it
20// indicates OSC 99 notification support. This function should be called from
21// the capabilities detection layer to determine terminal support.
22func DetectOSC99Support(seq string) bool {
23 var ok bool
24
25 p := ansi.NewParser()
26 p.SetHandler(ansi.Handler{
27 HandleOsc: func(cmd int, data []byte) {
28 if cmd != 99 {
29 return
30 }
31
32 response := strings.TrimPrefix(string(data), "99;")
33 metadata, payload, found := strings.Cut(response, ";")
34 if !found {
35 return
36 }
37
38 var hasID, hasQuery bool
39 for field := range strings.SplitSeq(metadata, ":") {
40 hasID = hasID || field == "i="+osc99QueryID
41 hasQuery = hasQuery || field == "p=?"
42 }
43 if !hasID || !hasQuery {
44 return
45 }
46
47 ok = isOSC99CapacityPayload(payload)
48 },
49 })
50
51 for i := 0; i < len(seq); i++ {
52 p.Advance(seq[i])
53 }
54
55 return ok
56}
57
58func isOSC99CapacityPayload(payload string) bool {
59 for field := range strings.SplitSeq(payload, ":") {
60 key, value, found := strings.Cut(field, "=")
61 if !found || key != "p" {
62 continue
63 }
64
65 for item := range strings.SplitSeq(value, ",") {
66 if item == "title" {
67 return true
68 }
69 }
70 }
71
72 return false
73}
74
75// OSC99QuerySequence returns the OSC 99 query sequence used to detect
76// terminal support. This should be sent during capability detection.
77func OSC99QuerySequence() string {
78 return ansi.DesktopNotification("", "i="+osc99QueryID, "p=?")
79}
80
81// OSC99Backend sends desktop notifications using OSC 99.
82type OSC99Backend struct {
83 icon []byte
84}
85
86// NewOSC99Backend creates a new OSC 99 notification backend.
87func NewOSC99Backend(icon any) *OSC99Backend {
88 b := &OSC99Backend{}
89 if data, ok := icon.([]byte); ok && len(data) > 0 {
90 b.icon = data
91 }
92 return b
93}
94
95// Send returns a [tea.Raw] command that writes OSC 99 escape sequences to the
96// terminal.
97func (b *OSC99Backend) Send(n Notification) tea.Cmd {
98 slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
99
100 var sb strings.Builder
101 notifySeq++
102 id := fmt.Sprintf("crush-%d", notifySeq)
103
104 appName := "Crush"
105 notificationType := "crush-notification"
106
107 sb.WriteString(ansi.DesktopNotification(n.Title, "i="+id, "d=0", "p=title", "a="+appName, "t="+notificationType))
108 if n.Message != "" {
109 sb.WriteString(ansi.DesktopNotification(n.Message, "i="+id, "d=0", "p=body", "a="+appName, "t="+notificationType))
110 }
111
112 if len(b.icon) > 0 {
113 encoded := base64.StdEncoding.EncodeToString(b.icon)
114 sb.WriteString(ansi.DesktopNotification(encoded, "i="+id, "d=0", "p=icon", "e=1", "a="+appName, "t="+notificationType))
115 }
116
117 sb.WriteString(ansi.DesktopNotification("", "i="+id, "d=1", "a="+appName, "t="+notificationType))
118
119 return tea.Raw(sb.String())
120}
121
122// OSC777Backend sends desktop notifications using OSC 777.
123type OSC777Backend struct{}
124
125// NewOSC777Backend creates a new OSC 777 notification backend.
126func NewOSC777Backend() *OSC777Backend {
127 return &OSC777Backend{}
128}
129
130// Send returns a [tea.Raw] command that writes an OSC 777 escape sequence to
131// the terminal.
132func (b *OSC777Backend) Send(n Notification) tea.Cmd {
133 slog.Debug("Sending OSC 777 notification", "title", n.Title, "message", n.Message)
134
135 return tea.Raw(ansi.URxvtExt("notify", n.Title, n.Message))
136}