1package app
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8
9 "github.com/bnema/nefercap/internal/adapters/control"
10 "github.com/bnema/nefercap/internal/adapters/ffmpeg"
11 "github.com/bnema/nefercap/internal/adapters/indicator"
12 "github.com/bnema/nefercap/internal/adapters/uierrors"
13 "github.com/bnema/nefercap/internal/adapters/wayland"
14 "github.com/bnema/nefercap/internal/core"
15 "github.com/bnema/nefercap/internal/logging"
16 "github.com/bnema/nefercap/internal/ports"
17 "github.com/bnema/wlturbo"
18)
19
20// recordInteractive records and listens for stop requests. With the
21// compositor's capture exclusion it also shows the HUD, excluded from the
22// video; without it nothing is drawn, so the user stops with the same
23// shortcut or `nefercap stop`. A stop requested by the user is success; the
24// outcome says whether a file exists.
25func recordInteractive(ctx context.Context, notices io.Writer, source *wayland.Source, outputs []ports.Output, sel ports.Selection) (outcome recordingOutcome, err error) {
26 var output ports.Output
27 for _, candidate := range outputs {
28 if candidate.ID == sel.Target.OutputID {
29 output = candidate
30 break
31 }
32 }
33 if output.ID == 0 {
34 return outcome, ports.ErrOutputNotFound
35 }
36 token, withHUD, err := startExclusion(ctx, notices, source, sel.Target)
37 if err != nil {
38 return outcome, err
39 }
40 server, err := control.Listen()
41 if err != nil {
42 return outcome, err
43 }
44 defer func() {
45 if closeErr := server.Close(); closeErr != nil {
46 err = errors.Join(err, closeErr)
47 }
48 }()
49
50 recordCtx, cancelRecord := context.WithCancelCause(ctx)
51 defer cancelRecord(nil)
52 hudCtx, cancelHUD := context.WithCancel(ctx)
53 defer cancelHUD()
54 life := newLifecycle(ctx, cancelRecord, cancelHUD)
55 hudStop := make(chan struct{}, 1)
56 watchCtx, stopWatch := context.WithCancel(context.Background())
57 controlDone := stopOnRequest(watchCtx, server.Requests(), life.userStop)
58 buttonDone := stopOnSignal(watchCtx, hudStop, life.userStop)
59 defer func() { stopWatch(); <-controlDone; <-buttonDone }()
60
61 end := ending{}
62 var video *ffmpeg.Writer
63 run := func() <-chan error {
64 video = ffmpeg.New()
65 captureDone := make(chan error, 1)
66 go func() { captureDone <- core.New(source, nil, video).Run(recordCtx, sel) }()
67 return captureDone
68 }
69 if !withHUD {
70 end.runErr = <-run()
71 life.beginShutdown()
72 } else {
73 hud := startHUD(hudCtx, output, recordedFrame(ctx, source, sel.Target), token, hudStop, life)
74 // Do not start the encoder until the compositor acknowledges the
75 // exclusion: a HUD that is not excluded would be in the video.
76 select {
77 case <-hud.ready:
78 captureDone := run()
79 select {
80 case end.runErr = <-captureDone:
81 life.beginShutdown()
82 cancelHUD()
83 end.hudErr = <-hud.done
84 case hudErr := <-hud.done:
85 end.hudErr = life.hudEnded(hudErr, false)
86 end.runErr = <-captureDone
87 }
88 case hudErr := <-hud.done:
89 end.hudErr = life.hudEnded(hudErr, !authorized(hud.ready))
90 case <-recordCtx.Done():
91 life.beginShutdown()
92 cancelHUD()
93 end.hudErr = <-hud.done
94 }
95 }
96 end.failure = life.failed()
97 end.parentErr = ctx.Err()
98 end.userStop = context.Cause(recordCtx) == errUserStop
99 err = classify(end)
100 if video != nil {
101 // Finalization may retain frames despite a late capture or HUD error.
102 // The writer knows whether it kept its file, not a pre-existing path.
103 outcome.Saved = video.Saved
104 }
105 return outcome, err
106}
107
108// startExclusion asks the compositor to keep the HUD out of the recording. It
109// reports whether a HUD can be shown: without the extension, or when the
110// compositor refuses the exclusion because another one is live (busy), the
111// recording goes on without HUD, like on a compositor with no extension. Any
112// other failure is fatal, including a session the compositor stopped.
113func startExclusion(ctx context.Context, notices io.Writer, source *wayland.Source, target ports.Target) (token string, withHUD bool, err error) {
114 if !source.Capabilities().Exclusion {
115 return "", false, nil
116 }
117 token, err = source.BeginExclusion(ctx, target)
118 switch {
119 case err == nil:
120 return token, true, nil
121 case errors.Is(err, wayland.ErrExclusionBusy) && ctx.Err() == nil:
122 log := logging.For(ctx, "app")
123 log.Warn().Err(err).Msg("capture exclusion refused: recording without indicator")
124 fmt.Fprintln(notices, "recording without indicator: stop with the same shortcut or `nefercap stop`")
125 return "", false, nil
126 }
127 return "", false, exclusionError(ctx, err)
128}
129
130// exclusionError classifies a BeginExclusion failure. The caller's own
131// cancellation stays the bare context error; every other failure gets the
132// phase and keeps its identity.
133func exclusionError(ctx context.Context, err error) error {
134 if ctx.Err() != nil && uierrors.IsCancellation(err) {
135 return ctx.Err()
136 }
137 return fmt.Errorf("begin capture exclusion: %w", err)
138}
139
140// authorized reports, without blocking, whether the HUD signalled ready. Ready
141// and done can both be pending; a HUD that was authorized did not close early.
142func authorized(ready <-chan struct{}) bool {
143 select {
144 case <-ready:
145 return true
146 default:
147 return false
148 }
149}
150
151// hudHandle is the owner's view of a running HUD: ready fires once when the
152// compositor acknowledged the exclusion, done delivers Run's result once.
153type hudHandle struct {
154 ready <-chan struct{}
155 done <-chan error
156}
157
158// startHUD runs the indicator on its own goroutine. Its detach callback and
159// button only signal the lifecycle; they never touch the recording.
160func startHUD(ctx context.Context, output ports.Output, frame ports.Region, token string, stop chan<- struct{}, life *lifecycle) hudHandle {
161 ready := make(chan struct{}, 1)
162 done := make(chan error, 1)
163 authorize := func(hookCtx context.Context, display *wlturbo.Display, surface wlturbo.Proxy) error {
164 if err := wayland.AuthorizeLayer(hookCtx, display, surface, token, life.detached); err != nil {
165 return err
166 }
167 ready <- struct{}{}
168 return nil
169 }
170 go func() {
171 hud := indicator.New(authorize, stop)
172 hud.Frame = frame
173 done <- hud.Run(ctx, output)
174 }()
175 return hudHandle{ready: ready, done: done}
176}
177
178// recordedFrame is where a workspace target is on its output, so that the HUD
179// sits in it. Anything else, or a workspace that cannot be found, is the whole
180// output (the zero Region).
181func recordedFrame(ctx context.Context, source *wayland.Source, t ports.Target) ports.Region {
182 if t.WorkspaceID == 0 {
183 return ports.Region{}
184 }
185 list, err := source.Workspaces(ctx)
186 if err != nil {
187 if ctx.Err() != nil {
188 return ports.Region{}
189 }
190 log := logging.For(ctx, "app")
191 log.Warn().Err(err).Msg("workspace frame unavailable: using the whole output")
192 return ports.Region{}
193 }
194 for _, w := range list {
195 if w.ID == t.WorkspaceID && w.OutputID == t.OutputID {
196 return w.Region
197 }
198 }
199 return ports.Region{}
200}