main f2d166b

screenshot.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 26

Tags 10

↑↓ moveenter openesc close