osc.go

  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}