1// Package statefile publishes core's state as JSON for scripts, and reads it
2// back for the `neferwl state` command. The JSON format is defined here,
3// not in ports: it is a public interface scripts depend on.
4package statefile
5
6import (
7 "context"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "os"
12 "path/filepath"
13 "strconv"
14 "strings"
15
16 "github.com/bnema/neferwl/internal/ports"
17 "github.com/bnema/zerowrap"
18)
19
20// Env names the state file in the environment of processes neferwl starts.
21const Env = "NEFERWL_STATE"
22
23// Path is the state file of the session on a wayland socket: one per
24// session, so nested or test instances never overwrite each other.
25func Path(runtimeDir, socket string) (string, error) {
26 socket = filepath.Base(socket)
27 if runtimeDir == "" || socket == "" || socket == "." || socket == "/" {
28 return "", errors.New("XDG_RUNTIME_DIR and WAYLAND_DISPLAY must be set")
29 }
30 return filepath.Join(runtimeDir, "neferwl", socket+".json"), nil
31}
32
33// State is the file format.
34type State struct {
35 Output string `json:"output"`
36 Outputs []Output `json:"outputs"`
37 Window *Window `json:"window"`
38 Windows []Window `json:"windows"`
39}
40
41// Output is one output: its active numbered workspace (0 while a hidden one
42// is shown), how many numbered workspaces it has, and the name of the
43// workspace on screen.
44type Output struct {
45 Name string `json:"name"`
46 Active int `json:"active"`
47 Count int `json:"count"`
48 Workspace string `json:"workspace"`
49}
50
51// Window is one mapped window.
52type Window struct {
53 ID uint64 `json:"id"`
54 AppID string `json:"app_id"`
55 PID int `json:"pid"`
56 Output string `json:"output"`
57 Workspace int `json:"workspace"`
58 Visible bool `json:"visible"`
59}
60
61func fromPorts(st ports.State) State {
62 out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows))}
63 for _, o := range st.Outputs {
64 out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace})
65 }
66 for _, w := range st.Windows {
67 out.Windows = append(out.Windows, window(w))
68 }
69 if st.Window != nil {
70 w := window(*st.Window)
71 out.Window = &w
72 }
73 return out
74}
75
76func window(w ports.WindowState) Window {
77 return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, Visible: w.Visible}
78}
79
80// Run writes every state it receives to path until ctx ends, then removes
81// the file: a stale state must not outlive the session.
82func Run(ctx context.Context, path string, states <-chan ports.State, log zerowrap.Logger) error {
83 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
84 return err
85 }
86 // A file left by a crashed session on this socket is not ours.
87 _ = os.Remove(path)
88 defer os.Remove(path)
89 for {
90 select {
91 case <-ctx.Done():
92 return nil
93 case st := <-states:
94 if err := write(path, fromPorts(st)); err != nil {
95 // Scripts lose their state; the session goes on.
96 log.Warn().Err(err).Str("path", path).Msg("write state")
97 }
98 }
99 }
100}
101
102// write replaces the file atomically: readers never see half a state.
103func write(path string, st State) error {
104 data, err := json.Marshal(st)
105 if err != nil {
106 return err
107 }
108 f, err := os.CreateTemp(filepath.Dir(path), ".state-*.json")
109 if err != nil {
110 return err
111 }
112 _, werr := f.Write(append(data, '\n'))
113 cerr := f.Close()
114 if err := errors.Join(werr, cerr); err != nil {
115 os.Remove(f.Name())
116 return err
117 }
118 return os.Rename(f.Name(), path)
119}
120
121// Read loads the state file.
122func Read(path string) (State, error) {
123 var st State
124 data, err := os.ReadFile(path)
125 if err != nil {
126 return st, err
127 }
128 return st, json.Unmarshal(data, &st)
129}
130
131// procTree finds the parent of a process.
132type procTree interface {
133 Parent(pid int) (int, bool)
134}
135
136// linuxProcTree reads /proc/<pid>/stat.
137type linuxProcTree struct{}
138
139func (linuxProcTree) Parent(pid int) (int, bool) {
140 data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
141 if err != nil {
142 return 0, false
143 }
144 // "pid (comm) state ppid ...": comm may hold spaces and parentheses.
145 i := strings.LastIndexByte(string(data), ')')
146 if i < 0 {
147 return 0, false
148 }
149 f := strings.Fields(string(data[i+1:]))
150 if len(f) < 2 {
151 return 0, false
152 }
153 ppid, err := strconv.Atoi(f[1])
154 return ppid, err == nil && ppid > 0
155}
156
157// ErrNoWindow means no window belongs to the process or its ancestors.
158var ErrNoWindow = errors.New("no window for this process")
159
160// OutputOf finds the output showing a process: the window of the process
161// itself or of its nearest ancestor, e.g. the terminal of a shell. Only
162// application windows count: bars (layer surfaces) have no single output.
163// A process with windows on several outputs resolves to its focused window,
164// else a visible one. PIDs are those of the compositor's PID namespace.
165func OutputOf(st State, pid int) (Output, error) {
166 return outputOf(st, pid, linuxProcTree{})
167}
168
169func outputOf(st State, pid int, tree procTree) (Output, error) {
170 byPID := map[int]Window{}
171 for _, w := range st.Windows {
172 if w.PID <= 0 {
173 continue
174 }
175 cur, seen := byPID[w.PID]
176 if !seen || better(st, w, cur) {
177 byPID[w.PID] = w
178 }
179 }
180 // A depth limit guards against a cycle from a reused PID mid-walk.
181 for range 64 {
182 if w, ok := byPID[pid]; ok {
183 for _, o := range st.Outputs {
184 if o.Name == w.Output {
185 return o, nil
186 }
187 }
188 return Output{}, fmt.Errorf("output %q not in state", w.Output)
189 }
190 parent, ok := tree.Parent(pid)
191 if !ok || parent == pid {
192 break
193 }
194 pid = parent
195 }
196 return Output{}, ErrNoWindow
197}
198
199// better prefers the focused window, then a visible one.
200func better(st State, a, b Window) bool {
201 focused := func(w Window) bool { return st.Window != nil && st.Window.ID == w.ID }
202 if focused(a) != focused(b) {
203 return focused(a)
204 }
205 return a.Visible && !b.Visible
206}