//go:build linux
package wayland
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"time"
)
// Niri IPC screenshot stream implementation.
//
// Protocol reference (niri 26.04):
// - niri wiki IPC.md (Event Stream, Programmatic Access):
// Connect to UNIX domain socket at $NIRI_SOCKET.
// Send single-line JSON request "EventStream"\n.
// Read single-line JSON reply {"Ok":"Handled"} (or {"Err":"..."}).
// Read subsequent stream of newline-delimited JSON events.
// - niri-ipc/src/lib.rs:
// Request::EventStream
// Response::Handled
// Event::ScreenshotCaptured { path: Option<String> }
// - Event ordering in niri (src/niri.rs save_screenshot()):
// niri first dispatches the clipboard selection via calloop channel tx.send(buf),
// writes the screenshot to disk if configured, and then dispatches the completion
// event via event_tx.send(path_string) to fire state.ipc_screenshot_taken(path).
// Because the Wayland connection and the Niri IPC socket are separate Unix streams,
// the ScreenshotCaptured IPC event may arrive at chirami slightly before or after
// the Wayland data-control selection offer arrives. When path is null or unreadable,
// we briefly retry reading image/png from the clipboard.
func (b *backend) watchNiri(ctx context.Context, socketPath string) {
backoff := 100 * time.Millisecond
const maxBackoff = 5 * time.Second
for {
select {
case <-ctx.Done():
return
default:
}
err := b.runNiriStream(ctx, socketPath)
if ctx.Err() != nil {
return
}
if debug && err != nil {
debugf("wayland: niri ipc stream exited: %v (reconnecting in %v)", err, backoff)
}
select {
case <-ctx.Done():
return
case <-time.After(backoff):
}
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
func (b *backend) runNiriStream(ctx context.Context, socketPath string) error {
var dialer net.Dialer
conn, err := dialer.DialContext(ctx, "unix", socketPath)
if err != nil {
return err
}
defer conn.Close()
done := make(chan struct{})
defer close(done)
go func() {
select {
case <-ctx.Done():
_ = conn.Close()
case <-done:
}
}()
// Write JSON request "EventStream"\n
if _, err := conn.Write([]byte("\"EventStream\"\n")); err != nil {
return fmt.Errorf("write EventStream request: %w", err)
}
r := bufio.NewReader(conn)
replyLine, err := readNiriLine(r)
if err != nil {
return fmt.Errorf("read EventStream reply: %w", err)
}
var reply struct {
Ok string `json:"Ok"`
Err string `json:"Err"`
}
if err := json.Unmarshal(replyLine, &reply); err != nil {
return fmt.Errorf("unmarshal EventStream reply %q: %w", string(replyLine), err)
}
if reply.Err != "" {
return fmt.Errorf("niri EventStream error: %s", reply.Err)
}
if reply.Ok != "Handled" {
return fmt.Errorf("unexpected niri EventStream reply: %s", string(replyLine))
}
return b.readNiriEvents(ctx, r)
}
func (b *backend) readNiriEvents(ctx context.Context, r *bufio.Reader) error {
for {
line, err := readNiriLine(r)
if err != nil {
return err
}
// Fast filter to ignore other high-volume events (workspaces, window focus, etc.)
if !bytes.Contains(line, []byte(`"ScreenshotCaptured"`)) {
continue
}
var ev struct {
ScreenshotCaptured *struct {
Path *string `json:"path"`
} `json:"ScreenshotCaptured"`
}
if err := json.Unmarshal(line, &ev); err != nil || ev.ScreenshotCaptured == nil {
continue
}
b.handleScreenshotCaptured(ctx, ev.ScreenshotCaptured.Path)
}
}
func (b *backend) handleScreenshotCaptured(ctx context.Context, path *string) {
if path != nil && *path != "" {
p := expandHome(*path)
data, err := os.ReadFile(p)
if err == nil && len(data) > 0 && isPNG(data) {
b.deliverScreenshot(data)
return
}
}
// When path is not provided, null, or unreadable, read from clipboard.
// We retry briefly because niri sends clipboard data and IPC events concurrently.
img, err := b.ReadClipboardImageWithRetry(ctx, 1*time.Second)
if err == nil && len(img) > 0 {
b.deliverScreenshot(img)
} else if debug {
debugf("wayland: failed to read screenshot from clipboard: %v", err)
}
}
// ReadClipboardImageWithRetry reads the clipboard's image/png once niri's
// new selection has arrived. niri sets the clipboard just before it sends
// ScreenshotCaptured, but the two travel on different sockets, so the
// selection may land a little before or after the event; an older one is an
// earlier copy and is never read, so a stale image is never OCR'd.
func (b *backend) ReadClipboardImageWithRetry(ctx context.Context, timeout time.Duration) ([]byte, error) {
since := time.Now().Add(-500 * time.Millisecond)
deadline := time.Now().Add(timeout)
for {
if time.Unix(0, b.lastClipOfferNanos.Load()).After(since) {
img, err := b.ReadClipboardImage(ctx)
if err == nil && isPNG(img) {
return img, nil
}
}
rem := time.Until(deadline)
if rem <= 0 {
return nil, errors.New("no new image/png on the clipboard")
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-b.ctx.Done():
return nil, b.ctx.Err()
case <-b.clipboardNotify:
case <-time.After(min(rem, 25*time.Millisecond)):
}
}
}
func (b *backend) deliverScreenshot(data []byte) {
select {
case b.shots <- data:
default:
if debug {
debugf("wayland: dropped screenshot: shots channel buffer full")
}
}
}
var pngHeader = []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
func isPNG(data []byte) bool {
return bytes.HasPrefix(data, pngHeader)
}
func expandHome(p string) string {
if strings.HasPrefix(p, "~/") {
if home := os.Getenv("HOME"); home != "" {
return filepath.Join(home, p[2:])
}
}
return p
}
func readNiriLine(r *bufio.Reader) ([]byte, error) {
var buf bytes.Buffer
for {
chunk, isPrefix, err := r.ReadLine()
if err != nil {
return nil, err
}
buf.Write(chunk)
if !isPrefix {
break
}
if buf.Len() > 16*1024*1024 {
return nil, errors.New("niri event line exceeds 16MB limit")
}
}
return buf.Bytes(), nil
}