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// 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}