Add native i3 desktop announcements
This commit is contained in:
1 parent
49eeae89f4
commit
bf1f155018
22 files changed
+1835
-1
No files matched your search
@@ -0,0 +1,155 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user