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 "time"
16
17 "github.com/bnema/neferwl/internal/adapters/workspaceid"
18 "github.com/bnema/neferwl/internal/ports"
19 "github.com/bnema/zerowrap"
20)
21
22// Env names the state file in the environment of processes neferwl starts.
23const Env = "NEFERWL_STATE"
24
25// Path is the state file of the session on a wayland socket: one per
26// session, so nested or test instances never overwrite each other.
27func Path(runtimeDir, socket string) (string, error) {
28 socket = filepath.Base(socket)
29 if runtimeDir == "" || socket == "" || socket == "." || socket == "/" {
30 return "", errors.New("XDG_RUNTIME_DIR and WAYLAND_DISPLAY must be set")
31 }
32 return filepath.Join(runtimeDir, "neferwl", socket+".json"), nil
33}
34
35// State is the file format.
36type State struct {
37 Output string `json:"output"`
38 Outputs []Output `json:"outputs"`
39 Window *Window `json:"window"`
40 Windows []Window `json:"windows"`
41 // Workspaces lists every workspace of every output.
42 Workspaces []Workspace `json:"workspaces"`
43}
44
45// Workspace is one workspace and its tiled columns, left to right. ID is
46// the workspace_id string of Output. Number is 0 for a hidden workspace.
47// Visible means on screen on its output.
48type Workspace struct {
49 ID string `json:"id"`
50 Number int `json:"number"`
51 Name string `json:"name"`
52 Output string `json:"output"`
53 Visible bool `json:"visible"`
54 Overflow string `json:"overflow"`
55 Columns []Column `json:"columns"`
56}
57
58// Column is the area of one column in logical pixels of its output, view
59// offset applied (it may be off-screen), and its windows top to bottom.
60type Column struct {
61 X int `json:"x"`
62 Y int `json:"y"`
63 Width int `json:"width"`
64 Height int `json:"height"`
65 Windows []Tile `json:"windows"`
66}
67
68// Tile is one tiled window and its rectangle.
69type Tile struct {
70 ID uint64 `json:"id"`
71 X int `json:"x"`
72 Y int `json:"y"`
73 Width int `json:"width"`
74 Height int `json:"height"`
75}
76
77// Output is one output: its active numbered workspace (0 while a hidden one
78// is shown), how many numbered workspaces it has, and the name and ID of the
79// workspace on screen. WorkspaceID is the same string ext_workspace_handle_v1
80// sends as its id (package workspaceid): "name:<name>" for a configured
81// workspace, else "<launch prefix>-<core ID>".
82type Output struct {
83 Name string `json:"name"`
84 Active int `json:"active"`
85 Count int `json:"count"`
86 Workspace string `json:"workspace"`
87 WorkspaceID string `json:"workspace_id"`
88}
89
90// Window is one mapped window.
91type Window struct {
92 ID uint64 `json:"id"`
93 AppID string `json:"app_id"`
94 PID int `json:"pid"`
95 Output string `json:"output"`
96 Workspace int `json:"workspace"`
97 // WorkspaceID is the ID of the window's workspace, numbered or hidden
98 // (see Output.WorkspaceID).
99 WorkspaceID string `json:"workspace_id"`
100 Visible bool `json:"visible"`
101 // Floating is set for native floats (dialogs) and stashed windows.
102 Floating bool `json:"floating"`
103 // Fullscreen is set for the fullscreen window of its workspace.
104 Fullscreen bool `json:"fullscreen"`
105 // StashIndex is the 1-based place of a stashed window in its
106 // workspace's stash of StashCount windows; both are 0 outside it.
107 StashIndex int `json:"stash_index"`
108 StashCount int `json:"stash_count"`
109 // Column and Row are the 1-based place of a tiled window; both are 0
110 // for floating windows.
111 Column int `json:"column"`
112 Row int `json:"row"`
113 // Hidden is set for a stashed window while the stash is hidden.
114 Hidden bool `json:"hidden"`
115}
116
117func fromPorts(st ports.State, ids *workspaceid.IDs) State {
118 out := State{Output: st.Output, Outputs: make([]Output, 0, len(st.Outputs)), Windows: make([]Window, 0, len(st.Windows)), Workspaces: make([]Workspace, 0, len(st.Workspaces))}
119 for _, o := range st.Outputs {
120 out.Outputs = append(out.Outputs, Output{Name: o.Name, Active: o.Active, Count: o.Count, Workspace: o.Workspace, WorkspaceID: ids.ID(o.WorkspaceID, o.Workspace)})
121 }
122 for _, w := range st.Windows {
123 out.Windows = append(out.Windows, window(w, ids))
124 }
125 for _, w := range st.Workspaces {
126 ws := Workspace{ID: ids.ID(w.ID, w.Name), Number: w.Number, Name: w.Name, Output: w.Output, Visible: w.Visible, Overflow: w.Overflow, Columns: make([]Column, 0, len(w.Columns))}
127 for _, c := range w.Columns {
128 col := Column{X: c.Rect.X, Y: c.Rect.Y, Width: c.Rect.W, Height: c.Rect.H, Windows: make([]Tile, 0, len(c.Windows))}
129 for _, t := range c.Windows {
130 col.Windows = append(col.Windows, Tile{ID: uint64(t.ID), X: t.Rect.X, Y: t.Rect.Y, Width: t.Rect.W, Height: t.Rect.H})
131 }
132 ws.Columns = append(ws.Columns, col)
133 }
134 out.Workspaces = append(out.Workspaces, ws)
135 }
136 if st.Window != nil {
137 w := window(*st.Window, ids)
138 out.Window = &w
139 }
140 return out
141}
142
143func window(w ports.WindowState, ids *workspaceid.IDs) Window {
144 return Window{ID: uint64(w.ID), AppID: w.AppID, PID: w.PID, Output: w.Output, Workspace: w.Workspace, WorkspaceID: ids.ID(w.WorkspaceID, w.WorkspaceName), Visible: w.Visible, Floating: w.Floating, Fullscreen: w.Fullscreen, StashIndex: w.StashIndex, StashCount: w.StashCount, Column: w.Column, Row: w.Row, Hidden: w.Hidden}
145}
146
147// writeInterval is the least time between two writes of the file.
148const writeInterval = time.Second
149
150// Run writes the states it receives to path until ctx ends, then removes
151// the file: a stale state must not outlive the session. It writes at most
152// once per writeInterval: a state after a quiet interval is written at once,
153// others are coalesced and the latest is written when the interval ends.
154func Run(ctx context.Context, path string, states <-chan ports.State, ids *workspaceid.IDs, log zerowrap.Logger) error {
155 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
156 return err
157 }
158 // A file left by a crashed session on this socket is not ours.
159 _ = os.Remove(path)
160 defer os.Remove(path)
161 flush := func(st ports.State) {
162 if err := write(path, fromPorts(st, ids)); err != nil {
163 // Scripts lose their state; the session goes on.
164 log.Warn().Err(err).Str("path", path).Msg("write state")
165 }
166 }
167 timer := time.NewTimer(0)
168 defer timer.Stop()
169 <-timer.C
170 var (
171 last time.Time
172 pending ports.State
173 waiting bool
174 )
175 for {
176 var due <-chan time.Time
177 if waiting {
178 due = timer.C
179 }
180 select {
181 case <-ctx.Done():
182 return nil
183 case st := <-states:
184 if waiting {
185 pending = st
186 continue
187 }
188 if wait := writeInterval - time.Since(last); !last.IsZero() && wait > 0 {
189 pending, waiting = st, true
190 timer.Reset(wait)
191 continue
192 }
193 flush(st)
194 last = time.Now()
195 case <-due:
196 waiting = false
197 flush(pending)
198 last = time.Now()
199 }
200 }
201}
202
203// write replaces the file atomically: readers never see half a state.
204func write(path string, st State) error {
205 data, err := json.Marshal(st)
206 if err != nil {
207 return err
208 }
209 f, err := os.CreateTemp(filepath.Dir(path), ".state-*.json")
210 if err != nil {
211 return err
212 }
213 _, werr := f.Write(append(data, '\n'))
214 cerr := f.Close()
215 if err := errors.Join(werr, cerr); err != nil {
216 os.Remove(f.Name())
217 return err
218 }
219 return os.Rename(f.Name(), path)
220}
221
222// Read loads the state file.
223func Read(path string) (State, error) {
224 var st State
225 data, err := os.ReadFile(path)
226 if err != nil {
227 return st, err
228 }
229 return st, json.Unmarshal(data, &st)
230}
231
232// procTree finds the parent of a process.
233type procTree interface {
234 Parent(pid int) (int, bool)
235}
236
237// linuxProcTree reads /proc/<pid>/stat.
238type linuxProcTree struct{}
239
240func (linuxProcTree) Parent(pid int) (int, bool) {
241 data, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
242 if err != nil {
243 return 0, false
244 }
245 // "pid (comm) state ppid ...": comm may hold spaces and parentheses.
246 i := strings.LastIndexByte(string(data), ')')
247 if i < 0 {
248 return 0, false
249 }
250 f := strings.Fields(string(data[i+1:]))
251 if len(f) < 2 {
252 return 0, false
253 }
254 ppid, err := strconv.Atoi(f[1])
255 return ppid, err == nil && ppid > 0
256}
257
258// ErrNoWindow means no window belongs to the process or its ancestors.
259var ErrNoWindow = errors.New("no window for this process")
260
261// OutputOf finds the output showing a process: the window of the process
262// itself or of its nearest ancestor, e.g. the terminal of a shell. Only
263// application windows count: bars (layer surfaces) have no single output.
264// A process with windows on several outputs resolves to its focused window,
265// else a visible one. PIDs are those of the compositor's PID namespace.
266func OutputOf(st State, pid int) (Output, error) {
267 return outputOf(st, pid, linuxProcTree{})
268}
269
270func outputOf(st State, pid int, tree procTree) (Output, error) {
271 byPID := map[int]Window{}
272 for _, w := range st.Windows {
273 if w.PID <= 0 {
274 continue
275 }
276 cur, seen := byPID[w.PID]
277 if !seen || better(st, w, cur) {
278 byPID[w.PID] = w
279 }
280 }
281 // A depth limit guards against a cycle from a reused PID mid-walk.
282 for range 64 {
283 if w, ok := byPID[pid]; ok {
284 for _, o := range st.Outputs {
285 if o.Name == w.Output {
286 return o, nil
287 }
288 }
289 return Output{}, fmt.Errorf("output %q not in state", w.Output)
290 }
291 parent, ok := tree.Parent(pid)
292 if !ok || parent == pid {
293 break
294 }
295 pid = parent
296 }
297 return Output{}, ErrNoWindow
298}
299
300// better prefers the focused window, then a visible one.
301func better(st State, a, b Window) bool {
302 focused := func(w Window) bool { return st.Window != nil && st.Window.ID == w.ID }
303 if focused(a) != focused(b) {
304 return focused(a)
305 }
306 return a.Visible && !b.Visible
307}