1package screencast
2
3import (
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, tagged with the ID of
19// the job, goes back through the service's transport and the owner sends
20// the reply, so the bus is still used by the owner only. A result whose ID
21// is not the job in flight is dropped. A job belongs to one bus attempt:
22// when the attempt ends, the job is cancelled and joined before the state is
23// reset (stopShot). A run that exits cleanly is a success only if it left a
24// non-empty regular file at F; otherwise the reply is (false, ""), which is
25// also how a cancelled interactive screenshot is answered. SelectArea and ScreenshotWindow (the "Select
26// area" and "Window" choices of the GNOME dialog) are not supported;
27// include_cursor and flash are ignored.
28
29// shotState is the screenshot in flight; one at most.
30type shotState struct {
31 busy bool
32 id uint64 // names the job; results of other jobs are stale
33 req request
34 uri bool // reply with a file:// URI
35 path string
36 cancel context.CancelFunc // stops the run
37 done chan struct{} // closed when the job goroutine is past its result
38}
39
40// shotResult is what a finished nefercap run leaves for the owner.
41type shotResult struct {
42 ready bool
43 id uint64 // the job it belongs to
44 err error // nefercap failed
45 made bool // the output file exists: a regular, non-empty file
46}
47
48// screenshot answers Screenshot, ScreenshotArea and InteractiveScreenshot.
49func (s *Service) screenshot(req *request) {
50 if s.shot.busy {
51 s.replyErr(req, errLimits, "a screenshot is already being taken")
52 return
53 }
54 var args []string
55 dir := shotDir()
56 if dir == "" {
57 s.replyErr(req, errFailed, "no runtime directory")
58 return
59 }
60 path := filepath.Join(dir, "screenshot-"+strconv.FormatInt(time.Now().UnixNano(), 10)+".png")
61 switch req.method {
62 case mShot:
63 out := s.inv.Focused
64 if out == "" && len(s.inv.Outputs) > 0 {
65 out = s.inv.Outputs[0].Info.Name
66 }
67 if out == "" {
68 s.replyErr(req, errFailed, "no output")
69 return
70 }
71 args = []string{"screenshot", "-output", out, "-file", path}
72 case mShotArea:
73 out, region, ok := s.areaTarget(req.x, req.y, req.w, req.h)
74 if !ok {
75 s.replyErr(req, errInvalidArgs, "area outside every output")
76 return
77 }
78 args = []string{"screenshot", "-output", out, "-region", region, "-file", path}
79 default:
80 args = []string{"shot", "-file", path}
81 }
82 if err := os.MkdirAll(dir, 0o700); err != nil {
83 s.log.Warn().Err(err).Msg("screenshot directory not created")
84 s.replyErr(req, errFailed, "screenshot directory not created")
85 return
86 }
87 s.shotSeq++
88 s.shot = shotState{busy: true, id: s.shotSeq, req: *req, uri: req.method == mShotInteractive, path: path}
89 s.runShot(args)
90}
91
92// shotDir is $XDG_RUNTIME_DIR/neferwl.
93func shotDir() string {
94 rt := os.Getenv("XDG_RUNTIME_DIR")
95 if rt == "" {
96 return ""
97 }
98 return filepath.Join(rt, "neferwl")
99}
100
101// areaTarget finds the output that contains the corner (x, y) of an area in
102// the logical space and the output-local region, clipped to the output.
103func (s *Service) areaTarget(x, y, w, h int32) (string, string, bool) {
104 if w <= 0 || h <= 0 {
105 return "", "", false
106 }
107 for i := range s.inv.Outputs {
108 o := &s.inv.Outputs[i]
109 lx, ly := int(x)-o.X, int(y)-o.Y
110 if lx < 0 || ly < 0 || lx >= o.Width || ly >= o.Height {
111 continue
112 }
113 cw, ch := min(int(w), o.Width-lx), min(int(h), o.Height-ly)
114 return o.Info.Name, strconv.Itoa(lx) + "," + strconv.Itoa(ly) + "," + strconv.Itoa(cw) + "x" + strconv.Itoa(ch), true
115 }
116 return "", "", false
117}
118
119// runShot runs nefercap in a goroutine for the job in s.shot and leaves the
120// tagged result for the owner. The run ends with the attempt's context.
121func (s *Service) runShot(args []string) {
122 ctx, cancel := context.WithTimeout(s.ctx, shotTimeout)
123 done := make(chan struct{})
124 s.shot.cancel, s.shot.done = cancel, done
125 id, path := s.shot.id, s.shot.path
126 run := s.runner
127 s.jobs.Add(1)
128 go func() {
129 defer s.jobs.Done()
130 defer close(done)
131 defer cancel()
132 err := run.Run(ctx, args)
133 res := shotResult{ready: true, id: id, err: err}
134 if err == nil {
135 res.made = isOutputFile(path)
136 }
137 s.mu.Lock()
138 s.shotRes = res
139 s.woken = true
140 s.mu.Unlock()
141 s.wake()
142 }()
143}
144
145// isOutputFile reports whether path is a regular, non-empty file. A symlink
146// is not followed: it is not a regular file.
147func isOutputFile(path string) bool {
148 fi, err := os.Lstat(path)
149 return err == nil && fi.Mode().IsRegular() && fi.Size() > 0
150}
151
152// stopShot cancels the screenshot in flight, waits for its goroutine and
153// forgets its result, so no job outlives the attempt that started it. The
154// requester is told it failed when the bus still carries the answer.
155func (s *Service) stopShot() {
156 st := s.shot
157 if !st.busy {
158 return
159 }
160 st.cancel()
161 <-st.done
162 s.mu.Lock()
163 s.shotRes = shotResult{}
164 s.mu.Unlock()
165 s.shot = shotState{}
166 _ = os.Remove(st.path)
167 s.replyErr(&st.req, errFailed, "screenshot cancelled")
168}
169
170// onShotDone sends the reply of the screenshot that finished. A result of
171// another job than the one in flight is stale and completes nothing.
172func (s *Service) onShotDone(r *shotResult) {
173 st := s.shot
174 if !st.busy || r.id != st.id {
175 return
176 }
177 s.shot = shotState{}
178 switch {
179 case r.err != nil:
180 s.log.Warn().Err(r.err).Msg("screenshot failed")
181 _ = os.Remove(st.path)
182 s.replyErr(&st.req, errFailed, "screenshot failed")
183 case !r.made:
184 // nefercap exited cleanly and left nothing usable (an interactive
185 // screenshot that was cancelled, for one).
186 s.log.Info().Msg("screenshot produced no file")
187 _ = os.Remove(st.path)
188 if e := s.replyTo(&st.req, "bs"); e != nil {
189 e.Bool(false)
190 e.Str("")
191 s.send()
192 }
193 default:
194 out := st.path
195 if st.uri {
196 out = "file://" + out
197 }
198 if e := s.replyTo(&st.req, "bs"); e != nil {
199 e.Bool(true)
200 e.Str(out)
201 s.send()
202 }
203 }
204}