156 lines
3.9 KiB
Go
156 lines
3.9 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package i3ipc
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"time"
|
|
)
|
|
|
|
const queryTimeout = 2 * time.Second
|
|
|
|
type connection struct {
|
|
net.Conn
|
|
pending []message
|
|
pendingBytes int
|
|
}
|
|
|
|
// request preserves events interleaved with a reply on the subscribed socket.
|
|
// One worker owns both reads and comparisons; overflow forces a fresh baseline.
|
|
func (c *connection) request(kind uint32, body []byte, result any) error {
|
|
if err := c.SetDeadline(time.Now().Add(queryTimeout)); err != nil {
|
|
return err
|
|
}
|
|
defer c.SetDeadline(time.Time{})
|
|
if err := writeMessage(c, kind, body); err != nil {
|
|
return err
|
|
}
|
|
for {
|
|
m, err := readMessage(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if m.kind == kind {
|
|
return json.Unmarshal(m.body, result)
|
|
}
|
|
if m.kind&eventBit == 0 {
|
|
return errors.New("unexpected i3 reply")
|
|
}
|
|
if len(c.pending) >= maxPending || c.pendingBytes+len(m.body) > maxPayload {
|
|
return errors.New("i3 event backlog exceeded")
|
|
}
|
|
c.pending = append(c.pending, m)
|
|
c.pendingBytes += len(m.body)
|
|
}
|
|
}
|
|
|
|
func (c *connection) queryTree() (node, error) {
|
|
var root node
|
|
err := c.request(tree, nil, &root)
|
|
if err == nil && (root.Type != "root" || root.ID == 0) {
|
|
err = errors.New("invalid i3 tree")
|
|
}
|
|
return root, err
|
|
}
|
|
|
|
func session(ctx context.Context, path string, emit func(Event)) error {
|
|
socket, err := (&net.Dialer{Timeout: queryTimeout}).DialContext(ctx, "unix", path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c := &connection{Conn: socket}
|
|
defer c.Close()
|
|
// Closing the descriptor interrupts both idle reads and queries immediately.
|
|
stop := context.AfterFunc(ctx, func() { c.Close() })
|
|
defer stop()
|
|
var subscribed struct {
|
|
Success bool `json:"success"`
|
|
}
|
|
if err := c.request(subscribe, []byte(`["workspace","mode","window"]`), &subscribed); err != nil {
|
|
return err
|
|
}
|
|
if !subscribed.Success {
|
|
return errors.New("i3 subscription rejected")
|
|
}
|
|
var spaces []workspace
|
|
if err := c.request(workspaces, nil, &spaces); err != nil {
|
|
return err
|
|
}
|
|
root, err := c.queryTree()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var state state
|
|
state.baseline(root, spaces)
|
|
// Everything received through the baseline reply belongs to the snapshot.
|
|
// This also suppresses reconnect snapshots and races during initialization.
|
|
c.pending = nil
|
|
c.pendingBytes = 0
|
|
for ctx.Err() == nil {
|
|
var m message
|
|
if len(c.pending) != 0 {
|
|
m = c.pending[0]
|
|
c.pendingBytes -= len(m.body)
|
|
c.pending[0] = message{}
|
|
c.pending = c.pending[1:]
|
|
} else {
|
|
// Idle sessions have no timeout; a partial frame must finish promptly.
|
|
// Read one byte first so an idle desktop doesn't reconnect periodically.
|
|
var first [1]byte
|
|
if _, err := c.Read(first[:]); err != nil {
|
|
return err
|
|
}
|
|
if err := c.SetReadDeadline(time.Now().Add(queryTimeout)); err != nil {
|
|
return err
|
|
}
|
|
m, err = readMessage(io.MultiReader(bytes.NewReader(first[:]), c))
|
|
c.SetReadDeadline(time.Time{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
events, err := state.apply(m, c.queryTree)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, event := range events {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
emit(event)
|
|
}
|
|
}
|
|
return ctx.Err()
|
|
}
|
|
|
|
// Run rediscovers the active session after loss. All connection/cache state is
|
|
// local to one session and is discarded on errors. emit must be nonblocking.
|
|
// discover must verify the current window manager, not just an environment hint.
|
|
func Run(ctx context.Context, discover func() string, emit func(Event)) {
|
|
backoff := 250 * time.Millisecond
|
|
for ctx.Err() == nil {
|
|
started := time.Now()
|
|
if path := discover(); path != "" {
|
|
_ = session(ctx, path, emit)
|
|
}
|
|
if time.Since(started) > 10*time.Second {
|
|
backoff = 250 * time.Millisecond
|
|
}
|
|
timer := time.NewTimer(backoff)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return
|
|
case <-timer.C:
|
|
}
|
|
backoff = min(5*time.Second, backoff*2)
|
|
}
|
|
}
|