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