feat(screencast): serve the Mutter and Shell D-Bus objects behind a pidfd caller check
- commit
- b413735def9a539ecd83b3192daa403795ce09ed
- author
- brice · · 2026-10-14 20:27 UTC
- committer
- brice · · 2026-10-14 21:39 UTC
- parent
- 8bce9bf
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +2464 -40
M internal/adapters/screencast/bus.go +416 -16
| @@ -1,29 +1,429 @@ | |||
| 1 | 1 | package screencast | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/zerobus" | |
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "strconv" | |
| 6 | + | "strings" | |
| 4 | 7 | ||
| 5 | - | // request is a decoded method call: every string is copied and capped, so it | |
| 6 | - | // outlives the receive buffer while it waits for the caller check. | |
| 8 | + | "github.com/bnema/zerobus" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Object paths, interfaces and errors of the served contract. | |
| 12 | + | const ( | |
| 13 | + | pathScreenCast = "/org/gnome/Mutter/ScreenCast" | |
| 14 | + | prefixSession = pathScreenCast + "/Session/u" | |
| 15 | + | prefixStream = pathScreenCast + "/Stream/u" | |
| 16 | + | pathDisplayConfig = "/org/gnome/Mutter/DisplayConfig" | |
| 17 | + | pathIntrospect = "/org/gnome/Shell/Introspect" | |
| 18 | + | pathScreenshot = "/org/gnome/Shell/Screenshot" | |
| 19 | + | ||
| 20 | + | ifaceScreenCast = "org.gnome.Mutter.ScreenCast" | |
| 21 | + | ifaceSession = ifaceScreenCast + ".Session" | |
| 22 | + | ifaceStream = ifaceScreenCast + ".Stream" | |
| 23 | + | ifaceDisplayConfig = "org.gnome.Mutter.DisplayConfig" | |
| 24 | + | ifaceIntrospect = "org.gnome.Shell.Introspect" | |
| 25 | + | ifaceScreenshot = "org.gnome.Shell.Screenshot" | |
| 26 | + | ifaceProps = "org.freedesktop.DBus.Properties" | |
| 27 | + | ifacePeer = "org.freedesktop.DBus.Peer" | |
| 28 | + | ifaceIntrospectable = "org.freedesktop.DBus.Introspectable" | |
| 29 | + | ||
| 30 | + | errAccessDenied = "org.freedesktop.DBus.Error.AccessDenied" | |
| 31 | + | errLimits = "org.freedesktop.DBus.Error.LimitsExceeded" | |
| 32 | + | errNotSupported = "org.freedesktop.DBus.Error.NotSupported" | |
| 33 | + | errInvalidArgs = "org.freedesktop.DBus.Error.InvalidArgs" | |
| 34 | + | errUnknownObject = "org.freedesktop.DBus.Error.UnknownObject" | |
| 35 | + | errUnknownProperty = "org.freedesktop.DBus.Error.UnknownProperty" | |
| 36 | + | errUnknownInterface = "org.freedesktop.DBus.Error.UnknownInterface" | |
| 37 | + | errPropertyReadOnly = "org.freedesktop.DBus.Error.PropertyReadOnly" | |
| 38 | + | errFailed = "org.freedesktop.DBus.Error.Failed" | |
| 39 | + | ) | |
| 40 | + | ||
| 41 | + | // objKind is the object a call is addressed to. | |
| 42 | + | type objKind uint8 | |
| 43 | + | ||
| 44 | + | const ( | |
| 45 | + | objNone objKind = iota | |
| 46 | + | objScreenCast | |
| 47 | + | objSession | |
| 48 | + | objStream | |
| 49 | + | objDisplayConfig | |
| 50 | + | objIntrospect | |
| 51 | + | objScreenshot | |
| 52 | + | ) | |
| 53 | + | ||
| 54 | + | // method is a call the service understands, after its (path, interface, | |
| 55 | + | // member) were resolved. | |
| 56 | + | type method uint8 | |
| 57 | + | ||
| 58 | + | const ( | |
| 59 | + | mNotSupported method = iota | |
| 60 | + | mCreateSession | |
| 61 | + | mStart | |
| 62 | + | mStop | |
| 63 | + | mRecordMonitor | |
| 64 | + | mRecordWindow | |
| 65 | + | mGet | |
| 66 | + | mGetAll | |
| 67 | + | mSet | |
| 68 | + | mPing | |
| 69 | + | mIntrospect | |
| 70 | + | mGetState | |
| 71 | + | mGetWindows | |
| 72 | + | mShot | |
| 73 | + | mShotArea | |
| 74 | + | mShotInteractive | |
| 75 | + | mUnknownObject | |
| 76 | + | ) | |
| 77 | + | ||
| 78 | + | // request is a decoded method call. Every value is copied out of the receive | |
| 79 | + | // buffer with a length cap, so the request can wait for the caller check and | |
| 80 | + | // the reply can be built after other messages were read. | |
| 7 | 81 | type request struct { | |
| 8 | - | sender string | |
| 9 | - | serial uint32 | |
| 10 | - | noWait bool // the caller expects no reply | |
| 82 | + | sender string | |
| 83 | + | serial uint32 | |
| 84 | + | noReply bool | |
| 85 | + | obj objKind | |
| 86 | + | id uint32 // session or stream number from the path | |
| 87 | + | method method | |
| 88 | + | bad bool // the body does not match the method | |
| 89 | + | ||
| 90 | + | text, text2 string // connector; Properties interface and name | |
| 91 | + | window uint64 | |
| 92 | + | cursor uint32 | |
| 93 | + | remote bool // CreateSession: remote-desktop-session-id given | |
| 94 | + | x, y, w, h int32 | |
| 95 | + | flag bool | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // privileged reports the calls that read the caller's executable again. | |
| 99 | + | func (r *request) privileged() bool { | |
| 100 | + | switch r.method { | |
| 101 | + | case mCreateSession, mStart, mShot, mShotArea, mShotInteractive: | |
| 102 | + | return true | |
| 103 | + | } | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // capStr copies s, cut to maxArg bytes. | |
| 108 | + | func capStr(s string) string { | |
| 109 | + | if len(s) > maxArg { | |
| 110 | + | s = s[:maxArg] | |
| 111 | + | } | |
| 112 | + | return strings.Clone(s) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // resolve finds the object of a path. A served object needs its bus name. | |
| 116 | + | func (s *Service) resolve(path string) (objKind, uint32) { | |
| 117 | + | switch path { | |
| 118 | + | case pathScreenCast: | |
| 119 | + | return objScreenCast, 0 | |
| 120 | + | case pathDisplayConfig: | |
| 121 | + | return objDisplayConfig, 0 | |
| 122 | + | case pathIntrospect: | |
| 123 | + | return objIntrospect, 0 | |
| 124 | + | case pathScreenshot: | |
| 125 | + | return objScreenshot, 0 | |
| 126 | + | } | |
| 127 | + | if n, ok := strings.CutPrefix(path, prefixSession); ok { | |
| 128 | + | id, err := strconv.ParseUint(n, 10, 32) | |
| 129 | + | return objSession, uint32(id) | boolBit(err != nil) | |
| 130 | + | } | |
| 131 | + | if n, ok := strings.CutPrefix(path, prefixStream); ok { | |
| 132 | + | id, err := strconv.ParseUint(n, 10, 32) | |
| 133 | + | return objStream, uint32(id) | boolBit(err != nil) | |
| 134 | + | } | |
| 135 | + | return objNone, 0 | |
| 11 | 136 | } | |
| 12 | 137 | ||
| 13 | - | // session is one org.gnome.Mutter.ScreenCast.Session. | |
| 14 | - | type session struct { | |
| 15 | - | used bool | |
| 16 | - | owner string | |
| 138 | + | // boolBit marks an unparsable number with an id no object has. | |
| 139 | + | func boolBit(bad bool) uint32 { | |
| 140 | + | if bad { | |
| 141 | + | return 0xffffffff | |
| 142 | + | } | |
| 143 | + | return 0 | |
| 17 | 144 | } | |
| 18 | 145 | ||
| 19 | - | func (s *Service) onMessage(m *zerobus.Message) { _ = m } | |
| 146 | + | // served reports whether the bus name of the object is ours. | |
| 147 | + | func (s *Service) served(k objKind) bool { | |
| 148 | + | switch k { | |
| 149 | + | case objScreenCast, objSession, objStream: | |
| 150 | + | return s.owns[0] | |
| 151 | + | case objDisplayConfig: | |
| 152 | + | return s.owns[1] | |
| 153 | + | case objIntrospect: | |
| 154 | + | return s.owns[2] | |
| 155 | + | case objScreenshot: | |
| 156 | + | return s.owns[3] | |
| 157 | + | } | |
| 158 | + | return false | |
| 159 | + | } | |
| 20 | 160 | ||
| 21 | - | func (s *Service) forEachSession(f func(*session)) { | |
| 22 | - | for i := range s.sessions { | |
| 23 | - | if s.sessions[i].used { | |
| 24 | - | f(&s.sessions[i]) | |
| 161 | + | // decode reads a call completely: nothing of m is used after it returns. | |
| 162 | + | func (s *Service) decode(m *zerobus.Message) request { | |
| 163 | + | req := request{sender: strings.Clone(m.Sender), serial: m.Serial, noReply: m.Flags&zerobus.FlagNoReplyExpected != 0} | |
| 164 | + | req.obj, req.id = s.resolve(m.Path) | |
| 165 | + | if !s.served(req.obj) { | |
| 166 | + | req.method = mUnknownObject | |
| 167 | + | return req | |
| 168 | + | } | |
| 169 | + | r := m.Body() | |
| 170 | + | sig := m.Signature | |
| 171 | + | want := func(w string) bool { | |
| 172 | + | if sig != w { | |
| 173 | + | req.bad = true | |
| 174 | + | return false | |
| 175 | + | } | |
| 176 | + | return true | |
| 177 | + | } | |
| 178 | + | switch m.Interface { | |
| 179 | + | case ifaceProps: | |
| 180 | + | switch m.Member { | |
| 181 | + | case "Get": | |
| 182 | + | req.method = mGet | |
| 183 | + | if want("ss") { | |
| 184 | + | req.text, req.text2 = capStr(r.Str()), capStr(r.Str()) | |
| 185 | + | } | |
| 186 | + | case "GetAll": | |
| 187 | + | req.method = mGetAll | |
| 188 | + | if want("s") { | |
| 189 | + | req.text = capStr(r.Str()) | |
| 190 | + | } | |
| 191 | + | case "Set": | |
| 192 | + | req.method = mSet | |
| 193 | + | } | |
| 194 | + | case ifacePeer: | |
| 195 | + | if m.Member == "Ping" { | |
| 196 | + | req.method = mPing | |
| 197 | + | want("") | |
| 25 | 198 | } | |
| 199 | + | case ifaceIntrospectable: | |
| 200 | + | if m.Member == "Introspect" { | |
| 201 | + | req.method = mIntrospect | |
| 202 | + | want("") | |
| 203 | + | } | |
| 204 | + | case ifaceScreenCast: | |
| 205 | + | if req.obj == objScreenCast && m.Member == "CreateSession" { | |
| 206 | + | req.method = mCreateSession | |
| 207 | + | if want("a{sv}") { | |
| 208 | + | s.readProps(r, &req) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | case ifaceSession: | |
| 212 | + | if req.obj == objSession { | |
| 213 | + | switch m.Member { | |
| 214 | + | case "Start": | |
| 215 | + | req.method = mStart | |
| 216 | + | want("") | |
| 217 | + | case "Stop": | |
| 218 | + | req.method = mStop | |
| 219 | + | want("") | |
| 220 | + | case "RecordMonitor": | |
| 221 | + | req.method = mRecordMonitor | |
| 222 | + | if want("sa{sv}") { | |
| 223 | + | req.text = capStr(r.Str()) | |
| 224 | + | s.readProps(r, &req) | |
| 225 | + | } | |
| 226 | + | case "RecordWindow": | |
| 227 | + | req.method = mRecordWindow | |
| 228 | + | if want("a{sv}") { | |
| 229 | + | s.readProps(r, &req) | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | case ifaceDisplayConfig: | |
| 234 | + | if req.obj == objDisplayConfig && m.Member == "GetCurrentState" { | |
| 235 | + | req.method = mGetState | |
| 236 | + | want("") | |
| 237 | + | } | |
| 238 | + | case ifaceIntrospect: | |
| 239 | + | if req.obj == objIntrospect && m.Member == "GetWindows" { | |
| 240 | + | req.method = mGetWindows | |
| 241 | + | want("") | |
| 242 | + | } | |
| 243 | + | case ifaceScreenshot: | |
| 244 | + | if req.obj == objScreenshot { | |
| 245 | + | switch m.Member { | |
| 246 | + | case "Screenshot": | |
| 247 | + | req.method = mShot | |
| 248 | + | if want("bbs") { | |
| 249 | + | _ = r.Bool() | |
| 250 | + | req.flag = r.Bool() | |
| 251 | + | _ = r.Str() | |
| 252 | + | } | |
| 253 | + | case "ScreenshotArea": | |
| 254 | + | req.method = mShotArea | |
| 255 | + | if want("iiiibs") { | |
| 256 | + | req.x, req.y, req.w, req.h = r.Int32(), r.Int32(), r.Int32(), r.Int32() | |
| 257 | + | _ = r.Bool() | |
| 258 | + | _ = r.Str() | |
| 259 | + | } | |
| 260 | + | case "InteractiveScreenshot": | |
| 261 | + | req.method = mShotInteractive | |
| 262 | + | want("") | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | if r.Err() != nil { | |
| 267 | + | req.bad = true | |
| 26 | 268 | } | |
| 269 | + | return req | |
| 27 | 270 | } | |
| 28 | 271 | ||
| 29 | - | func (s *Service) closeSession(sess *session, signal bool) { _, _ = sess, signal } | |
| 272 | + | // readProps reads an a{sv} and keeps the keys the service knows. | |
| 273 | + | func (s *Service) readProps(r *zerobus.Reader, req *request) { | |
| 274 | + | end := r.Array('{') | |
| 275 | + | for r.More(end) { | |
| 276 | + | r.Struct() | |
| 277 | + | key := r.Str() | |
| 278 | + | sig := r.Variant() | |
| 279 | + | switch { | |
| 280 | + | case key == "cursor-mode" && sig == "u": | |
| 281 | + | req.cursor = r.Uint32() | |
| 282 | + | case key == "window-id" && sig == "t": | |
| 283 | + | req.window = r.Uint64() | |
| 284 | + | case key == "remote-desktop-session-id": | |
| 285 | + | req.remote = true | |
| 286 | + | r.Skip(sig) | |
| 287 | + | default: | |
| 288 | + | r.Skip(sig) | |
| 289 | + | } | |
| 290 | + | } | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // onMessage handles one message from the bus. | |
| 294 | + | func (s *Service) onMessage(m *zerobus.Message) { | |
| 295 | + | switch m.Type { | |
| 296 | + | case zerobus.TypeMethodCall: | |
| 297 | + | if m.Sender != "" { | |
| 298 | + | req := s.decode(m) | |
| 299 | + | s.admit(&req) | |
| 300 | + | } | |
| 301 | + | case zerobus.TypeMethodReturn, zerobus.TypeError: | |
| 302 | + | if m.Sender == busDaemon { | |
| 303 | + | s.onBusReply(m) | |
| 304 | + | } | |
| 305 | + | case zerobus.TypeSignal: | |
| 306 | + | if m.Sender == busDaemon && m.Interface == busDaemon && m.Member == "NameOwnerChanged" { | |
| 307 | + | r := m.Body() | |
| 308 | + | name := r.Str() | |
| 309 | + | _ = r.Str() | |
| 310 | + | owner := r.Str() | |
| 311 | + | if r.Err() == nil && owner == "" && strings.HasPrefix(name, ":") { | |
| 312 | + | s.onGone(name) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // send sends the message that was built. A broken connection ends the | |
| 319 | + | // attempt; any other failure drops that one message. | |
| 320 | + | func (s *Service) send() { | |
| 321 | + | if _, err := s.conn.Send(); err != nil { | |
| 322 | + | if errors.Is(err, zerobus.ErrClosed) { | |
| 323 | + | s.fatal = err | |
| 324 | + | return | |
| 325 | + | } | |
| 326 | + | s.log.Warn().Err(err).Msg("message not sent") | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // replyTo builds the reply of req, or nil when the caller wants none. | |
| 331 | + | func (s *Service) replyTo(req *request, sig string) *zerobus.Encoder { | |
| 332 | + | if req.noReply { | |
| 333 | + | return nil | |
| 334 | + | } | |
| 335 | + | return s.conn.NewReply(&zerobus.Message{Sender: req.sender, Serial: req.serial}, sig) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // reply sends an empty reply. | |
| 339 | + | func (s *Service) reply(req *request) { | |
| 340 | + | if s.replyTo(req, "") != nil { | |
| 341 | + | s.send() | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | // replyErr sends an error reply. | |
| 346 | + | func (s *Service) replyErr(req *request, name, text string) { | |
| 347 | + | if req.noReply { | |
| 348 | + | return | |
| 349 | + | } | |
| 350 | + | s.conn.NewError(&zerobus.Message{Sender: req.sender, Serial: req.serial}, name, "s").Str(text) | |
| 351 | + | s.send() | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | // emit sends a signal to one peer. | |
| 355 | + | func (s *Service) emitTo(dest, path, iface, member string) *zerobus.Encoder { | |
| 356 | + | return s.conn.NewSignalTo(dest, path, iface, member, "") | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // dispatch runs a call of a checked caller. | |
| 360 | + | func (s *Service) dispatch(c *caller, req *request) { | |
| 361 | + | if !s.allowed(c.exe) { | |
| 362 | + | s.deny(req) | |
| 363 | + | return | |
| 364 | + | } | |
| 365 | + | if req.privileged() && !s.recheck(c) { | |
| 366 | + | s.deny(req) | |
| 367 | + | return | |
| 368 | + | } | |
| 369 | + | switch { | |
| 370 | + | case req.method == mUnknownObject: | |
| 371 | + | s.replyErr(req, errUnknownObject, "no such object") | |
| 372 | + | return | |
| 373 | + | case req.bad: | |
| 374 | + | s.replyErr(req, errInvalidArgs, "unexpected arguments") | |
| 375 | + | return | |
| 376 | + | } | |
| 377 | + | var sess *session | |
| 378 | + | var st *stream | |
| 379 | + | switch req.obj { | |
| 380 | + | case objSession: | |
| 381 | + | if sess = s.sessionByID(req.id); sess == nil { | |
| 382 | + | s.replyErr(req, errUnknownObject, "no such session") | |
| 383 | + | return | |
| 384 | + | } | |
| 385 | + | case objStream: | |
| 386 | + | if st = s.streamByID(req.id); st == nil { | |
| 387 | + | s.replyErr(req, errUnknownObject, "no such stream") | |
| 388 | + | return | |
| 389 | + | } | |
| 390 | + | sess = st.sess | |
| 391 | + | } | |
| 392 | + | if sess != nil && sess.owner != req.sender { | |
| 393 | + | s.deny(req) | |
| 394 | + | return | |
| 395 | + | } | |
| 396 | + | switch req.method { | |
| 397 | + | case mPing: | |
| 398 | + | s.reply(req) | |
| 399 | + | case mIntrospect: | |
| 400 | + | if e := s.replyTo(req, "s"); e != nil { | |
| 401 | + | e.Str("<node/>") | |
| 402 | + | s.send() | |
| 403 | + | } | |
| 404 | + | case mGet, mGetAll, mSet: | |
| 405 | + | s.properties(req, sess, st) | |
| 406 | + | case mCreateSession: | |
| 407 | + | s.createSession(req) | |
| 408 | + | case mStart: | |
| 409 | + | s.start(req, sess) | |
| 410 | + | case mStop: | |
| 411 | + | s.reply(req) | |
| 412 | + | s.closeSession(sess, true) | |
| 413 | + | case mRecordMonitor, mRecordWindow: | |
| 414 | + | s.record(req, sess) | |
| 415 | + | case mGetState: | |
| 416 | + | s.replyState(req) | |
| 417 | + | case mGetWindows: | |
| 418 | + | s.replyWindows(req) | |
| 419 | + | case mShot, mShotArea, mShotInteractive: | |
| 420 | + | s.screenshot(req) | |
| 421 | + | default: | |
| 422 | + | s.replyErr(req, errNotSupported, "not supported") | |
| 423 | + | } | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // deny refuses a call. | |
| 427 | + | func (s *Service) deny(req *request) { | |
| 428 | + | s.replyErr(req, errAccessDenied, "caller not allowed to capture the screen") | |
| 429 | + | } | |
| @@ -1,29 +1,429 @@ | |||||
| 1 | package screencast | 1 | package screencast | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/zerobus" | 3 | + | import ( |
| 4 | + | "errors" | |||
| 5 | + | "strconv" | |||
| 6 | + | "strings" | |||
| 4 | 7 | ||||
| 5 | - | // request is a decoded method call: every string is copied and capped, so it | 8 | + | "github.com/bnema/zerobus" |
| 6 | - | // outlives the receive buffer while it waits for the caller check. | 9 | + | ) |
| 10 | + | ||||
| 11 | + | // Object paths, interfaces and errors of the served contract. | |||
| 12 | + | const ( | |||
| 13 | + | pathScreenCast = "/org/gnome/Mutter/ScreenCast" | |||
| 14 | + | prefixSession = pathScreenCast + "/Session/u" | |||
| 15 | + | prefixStream = pathScreenCast + "/Stream/u" | |||
| 16 | + | pathDisplayConfig = "/org/gnome/Mutter/DisplayConfig" | |||
| 17 | + | pathIntrospect = "/org/gnome/Shell/Introspect" | |||
| 18 | + | pathScreenshot = "/org/gnome/Shell/Screenshot" | |||
| 19 | + | ||||
| 20 | + | ifaceScreenCast = "org.gnome.Mutter.ScreenCast" | |||
| 21 | + | ifaceSession = ifaceScreenCast + ".Session" | |||
| 22 | + | ifaceStream = ifaceScreenCast + ".Stream" | |||
| 23 | + | ifaceDisplayConfig = "org.gnome.Mutter.DisplayConfig" | |||
| 24 | + | ifaceIntrospect = "org.gnome.Shell.Introspect" | |||
| 25 | + | ifaceScreenshot = "org.gnome.Shell.Screenshot" | |||
| 26 | + | ifaceProps = "org.freedesktop.DBus.Properties" | |||
| 27 | + | ifacePeer = "org.freedesktop.DBus.Peer" | |||
| 28 | + | ifaceIntrospectable = "org.freedesktop.DBus.Introspectable" | |||
| 29 | + | ||||
| 30 | + | errAccessDenied = "org.freedesktop.DBus.Error.AccessDenied" | |||
| 31 | + | errLimits = "org.freedesktop.DBus.Error.LimitsExceeded" | |||
| 32 | + | errNotSupported = "org.freedesktop.DBus.Error.NotSupported" | |||
| 33 | + | errInvalidArgs = "org.freedesktop.DBus.Error.InvalidArgs" | |||
| 34 | + | errUnknownObject = "org.freedesktop.DBus.Error.UnknownObject" | |||
| 35 | + | errUnknownProperty = "org.freedesktop.DBus.Error.UnknownProperty" | |||
| 36 | + | errUnknownInterface = "org.freedesktop.DBus.Error.UnknownInterface" | |||
| 37 | + | errPropertyReadOnly = "org.freedesktop.DBus.Error.PropertyReadOnly" | |||
| 38 | + | errFailed = "org.freedesktop.DBus.Error.Failed" | |||
| 39 | + | ) | |||
| 40 | + | ||||
| 41 | + | // objKind is the object a call is addressed to. | |||
| 42 | + | type objKind uint8 | |||
| 43 | + | ||||
| 44 | + | const ( | |||
| 45 | + | objNone objKind = iota | |||
| 46 | + | objScreenCast | |||
| 47 | + | objSession | |||
| 48 | + | objStream | |||
| 49 | + | objDisplayConfig | |||
| 50 | + | objIntrospect | |||
| 51 | + | objScreenshot | |||
| 52 | + | ) | |||
| 53 | + | ||||
| 54 | + | // method is a call the service understands, after its (path, interface, | |||
| 55 | + | // member) were resolved. | |||
| 56 | + | type method uint8 | |||
| 57 | + | ||||
| 58 | + | const ( | |||
| 59 | + | mNotSupported method = iota | |||
| 60 | + | mCreateSession | |||
| 61 | + | mStart | |||
| 62 | + | mStop | |||
| 63 | + | mRecordMonitor | |||
| 64 | + | mRecordWindow | |||
| 65 | + | mGet | |||
| 66 | + | mGetAll | |||
| 67 | + | mSet | |||
| 68 | + | mPing | |||
| 69 | + | mIntrospect | |||
| 70 | + | mGetState | |||
| 71 | + | mGetWindows | |||
| 72 | + | mShot | |||
| 73 | + | mShotArea | |||
| 74 | + | mShotInteractive | |||
| 75 | + | mUnknownObject | |||
| 76 | + | ) | |||
| 77 | + | ||||
| 78 | + | // request is a decoded method call. Every value is copied out of the receive | |||
| 79 | + | // buffer with a length cap, so the request can wait for the caller check and | |||
| 80 | + | // the reply can be built after other messages were read. | |||
| 7 | type request struct { | 81 | type request struct { | ||
| 8 | - | sender string | 82 | + | sender string |
| 9 | - | serial uint32 | 83 | + | serial uint32 |
| 10 | - | noWait bool // the caller expects no reply | 84 | + | noReply bool |
| 85 | + | obj objKind | |||
| 86 | + | id uint32 // session or stream number from the path | |||
| 87 | + | method method | |||
| 88 | + | bad bool // the body does not match the method | |||
| 89 | + | ||||
| 90 | + | text, text2 string // connector; Properties interface and name | |||
| 91 | + | window uint64 | |||
| 92 | + | cursor uint32 | |||
| 93 | + | remote bool // CreateSession: remote-desktop-session-id given | |||
| 94 | + | x, y, w, h int32 | |||
| 95 | + | flag bool | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // privileged reports the calls that read the caller's executable again. | |||
| 99 | + | func (r *request) privileged() bool { | |||
| 100 | + | switch r.method { | |||
| 101 | + | case mCreateSession, mStart, mShot, mShotArea, mShotInteractive: | |||
| 102 | + | return true | |||
| 103 | + | } | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // capStr copies s, cut to maxArg bytes. | |||
| 108 | + | func capStr(s string) string { | |||
| 109 | + | if len(s) > maxArg { | |||
| 110 | + | s = s[:maxArg] | |||
| 111 | + | } | |||
| 112 | + | return strings.Clone(s) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // resolve finds the object of a path. A served object needs its bus name. | |||
| 116 | + | func (s *Service) resolve(path string) (objKind, uint32) { | |||
| 117 | + | switch path { | |||
| 118 | + | case pathScreenCast: | |||
| 119 | + | return objScreenCast, 0 | |||
| 120 | + | case pathDisplayConfig: | |||
| 121 | + | return objDisplayConfig, 0 | |||
| 122 | + | case pathIntrospect: | |||
| 123 | + | return objIntrospect, 0 | |||
| 124 | + | case pathScreenshot: | |||
| 125 | + | return objScreenshot, 0 | |||
| 126 | + | } | |||
| 127 | + | if n, ok := strings.CutPrefix(path, prefixSession); ok { | |||
| 128 | + | id, err := strconv.ParseUint(n, 10, 32) | |||
| 129 | + | return objSession, uint32(id) | boolBit(err != nil) | |||
| 130 | + | } | |||
| 131 | + | if n, ok := strings.CutPrefix(path, prefixStream); ok { | |||
| 132 | + | id, err := strconv.ParseUint(n, 10, 32) | |||
| 133 | + | return objStream, uint32(id) | boolBit(err != nil) | |||
| 134 | + | } | |||
| 135 | + | return objNone, 0 | |||
| 11 | } | 136 | } | ||
| 12 | 137 | ||||
| 13 | - | // session is one org.gnome.Mutter.ScreenCast.Session. | 138 | + | // boolBit marks an unparsable number with an id no object has. |
| 14 | - | type session struct { | 139 | + | func boolBit(bad bool) uint32 { |
| 15 | - | used bool | 140 | + | if bad { |
| 16 | - | owner string | 141 | + | return 0xffffffff |
| 142 | + | } | |||
| 143 | + | return 0 | |||
| 17 | } | 144 | } | ||
| 18 | 145 | ||||
| 19 | - | func (s *Service) onMessage(m *zerobus.Message) { _ = m } | 146 | + | // served reports whether the bus name of the object is ours. |
| 147 | + | func (s *Service) served(k objKind) bool { | |||
| 148 | + | switch k { | |||
| 149 | + | case objScreenCast, objSession, objStream: | |||
| 150 | + | return s.owns[0] | |||
| 151 | + | case objDisplayConfig: | |||
| 152 | + | return s.owns[1] | |||
| 153 | + | case objIntrospect: | |||
| 154 | + | return s.owns[2] | |||
| 155 | + | case objScreenshot: | |||
| 156 | + | return s.owns[3] | |||
| 157 | + | } | |||
| 158 | + | return false | |||
| 159 | + | } | |||
| 20 | 160 | ||||
| 21 | - | func (s *Service) forEachSession(f func(*session)) { | 161 | + | // decode reads a call completely: nothing of m is used after it returns. |
| 22 | - | for i := range s.sessions { | 162 | + | func (s *Service) decode(m *zerobus.Message) request { |
| 23 | - | if s.sessions[i].used { | 163 | + | req := request{sender: strings.Clone(m.Sender), serial: m.Serial, noReply: m.Flags&zerobus.FlagNoReplyExpected != 0} |
| 24 | - | f(&s.sessions[i]) | 164 | + | req.obj, req.id = s.resolve(m.Path) |
| 165 | + | if !s.served(req.obj) { | |||
| 166 | + | req.method = mUnknownObject | |||
| 167 | + | return req | |||
| 168 | + | } | |||
| 169 | + | r := m.Body() | |||
| 170 | + | sig := m.Signature | |||
| 171 | + | want := func(w string) bool { | |||
| 172 | + | if sig != w { | |||
| 173 | + | req.bad = true | |||
| 174 | + | return false | |||
| 175 | + | } | |||
| 176 | + | return true | |||
| 177 | + | } | |||
| 178 | + | switch m.Interface { | |||
| 179 | + | case ifaceProps: | |||
| 180 | + | switch m.Member { | |||
| 181 | + | case "Get": | |||
| 182 | + | req.method = mGet | |||
| 183 | + | if want("ss") { | |||
| 184 | + | req.text, req.text2 = capStr(r.Str()), capStr(r.Str()) | |||
| 185 | + | } | |||
| 186 | + | case "GetAll": | |||
| 187 | + | req.method = mGetAll | |||
| 188 | + | if want("s") { | |||
| 189 | + | req.text = capStr(r.Str()) | |||
| 190 | + | } | |||
| 191 | + | case "Set": | |||
| 192 | + | req.method = mSet | |||
| 193 | + | } | |||
| 194 | + | case ifacePeer: | |||
| 195 | + | if m.Member == "Ping" { | |||
| 196 | + | req.method = mPing | |||
| 197 | + | want("") | |||
| 25 | } | 198 | } | ||
| 199 | + | case ifaceIntrospectable: | |||
| 200 | + | if m.Member == "Introspect" { | |||
| 201 | + | req.method = mIntrospect | |||
| 202 | + | want("") | |||
| 203 | + | } | |||
| 204 | + | case ifaceScreenCast: | |||
| 205 | + | if req.obj == objScreenCast && m.Member == "CreateSession" { | |||
| 206 | + | req.method = mCreateSession | |||
| 207 | + | if want("a{sv}") { | |||
| 208 | + | s.readProps(r, &req) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | case ifaceSession: | |||
| 212 | + | if req.obj == objSession { | |||
| 213 | + | switch m.Member { | |||
| 214 | + | case "Start": | |||
| 215 | + | req.method = mStart | |||
| 216 | + | want("") | |||
| 217 | + | case "Stop": | |||
| 218 | + | req.method = mStop | |||
| 219 | + | want("") | |||
| 220 | + | case "RecordMonitor": | |||
| 221 | + | req.method = mRecordMonitor | |||
| 222 | + | if want("sa{sv}") { | |||
| 223 | + | req.text = capStr(r.Str()) | |||
| 224 | + | s.readProps(r, &req) | |||
| 225 | + | } | |||
| 226 | + | case "RecordWindow": | |||
| 227 | + | req.method = mRecordWindow | |||
| 228 | + | if want("a{sv}") { | |||
| 229 | + | s.readProps(r, &req) | |||
| 230 | + | } | |||
| 231 | + | } | |||
| 232 | + | } | |||
| 233 | + | case ifaceDisplayConfig: | |||
| 234 | + | if req.obj == objDisplayConfig && m.Member == "GetCurrentState" { | |||
| 235 | + | req.method = mGetState | |||
| 236 | + | want("") | |||
| 237 | + | } | |||
| 238 | + | case ifaceIntrospect: | |||
| 239 | + | if req.obj == objIntrospect && m.Member == "GetWindows" { | |||
| 240 | + | req.method = mGetWindows | |||
| 241 | + | want("") | |||
| 242 | + | } | |||
| 243 | + | case ifaceScreenshot: | |||
| 244 | + | if req.obj == objScreenshot { | |||
| 245 | + | switch m.Member { | |||
| 246 | + | case "Screenshot": | |||
| 247 | + | req.method = mShot | |||
| 248 | + | if want("bbs") { | |||
| 249 | + | _ = r.Bool() | |||
| 250 | + | req.flag = r.Bool() | |||
| 251 | + | _ = r.Str() | |||
| 252 | + | } | |||
| 253 | + | case "ScreenshotArea": | |||
| 254 | + | req.method = mShotArea | |||
| 255 | + | if want("iiiibs") { | |||
| 256 | + | req.x, req.y, req.w, req.h = r.Int32(), r.Int32(), r.Int32(), r.Int32() | |||
| 257 | + | _ = r.Bool() | |||
| 258 | + | _ = r.Str() | |||
| 259 | + | } | |||
| 260 | + | case "InteractiveScreenshot": | |||
| 261 | + | req.method = mShotInteractive | |||
| 262 | + | want("") | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | } | |||
| 266 | + | if r.Err() != nil { | |||
| 267 | + | req.bad = true | |||
| 26 | } | 268 | } | ||
| 269 | + | return req | |||
| 27 | } | 270 | } | ||
| 28 | 271 | ||||
| 29 | - | func (s *Service) closeSession(sess *session, signal bool) { _, _ = sess, signal } | 272 | + | // readProps reads an a{sv} and keeps the keys the service knows. |
| 273 | + | func (s *Service) readProps(r *zerobus.Reader, req *request) { | |||
| 274 | + | end := r.Array('{') | |||
| 275 | + | for r.More(end) { | |||
| 276 | + | r.Struct() | |||
| 277 | + | key := r.Str() | |||
| 278 | + | sig := r.Variant() | |||
| 279 | + | switch { | |||
| 280 | + | case key == "cursor-mode" && sig == "u": | |||
| 281 | + | req.cursor = r.Uint32() | |||
| 282 | + | case key == "window-id" && sig == "t": | |||
| 283 | + | req.window = r.Uint64() | |||
| 284 | + | case key == "remote-desktop-session-id": | |||
| 285 | + | req.remote = true | |||
| 286 | + | r.Skip(sig) | |||
| 287 | + | default: | |||
| 288 | + | r.Skip(sig) | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // onMessage handles one message from the bus. | |||
| 294 | + | func (s *Service) onMessage(m *zerobus.Message) { | |||
| 295 | + | switch m.Type { | |||
| 296 | + | case zerobus.TypeMethodCall: | |||
| 297 | + | if m.Sender != "" { | |||
| 298 | + | req := s.decode(m) | |||
| 299 | + | s.admit(&req) | |||
| 300 | + | } | |||
| 301 | + | case zerobus.TypeMethodReturn, zerobus.TypeError: | |||
| 302 | + | if m.Sender == busDaemon { | |||
| 303 | + | s.onBusReply(m) | |||
| 304 | + | } | |||
| 305 | + | case zerobus.TypeSignal: | |||
| 306 | + | if m.Sender == busDaemon && m.Interface == busDaemon && m.Member == "NameOwnerChanged" { | |||
| 307 | + | r := m.Body() | |||
| 308 | + | name := r.Str() | |||
| 309 | + | _ = r.Str() | |||
| 310 | + | owner := r.Str() | |||
| 311 | + | if r.Err() == nil && owner == "" && strings.HasPrefix(name, ":") { | |||
| 312 | + | s.onGone(name) | |||
| 313 | + | } | |||
| 314 | + | } | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | // send sends the message that was built. A broken connection ends the | |||
| 319 | + | // attempt; any other failure drops that one message. | |||
| 320 | + | func (s *Service) send() { | |||
| 321 | + | if _, err := s.conn.Send(); err != nil { | |||
| 322 | + | if errors.Is(err, zerobus.ErrClosed) { | |||
| 323 | + | s.fatal = err | |||
| 324 | + | return | |||
| 325 | + | } | |||
| 326 | + | s.log.Warn().Err(err).Msg("message not sent") | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | // replyTo builds the reply of req, or nil when the caller wants none. | |||
| 331 | + | func (s *Service) replyTo(req *request, sig string) *zerobus.Encoder { | |||
| 332 | + | if req.noReply { | |||
| 333 | + | return nil | |||
| 334 | + | } | |||
| 335 | + | return s.conn.NewReply(&zerobus.Message{Sender: req.sender, Serial: req.serial}, sig) | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | // reply sends an empty reply. | |||
| 339 | + | func (s *Service) reply(req *request) { | |||
| 340 | + | if s.replyTo(req, "") != nil { | |||
| 341 | + | s.send() | |||
| 342 | + | } | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | // replyErr sends an error reply. | |||
| 346 | + | func (s *Service) replyErr(req *request, name, text string) { | |||
| 347 | + | if req.noReply { | |||
| 348 | + | return | |||
| 349 | + | } | |||
| 350 | + | s.conn.NewError(&zerobus.Message{Sender: req.sender, Serial: req.serial}, name, "s").Str(text) | |||
| 351 | + | s.send() | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | // emit sends a signal to one peer. | |||
| 355 | + | func (s *Service) emitTo(dest, path, iface, member string) *zerobus.Encoder { | |||
| 356 | + | return s.conn.NewSignalTo(dest, path, iface, member, "") | |||
| 357 | + | } | |||
| 358 | + | ||||
| 359 | + | // dispatch runs a call of a checked caller. | |||
| 360 | + | func (s *Service) dispatch(c *caller, req *request) { | |||
| 361 | + | if !s.allowed(c.exe) { | |||
| 362 | + | s.deny(req) | |||
| 363 | + | return | |||
| 364 | + | } | |||
| 365 | + | if req.privileged() && !s.recheck(c) { | |||
| 366 | + | s.deny(req) | |||
| 367 | + | return | |||
| 368 | + | } | |||
| 369 | + | switch { | |||
| 370 | + | case req.method == mUnknownObject: | |||
| 371 | + | s.replyErr(req, errUnknownObject, "no such object") | |||
| 372 | + | return | |||
| 373 | + | case req.bad: | |||
| 374 | + | s.replyErr(req, errInvalidArgs, "unexpected arguments") | |||
| 375 | + | return | |||
| 376 | + | } | |||
| 377 | + | var sess *session | |||
| 378 | + | var st *stream | |||
| 379 | + | switch req.obj { | |||
| 380 | + | case objSession: | |||
| 381 | + | if sess = s.sessionByID(req.id); sess == nil { | |||
| 382 | + | s.replyErr(req, errUnknownObject, "no such session") | |||
| 383 | + | return | |||
| 384 | + | } | |||
| 385 | + | case objStream: | |||
| 386 | + | if st = s.streamByID(req.id); st == nil { | |||
| 387 | + | s.replyErr(req, errUnknownObject, "no such stream") | |||
| 388 | + | return | |||
| 389 | + | } | |||
| 390 | + | sess = st.sess | |||
| 391 | + | } | |||
| 392 | + | if sess != nil && sess.owner != req.sender { | |||
| 393 | + | s.deny(req) | |||
| 394 | + | return | |||
| 395 | + | } | |||
| 396 | + | switch req.method { | |||
| 397 | + | case mPing: | |||
| 398 | + | s.reply(req) | |||
| 399 | + | case mIntrospect: | |||
| 400 | + | if e := s.replyTo(req, "s"); e != nil { | |||
| 401 | + | e.Str("<node/>") | |||
| 402 | + | s.send() | |||
| 403 | + | } | |||
| 404 | + | case mGet, mGetAll, mSet: | |||
| 405 | + | s.properties(req, sess, st) | |||
| 406 | + | case mCreateSession: | |||
| 407 | + | s.createSession(req) | |||
| 408 | + | case mStart: | |||
| 409 | + | s.start(req, sess) | |||
| 410 | + | case mStop: | |||
| 411 | + | s.reply(req) | |||
| 412 | + | s.closeSession(sess, true) | |||
| 413 | + | case mRecordMonitor, mRecordWindow: | |||
| 414 | + | s.record(req, sess) | |||
| 415 | + | case mGetState: | |||
| 416 | + | s.replyState(req) | |||
| 417 | + | case mGetWindows: | |||
| 418 | + | s.replyWindows(req) | |||
| 419 | + | case mShot, mShotArea, mShotInteractive: | |||
| 420 | + | s.screenshot(req) | |||
| 421 | + | default: | |||
| 422 | + | s.replyErr(req, errNotSupported, "not supported") | |||
| 423 | + | } | |||
| 424 | + | } | |||
| 425 | + | ||||
| 426 | + | // deny refuses a call. | |||
| 427 | + | func (s *Service) deny(req *request) { | |||
| 428 | + | s.replyErr(req, errAccessDenied, "caller not allowed to capture the screen") | |||
| 429 | + | } | |||
A internal/adapters/screencast/bus_test.go +596 -0
| @@ -0,0 +1,596 @@ | |||
| 1 | + | package screencast | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/captureallow" | |
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |
| 11 | + | "github.com/bnema/zerobus" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func testInventory() ports.CastInventory { | |
| 15 | + | return ports.CastInventory{ | |
| 16 | + | Focused: "HEADLESS-1", | |
| 17 | + | Outputs: []ports.CastOutput{ | |
| 18 | + | {Info: ports.OutputInfo{Name: "HEADLESS-2", Make: "Acme", Model: "Two", Width: 32, Height: 32, RefreshMilli: 30000}, Modes: []ports.OutputMode{{Width: 32, Height: 32, RefreshMilli: 30000, Preferred: true}}, Current: 0, X: 64, Width: 32, Height: 32, Scale: 1}, | |
| 19 | + | {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 64, Height: 32, RefreshMilli: 60000}, Modes: []ports.OutputMode{{Width: 64, Height: 32, RefreshMilli: 60000, Preferred: true}, {Width: 32, Height: 16, RefreshMilli: 60000}}, Current: 0, Width: 64, Height: 32, Scale: 1}, | |
| 20 | + | }, | |
| 21 | + | Windows: []ports.CastWindow{{ID: 7, AppID: "org.example.App", Title: "Hello"}, {ID: 3, AppID: "foot", Title: "sh"}}, | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // ready starts the service, waits for its names and publishes the inventory. | |
| 26 | + | func ready(t *testing.T, o harnessOpts) (*svcHarness, *zerobus.Conn) { | |
| 27 | + | t.Helper() | |
| 28 | + | h := newSvcHarness(t, o) | |
| 29 | + | c := h.client() | |
| 30 | + | for _, n := range []string{nameDisplayConfig, nameIntrospect, nameScreenshot} { | |
| 31 | + | h.waitName(c, n) | |
| 32 | + | } | |
| 33 | + | if !o.noPW { | |
| 34 | + | h.waitName(c, nameScreenCast) | |
| 35 | + | } | |
| 36 | + | h.svc.Inventory(testInventory()) | |
| 37 | + | // The inventory is applied by the owner before it serves the next call | |
| 38 | + | // that needs it: a round trip through the service orders them. | |
| 39 | + | deadline := time.Now().Add(5 * time.Second) | |
| 40 | + | for time.Now().Before(deadline) { | |
| 41 | + | _, m := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil) | |
| 42 | + | if m != nil && m.Signature == "a{ta{sv}}" { | |
| 43 | + | r := m.Body() | |
| 44 | + | if end := r.Array('{'); r.More(end) { | |
| 45 | + | return h, c | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | time.Sleep(5 * time.Millisecond) | |
| 49 | + | } | |
| 50 | + | t.Fatal("inventory not applied") | |
| 51 | + | return h, c | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func createSession(t *testing.T, c *zerobus.Conn) string { | |
| 55 | + | t.Helper() | |
| 56 | + | name, m := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { | |
| 57 | + | a := e.BeginArray('{') | |
| 58 | + | e.EndArray(a) | |
| 59 | + | }) | |
| 60 | + | if name != "" { | |
| 61 | + | t.Fatalf("CreateSession: %s", name) | |
| 62 | + | } | |
| 63 | + | return strings.Clone(m.Body().ObjectPath()) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func recordMonitor(c *zerobus.Conn, sess, connector string) (string, string) { | |
| 67 | + | name, m := callErr(c, nameScreenCast, sess, ifaceSession, "RecordMonitor", "sa{sv}", func(e *zerobus.Encoder) { | |
| 68 | + | e.Str(connector) | |
| 69 | + | a := e.BeginArray('{') | |
| 70 | + | e.Struct() | |
| 71 | + | e.Str("cursor-mode") | |
| 72 | + | e.Variant("u") | |
| 73 | + | e.Uint32(1) | |
| 74 | + | e.EndArray(a) | |
| 75 | + | }) | |
| 76 | + | if name != "" { | |
| 77 | + | return name, "" | |
| 78 | + | } | |
| 79 | + | return "", strings.Clone(m.Body().ObjectPath()) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Every method of the contract answers a caller on the allowlist. | |
| 83 | + | func TestMethodMatrix(t *testing.T) { | |
| 84 | + | h, c := ready(t, harnessOpts{}) | |
| 85 | + | _ = h | |
| 86 | + | sess := createSession(t, c) | |
| 87 | + | if sess != "/org/gnome/Mutter/ScreenCast/Session/u1" { | |
| 88 | + | t.Fatalf("session path %q", sess) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Properties of the root object. | |
| 92 | + | name, m := callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Version") }) | |
| 93 | + | if name != "" || m.Signature != "v" { | |
| 94 | + | t.Fatalf("Get Version: %s %v", name, m) | |
| 95 | + | } | |
| 96 | + | r := m.Body() | |
| 97 | + | if sig := r.Variant(); sig != "i" || r.Int32() != 4 { | |
| 98 | + | t.Fatal("Version is not 4") | |
| 99 | + | } | |
| 100 | + | if name, _ = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Nope") }); name != errUnknownProperty { | |
| 101 | + | t.Fatalf("unknown property: %q", name) | |
| 102 | + | } | |
| 103 | + | if name, _ = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Set", "ssv", func(e *zerobus.Encoder) { | |
| 104 | + | e.Str(ifaceScreenCast) | |
| 105 | + | e.Str("Version") | |
| 106 | + | e.Variant("i") | |
| 107 | + | e.Int32(1) | |
| 108 | + | }); name != errPropertyReadOnly { | |
| 109 | + | t.Fatalf("Set: %q", name) | |
| 110 | + | } | |
| 111 | + | name, m = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast) }) | |
| 112 | + | if name != "" || m.Signature != "a{sv}" { | |
| 113 | + | t.Fatalf("GetAll: %s", name) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Streams: the monitor, the window, and what is not supported. | |
| 117 | + | stream, got := "", "" | |
| 118 | + | if name, stream = recordMonitor(c, sess, "HEADLESS-1"); name != "" { | |
| 119 | + | t.Fatalf("RecordMonitor: %s", name) | |
| 120 | + | } | |
| 121 | + | if stream != "/org/gnome/Mutter/ScreenCast/Stream/u1" { | |
| 122 | + | t.Fatalf("stream path %q", stream) | |
| 123 | + | } | |
| 124 | + | if name, _ = recordMonitor(c, sess, "NOPE-1"); name != errInvalidArgs { | |
| 125 | + | t.Fatalf("RecordMonitor of a missing monitor: %q", name) | |
| 126 | + | } | |
| 127 | + | name, m = callErr(c, nameScreenCast, sess, ifaceSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) { | |
| 128 | + | a := e.BeginArray('{') | |
| 129 | + | e.Struct() | |
| 130 | + | e.Str("window-id") | |
| 131 | + | e.Variant("t") | |
| 132 | + | e.Uint64(7) | |
| 133 | + | e.EndArray(a) | |
| 134 | + | }) | |
| 135 | + | if name != "" { | |
| 136 | + | t.Fatalf("RecordWindow: %s", name) | |
| 137 | + | } | |
| 138 | + | got = strings.Clone(m.Body().ObjectPath()) | |
| 139 | + | if got != "/org/gnome/Mutter/ScreenCast/Stream/u2" { | |
| 140 | + | t.Fatalf("window stream path %q", got) | |
| 141 | + | } | |
| 142 | + | name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) { | |
| 143 | + | a := e.BeginArray('{') | |
| 144 | + | e.Struct() | |
| 145 | + | e.Str("window-id") | |
| 146 | + | e.Variant("t") | |
| 147 | + | e.Uint64(99) | |
| 148 | + | e.EndArray(a) | |
| 149 | + | }) | |
| 150 | + | if name != errInvalidArgs { | |
| 151 | + | t.Fatalf("RecordWindow of a closed window: %q", name) | |
| 152 | + | } | |
| 153 | + | if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "RecordArea", "iiiia{sv}", func(e *zerobus.Encoder) { | |
| 154 | + | e.Int32(0) | |
| 155 | + | e.Int32(0) | |
| 156 | + | e.Int32(1) | |
| 157 | + | e.Int32(1) | |
| 158 | + | e.EndArray(e.BeginArray('{')) | |
| 159 | + | }); name == "" { | |
| 160 | + | t.Fatal("RecordArea is not supported") | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | // The stream answers GetAll with its parameters before Start. | |
| 164 | + | name, m = callErr(c, nameScreenCast, stream, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceStream) }) | |
| 165 | + | if name != "" { | |
| 166 | + | t.Fatalf("stream GetAll: %s", name) | |
| 167 | + | } | |
| 168 | + | r = m.Body() | |
| 169 | + | end := r.Array('{') | |
| 170 | + | r.Struct() | |
| 171 | + | if r.Str() != "Parameters" || r.Variant() != "a{sv}" { | |
| 172 | + | t.Fatal("no Parameters") | |
| 173 | + | } | |
| 174 | + | pend := r.Array('{') | |
| 175 | + | seen := map[string][2]int32{} | |
| 176 | + | for r.More(pend) { | |
| 177 | + | r.Struct() | |
| 178 | + | key := r.Str() | |
| 179 | + | if sig := r.Variant(); sig != "(ii)" { | |
| 180 | + | t.Fatalf("%s is %s", key, sig) | |
| 181 | + | } | |
| 182 | + | r.Struct() | |
| 183 | + | seen[key] = [2]int32{r.Int32(), r.Int32()} | |
| 184 | + | } | |
| 185 | + | _ = end | |
| 186 | + | if seen["position"] != [2]int32{0, 0} || seen["size"] != [2]int32{64, 32} { | |
| 187 | + | t.Fatalf("parameters %v", seen) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // Introspect and DisplayConfig properties. | |
| 191 | + | name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceIntrospect); e.Str("version") }) | |
| 192 | + | if name != "" { | |
| 193 | + | t.Fatalf("version: %s", name) | |
| 194 | + | } | |
| 195 | + | r = m.Body() | |
| 196 | + | if r.Variant() != "u" || r.Uint32() != 1 { | |
| 197 | + | t.Fatal("Introspect version is not 1") | |
| 198 | + | } | |
| 199 | + | if name, _ = callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "ApplyMonitorsConfig", "", nil); name == "" { | |
| 200 | + | t.Fatal("ApplyMonitorsConfig is not supported") | |
| 201 | + | } | |
| 202 | + | if name, _ = callErr(c, nameScreenCast, "/org/gnome/Mutter/ScreenCast/Session/u99", ifaceSession, "Stop", "", nil); name != errUnknownObject { | |
| 203 | + | t.Fatalf("unknown session: %q", name) | |
| 204 | + | } | |
| 205 | + | if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" { | |
| 206 | + | t.Fatalf("Stop: %q", name) | |
| 207 | + | } | |
| 208 | + | if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != errUnknownObject { | |
| 209 | + | t.Fatalf("Stop twice: %q", name) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // A signal from the session reaches only its owner: Stop is answered, then | |
| 214 | + | // the owner hears Closed. | |
| 215 | + | func TestStopSignalsClosedToOwner(t *testing.T) { | |
| 216 | + | h, c := ready(t, harnessOpts{}) | |
| 217 | + | other := h.client() | |
| 218 | + | rule := "type='signal',interface='" + ifaceSession + "'" | |
| 219 | + | for _, x := range []*zerobus.Conn{c, other} { | |
| 220 | + | if err := x.AddMatch(rule); err != nil { | |
| 221 | + | t.Fatal(err) | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | sess := createSession(t, c) | |
| 225 | + | if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" { | |
| 226 | + | t.Fatal(name) | |
| 227 | + | } | |
| 228 | + | m := readUntil(t, c, "Closed") | |
| 229 | + | if m.Path != sess || m.Type != zerobus.TypeSignal { | |
| 230 | + | t.Fatalf("closed %+v", m) | |
| 231 | + | } | |
| 232 | + | // The other client would have heard it before the answer of this call. | |
| 233 | + | other.Handle = func(m *zerobus.Message) { | |
| 234 | + | if m.Member == "Closed" { | |
| 235 | + | t.Error("Closed reached a client that is not the owner") | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | other.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetId", "") | |
| 239 | + | if _, err := other.Call(); err != nil { | |
| 240 | + | t.Fatal(err) | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func readUntil(t *testing.T, c *zerobus.Conn, member string) *zerobus.Message { | |
| 245 | + | t.Helper() | |
| 246 | + | for { | |
| 247 | + | m, err := c.ReadMessage() | |
| 248 | + | if err != nil { | |
| 249 | + | t.Fatal(err) | |
| 250 | + | } | |
| 251 | + | if m.Member == member { | |
| 252 | + | return m | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // A caller whose executable is not on the allowlist is refused by every | |
| 258 | + | // object and every method, and nothing is created. | |
| 259 | + | func TestDisallowedCallerGetsAccessDenied(t *testing.T) { | |
| 260 | + | h, c := ready(t, harnessOpts{}) | |
| 261 | + | h.peers.exe.Store("/usr/bin/evil") | |
| 262 | + | for _, tc := range []struct { | |
| 263 | + | dest, path, iface, member, sig string | |
| 264 | + | body func(*zerobus.Encoder) | |
| 265 | + | }{ | |
| 266 | + | {nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }}, | |
| 267 | + | {nameScreenCast, pathScreenCast, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast) }}, | |
| 268 | + | {nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Version") }}, | |
| 269 | + | {nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil}, | |
| 270 | + | {nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil}, | |
| 271 | + | {nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "", nil}, | |
| 272 | + | {nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) { e.Bool(false); e.Bool(false); e.Str("x") }}, | |
| 273 | + | {nameIntrospect, pathIntrospect, ifacePeer, "Ping", "", nil}, | |
| 274 | + | } { | |
| 275 | + | if name, _ := callErr(c, tc.dest, tc.path, tc.iface, tc.member, tc.sig, tc.body); name != errAccessDenied { | |
| 276 | + | t.Errorf("%s.%s: %q", tc.iface, tc.member, name) | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | select { | |
| 280 | + | case cmd := <-h.cmds: | |
| 281 | + | t.Fatalf("command sent for a refused caller: %+v", cmd) | |
| 282 | + | default: | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | // A policy that closes is applied at once: the cache keeps the executable, | |
| 287 | + | // not the verdict. | |
| 288 | + | func TestPolicyChangeApplies(t *testing.T) { | |
| 289 | + | h := newSvcHarness(t, harnessOpts{allow: captureallow.Closed()}) | |
| 290 | + | c := h.client() | |
| 291 | + | h.waitName(c, nameIntrospect) | |
| 292 | + | if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied { | |
| 293 | + | t.Fatalf("closed policy: %q", name) | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | // The executable is read again where it matters: an exec after the first | |
| 298 | + | // check is caught at CreateSession. | |
| 299 | + | func TestExecAfterFirstCheckIsCaught(t *testing.T) { | |
| 300 | + | h, c := ready(t, harnessOpts{}) | |
| 301 | + | if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != "" { | |
| 302 | + | t.Fatalf("first call: %q", name) | |
| 303 | + | } | |
| 304 | + | h.peers.exe.Store("/usr/bin/evil") | |
| 305 | + | // Not a privileged entry point: the cached executable still serves it. | |
| 306 | + | if name, _ := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil); name != "" { | |
| 307 | + | t.Fatalf("cached call: %q", name) | |
| 308 | + | } | |
| 309 | + | name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }) | |
| 310 | + | if name != errAccessDenied { | |
| 311 | + | t.Fatalf("CreateSession after exec: %q", name) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // A pidfd that names another process than the one the bus named is refused. | |
| 316 | + | func TestPidMismatchIsRefused(t *testing.T) { | |
| 317 | + | h := newSvcHarness(t, harnessOpts{}) | |
| 318 | + | h.peers.exePid = func(open int) int { return open + 1 } | |
| 319 | + | c := h.client() | |
| 320 | + | h.waitName(c, nameIntrospect) | |
| 321 | + | if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied { | |
| 322 | + | t.Fatalf("pid mismatch: %q", name) | |
| 323 | + | } | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func TestPidfdOpenFailureIsRefused(t *testing.T) { | |
| 327 | + | h := newSvcHarness(t, harnessOpts{}) | |
| 328 | + | h.peers.openErr = errors.New("no such process") | |
| 329 | + | c := h.client() | |
| 330 | + | h.waitName(c, nameIntrospect) | |
| 331 | + | if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied { | |
| 332 | + | t.Fatalf("pidfd_open failure: %q", name) | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // The session and its streams answer only the unique name that created it. | |
| 337 | + | func TestNonOwnerIsDenied(t *testing.T) { | |
| 338 | + | h, c := ready(t, harnessOpts{}) | |
| 339 | + | other := h.client() | |
| 340 | + | sess := createSession(t, c) | |
| 341 | + | _, stream := recordMonitor(c, sess, "HEADLESS-1") | |
| 342 | + | for _, tc := range []struct{ path, iface, member, sig string }{ | |
| 343 | + | {sess, ifaceSession, "Stop", ""}, | |
| 344 | + | {sess, ifaceSession, "Start", ""}, | |
| 345 | + | {sess, ifaceProps, "GetAll", "s"}, | |
| 346 | + | {stream, ifaceProps, "GetAll", "s"}, | |
| 347 | + | {stream, ifaceProps, "Get", "ss"}, | |
| 348 | + | } { | |
| 349 | + | name, _ := callErr(other, nameScreenCast, tc.path, tc.iface, tc.member, tc.sig, func(e *zerobus.Encoder) { | |
| 350 | + | switch tc.sig { | |
| 351 | + | case "s": | |
| 352 | + | e.Str(ifaceSession) | |
| 353 | + | case "ss": | |
| 354 | + | e.Str(ifaceStream) | |
| 355 | + | e.Str("Parameters") | |
| 356 | + | } | |
| 357 | + | }) | |
| 358 | + | if name != errAccessDenied { | |
| 359 | + | t.Errorf("%s %s: %q", tc.path, tc.member, name) | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | if name, _ := recordMonitor(other, sess, "HEADLESS-1"); name != errAccessDenied { | |
| 363 | + | t.Errorf("RecordMonitor by a non-owner: %q", name) | |
| 364 | + | } | |
| 365 | + | // The owner is unaffected. | |
| 366 | + | if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" { | |
| 367 | + | t.Fatalf("owner Stop: %q", name) | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | // Requests wait in a fixed table while their sender is checked: when it is | |
| 372 | + | // full, the next one gets LimitsExceeded, and the parked ones are answered | |
| 373 | + | // once the check ends. | |
| 374 | + | func TestPendingTableFull(t *testing.T) { | |
| 375 | + | h := newSvcHarness(t, harnessOpts{holdOp: true}) | |
| 376 | + | c := h.client() | |
| 377 | + | h.waitName(c, nameIntrospect) | |
| 378 | + | serials := make([]uint32, 0, maxPending+1) | |
| 379 | + | for range maxPending + 1 { | |
| 380 | + | c.NewCall(nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "") | |
| 381 | + | serial, err := c.Send() | |
| 382 | + | if err != nil { | |
| 383 | + | t.Fatal(err) | |
| 384 | + | } | |
| 385 | + | serials = append(serials, serial) | |
| 386 | + | } | |
| 387 | + | m, err := c.ReadMessage() | |
| 388 | + | if err != nil { | |
| 389 | + | t.Fatal(err) | |
| 390 | + | } | |
| 391 | + | if m.Type != zerobus.TypeError || m.ReplySerial != serials[maxPending] || m.ErrorName != errLimits { | |
| 392 | + | t.Fatalf("17th request: type %d error %q reply %d", m.Type, m.ErrorName, m.ReplySerial) | |
| 393 | + | } | |
| 394 | + | close(h.peers.openGo) | |
| 395 | + | for range maxPending { | |
| 396 | + | m, err := c.ReadMessage() | |
| 397 | + | if err != nil { | |
| 398 | + | t.Fatal(err) | |
| 399 | + | } | |
| 400 | + | if m.Type != zerobus.TypeMethodReturn { | |
| 401 | + | t.Fatalf("parked request: type %d %q", m.Type, m.ErrorName) | |
| 402 | + | } | |
| 403 | + | } | |
| 404 | + | } | |
| 405 | + | ||
| 406 | + | // Closing the owner's connection stops its session: its slot is freed, so | |
| 407 | + | // a new session starts again from the table. | |
| 408 | + | func TestOwnerVanishFreesSessions(t *testing.T) { | |
| 409 | + | h, c := ready(t, harnessOpts{}) | |
| 410 | + | owner := h.client() | |
| 411 | + | for range maxSessions { | |
| 412 | + | createSession(t, owner) | |
| 413 | + | } | |
| 414 | + | if name, _ := callErr(owner, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }); name != errLimits { | |
| 415 | + | t.Fatalf("over the limit: %q", name) | |
| 416 | + | } | |
| 417 | + | _ = owner.Close() | |
| 418 | + | deadline := time.Now().Add(5 * time.Second) | |
| 419 | + | for { | |
| 420 | + | name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }) | |
| 421 | + | if name == "" { | |
| 422 | + | return | |
| 423 | + | } | |
| 424 | + | if time.Now().After(deadline) { | |
| 425 | + | t.Fatalf("sessions of a vanished owner stay: %q\n%s", name, h.log.String()) | |
| 426 | + | } | |
| 427 | + | time.Sleep(10 * time.Millisecond) | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // The remote desktop key is refused, and a service without PipeWire does not | |
| 432 | + | // take the ScreenCast name. | |
| 433 | + | func TestRemoteDesktopRefusedAndNoPipeWire(t *testing.T) { | |
| 434 | + | _, c := ready(t, harnessOpts{}) | |
| 435 | + | name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { | |
| 436 | + | a := e.BeginArray('{') | |
| 437 | + | e.Struct() | |
| 438 | + | e.Str("remote-desktop-session-id") | |
| 439 | + | e.Variant("s") | |
| 440 | + | e.Str("x") | |
| 441 | + | e.EndArray(a) | |
| 442 | + | }) | |
| 443 | + | if name != errNotSupported { | |
| 444 | + | t.Fatalf("remote desktop: %q", name) | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | h := newSvcHarness(t, harnessOpts{noPW: true}) | |
| 448 | + | c2 := h.client() | |
| 449 | + | h.waitName(c2, nameIntrospect) | |
| 450 | + | e := c2.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s") | |
| 451 | + | e.Str(nameScreenCast) | |
| 452 | + | m, err := c2.Call() | |
| 453 | + | if err != nil || m.Body().Bool() { | |
| 454 | + | t.Fatalf("ScreenCast name taken without PipeWire (%v)", err) | |
| 455 | + | } | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | // The encoding of GetCurrentState and GetWindows from an inventory. | |
| 459 | + | func TestDisplayConfigAndWindows(t *testing.T) { | |
| 460 | + | _, c := ready(t, harnessOpts{}) | |
| 461 | + | name, m := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil) | |
| 462 | + | if name != "" { | |
| 463 | + | t.Fatal(name) | |
| 464 | + | } | |
| 465 | + | r := m.Body() | |
| 466 | + | if r.Uint32() != 0 { | |
| 467 | + | t.Fatal("serial") | |
| 468 | + | } | |
| 469 | + | type mon struct { | |
| 470 | + | connector string | |
| 471 | + | current int | |
| 472 | + | modes int | |
| 473 | + | display string | |
| 474 | + | builtin bool | |
| 475 | + | } | |
| 476 | + | var mons []mon | |
| 477 | + | end := r.Array('(') | |
| 478 | + | for r.More(end) { | |
| 479 | + | r.Struct() | |
| 480 | + | r.Struct() | |
| 481 | + | x := mon{connector: r.Str()} | |
| 482 | + | r.Str() | |
| 483 | + | r.Str() | |
| 484 | + | r.Str() | |
| 485 | + | mend := r.Array('(') | |
| 486 | + | for r.More(mend) { | |
| 487 | + | r.Struct() | |
| 488 | + | r.Str() | |
| 489 | + | r.Int32() | |
| 490 | + | r.Int32() | |
| 491 | + | r.Float64() | |
| 492 | + | r.Float64() | |
| 493 | + | send := r.Array('d') | |
| 494 | + | for r.More(send) { | |
| 495 | + | r.Float64() | |
| 496 | + | } | |
| 497 | + | pend := r.Array('{') | |
| 498 | + | x.modes++ | |
| 499 | + | for r.More(pend) { | |
| 500 | + | r.Struct() | |
| 501 | + | key := r.Str() | |
| 502 | + | sig := r.Variant() | |
| 503 | + | if key == "is-current" && r.Bool() { | |
| 504 | + | x.current++ | |
| 505 | + | } else if sig == "b" { | |
| 506 | + | r.Bool() | |
| 507 | + | } | |
| 508 | + | } | |
| 509 | + | } | |
| 510 | + | pend := r.Array('{') | |
| 511 | + | for r.More(pend) { | |
| 512 | + | r.Struct() | |
| 513 | + | key := r.Str() | |
| 514 | + | switch r.Variant() { | |
| 515 | + | case "s": | |
| 516 | + | if v := r.Str(); key == "display-name" { | |
| 517 | + | x.display = v | |
| 518 | + | } | |
| 519 | + | case "b": | |
| 520 | + | x.builtin = r.Bool() | |
| 521 | + | } | |
| 522 | + | } | |
| 523 | + | mons = append(mons, x) | |
| 524 | + | } | |
| 525 | + | logicals := 0 | |
| 526 | + | lend := r.Array('(') | |
| 527 | + | for r.More(lend) { | |
| 528 | + | r.Struct() | |
| 529 | + | r.Int32() | |
| 530 | + | r.Int32() | |
| 531 | + | r.Float64() | |
| 532 | + | r.Uint32() | |
| 533 | + | r.Bool() | |
| 534 | + | mend := r.Array('(') | |
| 535 | + | for r.More(mend) { | |
| 536 | + | r.Struct() | |
| 537 | + | r.Str() | |
| 538 | + | r.Str() | |
| 539 | + | r.Str() | |
| 540 | + | r.Str() | |
| 541 | + | } | |
| 542 | + | pend := r.Array('{') | |
| 543 | + | r.SkipTo(pend) | |
| 544 | + | logicals++ | |
| 545 | + | } | |
| 546 | + | pend := r.Array('{') | |
| 547 | + | r.Struct() | |
| 548 | + | if r.Str() != "layout-mode" || r.Variant() != "u" || r.Uint32() != 1 { | |
| 549 | + | t.Fatal("layout-mode") | |
| 550 | + | } | |
| 551 | + | _ = pend | |
| 552 | + | if !r.Done() { | |
| 553 | + | t.Fatalf("trailing data: %v", r.Err()) | |
| 554 | + | } | |
| 555 | + | if len(mons) != 2 || logicals != 2 || mons[0].connector != "HEADLESS-1" || mons[1].connector != "HEADLESS-2" { | |
| 556 | + | t.Fatalf("monitors %+v logical %d", mons, logicals) | |
| 557 | + | } | |
| 558 | + | for _, x := range mons { | |
| 559 | + | if x.current != 1 { | |
| 560 | + | t.Errorf("%s has %d current modes", x.connector, x.current) | |
| 561 | + | } | |
| 562 | + | } | |
| 563 | + | if mons[0].modes != 2 || mons[1].display != "Acme Two" || mons[0].display != "HEADLESS-1" { | |
| 564 | + | t.Fatalf("monitors %+v", mons) | |
| 565 | + | } | |
| 566 | + | ||
| 567 | + | name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil) | |
| 568 | + | if name != "" { | |
| 569 | + | t.Fatal(name) | |
| 570 | + | } | |
| 571 | + | r = m.Body() | |
| 572 | + | got := map[uint64][2]string{} | |
| 573 | + | wend := r.Array('{') | |
| 574 | + | for r.More(wend) { | |
| 575 | + | r.Struct() | |
| 576 | + | id := r.Uint64() | |
| 577 | + | var v [2]string | |
| 578 | + | pend := r.Array('{') | |
| 579 | + | for r.More(pend) { | |
| 580 | + | r.Struct() | |
| 581 | + | key := r.Str() | |
| 582 | + | r.Variant() | |
| 583 | + | val := r.Str() | |
| 584 | + | switch key { | |
| 585 | + | case "app-id": | |
| 586 | + | v[0] = val | |
| 587 | + | case "title": | |
| 588 | + | v[1] = val | |
| 589 | + | } | |
| 590 | + | } | |
| 591 | + | got[id] = v | |
| 592 | + | } | |
| 593 | + | if len(got) != 2 || got[7] != [2]string{"org.example.App", "Hello"} || got[3] != [2]string{"foot", "sh"} { | |
| 594 | + | t.Fatalf("windows %v", got) | |
| 595 | + | } | |
| 596 | + | } | |
| @@ -0,0 +1,596 @@ | |||||
| 1 | + | package screencast | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/captureallow" | |||
| 10 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 11 | + | "github.com/bnema/zerobus" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func testInventory() ports.CastInventory { | |||
| 15 | + | return ports.CastInventory{ | |||
| 16 | + | Focused: "HEADLESS-1", | |||
| 17 | + | Outputs: []ports.CastOutput{ | |||
| 18 | + | {Info: ports.OutputInfo{Name: "HEADLESS-2", Make: "Acme", Model: "Two", Width: 32, Height: 32, RefreshMilli: 30000}, Modes: []ports.OutputMode{{Width: 32, Height: 32, RefreshMilli: 30000, Preferred: true}}, Current: 0, X: 64, Width: 32, Height: 32, Scale: 1}, | |||
| 19 | + | {Info: ports.OutputInfo{Name: "HEADLESS-1", Width: 64, Height: 32, RefreshMilli: 60000}, Modes: []ports.OutputMode{{Width: 64, Height: 32, RefreshMilli: 60000, Preferred: true}, {Width: 32, Height: 16, RefreshMilli: 60000}}, Current: 0, Width: 64, Height: 32, Scale: 1}, | |||
| 20 | + | }, | |||
| 21 | + | Windows: []ports.CastWindow{{ID: 7, AppID: "org.example.App", Title: "Hello"}, {ID: 3, AppID: "foot", Title: "sh"}}, | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // ready starts the service, waits for its names and publishes the inventory. | |||
| 26 | + | func ready(t *testing.T, o harnessOpts) (*svcHarness, *zerobus.Conn) { | |||
| 27 | + | t.Helper() | |||
| 28 | + | h := newSvcHarness(t, o) | |||
| 29 | + | c := h.client() | |||
| 30 | + | for _, n := range []string{nameDisplayConfig, nameIntrospect, nameScreenshot} { | |||
| 31 | + | h.waitName(c, n) | |||
| 32 | + | } | |||
| 33 | + | if !o.noPW { | |||
| 34 | + | h.waitName(c, nameScreenCast) | |||
| 35 | + | } | |||
| 36 | + | h.svc.Inventory(testInventory()) | |||
| 37 | + | // The inventory is applied by the owner before it serves the next call | |||
| 38 | + | // that needs it: a round trip through the service orders them. | |||
| 39 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 40 | + | for time.Now().Before(deadline) { | |||
| 41 | + | _, m := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil) | |||
| 42 | + | if m != nil && m.Signature == "a{ta{sv}}" { | |||
| 43 | + | r := m.Body() | |||
| 44 | + | if end := r.Array('{'); r.More(end) { | |||
| 45 | + | return h, c | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | time.Sleep(5 * time.Millisecond) | |||
| 49 | + | } | |||
| 50 | + | t.Fatal("inventory not applied") | |||
| 51 | + | return h, c | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func createSession(t *testing.T, c *zerobus.Conn) string { | |||
| 55 | + | t.Helper() | |||
| 56 | + | name, m := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { | |||
| 57 | + | a := e.BeginArray('{') | |||
| 58 | + | e.EndArray(a) | |||
| 59 | + | }) | |||
| 60 | + | if name != "" { | |||
| 61 | + | t.Fatalf("CreateSession: %s", name) | |||
| 62 | + | } | |||
| 63 | + | return strings.Clone(m.Body().ObjectPath()) | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func recordMonitor(c *zerobus.Conn, sess, connector string) (string, string) { | |||
| 67 | + | name, m := callErr(c, nameScreenCast, sess, ifaceSession, "RecordMonitor", "sa{sv}", func(e *zerobus.Encoder) { | |||
| 68 | + | e.Str(connector) | |||
| 69 | + | a := e.BeginArray('{') | |||
| 70 | + | e.Struct() | |||
| 71 | + | e.Str("cursor-mode") | |||
| 72 | + | e.Variant("u") | |||
| 73 | + | e.Uint32(1) | |||
| 74 | + | e.EndArray(a) | |||
| 75 | + | }) | |||
| 76 | + | if name != "" { | |||
| 77 | + | return name, "" | |||
| 78 | + | } | |||
| 79 | + | return "", strings.Clone(m.Body().ObjectPath()) | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // Every method of the contract answers a caller on the allowlist. | |||
| 83 | + | func TestMethodMatrix(t *testing.T) { | |||
| 84 | + | h, c := ready(t, harnessOpts{}) | |||
| 85 | + | _ = h | |||
| 86 | + | sess := createSession(t, c) | |||
| 87 | + | if sess != "/org/gnome/Mutter/ScreenCast/Session/u1" { | |||
| 88 | + | t.Fatalf("session path %q", sess) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // Properties of the root object. | |||
| 92 | + | name, m := callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Version") }) | |||
| 93 | + | if name != "" || m.Signature != "v" { | |||
| 94 | + | t.Fatalf("Get Version: %s %v", name, m) | |||
| 95 | + | } | |||
| 96 | + | r := m.Body() | |||
| 97 | + | if sig := r.Variant(); sig != "i" || r.Int32() != 4 { | |||
| 98 | + | t.Fatal("Version is not 4") | |||
| 99 | + | } | |||
| 100 | + | if name, _ = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Nope") }); name != errUnknownProperty { | |||
| 101 | + | t.Fatalf("unknown property: %q", name) | |||
| 102 | + | } | |||
| 103 | + | if name, _ = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "Set", "ssv", func(e *zerobus.Encoder) { | |||
| 104 | + | e.Str(ifaceScreenCast) | |||
| 105 | + | e.Str("Version") | |||
| 106 | + | e.Variant("i") | |||
| 107 | + | e.Int32(1) | |||
| 108 | + | }); name != errPropertyReadOnly { | |||
| 109 | + | t.Fatalf("Set: %q", name) | |||
| 110 | + | } | |||
| 111 | + | name, m = callErr(c, nameScreenCast, pathScreenCast, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast) }) | |||
| 112 | + | if name != "" || m.Signature != "a{sv}" { | |||
| 113 | + | t.Fatalf("GetAll: %s", name) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // Streams: the monitor, the window, and what is not supported. | |||
| 117 | + | stream, got := "", "" | |||
| 118 | + | if name, stream = recordMonitor(c, sess, "HEADLESS-1"); name != "" { | |||
| 119 | + | t.Fatalf("RecordMonitor: %s", name) | |||
| 120 | + | } | |||
| 121 | + | if stream != "/org/gnome/Mutter/ScreenCast/Stream/u1" { | |||
| 122 | + | t.Fatalf("stream path %q", stream) | |||
| 123 | + | } | |||
| 124 | + | if name, _ = recordMonitor(c, sess, "NOPE-1"); name != errInvalidArgs { | |||
| 125 | + | t.Fatalf("RecordMonitor of a missing monitor: %q", name) | |||
| 126 | + | } | |||
| 127 | + | name, m = callErr(c, nameScreenCast, sess, ifaceSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) { | |||
| 128 | + | a := e.BeginArray('{') | |||
| 129 | + | e.Struct() | |||
| 130 | + | e.Str("window-id") | |||
| 131 | + | e.Variant("t") | |||
| 132 | + | e.Uint64(7) | |||
| 133 | + | e.EndArray(a) | |||
| 134 | + | }) | |||
| 135 | + | if name != "" { | |||
| 136 | + | t.Fatalf("RecordWindow: %s", name) | |||
| 137 | + | } | |||
| 138 | + | got = strings.Clone(m.Body().ObjectPath()) | |||
| 139 | + | if got != "/org/gnome/Mutter/ScreenCast/Stream/u2" { | |||
| 140 | + | t.Fatalf("window stream path %q", got) | |||
| 141 | + | } | |||
| 142 | + | name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "RecordWindow", "a{sv}", func(e *zerobus.Encoder) { | |||
| 143 | + | a := e.BeginArray('{') | |||
| 144 | + | e.Struct() | |||
| 145 | + | e.Str("window-id") | |||
| 146 | + | e.Variant("t") | |||
| 147 | + | e.Uint64(99) | |||
| 148 | + | e.EndArray(a) | |||
| 149 | + | }) | |||
| 150 | + | if name != errInvalidArgs { | |||
| 151 | + | t.Fatalf("RecordWindow of a closed window: %q", name) | |||
| 152 | + | } | |||
| 153 | + | if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "RecordArea", "iiiia{sv}", func(e *zerobus.Encoder) { | |||
| 154 | + | e.Int32(0) | |||
| 155 | + | e.Int32(0) | |||
| 156 | + | e.Int32(1) | |||
| 157 | + | e.Int32(1) | |||
| 158 | + | e.EndArray(e.BeginArray('{')) | |||
| 159 | + | }); name == "" { | |||
| 160 | + | t.Fatal("RecordArea is not supported") | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | // The stream answers GetAll with its parameters before Start. | |||
| 164 | + | name, m = callErr(c, nameScreenCast, stream, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceStream) }) | |||
| 165 | + | if name != "" { | |||
| 166 | + | t.Fatalf("stream GetAll: %s", name) | |||
| 167 | + | } | |||
| 168 | + | r = m.Body() | |||
| 169 | + | end := r.Array('{') | |||
| 170 | + | r.Struct() | |||
| 171 | + | if r.Str() != "Parameters" || r.Variant() != "a{sv}" { | |||
| 172 | + | t.Fatal("no Parameters") | |||
| 173 | + | } | |||
| 174 | + | pend := r.Array('{') | |||
| 175 | + | seen := map[string][2]int32{} | |||
| 176 | + | for r.More(pend) { | |||
| 177 | + | r.Struct() | |||
| 178 | + | key := r.Str() | |||
| 179 | + | if sig := r.Variant(); sig != "(ii)" { | |||
| 180 | + | t.Fatalf("%s is %s", key, sig) | |||
| 181 | + | } | |||
| 182 | + | r.Struct() | |||
| 183 | + | seen[key] = [2]int32{r.Int32(), r.Int32()} | |||
| 184 | + | } | |||
| 185 | + | _ = end | |||
| 186 | + | if seen["position"] != [2]int32{0, 0} || seen["size"] != [2]int32{64, 32} { | |||
| 187 | + | t.Fatalf("parameters %v", seen) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // Introspect and DisplayConfig properties. | |||
| 191 | + | name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceIntrospect); e.Str("version") }) | |||
| 192 | + | if name != "" { | |||
| 193 | + | t.Fatalf("version: %s", name) | |||
| 194 | + | } | |||
| 195 | + | r = m.Body() | |||
| 196 | + | if r.Variant() != "u" || r.Uint32() != 1 { | |||
| 197 | + | t.Fatal("Introspect version is not 1") | |||
| 198 | + | } | |||
| 199 | + | if name, _ = callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "ApplyMonitorsConfig", "", nil); name == "" { | |||
| 200 | + | t.Fatal("ApplyMonitorsConfig is not supported") | |||
| 201 | + | } | |||
| 202 | + | if name, _ = callErr(c, nameScreenCast, "/org/gnome/Mutter/ScreenCast/Session/u99", ifaceSession, "Stop", "", nil); name != errUnknownObject { | |||
| 203 | + | t.Fatalf("unknown session: %q", name) | |||
| 204 | + | } | |||
| 205 | + | if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" { | |||
| 206 | + | t.Fatalf("Stop: %q", name) | |||
| 207 | + | } | |||
| 208 | + | if name, _ = callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != errUnknownObject { | |||
| 209 | + | t.Fatalf("Stop twice: %q", name) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // A signal from the session reaches only its owner: Stop is answered, then | |||
| 214 | + | // the owner hears Closed. | |||
| 215 | + | func TestStopSignalsClosedToOwner(t *testing.T) { | |||
| 216 | + | h, c := ready(t, harnessOpts{}) | |||
| 217 | + | other := h.client() | |||
| 218 | + | rule := "type='signal',interface='" + ifaceSession + "'" | |||
| 219 | + | for _, x := range []*zerobus.Conn{c, other} { | |||
| 220 | + | if err := x.AddMatch(rule); err != nil { | |||
| 221 | + | t.Fatal(err) | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | sess := createSession(t, c) | |||
| 225 | + | if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" { | |||
| 226 | + | t.Fatal(name) | |||
| 227 | + | } | |||
| 228 | + | m := readUntil(t, c, "Closed") | |||
| 229 | + | if m.Path != sess || m.Type != zerobus.TypeSignal { | |||
| 230 | + | t.Fatalf("closed %+v", m) | |||
| 231 | + | } | |||
| 232 | + | // The other client would have heard it before the answer of this call. | |||
| 233 | + | other.Handle = func(m *zerobus.Message) { | |||
| 234 | + | if m.Member == "Closed" { | |||
| 235 | + | t.Error("Closed reached a client that is not the owner") | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | other.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "GetId", "") | |||
| 239 | + | if _, err := other.Call(); err != nil { | |||
| 240 | + | t.Fatal(err) | |||
| 241 | + | } | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | func readUntil(t *testing.T, c *zerobus.Conn, member string) *zerobus.Message { | |||
| 245 | + | t.Helper() | |||
| 246 | + | for { | |||
| 247 | + | m, err := c.ReadMessage() | |||
| 248 | + | if err != nil { | |||
| 249 | + | t.Fatal(err) | |||
| 250 | + | } | |||
| 251 | + | if m.Member == member { | |||
| 252 | + | return m | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | ||||
| 257 | + | // A caller whose executable is not on the allowlist is refused by every | |||
| 258 | + | // object and every method, and nothing is created. | |||
| 259 | + | func TestDisallowedCallerGetsAccessDenied(t *testing.T) { | |||
| 260 | + | h, c := ready(t, harnessOpts{}) | |||
| 261 | + | h.peers.exe.Store("/usr/bin/evil") | |||
| 262 | + | for _, tc := range []struct { | |||
| 263 | + | dest, path, iface, member, sig string | |||
| 264 | + | body func(*zerobus.Encoder) | |||
| 265 | + | }{ | |||
| 266 | + | {nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }}, | |||
| 267 | + | {nameScreenCast, pathScreenCast, ifaceProps, "GetAll", "s", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast) }}, | |||
| 268 | + | {nameScreenCast, pathScreenCast, ifaceProps, "Get", "ss", func(e *zerobus.Encoder) { e.Str(ifaceScreenCast); e.Str("Version") }}, | |||
| 269 | + | {nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil}, | |||
| 270 | + | {nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil}, | |||
| 271 | + | {nameScreenshot, pathScreenshot, ifaceScreenshot, "InteractiveScreenshot", "", nil}, | |||
| 272 | + | {nameScreenshot, pathScreenshot, ifaceScreenshot, "Screenshot", "bbs", func(e *zerobus.Encoder) { e.Bool(false); e.Bool(false); e.Str("x") }}, | |||
| 273 | + | {nameIntrospect, pathIntrospect, ifacePeer, "Ping", "", nil}, | |||
| 274 | + | } { | |||
| 275 | + | if name, _ := callErr(c, tc.dest, tc.path, tc.iface, tc.member, tc.sig, tc.body); name != errAccessDenied { | |||
| 276 | + | t.Errorf("%s.%s: %q", tc.iface, tc.member, name) | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | select { | |||
| 280 | + | case cmd := <-h.cmds: | |||
| 281 | + | t.Fatalf("command sent for a refused caller: %+v", cmd) | |||
| 282 | + | default: | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | // A policy that closes is applied at once: the cache keeps the executable, | |||
| 287 | + | // not the verdict. | |||
| 288 | + | func TestPolicyChangeApplies(t *testing.T) { | |||
| 289 | + | h := newSvcHarness(t, harnessOpts{allow: captureallow.Closed()}) | |||
| 290 | + | c := h.client() | |||
| 291 | + | h.waitName(c, nameIntrospect) | |||
| 292 | + | if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied { | |||
| 293 | + | t.Fatalf("closed policy: %q", name) | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | // The executable is read again where it matters: an exec after the first | |||
| 298 | + | // check is caught at CreateSession. | |||
| 299 | + | func TestExecAfterFirstCheckIsCaught(t *testing.T) { | |||
| 300 | + | h, c := ready(t, harnessOpts{}) | |||
| 301 | + | if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != "" { | |||
| 302 | + | t.Fatalf("first call: %q", name) | |||
| 303 | + | } | |||
| 304 | + | h.peers.exe.Store("/usr/bin/evil") | |||
| 305 | + | // Not a privileged entry point: the cached executable still serves it. | |||
| 306 | + | if name, _ := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil); name != "" { | |||
| 307 | + | t.Fatalf("cached call: %q", name) | |||
| 308 | + | } | |||
| 309 | + | name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }) | |||
| 310 | + | if name != errAccessDenied { | |||
| 311 | + | t.Fatalf("CreateSession after exec: %q", name) | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | // A pidfd that names another process than the one the bus named is refused. | |||
| 316 | + | func TestPidMismatchIsRefused(t *testing.T) { | |||
| 317 | + | h := newSvcHarness(t, harnessOpts{}) | |||
| 318 | + | h.peers.exePid = func(open int) int { return open + 1 } | |||
| 319 | + | c := h.client() | |||
| 320 | + | h.waitName(c, nameIntrospect) | |||
| 321 | + | if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied { | |||
| 322 | + | t.Fatalf("pid mismatch: %q", name) | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func TestPidfdOpenFailureIsRefused(t *testing.T) { | |||
| 327 | + | h := newSvcHarness(t, harnessOpts{}) | |||
| 328 | + | h.peers.openErr = errors.New("no such process") | |||
| 329 | + | c := h.client() | |||
| 330 | + | h.waitName(c, nameIntrospect) | |||
| 331 | + | if name, _ := callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil); name != errAccessDenied { | |||
| 332 | + | t.Fatalf("pidfd_open failure: %q", name) | |||
| 333 | + | } | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | // The session and its streams answer only the unique name that created it. | |||
| 337 | + | func TestNonOwnerIsDenied(t *testing.T) { | |||
| 338 | + | h, c := ready(t, harnessOpts{}) | |||
| 339 | + | other := h.client() | |||
| 340 | + | sess := createSession(t, c) | |||
| 341 | + | _, stream := recordMonitor(c, sess, "HEADLESS-1") | |||
| 342 | + | for _, tc := range []struct{ path, iface, member, sig string }{ | |||
| 343 | + | {sess, ifaceSession, "Stop", ""}, | |||
| 344 | + | {sess, ifaceSession, "Start", ""}, | |||
| 345 | + | {sess, ifaceProps, "GetAll", "s"}, | |||
| 346 | + | {stream, ifaceProps, "GetAll", "s"}, | |||
| 347 | + | {stream, ifaceProps, "Get", "ss"}, | |||
| 348 | + | } { | |||
| 349 | + | name, _ := callErr(other, nameScreenCast, tc.path, tc.iface, tc.member, tc.sig, func(e *zerobus.Encoder) { | |||
| 350 | + | switch tc.sig { | |||
| 351 | + | case "s": | |||
| 352 | + | e.Str(ifaceSession) | |||
| 353 | + | case "ss": | |||
| 354 | + | e.Str(ifaceStream) | |||
| 355 | + | e.Str("Parameters") | |||
| 356 | + | } | |||
| 357 | + | }) | |||
| 358 | + | if name != errAccessDenied { | |||
| 359 | + | t.Errorf("%s %s: %q", tc.path, tc.member, name) | |||
| 360 | + | } | |||
| 361 | + | } | |||
| 362 | + | if name, _ := recordMonitor(other, sess, "HEADLESS-1"); name != errAccessDenied { | |||
| 363 | + | t.Errorf("RecordMonitor by a non-owner: %q", name) | |||
| 364 | + | } | |||
| 365 | + | // The owner is unaffected. | |||
| 366 | + | if name, _ := callErr(c, nameScreenCast, sess, ifaceSession, "Stop", "", nil); name != "" { | |||
| 367 | + | t.Fatalf("owner Stop: %q", name) | |||
| 368 | + | } | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | // Requests wait in a fixed table while their sender is checked: when it is | |||
| 372 | + | // full, the next one gets LimitsExceeded, and the parked ones are answered | |||
| 373 | + | // once the check ends. | |||
| 374 | + | func TestPendingTableFull(t *testing.T) { | |||
| 375 | + | h := newSvcHarness(t, harnessOpts{holdOp: true}) | |||
| 376 | + | c := h.client() | |||
| 377 | + | h.waitName(c, nameIntrospect) | |||
| 378 | + | serials := make([]uint32, 0, maxPending+1) | |||
| 379 | + | for range maxPending + 1 { | |||
| 380 | + | c.NewCall(nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "") | |||
| 381 | + | serial, err := c.Send() | |||
| 382 | + | if err != nil { | |||
| 383 | + | t.Fatal(err) | |||
| 384 | + | } | |||
| 385 | + | serials = append(serials, serial) | |||
| 386 | + | } | |||
| 387 | + | m, err := c.ReadMessage() | |||
| 388 | + | if err != nil { | |||
| 389 | + | t.Fatal(err) | |||
| 390 | + | } | |||
| 391 | + | if m.Type != zerobus.TypeError || m.ReplySerial != serials[maxPending] || m.ErrorName != errLimits { | |||
| 392 | + | t.Fatalf("17th request: type %d error %q reply %d", m.Type, m.ErrorName, m.ReplySerial) | |||
| 393 | + | } | |||
| 394 | + | close(h.peers.openGo) | |||
| 395 | + | for range maxPending { | |||
| 396 | + | m, err := c.ReadMessage() | |||
| 397 | + | if err != nil { | |||
| 398 | + | t.Fatal(err) | |||
| 399 | + | } | |||
| 400 | + | if m.Type != zerobus.TypeMethodReturn { | |||
| 401 | + | t.Fatalf("parked request: type %d %q", m.Type, m.ErrorName) | |||
| 402 | + | } | |||
| 403 | + | } | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | // Closing the owner's connection stops its session: its slot is freed, so | |||
| 407 | + | // a new session starts again from the table. | |||
| 408 | + | func TestOwnerVanishFreesSessions(t *testing.T) { | |||
| 409 | + | h, c := ready(t, harnessOpts{}) | |||
| 410 | + | owner := h.client() | |||
| 411 | + | for range maxSessions { | |||
| 412 | + | createSession(t, owner) | |||
| 413 | + | } | |||
| 414 | + | if name, _ := callErr(owner, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }); name != errLimits { | |||
| 415 | + | t.Fatalf("over the limit: %q", name) | |||
| 416 | + | } | |||
| 417 | + | _ = owner.Close() | |||
| 418 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 419 | + | for { | |||
| 420 | + | name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { e.EndArray(e.BeginArray('{')) }) | |||
| 421 | + | if name == "" { | |||
| 422 | + | return | |||
| 423 | + | } | |||
| 424 | + | if time.Now().After(deadline) { | |||
| 425 | + | t.Fatalf("sessions of a vanished owner stay: %q\n%s", name, h.log.String()) | |||
| 426 | + | } | |||
| 427 | + | time.Sleep(10 * time.Millisecond) | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | // The remote desktop key is refused, and a service without PipeWire does not | |||
| 432 | + | // take the ScreenCast name. | |||
| 433 | + | func TestRemoteDesktopRefusedAndNoPipeWire(t *testing.T) { | |||
| 434 | + | _, c := ready(t, harnessOpts{}) | |||
| 435 | + | name, _ := callErr(c, nameScreenCast, pathScreenCast, ifaceScreenCast, "CreateSession", "a{sv}", func(e *zerobus.Encoder) { | |||
| 436 | + | a := e.BeginArray('{') | |||
| 437 | + | e.Struct() | |||
| 438 | + | e.Str("remote-desktop-session-id") | |||
| 439 | + | e.Variant("s") | |||
| 440 | + | e.Str("x") | |||
| 441 | + | e.EndArray(a) | |||
| 442 | + | }) | |||
| 443 | + | if name != errNotSupported { | |||
| 444 | + | t.Fatalf("remote desktop: %q", name) | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | h := newSvcHarness(t, harnessOpts{noPW: true}) | |||
| 448 | + | c2 := h.client() | |||
| 449 | + | h.waitName(c2, nameIntrospect) | |||
| 450 | + | e := c2.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s") | |||
| 451 | + | e.Str(nameScreenCast) | |||
| 452 | + | m, err := c2.Call() | |||
| 453 | + | if err != nil || m.Body().Bool() { | |||
| 454 | + | t.Fatalf("ScreenCast name taken without PipeWire (%v)", err) | |||
| 455 | + | } | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | // The encoding of GetCurrentState and GetWindows from an inventory. | |||
| 459 | + | func TestDisplayConfigAndWindows(t *testing.T) { | |||
| 460 | + | _, c := ready(t, harnessOpts{}) | |||
| 461 | + | name, m := callErr(c, nameDisplayConfig, pathDisplayConfig, ifaceDisplayConfig, "GetCurrentState", "", nil) | |||
| 462 | + | if name != "" { | |||
| 463 | + | t.Fatal(name) | |||
| 464 | + | } | |||
| 465 | + | r := m.Body() | |||
| 466 | + | if r.Uint32() != 0 { | |||
| 467 | + | t.Fatal("serial") | |||
| 468 | + | } | |||
| 469 | + | type mon struct { | |||
| 470 | + | connector string | |||
| 471 | + | current int | |||
| 472 | + | modes int | |||
| 473 | + | display string | |||
| 474 | + | builtin bool | |||
| 475 | + | } | |||
| 476 | + | var mons []mon | |||
| 477 | + | end := r.Array('(') | |||
| 478 | + | for r.More(end) { | |||
| 479 | + | r.Struct() | |||
| 480 | + | r.Struct() | |||
| 481 | + | x := mon{connector: r.Str()} | |||
| 482 | + | r.Str() | |||
| 483 | + | r.Str() | |||
| 484 | + | r.Str() | |||
| 485 | + | mend := r.Array('(') | |||
| 486 | + | for r.More(mend) { | |||
| 487 | + | r.Struct() | |||
| 488 | + | r.Str() | |||
| 489 | + | r.Int32() | |||
| 490 | + | r.Int32() | |||
| 491 | + | r.Float64() | |||
| 492 | + | r.Float64() | |||
| 493 | + | send := r.Array('d') | |||
| 494 | + | for r.More(send) { | |||
| 495 | + | r.Float64() | |||
| 496 | + | } | |||
| 497 | + | pend := r.Array('{') | |||
| 498 | + | x.modes++ | |||
| 499 | + | for r.More(pend) { | |||
| 500 | + | r.Struct() | |||
| 501 | + | key := r.Str() | |||
| 502 | + | sig := r.Variant() | |||
| 503 | + | if key == "is-current" && r.Bool() { | |||
| 504 | + | x.current++ | |||
| 505 | + | } else if sig == "b" { | |||
| 506 | + | r.Bool() | |||
| 507 | + | } | |||
| 508 | + | } | |||
| 509 | + | } | |||
| 510 | + | pend := r.Array('{') | |||
| 511 | + | for r.More(pend) { | |||
| 512 | + | r.Struct() | |||
| 513 | + | key := r.Str() | |||
| 514 | + | switch r.Variant() { | |||
| 515 | + | case "s": | |||
| 516 | + | if v := r.Str(); key == "display-name" { | |||
| 517 | + | x.display = v | |||
| 518 | + | } | |||
| 519 | + | case "b": | |||
| 520 | + | x.builtin = r.Bool() | |||
| 521 | + | } | |||
| 522 | + | } | |||
| 523 | + | mons = append(mons, x) | |||
| 524 | + | } | |||
| 525 | + | logicals := 0 | |||
| 526 | + | lend := r.Array('(') | |||
| 527 | + | for r.More(lend) { | |||
| 528 | + | r.Struct() | |||
| 529 | + | r.Int32() | |||
| 530 | + | r.Int32() | |||
| 531 | + | r.Float64() | |||
| 532 | + | r.Uint32() | |||
| 533 | + | r.Bool() | |||
| 534 | + | mend := r.Array('(') | |||
| 535 | + | for r.More(mend) { | |||
| 536 | + | r.Struct() | |||
| 537 | + | r.Str() | |||
| 538 | + | r.Str() | |||
| 539 | + | r.Str() | |||
| 540 | + | r.Str() | |||
| 541 | + | } | |||
| 542 | + | pend := r.Array('{') | |||
| 543 | + | r.SkipTo(pend) | |||
| 544 | + | logicals++ | |||
| 545 | + | } | |||
| 546 | + | pend := r.Array('{') | |||
| 547 | + | r.Struct() | |||
| 548 | + | if r.Str() != "layout-mode" || r.Variant() != "u" || r.Uint32() != 1 { | |||
| 549 | + | t.Fatal("layout-mode") | |||
| 550 | + | } | |||
| 551 | + | _ = pend | |||
| 552 | + | if !r.Done() { | |||
| 553 | + | t.Fatalf("trailing data: %v", r.Err()) | |||
| 554 | + | } | |||
| 555 | + | if len(mons) != 2 || logicals != 2 || mons[0].connector != "HEADLESS-1" || mons[1].connector != "HEADLESS-2" { | |||
| 556 | + | t.Fatalf("monitors %+v logical %d", mons, logicals) | |||
| 557 | + | } | |||
| 558 | + | for _, x := range mons { | |||
| 559 | + | if x.current != 1 { | |||
| 560 | + | t.Errorf("%s has %d current modes", x.connector, x.current) | |||
| 561 | + | } | |||
| 562 | + | } | |||
| 563 | + | if mons[0].modes != 2 || mons[1].display != "Acme Two" || mons[0].display != "HEADLESS-1" { | |||
| 564 | + | t.Fatalf("monitors %+v", mons) | |||
| 565 | + | } | |||
| 566 | + | ||||
| 567 | + | name, m = callErr(c, nameIntrospect, pathIntrospect, ifaceIntrospect, "GetWindows", "", nil) | |||
| 568 | + | if name != "" { | |||
| 569 | + | t.Fatal(name) | |||
| 570 | + | } | |||
| 571 | + | r = m.Body() | |||
| 572 | + | got := map[uint64][2]string{} | |||
| 573 | + | wend := r.Array('{') | |||
| 574 | + | for r.More(wend) { | |||
| 575 | + | r.Struct() | |||
| 576 | + | id := r.Uint64() | |||
| 577 | + | var v [2]string | |||
| 578 | + | pend := r.Array('{') | |||
| 579 | + | for r.More(pend) { | |||
| 580 | + | r.Struct() | |||
| 581 | + | key := r.Str() | |||
| 582 | + | r.Variant() | |||
| 583 | + | val := r.Str() | |||
| 584 | + | switch key { | |||
| 585 | + | case "app-id": | |||
| 586 | + | v[0] = val | |||
| 587 | + | case "title": | |||
| 588 | + | v[1] = val | |||
| 589 | + | } | |||
| 590 | + | } | |||
| 591 | + | got[id] = v | |||
| 592 | + | } | |||
| 593 | + | if len(got) != 2 || got[7] != [2]string{"org.example.App", "Hello"} || got[3] != [2]string{"foot", "sh"} { | |||
| 594 | + | t.Fatalf("windows %v", got) | |||
| 595 | + | } | |||
| 596 | + | } | |||
M internal/adapters/screencast/caller.go +203 -2
| @@ -1,6 +1,24 @@ | |||
| 1 | 1 | package screencast | |
| 2 | 2 | ||
| 3 | - | import "os" | |
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zerobus" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // The caller check proves which executable a D-Bus peer runs, with the pidfd | |
| 10 | + | // proof the wayland adapter uses for its clients (package peerexe): | |
| 11 | + | // | |
| 12 | + | // 1. GetConnectionUnixProcessID(name) names a pid; | |
| 13 | + | // 2. pidfd_open(pid) pins that process; | |
| 14 | + | // 3. a second GetConnectionUnixProcessID must name the same pid: the name | |
| 15 | + | // was connected throughout, so the pidfd names the peer and not a process | |
| 16 | + | // that recycled the pid; | |
| 17 | + | // 4. the exe is read through the pidfd. | |
| 18 | + | // | |
| 19 | + | // The bus calls are sent with Send and their replies come back through | |
| 20 | + | // ReadMessage, matched by serial: nothing here waits. A request that arrives | |
| 21 | + | // while its sender is being checked waits in the pending table. | |
| 4 | 22 | ||
| 5 | 23 | // callerState is how far the proof of a D-Bus peer's executable got. | |
| 6 | 24 | type callerState uint8 | |
| @@ -10,7 +28,7 @@ const ( | |||
| 10 | 28 | // callerPid: waiting for the bus to name the peer's pid. | |
| 11 | 29 | callerPid | |
| 12 | 30 | // callerConfirm: a pidfd was opened; waiting for the bus to name the same | |
| 13 | - | // pid again, which proves the pidfd names the peer. | |
| 31 | + | // pid again. | |
| 14 | 32 | callerConfirm | |
| 15 | 33 | // callerKnown: exe is the executable of the peer. | |
| 16 | 34 | callerKnown | |
| @@ -40,3 +58,186 @@ type pending struct { | |||
| 40 | 58 | used bool | |
| 41 | 59 | req request | |
| 42 | 60 | } | |
| 61 | + | ||
| 62 | + | // callerOf returns the cache entry of a unique name. | |
| 63 | + | func (s *Service) callerOf(name string) *caller { | |
| 64 | + | for i := range s.callers { | |
| 65 | + | if s.callers[i].state != callerFree && s.callers[i].name == name { | |
| 66 | + | return &s.callers[i] | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | return nil | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // admit takes a decoded call: a checked sender is served at once, any other | |
| 73 | + | // waits for its check. | |
| 74 | + | func (s *Service) admit(req *request) { | |
| 75 | + | c := s.callerOf(req.sender) | |
| 76 | + | if c != nil && c.state == callerKnown { | |
| 77 | + | s.dispatch(c, req) | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | slot := -1 | |
| 81 | + | for i := range s.pending { | |
| 82 | + | if !s.pending[i].used { | |
| 83 | + | slot = i | |
| 84 | + | break | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | if slot < 0 { | |
| 88 | + | s.replyErr(req, errLimits, "too many requests wait for a caller check") | |
| 89 | + | return | |
| 90 | + | } | |
| 91 | + | if c == nil { | |
| 92 | + | for i := range s.callers { | |
| 93 | + | if s.callers[i].state == callerFree { | |
| 94 | + | c = &s.callers[i] | |
| 95 | + | break | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | if c == nil { | |
| 99 | + | s.replyErr(req, errLimits, "too many callers") | |
| 100 | + | return | |
| 101 | + | } | |
| 102 | + | *c = caller{state: callerPid, name: req.sender} | |
| 103 | + | if !s.askPid(c) { | |
| 104 | + | c.release() | |
| 105 | + | s.deny(req) | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | s.pending[slot] = pending{used: true, req: *req} | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // askPid sends GetConnectionUnixProcessID for c and keeps its serial. | |
| 113 | + | func (s *Service) askPid(c *caller) bool { | |
| 114 | + | e, serial := s.conn.NewMethodCall(busDaemon, busDaemonPath, busDaemon, "GetConnectionUnixProcessID", "s") | |
| 115 | + | e.Str(c.name) | |
| 116 | + | if _, err := s.conn.Send(); err != nil { | |
| 117 | + | s.fatal = err | |
| 118 | + | return false | |
| 119 | + | } | |
| 120 | + | c.serial = serial | |
| 121 | + | return true | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // onBusReply advances the check of the caller the reply answers; any other | |
| 125 | + | // reply of the bus is not ours. | |
| 126 | + | func (s *Service) onBusReply(m *zerobus.Message) { | |
| 127 | + | var c *caller | |
| 128 | + | for i := range s.callers { | |
| 129 | + | cc := &s.callers[i] | |
| 130 | + | if (cc.state == callerPid || cc.state == callerConfirm) && cc.serial == m.ReplySerial { | |
| 131 | + | c = cc | |
| 132 | + | break | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | if c == nil { | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | pid := -1 | |
| 139 | + | if m.Type == zerobus.TypeMethodReturn && m.Signature == "u" { | |
| 140 | + | r := m.Body() | |
| 141 | + | v := r.Uint32() | |
| 142 | + | if r.Err() == nil && v > 0 && v < 1<<31 { | |
| 143 | + | pid = int(v) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | switch { | |
| 147 | + | case pid < 0: | |
| 148 | + | s.failCaller(c, "pid lookup failed") | |
| 149 | + | case c.state == callerPid: | |
| 150 | + | pidfd, err := s.peers.Open(pid) | |
| 151 | + | if err != nil { | |
| 152 | + | s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: no pidfd") | |
| 153 | + | s.failCaller(c, "") | |
| 154 | + | return | |
| 155 | + | } | |
| 156 | + | c.pid, c.pidfd, c.state = pid, pidfd, callerConfirm | |
| 157 | + | if !s.askPid(c) { | |
| 158 | + | s.failCaller(c, "") | |
| 159 | + | } | |
| 160 | + | default: | |
| 161 | + | if pid != c.pid { | |
| 162 | + | s.log.Info().Str("sender", c.name).Msg("caller refused: pid changed during the check") | |
| 163 | + | s.failCaller(c, "") | |
| 164 | + | return | |
| 165 | + | } | |
| 166 | + | exe, got, err := s.peers.Exe(c.pidfd) | |
| 167 | + | if err != nil || got != c.pid { | |
| 168 | + | s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: executable not proven") | |
| 169 | + | s.failCaller(c, "") | |
| 170 | + | return | |
| 171 | + | } | |
| 172 | + | c.exe, c.state = exe, callerKnown | |
| 173 | + | s.flush(c) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // failCaller refuses every request that waits for c and forgets it. | |
| 178 | + | func (s *Service) failCaller(c *caller, why string) { | |
| 179 | + | if why != "" { | |
| 180 | + | s.log.Info().Str("sender", c.name).Msg("caller refused: " + why) | |
| 181 | + | } | |
| 182 | + | name := c.name | |
| 183 | + | c.release() | |
| 184 | + | for i := range s.pending { | |
| 185 | + | if p := &s.pending[i]; p.used && p.req.sender == name { | |
| 186 | + | req := p.req | |
| 187 | + | *p = pending{} | |
| 188 | + | s.deny(&req) | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // flush serves the requests that waited for c, in arrival order. | |
| 194 | + | func (s *Service) flush(c *caller) { | |
| 195 | + | for i := range s.pending { | |
| 196 | + | p := &s.pending[i] | |
| 197 | + | if !p.used || p.req.sender != c.name { | |
| 198 | + | continue | |
| 199 | + | } | |
| 200 | + | req := p.req | |
| 201 | + | *p = pending{} | |
| 202 | + | s.dispatch(c, &req) | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | // onGone forgets a peer that left the bus: its check, its parked requests | |
| 207 | + | // and its sessions. | |
| 208 | + | func (s *Service) onGone(name string) { | |
| 209 | + | if c := s.callerOf(name); c != nil { | |
| 210 | + | c.release() | |
| 211 | + | } | |
| 212 | + | for i := range s.pending { | |
| 213 | + | if s.pending[i].used && s.pending[i].req.sender == name { | |
| 214 | + | s.pending[i] = pending{} | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | for i := range s.sessions { | |
| 218 | + | if sess := &s.sessions[i]; sess.used && sess.owner == name { | |
| 219 | + | s.closeSession(sess, false) | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // allowed reports whether the allowlist in force admits exe. | |
| 225 | + | func (s *Service) allowed(exe string) bool { | |
| 226 | + | p := s.policy() | |
| 227 | + | return p != nil && p.Allows(exe) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | // recheck reads the executable behind the pidfd again, so an exec after the | |
| 231 | + | // first check is caught, and applies the allowlist to it. | |
| 232 | + | func (s *Service) recheck(c *caller) bool { | |
| 233 | + | exe, pid, err := s.peers.Exe(c.pidfd) | |
| 234 | + | if err != nil || pid != c.pid { | |
| 235 | + | s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: executable not proven") | |
| 236 | + | return false | |
| 237 | + | } | |
| 238 | + | if exe != c.exe { | |
| 239 | + | s.log.Info().Str("sender", c.name).Str("was", c.exe).Str("now", exe).Msg("caller executable changed") | |
| 240 | + | c.exe = exe | |
| 241 | + | } | |
| 242 | + | return s.allowed(exe) | |
| 243 | + | } | |
| @@ -1,6 +1,24 @@ | |||||
| 1 | package screencast | 1 | package screencast | ||
| 2 | 2 | ||||
| 3 | - | import "os" | 3 | + | import ( |
| 4 | + | "os" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zerobus" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // The caller check proves which executable a D-Bus peer runs, with the pidfd | |||
| 10 | + | // proof the wayland adapter uses for its clients (package peerexe): | |||
| 11 | + | // | |||
| 12 | + | // 1. GetConnectionUnixProcessID(name) names a pid; | |||
| 13 | + | // 2. pidfd_open(pid) pins that process; | |||
| 14 | + | // 3. a second GetConnectionUnixProcessID must name the same pid: the name | |||
| 15 | + | // was connected throughout, so the pidfd names the peer and not a process | |||
| 16 | + | // that recycled the pid; | |||
| 17 | + | // 4. the exe is read through the pidfd. | |||
| 18 | + | // | |||
| 19 | + | // The bus calls are sent with Send and their replies come back through | |||
| 20 | + | // ReadMessage, matched by serial: nothing here waits. A request that arrives | |||
| 21 | + | // while its sender is being checked waits in the pending table. | |||
| 4 | 22 | ||||
| 5 | // callerState is how far the proof of a D-Bus peer's executable got. | 23 | // callerState is how far the proof of a D-Bus peer's executable got. | ||
| 6 | type callerState uint8 | 24 | type callerState uint8 | ||
| @@ -10,7 +28,7 @@ const ( | |||||
| 10 | // callerPid: waiting for the bus to name the peer's pid. | 28 | // callerPid: waiting for the bus to name the peer's pid. | ||
| 11 | callerPid | 29 | callerPid | ||
| 12 | // callerConfirm: a pidfd was opened; waiting for the bus to name the same | 30 | // callerConfirm: a pidfd was opened; waiting for the bus to name the same | ||
| 13 | - | // pid again, which proves the pidfd names the peer. | 31 | + | // pid again. |
| 14 | callerConfirm | 32 | callerConfirm | ||
| 15 | // callerKnown: exe is the executable of the peer. | 33 | // callerKnown: exe is the executable of the peer. | ||
| 16 | callerKnown | 34 | callerKnown | ||
| @@ -40,3 +58,186 @@ type pending struct { | |||||
| 40 | used bool | 58 | used bool | ||
| 41 | req request | 59 | req request | ||
| 42 | } | 60 | } | ||
| 61 | + | ||||
| 62 | + | // callerOf returns the cache entry of a unique name. | |||
| 63 | + | func (s *Service) callerOf(name string) *caller { | |||
| 64 | + | for i := range s.callers { | |||
| 65 | + | if s.callers[i].state != callerFree && s.callers[i].name == name { | |||
| 66 | + | return &s.callers[i] | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | return nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // admit takes a decoded call: a checked sender is served at once, any other | |||
| 73 | + | // waits for its check. | |||
| 74 | + | func (s *Service) admit(req *request) { | |||
| 75 | + | c := s.callerOf(req.sender) | |||
| 76 | + | if c != nil && c.state == callerKnown { | |||
| 77 | + | s.dispatch(c, req) | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | slot := -1 | |||
| 81 | + | for i := range s.pending { | |||
| 82 | + | if !s.pending[i].used { | |||
| 83 | + | slot = i | |||
| 84 | + | break | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | if slot < 0 { | |||
| 88 | + | s.replyErr(req, errLimits, "too many requests wait for a caller check") | |||
| 89 | + | return | |||
| 90 | + | } | |||
| 91 | + | if c == nil { | |||
| 92 | + | for i := range s.callers { | |||
| 93 | + | if s.callers[i].state == callerFree { | |||
| 94 | + | c = &s.callers[i] | |||
| 95 | + | break | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | if c == nil { | |||
| 99 | + | s.replyErr(req, errLimits, "too many callers") | |||
| 100 | + | return | |||
| 101 | + | } | |||
| 102 | + | *c = caller{state: callerPid, name: req.sender} | |||
| 103 | + | if !s.askPid(c) { | |||
| 104 | + | c.release() | |||
| 105 | + | s.deny(req) | |||
| 106 | + | return | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | s.pending[slot] = pending{used: true, req: *req} | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // askPid sends GetConnectionUnixProcessID for c and keeps its serial. | |||
| 113 | + | func (s *Service) askPid(c *caller) bool { | |||
| 114 | + | e, serial := s.conn.NewMethodCall(busDaemon, busDaemonPath, busDaemon, "GetConnectionUnixProcessID", "s") | |||
| 115 | + | e.Str(c.name) | |||
| 116 | + | if _, err := s.conn.Send(); err != nil { | |||
| 117 | + | s.fatal = err | |||
| 118 | + | return false | |||
| 119 | + | } | |||
| 120 | + | c.serial = serial | |||
| 121 | + | return true | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // onBusReply advances the check of the caller the reply answers; any other | |||
| 125 | + | // reply of the bus is not ours. | |||
| 126 | + | func (s *Service) onBusReply(m *zerobus.Message) { | |||
| 127 | + | var c *caller | |||
| 128 | + | for i := range s.callers { | |||
| 129 | + | cc := &s.callers[i] | |||
| 130 | + | if (cc.state == callerPid || cc.state == callerConfirm) && cc.serial == m.ReplySerial { | |||
| 131 | + | c = cc | |||
| 132 | + | break | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | if c == nil { | |||
| 136 | + | return | |||
| 137 | + | } | |||
| 138 | + | pid := -1 | |||
| 139 | + | if m.Type == zerobus.TypeMethodReturn && m.Signature == "u" { | |||
| 140 | + | r := m.Body() | |||
| 141 | + | v := r.Uint32() | |||
| 142 | + | if r.Err() == nil && v > 0 && v < 1<<31 { | |||
| 143 | + | pid = int(v) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | switch { | |||
| 147 | + | case pid < 0: | |||
| 148 | + | s.failCaller(c, "pid lookup failed") | |||
| 149 | + | case c.state == callerPid: | |||
| 150 | + | pidfd, err := s.peers.Open(pid) | |||
| 151 | + | if err != nil { | |||
| 152 | + | s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: no pidfd") | |||
| 153 | + | s.failCaller(c, "") | |||
| 154 | + | return | |||
| 155 | + | } | |||
| 156 | + | c.pid, c.pidfd, c.state = pid, pidfd, callerConfirm | |||
| 157 | + | if !s.askPid(c) { | |||
| 158 | + | s.failCaller(c, "") | |||
| 159 | + | } | |||
| 160 | + | default: | |||
| 161 | + | if pid != c.pid { | |||
| 162 | + | s.log.Info().Str("sender", c.name).Msg("caller refused: pid changed during the check") | |||
| 163 | + | s.failCaller(c, "") | |||
| 164 | + | return | |||
| 165 | + | } | |||
| 166 | + | exe, got, err := s.peers.Exe(c.pidfd) | |||
| 167 | + | if err != nil || got != c.pid { | |||
| 168 | + | s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: executable not proven") | |||
| 169 | + | s.failCaller(c, "") | |||
| 170 | + | return | |||
| 171 | + | } | |||
| 172 | + | c.exe, c.state = exe, callerKnown | |||
| 173 | + | s.flush(c) | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // failCaller refuses every request that waits for c and forgets it. | |||
| 178 | + | func (s *Service) failCaller(c *caller, why string) { | |||
| 179 | + | if why != "" { | |||
| 180 | + | s.log.Info().Str("sender", c.name).Msg("caller refused: " + why) | |||
| 181 | + | } | |||
| 182 | + | name := c.name | |||
| 183 | + | c.release() | |||
| 184 | + | for i := range s.pending { | |||
| 185 | + | if p := &s.pending[i]; p.used && p.req.sender == name { | |||
| 186 | + | req := p.req | |||
| 187 | + | *p = pending{} | |||
| 188 | + | s.deny(&req) | |||
| 189 | + | } | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // flush serves the requests that waited for c, in arrival order. | |||
| 194 | + | func (s *Service) flush(c *caller) { | |||
| 195 | + | for i := range s.pending { | |||
| 196 | + | p := &s.pending[i] | |||
| 197 | + | if !p.used || p.req.sender != c.name { | |||
| 198 | + | continue | |||
| 199 | + | } | |||
| 200 | + | req := p.req | |||
| 201 | + | *p = pending{} | |||
| 202 | + | s.dispatch(c, &req) | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | // onGone forgets a peer that left the bus: its check, its parked requests | |||
| 207 | + | // and its sessions. | |||
| 208 | + | func (s *Service) onGone(name string) { | |||
| 209 | + | if c := s.callerOf(name); c != nil { | |||
| 210 | + | c.release() | |||
| 211 | + | } | |||
| 212 | + | for i := range s.pending { | |||
| 213 | + | if s.pending[i].used && s.pending[i].req.sender == name { | |||
| 214 | + | s.pending[i] = pending{} | |||
| 215 | + | } | |||
| 216 | + | } | |||
| 217 | + | for i := range s.sessions { | |||
| 218 | + | if sess := &s.sessions[i]; sess.used && sess.owner == name { | |||
| 219 | + | s.closeSession(sess, false) | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // allowed reports whether the allowlist in force admits exe. | |||
| 225 | + | func (s *Service) allowed(exe string) bool { | |||
| 226 | + | p := s.policy() | |||
| 227 | + | return p != nil && p.Allows(exe) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | // recheck reads the executable behind the pidfd again, so an exec after the | |||
| 231 | + | // first check is caught, and applies the allowlist to it. | |||
| 232 | + | func (s *Service) recheck(c *caller) bool { | |||
| 233 | + | exe, pid, err := s.peers.Exe(c.pidfd) | |||
| 234 | + | if err != nil || pid != c.pid { | |||
| 235 | + | s.log.Info().Err(err).Str("sender", c.name).Msg("caller refused: executable not proven") | |||
| 236 | + | return false | |||
| 237 | + | } | |||
| 238 | + | if exe != c.exe { | |||
| 239 | + | s.log.Info().Str("sender", c.name).Str("was", c.exe).Str("now", exe).Msg("caller executable changed") | |||
| 240 | + | c.exe = exe | |||
| 241 | + | } | |||
| 242 | + | return s.allowed(exe) | |||
| 243 | + | } | |||
A internal/adapters/screencast/harness_test.go +214 -0
| @@ -0,0 +1,214 @@ | |||
| 1 | + | package screencast | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "bytes" | |
| 6 | + | "context" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "strings" | |
| 10 | + | "sync" | |
| 11 | + | "sync/atomic" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/neferwl/internal/adapters/captureallow" | |
| 16 | + | "github.com/bnema/neferwl/internal/ports" | |
| 17 | + | "github.com/bnema/zerobus" | |
| 18 | + | "github.com/bnema/zerowrap" | |
| 19 | + | "github.com/stretchr/testify/mock" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // privateBus starts a dbus-daemon of its own, so tests never touch the | |
| 23 | + | // desktop's session bus. CI must run these tests: there, a missing daemon | |
| 24 | + | // fails. | |
| 25 | + | func privateBus(t *testing.T) string { | |
| 26 | + | t.Helper() | |
| 27 | + | bin, err := exec.LookPath("dbus-daemon") | |
| 28 | + | if err != nil { | |
| 29 | + | if os.Getenv("CI") != "" { | |
| 30 | + | t.Fatal("dbus-daemon not installed") | |
| 31 | + | } | |
| 32 | + | t.Skip("dbus-daemon not installed") | |
| 33 | + | } | |
| 34 | + | cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1") | |
| 35 | + | out, err := cmd.StdoutPipe() | |
| 36 | + | if err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | if err := cmd.Start(); err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) | |
| 43 | + | line, err := bufio.NewReader(out).ReadString('\n') | |
| 44 | + | if err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | return strings.TrimSpace(line) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // logBuf is a locked bytes.Buffer, a real io.Writer. | |
| 51 | + | type logBuf struct { | |
| 52 | + | mu sync.Mutex | |
| 53 | + | b bytes.Buffer | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (l *logBuf) Write(p []byte) (int, error) { | |
| 57 | + | l.mu.Lock() | |
| 58 | + | defer l.mu.Unlock() | |
| 59 | + | return l.b.Write(p) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (l *logBuf) String() string { | |
| 63 | + | l.mu.Lock() | |
| 64 | + | defer l.mu.Unlock() | |
| 65 | + | return l.b.String() | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func testLog(out *logBuf) zerowrap.Logger { | |
| 69 | + | return zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: out}).WithField("component", "screencast") | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // peers is what the mocked peerResolver answers: the executable of every | |
| 73 | + | // peer, and a hook to hold Open. | |
| 74 | + | type peers struct { | |
| 75 | + | exe atomic.Value // string | |
| 76 | + | exeNow func() string | |
| 77 | + | openGo chan struct{} // when set, Open waits for it | |
| 78 | + | pid atomic.Int64 | |
| 79 | + | openErr error | |
| 80 | + | exePid func(open int) int // pid Exe reports; the opened one when nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // svcHarness runs a Service on a private bus with mocked executables and | |
| 84 | + | // PipeWire. | |
| 85 | + | type svcHarness struct { | |
| 86 | + | t *testing.T | |
| 87 | + | addr string | |
| 88 | + | svc *Service | |
| 89 | + | cmds chan ports.CastCommand | |
| 90 | + | peers *peers | |
| 91 | + | core *mockvideoCore | |
| 92 | + | log *logBuf | |
| 93 | + | done chan struct{} | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | type harnessOpts struct { | |
| 97 | + | allow *captureallow.Policy | |
| 98 | + | noPW bool | |
| 99 | + | holdOp bool | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness { | |
| 103 | + | t.Helper() | |
| 104 | + | h := &svcHarness{t: t, addr: privateBus(t), cmds: make(chan ports.CastCommand, 64), peers: &peers{}, log: &logBuf{}, done: make(chan struct{})} | |
| 105 | + | h.peers.exe.Store("/usr/bin/grim") | |
| 106 | + | if o.holdOp { | |
| 107 | + | h.peers.openGo = make(chan struct{}) | |
| 108 | + | } | |
| 109 | + | allow := o.allow | |
| 110 | + | if allow == nil { | |
| 111 | + | allow = captureallow.Builtin() | |
| 112 | + | } | |
| 113 | + | h.svc = New(func() *captureallow.Policy { return allow }, h.cmds, testLog(h.log)) | |
| 114 | + | h.svc.address = h.addr | |
| 115 | + | ||
| 116 | + | res := newMockpeerResolver(t) | |
| 117 | + | res.EXPECT().Open(mock.Anything).RunAndReturn(func(pid int) (*os.File, error) { | |
| 118 | + | if h.peers.openGo != nil { | |
| 119 | + | <-h.peers.openGo | |
| 120 | + | } | |
| 121 | + | if h.peers.openErr != nil { | |
| 122 | + | return nil, h.peers.openErr | |
| 123 | + | } | |
| 124 | + | h.peers.pid.Store(int64(pid)) | |
| 125 | + | return os.Open(os.DevNull) | |
| 126 | + | }).Maybe() | |
| 127 | + | res.EXPECT().Exe(mock.Anything).RunAndReturn(func(*os.File) (string, int, error) { | |
| 128 | + | open := int(h.peers.pid.Load()) | |
| 129 | + | pid := open | |
| 130 | + | if h.peers.exePid != nil { | |
| 131 | + | pid = h.peers.exePid(open) | |
| 132 | + | } | |
| 133 | + | if h.peers.exeNow != nil { | |
| 134 | + | return h.peers.exeNow(), pid, nil | |
| 135 | + | } | |
| 136 | + | return h.peers.exe.Load().(string), pid, nil | |
| 137 | + | }).Maybe() | |
| 138 | + | h.svc.peers = res | |
| 139 | + | ||
| 140 | + | pwOpen := newMockpipewireOpener(t) | |
| 141 | + | if o.noPW { | |
| 142 | + | pwOpen.EXPECT().Open().Return(nil, os.ErrNotExist) | |
| 143 | + | } else { | |
| 144 | + | r, w, err := os.Pipe() | |
| 145 | + | if err != nil { | |
| 146 | + | t.Fatal(err) | |
| 147 | + | } | |
| 148 | + | t.Cleanup(func() { r.Close(); w.Close() }) | |
| 149 | + | h.core = newMockvideoCore(t) | |
| 150 | + | h.core.EXPECT().Fd().Return(int(r.Fd())) | |
| 151 | + | h.core.EXPECT().Iterate().Maybe() | |
| 152 | + | h.core.EXPECT().Close().Return().Maybe() | |
| 153 | + | pwOpen.EXPECT().Open().Return(h.core, nil) | |
| 154 | + | } | |
| 155 | + | h.svc.pw = pwOpen | |
| 156 | + | ||
| 157 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 158 | + | go func() { defer close(h.done); h.svc.Run(ctx, 10*time.Millisecond) }() | |
| 159 | + | t.Cleanup(func() { | |
| 160 | + | cancel() | |
| 161 | + | select { | |
| 162 | + | case <-h.done: | |
| 163 | + | case <-time.After(5 * time.Second): | |
| 164 | + | t.Error("Run did not stop") | |
| 165 | + | } | |
| 166 | + | }) | |
| 167 | + | return h | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // client is a bus connection of the test. | |
| 171 | + | func (h *svcHarness) client() *zerobus.Conn { | |
| 172 | + | h.t.Helper() | |
| 173 | + | c, err := zerobus.Dial(h.addr) | |
| 174 | + | if err != nil { | |
| 175 | + | h.t.Fatal(err) | |
| 176 | + | } | |
| 177 | + | h.t.Cleanup(func() { _ = c.Close() }) | |
| 178 | + | return c | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // waitName waits until the service owns name. | |
| 182 | + | func (h *svcHarness) waitName(c *zerobus.Conn, name string) { | |
| 183 | + | h.t.Helper() | |
| 184 | + | deadline := time.Now().Add(5 * time.Second) | |
| 185 | + | for time.Now().Before(deadline) { | |
| 186 | + | e := c.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s") | |
| 187 | + | e.Str(name) | |
| 188 | + | m, err := c.Call() | |
| 189 | + | if err != nil { | |
| 190 | + | h.t.Fatal(err) | |
| 191 | + | } | |
| 192 | + | if m.Body().Bool() { | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | time.Sleep(5 * time.Millisecond) | |
| 196 | + | } | |
| 197 | + | h.t.Fatalf("%s not owned", name) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // callErr calls and returns the D-Bus error name, "" on success. | |
| 201 | + | func callErr(c *zerobus.Conn, dest, path, iface, member, sig string, body func(*zerobus.Encoder)) (string, *zerobus.Message) { | |
| 202 | + | e := c.NewCall(dest, path, iface, member, sig) | |
| 203 | + | if body != nil { | |
| 204 | + | body(e) | |
| 205 | + | } | |
| 206 | + | m, err := c.Call() | |
| 207 | + | if err == nil { | |
| 208 | + | return "", m | |
| 209 | + | } | |
| 210 | + | if de, ok := err.(*zerobus.Error); ok { | |
| 211 | + | return de.Name, m | |
| 212 | + | } | |
| 213 | + | return err.Error(), m | |
| 214 | + | } | |
| @@ -0,0 +1,214 @@ | |||||
| 1 | + | package screencast | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "bytes" | |||
| 6 | + | "context" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "strings" | |||
| 10 | + | "sync" | |||
| 11 | + | "sync/atomic" | |||
| 12 | + | "testing" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/neferwl/internal/adapters/captureallow" | |||
| 16 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 17 | + | "github.com/bnema/zerobus" | |||
| 18 | + | "github.com/bnema/zerowrap" | |||
| 19 | + | "github.com/stretchr/testify/mock" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | // privateBus starts a dbus-daemon of its own, so tests never touch the | |||
| 23 | + | // desktop's session bus. CI must run these tests: there, a missing daemon | |||
| 24 | + | // fails. | |||
| 25 | + | func privateBus(t *testing.T) string { | |||
| 26 | + | t.Helper() | |||
| 27 | + | bin, err := exec.LookPath("dbus-daemon") | |||
| 28 | + | if err != nil { | |||
| 29 | + | if os.Getenv("CI") != "" { | |||
| 30 | + | t.Fatal("dbus-daemon not installed") | |||
| 31 | + | } | |||
| 32 | + | t.Skip("dbus-daemon not installed") | |||
| 33 | + | } | |||
| 34 | + | cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1") | |||
| 35 | + | out, err := cmd.StdoutPipe() | |||
| 36 | + | if err != nil { | |||
| 37 | + | t.Fatal(err) | |||
| 38 | + | } | |||
| 39 | + | if err := cmd.Start(); err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) | |||
| 43 | + | line, err := bufio.NewReader(out).ReadString('\n') | |||
| 44 | + | if err != nil { | |||
| 45 | + | t.Fatal(err) | |||
| 46 | + | } | |||
| 47 | + | return strings.TrimSpace(line) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | // logBuf is a locked bytes.Buffer, a real io.Writer. | |||
| 51 | + | type logBuf struct { | |||
| 52 | + | mu sync.Mutex | |||
| 53 | + | b bytes.Buffer | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (l *logBuf) Write(p []byte) (int, error) { | |||
| 57 | + | l.mu.Lock() | |||
| 58 | + | defer l.mu.Unlock() | |||
| 59 | + | return l.b.Write(p) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (l *logBuf) String() string { | |||
| 63 | + | l.mu.Lock() | |||
| 64 | + | defer l.mu.Unlock() | |||
| 65 | + | return l.b.String() | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func testLog(out *logBuf) zerowrap.Logger { | |||
| 69 | + | return zerowrap.New(zerowrap.Config{Level: "debug", Format: "json", Output: out}).WithField("component", "screencast") | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | // peers is what the mocked peerResolver answers: the executable of every | |||
| 73 | + | // peer, and a hook to hold Open. | |||
| 74 | + | type peers struct { | |||
| 75 | + | exe atomic.Value // string | |||
| 76 | + | exeNow func() string | |||
| 77 | + | openGo chan struct{} // when set, Open waits for it | |||
| 78 | + | pid atomic.Int64 | |||
| 79 | + | openErr error | |||
| 80 | + | exePid func(open int) int // pid Exe reports; the opened one when nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // svcHarness runs a Service on a private bus with mocked executables and | |||
| 84 | + | // PipeWire. | |||
| 85 | + | type svcHarness struct { | |||
| 86 | + | t *testing.T | |||
| 87 | + | addr string | |||
| 88 | + | svc *Service | |||
| 89 | + | cmds chan ports.CastCommand | |||
| 90 | + | peers *peers | |||
| 91 | + | core *mockvideoCore | |||
| 92 | + | log *logBuf | |||
| 93 | + | done chan struct{} | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | type harnessOpts struct { | |||
| 97 | + | allow *captureallow.Policy | |||
| 98 | + | noPW bool | |||
| 99 | + | holdOp bool | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func newSvcHarness(t *testing.T, o harnessOpts) *svcHarness { | |||
| 103 | + | t.Helper() | |||
| 104 | + | h := &svcHarness{t: t, addr: privateBus(t), cmds: make(chan ports.CastCommand, 64), peers: &peers{}, log: &logBuf{}, done: make(chan struct{})} | |||
| 105 | + | h.peers.exe.Store("/usr/bin/grim") | |||
| 106 | + | if o.holdOp { | |||
| 107 | + | h.peers.openGo = make(chan struct{}) | |||
| 108 | + | } | |||
| 109 | + | allow := o.allow | |||
| 110 | + | if allow == nil { | |||
| 111 | + | allow = captureallow.Builtin() | |||
| 112 | + | } | |||
| 113 | + | h.svc = New(func() *captureallow.Policy { return allow }, h.cmds, testLog(h.log)) | |||
| 114 | + | h.svc.address = h.addr | |||
| 115 | + | ||||
| 116 | + | res := newMockpeerResolver(t) | |||
| 117 | + | res.EXPECT().Open(mock.Anything).RunAndReturn(func(pid int) (*os.File, error) { | |||
| 118 | + | if h.peers.openGo != nil { | |||
| 119 | + | <-h.peers.openGo | |||
| 120 | + | } | |||
| 121 | + | if h.peers.openErr != nil { | |||
| 122 | + | return nil, h.peers.openErr | |||
| 123 | + | } | |||
| 124 | + | h.peers.pid.Store(int64(pid)) | |||
| 125 | + | return os.Open(os.DevNull) | |||
| 126 | + | }).Maybe() | |||
| 127 | + | res.EXPECT().Exe(mock.Anything).RunAndReturn(func(*os.File) (string, int, error) { | |||
| 128 | + | open := int(h.peers.pid.Load()) | |||
| 129 | + | pid := open | |||
| 130 | + | if h.peers.exePid != nil { | |||
| 131 | + | pid = h.peers.exePid(open) | |||
| 132 | + | } | |||
| 133 | + | if h.peers.exeNow != nil { | |||
| 134 | + | return h.peers.exeNow(), pid, nil | |||
| 135 | + | } | |||
| 136 | + | return h.peers.exe.Load().(string), pid, nil | |||
| 137 | + | }).Maybe() | |||
| 138 | + | h.svc.peers = res | |||
| 139 | + | ||||
| 140 | + | pwOpen := newMockpipewireOpener(t) | |||
| 141 | + | if o.noPW { | |||
| 142 | + | pwOpen.EXPECT().Open().Return(nil, os.ErrNotExist) | |||
| 143 | + | } else { | |||
| 144 | + | r, w, err := os.Pipe() | |||
| 145 | + | if err != nil { | |||
| 146 | + | t.Fatal(err) | |||
| 147 | + | } | |||
| 148 | + | t.Cleanup(func() { r.Close(); w.Close() }) | |||
| 149 | + | h.core = newMockvideoCore(t) | |||
| 150 | + | h.core.EXPECT().Fd().Return(int(r.Fd())) | |||
| 151 | + | h.core.EXPECT().Iterate().Maybe() | |||
| 152 | + | h.core.EXPECT().Close().Return().Maybe() | |||
| 153 | + | pwOpen.EXPECT().Open().Return(h.core, nil) | |||
| 154 | + | } | |||
| 155 | + | h.svc.pw = pwOpen | |||
| 156 | + | ||||
| 157 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 158 | + | go func() { defer close(h.done); h.svc.Run(ctx, 10*time.Millisecond) }() | |||
| 159 | + | t.Cleanup(func() { | |||
| 160 | + | cancel() | |||
| 161 | + | select { | |||
| 162 | + | case <-h.done: | |||
| 163 | + | case <-time.After(5 * time.Second): | |||
| 164 | + | t.Error("Run did not stop") | |||
| 165 | + | } | |||
| 166 | + | }) | |||
| 167 | + | return h | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // client is a bus connection of the test. | |||
| 171 | + | func (h *svcHarness) client() *zerobus.Conn { | |||
| 172 | + | h.t.Helper() | |||
| 173 | + | c, err := zerobus.Dial(h.addr) | |||
| 174 | + | if err != nil { | |||
| 175 | + | h.t.Fatal(err) | |||
| 176 | + | } | |||
| 177 | + | h.t.Cleanup(func() { _ = c.Close() }) | |||
| 178 | + | return c | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // waitName waits until the service owns name. | |||
| 182 | + | func (h *svcHarness) waitName(c *zerobus.Conn, name string) { | |||
| 183 | + | h.t.Helper() | |||
| 184 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 185 | + | for time.Now().Before(deadline) { | |||
| 186 | + | e := c.NewCall("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "NameHasOwner", "s") | |||
| 187 | + | e.Str(name) | |||
| 188 | + | m, err := c.Call() | |||
| 189 | + | if err != nil { | |||
| 190 | + | h.t.Fatal(err) | |||
| 191 | + | } | |||
| 192 | + | if m.Body().Bool() { | |||
| 193 | + | return | |||
| 194 | + | } | |||
| 195 | + | time.Sleep(5 * time.Millisecond) | |||
| 196 | + | } | |||
| 197 | + | h.t.Fatalf("%s not owned", name) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // callErr calls and returns the D-Bus error name, "" on success. | |||
| 201 | + | func callErr(c *zerobus.Conn, dest, path, iface, member, sig string, body func(*zerobus.Encoder)) (string, *zerobus.Message) { | |||
| 202 | + | e := c.NewCall(dest, path, iface, member, sig) | |||
| 203 | + | if body != nil { | |||
| 204 | + | body(e) | |||
| 205 | + | } | |||
| 206 | + | m, err := c.Call() | |||
| 207 | + | if err == nil { | |||
| 208 | + | return "", m | |||
| 209 | + | } | |||
| 210 | + | if de, ok := err.(*zerobus.Error); ok { | |||
| 211 | + | return de.Name, m | |||
| 212 | + | } | |||
| 213 | + | return err.Error(), m | |||
| 214 | + | } | |||
M internal/adapters/screencast/inventory.go +184 -2
| @@ -1,5 +1,187 @@ | |||
| 1 | 1 | package screencast | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/neferwl/internal/ports" | |
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "slices" | |
| 6 | + | "strings" | |
| 4 | 7 | ||
| 5 | - | func (s *Service) applyInventory(inv ports.CastInventory) { s.inv, s.haveInv = inv, true } | |
| 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). | |
| 15 | + | func (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.conn.NewSignal(pathDisplayConfig, ifaceDisplayConfig, "MonitorsChanged", "") | |
| 33 | + | s.send() | |
| 34 | + | } | |
| 35 | + | if s.owns[2] && !reflect.DeepEqual(old.Windows, inv.Windows) { | |
| 36 | + | s.conn.NewSignal(pathIntrospect, ifaceIntrospect, "WindowsChanged", "") | |
| 37 | + | s.send() | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // isBuiltin follows Mutter: the connector of a laptop panel. | |
| 42 | + | func isBuiltin(connector string) bool { | |
| 43 | + | for _, p := range [...]string{"eDP", "LVDS", "DSI"} { | |
| 44 | + | if strings.HasPrefix(connector, p) { | |
| 45 | + | return true | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | return false | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // refreshHz is a refresh rate in Hz from millihertz. | |
| 52 | + | func refreshHz(milli int) float64 { return float64(milli) / 1000 } | |
| 53 | + | ||
| 54 | + | // replyState answers DisplayConfig.GetCurrentState: | |
| 55 | + | // (u, a((ssss)a(siiddada{sv})a{sv}), a(iiduba(ssss)a{sv}), a{sv}). | |
| 56 | + | // layout-mode 1 is physical: logical sizes are scaled sizes of the modes. | |
| 57 | + | // Exactly one mode of each monitor is current, as x-d-p-g requires. | |
| 58 | + | func (s *Service) replyState(req *request) { | |
| 59 | + | e := s.replyTo(req, "ua((ssss)a(siiddada{sv})a{sv})a(iiduba(ssss)a{sv})a{sv}") | |
| 60 | + | if e == nil { | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | e.Uint32(0) | |
| 64 | + | monitors := e.BeginArray('(') | |
| 65 | + | for i := range s.inv.Outputs { | |
| 66 | + | o := &s.inv.Outputs[i] | |
| 67 | + | e.Struct() | |
| 68 | + | s.encodeNames(e, o) | |
| 69 | + | modes := e.BeginArray('(') | |
| 70 | + | cur := o.Current | |
| 71 | + | if cur < 0 || cur >= len(o.Modes) { | |
| 72 | + | cur = 0 | |
| 73 | + | } | |
| 74 | + | for j, m := range o.Modes { | |
| 75 | + | e.Struct() | |
| 76 | + | e.Str(modeID(m)) | |
| 77 | + | e.Int32(int32(m.Width)) | |
| 78 | + | e.Int32(int32(m.Height)) | |
| 79 | + | e.Float64(refreshHz(m.RefreshMilli)) | |
| 80 | + | e.Float64(1) | |
| 81 | + | sc := e.BeginArray('d') | |
| 82 | + | e.Float64(1) | |
| 83 | + | e.EndArray(sc) | |
| 84 | + | props := e.BeginArray('{') | |
| 85 | + | e.Struct() | |
| 86 | + | e.Str("is-preferred") | |
| 87 | + | e.Variant("b") | |
| 88 | + | e.Bool(m.Preferred) | |
| 89 | + | if j == cur { | |
| 90 | + | e.Struct() | |
| 91 | + | e.Str("is-current") | |
| 92 | + | e.Variant("b") | |
| 93 | + | e.Bool(true) | |
| 94 | + | } | |
| 95 | + | e.EndArray(props) | |
| 96 | + | } | |
| 97 | + | e.EndArray(modes) | |
| 98 | + | props := e.BeginArray('{') | |
| 99 | + | display := strings.TrimSpace(o.Info.Make + " " + o.Info.Model) | |
| 100 | + | if display == "" { | |
| 101 | + | display = o.Info.Name | |
| 102 | + | } | |
| 103 | + | e.Struct() | |
| 104 | + | e.Str("display-name") | |
| 105 | + | e.Variant("s") | |
| 106 | + | e.Str(display) | |
| 107 | + | e.Struct() | |
| 108 | + | e.Str("is-builtin") | |
| 109 | + | e.Variant("b") | |
| 110 | + | e.Bool(isBuiltin(o.Info.Name)) | |
| 111 | + | e.EndArray(props) | |
| 112 | + | } | |
| 113 | + | e.EndArray(monitors) | |
| 114 | + | logical := e.BeginArray('(') | |
| 115 | + | for i := range s.inv.Outputs { | |
| 116 | + | o := &s.inv.Outputs[i] | |
| 117 | + | e.Struct() | |
| 118 | + | e.Int32(int32(o.X)) | |
| 119 | + | e.Int32(int32(o.Y)) | |
| 120 | + | scale := o.Scale | |
| 121 | + | if scale <= 0 { | |
| 122 | + | scale = 1 | |
| 123 | + | } | |
| 124 | + | e.Float64(scale) | |
| 125 | + | e.Uint32(uint32(o.Transform)) | |
| 126 | + | e.Bool(i == 0) | |
| 127 | + | mons := e.BeginArray('(') | |
| 128 | + | e.Struct() | |
| 129 | + | s.encodeNames(e, o) | |
| 130 | + | e.EndArray(mons) | |
| 131 | + | props := e.BeginArray('{') | |
| 132 | + | e.EndArray(props) | |
| 133 | + | } | |
| 134 | + | e.EndArray(logical) | |
| 135 | + | props := e.BeginArray('{') | |
| 136 | + | e.Struct() | |
| 137 | + | e.Str("layout-mode") | |
| 138 | + | e.Variant("u") | |
| 139 | + | e.Uint32(1) | |
| 140 | + | e.EndArray(props) | |
| 141 | + | s.send() | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // encodeNames writes the monitor spec (connector, vendor, product, serial). | |
| 145 | + | // The serial falls back to the connector, as session restore needs one. | |
| 146 | + | func (s *Service) encodeNames(e *zerobus.Encoder, o *ports.CastOutput) { | |
| 147 | + | e.Struct() | |
| 148 | + | serial := o.Info.Serial | |
| 149 | + | if serial == "" { | |
| 150 | + | serial = o.Info.Name | |
| 151 | + | } | |
| 152 | + | e.Str(o.Info.Name) | |
| 153 | + | e.Str(o.Info.Make) | |
| 154 | + | e.Str(o.Info.Model) | |
| 155 | + | e.Str(serial) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func modeID(m ports.OutputMode) string { | |
| 159 | + | return itoa(m.Width) + "x" + itoa(m.Height) + "@" + ftoa(refreshHz(m.RefreshMilli)) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // replyWindows answers Introspect.GetWindows: a{ta{sv}} of app-id and title. | |
| 163 | + | // The app id is the raw Wayland app id. | |
| 164 | + | func (s *Service) replyWindows(req *request) { | |
| 165 | + | e := s.replyTo(req, "a{ta{sv}}") | |
| 166 | + | if e == nil { | |
| 167 | + | return | |
| 168 | + | } | |
| 169 | + | a := e.BeginArray('{') | |
| 170 | + | for i := range s.inv.Windows { | |
| 171 | + | w := &s.inv.Windows[i] | |
| 172 | + | e.Struct() | |
| 173 | + | e.Uint64(uint64(w.ID)) | |
| 174 | + | props := e.BeginArray('{') | |
| 175 | + | e.Struct() | |
| 176 | + | e.Str("app-id") | |
| 177 | + | e.Variant("s") | |
| 178 | + | e.Str(w.AppID) | |
| 179 | + | e.Struct() | |
| 180 | + | e.Str("title") | |
| 181 | + | e.Variant("s") | |
| 182 | + | e.Str(w.Title) | |
| 183 | + | e.EndArray(props) | |
| 184 | + | } | |
| 185 | + | e.EndArray(a) | |
| 186 | + | s.send() | |
| 187 | + | } | |
| @@ -1,5 +1,187 @@ | |||||
| 1 | package screencast | 1 | package screencast | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/neferwl/internal/ports" | 3 | + | import ( |
| 4 | + | "reflect" | |||
| 5 | + | "slices" | |||
| 6 | + | "strings" | |||
| 4 | 7 | ||||
| 5 | - | func (s *Service) applyInventory(inv ports.CastInventory) { s.inv, s.haveInv = inv, true } | 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). | |||
| 15 | + | func (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.conn.NewSignal(pathDisplayConfig, ifaceDisplayConfig, "MonitorsChanged", "") | |||
| 33 | + | s.send() | |||
| 34 | + | } | |||
| 35 | + | if s.owns[2] && !reflect.DeepEqual(old.Windows, inv.Windows) { | |||
| 36 | + | s.conn.NewSignal(pathIntrospect, ifaceIntrospect, "WindowsChanged", "") | |||
| 37 | + | s.send() | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // isBuiltin follows Mutter: the connector of a laptop panel. | |||
| 42 | + | func isBuiltin(connector string) bool { | |||
| 43 | + | for _, p := range [...]string{"eDP", "LVDS", "DSI"} { | |||
| 44 | + | if strings.HasPrefix(connector, p) { | |||
| 45 | + | return true | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | return false | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // refreshHz is a refresh rate in Hz from millihertz. | |||
| 52 | + | func refreshHz(milli int) float64 { return float64(milli) / 1000 } | |||
| 53 | + | ||||
| 54 | + | // replyState answers DisplayConfig.GetCurrentState: | |||
| 55 | + | // (u, a((ssss)a(siiddada{sv})a{sv}), a(iiduba(ssss)a{sv}), a{sv}). | |||
| 56 | + | // layout-mode 1 is physical: logical sizes are scaled sizes of the modes. | |||
| 57 | + | // Exactly one mode of each monitor is current, as x-d-p-g requires. | |||
| 58 | + | func (s *Service) replyState(req *request) { | |||
| 59 | + | e := s.replyTo(req, "ua((ssss)a(siiddada{sv})a{sv})a(iiduba(ssss)a{sv})a{sv}") | |||
| 60 | + | if e == nil { | |||
| 61 | + | return | |||
| 62 | + | } | |||
| 63 | + | e.Uint32(0) | |||
| 64 | + | monitors := e.BeginArray('(') | |||
| 65 | + | for i := range s.inv.Outputs { | |||
| 66 | + | o := &s.inv.Outputs[i] | |||
| 67 | + | e.Struct() | |||
| 68 | + | s.encodeNames(e, o) | |||
| 69 | + | modes := e.BeginArray('(') | |||
| 70 | + | cur := o.Current | |||
| 71 | + | if cur < 0 || cur >= len(o.Modes) { | |||
| 72 | + | cur = 0 | |||
| 73 | + | } | |||
| 74 | + | for j, m := range o.Modes { | |||
| 75 | + | e.Struct() | |||
| 76 | + | e.Str(modeID(m)) | |||
| 77 | + | e.Int32(int32(m.Width)) | |||
| 78 | + | e.Int32(int32(m.Height)) | |||
| 79 | + | e.Float64(refreshHz(m.RefreshMilli)) | |||
| 80 | + | e.Float64(1) | |||
| 81 | + | sc := e.BeginArray('d') | |||
| 82 | + | e.Float64(1) | |||
| 83 | + | e.EndArray(sc) | |||
| 84 | + | props := e.BeginArray('{') | |||
| 85 | + | e.Struct() | |||
| 86 | + | e.Str("is-preferred") | |||
| 87 | + | e.Variant("b") | |||
| 88 | + | e.Bool(m.Preferred) | |||
| 89 | + | if j == cur { | |||
| 90 | + | e.Struct() | |||
| 91 | + | e.Str("is-current") | |||
| 92 | + | e.Variant("b") | |||
| 93 | + | e.Bool(true) | |||
| 94 | + | } | |||
| 95 | + | e.EndArray(props) | |||
| 96 | + | } | |||
| 97 | + | e.EndArray(modes) | |||
| 98 | + | props := e.BeginArray('{') | |||
| 99 | + | display := strings.TrimSpace(o.Info.Make + " " + o.Info.Model) | |||
| 100 | + | if display == "" { | |||
| 101 | + | display = o.Info.Name | |||
| 102 | + | } | |||
| 103 | + | e.Struct() | |||
| 104 | + | e.Str("display-name") | |||
| 105 | + | e.Variant("s") | |||
| 106 | + | e.Str(display) | |||
| 107 | + | e.Struct() | |||
| 108 | + | e.Str("is-builtin") | |||
| 109 | + | e.Variant("b") | |||
| 110 | + | e.Bool(isBuiltin(o.Info.Name)) | |||
| 111 | + | e.EndArray(props) | |||
| 112 | + | } | |||
| 113 | + | e.EndArray(monitors) | |||
| 114 | + | logical := e.BeginArray('(') | |||
| 115 | + | for i := range s.inv.Outputs { | |||
| 116 | + | o := &s.inv.Outputs[i] | |||
| 117 | + | e.Struct() | |||
| 118 | + | e.Int32(int32(o.X)) | |||
| 119 | + | e.Int32(int32(o.Y)) | |||
| 120 | + | scale := o.Scale | |||
| 121 | + | if scale <= 0 { | |||
| 122 | + | scale = 1 | |||
| 123 | + | } | |||
| 124 | + | e.Float64(scale) | |||
| 125 | + | e.Uint32(uint32(o.Transform)) | |||
| 126 | + | e.Bool(i == 0) | |||
| 127 | + | mons := e.BeginArray('(') | |||
| 128 | + | e.Struct() | |||
| 129 | + | s.encodeNames(e, o) | |||
| 130 | + | e.EndArray(mons) | |||
| 131 | + | props := e.BeginArray('{') | |||
| 132 | + | e.EndArray(props) | |||
| 133 | + | } | |||
| 134 | + | e.EndArray(logical) | |||
| 135 | + | props := e.BeginArray('{') | |||
| 136 | + | e.Struct() | |||
| 137 | + | e.Str("layout-mode") | |||
| 138 | + | e.Variant("u") | |||
| 139 | + | e.Uint32(1) | |||
| 140 | + | e.EndArray(props) | |||
| 141 | + | s.send() | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | // encodeNames writes the monitor spec (connector, vendor, product, serial). | |||
| 145 | + | // The serial falls back to the connector, as session restore needs one. | |||
| 146 | + | func (s *Service) encodeNames(e *zerobus.Encoder, o *ports.CastOutput) { | |||
| 147 | + | e.Struct() | |||
| 148 | + | serial := o.Info.Serial | |||
| 149 | + | if serial == "" { | |||
| 150 | + | serial = o.Info.Name | |||
| 151 | + | } | |||
| 152 | + | e.Str(o.Info.Name) | |||
| 153 | + | e.Str(o.Info.Make) | |||
| 154 | + | e.Str(o.Info.Model) | |||
| 155 | + | e.Str(serial) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func modeID(m ports.OutputMode) string { | |||
| 159 | + | return itoa(m.Width) + "x" + itoa(m.Height) + "@" + ftoa(refreshHz(m.RefreshMilli)) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // replyWindows answers Introspect.GetWindows: a{ta{sv}} of app-id and title. | |||
| 163 | + | // The app id is the raw Wayland app id. | |||
| 164 | + | func (s *Service) replyWindows(req *request) { | |||
| 165 | + | e := s.replyTo(req, "a{ta{sv}}") | |||
| 166 | + | if e == nil { | |||
| 167 | + | return | |||
| 168 | + | } | |||
| 169 | + | a := e.BeginArray('{') | |||
| 170 | + | for i := range s.inv.Windows { | |||
| 171 | + | w := &s.inv.Windows[i] | |||
| 172 | + | e.Struct() | |||
| 173 | + | e.Uint64(uint64(w.ID)) | |||
| 174 | + | props := e.BeginArray('{') | |||
| 175 | + | e.Struct() | |||
| 176 | + | e.Str("app-id") | |||
| 177 | + | e.Variant("s") | |||
| 178 | + | e.Str(w.AppID) | |||
| 179 | + | e.Struct() | |||
| 180 | + | e.Str("title") | |||
| 181 | + | e.Variant("s") | |||
| 182 | + | e.Str(w.Title) | |||
| 183 | + | e.EndArray(props) | |||
| 184 | + | } | |||
| 185 | + | e.EndArray(a) | |||
| 186 | + | s.send() | |||
| 187 | + | } | |||
A internal/adapters/screencast/producer.go +314 -0
| @@ -0,0 +1,314 @@ | |||
| 1 | + | package screencast | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | "github.com/bnema/purego-pipewire/pipewire" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // The producer turns wayland's damage into PipeWire frames, one stream at a | |
| 11 | + | // time and without allocating in steady state: | |
| 12 | + | // | |
| 13 | + | // CastDamage -> dirty | |
| 14 | + | // pump: STREAMING, dirty or first frame, no frame in flight, interval | |
| 15 | + | // elapsed, a buffer free -> Dequeue, CastFrame{Data: buffer} | |
| 16 | + | // CastFrameDone ok -> Queue, then Trigger (the order PipeWire needs) | |
| 17 | + | // CastFrameDone with an error -> Release, retry paced | |
| 18 | + | // | |
| 19 | + | // The buffer's mapping is lent to the wayland server until the CastFrameDone | |
| 20 | + | // of that buffer, so the source is never resized or destroyed while a frame | |
| 21 | + | // is in flight. | |
| 22 | + | ||
| 23 | + | // wl_shm formats of the supported video formats. | |
| 24 | + | const ( | |
| 25 | + | shmARGB8888 = 0 | |
| 26 | + | shmXRGB8888 = 1 | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | // maxFrameErrors is how many frames in a row may fail before the stream | |
| 30 | + | // stops. | |
| 31 | + | const maxFrameErrors = 3 | |
| 32 | + | ||
| 33 | + | // retryOpen is how long a command waits when the wayland channel is full. | |
| 34 | + | const retryOpen = 10 * time.Millisecond | |
| 35 | + | ||
| 36 | + | func shmFormat(f pipewire.VideoFormat) uint32 { | |
| 37 | + | if f == pipewire.VideoFormatBGRA { | |
| 38 | + | return shmARGB8888 | |
| 39 | + | } | |
| 40 | + | return shmXRGB8888 | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // streamOfCast finds the stream of a cast ID. | |
| 44 | + | func (s *Service) streamOfCast(id uint64) *stream { | |
| 45 | + | for i := range s.sessions { | |
| 46 | + | sess := &s.sessions[i] | |
| 47 | + | if !sess.used { | |
| 48 | + | continue | |
| 49 | + | } | |
| 50 | + | for j := range sess.streams { | |
| 51 | + | if st := &sess.streams[j]; st.used && st.cast == id { | |
| 52 | + | return st | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // onCastEvent applies one event of the wayland server. | |
| 60 | + | func (s *Service) onCastEvent(ev *ports.CastEvent) { | |
| 61 | + | st := s.streamOfCast(ev.ID) | |
| 62 | + | if st == nil { | |
| 63 | + | return | |
| 64 | + | } | |
| 65 | + | switch ev.Kind { | |
| 66 | + | case ports.CastDamage: | |
| 67 | + | st.dirty = true | |
| 68 | + | case ports.CastSize: | |
| 69 | + | if st.closing { | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | if st.haveSize && st.w == ev.Width && st.h == ev.Height { | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | st.w, st.h, st.haveSize = ev.Width, ev.Height, true | |
| 76 | + | if st.src != nil { | |
| 77 | + | st.needResize = true | |
| 78 | + | } | |
| 79 | + | case ports.CastFrameDone: | |
| 80 | + | s.frameDone(st, ev) | |
| 81 | + | case ports.CastStopped: | |
| 82 | + | st.stopped, st.needClose = true, false | |
| 83 | + | if st.inflight { | |
| 84 | + | // Wayland orders CastStopped after the frame's CastFrameDone. | |
| 85 | + | s.log.Warn().Str("stream", st.path).Msg("cast stopped with a frame in flight") | |
| 86 | + | st.inflight = false | |
| 87 | + | s.giveBack(st) | |
| 88 | + | } | |
| 89 | + | if !st.closing { | |
| 90 | + | s.log.Info().Str("stream", st.path).Str("reason", string(ev.Reason)).Err(ev.Err).Msg("cast stopped") | |
| 91 | + | s.closeSession(st.sess, true) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // giveBack releases the buffer a frame borrowed. | |
| 97 | + | func (s *Service) giveBack(st *stream) { | |
| 98 | + | if st.buf != nil && st.src != nil { | |
| 99 | + | st.src.Release(st.buf) | |
| 100 | + | } | |
| 101 | + | st.buf = nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // frameDone settles the frame in flight: a complete image is queued and | |
| 105 | + | // triggered, anything else gives the buffer back. | |
| 106 | + | func (s *Service) frameDone(st *stream, ev *ports.CastEvent) { | |
| 107 | + | if !st.inflight || st.buf == nil || st.buf.Index != ev.Buf { | |
| 108 | + | s.log.Warn().Str("stream", st.path).Int("buf", ev.Buf).Msg("frame done for no frame in flight") | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | st.inflight = false | |
| 112 | + | if ev.Err != nil || st.closing { | |
| 113 | + | s.giveBack(st) | |
| 114 | + | if ev.Err == nil { | |
| 115 | + | return | |
| 116 | + | } | |
| 117 | + | st.errs++ | |
| 118 | + | st.dirty = true | |
| 119 | + | if !st.closing { | |
| 120 | + | s.log.Info().Str("stream", st.path).Err(ev.Err).Int("errors", st.errs).Msg("cast frame failed") | |
| 121 | + | if st.errs >= maxFrameErrors { | |
| 122 | + | s.closeSession(st.sess, true) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | return | |
| 126 | + | } | |
| 127 | + | st.errs = 0 | |
| 128 | + | st.seq++ | |
| 129 | + | var pts time.Duration | |
| 130 | + | if !ev.Time.IsZero() { | |
| 131 | + | pts = time.Duration(ev.Time.UnixNano()) | |
| 132 | + | } | |
| 133 | + | b := st.buf | |
| 134 | + | st.buf = nil | |
| 135 | + | st.src.Queue(b, st.seq, pts, false) | |
| 136 | + | st.src.Trigger() | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // pumpAll runs every stream: commands, negotiation and frames. | |
| 140 | + | func (s *Service) pumpAll(now time.Time) { | |
| 141 | + | s.waitFor = 0 | |
| 142 | + | for i := range s.sessions { | |
| 143 | + | sess := &s.sessions[i] | |
| 144 | + | if !sess.used { | |
| 145 | + | continue | |
| 146 | + | } | |
| 147 | + | for j := range sess.streams { | |
| 148 | + | if st := &sess.streams[j]; st.used { | |
| 149 | + | s.pump(st, now) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // wait asks for a poll timeout no longer than d. | |
| 156 | + | func (s *Service) wait(d time.Duration) { | |
| 157 | + | if d <= 0 { | |
| 158 | + | d = time.Millisecond | |
| 159 | + | } | |
| 160 | + | if s.waitFor == 0 || d < s.waitFor { | |
| 161 | + | s.waitFor = d | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // send puts a command on the wayland channel without blocking. | |
| 166 | + | func (s *Service) sendCmd(cmd ports.CastCommand) bool { | |
| 167 | + | select { | |
| 168 | + | case s.cmds <- cmd: | |
| 169 | + | return true | |
| 170 | + | default: | |
| 171 | + | return false | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func (s *Service) pump(st *stream, now time.Time) { | |
| 176 | + | if st.closing { | |
| 177 | + | s.pumpClosing(st) | |
| 178 | + | return | |
| 179 | + | } | |
| 180 | + | if st.needOpen { | |
| 181 | + | cmd := ports.CastCommand{Kind: ports.CastOpen, ID: st.cast, Output: st.output, Window: st.window, Cursor: st.cursor} | |
| 182 | + | if !s.sendCmd(cmd) { | |
| 183 | + | s.wait(retryOpen) | |
| 184 | + | return | |
| 185 | + | } | |
| 186 | + | st.needOpen, st.opened = false, true | |
| 187 | + | } | |
| 188 | + | if !st.haveSize { | |
| 189 | + | return | |
| 190 | + | } | |
| 191 | + | if st.src == nil { | |
| 192 | + | s.openSource(st) | |
| 193 | + | if st.src == nil { | |
| 194 | + | return | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | if st.pwState == pipewire.StreamStateError || st.pwState == pipewire.StreamStateUnconnected && st.seenState { | |
| 198 | + | s.log.Info().Str("stream", st.path).Int32("state", st.pwState).Msg("PipeWire stream ended") | |
| 199 | + | s.closeSession(st.sess, true) | |
| 200 | + | return | |
| 201 | + | } | |
| 202 | + | if st.needResize && !st.inflight { | |
| 203 | + | st.needResize = false | |
| 204 | + | st.fw, st.fh = 0, 0 | |
| 205 | + | if err := st.src.Resize(st.w, st.h); err != nil { | |
| 206 | + | s.log.Warn().Err(err).Str("stream", st.path).Msg("PipeWire resize failed") | |
| 207 | + | s.closeSession(st.sess, true) | |
| 208 | + | return | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | if !st.announced && st.pwState >= pipewire.StreamStatePaused && st.nodeID != 0 { | |
| 212 | + | st.announced = true | |
| 213 | + | s.conn.NewSignalTo(st.sess.owner, st.path, ifaceStream, "PipeWireStreamAdded", "u").Uint32(st.nodeID) | |
| 214 | + | s.send() | |
| 215 | + | } | |
| 216 | + | s.pumpFrame(st, now) | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | // openSource creates the PipeWire node once the target size is known. | |
| 220 | + | func (s *Service) openSource(st *stream) { | |
| 221 | + | if s.pwc == nil { | |
| 222 | + | s.closeSession(st.sess, true) | |
| 223 | + | return | |
| 224 | + | } | |
| 225 | + | cfg := pipewire.VideoConfig{Name: "neferwl-screencast", Width: st.w, Height: st.h, MaxFPS: st.maxFPS, Formats: []pipewire.VideoFormat{pipewire.VideoFormatBGRx, pipewire.VideoFormatBGRA}} | |
| 226 | + | src, err := s.pwc.NewVideoSource(cfg, &st.h2) | |
| 227 | + | if err != nil { | |
| 228 | + | s.log.Warn().Err(err).Str("stream", st.path).Msg("PipeWire stream not created") | |
| 229 | + | s.closeSession(st.sess, true) | |
| 230 | + | return | |
| 231 | + | } | |
| 232 | + | st.src = src | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // pumpFrame requests the next frame when one is due. | |
| 236 | + | func (s *Service) pumpFrame(st *stream, now time.Time) { | |
| 237 | + | if st.inflight || st.pwState != pipewire.StreamStateStreaming || st.needResize || | |
| 238 | + | st.fw != st.w || st.fh != st.h || st.fw == 0 { | |
| 239 | + | return | |
| 240 | + | } | |
| 241 | + | if !st.dirty && !st.forced { | |
| 242 | + | return | |
| 243 | + | } | |
| 244 | + | interval := time.Second / time.Duration(max(st.maxFPS, 1)) | |
| 245 | + | if since := now.Sub(st.lastReq); !st.lastReq.IsZero() && since < interval { | |
| 246 | + | s.wait(interval - since) | |
| 247 | + | return | |
| 248 | + | } | |
| 249 | + | b := st.src.Dequeue() | |
| 250 | + | if b == nil { | |
| 251 | + | // Every buffer is with the consumer: look again shortly. | |
| 252 | + | s.wait(interval) | |
| 253 | + | return | |
| 254 | + | } | |
| 255 | + | if b.Width != st.w || b.Height != st.h || len(b.Data) < b.Stride*b.Height { | |
| 256 | + | st.src.Release(b) | |
| 257 | + | s.wait(interval) | |
| 258 | + | return | |
| 259 | + | } | |
| 260 | + | cmd := ports.CastCommand{Kind: ports.CastFrame, ID: st.cast, Buf: b.Index, Data: b.Data, Stride: b.Stride, Width: b.Width, Height: b.Height, Format: shmFormat(b.Format)} | |
| 261 | + | if !s.sendCmd(cmd) { | |
| 262 | + | st.src.Release(b) | |
| 263 | + | s.wait(interval) | |
| 264 | + | return | |
| 265 | + | } | |
| 266 | + | st.inflight, st.buf = true, b | |
| 267 | + | st.lastReq, st.dirty, st.forced = now, false, false | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // pumpClosing finishes a stream of an ended session: CastClose, then the | |
| 271 | + | // PipeWire node once no frame is in flight. | |
| 272 | + | func (s *Service) pumpClosing(st *stream) { | |
| 273 | + | if st.needClose { | |
| 274 | + | if !s.sendCmd(ports.CastCommand{Kind: ports.CastClose, ID: st.cast}) { | |
| 275 | + | s.wait(retryOpen) | |
| 276 | + | return | |
| 277 | + | } | |
| 278 | + | st.needClose = false | |
| 279 | + | } | |
| 280 | + | if st.inflight { | |
| 281 | + | return | |
| 282 | + | } | |
| 283 | + | if st.src != nil { | |
| 284 | + | s.giveBack(st) | |
| 285 | + | st.src.Destroy() | |
| 286 | + | st.src = nil | |
| 287 | + | } | |
| 288 | + | *st = stream{} | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // reap frees the dying sessions once every stream released its resources. | |
| 292 | + | func (s *Service) reap() { | |
| 293 | + | for i := range s.sessions { | |
| 294 | + | sess := &s.sessions[i] | |
| 295 | + | if !sess.used || !sess.dying { | |
| 296 | + | continue | |
| 297 | + | } | |
| 298 | + | live := false | |
| 299 | + | for j := range sess.streams { | |
| 300 | + | live = live || sess.streams[j].used | |
| 301 | + | } | |
| 302 | + | if !live { | |
| 303 | + | *sess = session{} | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // streamsLive reports whether a stream still holds PipeWire resources or a | |
| 309 | + | // lent buffer. | |
| 310 | + | func (s *Service) streamsLive() bool { | |
| 311 | + | live := false | |
| 312 | + | s.forEachStream(func(st *stream) { live = live || st.src != nil || st.inflight }) | |
| 313 | + | return live | |
| 314 | + | } | |
| @@ -0,0 +1,314 @@ | |||||
| 1 | + | package screencast | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | "github.com/bnema/purego-pipewire/pipewire" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // The producer turns wayland's damage into PipeWire frames, one stream at a | |||
| 11 | + | // time and without allocating in steady state: | |||
| 12 | + | // | |||
| 13 | + | // CastDamage -> dirty | |||
| 14 | + | // pump: STREAMING, dirty or first frame, no frame in flight, interval | |||
| 15 | + | // elapsed, a buffer free -> Dequeue, CastFrame{Data: buffer} | |||
| 16 | + | // CastFrameDone ok -> Queue, then Trigger (the order PipeWire needs) | |||
| 17 | + | // CastFrameDone with an error -> Release, retry paced | |||
| 18 | + | // | |||
| 19 | + | // The buffer's mapping is lent to the wayland server until the CastFrameDone | |||
| 20 | + | // of that buffer, so the source is never resized or destroyed while a frame | |||
| 21 | + | // is in flight. | |||
| 22 | + | ||||
| 23 | + | // wl_shm formats of the supported video formats. | |||
| 24 | + | const ( | |||
| 25 | + | shmARGB8888 = 0 | |||
| 26 | + | shmXRGB8888 = 1 | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 29 | + | // maxFrameErrors is how many frames in a row may fail before the stream | |||
| 30 | + | // stops. | |||
| 31 | + | const maxFrameErrors = 3 | |||
| 32 | + | ||||
| 33 | + | // retryOpen is how long a command waits when the wayland channel is full. | |||
| 34 | + | const retryOpen = 10 * time.Millisecond | |||
| 35 | + | ||||
| 36 | + | func shmFormat(f pipewire.VideoFormat) uint32 { | |||
| 37 | + | if f == pipewire.VideoFormatBGRA { | |||
| 38 | + | return shmARGB8888 | |||
| 39 | + | } | |||
| 40 | + | return shmXRGB8888 | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // streamOfCast finds the stream of a cast ID. | |||
| 44 | + | func (s *Service) streamOfCast(id uint64) *stream { | |||
| 45 | + | for i := range s.sessions { | |||
| 46 | + | sess := &s.sessions[i] | |||
| 47 | + | if !sess.used { | |||
| 48 | + | continue | |||
| 49 | + | } | |||
| 50 | + | for j := range sess.streams { | |||
| 51 | + | if st := &sess.streams[j]; st.used && st.cast == id { | |||
| 52 | + | return st | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | return nil | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // onCastEvent applies one event of the wayland server. | |||
| 60 | + | func (s *Service) onCastEvent(ev *ports.CastEvent) { | |||
| 61 | + | st := s.streamOfCast(ev.ID) | |||
| 62 | + | if st == nil { | |||
| 63 | + | return | |||
| 64 | + | } | |||
| 65 | + | switch ev.Kind { | |||
| 66 | + | case ports.CastDamage: | |||
| 67 | + | st.dirty = true | |||
| 68 | + | case ports.CastSize: | |||
| 69 | + | if st.closing { | |||
| 70 | + | return | |||
| 71 | + | } | |||
| 72 | + | if st.haveSize && st.w == ev.Width && st.h == ev.Height { | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | st.w, st.h, st.haveSize = ev.Width, ev.Height, true | |||
| 76 | + | if st.src != nil { | |||
| 77 | + | st.needResize = true | |||
| 78 | + | } | |||
| 79 | + | case ports.CastFrameDone: | |||
| 80 | + | s.frameDone(st, ev) | |||
| 81 | + | case ports.CastStopped: | |||
| 82 | + | st.stopped, st.needClose = true, false | |||
| 83 | + | if st.inflight { | |||
| 84 | + | // Wayland orders CastStopped after the frame's CastFrameDone. | |||
| 85 | + | s.log.Warn().Str("stream", st.path).Msg("cast stopped with a frame in flight") | |||
| 86 | + | st.inflight = false | |||
| 87 | + | s.giveBack(st) | |||
| 88 | + | } | |||
| 89 | + | if !st.closing { | |||
| 90 | + | s.log.Info().Str("stream", st.path).Str("reason", string(ev.Reason)).Err(ev.Err).Msg("cast stopped") | |||
| 91 | + | s.closeSession(st.sess, true) | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // giveBack releases the buffer a frame borrowed. | |||
| 97 | + | func (s *Service) giveBack(st *stream) { | |||
| 98 | + | if st.buf != nil && st.src != nil { | |||
| 99 | + | st.src.Release(st.buf) | |||
| 100 | + | } | |||
| 101 | + | st.buf = nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // frameDone settles the frame in flight: a complete image is queued and | |||
| 105 | + | // triggered, anything else gives the buffer back. | |||
| 106 | + | func (s *Service) frameDone(st *stream, ev *ports.CastEvent) { | |||
| 107 | + | if !st.inflight || st.buf == nil || st.buf.Index != ev.Buf { | |||
| 108 | + | s.log.Warn().Str("stream", st.path).Int("buf", ev.Buf).Msg("frame done for no frame in flight") | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | st.inflight = false | |||
| 112 | + | if ev.Err != nil || st.closing { | |||
| 113 | + | s.giveBack(st) | |||
| 114 | + | if ev.Err == nil { | |||
| 115 | + | return | |||
| 116 | + | } | |||
| 117 | + | st.errs++ | |||
| 118 | + | st.dirty = true | |||
| 119 | + | if !st.closing { | |||
| 120 | + | s.log.Info().Str("stream", st.path).Err(ev.Err).Int("errors", st.errs).Msg("cast frame failed") | |||
| 121 | + | if st.errs >= maxFrameErrors { | |||
| 122 | + | s.closeSession(st.sess, true) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | return | |||
| 126 | + | } | |||
| 127 | + | st.errs = 0 | |||
| 128 | + | st.seq++ | |||
| 129 | + | var pts time.Duration | |||
| 130 | + | if !ev.Time.IsZero() { | |||
| 131 | + | pts = time.Duration(ev.Time.UnixNano()) | |||
| 132 | + | } | |||
| 133 | + | b := st.buf | |||
| 134 | + | st.buf = nil | |||
| 135 | + | st.src.Queue(b, st.seq, pts, false) | |||
| 136 | + | st.src.Trigger() | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // pumpAll runs every stream: commands, negotiation and frames. | |||
| 140 | + | func (s *Service) pumpAll(now time.Time) { | |||
| 141 | + | s.waitFor = 0 | |||
| 142 | + | for i := range s.sessions { | |||
| 143 | + | sess := &s.sessions[i] | |||
| 144 | + | if !sess.used { | |||
| 145 | + | continue | |||
| 146 | + | } | |||
| 147 | + | for j := range sess.streams { | |||
| 148 | + | if st := &sess.streams[j]; st.used { | |||
| 149 | + | s.pump(st, now) | |||
| 150 | + | } | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | // wait asks for a poll timeout no longer than d. | |||
| 156 | + | func (s *Service) wait(d time.Duration) { | |||
| 157 | + | if d <= 0 { | |||
| 158 | + | d = time.Millisecond | |||
| 159 | + | } | |||
| 160 | + | if s.waitFor == 0 || d < s.waitFor { | |||
| 161 | + | s.waitFor = d | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // send puts a command on the wayland channel without blocking. | |||
| 166 | + | func (s *Service) sendCmd(cmd ports.CastCommand) bool { | |||
| 167 | + | select { | |||
| 168 | + | case s.cmds <- cmd: | |||
| 169 | + | return true | |||
| 170 | + | default: | |||
| 171 | + | return false | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func (s *Service) pump(st *stream, now time.Time) { | |||
| 176 | + | if st.closing { | |||
| 177 | + | s.pumpClosing(st) | |||
| 178 | + | return | |||
| 179 | + | } | |||
| 180 | + | if st.needOpen { | |||
| 181 | + | cmd := ports.CastCommand{Kind: ports.CastOpen, ID: st.cast, Output: st.output, Window: st.window, Cursor: st.cursor} | |||
| 182 | + | if !s.sendCmd(cmd) { | |||
| 183 | + | s.wait(retryOpen) | |||
| 184 | + | return | |||
| 185 | + | } | |||
| 186 | + | st.needOpen, st.opened = false, true | |||
| 187 | + | } | |||
| 188 | + | if !st.haveSize { | |||
| 189 | + | return | |||
| 190 | + | } | |||
| 191 | + | if st.src == nil { | |||
| 192 | + | s.openSource(st) | |||
| 193 | + | if st.src == nil { | |||
| 194 | + | return | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | if st.pwState == pipewire.StreamStateError || st.pwState == pipewire.StreamStateUnconnected && st.seenState { | |||
| 198 | + | s.log.Info().Str("stream", st.path).Int32("state", st.pwState).Msg("PipeWire stream ended") | |||
| 199 | + | s.closeSession(st.sess, true) | |||
| 200 | + | return | |||
| 201 | + | } | |||
| 202 | + | if st.needResize && !st.inflight { | |||
| 203 | + | st.needResize = false | |||
| 204 | + | st.fw, st.fh = 0, 0 | |||
| 205 | + | if err := st.src.Resize(st.w, st.h); err != nil { | |||
| 206 | + | s.log.Warn().Err(err).Str("stream", st.path).Msg("PipeWire resize failed") | |||
| 207 | + | s.closeSession(st.sess, true) | |||
| 208 | + | return | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | if !st.announced && st.pwState >= pipewire.StreamStatePaused && st.nodeID != 0 { | |||
| 212 | + | st.announced = true | |||
| 213 | + | s.conn.NewSignalTo(st.sess.owner, st.path, ifaceStream, "PipeWireStreamAdded", "u").Uint32(st.nodeID) | |||
| 214 | + | s.send() | |||
| 215 | + | } | |||
| 216 | + | s.pumpFrame(st, now) | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | // openSource creates the PipeWire node once the target size is known. | |||
| 220 | + | func (s *Service) openSource(st *stream) { | |||
| 221 | + | if s.pwc == nil { | |||
| 222 | + | s.closeSession(st.sess, true) | |||
| 223 | + | return | |||
| 224 | + | } | |||
| 225 | + | cfg := pipewire.VideoConfig{Name: "neferwl-screencast", Width: st.w, Height: st.h, MaxFPS: st.maxFPS, Formats: []pipewire.VideoFormat{pipewire.VideoFormatBGRx, pipewire.VideoFormatBGRA}} | |||
| 226 | + | src, err := s.pwc.NewVideoSource(cfg, &st.h2) | |||
| 227 | + | if err != nil { | |||
| 228 | + | s.log.Warn().Err(err).Str("stream", st.path).Msg("PipeWire stream not created") | |||
| 229 | + | s.closeSession(st.sess, true) | |||
| 230 | + | return | |||
| 231 | + | } | |||
| 232 | + | st.src = src | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // pumpFrame requests the next frame when one is due. | |||
| 236 | + | func (s *Service) pumpFrame(st *stream, now time.Time) { | |||
| 237 | + | if st.inflight || st.pwState != pipewire.StreamStateStreaming || st.needResize || | |||
| 238 | + | st.fw != st.w || st.fh != st.h || st.fw == 0 { | |||
| 239 | + | return | |||
| 240 | + | } | |||
| 241 | + | if !st.dirty && !st.forced { | |||
| 242 | + | return | |||
| 243 | + | } | |||
| 244 | + | interval := time.Second / time.Duration(max(st.maxFPS, 1)) | |||
| 245 | + | if since := now.Sub(st.lastReq); !st.lastReq.IsZero() && since < interval { | |||
| 246 | + | s.wait(interval - since) | |||
| 247 | + | return | |||
| 248 | + | } | |||
| 249 | + | b := st.src.Dequeue() | |||
| 250 | + | if b == nil { | |||
| 251 | + | // Every buffer is with the consumer: look again shortly. | |||
| 252 | + | s.wait(interval) | |||
| 253 | + | return | |||
| 254 | + | } | |||
| 255 | + | if b.Width != st.w || b.Height != st.h || len(b.Data) < b.Stride*b.Height { | |||
| 256 | + | st.src.Release(b) | |||
| 257 | + | s.wait(interval) | |||
| 258 | + | return | |||
| 259 | + | } | |||
| 260 | + | cmd := ports.CastCommand{Kind: ports.CastFrame, ID: st.cast, Buf: b.Index, Data: b.Data, Stride: b.Stride, Width: b.Width, Height: b.Height, Format: shmFormat(b.Format)} | |||
| 261 | + | if !s.sendCmd(cmd) { | |||
| 262 | + | st.src.Release(b) | |||
| 263 | + | s.wait(interval) | |||
| 264 | + | return | |||
| 265 | + | } | |||
| 266 | + | st.inflight, st.buf = true, b | |||
| 267 | + | st.lastReq, st.dirty, st.forced = now, false, false | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | // pumpClosing finishes a stream of an ended session: CastClose, then the | |||
| 271 | + | // PipeWire node once no frame is in flight. | |||
| 272 | + | func (s *Service) pumpClosing(st *stream) { | |||
| 273 | + | if st.needClose { | |||
| 274 | + | if !s.sendCmd(ports.CastCommand{Kind: ports.CastClose, ID: st.cast}) { | |||
| 275 | + | s.wait(retryOpen) | |||
| 276 | + | return | |||
| 277 | + | } | |||
| 278 | + | st.needClose = false | |||
| 279 | + | } | |||
| 280 | + | if st.inflight { | |||
| 281 | + | return | |||
| 282 | + | } | |||
| 283 | + | if st.src != nil { | |||
| 284 | + | s.giveBack(st) | |||
| 285 | + | st.src.Destroy() | |||
| 286 | + | st.src = nil | |||
| 287 | + | } | |||
| 288 | + | *st = stream{} | |||
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | // reap frees the dying sessions once every stream released its resources. | |||
| 292 | + | func (s *Service) reap() { | |||
| 293 | + | for i := range s.sessions { | |||
| 294 | + | sess := &s.sessions[i] | |||
| 295 | + | if !sess.used || !sess.dying { | |||
| 296 | + | continue | |||
| 297 | + | } | |||
| 298 | + | live := false | |||
| 299 | + | for j := range sess.streams { | |||
| 300 | + | live = live || sess.streams[j].used | |||
| 301 | + | } | |||
| 302 | + | if !live { | |||
| 303 | + | *sess = session{} | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | // streamsLive reports whether a stream still holds PipeWire resources or a | |||
| 309 | + | // lent buffer. | |||
| 310 | + | func (s *Service) streamsLive() bool { | |||
| 311 | + | live := false | |||
| 312 | + | s.forEachStream(func(st *stream) { live = live || st.src != nil || st.inflight }) | |||
| 313 | + | return live | |||
| 314 | + | } | |||
M internal/adapters/screencast/screencast.go +3 -7
| @@ -16,7 +16,6 @@ package screencast | |||
| 16 | 16 | import ( | |
| 17 | 17 | "context" | |
| 18 | 18 | "errors" | |
| 19 | - | "os" | |
| 20 | 19 | "runtime" | |
| 21 | 20 | "sync" | |
| 22 | 21 | "time" | |
| @@ -211,7 +210,7 @@ func (s *Service) attempt(ctx context.Context) (err error) { | |||
| 211 | 210 | s.log.Warn().Err(err).Msg("PipeWire unavailable: screen casting is not offered") | |
| 212 | 211 | } | |
| 213 | 212 | defer s.shutdown() | |
| 214 | - | if err = s.start(); err != nil { | |
| 213 | + | if err = s.acquire(); err != nil { | |
| 215 | 214 | return err | |
| 216 | 215 | } | |
| 217 | 216 | return s.loop(ctx) | |
| @@ -228,8 +227,8 @@ func (s *Service) reset(ctx context.Context, conn bus) { | |||
| 228 | 227 | s.shot = shotState{} | |
| 229 | 228 | } | |
| 230 | 229 | ||
| 231 | - | // start watches departures and takes the names. | |
| 232 | - | func (s *Service) start() error { | |
| 230 | + | // acquire watches departures and takes the names. | |
| 231 | + | func (s *Service) acquire() error { | |
| 233 | 232 | rule := "type='signal',sender='" + busDaemon + "',interface='" + busDaemon + "',member='NameOwnerChanged',arg2=''" | |
| 234 | 233 | if err := s.conn.AddMatch(rule); err != nil { | |
| 235 | 234 | return err | |
| @@ -362,6 +361,3 @@ func (s *Service) timeout() int { | |||
| 362 | 361 | } | |
| 363 | 362 | return int((wait + time.Millisecond - 1) / time.Millisecond) | |
| 364 | 363 | } | |
| 365 | - | ||
| 366 | - | // runtimeDir is where screenshots are written. | |
| 367 | - | func runtimeDir() string { return os.Getenv("XDG_RUNTIME_DIR") } | |
| @@ -16,7 +16,6 @@ package screencast | |||||
| 16 | import ( | 16 | import ( | ||
| 17 | "context" | 17 | "context" | ||
| 18 | "errors" | 18 | "errors" | ||
| 19 | - | "os" | |||
| 20 | "runtime" | 19 | "runtime" | ||
| 21 | "sync" | 20 | "sync" | ||
| 22 | "time" | 21 | "time" | ||
| @@ -211,7 +210,7 @@ func (s *Service) attempt(ctx context.Context) (err error) { | |||||
| 211 | s.log.Warn().Err(err).Msg("PipeWire unavailable: screen casting is not offered") | 210 | s.log.Warn().Err(err).Msg("PipeWire unavailable: screen casting is not offered") | ||
| 212 | } | 211 | } | ||
| 213 | defer s.shutdown() | 212 | defer s.shutdown() | ||
| 214 | - | if err = s.start(); err != nil { | 213 | + | if err = s.acquire(); err != nil { |
| 215 | return err | 214 | return err | ||
| 216 | } | 215 | } | ||
| 217 | return s.loop(ctx) | 216 | return s.loop(ctx) | ||
| @@ -228,8 +227,8 @@ func (s *Service) reset(ctx context.Context, conn bus) { | |||||
| 228 | s.shot = shotState{} | 227 | s.shot = shotState{} | ||
| 229 | } | 228 | } | ||
| 230 | 229 | ||||
| 231 | - | // start watches departures and takes the names. | 230 | + | // acquire watches departures and takes the names. |
| 232 | - | func (s *Service) start() error { | 231 | + | func (s *Service) acquire() error { |
| 233 | rule := "type='signal',sender='" + busDaemon + "',interface='" + busDaemon + "',member='NameOwnerChanged',arg2=''" | 232 | rule := "type='signal',sender='" + busDaemon + "',interface='" + busDaemon + "',member='NameOwnerChanged',arg2=''" | ||
| 234 | if err := s.conn.AddMatch(rule); err != nil { | 233 | if err := s.conn.AddMatch(rule); err != nil { | ||
| 235 | return err | 234 | return err | ||
| @@ -362,6 +361,3 @@ func (s *Service) timeout() int { | |||||
| 362 | } | 361 | } | ||
| 363 | return int((wait + time.Millisecond - 1) / time.Millisecond) | 362 | return int((wait + time.Millisecond - 1) / time.Millisecond) | ||
| 364 | } | 363 | } | ||
| 365 | - | ||||
| 366 | - | // runtimeDir is where screenshots are written. | |||
| 367 | - | func runtimeDir() string { return os.Getenv("XDG_RUNTIME_DIR") } | |||
M internal/adapters/screencast/screenshot.go +138 -2
| @@ -1,7 +1,33 @@ | |||
| 1 | 1 | package screencast | |
| 2 | 2 | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strconv" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Screenshots are taken by nefercap, run as a child process: | |
| 12 | + | // | |
| 13 | + | // Screenshot nefercap screenshot -output FOCUSED -file F | |
| 14 | + | // ScreenshotArea nefercap screenshot -output O -region X,Y,WxH -file F | |
| 15 | + | // InteractiveScreenshot nefercap shot -file F | |
| 16 | + | // | |
| 17 | + | // F is $XDG_RUNTIME_DIR/neferwl/screenshot-<unixnano>.png. One run at most | |
| 18 | + | // is in flight. The run is a goroutine; its result goes back through the | |
| 19 | + | // service's transport and the owner sends the reply, so the bus is still | |
| 20 | + | // used by the owner only. SelectArea and ScreenshotWindow (the "Select | |
| 21 | + | // area" and "Window" choices of the GNOME dialog) are not supported; | |
| 22 | + | // include_cursor and flash are ignored. | |
| 23 | + | ||
| 3 | 24 | // shotState is the screenshot in flight; one at most. | |
| 4 | - | type shotState struct{ busy bool } | |
| 25 | + | type shotState struct { | |
| 26 | + | busy bool | |
| 27 | + | req request | |
| 28 | + | uri bool // reply with a file:// URI | |
| 29 | + | path string | |
| 30 | + | } | |
| 5 | 31 | ||
| 6 | 32 | // shotResult is what a finished nefercap run leaves for the owner. | |
| 7 | 33 | type shotResult struct { | |
| @@ -9,4 +35,114 @@ type shotResult struct { | |||
| 9 | 35 | err error | |
| 10 | 36 | } | |
| 11 | 37 | ||
| 12 | - | func (s *Service) onShotDone(r *shotResult) { _ = r } | |
| 38 | + | // screenshot answers Screenshot, ScreenshotArea and InteractiveScreenshot. | |
| 39 | + | func (s *Service) screenshot(req *request) { | |
| 40 | + | if s.shot.busy { | |
| 41 | + | s.replyErr(req, errLimits, "a screenshot is already being taken") | |
| 42 | + | return | |
| 43 | + | } | |
| 44 | + | var args []string | |
| 45 | + | dir := shotDir() | |
| 46 | + | if dir == "" { | |
| 47 | + | s.replyErr(req, errFailed, "no runtime directory") | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | path := filepath.Join(dir, "screenshot-"+strconv.FormatInt(time.Now().UnixNano(), 10)+".png") | |
| 51 | + | switch req.method { | |
| 52 | + | case mShot: | |
| 53 | + | out := s.inv.Focused | |
| 54 | + | if out == "" && len(s.inv.Outputs) > 0 { | |
| 55 | + | out = s.inv.Outputs[0].Info.Name | |
| 56 | + | } | |
| 57 | + | if out == "" { | |
| 58 | + | s.replyErr(req, errFailed, "no output") | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | args = []string{"screenshot", "-output", out, "-file", path} | |
| 62 | + | case mShotArea: | |
| 63 | + | out, region, ok := s.areaTarget(req.x, req.y, req.w, req.h) | |
| 64 | + | if !ok { | |
| 65 | + | s.replyErr(req, errInvalidArgs, "area outside every output") | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | args = []string{"screenshot", "-output", out, "-region", region, "-file", path} | |
| 69 | + | default: | |
| 70 | + | args = []string{"shot", "-file", path} | |
| 71 | + | } | |
| 72 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 73 | + | s.log.Warn().Err(err).Msg("screenshot directory not created") | |
| 74 | + | s.replyErr(req, errFailed, "screenshot directory not created") | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | s.shot = shotState{busy: true, req: *req, uri: req.method == mShotInteractive, path: path} | |
| 78 | + | s.runShot(s.ctx, args) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // shotDir is $XDG_RUNTIME_DIR/neferwl. | |
| 82 | + | func shotDir() string { | |
| 83 | + | rt := os.Getenv("XDG_RUNTIME_DIR") | |
| 84 | + | if rt == "" { | |
| 85 | + | return "" | |
| 86 | + | } | |
| 87 | + | return filepath.Join(rt, "neferwl") | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // areaTarget finds the output that contains the corner (x, y) of an area in | |
| 91 | + | // the logical space and the output-local region, clipped to the output. | |
| 92 | + | func (s *Service) areaTarget(x, y, w, h int32) (string, string, bool) { | |
| 93 | + | if w <= 0 || h <= 0 { | |
| 94 | + | return "", "", false | |
| 95 | + | } | |
| 96 | + | for i := range s.inv.Outputs { | |
| 97 | + | o := &s.inv.Outputs[i] | |
| 98 | + | lx, ly := int(x)-o.X, int(y)-o.Y | |
| 99 | + | if lx < 0 || ly < 0 || lx >= o.Width || ly >= o.Height { | |
| 100 | + | continue | |
| 101 | + | } | |
| 102 | + | cw, ch := min(int(w), o.Width-lx), min(int(h), o.Height-ly) | |
| 103 | + | return o.Info.Name, strconv.Itoa(lx) + "," + strconv.Itoa(ly) + "," + strconv.Itoa(cw) + "x" + strconv.Itoa(ch), true | |
| 104 | + | } | |
| 105 | + | return "", "", false | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // runShot runs nefercap in a goroutine and leaves the result for the owner. | |
| 109 | + | func (s *Service) runShot(parent context.Context, args []string) { | |
| 110 | + | s.jobs.Add(1) | |
| 111 | + | go func() { | |
| 112 | + | defer s.jobs.Done() | |
| 113 | + | ctx, cancel := context.WithTimeout(parent, shotTimeout) | |
| 114 | + | defer cancel() | |
| 115 | + | err := s.runner.Run(ctx, args) | |
| 116 | + | s.mu.Lock() | |
| 117 | + | s.shotRes = shotResult{ready: true, err: err} | |
| 118 | + | s.woken = true | |
| 119 | + | s.mu.Unlock() | |
| 120 | + | s.wake() | |
| 121 | + | }() | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // onShotDone sends the reply of the screenshot that finished. | |
| 125 | + | func (s *Service) onShotDone(r *shotResult) { | |
| 126 | + | st := s.shot | |
| 127 | + | s.shot = shotState{} | |
| 128 | + | if !st.busy { | |
| 129 | + | return | |
| 130 | + | } | |
| 131 | + | if r.err != nil { | |
| 132 | + | s.log.Warn().Err(r.err).Msg("screenshot failed") | |
| 133 | + | _ = os.Remove(st.path) | |
| 134 | + | s.replyErr(&st.req, errFailed, "screenshot failed") | |
| 135 | + | return | |
| 136 | + | } | |
| 137 | + | out := st.path | |
| 138 | + | if st.uri { | |
| 139 | + | out = "file://" + out | |
| 140 | + | } | |
| 141 | + | e := s.replyTo(&st.req, "bs") | |
| 142 | + | if e == nil { | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | e.Bool(true) | |
| 146 | + | e.Str(out) | |
| 147 | + | s.send() | |
| 148 | + | } | |
| @@ -1,7 +1,33 @@ | |||||
| 1 | package screencast | 1 | package screencast | ||
| 2 | 2 | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strconv" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Screenshots are taken by nefercap, run as a child process: | |||
| 12 | + | // | |||
| 13 | + | // Screenshot nefercap screenshot -output FOCUSED -file F | |||
| 14 | + | // ScreenshotArea nefercap screenshot -output O -region X,Y,WxH -file F | |||
| 15 | + | // InteractiveScreenshot nefercap shot -file F | |||
| 16 | + | // | |||
| 17 | + | // F is $XDG_RUNTIME_DIR/neferwl/screenshot-<unixnano>.png. One run at most | |||
| 18 | + | // is in flight. The run is a goroutine; its result goes back through the | |||
| 19 | + | // service's transport and the owner sends the reply, so the bus is still | |||
| 20 | + | // used by the owner only. SelectArea and ScreenshotWindow (the "Select | |||
| 21 | + | // area" and "Window" choices of the GNOME dialog) are not supported; | |||
| 22 | + | // include_cursor and flash are ignored. | |||
| 23 | + | ||||
| 3 | // shotState is the screenshot in flight; one at most. | 24 | // shotState is the screenshot in flight; one at most. | ||
| 4 | - | type shotState struct{ busy bool } | 25 | + | type shotState struct { |
| 26 | + | busy bool | |||
| 27 | + | req request | |||
| 28 | + | uri bool // reply with a file:// URI | |||
| 29 | + | path string | |||
| 30 | + | } | |||
| 5 | 31 | ||||
| 6 | // shotResult is what a finished nefercap run leaves for the owner. | 32 | // shotResult is what a finished nefercap run leaves for the owner. | ||
| 7 | type shotResult struct { | 33 | type shotResult struct { | ||
| @@ -9,4 +35,114 @@ type shotResult struct { | |||||
| 9 | err error | 35 | err error | ||
| 10 | } | 36 | } | ||
| 11 | 37 | ||||
| 12 | - | func (s *Service) onShotDone(r *shotResult) { _ = r } | 38 | + | // screenshot answers Screenshot, ScreenshotArea and InteractiveScreenshot. |
| 39 | + | func (s *Service) screenshot(req *request) { | |||
| 40 | + | if s.shot.busy { | |||
| 41 | + | s.replyErr(req, errLimits, "a screenshot is already being taken") | |||
| 42 | + | return | |||
| 43 | + | } | |||
| 44 | + | var args []string | |||
| 45 | + | dir := shotDir() | |||
| 46 | + | if dir == "" { | |||
| 47 | + | s.replyErr(req, errFailed, "no runtime directory") | |||
| 48 | + | return | |||
| 49 | + | } | |||
| 50 | + | path := filepath.Join(dir, "screenshot-"+strconv.FormatInt(time.Now().UnixNano(), 10)+".png") | |||
| 51 | + | switch req.method { | |||
| 52 | + | case mShot: | |||
| 53 | + | out := s.inv.Focused | |||
| 54 | + | if out == "" && len(s.inv.Outputs) > 0 { | |||
| 55 | + | out = s.inv.Outputs[0].Info.Name | |||
| 56 | + | } | |||
| 57 | + | if out == "" { | |||
| 58 | + | s.replyErr(req, errFailed, "no output") | |||
| 59 | + | return | |||
| 60 | + | } | |||
| 61 | + | args = []string{"screenshot", "-output", out, "-file", path} | |||
| 62 | + | case mShotArea: | |||
| 63 | + | out, region, ok := s.areaTarget(req.x, req.y, req.w, req.h) | |||
| 64 | + | if !ok { | |||
| 65 | + | s.replyErr(req, errInvalidArgs, "area outside every output") | |||
| 66 | + | return | |||
| 67 | + | } | |||
| 68 | + | args = []string{"screenshot", "-output", out, "-region", region, "-file", path} | |||
| 69 | + | default: | |||
| 70 | + | args = []string{"shot", "-file", path} | |||
| 71 | + | } | |||
| 72 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 73 | + | s.log.Warn().Err(err).Msg("screenshot directory not created") | |||
| 74 | + | s.replyErr(req, errFailed, "screenshot directory not created") | |||
| 75 | + | return | |||
| 76 | + | } | |||
| 77 | + | s.shot = shotState{busy: true, req: *req, uri: req.method == mShotInteractive, path: path} | |||
| 78 | + | s.runShot(s.ctx, args) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // shotDir is $XDG_RUNTIME_DIR/neferwl. | |||
| 82 | + | func shotDir() string { | |||
| 83 | + | rt := os.Getenv("XDG_RUNTIME_DIR") | |||
| 84 | + | if rt == "" { | |||
| 85 | + | return "" | |||
| 86 | + | } | |||
| 87 | + | return filepath.Join(rt, "neferwl") | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // areaTarget finds the output that contains the corner (x, y) of an area in | |||
| 91 | + | // the logical space and the output-local region, clipped to the output. | |||
| 92 | + | func (s *Service) areaTarget(x, y, w, h int32) (string, string, bool) { | |||
| 93 | + | if w <= 0 || h <= 0 { | |||
| 94 | + | return "", "", false | |||
| 95 | + | } | |||
| 96 | + | for i := range s.inv.Outputs { | |||
| 97 | + | o := &s.inv.Outputs[i] | |||
| 98 | + | lx, ly := int(x)-o.X, int(y)-o.Y | |||
| 99 | + | if lx < 0 || ly < 0 || lx >= o.Width || ly >= o.Height { | |||
| 100 | + | continue | |||
| 101 | + | } | |||
| 102 | + | cw, ch := min(int(w), o.Width-lx), min(int(h), o.Height-ly) | |||
| 103 | + | return o.Info.Name, strconv.Itoa(lx) + "," + strconv.Itoa(ly) + "," + strconv.Itoa(cw) + "x" + strconv.Itoa(ch), true | |||
| 104 | + | } | |||
| 105 | + | return "", "", false | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // runShot runs nefercap in a goroutine and leaves the result for the owner. | |||
| 109 | + | func (s *Service) runShot(parent context.Context, args []string) { | |||
| 110 | + | s.jobs.Add(1) | |||
| 111 | + | go func() { | |||
| 112 | + | defer s.jobs.Done() | |||
| 113 | + | ctx, cancel := context.WithTimeout(parent, shotTimeout) | |||
| 114 | + | defer cancel() | |||
| 115 | + | err := s.runner.Run(ctx, args) | |||
| 116 | + | s.mu.Lock() | |||
| 117 | + | s.shotRes = shotResult{ready: true, err: err} | |||
| 118 | + | s.woken = true | |||
| 119 | + | s.mu.Unlock() | |||
| 120 | + | s.wake() | |||
| 121 | + | }() | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // onShotDone sends the reply of the screenshot that finished. | |||
| 125 | + | func (s *Service) onShotDone(r *shotResult) { | |||
| 126 | + | st := s.shot | |||
| 127 | + | s.shot = shotState{} | |||
| 128 | + | if !st.busy { | |||
| 129 | + | return | |||
| 130 | + | } | |||
| 131 | + | if r.err != nil { | |||
| 132 | + | s.log.Warn().Err(r.err).Msg("screenshot failed") | |||
| 133 | + | _ = os.Remove(st.path) | |||
| 134 | + | s.replyErr(&st.req, errFailed, "screenshot failed") | |||
| 135 | + | return | |||
| 136 | + | } | |||
| 137 | + | out := st.path | |||
| 138 | + | if st.uri { | |||
| 139 | + | out = "file://" + out | |||
| 140 | + | } | |||
| 141 | + | e := s.replyTo(&st.req, "bs") | |||
| 142 | + | if e == nil { | |||
| 143 | + | return | |||
| 144 | + | } | |||
| 145 | + | e.Bool(true) | |||
| 146 | + | e.Str(out) | |||
| 147 | + | s.send() | |||
| 148 | + | } | |||
A internal/adapters/screencast/session.go +298 -0
| @@ -0,0 +1,298 @@ | |||
| 1 | + | package screencast | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strconv" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // session is one org.gnome.Mutter.ScreenCast.Session. The sessions live in a | |
| 10 | + | // fixed array of Service, so pointers into it (the streams' back references, | |
| 11 | + | // the PipeWire handlers) stay valid. | |
| 12 | + | type session struct { | |
| 13 | + | used bool | |
| 14 | + | id uint32 | |
| 15 | + | path string | |
| 16 | + | owner string // unique name that called CreateSession | |
| 17 | + | // started: Start was called, the streams were opened. | |
| 18 | + | started bool | |
| 19 | + | // dying: the session is closed to the bus; the slot is freed once every | |
| 20 | + | // stream released its PipeWire resources. | |
| 21 | + | dying bool | |
| 22 | + | streams [maxStreams]stream | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (s *Service) sessionByID(id uint32) *session { | |
| 26 | + | for i := range s.sessions { | |
| 27 | + | if sess := &s.sessions[i]; sess.used && !sess.dying && sess.id == id { | |
| 28 | + | return sess | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (s *Service) streamByID(id uint32) *stream { | |
| 35 | + | for i := range s.sessions { | |
| 36 | + | sess := &s.sessions[i] | |
| 37 | + | if !sess.used || sess.dying { | |
| 38 | + | continue | |
| 39 | + | } | |
| 40 | + | for j := range sess.streams { | |
| 41 | + | if st := &sess.streams[j]; st.used && st.id == id { | |
| 42 | + | return st | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | return nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (s *Service) forEachSession(f func(*session)) { | |
| 50 | + | for i := range s.sessions { | |
| 51 | + | if s.sessions[i].used { | |
| 52 | + | f(&s.sessions[i]) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (s *Service) forEachStream(f func(*stream)) { | |
| 58 | + | for i := range s.sessions { | |
| 59 | + | sess := &s.sessions[i] | |
| 60 | + | if !sess.used { | |
| 61 | + | continue | |
| 62 | + | } | |
| 63 | + | for j := range sess.streams { | |
| 64 | + | if sess.streams[j].used { | |
| 65 | + | f(&sess.streams[j]) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // createSession answers CreateSession. | |
| 72 | + | func (s *Service) createSession(req *request) { | |
| 73 | + | if req.remote { | |
| 74 | + | s.replyErr(req, errNotSupported, "remote desktop sessions are not supported") | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | var sess *session | |
| 78 | + | for i := range s.sessions { | |
| 79 | + | if !s.sessions[i].used { | |
| 80 | + | sess = &s.sessions[i] | |
| 81 | + | break | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | if sess == nil { | |
| 85 | + | s.replyErr(req, errLimits, "too many screen cast sessions") | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | s.nextSess++ | |
| 89 | + | *sess = session{used: true, id: s.nextSess, owner: req.sender} | |
| 90 | + | sess.path = prefixSession + strconv.FormatUint(uint64(sess.id), 10) | |
| 91 | + | s.log.Info().Str("sender", req.sender).Str("session", sess.path).Msg("screen cast session created") | |
| 92 | + | if e := s.replyTo(req, "o"); e != nil { | |
| 93 | + | e.ObjectPath(sess.path) | |
| 94 | + | s.send() | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // record answers RecordMonitor and RecordWindow: it adds a stream. | |
| 99 | + | func (s *Service) record(req *request, sess *session) { | |
| 100 | + | if sess.started { | |
| 101 | + | s.replyErr(req, errFailed, "session already started") | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | var st *stream | |
| 105 | + | for i := range sess.streams { | |
| 106 | + | if !sess.streams[i].used { | |
| 107 | + | st = &sess.streams[i] | |
| 108 | + | break | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | if st == nil { | |
| 112 | + | s.replyErr(req, errLimits, "too many streams in the session") | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | *st = stream{used: true, sess: sess, cursor: req.cursor != 0, maxFPS: defaultFPS, pwState: -2} | |
| 116 | + | if req.method == mRecordWindow { | |
| 117 | + | if !s.haveWindow(ports.WindowID(req.window)) { | |
| 118 | + | *st = stream{} | |
| 119 | + | s.replyErr(req, errInvalidArgs, "no such window") | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | st.window = ports.WindowID(req.window) | |
| 123 | + | // A window moves and resizes: the stream carries no position. | |
| 124 | + | st.px, st.py, st.pw, st.ph = 0, 0, 1, 1 | |
| 125 | + | } else { | |
| 126 | + | o := s.outputByName(req.text) | |
| 127 | + | if o == nil { | |
| 128 | + | *st = stream{} | |
| 129 | + | s.replyErr(req, errInvalidArgs, "no such monitor") | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | st.output = o.Info.Name | |
| 133 | + | st.px, st.py, st.pw, st.ph = int32(o.X), int32(o.Y), int32(o.Width), int32(o.Height) | |
| 134 | + | if o.Current >= 0 && o.Current < len(o.Modes) { | |
| 135 | + | if hz := o.Modes[o.Current].RefreshMilli / 1000; hz > 0 { | |
| 136 | + | st.maxFPS = min(hz, 240) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | s.nextStrm++ | |
| 141 | + | s.nextCast++ | |
| 142 | + | st.id, st.cast = s.nextStrm, s.nextCast | |
| 143 | + | st.path = prefixStream + strconv.FormatUint(uint64(st.id), 10) | |
| 144 | + | st.h2 = streamHandler{st: st} | |
| 145 | + | if e := s.replyTo(req, "o"); e != nil { | |
| 146 | + | e.ObjectPath(st.path) | |
| 147 | + | s.send() | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | // start answers Start: the streams are opened on the wayland side. | |
| 152 | + | func (s *Service) start(req *request, sess *session) { | |
| 153 | + | if sess.started { | |
| 154 | + | s.replyErr(req, errFailed, "session already started") | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | n := 0 | |
| 158 | + | for i := range sess.streams { | |
| 159 | + | if sess.streams[i].used { | |
| 160 | + | sess.streams[i].needOpen = true | |
| 161 | + | n++ | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | if n == 0 { | |
| 165 | + | s.replyErr(req, errFailed, "session has no stream") | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | sess.started = true | |
| 169 | + | s.reply(req) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // closeSession ends a session: its streams are closed on the wayland side | |
| 173 | + | // and, with signal set, the owner is told. The slot is freed by reap. | |
| 174 | + | func (s *Service) closeSession(sess *session, signal bool) { | |
| 175 | + | if sess.dying { | |
| 176 | + | return | |
| 177 | + | } | |
| 178 | + | sess.dying = true | |
| 179 | + | for i := range sess.streams { | |
| 180 | + | if st := &sess.streams[i]; st.used { | |
| 181 | + | st.close() | |
| 182 | + | if !st.opened && st.src == nil { | |
| 183 | + | *st = stream{} | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | } | |
| 187 | + | if signal { | |
| 188 | + | s.emitTo(sess.owner, sess.path, ifaceSession, "Closed") | |
| 189 | + | s.send() | |
| 190 | + | } | |
| 191 | + | s.log.Info().Str("session", sess.path).Msg("screen cast session closed") | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // outputByName finds an output of the inventory by connector. | |
| 195 | + | func (s *Service) outputByName(name string) *ports.CastOutput { | |
| 196 | + | for i := range s.inv.Outputs { | |
| 197 | + | if s.inv.Outputs[i].Info.Name == name { | |
| 198 | + | return &s.inv.Outputs[i] | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | return nil | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func (s *Service) haveWindow(id ports.WindowID) bool { | |
| 205 | + | for i := range s.inv.Windows { | |
| 206 | + | if s.inv.Windows[i].ID == id { | |
| 207 | + | return true | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | return false | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // properties answers Properties.Get, GetAll and Set. | |
| 214 | + | func (s *Service) properties(req *request, sess *session, st *stream) { | |
| 215 | + | if req.method == mSet { | |
| 216 | + | s.replyErr(req, errPropertyReadOnly, "no writable property") | |
| 217 | + | return | |
| 218 | + | } | |
| 219 | + | iface := "" | |
| 220 | + | switch req.obj { | |
| 221 | + | case objScreenCast: | |
| 222 | + | iface = ifaceScreenCast | |
| 223 | + | case objSession: | |
| 224 | + | iface = ifaceSession | |
| 225 | + | case objStream: | |
| 226 | + | iface = ifaceStream | |
| 227 | + | case objDisplayConfig: | |
| 228 | + | iface = ifaceDisplayConfig | |
| 229 | + | case objIntrospect: | |
| 230 | + | iface = ifaceIntrospect | |
| 231 | + | case objScreenshot: | |
| 232 | + | iface = ifaceScreenshot | |
| 233 | + | } | |
| 234 | + | if req.text != iface && !(req.method == mGetAll && req.text == "") { | |
| 235 | + | s.replyErr(req, errUnknownInterface, "no such interface") | |
| 236 | + | return | |
| 237 | + | } | |
| 238 | + | if req.method == mGetAll { | |
| 239 | + | e := s.replyTo(req, "a{sv}") | |
| 240 | + | if e == nil { | |
| 241 | + | return | |
| 242 | + | } | |
| 243 | + | a := e.BeginArray('{') | |
| 244 | + | switch req.obj { | |
| 245 | + | case objScreenCast: | |
| 246 | + | e.Struct() | |
| 247 | + | e.Str("Version") | |
| 248 | + | e.Variant("i") | |
| 249 | + | e.Int32(screenCastVersion) | |
| 250 | + | case objStream: | |
| 251 | + | e.Struct() | |
| 252 | + | e.Str("Parameters") | |
| 253 | + | e.Variant("a{sv}") | |
| 254 | + | encodeParameters(e, st) | |
| 255 | + | case objIntrospect: | |
| 256 | + | e.Struct() | |
| 257 | + | e.Str("version") | |
| 258 | + | e.Variant("u") | |
| 259 | + | e.Uint32(introspectVersion) | |
| 260 | + | } | |
| 261 | + | e.EndArray(a) | |
| 262 | + | s.send() | |
| 263 | + | return | |
| 264 | + | } | |
| 265 | + | switch { | |
| 266 | + | case req.obj == objScreenCast && req.text2 == "Version": | |
| 267 | + | if e := s.replyTo(req, "v"); e != nil { | |
| 268 | + | e.Variant("i") | |
| 269 | + | e.Int32(screenCastVersion) | |
| 270 | + | s.send() | |
| 271 | + | } | |
| 272 | + | case req.obj == objStream && req.text2 == "Parameters": | |
| 273 | + | if e := s.replyTo(req, "v"); e != nil { | |
| 274 | + | e.Variant("a{sv}") | |
| 275 | + | encodeParameters(e, st) | |
| 276 | + | s.send() | |
| 277 | + | } | |
| 278 | + | case req.obj == objIntrospect && req.text2 == "version": | |
| 279 | + | if e := s.replyTo(req, "v"); e != nil { | |
| 280 | + | e.Variant("u") | |
| 281 | + | e.Uint32(introspectVersion) | |
| 282 | + | s.send() | |
| 283 | + | } | |
| 284 | + | default: | |
| 285 | + | s.replyErr(req, errUnknownProperty, "no such property") | |
| 286 | + | } | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // Versions served. Introspect version 2 would make x-d-p-g read | |
| 290 | + | // AnimationsEnabled and ScreenSize, which are not served. | |
| 291 | + | const ( | |
| 292 | + | screenCastVersion = 4 | |
| 293 | + | introspectVersion = 1 | |
| 294 | + | defaultFPS = 60 | |
| 295 | + | ) | |
| 296 | + | ||
| 297 | + | func itoa(n int) string { return strconv.Itoa(n) } | |
| 298 | + | func ftoa(f float64) string { return strconv.FormatFloat(f, 'f', 3, 64) } | |
| @@ -0,0 +1,298 @@ | |||||
| 1 | + | package screencast | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strconv" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // session is one org.gnome.Mutter.ScreenCast.Session. The sessions live in a | |||
| 10 | + | // fixed array of Service, so pointers into it (the streams' back references, | |||
| 11 | + | // the PipeWire handlers) stay valid. | |||
| 12 | + | type session struct { | |||
| 13 | + | used bool | |||
| 14 | + | id uint32 | |||
| 15 | + | path string | |||
| 16 | + | owner string // unique name that called CreateSession | |||
| 17 | + | // started: Start was called, the streams were opened. | |||
| 18 | + | started bool | |||
| 19 | + | // dying: the session is closed to the bus; the slot is freed once every | |||
| 20 | + | // stream released its PipeWire resources. | |||
| 21 | + | dying bool | |||
| 22 | + | streams [maxStreams]stream | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (s *Service) sessionByID(id uint32) *session { | |||
| 26 | + | for i := range s.sessions { | |||
| 27 | + | if sess := &s.sessions[i]; sess.used && !sess.dying && sess.id == id { | |||
| 28 | + | return sess | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | return nil | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (s *Service) streamByID(id uint32) *stream { | |||
| 35 | + | for i := range s.sessions { | |||
| 36 | + | sess := &s.sessions[i] | |||
| 37 | + | if !sess.used || sess.dying { | |||
| 38 | + | continue | |||
| 39 | + | } | |||
| 40 | + | for j := range sess.streams { | |||
| 41 | + | if st := &sess.streams[j]; st.used && st.id == id { | |||
| 42 | + | return st | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | return nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (s *Service) forEachSession(f func(*session)) { | |||
| 50 | + | for i := range s.sessions { | |||
| 51 | + | if s.sessions[i].used { | |||
| 52 | + | f(&s.sessions[i]) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (s *Service) forEachStream(f func(*stream)) { | |||
| 58 | + | for i := range s.sessions { | |||
| 59 | + | sess := &s.sessions[i] | |||
| 60 | + | if !sess.used { | |||
| 61 | + | continue | |||
| 62 | + | } | |||
| 63 | + | for j := range sess.streams { | |||
| 64 | + | if sess.streams[j].used { | |||
| 65 | + | f(&sess.streams[j]) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | // createSession answers CreateSession. | |||
| 72 | + | func (s *Service) createSession(req *request) { | |||
| 73 | + | if req.remote { | |||
| 74 | + | s.replyErr(req, errNotSupported, "remote desktop sessions are not supported") | |||
| 75 | + | return | |||
| 76 | + | } | |||
| 77 | + | var sess *session | |||
| 78 | + | for i := range s.sessions { | |||
| 79 | + | if !s.sessions[i].used { | |||
| 80 | + | sess = &s.sessions[i] | |||
| 81 | + | break | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | if sess == nil { | |||
| 85 | + | s.replyErr(req, errLimits, "too many screen cast sessions") | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | s.nextSess++ | |||
| 89 | + | *sess = session{used: true, id: s.nextSess, owner: req.sender} | |||
| 90 | + | sess.path = prefixSession + strconv.FormatUint(uint64(sess.id), 10) | |||
| 91 | + | s.log.Info().Str("sender", req.sender).Str("session", sess.path).Msg("screen cast session created") | |||
| 92 | + | if e := s.replyTo(req, "o"); e != nil { | |||
| 93 | + | e.ObjectPath(sess.path) | |||
| 94 | + | s.send() | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // record answers RecordMonitor and RecordWindow: it adds a stream. | |||
| 99 | + | func (s *Service) record(req *request, sess *session) { | |||
| 100 | + | if sess.started { | |||
| 101 | + | s.replyErr(req, errFailed, "session already started") | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | var st *stream | |||
| 105 | + | for i := range sess.streams { | |||
| 106 | + | if !sess.streams[i].used { | |||
| 107 | + | st = &sess.streams[i] | |||
| 108 | + | break | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | if st == nil { | |||
| 112 | + | s.replyErr(req, errLimits, "too many streams in the session") | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | *st = stream{used: true, sess: sess, cursor: req.cursor != 0, maxFPS: defaultFPS, pwState: -2} | |||
| 116 | + | if req.method == mRecordWindow { | |||
| 117 | + | if !s.haveWindow(ports.WindowID(req.window)) { | |||
| 118 | + | *st = stream{} | |||
| 119 | + | s.replyErr(req, errInvalidArgs, "no such window") | |||
| 120 | + | return | |||
| 121 | + | } | |||
| 122 | + | st.window = ports.WindowID(req.window) | |||
| 123 | + | // A window moves and resizes: the stream carries no position. | |||
| 124 | + | st.px, st.py, st.pw, st.ph = 0, 0, 1, 1 | |||
| 125 | + | } else { | |||
| 126 | + | o := s.outputByName(req.text) | |||
| 127 | + | if o == nil { | |||
| 128 | + | *st = stream{} | |||
| 129 | + | s.replyErr(req, errInvalidArgs, "no such monitor") | |||
| 130 | + | return | |||
| 131 | + | } | |||
| 132 | + | st.output = o.Info.Name | |||
| 133 | + | st.px, st.py, st.pw, st.ph = int32(o.X), int32(o.Y), int32(o.Width), int32(o.Height) | |||
| 134 | + | if o.Current >= 0 && o.Current < len(o.Modes) { | |||
| 135 | + | if hz := o.Modes[o.Current].RefreshMilli / 1000; hz > 0 { | |||
| 136 | + | st.maxFPS = min(hz, 240) | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | s.nextStrm++ | |||
| 141 | + | s.nextCast++ | |||
| 142 | + | st.id, st.cast = s.nextStrm, s.nextCast | |||
| 143 | + | st.path = prefixStream + strconv.FormatUint(uint64(st.id), 10) | |||
| 144 | + | st.h2 = streamHandler{st: st} | |||
| 145 | + | if e := s.replyTo(req, "o"); e != nil { | |||
| 146 | + | e.ObjectPath(st.path) | |||
| 147 | + | s.send() | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | // start answers Start: the streams are opened on the wayland side. | |||
| 152 | + | func (s *Service) start(req *request, sess *session) { | |||
| 153 | + | if sess.started { | |||
| 154 | + | s.replyErr(req, errFailed, "session already started") | |||
| 155 | + | return | |||
| 156 | + | } | |||
| 157 | + | n := 0 | |||
| 158 | + | for i := range sess.streams { | |||
| 159 | + | if sess.streams[i].used { | |||
| 160 | + | sess.streams[i].needOpen = true | |||
| 161 | + | n++ | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | if n == 0 { | |||
| 165 | + | s.replyErr(req, errFailed, "session has no stream") | |||
| 166 | + | return | |||
| 167 | + | } | |||
| 168 | + | sess.started = true | |||
| 169 | + | s.reply(req) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // closeSession ends a session: its streams are closed on the wayland side | |||
| 173 | + | // and, with signal set, the owner is told. The slot is freed by reap. | |||
| 174 | + | func (s *Service) closeSession(sess *session, signal bool) { | |||
| 175 | + | if sess.dying { | |||
| 176 | + | return | |||
| 177 | + | } | |||
| 178 | + | sess.dying = true | |||
| 179 | + | for i := range sess.streams { | |||
| 180 | + | if st := &sess.streams[i]; st.used { | |||
| 181 | + | st.close() | |||
| 182 | + | if !st.opened && st.src == nil { | |||
| 183 | + | *st = stream{} | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | } | |||
| 187 | + | if signal { | |||
| 188 | + | s.emitTo(sess.owner, sess.path, ifaceSession, "Closed") | |||
| 189 | + | s.send() | |||
| 190 | + | } | |||
| 191 | + | s.log.Info().Str("session", sess.path).Msg("screen cast session closed") | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // outputByName finds an output of the inventory by connector. | |||
| 195 | + | func (s *Service) outputByName(name string) *ports.CastOutput { | |||
| 196 | + | for i := range s.inv.Outputs { | |||
| 197 | + | if s.inv.Outputs[i].Info.Name == name { | |||
| 198 | + | return &s.inv.Outputs[i] | |||
| 199 | + | } | |||
| 200 | + | } | |||
| 201 | + | return nil | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func (s *Service) haveWindow(id ports.WindowID) bool { | |||
| 205 | + | for i := range s.inv.Windows { | |||
| 206 | + | if s.inv.Windows[i].ID == id { | |||
| 207 | + | return true | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | return false | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | // properties answers Properties.Get, GetAll and Set. | |||
| 214 | + | func (s *Service) properties(req *request, sess *session, st *stream) { | |||
| 215 | + | if req.method == mSet { | |||
| 216 | + | s.replyErr(req, errPropertyReadOnly, "no writable property") | |||
| 217 | + | return | |||
| 218 | + | } | |||
| 219 | + | iface := "" | |||
| 220 | + | switch req.obj { | |||
| 221 | + | case objScreenCast: | |||
| 222 | + | iface = ifaceScreenCast | |||
| 223 | + | case objSession: | |||
| 224 | + | iface = ifaceSession | |||
| 225 | + | case objStream: | |||
| 226 | + | iface = ifaceStream | |||
| 227 | + | case objDisplayConfig: | |||
| 228 | + | iface = ifaceDisplayConfig | |||
| 229 | + | case objIntrospect: | |||
| 230 | + | iface = ifaceIntrospect | |||
| 231 | + | case objScreenshot: | |||
| 232 | + | iface = ifaceScreenshot | |||
| 233 | + | } | |||
| 234 | + | if req.text != iface && !(req.method == mGetAll && req.text == "") { | |||
| 235 | + | s.replyErr(req, errUnknownInterface, "no such interface") | |||
| 236 | + | return | |||
| 237 | + | } | |||
| 238 | + | if req.method == mGetAll { | |||
| 239 | + | e := s.replyTo(req, "a{sv}") | |||
| 240 | + | if e == nil { | |||
| 241 | + | return | |||
| 242 | + | } | |||
| 243 | + | a := e.BeginArray('{') | |||
| 244 | + | switch req.obj { | |||
| 245 | + | case objScreenCast: | |||
| 246 | + | e.Struct() | |||
| 247 | + | e.Str("Version") | |||
| 248 | + | e.Variant("i") | |||
| 249 | + | e.Int32(screenCastVersion) | |||
| 250 | + | case objStream: | |||
| 251 | + | e.Struct() | |||
| 252 | + | e.Str("Parameters") | |||
| 253 | + | e.Variant("a{sv}") | |||
| 254 | + | encodeParameters(e, st) | |||
| 255 | + | case objIntrospect: | |||
| 256 | + | e.Struct() | |||
| 257 | + | e.Str("version") | |||
| 258 | + | e.Variant("u") | |||
| 259 | + | e.Uint32(introspectVersion) | |||
| 260 | + | } | |||
| 261 | + | e.EndArray(a) | |||
| 262 | + | s.send() | |||
| 263 | + | return | |||
| 264 | + | } | |||
| 265 | + | switch { | |||
| 266 | + | case req.obj == objScreenCast && req.text2 == "Version": | |||
| 267 | + | if e := s.replyTo(req, "v"); e != nil { | |||
| 268 | + | e.Variant("i") | |||
| 269 | + | e.Int32(screenCastVersion) | |||
| 270 | + | s.send() | |||
| 271 | + | } | |||
| 272 | + | case req.obj == objStream && req.text2 == "Parameters": | |||
| 273 | + | if e := s.replyTo(req, "v"); e != nil { | |||
| 274 | + | e.Variant("a{sv}") | |||
| 275 | + | encodeParameters(e, st) | |||
| 276 | + | s.send() | |||
| 277 | + | } | |||
| 278 | + | case req.obj == objIntrospect && req.text2 == "version": | |||
| 279 | + | if e := s.replyTo(req, "v"); e != nil { | |||
| 280 | + | e.Variant("u") | |||
| 281 | + | e.Uint32(introspectVersion) | |||
| 282 | + | s.send() | |||
| 283 | + | } | |||
| 284 | + | default: | |||
| 285 | + | s.replyErr(req, errUnknownProperty, "no such property") | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | // Versions served. Introspect version 2 would make x-d-p-g read | |||
| 290 | + | // AnimationsEnabled and ScreenSize, which are not served. | |||
| 291 | + | const ( | |||
| 292 | + | screenCastVersion = 4 | |||
| 293 | + | introspectVersion = 1 | |||
| 294 | + | defaultFPS = 60 | |||
| 295 | + | ) | |||
| 296 | + | ||||
| 297 | + | func itoa(n int) string { return strconv.Itoa(n) } | |||
| 298 | + | func ftoa(f float64) string { return strconv.FormatFloat(f, 'f', 3, 64) } | |||
M internal/adapters/screencast/stream.go +98 -11
| @@ -4,23 +4,110 @@ import ( | |||
| 4 | 4 | "time" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/neferwl/internal/ports" | |
| 7 | + | "github.com/bnema/purego-pipewire/pipewire" | |
| 8 | + | "github.com/bnema/zerobus" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | - | // stream is one cast of a session. | |
| 11 | + | // stream is one cast of a session: a PipeWire node fed with frames that the | |
| 12 | + | // wayland server renders. Everything here is touched by the owner goroutine | |
| 13 | + | // only; the PipeWire handler runs on it too, inside Loop.Iterate. | |
| 10 | 14 | type stream struct { | |
| 11 | - | used bool | |
| 12 | - | dirty bool | |
| 15 | + | used bool | |
| 16 | + | sess *session | |
| 17 | + | id uint32 // number in the object path | |
| 18 | + | cast uint64 // ID in the ports.CastCommands | |
| 19 | + | path string | |
| 20 | + | ||
| 21 | + | // Target. | |
| 22 | + | output string | |
| 23 | + | window ports.WindowID | |
| 24 | + | cursor bool | |
| 25 | + | maxFPS int | |
| 26 | + | // Stream.Parameters: position and size in the compositor space. | |
| 27 | + | px, py, pw, ph int32 | |
| 28 | + | ||
| 29 | + | // Lifecycle. | |
| 30 | + | needOpen bool // CastOpen is yet to be sent | |
| 31 | + | opened bool // CastOpen was sent | |
| 32 | + | needClose bool // CastClose is yet to be sent | |
| 33 | + | closing bool // the session ended: free once no frame is in flight | |
| 34 | + | stopped bool // CastStopped came: the wayland side is done | |
| 35 | + | ||
| 36 | + | // Negotiation. | |
| 37 | + | w, h int // size of the target, from CastSize | |
| 38 | + | haveSize bool | |
| 39 | + | needResize bool | |
| 40 | + | src videoSource | |
| 41 | + | h2 streamHandler | |
| 42 | + | pwState int32 // last state of the PipeWire stream; -2 before the first | |
| 43 | + | seenState bool // a state above UNCONNECTED was seen | |
| 44 | + | nodeID uint32 | |
| 45 | + | announced bool // PipeWireStreamAdded was sent | |
| 46 | + | format pipewire.VideoFormat | |
| 47 | + | fw, fh int // size the format was negotiated at; 0 while renegotiating | |
| 48 | + | ||
| 49 | + | // Frames. | |
| 50 | + | dirty bool | |
| 51 | + | forced bool // the first frame after STREAMING is not paced by damage | |
| 52 | + | inflight bool | |
| 53 | + | buf *pipewire.VideoBuffer | |
| 54 | + | lastReq time.Time | |
| 55 | + | seq uint64 | |
| 56 | + | errs int // consecutive failed frames | |
| 13 | 57 | } | |
| 14 | 58 | ||
| 15 | - | func (s *Service) onCastEvent(ev *ports.CastEvent) { _ = ev } | |
| 59 | + | // streamHandler is the pipewire.VideoHandler of a stream. Its callbacks run | |
| 60 | + | // inside Loop.Iterate: they only record, the pump acts. | |
| 61 | + | type streamHandler struct{ st *stream } | |
| 16 | 62 | ||
| 17 | - | func (s *Service) forEachStream(f func(*stream)) { | |
| 18 | - | for i := range s.sessions { | |
| 19 | - | _ = f | |
| 20 | - | _ = i | |
| 63 | + | func (h *streamHandler) StateChanged(state int32, nodeID uint32, _ string) { | |
| 64 | + | st := h.st | |
| 65 | + | st.pwState = state | |
| 66 | + | if nodeID != 0 { | |
| 67 | + | st.nodeID = nodeID | |
| 68 | + | } | |
| 69 | + | if state > pipewire.StreamStateUnconnected { | |
| 70 | + | st.seenState = true | |
| 71 | + | } | |
| 72 | + | if state == pipewire.StreamStateStreaming { | |
| 73 | + | st.forced = true | |
| 21 | 74 | } | |
| 22 | 75 | } | |
| 23 | 76 | ||
| 24 | - | func (s *Service) pumpAll(now time.Time) {} | |
| 25 | - | func (s *Service) reap() {} | |
| 26 | - | func (s *Service) streamsLive() bool { return false } | |
| 77 | + | func (h *streamHandler) FormatChanged(f pipewire.VideoFormat, w, hh, _ int) { | |
| 78 | + | h.st.format, h.st.fw, h.st.fh = f, w, hh | |
| 79 | + | // A new format shows a new picture: the first frame is not paced by damage. | |
| 80 | + | h.st.forced = true | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // BuffersChanged: the next pump looks for a free buffer anyway. | |
| 84 | + | func (h *streamHandler) BuffersChanged() {} | |
| 85 | + | ||
| 86 | + | // close ends the stream on the wayland side; the slot is freed by reap once | |
| 87 | + | // no frame is in flight. | |
| 88 | + | func (st *stream) close() { | |
| 89 | + | if st.closing { | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | st.closing = true | |
| 93 | + | st.needOpen = false | |
| 94 | + | st.needClose = st.opened && !st.stopped | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // encodeParameters writes the a{sv} of Stream.Parameters. | |
| 98 | + | func encodeParameters(e *zerobus.Encoder, st *stream) { | |
| 99 | + | a := e.BeginArray('{') | |
| 100 | + | e.Struct() | |
| 101 | + | e.Str("position") | |
| 102 | + | e.Variant("(ii)") | |
| 103 | + | e.Struct() | |
| 104 | + | e.Int32(st.px) | |
| 105 | + | e.Int32(st.py) | |
| 106 | + | e.Struct() | |
| 107 | + | e.Str("size") | |
| 108 | + | e.Variant("(ii)") | |
| 109 | + | e.Struct() | |
| 110 | + | e.Int32(st.pw) | |
| 111 | + | e.Int32(st.ph) | |
| 112 | + | e.EndArray(a) | |
| 113 | + | } | |
| @@ -4,23 +4,110 @@ import ( | |||||
| 4 | "time" | 4 | "time" | ||
| 5 | 5 | ||||
| 6 | "github.com/bnema/neferwl/internal/ports" | 6 | "github.com/bnema/neferwl/internal/ports" | ||
| 7 | + | "github.com/bnema/purego-pipewire/pipewire" | |||
| 8 | + | "github.com/bnema/zerobus" | |||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 9 | - | // stream is one cast of a session. | 11 | + | // stream is one cast of a session: a PipeWire node fed with frames that the |
| 12 | + | // wayland server renders. Everything here is touched by the owner goroutine | |||
| 13 | + | // only; the PipeWire handler runs on it too, inside Loop.Iterate. | |||
| 10 | type stream struct { | 14 | type stream struct { | ||
| 11 | - | used bool | 15 | + | used bool |
| 12 | - | dirty bool | 16 | + | sess *session |
| 17 | + | id uint32 // number in the object path | |||
| 18 | + | cast uint64 // ID in the ports.CastCommands | |||
| 19 | + | path string | |||
| 20 | + | ||||
| 21 | + | // Target. | |||
| 22 | + | output string | |||
| 23 | + | window ports.WindowID | |||
| 24 | + | cursor bool | |||
| 25 | + | maxFPS int | |||
| 26 | + | // Stream.Parameters: position and size in the compositor space. | |||
| 27 | + | px, py, pw, ph int32 | |||
| 28 | + | ||||
| 29 | + | // Lifecycle. | |||
| 30 | + | needOpen bool // CastOpen is yet to be sent | |||
| 31 | + | opened bool // CastOpen was sent | |||
| 32 | + | needClose bool // CastClose is yet to be sent | |||
| 33 | + | closing bool // the session ended: free once no frame is in flight | |||
| 34 | + | stopped bool // CastStopped came: the wayland side is done | |||
| 35 | + | ||||
| 36 | + | // Negotiation. | |||
| 37 | + | w, h int // size of the target, from CastSize | |||
| 38 | + | haveSize bool | |||
| 39 | + | needResize bool | |||
| 40 | + | src videoSource | |||
| 41 | + | h2 streamHandler | |||
| 42 | + | pwState int32 // last state of the PipeWire stream; -2 before the first | |||
| 43 | + | seenState bool // a state above UNCONNECTED was seen | |||
| 44 | + | nodeID uint32 | |||
| 45 | + | announced bool // PipeWireStreamAdded was sent | |||
| 46 | + | format pipewire.VideoFormat | |||
| 47 | + | fw, fh int // size the format was negotiated at; 0 while renegotiating | |||
| 48 | + | ||||
| 49 | + | // Frames. | |||
| 50 | + | dirty bool | |||
| 51 | + | forced bool // the first frame after STREAMING is not paced by damage | |||
| 52 | + | inflight bool | |||
| 53 | + | buf *pipewire.VideoBuffer | |||
| 54 | + | lastReq time.Time | |||
| 55 | + | seq uint64 | |||
| 56 | + | errs int // consecutive failed frames | |||
| 13 | } | 57 | } | ||
| 14 | 58 | ||||
| 15 | - | func (s *Service) onCastEvent(ev *ports.CastEvent) { _ = ev } | 59 | + | // streamHandler is the pipewire.VideoHandler of a stream. Its callbacks run |
| 60 | + | // inside Loop.Iterate: they only record, the pump acts. | |||
| 61 | + | type streamHandler struct{ st *stream } | |||
| 16 | 62 | ||||
| 17 | - | func (s *Service) forEachStream(f func(*stream)) { | 63 | + | func (h *streamHandler) StateChanged(state int32, nodeID uint32, _ string) { |
| 18 | - | for i := range s.sessions { | 64 | + | st := h.st |
| 19 | - | _ = f | 65 | + | st.pwState = state |
| 20 | - | _ = i | 66 | + | if nodeID != 0 { |
| 67 | + | st.nodeID = nodeID | |||
| 68 | + | } | |||
| 69 | + | if state > pipewire.StreamStateUnconnected { | |||
| 70 | + | st.seenState = true | |||
| 71 | + | } | |||
| 72 | + | if state == pipewire.StreamStateStreaming { | |||
| 73 | + | st.forced = true | |||
| 21 | } | 74 | } | ||
| 22 | } | 75 | } | ||
| 23 | 76 | ||||
| 24 | - | func (s *Service) pumpAll(now time.Time) {} | 77 | + | func (h *streamHandler) FormatChanged(f pipewire.VideoFormat, w, hh, _ int) { |
| 25 | - | func (s *Service) reap() {} | 78 | + | h.st.format, h.st.fw, h.st.fh = f, w, hh |
| 26 | - | func (s *Service) streamsLive() bool { return false } | 79 | + | // A new format shows a new picture: the first frame is not paced by damage. |
| 80 | + | h.st.forced = true | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // BuffersChanged: the next pump looks for a free buffer anyway. | |||
| 84 | + | func (h *streamHandler) BuffersChanged() {} | |||
| 85 | + | ||||
| 86 | + | // close ends the stream on the wayland side; the slot is freed by reap once | |||
| 87 | + | // no frame is in flight. | |||
| 88 | + | func (st *stream) close() { | |||
| 89 | + | if st.closing { | |||
| 90 | + | return | |||
| 91 | + | } | |||
| 92 | + | st.closing = true | |||
| 93 | + | st.needOpen = false | |||
| 94 | + | st.needClose = st.opened && !st.stopped | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | // encodeParameters writes the a{sv} of Stream.Parameters. | |||
| 98 | + | func encodeParameters(e *zerobus.Encoder, st *stream) { | |||
| 99 | + | a := e.BeginArray('{') | |||
| 100 | + | e.Struct() | |||
| 101 | + | e.Str("position") | |||
| 102 | + | e.Variant("(ii)") | |||
| 103 | + | e.Struct() | |||
| 104 | + | e.Int32(st.px) | |||
| 105 | + | e.Int32(st.py) | |||
| 106 | + | e.Struct() | |||
| 107 | + | e.Str("size") | |||
| 108 | + | e.Variant("(ii)") | |||
| 109 | + | e.Struct() | |||
| 110 | + | e.Int32(st.pw) | |||
| 111 | + | e.Int32(st.ph) | |||
| 112 | + | e.EndArray(a) | |||
| 113 | + | } | |||