1package screencast
2
3import (
4 "reflect"
5 "slices"
6 "strings"
7
8 "github.com/bnema/neferwl/internal/ports"
9 "github.com/bnema/zerobus"
10)
11
12// applyInventory takes the outputs and windows wayland published. The
13// receiver owns the slices. Departures of outputs and windows are signalled
14// to the clients that watch them (the first inventory is not a change).
15func (s *Service) applyInventory(inv ports.CastInventory) {
16 slices.SortFunc(inv.Outputs, func(a, b ports.CastOutput) int { return strings.Compare(a.Info.Name, b.Info.Name) })
17 slices.SortFunc(inv.Windows, func(a, b ports.CastWindow) int {
18 switch {
19 case a.ID < b.ID:
20 return -1
21 case a.ID > b.ID:
22 return 1
23 }
24 return 0
25 })
26 old, had := s.inv, s.haveInv
27 s.inv, s.haveInv = inv, true
28 if !had {
29 return
30 }
31 if s.owns[1] && !reflect.DeepEqual(old.Outputs, inv.Outputs) {
32 s.notifyAllowed(pathDisplayConfig, ifaceDisplayConfig, "MonitorsChanged")
33 }
34 if s.owns[2] && !reflect.DeepEqual(old.Windows, inv.Windows) {
35 s.notifyAllowed(pathIntrospect, ifaceIntrospect, "WindowsChanged")
36 }
37}
38
39// notifyAllowed sends a body-less signal to each peer that passed the caller
40// check and whose executable the allowlist admits now. The signals are
41// directed: the inventory (output names, window titles) is never broadcast,
42// so a peer that was refused, or has not been checked, learns nothing. At
43// most maxCallers peers are served, from the cache, without allocation. The
44// executable is read again through the pidfd, as at the privileged entry
45// points. Peers that left the bus are gone from the cache (onGone).
46func (s *Service) notifyAllowed(path, iface, member string) {
47 for i := range s.callers {
48 c := &s.callers[i]
49 if c.state != callerKnown || !s.allowed(c.exe) || !s.recheck(c) {
50 continue
51 }
52 s.conn.NewSignalTo(c.name, path, iface, member, "")
53 s.send()
54 }
55}
56
57// isBuiltin follows Mutter: the connector of a laptop panel.
58func isBuiltin(connector string) bool {
59 for _, p := range [...]string{"eDP", "LVDS", "DSI"} {
60 if strings.HasPrefix(connector, p) {
61 return true
62 }
63 }
64 return false
65}
66
67// refreshHz is a refresh rate in Hz from millihertz.
68func refreshHz(milli int) float64 { return float64(milli) / 1000 }
69
70// replyState answers DisplayConfig.GetCurrentState:
71// (u, a((ssss)a(siiddada{sv})a{sv}), a(iiduba(ssss)a{sv}), a{sv}).
72// layout-mode 1 is physical: logical sizes are scaled sizes of the modes.
73// Exactly one mode of each monitor is current, as x-d-p-g requires.
74func (s *Service) replyState(req *request) {
75 e := s.replyTo(req, "ua((ssss)a(siiddada{sv})a{sv})a(iiduba(ssss)a{sv})a{sv}")
76 if e == nil {
77 return
78 }
79 e.Uint32(0)
80 monitors := e.BeginArray('(')
81 for i := range s.inv.Outputs {
82 o := &s.inv.Outputs[i]
83 e.Struct()
84 s.encodeNames(e, o)
85 modes := e.BeginArray('(')
86 cur := o.Current
87 if cur < 0 || cur >= len(o.Modes) {
88 cur = 0
89 }
90 for j, m := range o.Modes {
91 e.Struct()
92 e.Str(modeID(m))
93 e.Int32(int32(m.Width))
94 e.Int32(int32(m.Height))
95 e.Float64(refreshHz(m.RefreshMilli))
96 e.Float64(1)
97 sc := e.BeginArray('d')
98 e.Float64(1)
99 e.EndArray(sc)
100 props := e.BeginArray('{')
101 e.Struct()
102 e.Str("is-preferred")
103 e.Variant("b")
104 e.Bool(m.Preferred)
105 if j == cur {
106 e.Struct()
107 e.Str("is-current")
108 e.Variant("b")
109 e.Bool(true)
110 }
111 e.EndArray(props)
112 }
113 e.EndArray(modes)
114 props := e.BeginArray('{')
115 display := strings.TrimSpace(o.Info.Make + " " + o.Info.Model)
116 if display == "" {
117 display = o.Info.Name
118 }
119 e.Struct()
120 e.Str("display-name")
121 e.Variant("s")
122 e.Str(display)
123 e.Struct()
124 e.Str("is-builtin")
125 e.Variant("b")
126 e.Bool(isBuiltin(o.Info.Name))
127 e.EndArray(props)
128 }
129 e.EndArray(monitors)
130 logical := e.BeginArray('(')
131 for i := range s.inv.Outputs {
132 o := &s.inv.Outputs[i]
133 e.Struct()
134 e.Int32(int32(o.X))
135 e.Int32(int32(o.Y))
136 scale := o.Scale
137 if scale <= 0 {
138 scale = 1
139 }
140 e.Float64(scale)
141 e.Uint32(uint32(o.Transform))
142 e.Bool(i == 0)
143 mons := e.BeginArray('(')
144 e.Struct()
145 s.encodeNames(e, o)
146 e.EndArray(mons)
147 props := e.BeginArray('{')
148 e.EndArray(props)
149 }
150 e.EndArray(logical)
151 props := e.BeginArray('{')
152 e.Struct()
153 e.Str("layout-mode")
154 e.Variant("u")
155 e.Uint32(1)
156 e.EndArray(props)
157 s.send()
158}
159
160// encodeNames writes the monitor spec (connector, vendor, product, serial).
161// The serial falls back to the connector, as session restore needs one.
162func (s *Service) encodeNames(e *zerobus.Encoder, o *ports.CastOutput) {
163 e.Struct()
164 serial := o.Info.Serial
165 if serial == "" {
166 serial = o.Info.Name
167 }
168 e.Str(o.Info.Name)
169 e.Str(o.Info.Make)
170 e.Str(o.Info.Model)
171 e.Str(serial)
172}
173
174func modeID(m ports.OutputMode) string {
175 return itoa(m.Width) + "x" + itoa(m.Height) + "@" + ftoa(refreshHz(m.RefreshMilli))
176}
177
178// replyWindows answers Introspect.GetWindows: a{ta{sv}} of app-id and title.
179// The app id is the raw Wayland app id.
180func (s *Service) replyWindows(req *request) {
181 e := s.replyTo(req, "a{ta{sv}}")
182 if e == nil {
183 return
184 }
185 a := e.BeginArray('{')
186 for i := range s.inv.Windows {
187 w := &s.inv.Windows[i]
188 e.Struct()
189 e.Uint64(uint64(w.ID))
190 props := e.BeginArray('{')
191 e.Struct()
192 e.Str("app-id")
193 e.Variant("s")
194 e.Str(w.AppID)
195 e.Struct()
196 e.Str("title")
197 e.Variant("s")
198 e.Str(w.Title)
199 e.EndArray(props)
200 }
201 e.EndArray(a)
202 s.send()
203}