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