feat(hints): dump scanned frames when NEFERWL_HINT_DEBUG is set
Opt-in: writes the frame, an annotated copy and a JSON summary per scan under $XDG_STATE_HOME/neferwl/hints, keeping the 50 most recent. Documented in docs/config.md.
6 files · +414 -6
M docs/config.md +4 -0
| @@ -309,3 +309,7 @@ Otherwise, left from the first column unfolds the stash. Up/down walk the cards, | |||
| 309 | 309 | - `hint.targets = links` labels links and URLs only, not buttons. | |
| 310 | 310 | - `hint` does nothing while the session is locked, on a rotated or flipped screen, while the overview is open, while a pointer button, drag or pointer constraint is active, or while hint mode is already active. Hint mode ends when the workspace on that screen changes or the overview opens. | |
| 311 | 311 | - No capture indicator appears: the scan happens inside NeferWL and no client receives the pixels. Screen captures do not contain the labels. | |
| 312 | + | ||
| 313 | + | #### Debugging hint mode | |
| 314 | + | ||
| 315 | + | With `NEFERWL_HINT_DEBUG=1` set when NeferWL starts (for example `NEFERWL_HINT_DEBUG=1 make tty`, or the variable in the launch environment), every successful scan writes three files to `$XDG_STATE_HOME/neferwl/hints/` (`~/.local/state/neferwl/hints/` when unset): `<time>-<output>.png`, the frame as scanned; `<time>-<output>.annotated.png`, the same with each target outlined (links green, buttons blue, text grey); and `<time>-<output>.json`, the targets and timings. NeferWL keeps the 50 most recent scans and logs a warning at startup. The files contain the screen in clear: delete them after use. | |
| @@ -309,3 +309,7 @@ Otherwise, left from the first column unfolds the stash. Up/down walk the cards, | |||||
| 309 | - `hint.targets = links` labels links and URLs only, not buttons. | 309 | - `hint.targets = links` labels links and URLs only, not buttons. | ||
| 310 | - `hint` does nothing while the session is locked, on a rotated or flipped screen, while the overview is open, while a pointer button, drag or pointer constraint is active, or while hint mode is already active. Hint mode ends when the workspace on that screen changes or the overview opens. | 310 | - `hint` does nothing while the session is locked, on a rotated or flipped screen, while the overview is open, while a pointer button, drag or pointer constraint is active, or while hint mode is already active. Hint mode ends when the workspace on that screen changes or the overview opens. | ||
| 311 | - No capture indicator appears: the scan happens inside NeferWL and no client receives the pixels. Screen captures do not contain the labels. | 311 | - No capture indicator appears: the scan happens inside NeferWL and no client receives the pixels. Screen captures do not contain the labels. | ||
| 312 | + | ||||
| 313 | + | #### Debugging hint mode | |||
| 314 | + | ||||
| 315 | + | With `NEFERWL_HINT_DEBUG=1` set when NeferWL starts (for example `NEFERWL_HINT_DEBUG=1 make tty`, or the variable in the launch environment), every successful scan writes three files to `$XDG_STATE_HOME/neferwl/hints/` (`~/.local/state/neferwl/hints/` when unset): `<time>-<output>.png`, the frame as scanned; `<time>-<output>.annotated.png`, the same with each target outlined (links green, buttons blue, text grey); and `<time>-<output>.json`, the targets and timings. NeferWL keeps the 50 most recent scans and logs a warning at startup. The files contain the screen in clear: delete them after use. | |||
A internal/adapters/hints/dump.go +214 -0
| @@ -0,0 +1,214 @@ | |||
| 1 | + | package hints | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "image" | |
| 7 | + | "image/color" | |
| 8 | + | "image/png" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "sort" | |
| 12 | + | "strings" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/neferwl/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | const ( | |
| 19 | + | // maxDumps is the number of dump groups kept in the directory. | |
| 20 | + | maxDumps = 50 | |
| 21 | + | outlineSize = 2 | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // Options tunes Run. | |
| 25 | + | type Options struct { | |
| 26 | + | // DumpDir, when not empty, receives the scanned frames (PNG, annotated | |
| 27 | + | // PNG and JSON). They contain the screen in clear. | |
| 28 | + | DumpDir string | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // dumpJob is one scan to write. Pix is the worker buffer, not a copy: it | |
| 32 | + | // stays untouched until the worker serves the next request. | |
| 33 | + | type dumpJob struct { | |
| 34 | + | when time.Time | |
| 35 | + | req ports.HintScanRequest | |
| 36 | + | pix []byte | |
| 37 | + | stride int | |
| 38 | + | targets []ports.HintTarget | |
| 39 | + | captureD time.Duration | |
| 40 | + | scanD time.Duration | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type dumpTarget struct { | |
| 44 | + | Kind string `json:"kind"` | |
| 45 | + | Rect [4]int `json:"rect"` | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | type dumpMeta struct { | |
| 49 | + | Output string `json:"output"` | |
| 50 | + | Width int `json:"width"` | |
| 51 | + | Height int `json:"height"` | |
| 52 | + | Stride int `json:"stride"` | |
| 53 | + | Scale float64 `json:"scale"` | |
| 54 | + | LinksOnly bool `json:"links_only"` | |
| 55 | + | CaptureMS float64 `json:"capture_ms"` | |
| 56 | + | ScanMS float64 `json:"scan_ms"` | |
| 57 | + | Targets []dumpTarget `json:"targets"` | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func kindName(k ports.HintKind) string { | |
| 61 | + | switch k { | |
| 62 | + | case ports.HintLink: | |
| 63 | + | return "link" | |
| 64 | + | case ports.HintButton: | |
| 65 | + | return "button" | |
| 66 | + | default: | |
| 67 | + | return "text" | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func kindColor(k ports.HintKind) color.RGBA { | |
| 72 | + | switch k { | |
| 73 | + | case ports.HintLink: | |
| 74 | + | return color.RGBA{0x00, 0xc8, 0x53, 0xff} | |
| 75 | + | case ports.HintButton: | |
| 76 | + | return color.RGBA{0x29, 0x62, 0xff, 0xff} | |
| 77 | + | default: | |
| 78 | + | return color.RGBA{0x80, 0x80, 0x80, 0xff} | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 } | |
| 83 | + | ||
| 84 | + | // writeDump writes the three files of j into dir, then prunes old groups. | |
| 85 | + | func writeDump(dir string, j *dumpJob) error { | |
| 86 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |
| 87 | + | return err | |
| 88 | + | } | |
| 89 | + | prefix := filepath.Join(dir, j.when.Format("20060102-150405.000")+"-"+safeName(j.req.Output)) | |
| 90 | + | ||
| 91 | + | img := &image.RGBA{Pix: make([]byte, j.req.Height*j.stride), Stride: j.stride, Rect: image.Rect(0, 0, j.req.Width, j.req.Height)} | |
| 92 | + | for y := 0; y < j.req.Height; y++ { | |
| 93 | + | row := j.pix[y*j.stride : y*j.stride+j.req.Width*4] | |
| 94 | + | out := img.Pix[y*j.stride:] | |
| 95 | + | for x := 0; x < len(row); x += 4 { | |
| 96 | + | out[x], out[x+1], out[x+2], out[x+3] = row[x+2], row[x+1], row[x], 0xff | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | if err := writePNG(prefix+".png", img); err != nil { | |
| 100 | + | return err | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | scale := j.req.Scale | |
| 104 | + | if scale <= 0 { | |
| 105 | + | scale = 1 | |
| 106 | + | } | |
| 107 | + | for _, t := range j.targets { | |
| 108 | + | r := image.Rect(int(float64(t.Rect.X)*scale), int(float64(t.Rect.Y)*scale), | |
| 109 | + | int(float64(t.Rect.X+t.Rect.W)*scale), int(float64(t.Rect.Y+t.Rect.H)*scale)) | |
| 110 | + | outline(img, r, kindColor(t.Kind)) | |
| 111 | + | } | |
| 112 | + | if err := writePNG(prefix+".annotated.png", img); err != nil { | |
| 113 | + | return err | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | meta := dumpMeta{ | |
| 117 | + | Output: j.req.Output, Width: j.req.Width, Height: j.req.Height, Stride: j.stride, | |
| 118 | + | Scale: j.req.Scale, LinksOnly: j.req.LinksOnly, | |
| 119 | + | CaptureMS: ms(j.captureD), ScanMS: ms(j.scanD), | |
| 120 | + | Targets: make([]dumpTarget, 0, len(j.targets)), | |
| 121 | + | } | |
| 122 | + | for _, t := range j.targets { | |
| 123 | + | meta.Targets = append(meta.Targets, dumpTarget{Kind: kindName(t.Kind), Rect: [4]int{t.Rect.X, t.Rect.Y, t.Rect.X + t.Rect.W, t.Rect.Y + t.Rect.H}}) | |
| 124 | + | } | |
| 125 | + | data, err := json.Marshal(meta) | |
| 126 | + | if err != nil { | |
| 127 | + | return err | |
| 128 | + | } | |
| 129 | + | if err := writeFile(prefix+".json", func(f *os.File) error { _, err := f.Write(append(data, '\n')); return err }); err != nil { | |
| 130 | + | return err | |
| 131 | + | } | |
| 132 | + | return prune(dir, maxDumps) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func safeName(s string) string { | |
| 136 | + | return strings.Map(func(r rune) rune { | |
| 137 | + | if r == '/' || r == 0 { | |
| 138 | + | return '_' | |
| 139 | + | } | |
| 140 | + | return r | |
| 141 | + | }, s) | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func writePNG(path string, img image.Image) error { | |
| 145 | + | enc := png.Encoder{CompressionLevel: png.BestSpeed} | |
| 146 | + | return writeFile(path, func(f *os.File) error { return enc.Encode(f, img) }) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func writeFile(path string, fill func(*os.File) error) error { | |
| 150 | + | f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) | |
| 151 | + | if err != nil { | |
| 152 | + | return err | |
| 153 | + | } | |
| 154 | + | if err := fill(f); err != nil { | |
| 155 | + | _ = f.Close() | |
| 156 | + | return err | |
| 157 | + | } | |
| 158 | + | return f.Close() | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // outline draws a 2 px frame inside r, clipped to img. | |
| 162 | + | func outline(img *image.RGBA, r image.Rectangle, c color.RGBA) { | |
| 163 | + | r = r.Intersect(img.Rect) | |
| 164 | + | if r.Empty() { | |
| 165 | + | return | |
| 166 | + | } | |
| 167 | + | for y := r.Min.Y; y < r.Max.Y; y++ { | |
| 168 | + | for x := r.Min.X; x < r.Max.X; x++ { | |
| 169 | + | if x-r.Min.X < outlineSize || r.Max.X-1-x < outlineSize || y-r.Min.Y < outlineSize || r.Max.Y-1-y < outlineSize { | |
| 170 | + | img.SetRGBA(x, y, c) | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // groupOf returns the "<timestamp>-<output>" prefix of a dump file name. | |
| 177 | + | func groupOf(name string) string { | |
| 178 | + | for _, suf := range []string{".annotated.png", ".png", ".json"} { | |
| 179 | + | if strings.HasSuffix(name, suf) { | |
| 180 | + | return strings.TrimSuffix(name, suf) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | return "" | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // prune keeps the keep most recent groups of dir; timestamps sort by name. | |
| 187 | + | func prune(dir string, keep int) error { | |
| 188 | + | entries, err := os.ReadDir(dir) | |
| 189 | + | if err != nil { | |
| 190 | + | return err | |
| 191 | + | } | |
| 192 | + | groups := map[string][]string{} | |
| 193 | + | for _, e := range entries { | |
| 194 | + | if g := groupOf(e.Name()); g != "" && !e.IsDir() { | |
| 195 | + | groups[g] = append(groups[g], e.Name()) | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | if len(groups) <= keep { | |
| 199 | + | return nil | |
| 200 | + | } | |
| 201 | + | names := make([]string, 0, len(groups)) | |
| 202 | + | for g := range groups { | |
| 203 | + | names = append(names, g) | |
| 204 | + | } | |
| 205 | + | sort.Strings(names) | |
| 206 | + | for _, g := range names[:len(names)-keep] { | |
| 207 | + | for _, f := range groups[g] { | |
| 208 | + | if err := os.Remove(filepath.Join(dir, f)); err != nil { | |
| 209 | + | return fmt.Errorf("prune: %w", err) | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | return nil | |
| 214 | + | } | |
| @@ -0,0 +1,214 @@ | |||||
| 1 | + | package hints | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | "image" | |||
| 7 | + | "image/color" | |||
| 8 | + | "image/png" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "sort" | |||
| 12 | + | "strings" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | const ( | |||
| 19 | + | // maxDumps is the number of dump groups kept in the directory. | |||
| 20 | + | maxDumps = 50 | |||
| 21 | + | outlineSize = 2 | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // Options tunes Run. | |||
| 25 | + | type Options struct { | |||
| 26 | + | // DumpDir, when not empty, receives the scanned frames (PNG, annotated | |||
| 27 | + | // PNG and JSON). They contain the screen in clear. | |||
| 28 | + | DumpDir string | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // dumpJob is one scan to write. Pix is the worker buffer, not a copy: it | |||
| 32 | + | // stays untouched until the worker serves the next request. | |||
| 33 | + | type dumpJob struct { | |||
| 34 | + | when time.Time | |||
| 35 | + | req ports.HintScanRequest | |||
| 36 | + | pix []byte | |||
| 37 | + | stride int | |||
| 38 | + | targets []ports.HintTarget | |||
| 39 | + | captureD time.Duration | |||
| 40 | + | scanD time.Duration | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type dumpTarget struct { | |||
| 44 | + | Kind string `json:"kind"` | |||
| 45 | + | Rect [4]int `json:"rect"` | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type dumpMeta struct { | |||
| 49 | + | Output string `json:"output"` | |||
| 50 | + | Width int `json:"width"` | |||
| 51 | + | Height int `json:"height"` | |||
| 52 | + | Stride int `json:"stride"` | |||
| 53 | + | Scale float64 `json:"scale"` | |||
| 54 | + | LinksOnly bool `json:"links_only"` | |||
| 55 | + | CaptureMS float64 `json:"capture_ms"` | |||
| 56 | + | ScanMS float64 `json:"scan_ms"` | |||
| 57 | + | Targets []dumpTarget `json:"targets"` | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func kindName(k ports.HintKind) string { | |||
| 61 | + | switch k { | |||
| 62 | + | case ports.HintLink: | |||
| 63 | + | return "link" | |||
| 64 | + | case ports.HintButton: | |||
| 65 | + | return "button" | |||
| 66 | + | default: | |||
| 67 | + | return "text" | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func kindColor(k ports.HintKind) color.RGBA { | |||
| 72 | + | switch k { | |||
| 73 | + | case ports.HintLink: | |||
| 74 | + | return color.RGBA{0x00, 0xc8, 0x53, 0xff} | |||
| 75 | + | case ports.HintButton: | |||
| 76 | + | return color.RGBA{0x29, 0x62, 0xff, 0xff} | |||
| 77 | + | default: | |||
| 78 | + | return color.RGBA{0x80, 0x80, 0x80, 0xff} | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func ms(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 } | |||
| 83 | + | ||||
| 84 | + | // writeDump writes the three files of j into dir, then prunes old groups. | |||
| 85 | + | func writeDump(dir string, j *dumpJob) error { | |||
| 86 | + | if err := os.MkdirAll(dir, 0o700); err != nil { | |||
| 87 | + | return err | |||
| 88 | + | } | |||
| 89 | + | prefix := filepath.Join(dir, j.when.Format("20060102-150405.000")+"-"+safeName(j.req.Output)) | |||
| 90 | + | ||||
| 91 | + | img := &image.RGBA{Pix: make([]byte, j.req.Height*j.stride), Stride: j.stride, Rect: image.Rect(0, 0, j.req.Width, j.req.Height)} | |||
| 92 | + | for y := 0; y < j.req.Height; y++ { | |||
| 93 | + | row := j.pix[y*j.stride : y*j.stride+j.req.Width*4] | |||
| 94 | + | out := img.Pix[y*j.stride:] | |||
| 95 | + | for x := 0; x < len(row); x += 4 { | |||
| 96 | + | out[x], out[x+1], out[x+2], out[x+3] = row[x+2], row[x+1], row[x], 0xff | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | if err := writePNG(prefix+".png", img); err != nil { | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | scale := j.req.Scale | |||
| 104 | + | if scale <= 0 { | |||
| 105 | + | scale = 1 | |||
| 106 | + | } | |||
| 107 | + | for _, t := range j.targets { | |||
| 108 | + | r := image.Rect(int(float64(t.Rect.X)*scale), int(float64(t.Rect.Y)*scale), | |||
| 109 | + | int(float64(t.Rect.X+t.Rect.W)*scale), int(float64(t.Rect.Y+t.Rect.H)*scale)) | |||
| 110 | + | outline(img, r, kindColor(t.Kind)) | |||
| 111 | + | } | |||
| 112 | + | if err := writePNG(prefix+".annotated.png", img); err != nil { | |||
| 113 | + | return err | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | meta := dumpMeta{ | |||
| 117 | + | Output: j.req.Output, Width: j.req.Width, Height: j.req.Height, Stride: j.stride, | |||
| 118 | + | Scale: j.req.Scale, LinksOnly: j.req.LinksOnly, | |||
| 119 | + | CaptureMS: ms(j.captureD), ScanMS: ms(j.scanD), | |||
| 120 | + | Targets: make([]dumpTarget, 0, len(j.targets)), | |||
| 121 | + | } | |||
| 122 | + | for _, t := range j.targets { | |||
| 123 | + | meta.Targets = append(meta.Targets, dumpTarget{Kind: kindName(t.Kind), Rect: [4]int{t.Rect.X, t.Rect.Y, t.Rect.X + t.Rect.W, t.Rect.Y + t.Rect.H}}) | |||
| 124 | + | } | |||
| 125 | + | data, err := json.Marshal(meta) | |||
| 126 | + | if err != nil { | |||
| 127 | + | return err | |||
| 128 | + | } | |||
| 129 | + | if err := writeFile(prefix+".json", func(f *os.File) error { _, err := f.Write(append(data, '\n')); return err }); err != nil { | |||
| 130 | + | return err | |||
| 131 | + | } | |||
| 132 | + | return prune(dir, maxDumps) | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func safeName(s string) string { | |||
| 136 | + | return strings.Map(func(r rune) rune { | |||
| 137 | + | if r == '/' || r == 0 { | |||
| 138 | + | return '_' | |||
| 139 | + | } | |||
| 140 | + | return r | |||
| 141 | + | }, s) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func writePNG(path string, img image.Image) error { | |||
| 145 | + | enc := png.Encoder{CompressionLevel: png.BestSpeed} | |||
| 146 | + | return writeFile(path, func(f *os.File) error { return enc.Encode(f, img) }) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func writeFile(path string, fill func(*os.File) error) error { | |||
| 150 | + | f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) | |||
| 151 | + | if err != nil { | |||
| 152 | + | return err | |||
| 153 | + | } | |||
| 154 | + | if err := fill(f); err != nil { | |||
| 155 | + | _ = f.Close() | |||
| 156 | + | return err | |||
| 157 | + | } | |||
| 158 | + | return f.Close() | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // outline draws a 2 px frame inside r, clipped to img. | |||
| 162 | + | func outline(img *image.RGBA, r image.Rectangle, c color.RGBA) { | |||
| 163 | + | r = r.Intersect(img.Rect) | |||
| 164 | + | if r.Empty() { | |||
| 165 | + | return | |||
| 166 | + | } | |||
| 167 | + | for y := r.Min.Y; y < r.Max.Y; y++ { | |||
| 168 | + | for x := r.Min.X; x < r.Max.X; x++ { | |||
| 169 | + | if x-r.Min.X < outlineSize || r.Max.X-1-x < outlineSize || y-r.Min.Y < outlineSize || r.Max.Y-1-y < outlineSize { | |||
| 170 | + | img.SetRGBA(x, y, c) | |||
| 171 | + | } | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // groupOf returns the "<timestamp>-<output>" prefix of a dump file name. | |||
| 177 | + | func groupOf(name string) string { | |||
| 178 | + | for _, suf := range []string{".annotated.png", ".png", ".json"} { | |||
| 179 | + | if strings.HasSuffix(name, suf) { | |||
| 180 | + | return strings.TrimSuffix(name, suf) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | return "" | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // prune keeps the keep most recent groups of dir; timestamps sort by name. | |||
| 187 | + | func prune(dir string, keep int) error { | |||
| 188 | + | entries, err := os.ReadDir(dir) | |||
| 189 | + | if err != nil { | |||
| 190 | + | return err | |||
| 191 | + | } | |||
| 192 | + | groups := map[string][]string{} | |||
| 193 | + | for _, e := range entries { | |||
| 194 | + | if g := groupOf(e.Name()); g != "" && !e.IsDir() { | |||
| 195 | + | groups[g] = append(groups[g], e.Name()) | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | if len(groups) <= keep { | |||
| 199 | + | return nil | |||
| 200 | + | } | |||
| 201 | + | names := make([]string, 0, len(groups)) | |||
| 202 | + | for g := range groups { | |||
| 203 | + | names = append(names, g) | |||
| 204 | + | } | |||
| 205 | + | sort.Strings(names) | |||
| 206 | + | for _, g := range names[:len(names)-keep] { | |||
| 207 | + | for _, f := range groups[g] { | |||
| 208 | + | if err := os.Remove(filepath.Join(dir, f)); err != nil { | |||
| 209 | + | return fmt.Errorf("prune: %w", err) | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | return nil | |||
| 214 | + | } | |||
M internal/adapters/hints/hints.go +23 -4
| @@ -34,8 +34,8 @@ const ( | |||
| 34 | 34 | // Run serves requests until ctx is done. It handles one request at a time; | |
| 35 | 35 | // a request that arrives meanwhile is answered with ErrBusy. Every request | |
| 36 | 36 | // gets exactly one result on results. | |
| 37 | - | func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult) error { | |
| 38 | - | return run(ctx, requests, captures, captured, results, captureTimeout) | |
| 37 | + | func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, opts Options) error { | |
| 38 | + | return run(ctx, requests, captures, captured, results, captureTimeout, opts) | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | 41 | type worker struct { | |
| @@ -43,10 +43,12 @@ type worker struct { | |||
| 43 | 43 | buf []byte | |
| 44 | 44 | dst []hintscan.Target | |
| 45 | 45 | n uint64 | |
| 46 | + | dumpDir string | |
| 47 | + | pending *dumpJob | |
| 46 | 48 | } | |
| 47 | 49 | ||
| 48 | - | func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) error { | |
| 49 | - | w := &worker{scanner: hintscan.NewScanner()} | |
| 50 | + | func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration, opts Options) error { | |
| 51 | + | w := &worker{scanner: hintscan.NewScanner(), dumpDir: opts.DumpDir} | |
| 50 | 52 | for { | |
| 51 | 53 | select { | |
| 52 | 54 | case <-ctx.Done(): | |
| @@ -56,10 +58,24 @@ func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch | |||
| 56 | 58 | if !send(ctx, results, res) { | |
| 57 | 59 | return nil | |
| 58 | 60 | } | |
| 61 | + | w.dump(ctx) | |
| 59 | 62 | } | |
| 60 | 63 | } | |
| 61 | 64 | } | |
| 62 | 65 | ||
| 66 | + | // dump writes the last successful scan, after its result was sent. | |
| 67 | + | func (w *worker) dump(ctx context.Context) { | |
| 68 | + | j := w.pending | |
| 69 | + | w.pending = nil | |
| 70 | + | if j == nil { | |
| 71 | + | return | |
| 72 | + | } | |
| 73 | + | if err := writeDump(w.dumpDir, j); err != nil { | |
| 74 | + | log := logging.For(ctx, "hints") | |
| 75 | + | log.Warn().Err(err).Str("dir", w.dumpDir).Msg("hint debug dump failed") | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 63 | 79 | func send(ctx context.Context, results chan<- ports.HintScanResult, res ports.HintScanResult) bool { | |
| 64 | 80 | select { | |
| 65 | 81 | case results <- res: | |
| @@ -171,6 +187,9 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte | |||
| 171 | 187 | }) | |
| 172 | 188 | } | |
| 173 | 189 | res.Targets = out | |
| 190 | + | if w.dumpDir != "" { | |
| 191 | + | w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, captureD: captureDur, scanD: scanDur} | |
| 192 | + | } | |
| 174 | 193 | log.Info(). | |
| 175 | 194 | Str("output", req.Output). | |
| 176 | 195 | Int("targets", len(out)). | |
| @@ -34,8 +34,8 @@ const ( | |||||
| 34 | // Run serves requests until ctx is done. It handles one request at a time; | 34 | // Run serves requests until ctx is done. It handles one request at a time; | ||
| 35 | // a request that arrives meanwhile is answered with ErrBusy. Every request | 35 | // a request that arrives meanwhile is answered with ErrBusy. Every request | ||
| 36 | // gets exactly one result on results. | 36 | // gets exactly one result on results. | ||
| 37 | - | func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult) error { | 37 | + | func Run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, opts Options) error { |
| 38 | - | return run(ctx, requests, captures, captured, results, captureTimeout) | 38 | + | return run(ctx, requests, captures, captured, results, captureTimeout, opts) |
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | type worker struct { | 41 | type worker struct { | ||
| @@ -43,10 +43,12 @@ type worker struct { | |||||
| 43 | buf []byte | 43 | buf []byte | ||
| 44 | dst []hintscan.Target | 44 | dst []hintscan.Target | ||
| 45 | n uint64 | 45 | n uint64 | ||
| 46 | + | dumpDir string | |||
| 47 | + | pending *dumpJob | |||
| 46 | } | 48 | } | ||
| 47 | 49 | ||||
| 48 | - | func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration) error { | 50 | + | func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures chan<- ports.CaptureRequest, captured <-chan ports.CaptureDone, results chan<- ports.HintScanResult, timeout time.Duration, opts Options) error { |
| 49 | - | w := &worker{scanner: hintscan.NewScanner()} | 51 | + | w := &worker{scanner: hintscan.NewScanner(), dumpDir: opts.DumpDir} |
| 50 | for { | 52 | for { | ||
| 51 | select { | 53 | select { | ||
| 52 | case <-ctx.Done(): | 54 | case <-ctx.Done(): | ||
| @@ -56,10 +58,24 @@ func run(ctx context.Context, requests <-chan ports.HintScanRequest, captures ch | |||||
| 56 | if !send(ctx, results, res) { | 58 | if !send(ctx, results, res) { | ||
| 57 | return nil | 59 | return nil | ||
| 58 | } | 60 | } | ||
| 61 | + | w.dump(ctx) | |||
| 59 | } | 62 | } | ||
| 60 | } | 63 | } | ||
| 61 | } | 64 | } | ||
| 62 | 65 | ||||
| 66 | + | // dump writes the last successful scan, after its result was sent. | |||
| 67 | + | func (w *worker) dump(ctx context.Context) { | |||
| 68 | + | j := w.pending | |||
| 69 | + | w.pending = nil | |||
| 70 | + | if j == nil { | |||
| 71 | + | return | |||
| 72 | + | } | |||
| 73 | + | if err := writeDump(w.dumpDir, j); err != nil { | |||
| 74 | + | log := logging.For(ctx, "hints") | |||
| 75 | + | log.Warn().Err(err).Str("dir", w.dumpDir).Msg("hint debug dump failed") | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 63 | func send(ctx context.Context, results chan<- ports.HintScanResult, res ports.HintScanResult) bool { | 79 | func send(ctx context.Context, results chan<- ports.HintScanResult, res ports.HintScanResult) bool { | ||
| 64 | select { | 80 | select { | ||
| 65 | case results <- res: | 81 | case results <- res: | ||
| @@ -171,6 +187,9 @@ func (w *worker) scan(ctx context.Context, req ports.HintScanRequest, buf []byte | |||||
| 171 | }) | 187 | }) | ||
| 172 | } | 188 | } | ||
| 173 | res.Targets = out | 189 | res.Targets = out | ||
| 190 | + | if w.dumpDir != "" { | |||
| 191 | + | w.pending = &dumpJob{when: time.Now(), req: req, pix: buf, stride: stride, targets: out, captureD: captureDur, scanD: scanDur} | |||
| 192 | + | } | |||
| 174 | log.Info(). | 193 | log.Info(). | ||
| 175 | Str("output", req.Output). | 194 | Str("output", req.Output). | ||
| 176 | Int("targets", len(out)). | 195 | Int("targets", len(out)). | ||
M internal/adapters/hints/hints_test.go +139 -1
| @@ -2,11 +2,15 @@ package hints | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "errors" | |
| 7 | + | "fmt" | |
| 6 | 8 | "image" | |
| 7 | 9 | "image/draw" | |
| 8 | 10 | _ "image/png" | |
| 9 | 11 | "os" | |
| 12 | + | "path/filepath" | |
| 13 | + | "strings" | |
| 10 | 14 | "testing" | |
| 11 | 15 | "time" | |
| 12 | 16 | ||
| @@ -23,6 +27,10 @@ type harness struct { | |||
| 23 | 27 | } | |
| 24 | 28 | ||
| 25 | 29 | func start(t *testing.T, timeout time.Duration) *harness { | |
| 30 | + | return startWith(t, timeout, Options{}) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func startWith(t *testing.T, timeout time.Duration, opts Options) *harness { | |
| 26 | 34 | t.Helper() | |
| 27 | 35 | h := &harness{ | |
| 28 | 36 | requests: make(chan ports.HintScanRequest, 1), | |
| @@ -34,7 +42,7 @@ func start(t *testing.T, timeout time.Duration) *harness { | |||
| 34 | 42 | exited := make(chan struct{}) | |
| 35 | 43 | go func() { | |
| 36 | 44 | defer close(exited) | |
| 37 | - | _ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout) | |
| 45 | + | _ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout, opts) | |
| 38 | 46 | }() | |
| 39 | 47 | t.Cleanup(func() { cancel(); <-exited }) | |
| 40 | 48 | return h | |
| @@ -147,3 +155,133 @@ func TestHintBadSize(t *testing.T) { | |||
| 147 | 155 | res := <-h.results | |
| 148 | 156 | require.ErrorIs(t, res.Err, ErrBadRequest) | |
| 149 | 157 | } | |
| 158 | + | ||
| 159 | + | func scanOnce(t *testing.T, h *harness, pix []byte, w, hgt int, seq uint64) ports.HintScanResult { | |
| 160 | + | t.Helper() | |
| 161 | + | go func() { | |
| 162 | + | creq := <-h.captures | |
| 163 | + | copy(creq.Mapped, pix) | |
| 164 | + | h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |
| 165 | + | }() | |
| 166 | + | h.requests <- ports.HintScanRequest{Seq: seq, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1} | |
| 167 | + | res := <-h.results | |
| 168 | + | require.NoError(t, res.Err) | |
| 169 | + | return res | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // waitFiles waits for the dump the worker writes after sending the result. | |
| 173 | + | func waitFiles(t *testing.T, dir string, n int) []os.DirEntry { | |
| 174 | + | t.Helper() | |
| 175 | + | var entries []os.DirEntry | |
| 176 | + | require.Eventually(t, func() bool { | |
| 177 | + | entries, _ = os.ReadDir(dir) | |
| 178 | + | return len(entries) >= n | |
| 179 | + | }, 5*time.Second, 10*time.Millisecond) | |
| 180 | + | return entries | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestHintDump(t *testing.T) { | |
| 184 | + | pix, w, hgt := loadBGRA(t) | |
| 185 | + | dir := filepath.Join(t.TempDir(), "hints") | |
| 186 | + | h := startWith(t, 5*time.Second, Options{DumpDir: dir}) | |
| 187 | + | res := scanOnce(t, h, pix, w, hgt, 1) | |
| 188 | + | require.NotEmpty(t, res.Targets) | |
| 189 | + | ||
| 190 | + | entries := waitFiles(t, dir, 3) | |
| 191 | + | // A second request is only served once the dump is written. | |
| 192 | + | _ = scanOnceDone(t, h) | |
| 193 | + | var png, ann, js string | |
| 194 | + | for _, e := range entries { | |
| 195 | + | info, err := e.Info() | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |
| 198 | + | switch { | |
| 199 | + | case strings.HasSuffix(e.Name(), ".annotated.png"): | |
| 200 | + | ann = e.Name() | |
| 201 | + | case strings.HasSuffix(e.Name(), ".png"): | |
| 202 | + | png = e.Name() | |
| 203 | + | case strings.HasSuffix(e.Name(), ".json"): | |
| 204 | + | js = e.Name() | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | require.NotEmpty(t, png) | |
| 208 | + | require.NotEmpty(t, ann) | |
| 209 | + | require.Regexp(t, `^\d{8}-\d{6}\.\d{3}-HEADLESS-1\.json$`, js) | |
| 210 | + | ||
| 211 | + | st, err := os.Stat(dir) | |
| 212 | + | require.NoError(t, err) | |
| 213 | + | require.Equal(t, os.FileMode(0o700), st.Mode().Perm()) | |
| 214 | + | ||
| 215 | + | data, err := os.ReadFile(filepath.Join(dir, js)) | |
| 216 | + | require.NoError(t, err) | |
| 217 | + | var meta struct { | |
| 218 | + | Output string `json:"output"` | |
| 219 | + | Width int `json:"width"` | |
| 220 | + | Height int `json:"height"` | |
| 221 | + | Stride int `json:"stride"` | |
| 222 | + | Scale float64 `json:"scale"` | |
| 223 | + | LinksOnly bool `json:"links_only"` | |
| 224 | + | Targets []struct { | |
| 225 | + | Kind string `json:"kind"` | |
| 226 | + | Rect [4]int `json:"rect"` | |
| 227 | + | } `json:"targets"` | |
| 228 | + | } | |
| 229 | + | require.NoError(t, json.Unmarshal(data, &meta)) | |
| 230 | + | require.Equal(t, "HEADLESS-1", meta.Output) | |
| 231 | + | require.Equal(t, w, meta.Width) | |
| 232 | + | require.Equal(t, hgt, meta.Height) | |
| 233 | + | require.Equal(t, w*4, meta.Stride) | |
| 234 | + | require.Equal(t, 1.0, meta.Scale) | |
| 235 | + | require.Len(t, meta.Targets, len(res.Targets)) | |
| 236 | + | for i, tg := range res.Targets { | |
| 237 | + | require.Equal(t, [4]int{tg.Rect.X, tg.Rect.Y, tg.Rect.X + tg.Rect.W, tg.Rect.Y + tg.Rect.H}, meta.Targets[i].Rect) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | f, err := os.Open(filepath.Join(dir, png)) | |
| 241 | + | require.NoError(t, err) | |
| 242 | + | defer f.Close() | |
| 243 | + | img, _, err := image.Decode(f) | |
| 244 | + | require.NoError(t, err) | |
| 245 | + | require.Equal(t, image.Rect(0, 0, w, hgt), img.Bounds()) | |
| 246 | + | r, g, b, _ := img.At(0, 0).RGBA() | |
| 247 | + | require.Equal(t, uint32(pix[2])*0x101, r) | |
| 248 | + | require.Equal(t, uint32(pix[1])*0x101, g) | |
| 249 | + | require.Equal(t, uint32(pix[0])*0x101, b) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // scanOnceDone sends a request that fails fast on a blank capture and waits | |
| 253 | + | // for its result, which proves the previous dump finished. | |
| 254 | + | func scanOnceDone(t *testing.T, h *harness) ports.HintScanResult { | |
| 255 | + | t.Helper() | |
| 256 | + | go func() { | |
| 257 | + | creq := <-h.captures | |
| 258 | + | h.captured <- ports.CaptureDone{ID: creq.ID, Err: errors.New("stop")} | |
| 259 | + | }() | |
| 260 | + | h.requests <- ports.HintScanRequest{Seq: 99, Output: "x", Width: 8, Height: 8, Scale: 1} | |
| 261 | + | return <-h.results | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestHintDumpDisabled(t *testing.T) { | |
| 265 | + | pix, w, hgt := loadBGRA(t) | |
| 266 | + | dir := filepath.Join(t.TempDir(), "hints") | |
| 267 | + | h := start(t, 5*time.Second) | |
| 268 | + | scanOnce(t, h, pix, w, hgt, 1) | |
| 269 | + | scanOnceDone(t, h) | |
| 270 | + | _, err := os.Stat(dir) | |
| 271 | + | require.ErrorIs(t, err, os.ErrNotExist) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func TestHintDumpRetention(t *testing.T) { | |
| 275 | + | dir := t.TempDir() | |
| 276 | + | for i := 0; i < 60; i++ { | |
| 277 | + | for _, ext := range []string{".png", ".annotated.png", ".json"} { | |
| 278 | + | name := fmt.Sprintf("20260101-000000.%03d-o%s", i, ext) | |
| 279 | + | require.NoError(t, os.WriteFile(filepath.Join(dir, name), nil, 0o600)) | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | require.NoError(t, prune(dir, maxDumps)) | |
| 283 | + | entries, err := os.ReadDir(dir) | |
| 284 | + | require.NoError(t, err) | |
| 285 | + | require.Len(t, entries, maxDumps*3) | |
| 286 | + | require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name()) | |
| 287 | + | } | |
| @@ -2,11 +2,15 @@ package hints | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/json" | |||
| 5 | "errors" | 6 | "errors" | ||
| 7 | + | "fmt" | |||
| 6 | "image" | 8 | "image" | ||
| 7 | "image/draw" | 9 | "image/draw" | ||
| 8 | _ "image/png" | 10 | _ "image/png" | ||
| 9 | "os" | 11 | "os" | ||
| 12 | + | "path/filepath" | |||
| 13 | + | "strings" | |||
| 10 | "testing" | 14 | "testing" | ||
| 11 | "time" | 15 | "time" | ||
| 12 | 16 | ||||
| @@ -23,6 +27,10 @@ type harness struct { | |||||
| 23 | } | 27 | } | ||
| 24 | 28 | ||||
| 25 | func start(t *testing.T, timeout time.Duration) *harness { | 29 | func start(t *testing.T, timeout time.Duration) *harness { | ||
| 30 | + | return startWith(t, timeout, Options{}) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func startWith(t *testing.T, timeout time.Duration, opts Options) *harness { | |||
| 26 | t.Helper() | 34 | t.Helper() | ||
| 27 | h := &harness{ | 35 | h := &harness{ | ||
| 28 | requests: make(chan ports.HintScanRequest, 1), | 36 | requests: make(chan ports.HintScanRequest, 1), | ||
| @@ -34,7 +42,7 @@ func start(t *testing.T, timeout time.Duration) *harness { | |||||
| 34 | exited := make(chan struct{}) | 42 | exited := make(chan struct{}) | ||
| 35 | go func() { | 43 | go func() { | ||
| 36 | defer close(exited) | 44 | defer close(exited) | ||
| 37 | - | _ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout) | 45 | + | _ = run(ctx, h.requests, h.captures, h.captured, h.results, timeout, opts) |
| 38 | }() | 46 | }() | ||
| 39 | t.Cleanup(func() { cancel(); <-exited }) | 47 | t.Cleanup(func() { cancel(); <-exited }) | ||
| 40 | return h | 48 | return h | ||
| @@ -147,3 +155,133 @@ func TestHintBadSize(t *testing.T) { | |||||
| 147 | res := <-h.results | 155 | res := <-h.results | ||
| 148 | require.ErrorIs(t, res.Err, ErrBadRequest) | 156 | require.ErrorIs(t, res.Err, ErrBadRequest) | ||
| 149 | } | 157 | } | ||
| 158 | + | ||||
| 159 | + | func scanOnce(t *testing.T, h *harness, pix []byte, w, hgt int, seq uint64) ports.HintScanResult { | |||
| 160 | + | t.Helper() | |||
| 161 | + | go func() { | |||
| 162 | + | creq := <-h.captures | |||
| 163 | + | copy(creq.Mapped, pix) | |||
| 164 | + | h.captured <- ports.CaptureDone{ID: creq.ID, Output: creq.Output} | |||
| 165 | + | }() | |||
| 166 | + | h.requests <- ports.HintScanRequest{Seq: seq, Output: "HEADLESS-1", Width: w, Height: hgt, Scale: 1} | |||
| 167 | + | res := <-h.results | |||
| 168 | + | require.NoError(t, res.Err) | |||
| 169 | + | return res | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // waitFiles waits for the dump the worker writes after sending the result. | |||
| 173 | + | func waitFiles(t *testing.T, dir string, n int) []os.DirEntry { | |||
| 174 | + | t.Helper() | |||
| 175 | + | var entries []os.DirEntry | |||
| 176 | + | require.Eventually(t, func() bool { | |||
| 177 | + | entries, _ = os.ReadDir(dir) | |||
| 178 | + | return len(entries) >= n | |||
| 179 | + | }, 5*time.Second, 10*time.Millisecond) | |||
| 180 | + | return entries | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestHintDump(t *testing.T) { | |||
| 184 | + | pix, w, hgt := loadBGRA(t) | |||
| 185 | + | dir := filepath.Join(t.TempDir(), "hints") | |||
| 186 | + | h := startWith(t, 5*time.Second, Options{DumpDir: dir}) | |||
| 187 | + | res := scanOnce(t, h, pix, w, hgt, 1) | |||
| 188 | + | require.NotEmpty(t, res.Targets) | |||
| 189 | + | ||||
| 190 | + | entries := waitFiles(t, dir, 3) | |||
| 191 | + | // A second request is only served once the dump is written. | |||
| 192 | + | _ = scanOnceDone(t, h) | |||
| 193 | + | var png, ann, js string | |||
| 194 | + | for _, e := range entries { | |||
| 195 | + | info, err := e.Info() | |||
| 196 | + | require.NoError(t, err) | |||
| 197 | + | require.Equal(t, os.FileMode(0o600), info.Mode().Perm()) | |||
| 198 | + | switch { | |||
| 199 | + | case strings.HasSuffix(e.Name(), ".annotated.png"): | |||
| 200 | + | ann = e.Name() | |||
| 201 | + | case strings.HasSuffix(e.Name(), ".png"): | |||
| 202 | + | png = e.Name() | |||
| 203 | + | case strings.HasSuffix(e.Name(), ".json"): | |||
| 204 | + | js = e.Name() | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | require.NotEmpty(t, png) | |||
| 208 | + | require.NotEmpty(t, ann) | |||
| 209 | + | require.Regexp(t, `^\d{8}-\d{6}\.\d{3}-HEADLESS-1\.json$`, js) | |||
| 210 | + | ||||
| 211 | + | st, err := os.Stat(dir) | |||
| 212 | + | require.NoError(t, err) | |||
| 213 | + | require.Equal(t, os.FileMode(0o700), st.Mode().Perm()) | |||
| 214 | + | ||||
| 215 | + | data, err := os.ReadFile(filepath.Join(dir, js)) | |||
| 216 | + | require.NoError(t, err) | |||
| 217 | + | var meta struct { | |||
| 218 | + | Output string `json:"output"` | |||
| 219 | + | Width int `json:"width"` | |||
| 220 | + | Height int `json:"height"` | |||
| 221 | + | Stride int `json:"stride"` | |||
| 222 | + | Scale float64 `json:"scale"` | |||
| 223 | + | LinksOnly bool `json:"links_only"` | |||
| 224 | + | Targets []struct { | |||
| 225 | + | Kind string `json:"kind"` | |||
| 226 | + | Rect [4]int `json:"rect"` | |||
| 227 | + | } `json:"targets"` | |||
| 228 | + | } | |||
| 229 | + | require.NoError(t, json.Unmarshal(data, &meta)) | |||
| 230 | + | require.Equal(t, "HEADLESS-1", meta.Output) | |||
| 231 | + | require.Equal(t, w, meta.Width) | |||
| 232 | + | require.Equal(t, hgt, meta.Height) | |||
| 233 | + | require.Equal(t, w*4, meta.Stride) | |||
| 234 | + | require.Equal(t, 1.0, meta.Scale) | |||
| 235 | + | require.Len(t, meta.Targets, len(res.Targets)) | |||
| 236 | + | for i, tg := range res.Targets { | |||
| 237 | + | require.Equal(t, [4]int{tg.Rect.X, tg.Rect.Y, tg.Rect.X + tg.Rect.W, tg.Rect.Y + tg.Rect.H}, meta.Targets[i].Rect) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | f, err := os.Open(filepath.Join(dir, png)) | |||
| 241 | + | require.NoError(t, err) | |||
| 242 | + | defer f.Close() | |||
| 243 | + | img, _, err := image.Decode(f) | |||
| 244 | + | require.NoError(t, err) | |||
| 245 | + | require.Equal(t, image.Rect(0, 0, w, hgt), img.Bounds()) | |||
| 246 | + | r, g, b, _ := img.At(0, 0).RGBA() | |||
| 247 | + | require.Equal(t, uint32(pix[2])*0x101, r) | |||
| 248 | + | require.Equal(t, uint32(pix[1])*0x101, g) | |||
| 249 | + | require.Equal(t, uint32(pix[0])*0x101, b) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // scanOnceDone sends a request that fails fast on a blank capture and waits | |||
| 253 | + | // for its result, which proves the previous dump finished. | |||
| 254 | + | func scanOnceDone(t *testing.T, h *harness) ports.HintScanResult { | |||
| 255 | + | t.Helper() | |||
| 256 | + | go func() { | |||
| 257 | + | creq := <-h.captures | |||
| 258 | + | h.captured <- ports.CaptureDone{ID: creq.ID, Err: errors.New("stop")} | |||
| 259 | + | }() | |||
| 260 | + | h.requests <- ports.HintScanRequest{Seq: 99, Output: "x", Width: 8, Height: 8, Scale: 1} | |||
| 261 | + | return <-h.results | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestHintDumpDisabled(t *testing.T) { | |||
| 265 | + | pix, w, hgt := loadBGRA(t) | |||
| 266 | + | dir := filepath.Join(t.TempDir(), "hints") | |||
| 267 | + | h := start(t, 5*time.Second) | |||
| 268 | + | scanOnce(t, h, pix, w, hgt, 1) | |||
| 269 | + | scanOnceDone(t, h) | |||
| 270 | + | _, err := os.Stat(dir) | |||
| 271 | + | require.ErrorIs(t, err, os.ErrNotExist) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func TestHintDumpRetention(t *testing.T) { | |||
| 275 | + | dir := t.TempDir() | |||
| 276 | + | for i := 0; i < 60; i++ { | |||
| 277 | + | for _, ext := range []string{".png", ".annotated.png", ".json"} { | |||
| 278 | + | name := fmt.Sprintf("20260101-000000.%03d-o%s", i, ext) | |||
| 279 | + | require.NoError(t, os.WriteFile(filepath.Join(dir, name), nil, 0o600)) | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | require.NoError(t, prune(dir, maxDumps)) | |||
| 283 | + | entries, err := os.ReadDir(dir) | |||
| 284 | + | require.NoError(t, err) | |||
| 285 | + | require.Len(t, entries, maxDumps*3) | |||
| 286 | + | require.Equal(t, "20260101-000000.010-o.annotated.png", entries[0].Name()) | |||
| 287 | + | } | |||
A internal/app/hintdebug.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/hints" | |
| 9 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // hintOptions reads NEFERWL_HINT_DEBUG once. When it is "1", scanned frames | |
| 13 | + | // are written under $XDG_STATE_HOME/neferwl/hints. | |
| 14 | + | func hintOptions(ctx context.Context) hints.Options { | |
| 15 | + | if os.Getenv("NEFERWL_HINT_DEBUG") != "1" { | |
| 16 | + | return hints.Options{} | |
| 17 | + | } | |
| 18 | + | log := logging.For(ctx, "hints") | |
| 19 | + | state := os.Getenv("XDG_STATE_HOME") | |
| 20 | + | if state == "" { | |
| 21 | + | home, err := os.UserHomeDir() | |
| 22 | + | if err != nil { | |
| 23 | + | log.Warn().Err(err).Msg("hint debug dumps disabled") | |
| 24 | + | return hints.Options{} | |
| 25 | + | } | |
| 26 | + | state = filepath.Join(home, ".local", "state") | |
| 27 | + | } | |
| 28 | + | dir := filepath.Join(state, "neferwl", "hints") | |
| 29 | + | log.Warn().Str("dir", dir).Msg("hint debug dumps enabled") | |
| 30 | + | return hints.Options{DumpDir: dir} | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/adapters/hints" | |||
| 9 | + | "github.com/bnema/neferwl/internal/adapters/logging" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // hintOptions reads NEFERWL_HINT_DEBUG once. When it is "1", scanned frames | |||
| 13 | + | // are written under $XDG_STATE_HOME/neferwl/hints. | |||
| 14 | + | func hintOptions(ctx context.Context) hints.Options { | |||
| 15 | + | if os.Getenv("NEFERWL_HINT_DEBUG") != "1" { | |||
| 16 | + | return hints.Options{} | |||
| 17 | + | } | |||
| 18 | + | log := logging.For(ctx, "hints") | |||
| 19 | + | state := os.Getenv("XDG_STATE_HOME") | |||
| 20 | + | if state == "" { | |||
| 21 | + | home, err := os.UserHomeDir() | |||
| 22 | + | if err != nil { | |||
| 23 | + | log.Warn().Err(err).Msg("hint debug dumps disabled") | |||
| 24 | + | return hints.Options{} | |||
| 25 | + | } | |||
| 26 | + | state = filepath.Join(home, ".local", "state") | |||
| 27 | + | } | |||
| 28 | + | dir := filepath.Join(state, "neferwl", "hints") | |||
| 29 | + | log.Warn().Str("dir", dir).Msg("hint debug dumps enabled") | |||
| 30 | + | return hints.Options{DumpDir: dir} | |||
| 31 | + | } | |||
M internal/app/session.go +3 -1
| @@ -52,6 +52,7 @@ type session struct { | |||
| 52 | 52 | km *xkb.Keymap | |
| 53 | 53 | core *core.Core | |
| 54 | 54 | dmabuf ports.DMABufSupport | |
| 55 | + | hintOpts hints.Options | |
| 55 | 56 | wsIDs *workspaceid.IDs | |
| 56 | 57 | captureAllow *captureallow.Store | |
| 57 | 58 | cast *screencast.Service // nil without the D-Bus services | |
| @@ -101,6 +102,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||
| 101 | 102 | return s, err | |
| 102 | 103 | } | |
| 103 | 104 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | |
| 105 | + | s.hintOpts = hintOptions(ctx) | |
| 104 | 106 | // Every output renders on the same GPU: its formats are the clients'. | |
| 105 | 107 | s.dmabuf = vulkan.Probe() | |
| 106 | 108 | log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf") | |
| @@ -347,7 +349,7 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) | |||
| 347 | 349 | go func() { | |
| 348 | 350 | defer workers.Done() | |
| 349 | 351 | // Run only returns when ctx is done: nothing to report. | |
| 350 | - | _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults) | |
| 352 | + | _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts) | |
| 351 | 353 | }() | |
| 352 | 354 | go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }() | |
| 353 | 355 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | |
| @@ -52,6 +52,7 @@ type session struct { | |||||
| 52 | km *xkb.Keymap | 52 | km *xkb.Keymap | ||
| 53 | core *core.Core | 53 | core *core.Core | ||
| 54 | dmabuf ports.DMABufSupport | 54 | dmabuf ports.DMABufSupport | ||
| 55 | + | hintOpts hints.Options | |||
| 55 | wsIDs *workspaceid.IDs | 56 | wsIDs *workspaceid.IDs | ||
| 56 | captureAllow *captureallow.Store | 57 | captureAllow *captureallow.Store | ||
| 57 | cast *screencast.Service // nil without the D-Bus services | 58 | cast *screencast.Service // nil without the D-Bus services | ||
| @@ -101,6 +102,7 @@ func assembleSession(ctx context.Context, opts Options) (s *session, err error) | |||||
| 101 | return s, err | 102 | return s, err | ||
| 102 | } | 103 | } | ||
| 103 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | 104 | runtimeDir := os.Getenv("XDG_RUNTIME_DIR") | ||
| 105 | + | s.hintOpts = hintOptions(ctx) | |||
| 104 | // Every output renders on the same GPU: its formats are the clients'. | 106 | // Every output renders on the same GPU: its formats are the clients'. | ||
| 105 | s.dmabuf = vulkan.Probe() | 107 | s.dmabuf = vulkan.Probe() | ||
| 106 | log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf") | 108 | log.Info().Int("formats", len(s.dmabuf.Formats)).Msg("dmabuf") | ||
| @@ -347,7 +349,7 @@ func (s *session) run(ctx context.Context, inject func(chan<- ports.InputEvent)) | |||||
| 347 | go func() { | 349 | go func() { | ||
| 348 | defer workers.Done() | 350 | defer workers.Done() | ||
| 349 | // Run only returns when ctx is done: nothing to report. | 351 | // Run only returns when ctx is done: nothing to report. | ||
| 350 | - | _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults) | 352 | + | _ = hints.Run(ctx, p.hintScans, p.captures, p.hintCaptured, p.hintResults, s.hintOpts) |
| 351 | }() | 353 | }() | ||
| 352 | go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }() | 354 | go func() { defer workers.Done(); done <- child.Run(ctx, p.spawn) }() | ||
| 353 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | 355 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | ||