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// OSCBackend sends desktop notifications using OSC escape sequences. It
82// automatically selects the best available protocol: OSC 99 (modern standard)
83// if supported, falling back to OSC 777 (urxvt extension) otherwise.
84type OSCBackend struct {
85 icon []byte
86 supports99 bool
87}
88
89// NewOSCBackend creates a new OSC notification backend with automatic protocol
90// detection. If supports99 is true, it uses OSC 99; otherwise it falls back to
91// OSC 777.
92func NewOSCBackend(icon any, supports99 bool) *OSCBackend {
93 b := &OSCBackend{
94 supports99: supports99,
95 }
96 if data, ok := icon.([]byte); ok && len(data) > 0 {
97 b.icon = data
98 }
99 return b
100}
101
102// Send returns a [tea.Cmd] that writes OSC escape sequences to the terminal.
103// Uses OSC 99 if supported, otherwise OSC 777.
104func (b *OSCBackend) Send(n Notification) tea.Cmd {
105 if b.supports99 {
106 return b.sendOSC99(n)
107 }
108 return b.sendOSC777(n)
109}
110
111func (b *OSCBackend) sendOSC99(n Notification) tea.Cmd {
112 slog.Debug("Sending OSC 99 notification", "title", n.Title, "message", n.Message)
113
114 var sb strings.Builder
115 notifySeq++
116 id := fmt.Sprintf("crush-%d", notifySeq)
117
118 appName := "Crush"
119 notificationType := "crush-notification"
120
121 sb.WriteString(ansi.DesktopNotification(n.Title, "i="+id, "d=0", "p=title", "a="+appName, "t="+notificationType))
122 if n.Message != "" {
123 sb.WriteString(ansi.DesktopNotification(n.Message, "i="+id, "d=0", "p=body", "a="+appName, "t="+notificationType))
124 }
125
126 if len(b.icon) > 0 {
127 encoded := base64.StdEncoding.EncodeToString(b.icon)
128 sb.WriteString(ansi.DesktopNotification(encoded, "i="+id, "d=0", "p=icon", "e=1", "a="+appName, "t="+notificationType))
129 }
130
131 sb.WriteString(ansi.DesktopNotification("", "i="+id, "d=1", "a="+appName, "t="+notificationType))
132
133 return tea.Raw(sb.String())
134}
135
136func (b *OSCBackend) sendOSC777(n Notification) tea.Cmd {
137 slog.Debug("Sending OSC 777 notification", "title", n.Title, "message", n.Message)
138
139 return tea.Raw(ansi.URxvtExt("notify", n.Title, n.Message))
140}