detached e35e6e3

Merge 95445f0186132715bbf114dff5a60482daa8920e into c64260b018f2a81c31292054ab41102b41d60410

commit
e35e6e384be6c2cd1e10460372fff24fa1aff615
author
ermite · · 2026-10-22 04:55 UTC
merge of
c64260b + 95445f0 · diff against the first parent
signature
unsigned
13 files · +492 -80
M docs/dev/headless.md +9 -5
@@ -7,7 +7,7 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots
77 WAYLAND_DISPLAY=<name from the log> foot
88 ```
99
10-- `latest.png` shows the current frame; `frames.ffconcat` records frame timing. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows.
10+- `latest.png` shows the current frame; `frames.ffconcat` records frame timing. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. `latest.png` is a hard link to the newest frame, so replace it rather than editing it in place.
1111 - `--size 640x360,640x360` creates two outputs, arranged left to right,
1212 for testing `focus-monitor-left/right` and moving columns between outputs.
1313 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.
@@ -25,10 +25,14 @@ ffmpeg -f concat -safe 0 -i /tmp/shots/frames.ffconcat -vf "fps=60,format=yuv420
2525 For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters.
2626
2727 ### Demo recording
28-Run `tools/demo/record.sh` with Go 1.27, rootless Docker and ffmpeg installed.
29-It builds an Arch app image and the host compositor, then records the 1080p README demo.
30-Apps run in a disposable, offline container; only the headless Wayland socket is mounted.
31-A FIFO synchronizes `input.txt` with app launches after container startup.
28+Run `tools/demo/record.sh`. It requires Go 1.27, rootless Docker, ffmpeg, network access and an AMD GPU.
29+It builds the Arch image (apps, tesseract, AT-SPI) and the compositor binary, then records the 1080p README demo.
30+The compositor runs headless inside a disposable container, with the binary and a work directory bind-mounted and `/dev/dri` passed for the GPU.
31+The image only carries Mesa's radeon Vulkan driver, and hint scans need the sync files that a hardware driver exports; the script stops early without an AMD render node.
32+Firefox opens a live DuckDuckGo page, so the recording needs the network and its content varies.
33+A FIFO feeds `input.txt` while the script launches the apps on the same clock.
34+The hint step makes Firefox fullscreen and labels the page with `hint`; `hint-click <words>` then types the label of the link that shows those words, found from the scan's dump and an OCR read of the still page (`hint-read`).
35+The recording only counts if the compositor stops on the quit key that ends `input.txt`; reaching its `--timeout` first fails the run as truncated.
3236 Temporary captures are removed by default; set `KEEP_CAPTURE=1` to keep successful runs.
3337 The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`.
3438
@@ -7,7 +7,7 @@ neferwl --backend=headless --screenshot /tmp/neferwl-shots
7 WAYLAND_DISPLAY=<name from the log> foot7 WAYLAND_DISPLAY=<name from the log> foot
8 ```8 ```
9 9
10-- `latest.png` shows the current frame; `frames.ffconcat` records frame timing. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows.10+- `latest.png` shows the current frame; `frames.ffconcat` records frame timing. Every frame is also saved as `frame-NNNNNN.png`, so the directory grows. `latest.png` is a hard link to the newest frame, so replace it rather than editing it in place.
11 - `--size 640x360,640x360` creates two outputs, arranged left to right,11 - `--size 640x360,640x360` creates two outputs, arranged left to right,
12 for testing `focus-monitor-left/right` and moving columns between outputs.12 for testing `focus-monitor-left/right` and moving columns between outputs.
13 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.13 - With several outputs, each writes to `<dir>/HEADLESS-<n>/`.
@@ -25,10 +25,14 @@ ffmpeg -f concat -safe 0 -i /tmp/shots/frames.ffconcat -vf "fps=60,format=yuv420
25 For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters.25 For a GIF, convert `demo.mp4` with ffmpeg's `palettegen` and `paletteuse` filters.
26 26
27 ### Demo recording27 ### Demo recording
28-Run `tools/demo/record.sh` with Go 1.27, rootless Docker and ffmpeg installed.28+Run `tools/demo/record.sh`. It requires Go 1.27, rootless Docker, ffmpeg, network access and an AMD GPU.
29-It builds an Arch app image and the host compositor, then records the 1080p README demo.29+It builds the Arch image (apps, tesseract, AT-SPI) and the compositor binary, then records the 1080p README demo.
30-Apps run in a disposable, offline container; only the headless Wayland socket is mounted.30+The compositor runs headless inside a disposable container, with the binary and a work directory bind-mounted and `/dev/dri` passed for the GPU.
31-A FIFO synchronizes `input.txt` with app launches after container startup.31+The image only carries Mesa's radeon Vulkan driver, and hint scans need the sync files that a hardware driver exports; the script stops early without an AMD render node.
32+Firefox opens a live DuckDuckGo page, so the recording needs the network and its content varies.
33+A FIFO feeds `input.txt` while the script launches the apps on the same clock.
34+The hint step makes Firefox fullscreen and labels the page with `hint`; `hint-click <words>` then types the label of the link that shows those words, found from the scan's dump and an OCR read of the still page (`hint-read`).
35+The recording only counts if the compositor stops on the quit key that ends `input.txt`; reaching its `--timeout` first fails the run as truncated.
32 Temporary captures are removed by default; set `KEEP_CAPTURE=1` to keep successful runs.36 Temporary captures are removed by default; set `KEEP_CAPTURE=1` to keep successful runs.
33 The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`.37 The recording and poster replace `docs/media/demo.mp4` and `docs/media/demo.png`.
34 38
M docs/media/demo.mp4 +0 -0 binary file: no text diff

binary file: no text diff

M docs/media/demo.png +0 -0 binary file: no text diff

binary file: no text diff

M internal/adapters/headless/output.go +53 -4
@@ -8,6 +8,7 @@ import (
88 "image/png"
99 "os"
1010 "path/filepath"
11+ "sync"
1112 "time"
1213
1314 "golang.org/x/sys/unix"
@@ -583,7 +584,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
583584 if !written {
584585 continue
585586 }
586- written, err = writePNGSecureResult(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security)
587+ // latest.png is the frame just written, linked rather than encoded again.
588+ written, err = linkPNGSecure(filepath.Join(opts.ScreenshotDir, name), filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security)
587589 if err != nil {
588590 return fmt.Errorf("latest screenshot: %w", err)
589591 }
@@ -640,9 +642,31 @@ func writePNGSecure(path string, img image.Image, security ports.SessionSecurity
640642 return err
641643 }
642644
645+// screenshotPNG encodes the PNGs written by the headless output (frames, the
646+// latest.png fallback, raw HDR latest-pq.png): encoding is by far the largest
647+// cost of a headless frame, so it trades file size for speed. Its buffers are
648+// reused between files.
649+var screenshotPNG = png.Encoder{CompressionLevel: png.BestSpeed, BufferPool: new(pngBuffers)}
650+
651+// pngBuffers is a png.EncoderBufferPool.
652+type pngBuffers struct{ pool sync.Pool }
653+
654+func (p *pngBuffers) Get() *png.EncoderBuffer {
655+ b, _ := p.pool.Get().(*png.EncoderBuffer)
656+ return b
657+}
658+
659+func (p *pngBuffers) Put(b *png.EncoderBuffer) { p.pool.Put(b) }
660+
661+// gated reports whether a screenshot must not be published: the session is
662+// protected, or its security state is no longer the one the frame was drawn in.
663+func gated(security ports.SessionSecurity, state ports.SecurityState) bool {
664+ return security != nil && (state.Protected || security.Snapshot() != state)
665+}
666+
643667 // writePNGSecureResult distinguishes a security skip from a published PNG.
644668 func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) {
645- if security != nil && (state.Protected || security.Snapshot() != state) {
669+ if gated(security, state) {
646670 return false, nil
647671 }
648672 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
@@ -650,14 +674,14 @@ func writePNGSecureResult(path string, img image.Image, security ports.SessionSe
650674 return false, err
651675 }
652676 defer os.Remove(f.Name())
653- if err := png.Encode(f, img); err != nil {
677+ if err := screenshotPNG.Encode(f, img); err != nil {
654678 _ = f.Close()
655679 return false, err
656680 }
657681 if err := f.Close(); err != nil {
658682 return false, err
659683 }
660- if security != nil && security.Snapshot() != state {
684+ if gated(security, state) {
661685 return false, nil
662686 }
663687 if err := os.Rename(f.Name(), path); err != nil {
@@ -666,6 +690,31 @@ func writePNGSecureResult(path string, img image.Image, security ports.SessionSe
666690 return true, nil
667691 }
668692
693+// linkPNGSecure publishes the PNG already written at src as dst without
694+// encoding it again: a hard link under a temporary name, renamed over dst, so
695+// readers see the old or the new frame, never a partial one. Frame files are
696+// never modified after they are published, which makes the shared file safe.
697+// It gates on security like writePNGSecureResult, and falls back to encoding
698+// img where the file system cannot link.
699+func linkPNGSecure(src, dst string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) {
700+ if gated(security, state) {
701+ return false, nil
702+ }
703+ tmp := dst + ".tmp"
704+ _ = os.Remove(tmp)
705+ if err := os.Link(src, tmp); err != nil {
706+ return writePNGSecureResult(dst, img, security, state)
707+ }
708+ defer os.Remove(tmp)
709+ if gated(security, state) {
710+ return false, nil
711+ }
712+ if err := os.Rename(tmp, dst); err != nil {
713+ return false, err
714+ }
715+ return true, nil
716+}
717+
669718 // report queues what the output read, with flip for a drawn frame, and
670719 // pushes it to the sink.
671720 func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
@@ -8,6 +8,7 @@ import (
8 "image/png"8 "image/png"
9 "os"9 "os"
10 "path/filepath"10 "path/filepath"
11+ "sync"
11 "time"12 "time"
12 13
13 "golang.org/x/sys/unix"14 "golang.org/x/sys/unix"
@@ -583,7 +584,8 @@ func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents
583 if !written {584 if !written {
584 continue585 continue
585 }586 }
586- written, err = writePNGSecureResult(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security)587+ // latest.png is the frame just written, linked rather than encoded again.
588+ written, err = linkPNGSecure(filepath.Join(opts.ScreenshotDir, name), filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security)
587 if err != nil {589 if err != nil {
588 return fmt.Errorf("latest screenshot: %w", err)590 return fmt.Errorf("latest screenshot: %w", err)
589 }591 }
@@ -640,9 +642,31 @@ func writePNGSecure(path string, img image.Image, security ports.SessionSecurity
640 return err642 return err
641 }643 }
642 644
645+// screenshotPNG encodes the PNGs written by the headless output (frames, the
646+// latest.png fallback, raw HDR latest-pq.png): encoding is by far the largest
647+// cost of a headless frame, so it trades file size for speed. Its buffers are
648+// reused between files.
649+var screenshotPNG = png.Encoder{CompressionLevel: png.BestSpeed, BufferPool: new(pngBuffers)}
650+
651+// pngBuffers is a png.EncoderBufferPool.
652+type pngBuffers struct{ pool sync.Pool }
653+
654+func (p *pngBuffers) Get() *png.EncoderBuffer {
655+ b, _ := p.pool.Get().(*png.EncoderBuffer)
656+ return b
657+}
658+
659+func (p *pngBuffers) Put(b *png.EncoderBuffer) { p.pool.Put(b) }
660+
661+// gated reports whether a screenshot must not be published: the session is
662+// protected, or its security state is no longer the one the frame was drawn in.
663+func gated(security ports.SessionSecurity, state ports.SecurityState) bool {
664+ return security != nil && (state.Protected || security.Snapshot() != state)
665+}
666+
643 // writePNGSecureResult distinguishes a security skip from a published PNG.667 // writePNGSecureResult distinguishes a security skip from a published PNG.
644 func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) {668 func writePNGSecureResult(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) {
645- if security != nil && (state.Protected || security.Snapshot() != state) {669+ if gated(security, state) {
646 return false, nil670 return false, nil
647 }671 }
648 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")672 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
@@ -650,14 +674,14 @@ func writePNGSecureResult(path string, img image.Image, security ports.SessionSe
650 return false, err674 return false, err
651 }675 }
652 defer os.Remove(f.Name())676 defer os.Remove(f.Name())
653- if err := png.Encode(f, img); err != nil {677+ if err := screenshotPNG.Encode(f, img); err != nil {
654 _ = f.Close()678 _ = f.Close()
655 return false, err679 return false, err
656 }680 }
657 if err := f.Close(); err != nil {681 if err := f.Close(); err != nil {
658 return false, err682 return false, err
659 }683 }
660- if security != nil && security.Snapshot() != state {684+ if gated(security, state) {
661 return false, nil685 return false, nil
662 }686 }
663 if err := os.Rename(f.Name(), path); err != nil {687 if err := os.Rename(f.Name(), path); err != nil {
@@ -666,6 +690,31 @@ func writePNGSecureResult(path string, img image.Image, security ports.SessionSe
666 return true, nil690 return true, nil
667 }691 }
668 692
693+// linkPNGSecure publishes the PNG already written at src as dst without
694+// encoding it again: a hard link under a temporary name, renamed over dst, so
695+// readers see the old or the new frame, never a partial one. Frame files are
696+// never modified after they are published, which makes the shared file safe.
697+// It gates on security like writePNGSecureResult, and falls back to encoding
698+// img where the file system cannot link.
699+func linkPNGSecure(src, dst string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) (bool, error) {
700+ if gated(security, state) {
701+ return false, nil
702+ }
703+ tmp := dst + ".tmp"
704+ _ = os.Remove(tmp)
705+ if err := os.Link(src, tmp); err != nil {
706+ return writePNGSecureResult(dst, img, security, state)
707+ }
708+ defer os.Remove(tmp)
709+ if gated(security, state) {
710+ return false, nil
711+ }
712+ if err := os.Rename(tmp, dst); err != nil {
713+ return false, err
714+ }
715+ return true, nil
716+}
717+
669 // report queues what the output read, with flip for a drawn frame, and718 // report queues what the output read, with flip for a drawn frame, and
670 // pushes it to the sink.719 // pushes it to the sink.
671 func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {720 func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
M internal/adapters/headless/output_test.go +171 -0
@@ -14,6 +14,7 @@ import (
1414 "strconv"
1515 "strings"
1616 "sync"
17+ "syscall"
1718 "testing"
1819 "time"
1920
@@ -269,6 +270,176 @@ func TestRunTrimsRendererOnTick(t *testing.T) {
269270 }
270271 }
271272
273+// latest.png is the frame file linked, not encoded again: the same pixels,
274+// published atomically, and nothing left behind.
275+func TestLinkPNGSecure(t *testing.T) {
276+ dir := t.TempDir()
277+ img := image.NewRGBA(image.Rect(0, 0, 3, 2))
278+ for i := range img.Pix {
279+ img.Pix[i] = byte(i * 7)
280+ }
281+ frame, latest := filepath.Join(dir, "frame-000001.png"), filepath.Join(dir, "latest.png")
282+ if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
283+ t.Fatal(err)
284+ }
285+ // An older latest.png is replaced.
286+ if err := os.WriteFile(latest, []byte("old"), 0o600); err != nil {
287+ t.Fatal(err)
288+ }
289+ if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err != nil || !written {
290+ t.Fatalf("link = %v, %v", written, err)
291+ }
292+ a, err := os.ReadFile(frame)
293+ if err != nil {
294+ t.Fatal(err)
295+ }
296+ b, err := os.ReadFile(latest)
297+ if err != nil {
298+ t.Fatal(err)
299+ }
300+ if !bytes.Equal(a, b) {
301+ t.Fatal("latest.png differs from the frame")
302+ }
303+ fi, err := os.Stat(frame)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ li, err := os.Stat(latest)
308+ if err != nil {
309+ t.Fatal(err)
310+ }
311+ if !os.SameFile(fi, li) {
312+ t.Fatal("latest.png is a copy, not a hard link to the frame")
313+ }
314+ got, err := png.Decode(bytes.NewReader(b))
315+ if err != nil {
316+ t.Fatal(err)
317+ }
318+ for y := range 2 {
319+ for x := range 3 {
320+ if g, w := color.RGBAModel.Convert(got.At(x, y)), img.RGBAAt(x, y); g != color.Color(w) {
321+ t.Errorf("(%d,%d) = %v, want %v", x, y, g, w)
322+ }
323+ }
324+ }
325+ entries, err := os.ReadDir(dir)
326+ if err != nil {
327+ t.Fatal(err)
328+ }
329+ if len(entries) != 2 {
330+ t.Fatalf("leftover files: %v", entries)
331+ }
332+}
333+
334+// A frame that crossed a security change is not published as latest.png, a
335+// failed rename leaves no temporary file, and a source that cannot be linked
336+// is encoded instead.
337+func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) {
338+ img := image.NewRGBA(image.Rect(0, 0, 1, 1))
339+ good := ports.SecurityState{Generation: 1}
340+ // frameIn writes the frame to link and returns its directory and path.
341+ frameIn := func(t *testing.T) (string, string) {
342+ t.Helper()
343+ dir := t.TempDir()
344+ frame := filepath.Join(dir, "frame-000001.png")
345+ if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
346+ t.Fatal(err)
347+ }
348+ return dir, frame
349+ }
350+ // onlyFrame fails unless the frame is all the directory holds: no
351+ // latest.png and no latest.png.tmp.
352+ onlyFrame := func(t *testing.T, dir string) {
353+ t.Helper()
354+ if entries, _ := os.ReadDir(dir); len(entries) != 1 {
355+ t.Fatalf("latest.png or a temp file delivered: %v", entries)
356+ }
357+ }
358+ t.Run("protected state", func(t *testing.T) {
359+ dir, frame := frameIn(t)
360+ // A protected state is refused without reading the live snapshot.
361+ gate := portsmocks.NewMockSessionSecurity(t)
362+ if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, ports.SecurityState{Generation: 1, Protected: true}); err != nil || written {
363+ t.Fatalf("link = %v, %v", written, err)
364+ }
365+ onlyFrame(t, dir)
366+ })
367+ t.Run("protected snapshot", func(t *testing.T) {
368+ dir, frame := frameIn(t)
369+ gate := portsmocks.NewMockSessionSecurity(t)
370+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
371+ if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
372+ t.Fatalf("link = %v, %v", written, err)
373+ }
374+ onlyFrame(t, dir)
375+ })
376+ t.Run("changed after the link", func(t *testing.T) {
377+ dir, frame := frameIn(t)
378+ gate := portsmocks.NewMockSessionSecurity(t)
379+ gate.EXPECT().Snapshot().Return(good).Once()
380+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once()
381+ if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
382+ t.Fatalf("link = %v, %v", written, err)
383+ }
384+ onlyFrame(t, dir)
385+ })
386+ t.Run("rename failure", func(t *testing.T) {
387+ dir, frame := frameIn(t)
388+ // A non-empty directory cannot be replaced by a file.
389+ latest := filepath.Join(dir, "latest.png")
390+ if err := os.MkdirAll(filepath.Join(latest, "keep"), 0o700); err != nil {
391+ t.Fatal(err)
392+ }
393+ if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err == nil || written {
394+ t.Fatalf("link = %v, %v; want an error", written, err)
395+ }
396+ if _, err := os.Stat(latest + ".tmp"); !os.IsNotExist(err) {
397+ t.Fatalf("temporary link left behind: %v", err)
398+ }
399+ if _, err := os.Stat(filepath.Join(latest, "keep")); err != nil {
400+ t.Fatalf("existing directory touched: %v", err)
401+ }
402+ })
403+ t.Run("no source file", func(t *testing.T) {
404+ dir := t.TempDir()
405+ latest := filepath.Join(dir, "latest.png")
406+ if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written {
407+ t.Fatalf("link = %v, %v", written, err)
408+ }
409+ fi, err := os.Stat(latest)
410+ if err != nil {
411+ t.Fatal(err)
412+ }
413+ if !fi.Mode().IsRegular() {
414+ t.Fatalf("latest.png is %v", fi.Mode())
415+ }
416+ f, err := os.Open(latest)
417+ if err != nil {
418+ t.Fatal(err)
419+ }
420+ defer f.Close()
421+ if _, err := png.Decode(f); err != nil {
422+ t.Fatal(err)
423+ }
424+ // Encoded on its own: nothing else in the directory shares it.
425+ if entries, _ := os.ReadDir(dir); len(entries) != 1 {
426+ t.Fatalf("unexpected files: %v", entries)
427+ }
428+ if n, ok := linkCount(fi); ok && n != 1 {
429+ t.Fatalf("latest.png has %d links, want 1", n)
430+ }
431+ })
432+}
433+
434+// linkCount returns the hard link count of fi where the platform reports it.
435+func linkCount(fi os.FileInfo) (uint64, bool) {
436+ st, ok := fi.Sys().(*syscall.Stat_t)
437+ if !ok {
438+ return 0, false
439+ }
440+ return uint64(st.Nlink), true
441+}
442+
272443 func TestWritePNGAtomic(t *testing.T) {
273444 dir := t.TempDir()
274445 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
@@ -14,6 +14,7 @@ import (
14 "strconv"14 "strconv"
15 "strings"15 "strings"
16 "sync"16 "sync"
17+ "syscall"
17 "testing"18 "testing"
18 "time"19 "time"
19 20
@@ -269,6 +270,176 @@ func TestRunTrimsRendererOnTick(t *testing.T) {
269 }270 }
270 }271 }
271 272
273+// latest.png is the frame file linked, not encoded again: the same pixels,
274+// published atomically, and nothing left behind.
275+func TestLinkPNGSecure(t *testing.T) {
276+ dir := t.TempDir()
277+ img := image.NewRGBA(image.Rect(0, 0, 3, 2))
278+ for i := range img.Pix {
279+ img.Pix[i] = byte(i * 7)
280+ }
281+ frame, latest := filepath.Join(dir, "frame-000001.png"), filepath.Join(dir, "latest.png")
282+ if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
283+ t.Fatal(err)
284+ }
285+ // An older latest.png is replaced.
286+ if err := os.WriteFile(latest, []byte("old"), 0o600); err != nil {
287+ t.Fatal(err)
288+ }
289+ if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err != nil || !written {
290+ t.Fatalf("link = %v, %v", written, err)
291+ }
292+ a, err := os.ReadFile(frame)
293+ if err != nil {
294+ t.Fatal(err)
295+ }
296+ b, err := os.ReadFile(latest)
297+ if err != nil {
298+ t.Fatal(err)
299+ }
300+ if !bytes.Equal(a, b) {
301+ t.Fatal("latest.png differs from the frame")
302+ }
303+ fi, err := os.Stat(frame)
304+ if err != nil {
305+ t.Fatal(err)
306+ }
307+ li, err := os.Stat(latest)
308+ if err != nil {
309+ t.Fatal(err)
310+ }
311+ if !os.SameFile(fi, li) {
312+ t.Fatal("latest.png is a copy, not a hard link to the frame")
313+ }
314+ got, err := png.Decode(bytes.NewReader(b))
315+ if err != nil {
316+ t.Fatal(err)
317+ }
318+ for y := range 2 {
319+ for x := range 3 {
320+ if g, w := color.RGBAModel.Convert(got.At(x, y)), img.RGBAAt(x, y); g != color.Color(w) {
321+ t.Errorf("(%d,%d) = %v, want %v", x, y, g, w)
322+ }
323+ }
324+ }
325+ entries, err := os.ReadDir(dir)
326+ if err != nil {
327+ t.Fatal(err)
328+ }
329+ if len(entries) != 2 {
330+ t.Fatalf("leftover files: %v", entries)
331+ }
332+}
333+
334+// A frame that crossed a security change is not published as latest.png, a
335+// failed rename leaves no temporary file, and a source that cannot be linked
336+// is encoded instead.
337+func TestLinkPNGSecureSkipsAndFallsBack(t *testing.T) {
338+ img := image.NewRGBA(image.Rect(0, 0, 1, 1))
339+ good := ports.SecurityState{Generation: 1}
340+ // frameIn writes the frame to link and returns its directory and path.
341+ frameIn := func(t *testing.T) (string, string) {
342+ t.Helper()
343+ dir := t.TempDir()
344+ frame := filepath.Join(dir, "frame-000001.png")
345+ if err := writePNGSecure(frame, img, nil, ports.SecurityState{}); err != nil {
346+ t.Fatal(err)
347+ }
348+ return dir, frame
349+ }
350+ // onlyFrame fails unless the frame is all the directory holds: no
351+ // latest.png and no latest.png.tmp.
352+ onlyFrame := func(t *testing.T, dir string) {
353+ t.Helper()
354+ if entries, _ := os.ReadDir(dir); len(entries) != 1 {
355+ t.Fatalf("latest.png or a temp file delivered: %v", entries)
356+ }
357+ }
358+ t.Run("protected state", func(t *testing.T) {
359+ dir, frame := frameIn(t)
360+ // A protected state is refused without reading the live snapshot.
361+ gate := portsmocks.NewMockSessionSecurity(t)
362+ if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, ports.SecurityState{Generation: 1, Protected: true}); err != nil || written {
363+ t.Fatalf("link = %v, %v", written, err)
364+ }
365+ onlyFrame(t, dir)
366+ })
367+ t.Run("protected snapshot", func(t *testing.T) {
368+ dir, frame := frameIn(t)
369+ gate := portsmocks.NewMockSessionSecurity(t)
370+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 1, Protected: true})
371+ if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
372+ t.Fatalf("link = %v, %v", written, err)
373+ }
374+ onlyFrame(t, dir)
375+ })
376+ t.Run("changed after the link", func(t *testing.T) {
377+ dir, frame := frameIn(t)
378+ gate := portsmocks.NewMockSessionSecurity(t)
379+ gate.EXPECT().Snapshot().Return(good).Once()
380+ gate.EXPECT().Snapshot().Return(ports.SecurityState{Generation: 2}).Once()
381+ if written, err := linkPNGSecure(frame, filepath.Join(dir, "latest.png"), img, gate, good); err != nil || written {
382+ t.Fatalf("link = %v, %v", written, err)
383+ }
384+ onlyFrame(t, dir)
385+ })
386+ t.Run("rename failure", func(t *testing.T) {
387+ dir, frame := frameIn(t)
388+ // A non-empty directory cannot be replaced by a file.
389+ latest := filepath.Join(dir, "latest.png")
390+ if err := os.MkdirAll(filepath.Join(latest, "keep"), 0o700); err != nil {
391+ t.Fatal(err)
392+ }
393+ if written, err := linkPNGSecure(frame, latest, img, nil, ports.SecurityState{}); err == nil || written {
394+ t.Fatalf("link = %v, %v; want an error", written, err)
395+ }
396+ if _, err := os.Stat(latest + ".tmp"); !os.IsNotExist(err) {
397+ t.Fatalf("temporary link left behind: %v", err)
398+ }
399+ if _, err := os.Stat(filepath.Join(latest, "keep")); err != nil {
400+ t.Fatalf("existing directory touched: %v", err)
401+ }
402+ })
403+ t.Run("no source file", func(t *testing.T) {
404+ dir := t.TempDir()
405+ latest := filepath.Join(dir, "latest.png")
406+ if written, err := linkPNGSecure(filepath.Join(dir, "missing.png"), latest, img, nil, ports.SecurityState{}); err != nil || !written {
407+ t.Fatalf("link = %v, %v", written, err)
408+ }
409+ fi, err := os.Stat(latest)
410+ if err != nil {
411+ t.Fatal(err)
412+ }
413+ if !fi.Mode().IsRegular() {
414+ t.Fatalf("latest.png is %v", fi.Mode())
415+ }
416+ f, err := os.Open(latest)
417+ if err != nil {
418+ t.Fatal(err)
419+ }
420+ defer f.Close()
421+ if _, err := png.Decode(f); err != nil {
422+ t.Fatal(err)
423+ }
424+ // Encoded on its own: nothing else in the directory shares it.
425+ if entries, _ := os.ReadDir(dir); len(entries) != 1 {
426+ t.Fatalf("unexpected files: %v", entries)
427+ }
428+ if n, ok := linkCount(fi); ok && n != 1 {
429+ t.Fatalf("latest.png has %d links, want 1", n)
430+ }
431+ })
432+}
433+
434+// linkCount returns the hard link count of fi where the platform reports it.
435+func linkCount(fi os.FileInfo) (uint64, bool) {
436+ st, ok := fi.Sys().(*syscall.Stat_t)
437+ if !ok {
438+ return 0, false
439+ }
440+ return uint64(st.Nlink), true
441+}
442+
272 func TestWritePNGAtomic(t *testing.T) {443 func TestWritePNGAtomic(t *testing.T) {
273 dir := t.TempDir()444 dir := t.TempDir()
274 img := image.NewRGBA(image.Rect(0, 0, 1, 1))445 img := image.NewRGBA(image.Rect(0, 0, 1, 1))
M tools/demo/Dockerfile +24 -15
@@ -4,23 +4,32 @@ RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
44 pacman -Sy --noconfirm archlinux-keyring \
55 && pacman -Syu --noconfirm --needed \
66 firefox foot btop fastfetch eza mousepad \
7- ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-swrast
7+ ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-radeon \
8+ tesseract tesseract-data-eng at-spi2-core python
89 # Arch's btop carries file capabilities that rootless Docker cannot grant.
910 RUN install -m 0755 /usr/bin/btop /usr/local/bin/btop
10-RUN useradd -m -u 1000 demo \
11- && mkdir -p /home/demo/firefox /home/demo/.config/btop /run/wl \
12- && chmod 0700 /run/wl
13-COPY firefox/user.js /home/demo/firefox/user.js
14-COPY firefox/userChrome.css /home/demo/firefox/chrome/userChrome.css
15-COPY foot.ini /home/demo/.config/foot/foot.ini
16-COPY btop.conf /home/demo/.config/btop/btop.conf
17-COPY btop-vesper.theme /home/demo/.config/btop/themes/vesper.theme
18-COPY site /home/demo/site
11+# Mousepad warns about running as root, so it runs as demo (uid 1000).
12+RUN useradd -m -u 1000 demo && mkdir -p /root/firefox /root/.config/btop /run/wl && chmod 0755 /run/wl
13+COPY firefox/user.js /root/firefox/user.js
14+COPY firefox/userChrome.css /root/firefox/chrome/userChrome.css
15+COPY foot.ini /root/.config/foot/foot.ini
16+COPY btop.conf /root/.config/btop/btop.conf
17+COPY btop-vesper.theme /root/.config/btop/themes/vesper.theme
18+COPY demo-entry demo-hint-label /usr/local/bin/
19+# One config for both users: the compositor (root) reads it, Mousepad (demo) edits it.
1920 COPY config /home/demo/config
20-RUN chown -R demo:demo /home/demo /run/wl
21-ENV HOME=/home/demo XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \
21+COPY mousepad.keyfile /home/demo/.config/glib-2.0/settings/keyfile
22+RUN chown -R demo:demo /home/demo
23+ENV HOME=/root XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \
2224 GTK_THEME=Adwaita:dark ADW_DEBUG_COLOR_SCHEME=prefer-dark \
25+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/wl/bus \
2326 MOZ_ENABLE_WAYLAND=1 LIBGL_ALWAYS_SOFTWARE=1 COLORTERM=truecolor
24-WORKDIR /home/demo
25-# The recorder grants demo access to only the disposable headless socket.
26-CMD ["sleep", "infinity"]
27+# LIBGL_ALWAYS_SOFTWARE applies to the apps' OpenGL, not to the compositor (Vulkan).
28+WORKDIR /root
29+# Everything runs as the container's root, which rootless Docker maps to the
30+# host user, so the bind-mounted work directory stays owned by the host user.
31+# The compositor (bind-mounted at /usr/local/bin/neferwl) and the apps share one
32+# session bus, which AT-SPI needs. The compositor renders on the host GPU through
33+# /dev/dri: vulkan-radeon is its Mesa driver here (swap in vulkan-intel for an
34+# Intel GPU). Software Vulkan (lvp) cannot export the sync files hint scans need.
35+ENTRYPOINT ["/usr/local/bin/demo-entry"]
@@ -4,23 +4,32 @@ RUN --mount=type=cache,target=/var/cache/pacman/pkg,sharing=locked \
4 pacman -Sy --noconfirm archlinux-keyring \4 pacman -Sy --noconfirm archlinux-keyring \
5 && pacman -Syu --noconfirm --needed \5 && pacman -Syu --noconfirm --needed \
6 firefox foot btop fastfetch eza mousepad \6 firefox foot btop fastfetch eza mousepad \
7- ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-swrast7+ ttf-jetbrains-mono-nerd noto-fonts noto-fonts-emoji dbus mesa vulkan-radeon \
8+ tesseract tesseract-data-eng at-spi2-core python
8 # Arch's btop carries file capabilities that rootless Docker cannot grant.9 # Arch's btop carries file capabilities that rootless Docker cannot grant.
9 RUN install -m 0755 /usr/bin/btop /usr/local/bin/btop10 RUN install -m 0755 /usr/bin/btop /usr/local/bin/btop
10-RUN useradd -m -u 1000 demo \11+# Mousepad warns about running as root, so it runs as demo (uid 1000).
11- && mkdir -p /home/demo/firefox /home/demo/.config/btop /run/wl \12+RUN useradd -m -u 1000 demo && mkdir -p /root/firefox /root/.config/btop /run/wl && chmod 0755 /run/wl
12- && chmod 0700 /run/wl13+COPY firefox/user.js /root/firefox/user.js
13-COPY firefox/user.js /home/demo/firefox/user.js14+COPY firefox/userChrome.css /root/firefox/chrome/userChrome.css
14-COPY firefox/userChrome.css /home/demo/firefox/chrome/userChrome.css15+COPY foot.ini /root/.config/foot/foot.ini
15-COPY foot.ini /home/demo/.config/foot/foot.ini16+COPY btop.conf /root/.config/btop/btop.conf
16-COPY btop.conf /home/demo/.config/btop/btop.conf17+COPY btop-vesper.theme /root/.config/btop/themes/vesper.theme
17-COPY btop-vesper.theme /home/demo/.config/btop/themes/vesper.theme18+COPY demo-entry demo-hint-label /usr/local/bin/
18-COPY site /home/demo/site19+# One config for both users: the compositor (root) reads it, Mousepad (demo) edits it.
19 COPY config /home/demo/config20 COPY config /home/demo/config
20-RUN chown -R demo:demo /home/demo /run/wl21+COPY mousepad.keyfile /home/demo/.config/glib-2.0/settings/keyfile
21-ENV HOME=/home/demo XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \22+RUN chown -R demo:demo /home/demo
23+ENV HOME=/root XDG_RUNTIME_DIR=/run/wl WAYLAND_DISPLAY=wayland-0 \
22 GTK_THEME=Adwaita:dark ADW_DEBUG_COLOR_SCHEME=prefer-dark \24 GTK_THEME=Adwaita:dark ADW_DEBUG_COLOR_SCHEME=prefer-dark \
25+ DBUS_SESSION_BUS_ADDRESS=unix:path=/run/wl/bus \
23 MOZ_ENABLE_WAYLAND=1 LIBGL_ALWAYS_SOFTWARE=1 COLORTERM=truecolor26 MOZ_ENABLE_WAYLAND=1 LIBGL_ALWAYS_SOFTWARE=1 COLORTERM=truecolor
24-WORKDIR /home/demo27+# LIBGL_ALWAYS_SOFTWARE applies to the apps' OpenGL, not to the compositor (Vulkan).
25-# The recorder grants demo access to only the disposable headless socket.28+WORKDIR /root
26-CMD ["sleep", "infinity"]29+# Everything runs as the container's root, which rootless Docker maps to the
30+# host user, so the bind-mounted work directory stays owned by the host user.
31+# The compositor (bind-mounted at /usr/local/bin/neferwl) and the apps share one
32+# session bus, which AT-SPI needs. The compositor renders on the host GPU through
33+# /dev/dri: vulkan-radeon is its Mesa driver here (swap in vulkan-intel for an
34+# Intel GPU). Software Vulkan (lvp) cannot export the sync files hint scans need.
35+ENTRYPOINT ["/usr/local/bin/demo-entry"]
M tools/demo/config +5 -0
@@ -11,12 +11,17 @@ terminal.auto-open = off
1111 stash.spawn-terminal = off
1212 stash.capture = off
1313 keyboard.layout = us
14+hint.enable = on
15+hint.targets = links
1416
1517 # Columns fill a band; extra windows flow below.
1618 # Super+Up / Down travel between bands.
1719 # Super+Shift+s sets a window aside.
1820 # Super+s brings your stash into view.
1921 # Super+o opens the overview.
22+# Super+Shift+f makes a window fullscreen.
23+# Super+g labels the links; type one to click it.
24+bind.cmd+g = hint
2025 bind.cmd+code:3 = focus-workspace 2
2126 bind.cmd+code:2 = focus-workspace 1
2227
@@ -11,12 +11,17 @@ terminal.auto-open = off
11 stash.spawn-terminal = off11 stash.spawn-terminal = off
12 stash.capture = off12 stash.capture = off
13 keyboard.layout = us13 keyboard.layout = us
14+hint.enable = on
15+hint.targets = links
14 16
15 # Columns fill a band; extra windows flow below.17 # Columns fill a band; extra windows flow below.
16 # Super+Up / Down travel between bands.18 # Super+Up / Down travel between bands.
17 # Super+Shift+s sets a window aside.19 # Super+Shift+s sets a window aside.
18 # Super+s brings your stash into view.20 # Super+s brings your stash into view.
19 # Super+o opens the overview.21 # Super+o opens the overview.
22+# Super+Shift+f makes a window fullscreen.
23+# Super+g labels the links; type one to click it.
24+bind.cmd+g = hint
20 bind.cmd+code:3 = focus-workspace 225 bind.cmd+code:3 = focus-workspace 2
21 bind.cmd+code:2 = focus-workspace 126 bind.cmd+code:2 = focus-workspace 1
22 27
A tools/demo/demo-entry +9 -0
@@ -0,0 +1,9 @@
1+#!/bin/sh
2+# Starts the session bus shared by the compositor and the apps, turns
3+# accessibility on before any app starts, then runs the compositor as the
4+# container's main process: the container exits with it.
5+set -eu
6+dbus-daemon --session --fork --address="$DBUS_SESSION_BUS_ADDRESS"
7+dbus-send --session --print-reply --dest=org.a11y.Bus /org/a11y/bus \
8+ org.freedesktop.DBus.Properties.Set string:org.a11y.Status string:IsEnabled variant:boolean:true >/dev/null
9+exec /usr/local/bin/neferwl "$@"
@@ -0,0 +1,9 @@
1+#!/bin/sh
2+# Starts the session bus shared by the compositor and the apps, turns
3+# accessibility on before any app starts, then runs the compositor as the
4+# container's main process: the container exits with it.
5+set -eu
6+dbus-daemon --session --fork --address="$DBUS_SESSION_BUS_ADDRESS"
7+dbus-send --session --print-reply --dest=org.a11y.Bus /org/a11y/bus \
8+ org.freedesktop.DBus.Properties.Set string:org.a11y.Status string:IsEnabled variant:boolean:true >/dev/null
9+exec /usr/local/bin/neferwl "$@"
A tools/demo/demo-hint-label +76 -0
@@ -0,0 +1,76 @@
1+#!/usr/bin/env python3
2+"""Work out the hint label of a link, to type it the way a user reads it.
3+
4+ demo-hint-label ocr
5+ Reads the frame on screen now (the compositor's latest screenshot) with
6+ tesseract and keeps the words and their boxes in /tmp/hint-words.json.
7+ Run it while the page is still, before the hint bind, so that the
8+ lookup after the bind needs no OCR.
9+
10+ demo-hint-label label <since-epoch> <word>...
11+ Waits for the hint dump (NEFERWL_HINT_DEBUG=1) of the scan started after
12+ <since-epoch>, finds the link target that shows all the words and prints
13+ its label. Labels follow internal/core/hint.go: the targets whose centre
14+ is on the output, sorted by top then left edge, one letter each from the
15+ home-row alphabet, two letters past 26 targets.
16+"""
17+import csv, glob, io, json, os, re, shutil, subprocess, sys, time
18+
19+ALPHABET = "fjdkslaghrueiwoqptyvbcnxmz"
20+STATE = os.environ.get("XDG_STATE_HOME", "/work/state")
21+DUMPS = os.path.join(STATE, "neferwl", "hints")
22+SHOT = "/work/shots/latest.png"
23+WORDS = "/tmp/hint-words.json"
24+
25+
26+def ocr():
27+ shutil.copyfile(SHOT, "/tmp/hint-ocr.png")
28+ out = subprocess.run(["tesseract", "/tmp/hint-ocr.png", "stdout", "--psm", "11", "tsv"],
29+ capture_output=True, text=True, check=True).stdout
30+ words = []
31+ for row in csv.DictReader(io.StringIO(out), delimiter="\t", quoting=csv.QUOTE_NONE):
32+ text = re.sub(r"\W", "", row["text"] or "").lower()
33+ if text and float(row["conf"]) > 30:
34+ x, y, w, h = (int(row[k]) for k in ("left", "top", "width", "height"))
35+ words.append([text, x + w / 2, y + h / 2])
36+ with open(WORDS, "w") as f:
37+ json.dump(words, f)
38+
39+
40+def newest_dump(since):
41+ deadline = time.time() + 15
42+ while time.time() < deadline:
43+ for path in sorted(glob.glob(os.path.join(DUMPS, "*.json")), reverse=True):
44+ if os.path.getmtime(path) >= since:
45+ return path
46+ time.sleep(0.05)
47+ sys.exit("no hint scan dump after the bind")
48+
49+
50+def label(since, wanted):
51+ with open(newest_dump(since)) as f:
52+ meta = json.load(f)
53+ with open(WORDS) as f:
54+ words = json.load(f)
55+ # Core keeps the targets whose centre is on the output, then sorts them.
56+ width, height = meta["width"], meta["height"]
57+ targets = [t for t in meta["targets"]
58+ if 0 <= (t["rect"][0] + t["rect"][2]) / 2 < width and 0 <= (t["rect"][1] + t["rect"][3]) / 2 < height]
59+ targets.sort(key=lambda t: (t["rect"][1], t["rect"][0]))
60+ for i, t in enumerate(targets):
61+ x0, y0, x1, y1 = t["rect"]
62+ inside = {w for w, cx, cy in words if x0 <= cx < x1 and y0 <= cy < y1}
63+ if t["kind"] == "link" and set(wanted) <= inside:
64+ n = len(ALPHABET)
65+ print("link %d of %d shows %s" % (i + 1, len(targets), " ".join(wanted)), file=sys.stderr)
66+ print(ALPHABET[i] if len(targets) <= n else ALPHABET[i // n] + ALPHABET[i % n])
67+ return
68+ sys.exit("no link shows %s (%d targets, %d words read)" % (" ".join(wanted), len(targets), len(words)))
69+
70+
71+if sys.argv[1:2] == ["ocr"]:
72+ ocr()
73+elif sys.argv[1:2] == ["label"] and len(sys.argv) > 3:
74+ label(float(sys.argv[2]), [w.lower() for w in sys.argv[3:]])
75+else:
76+ sys.exit(__doc__)
@@ -0,0 +1,76 @@
1+#!/usr/bin/env python3
2+"""Work out the hint label of a link, to type it the way a user reads it.
3+
4+ demo-hint-label ocr
5+ Reads the frame on screen now (the compositor's latest screenshot) with
6+ tesseract and keeps the words and their boxes in /tmp/hint-words.json.
7+ Run it while the page is still, before the hint bind, so that the
8+ lookup after the bind needs no OCR.
9+
10+ demo-hint-label label <since-epoch> <word>...
11+ Waits for the hint dump (NEFERWL_HINT_DEBUG=1) of the scan started after
12+ <since-epoch>, finds the link target that shows all the words and prints
13+ its label. Labels follow internal/core/hint.go: the targets whose centre
14+ is on the output, sorted by top then left edge, one letter each from the
15+ home-row alphabet, two letters past 26 targets.
16+"""
17+import csv, glob, io, json, os, re, shutil, subprocess, sys, time
18+
19+ALPHABET = "fjdkslaghrueiwoqptyvbcnxmz"
20+STATE = os.environ.get("XDG_STATE_HOME", "/work/state")
21+DUMPS = os.path.join(STATE, "neferwl", "hints")
22+SHOT = "/work/shots/latest.png"
23+WORDS = "/tmp/hint-words.json"
24+
25+
26+def ocr():
27+ shutil.copyfile(SHOT, "/tmp/hint-ocr.png")
28+ out = subprocess.run(["tesseract", "/tmp/hint-ocr.png", "stdout", "--psm", "11", "tsv"],
29+ capture_output=True, text=True, check=True).stdout
30+ words = []
31+ for row in csv.DictReader(io.StringIO(out), delimiter="\t", quoting=csv.QUOTE_NONE):
32+ text = re.sub(r"\W", "", row["text"] or "").lower()
33+ if text and float(row["conf"]) > 30:
34+ x, y, w, h = (int(row[k]) for k in ("left", "top", "width", "height"))
35+ words.append([text, x + w / 2, y + h / 2])
36+ with open(WORDS, "w") as f:
37+ json.dump(words, f)
38+
39+
40+def newest_dump(since):
41+ deadline = time.time() + 15
42+ while time.time() < deadline:
43+ for path in sorted(glob.glob(os.path.join(DUMPS, "*.json")), reverse=True):
44+ if os.path.getmtime(path) >= since:
45+ return path
46+ time.sleep(0.05)
47+ sys.exit("no hint scan dump after the bind")
48+
49+
50+def label(since, wanted):
51+ with open(newest_dump(since)) as f:
52+ meta = json.load(f)
53+ with open(WORDS) as f:
54+ words = json.load(f)
55+ # Core keeps the targets whose centre is on the output, then sorts them.
56+ width, height = meta["width"], meta["height"]
57+ targets = [t for t in meta["targets"]
58+ if 0 <= (t["rect"][0] + t["rect"][2]) / 2 < width and 0 <= (t["rect"][1] + t["rect"][3]) / 2 < height]
59+ targets.sort(key=lambda t: (t["rect"][1], t["rect"][0]))
60+ for i, t in enumerate(targets):
61+ x0, y0, x1, y1 = t["rect"]
62+ inside = {w for w, cx, cy in words if x0 <= cx < x1 and y0 <= cy < y1}
63+ if t["kind"] == "link" and set(wanted) <= inside:
64+ n = len(ALPHABET)
65+ print("link %d of %d shows %s" % (i + 1, len(targets), " ".join(wanted)), file=sys.stderr)
66+ print(ALPHABET[i] if len(targets) <= n else ALPHABET[i // n] + ALPHABET[i % n])
67+ return
68+ sys.exit("no link shows %s (%d targets, %d words read)" % (" ".join(wanted), len(targets), len(words)))
69+
70+
71+if sys.argv[1:2] == ["ocr"]:
72+ ocr()
73+elif sys.argv[1:2] == ["label"] and len(sys.argv) > 3:
74+ label(float(sys.argv[2]), [w.lower() for w in sys.argv[3:]])
75+else:
76+ sys.exit(__doc__)
M tools/demo/input.txt +32 -1
@@ -1,8 +1,17 @@
11 move 1918 1078
2-sleep 12s
2+sleep 5s
3+# Unseen warm-up while Firefox sits beside the terminal: a hint bind cancelled
4+# at once still runs its scan, without drawing labels. It connects to the
5+# accessibility bus early; more of them below (each right after a key press, in
6+# an existing pause) let Firefox expose its page content by the end.
7+key Super+g
8+key Escape
9+sleep 7s
310 key Super+Up
411 sleep 1500ms
512 key Super+Down
13+key Super+g
14+key Escape
615 sleep 1500ms
716 key Super+Shift+s
817 sleep 500ms
@@ -11,6 +20,8 @@ sleep 1500ms
1120 key Super+2
1221 key Super+Shift+1
1322 key Super+1
23+key Super+g
24+key Escape
1425 sleep 3s
1526 key Super+Shift+s
1627 sleep 500ms
@@ -18,8 +29,12 @@ key Super+s
1829 sleep 1500ms
1930 key Super+s
2031 key Super+Left
32+key Super+g
33+key Escape
2134 sleep 2s
2235 key Super+Right
36+key Super+g
37+key Escape
2338 sleep 2s
2439 key Super+Left
2540 sleep 1s
@@ -40,4 +55,20 @@ sleep 1500ms
4055 key Up
4156 sleep 1s
4257 key Return
58+sleep 700ms
59+# Back on workspace 1: focus Firefox and fullscreen it. Once the page is still,
60+# read it, then hint mode starts and the label of the Wikipedia result
61+# ("Wayland (protocol) - Wikipedia") is typed.
62+key Super+Right
63+sleep 600ms
64+key Super+Shift+f
65+sleep 900ms
66+hint-read
67+sleep 200ms
68+key Super+g
69+sleep 1500ms
70+hint-click wikipedia protocol
71+# Firefox loads the page; the compositor writes no frame while it is still, so
72+# a pointer move marks the end of the hold.
4373 sleep 4s
74+move 1500 900
@@ -1,8 +1,17 @@
1 move 1918 10781 move 1918 1078
2-sleep 12s2+sleep 5s
3+# Unseen warm-up while Firefox sits beside the terminal: a hint bind cancelled
4+# at once still runs its scan, without drawing labels. It connects to the
5+# accessibility bus early; more of them below (each right after a key press, in
6+# an existing pause) let Firefox expose its page content by the end.
7+key Super+g
8+key Escape
9+sleep 7s
3 key Super+Up10 key Super+Up
4 sleep 1500ms11 sleep 1500ms
5 key Super+Down12 key Super+Down
13+key Super+g
14+key Escape
6 sleep 1500ms15 sleep 1500ms
7 key Super+Shift+s16 key Super+Shift+s
8 sleep 500ms17 sleep 500ms
@@ -11,6 +20,8 @@ sleep 1500ms
11 key Super+220 key Super+2
12 key Super+Shift+121 key Super+Shift+1
13 key Super+122 key Super+1
23+key Super+g
24+key Escape
14 sleep 3s25 sleep 3s
15 key Super+Shift+s26 key Super+Shift+s
16 sleep 500ms27 sleep 500ms
@@ -18,8 +29,12 @@ key Super+s
18 sleep 1500ms29 sleep 1500ms
19 key Super+s30 key Super+s
20 key Super+Left31 key Super+Left
32+key Super+g
33+key Escape
21 sleep 2s34 sleep 2s
22 key Super+Right35 key Super+Right
36+key Super+g
37+key Escape
23 sleep 2s38 sleep 2s
24 key Super+Left39 key Super+Left
25 sleep 1s40 sleep 1s
@@ -40,4 +55,20 @@ sleep 1500ms
40 key Up55 key Up
41 sleep 1s56 sleep 1s
42 key Return57 key Return
58+sleep 700ms
59+# Back on workspace 1: focus Firefox and fullscreen it. Once the page is still,
60+# read it, then hint mode starts and the label of the Wikipedia result
61+# ("Wayland (protocol) - Wikipedia") is typed.
62+key Super+Right
63+sleep 600ms
64+key Super+Shift+f
65+sleep 900ms
66+hint-read
67+sleep 200ms
68+key Super+g
69+sleep 1500ms
70+hint-click wikipedia protocol
71+# Firefox loads the page; the compositor writes no frame while it is still, so
72+# a pointer move marks the end of the hold.
43 sleep 4s73 sleep 4s
74+move 1500 900
A tools/demo/mousepad.keyfile +6 -0
@@ -0,0 +1,6 @@
1+[org/xfce/mousepad/preferences/view]
2+use-default-monospace-font=false
3+font-name='JetBrainsMono Nerd Font 15'
4+
5+[org/xfce/mousepad/preferences/window]
6+menubar-visible=false
@@ -0,0 +1,6 @@
1+[org/xfce/mousepad/preferences/view]
2+use-default-monospace-font=false
3+font-name='JetBrainsMono Nerd Font 15'
4+
5+[org/xfce/mousepad/preferences/window]
6+menubar-visible=false
M tools/demo/record.sh +107 -42
@@ -1,5 +1,13 @@
11 #!/usr/bin/env bash
2-# Requires Go 1.27, rootless Docker, bash and ffmpeg. Run from any directory.
2+# Requires Go 1.27, rootless Docker, bash, ffmpeg, network access and an AMD GPU.
3+# Run from any directory. The image holds the compositor's runtime (tesseract,
4+# AT-SPI, Mesa's radeon Vulkan driver) and the apps; the host builds the binary.
5+# The compositor renders on the host GPU through /dev/dri, and hint scans need
6+# the sync files only a hardware driver exports. The image only carries
7+# vulkan-radeon, so another GPU needs its Mesa driver in tools/demo/Dockerfile
8+# and the vendor check below changed.
9+# A recording only succeeds if the compositor stops on the quit key that ends
10+# tools/demo/input.txt: hitting --timeout first means a truncated video.
311 # KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up.
412 set -euo pipefail
513 root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
@@ -8,8 +16,17 @@ for command in docker go ffmpeg; do
816 command -v "$command" >/dev/null || { echo "Missing required command: $command" >&2; exit 1; }
917 done
1018 docker info >/dev/null 2>&1 || { echo 'Docker daemon unavailable; start rootless Docker first.' >&2; exit 1; }
19+amd=''
20+for vendor in /sys/class/drm/renderD*/device/vendor; do
21+ if [[ -r $vendor && $(<"$vendor") == 0x1002 ]]; then amd=yes; fi
22+done
23+[[ -n $amd ]] || {
24+ echo 'No AMD GPU render node (PCI vendor 0x1002) found: the demo image only carries the radeon Vulkan driver.' >&2
25+ echo 'See the header of this script to use another GPU.' >&2
26+ exit 1
27+}
1128 work=$(mktemp -d "${TMPDIR:-/tmp}/neferwl-demo.XXXXXX")
12-compositor='' launcher='' feeder='' success=''
29+launcher='' feeder='' success=''
1330 container=neferwl-demo-$$
1431 cleanup() {
1532 local status=$?
@@ -19,9 +36,8 @@ cleanup() {
1936 for pid in "$launcher" "$feeder"; do
2037 if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi
2138 done
22- if [[ -n $compositor ]]; then kill "$compositor" 2>/dev/null || true; fi
2339 docker rm -f "$container" >/dev/null 2>&1 || true
24- for pid in "$launcher" "$feeder" "$compositor"; do
40+ for pid in "$launcher" "$feeder"; do
2541 if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi
2642 done
2743 exec 3>&-
@@ -34,80 +50,129 @@ trap cleanup EXIT
3450 trap 'exit 130' INT
3551 trap 'exit 143' TERM
3652 printf 'Capture directory: %s\n' "$work"
37-mkdir -p "$work/shots" docs/media
53+mkdir -p "$work/shots" "$work/state" docs/media
3854 docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo
3955 CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl
40-state=$work/state
41-log=$state/neferwl/runs/headless/latest.log
56+log=$work/state/neferwl/runs/headless/latest.log
4257 mkfifo "$work/input"
4358 # Hold both ends open so the FIFO cannot block compositor startup or produce
44-# premature EOF. No input is fed until Docker and the first app are ready.
59+# premature EOF. No input is fed until the first app is ready.
4560 exec 3<>"$work/input"
46-env -u NEFERWL_STATE -u NEFERWL_SLOT -u WAYLAND_DISPLAY XDG_STATE_HOME="$state" "$work/neferwl" \
47- --backend=headless --size 1920x1080 --config tools/demo/config \
48- --no-xwayland --screenshot "$work/shots" --input - --timeout 75s <"$work/input" &
49-compositor=$!
50-socket=''
51-for ((attempt = 0; attempt < 150; attempt++)); do
52- kill -0 "$compositor" 2>/dev/null || { echo "Compositor exited before readiness; see $log" >&2; exit 1; }
61+# The compositor is the container's main process, so the container ends with
62+# it. Root in a rootless container is the host user: the bind-mounted work
63+# directory (binary, FIFO, frames, log, hint dumps) stays owned by the host user.
64+# The GPU render node gives the compositor the sync files that hint scans need.
65+docker run -d --name "$container" --hostname neferwl-demo --device /dev/dri \
66+ -e XDG_STATE_HOME=/work/state -e NEFERWL_HINT_DEBUG=1 \
67+ -v "$work:/work" -v "$work/neferwl:/usr/local/bin/neferwl:ro" neferwl-demo \
68+ --backend=headless --size 1920x1080 --config /home/demo/config --no-xwayland \
69+ --screenshot /work/shots --input /work/input --timeout 75s >/dev/null
70+display=''
71+deadline=$((SECONDS + 30))
72+while ((SECONDS < deadline)); do
73+ [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) == true ]] ||
74+ { echo "Compositor exited before readiness; see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
5375 if [[ -f $log ]]; then
5476 display=$(sed -n '/"message":"listening"/s/.*"WAYLAND_DISPLAY":"\([^" ]*\)".*/\1/p' "$log" | tail -1)
55- if [[ -n $display && -S $XDG_RUNTIME_DIR/${display##*/} ]]; then
56- socket=$XDG_RUNTIME_DIR/${display##*/}
57- break
58- fi
77+ [[ -n $display ]] && break
5978 fi
6079 sleep 0.1
6180 done
62-[[ -n $socket ]] || { echo "Timed out after 15s waiting for compositor socket; see $log" >&2; exit 1; }
63-# Container root maps to the host uid under rootless Docker. Grant demo access
64-# to this disposable socket only; Firefox rejects root with a demo-owned HOME.
65-docker run -d --name "$container" --hostname neferwl-demo --network none \
66- -v "$socket:/run/wl/wayland-0" neferwl-demo
67-docker exec "$container" chmod 666 /run/wl/wayland-0
81+[[ -n $display ]] || { echo "Timed out after 30s waiting for the compositor to listen; see $log" >&2; exit 1; }
82+# Mousepad runs as another user (see launch_apps) and needs the socket.
83+docker exec "$container" chmod 666 "/run/wl/${display##*/}"
84+app() { docker exec -d -e "WAYLAND_DISPLAY=${display##*/}" "$container" "$@"; }
6885 at() { local delay=$(($1 - SECONDS)); if ((delay > 0)); then sleep "$delay"; fi; }
6986 launch_apps() {
7087 # This shell timeline shares t=0 with input.txt. Only keys live in input.txt;
71- # Docker launches cannot be invoked by compositor input scripts.
88+ # app launches cannot be invoked by compositor input scripts.
7289 at 3
73- docker exec -d --user demo "$container" dbus-run-session -- firefox --no-remote \
74- --profile /home/demo/firefox file:///home/demo/site/index.html
90+ app firefox --no-remote --profile /root/firefox 'https://duckduckgo.com/?q=wayland+compositor'
7591 # btop's first per-core sample is bogus in this container. Let it collect
7692 # real deltas on workspace 2; input.txt brings it into view at t=17.
7793 at 5
78- docker exec -d --user demo "$container" foot --app-id demo-monitor \
79- --title 'Monitor / btop' /usr/local/bin/btop
94+ app foot --app-id demo-monitor --title 'Monitor / btop' /usr/local/bin/btop
8095 at 9
81- docker exec -d --user demo "$container" dbus-run-session -- bash -c \
82- 'gsettings set org.xfce.mousepad.preferences.view use-default-monospace-font false; gsettings set org.xfce.mousepad.preferences.view font-name "JetBrainsMono Nerd Font 15"; gsettings set org.xfce.mousepad.preferences.window menubar-visible false; mousepad /home/demo/config'
96+ # Mousepad runs as demo (it warns about root), with a file-backed
97+ # GSettings and no session bus (it needs the root-owned bus otherwise).
98+ app setpriv --reuid=1000 --regid=1000 --clear-groups env HOME=/home/demo \
99+ GSETTINGS_BACKEND=keyfile DBUS_SESSION_BUS_ADDRESS=disabled: \
100+ mousepad --disable-server /home/demo/config
83101 at 30
84- docker exec -d --user demo "$container" foot --title 'Fonts / workspace 2' bash -c \
102+ app foot --title 'Fonts / workspace 2' bash -c \
85103 'eza --tree --level=2 --icons --color=always /usr/share/fonts | head -30; exec sleep infinity'
86104 }
87-docker exec -d --user demo "$container" foot --title 'System / NeferWL' bash -c \
88- 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity'
105+feed() {
106+ # Forwards input.txt to the compositor in real time. `sleep` lines are
107+ # served here (not forwarded), so host-side steps can sit between two
108+ # compositor steps:
109+ # hint-read reads the still page with OCR, in the background
110+ # hint-click <word>... types the label of the link that shows all the
111+ # words, from the dump of the scan the last
112+ # `hint` bind started (demo-hint-label reproduces
113+ # the labelling of internal/core/hint.go)
114+ local line cmd d mark label reader=''
115+ while IFS= read -r line || [[ -n $line ]]; do
116+ cmd=${line%% *}
117+ case $cmd in
118+ '' | '#'*) ;;
119+ sleep)
120+ d=${line#sleep }
121+ if [[ $d == *ms ]]; then d=$(printf '%d.%03d' $((${d%ms} / 1000)) $((${d%ms} % 1000))); fi
122+ sleep "$d" ;;
123+ hint-read)
124+ docker exec "$container" demo-hint-label ocr &
125+ reader=$! ;;
126+ hint-click)
127+ [[ -n $reader ]] && wait "$reader"
128+ # shellcheck disable=SC2086
129+ label=$(docker exec "$container" demo-hint-label label "$mark" ${line#hint-click }) ||
130+ { echo "No hint label for: ${line#hint-click }" >&2; docker kill "$container" >/dev/null; return 1; }
131+ echo "Hint label for ${line#hint-click }: $label" >&2
132+ printf 'type %s\n' "$label" ;;
133+ *)
134+ [[ $line == 'key Super+g' ]] && mark=$(date +%s.%N)
135+ printf '%s\n' "$line" ;;
136+ esac
137+ done <tools/demo/input.txt
138+ printf 'key Ctrl+Alt+BackSpace\n'
139+}
140+app foot --title 'System / NeferWL' bash -c \
141+ 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity'
89142 # The first app is launched before t=0. FIFO input and shell launches now
90143 # share this epoch, independent of compositor/image/container startup time.
91144 SECONDS=0
92145 set -m
93-{ cat tools/demo/input.txt; printf '\nkey Ctrl+Alt+BackSpace\n'; } >&3 &
146+feed >&3 &
94147 feeder=$!
95148 launch_apps &
96149 launcher=$!
97150 set +m
98-# --timeout exits nonzero; accept only its explicit timeout log entry.
99-if ! wait "$compositor"; then
100- grep -q '"reason":"timeout"' "$log" || exit 1
151+# The compositor ends with the quit key at the end of input.txt. Hitting
152+# --timeout first (a stalled timeline) leaves a truncated recording: that fails.
153+status=$(docker wait "$container")
154+if grep -q '"reason":"timeout"' "$log"; then
155+ echo 'The compositor hit --timeout before the quit key: the recording is truncated.' >&2
156+ exit 1
101157 fi
102-compositor=
103-wait "$feeder"
158+[[ $status == 0 ]] || { echo "Compositor failed (exit $status); see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
159+wait "$feeder" || { echo 'Scripted input failed.' >&2; exit 1; }
104160 feeder=
105161 wait "$launcher"
106162 launcher=
163+# Firefox's links come from the accessibility tree; OCR (tesseract) must still load.
164+if grep -qE 'OCR unavailable|English OCR data missing' "$log"; then echo 'tesseract is missing in the image.' >&2; exit 1; fi
165+if grep -q 'capture needs sync file export' "$log"; then
166+ echo 'Hint scans failed: the compositor is not on a hardware Vulkan driver (radeon); see the header of this script.' >&2
167+ exit 1
168+fi
169+grep -qE '"atspi_targets":[1-9][0-9]*,.*"message":"hint scan"' "$log" ||
170+ { echo 'No hint scan found accessibility targets:' >&2; grep -E '"component":"(hints|atspi)"' "$log" | cut -c1-300 >&2; exit 1; }
171+grep '"message":"hint scan"' "$log" | sed -E 's/.*"atspi_targets":([0-9]+).*"ocr_links":([0-9]+).*"targets":([0-9]+).*/hint scan: \1 accessibility + \2 OCR links = \3 targets/' | tail -3
107172 ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \
108173 -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \
109174 -c:v libx264 -crf 23 -movflags +faststart docs/media/demo.mp4 -y
110-ffmpeg -hide_banner -loglevel warning -ss 7 -i docs/media/demo.mp4 \
175+ffmpeg -hide_banner -loglevel warning -ss 8 -i docs/media/demo.mp4 \
111176 -frames:v 1 -update 1 docs/media/demo.png -y
112177 success=yes
113178 if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi
@@ -1,5 +1,13 @@
1 #!/usr/bin/env bash1 #!/usr/bin/env bash
2-# Requires Go 1.27, rootless Docker, bash and ffmpeg. Run from any directory.2+# Requires Go 1.27, rootless Docker, bash, ffmpeg, network access and an AMD GPU.
3+# Run from any directory. The image holds the compositor's runtime (tesseract,
4+# AT-SPI, Mesa's radeon Vulkan driver) and the apps; the host builds the binary.
5+# The compositor renders on the host GPU through /dev/dri, and hint scans need
6+# the sync files only a hardware driver exports. The image only carries
7+# vulkan-radeon, so another GPU needs its Mesa driver in tools/demo/Dockerfile
8+# and the vendor check below changed.
9+# A recording only succeeds if the compositor stops on the quit key that ends
10+# tools/demo/input.txt: hitting --timeout first means a truncated video.
3 # KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up.11 # KEEP_CAPTURE=1 preserves temporary captures on success; failures always clean up.
4 set -euo pipefail12 set -euo pipefail
5 root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)13 root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)
@@ -8,8 +16,17 @@ for command in docker go ffmpeg; do
8 command -v "$command" >/dev/null || { echo "Missing required command: $command" >&2; exit 1; }16 command -v "$command" >/dev/null || { echo "Missing required command: $command" >&2; exit 1; }
9 done17 done
10 docker info >/dev/null 2>&1 || { echo 'Docker daemon unavailable; start rootless Docker first.' >&2; exit 1; }18 docker info >/dev/null 2>&1 || { echo 'Docker daemon unavailable; start rootless Docker first.' >&2; exit 1; }
19+amd=''
20+for vendor in /sys/class/drm/renderD*/device/vendor; do
21+ if [[ -r $vendor && $(<"$vendor") == 0x1002 ]]; then amd=yes; fi
22+done
23+[[ -n $amd ]] || {
24+ echo 'No AMD GPU render node (PCI vendor 0x1002) found: the demo image only carries the radeon Vulkan driver.' >&2
25+ echo 'See the header of this script to use another GPU.' >&2
26+ exit 1
27+}
11 work=$(mktemp -d "${TMPDIR:-/tmp}/neferwl-demo.XXXXXX")28 work=$(mktemp -d "${TMPDIR:-/tmp}/neferwl-demo.XXXXXX")
12-compositor='' launcher='' feeder='' success=''29+launcher='' feeder='' success=''
13 container=neferwl-demo-$$30 container=neferwl-demo-$$
14 cleanup() {31 cleanup() {
15 local status=$?32 local status=$?
@@ -19,9 +36,8 @@ cleanup() {
19 for pid in "$launcher" "$feeder"; do36 for pid in "$launcher" "$feeder"; do
20 if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi37 if [[ -n $pid ]]; then kill -- "-$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true; fi
21 done38 done
22- if [[ -n $compositor ]]; then kill "$compositor" 2>/dev/null || true; fi
23 docker rm -f "$container" >/dev/null 2>&1 || true39 docker rm -f "$container" >/dev/null 2>&1 || true
24- for pid in "$launcher" "$feeder" "$compositor"; do40+ for pid in "$launcher" "$feeder"; do
25 if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi41 if [[ -n $pid ]]; then wait "$pid" 2>/dev/null || true; fi
26 done42 done
27 exec 3>&-43 exec 3>&-
@@ -34,80 +50,129 @@ trap cleanup EXIT
34 trap 'exit 130' INT50 trap 'exit 130' INT
35 trap 'exit 143' TERM51 trap 'exit 143' TERM
36 printf 'Capture directory: %s\n' "$work"52 printf 'Capture directory: %s\n' "$work"
37-mkdir -p "$work/shots" docs/media53+mkdir -p "$work/shots" "$work/state" docs/media
38 docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo54 docker build -t neferwl-demo -f tools/demo/Dockerfile tools/demo
39 CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl55 CGO_ENABLED=0 go build -o "$work/neferwl" ./cmd/neferwl
40-state=$work/state56+log=$work/state/neferwl/runs/headless/latest.log
41-log=$state/neferwl/runs/headless/latest.log
42 mkfifo "$work/input"57 mkfifo "$work/input"
43 # Hold both ends open so the FIFO cannot block compositor startup or produce58 # Hold both ends open so the FIFO cannot block compositor startup or produce
44-# premature EOF. No input is fed until Docker and the first app are ready.59+# premature EOF. No input is fed until the first app is ready.
45 exec 3<>"$work/input"60 exec 3<>"$work/input"
46-env -u NEFERWL_STATE -u NEFERWL_SLOT -u WAYLAND_DISPLAY XDG_STATE_HOME="$state" "$work/neferwl" \61+# The compositor is the container's main process, so the container ends with
47- --backend=headless --size 1920x1080 --config tools/demo/config \62+# it. Root in a rootless container is the host user: the bind-mounted work
48- --no-xwayland --screenshot "$work/shots" --input - --timeout 75s <"$work/input" &63+# directory (binary, FIFO, frames, log, hint dumps) stays owned by the host user.
49-compositor=$!64+# The GPU render node gives the compositor the sync files that hint scans need.
50-socket=''65+docker run -d --name "$container" --hostname neferwl-demo --device /dev/dri \
51-for ((attempt = 0; attempt < 150; attempt++)); do66+ -e XDG_STATE_HOME=/work/state -e NEFERWL_HINT_DEBUG=1 \
52- kill -0 "$compositor" 2>/dev/null || { echo "Compositor exited before readiness; see $log" >&2; exit 1; }67+ -v "$work:/work" -v "$work/neferwl:/usr/local/bin/neferwl:ro" neferwl-demo \
68+ --backend=headless --size 1920x1080 --config /home/demo/config --no-xwayland \
69+ --screenshot /work/shots --input /work/input --timeout 75s >/dev/null
70+display=''
71+deadline=$((SECONDS + 30))
72+while ((SECONDS < deadline)); do
73+ [[ $(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null) == true ]] ||
74+ { echo "Compositor exited before readiness; see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
53 if [[ -f $log ]]; then75 if [[ -f $log ]]; then
54 display=$(sed -n '/"message":"listening"/s/.*"WAYLAND_DISPLAY":"\([^" ]*\)".*/\1/p' "$log" | tail -1)76 display=$(sed -n '/"message":"listening"/s/.*"WAYLAND_DISPLAY":"\([^" ]*\)".*/\1/p' "$log" | tail -1)
55- if [[ -n $display && -S $XDG_RUNTIME_DIR/${display##*/} ]]; then77+ [[ -n $display ]] && break
56- socket=$XDG_RUNTIME_DIR/${display##*/}
57- break
58- fi
59 fi78 fi
60 sleep 0.179 sleep 0.1
61 done80 done
62-[[ -n $socket ]] || { echo "Timed out after 15s waiting for compositor socket; see $log" >&2; exit 1; }81+[[ -n $display ]] || { echo "Timed out after 30s waiting for the compositor to listen; see $log" >&2; exit 1; }
63-# Container root maps to the host uid under rootless Docker. Grant demo access82+# Mousepad runs as another user (see launch_apps) and needs the socket.
64-# to this disposable socket only; Firefox rejects root with a demo-owned HOME.83+docker exec "$container" chmod 666 "/run/wl/${display##*/}"
65-docker run -d --name "$container" --hostname neferwl-demo --network none \84+app() { docker exec -d -e "WAYLAND_DISPLAY=${display##*/}" "$container" "$@"; }
66- -v "$socket:/run/wl/wayland-0" neferwl-demo
67-docker exec "$container" chmod 666 /run/wl/wayland-0
68 at() { local delay=$(($1 - SECONDS)); if ((delay > 0)); then sleep "$delay"; fi; }85 at() { local delay=$(($1 - SECONDS)); if ((delay > 0)); then sleep "$delay"; fi; }
69 launch_apps() {86 launch_apps() {
70 # This shell timeline shares t=0 with input.txt. Only keys live in input.txt;87 # This shell timeline shares t=0 with input.txt. Only keys live in input.txt;
71- # Docker launches cannot be invoked by compositor input scripts.88+ # app launches cannot be invoked by compositor input scripts.
72 at 389 at 3
73- docker exec -d --user demo "$container" dbus-run-session -- firefox --no-remote \90+ app firefox --no-remote --profile /root/firefox 'https://duckduckgo.com/?q=wayland+compositor'
74- --profile /home/demo/firefox file:///home/demo/site/index.html
75 # btop's first per-core sample is bogus in this container. Let it collect91 # btop's first per-core sample is bogus in this container. Let it collect
76 # real deltas on workspace 2; input.txt brings it into view at t=17.92 # real deltas on workspace 2; input.txt brings it into view at t=17.
77 at 593 at 5
78- docker exec -d --user demo "$container" foot --app-id demo-monitor \94+ app foot --app-id demo-monitor --title 'Monitor / btop' /usr/local/bin/btop
79- --title 'Monitor / btop' /usr/local/bin/btop
80 at 995 at 9
81- docker exec -d --user demo "$container" dbus-run-session -- bash -c \96+ # Mousepad runs as demo (it warns about root), with a file-backed
82- 'gsettings set org.xfce.mousepad.preferences.view use-default-monospace-font false; gsettings set org.xfce.mousepad.preferences.view font-name "JetBrainsMono Nerd Font 15"; gsettings set org.xfce.mousepad.preferences.window menubar-visible false; mousepad /home/demo/config'97+ # GSettings and no session bus (it needs the root-owned bus otherwise).
98+ app setpriv --reuid=1000 --regid=1000 --clear-groups env HOME=/home/demo \
99+ GSETTINGS_BACKEND=keyfile DBUS_SESSION_BUS_ADDRESS=disabled: \
100+ mousepad --disable-server /home/demo/config
83 at 30101 at 30
84- docker exec -d --user demo "$container" foot --title 'Fonts / workspace 2' bash -c \102+ app foot --title 'Fonts / workspace 2' bash -c \
85 'eza --tree --level=2 --icons --color=always /usr/share/fonts | head -30; exec sleep infinity'103 'eza --tree --level=2 --icons --color=always /usr/share/fonts | head -30; exec sleep infinity'
86 }104 }
87-docker exec -d --user demo "$container" foot --title 'System / NeferWL' bash -c \105+feed() {
88- 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity'106+ # Forwards input.txt to the compositor in real time. `sleep` lines are
107+ # served here (not forwarded), so host-side steps can sit between two
108+ # compositor steps:
109+ # hint-read reads the still page with OCR, in the background
110+ # hint-click <word>... types the label of the link that shows all the
111+ # words, from the dump of the scan the last
112+ # `hint` bind started (demo-hint-label reproduces
113+ # the labelling of internal/core/hint.go)
114+ local line cmd d mark label reader=''
115+ while IFS= read -r line || [[ -n $line ]]; do
116+ cmd=${line%% *}
117+ case $cmd in
118+ '' | '#'*) ;;
119+ sleep)
120+ d=${line#sleep }
121+ if [[ $d == *ms ]]; then d=$(printf '%d.%03d' $((${d%ms} / 1000)) $((${d%ms} % 1000))); fi
122+ sleep "$d" ;;
123+ hint-read)
124+ docker exec "$container" demo-hint-label ocr &
125+ reader=$! ;;
126+ hint-click)
127+ [[ -n $reader ]] && wait "$reader"
128+ # shellcheck disable=SC2086
129+ label=$(docker exec "$container" demo-hint-label label "$mark" ${line#hint-click }) ||
130+ { echo "No hint label for: ${line#hint-click }" >&2; docker kill "$container" >/dev/null; return 1; }
131+ echo "Hint label for ${line#hint-click }: $label" >&2
132+ printf 'type %s\n' "$label" ;;
133+ *)
134+ [[ $line == 'key Super+g' ]] && mark=$(date +%s.%N)
135+ printf '%s\n' "$line" ;;
136+ esac
137+ done <tools/demo/input.txt
138+ printf 'key Ctrl+Alt+BackSpace\n'
139+}
140+app foot --title 'System / NeferWL' bash -c \
141+ 'fastfetch --logo small --structure OS:Kernel:WM:Terminal:Memory:Colors; eza -la --no-user --icons --color=always /etc | head -24; exec sleep infinity'
89 # The first app is launched before t=0. FIFO input and shell launches now142 # The first app is launched before t=0. FIFO input and shell launches now
90 # share this epoch, independent of compositor/image/container startup time.143 # share this epoch, independent of compositor/image/container startup time.
91 SECONDS=0144 SECONDS=0
92 set -m145 set -m
93-{ cat tools/demo/input.txt; printf '\nkey Ctrl+Alt+BackSpace\n'; } >&3 &146+feed >&3 &
94 feeder=$!147 feeder=$!
95 launch_apps &148 launch_apps &
96 launcher=$!149 launcher=$!
97 set +m150 set +m
98-# --timeout exits nonzero; accept only its explicit timeout log entry.151+# The compositor ends with the quit key at the end of input.txt. Hitting
99-if ! wait "$compositor"; then152+# --timeout first (a stalled timeline) leaves a truncated recording: that fails.
100- grep -q '"reason":"timeout"' "$log" || exit 1153+status=$(docker wait "$container")
154+if grep -q '"reason":"timeout"' "$log"; then
155+ echo 'The compositor hit --timeout before the quit key: the recording is truncated.' >&2
156+ exit 1
101 fi157 fi
102-compositor=158+[[ $status == 0 ]] || { echo "Compositor failed (exit $status); see $log" >&2; docker logs "$container" >&2 || true; exit 1; }
103-wait "$feeder"159+wait "$feeder" || { echo 'Scripted input failed.' >&2; exit 1; }
104 feeder=160 feeder=
105 wait "$launcher"161 wait "$launcher"
106 launcher=162 launcher=
163+# Firefox's links come from the accessibility tree; OCR (tesseract) must still load.
164+if grep -qE 'OCR unavailable|English OCR data missing' "$log"; then echo 'tesseract is missing in the image.' >&2; exit 1; fi
165+if grep -q 'capture needs sync file export' "$log"; then
166+ echo 'Hint scans failed: the compositor is not on a hardware Vulkan driver (radeon); see the header of this script.' >&2
167+ exit 1
168+fi
169+grep -qE '"atspi_targets":[1-9][0-9]*,.*"message":"hint scan"' "$log" ||
170+ { echo 'No hint scan found accessibility targets:' >&2; grep -E '"component":"(hints|atspi)"' "$log" | cut -c1-300 >&2; exit 1; }
171+grep '"message":"hint scan"' "$log" | sed -E 's/.*"atspi_targets":([0-9]+).*"ocr_links":([0-9]+).*"targets":([0-9]+).*/hint scan: \1 accessibility + \2 OCR links = \3 targets/' | tail -3
107 ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \172 ffmpeg -hide_banner -loglevel warning -f concat -safe 0 \
108 -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \173 -i "$work/shots/frames.ffconcat" -vf 'fps=30,format=yuv420p' \
109 -c:v libx264 -crf 23 -movflags +faststart docs/media/demo.mp4 -y174 -c:v libx264 -crf 23 -movflags +faststart docs/media/demo.mp4 -y
110-ffmpeg -hide_banner -loglevel warning -ss 7 -i docs/media/demo.mp4 \175+ffmpeg -hide_banner -loglevel warning -ss 8 -i docs/media/demo.mp4 \
111 -frames:v 1 -update 1 docs/media/demo.png -y176 -frames:v 1 -update 1 docs/media/demo.png -y
112 success=yes177 success=yes
113 if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi178 if [[ ${KEEP_CAPTURE:-0} == 1 ]]; then printf 'Captures kept: %s\n' "$work"; fi
D tools/demo/site/index.html +0 -13
@@ -1,13 +0,0 @@
1-<!doctype html>
2-<html lang="en">
3-<meta charset="utf-8">
4-<meta name="color-scheme" content="dark">
5-<title>NeferWL — Space to think</title>
6-<style>
7-*{box-sizing:border-box}body{margin:0;background:#101010;color:#ffffff;font:18px/1.6 sans-serif}main{max-width:960px;margin:auto;padding:60px 38px}header{color:#ffc799;letter-spacing:.2em;font:700 18px monospace}small{color:#ffc799}h1{font-size:54px;line-height:1.12;letter-spacing:-2px;margin:40px 0 24px}p{color:#a0a0a0}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:36px 0}.card{padding:18px;background:#161616;border:1px solid #282828;border-radius:12px}.card b{color:#ffc799}.card p{font-size:15px;margin-bottom:0}pre{background:#161616;border-left:3px solid #ffc799;border-radius:6px;padding:24px;font:16px/1.8 monospace;color:#ffc799}footer{color:#a0a0a0;font:14px monospace}
8-</style>
9-<main><header>◈ NEFERWL</header><h1>Your desktop.<br>A little more focused.</h1><p>A Wayland compositor with room for your work.<br>Tile naturally, set things aside, see the whole picture.</p><div class="cards"><section class="card"><b>01 / Performance</b><p>Small, responsive.<br>Built in Go. Made to stay out of your way.</p></section><section class="card"><b>02 / Stash</b><p>Keep tools close.<br>Set aside a window. Bring it back in a keystroke.</p></section><section class="card"><b>03 / Overview</b><p>Find your focus.<br>Every workspace and stashed window, at a glance.</p></section></div><pre># A desktop that flows with you
10-layout.max-columns = 2
11-layout.overflow = cascade
12-animations = on</pre><footer>SUPER + ↑ / ↓ &nbsp; bands &nbsp; · &nbsp; SUPER + S &nbsp; stash &nbsp; · &nbsp; SUPER + O &nbsp; overview</footer></main>
13-</html>
@@ -1,13 +0,0 @@
1-<!doctype html>
2-<html lang="en">
3-<meta charset="utf-8">
4-<meta name="color-scheme" content="dark">
5-<title>NeferWL — Space to think</title>
6-<style>
7-*{box-sizing:border-box}body{margin:0;background:#101010;color:#ffffff;font:18px/1.6 sans-serif}main{max-width:960px;margin:auto;padding:60px 38px}header{color:#ffc799;letter-spacing:.2em;font:700 18px monospace}small{color:#ffc799}h1{font-size:54px;line-height:1.12;letter-spacing:-2px;margin:40px 0 24px}p{color:#a0a0a0}.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:36px 0}.card{padding:18px;background:#161616;border:1px solid #282828;border-radius:12px}.card b{color:#ffc799}.card p{font-size:15px;margin-bottom:0}pre{background:#161616;border-left:3px solid #ffc799;border-radius:6px;padding:24px;font:16px/1.8 monospace;color:#ffc799}footer{color:#a0a0a0;font:14px monospace}
8-</style>
9-<main><header>◈ NEFERWL</header><h1>Your desktop.<br>A little more focused.</h1><p>A Wayland compositor with room for your work.<br>Tile naturally, set things aside, see the whole picture.</p><div class="cards"><section class="card"><b>01 / Performance</b><p>Small, responsive.<br>Built in Go. Made to stay out of your way.</p></section><section class="card"><b>02 / Stash</b><p>Keep tools close.<br>Set aside a window. Bring it back in a keystroke.</p></section><section class="card"><b>03 / Overview</b><p>Find your focus.<br>Every workspace and stashed window, at a glance.</p></section></div><pre># A desktop that flows with you
10-layout.max-columns = 2
11-layout.overflow = cascade
12-animations = on</pre><footer>SUPER + ↑ / ↓ &nbsp; bands &nbsp; · &nbsp; SUPER + S &nbsp; stash &nbsp; · &nbsp; SUPER + O &nbsp; overview</footer></main>
13-</html>

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 25

Tags 10

↑↓ moveenter openesc close