feat: split kiosk into fallback canvas and OSD overlay layers (#8)
Build Ground Station / build (push) Successful in 37s

Drive two independent visual layers per display target from one shared
video presence signal: the fullscreen fallback/menu canvas below video
and a transparent, input-transparent OSD overlay above video, reserved
as the future flight OSD rendering surface. WFB and video decode stay
out of the UI process; pilot video keeps rendering priority.
This commit was merged in pull request #12.
This commit is contained in:
2026-10-02 10:02:34 +02:00
parent 056cd5440a
commit 41552b4797
9 changed files with 676 additions and 115 deletions
+4 -3
View File
@@ -16,9 +16,10 @@ Current capabilities:
- logs channel changes instead of every frame;
- starts/stops `fpv-video` from the configured CH6 power/lifecycle control;
- starts an independent kiosk display before video, tolerates temporary X11 downtime without restarting the supervisor, and stops deterministically on shutdown;
- keeps the fallback canvas below `fpv-video`, with `Brak połączenia WFB` while video is absent.
- keeps the fullscreen fallback/menu canvas below `fpv-video`, showing `Brak połączenia WFB` only while video is absent;
- maps a transparent, input-transparent OSD overlay above `fpv-video` as the reserved flight OSD rendering surface: it never presents pixels over the video or intercepts its input.
Future MIPI/touch targets use their own template rather than sharing one global layout. Flight OSD will use a separate transparent layer above video, preserving the pilot stream's priority.
Future MIPI/touch targets use their own templates rather than sharing one global layout. The flight OSD will render on the existing transparent layer above video, preserving the pilot stream's priority.
## Installation on Orange Pi
@@ -57,5 +58,5 @@ serial:
## Runtime dependencies
- X11 session on `:0` owned by `orangepi`; the kiosk waits for X11 and reconnects after display-manager restarts;
- `wmctrl` for fullscreen/below kiosk state; without it the canvas maps as a normal window and the condition is logged;
- `wmctrl` for the fullscreen/stacking kiosk window states; without it the kiosk windows map as normal windows and the condition is logged;
- `runuser` from util-linux for the unprivileged kiosk launch.
+25 -15
View File
@@ -1,4 +1,4 @@
// Command groundstation-display renders the Ground Station kiosk canvas. It
// Command groundstation-display renders the Ground Station kiosk layers. It
// owns its display lifecycle independently of WFB, the transmitter and the
// video launcher: it waits for its target to appear, redraws as needed, and
// stops only when it receives SIGINT or SIGTERM.
@@ -16,19 +16,31 @@ import (
"git.piksar.eu/piotr/groundstation/internal/display"
)
// buildTarget selects the target-specific opener and template. Each target
// owns its own backend and layouts; there is no shared global template.
// buildTarget wires one shared video presence signal into both the opener
// (which observes the video output lifecycle) and the fallback template
// (which shows or hides itself from it).
func buildTarget(name string) (display.Opener, display.Template, error) {
// buildTarget selects the target-specific layers. Each target owns its own
// backend and layouts; there is no shared global template. The Viture target
// builds two independent layers — the fullscreen fallback/menu canvas below
// video and the transparent OSD overlay above it — wired to one shared video
// presence signal: the sessions observe the video output lifecycle on the
// display server and the templates draw or hide from it. WFB and video
// decode never move into this process.
func buildTarget(name string) ([]display.Layer, error) {
switch name {
case "x11":
video := display.NewVideoPresence()
return display.X11Opener{Video: video},
display.NewFallbackTemplate(video, "Brak połączenia WFB"), nil
return []display.Layer{
{
Name: "fallback-canvas",
Opener: display.X11Opener{Video: video},
Template: display.NewFallbackTemplate(video, "Brak połączenia WFB"),
},
{
Name: "osd-overlay",
Opener: display.X11Opener{Video: video, Role: display.RoleAbove},
Template: display.NewOSDTemplate(video),
},
}, nil
default:
return nil, nil, fmt.Errorf("unknown display target %q (supported: x11)", name)
return nil, fmt.Errorf("unknown display target %q (supported: x11)", name)
}
}
@@ -36,7 +48,7 @@ func main() {
target := flag.String("target", "x11", "display target: x11 (Viture) today; mipi planned")
flag.Parse()
opener, template, err := buildTarget(*target)
layers, err := buildTarget(*target)
if err != nil {
log.Fatal(err)
}
@@ -44,9 +56,7 @@ func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Temporary X11 unavailability never exits this process; display.Run
// Temporary X11 unavailability never exits this process; every layer
// keeps retrying internally until the context is cancelled.
if err := display.Run(ctx, opener, template, display.Options{}); err != nil {
log.Fatalf("display: %v", err)
}
display.RunLayers(ctx, layers)
}
+45 -23
View File
@@ -19,45 +19,67 @@ func (c *recordingCanvas) Text(_, _ int, message string) {
}
func (c *recordingCanvas) Flush() { c.flushes++ }
func TestBuildTargetWiresSharedVideoPresenceToFallback(t *testing.T) {
opener, template, err := buildTarget("x11")
func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
layers, err := buildTarget("x11")
if err != nil {
t.Fatal(err)
}
if len(layers) != 2 {
t.Fatalf("layers=%d want the fallback canvas and the OSD overlay", len(layers))
}
canvas, osd := layers[0], layers[1]
if canvas.Name != "fallback-canvas" || osd.Name != "osd-overlay" {
t.Fatalf("layer names %q/%q want fallback-canvas/osd-overlay", canvas.Name, osd.Name)
}
x11, ok := opener.(display.X11Opener)
canvasOpener, ok := canvas.Opener.(display.X11Opener)
if !ok {
t.Fatalf("opener %T must be the X11 target", opener)
t.Fatalf("canvas opener %T must be the X11 target", canvas.Opener)
}
if x11.Video == nil {
t.Fatal("x11 opener must observe the video presence signal")
if canvasOpener.Role != display.RoleBelow {
t.Fatalf("canvas layer role %v must stay below video", canvasOpener.Role)
}
osdOpener, ok := osd.Opener.(display.X11Opener)
if !ok {
t.Fatalf("OSD opener %T must be the X11 target", osd.Opener)
}
if osdOpener.Role != display.RoleAbove {
t.Fatalf("OSD layer role %v must stay above video", osdOpener.Role)
}
canvas := &recordingCanvas{width: 800, height: 600}
template.Draw(canvas)
if len(canvas.lines) != 1 || canvas.lines[0] != "Brak połączenia WFB" {
t.Fatalf("lines=%q want the exact fallback text", canvas.lines)
// Both layers must share one presence signal so the video lifecycle
// observed by either session drives both templates.
if canvasOpener.Video == nil || osdOpener.Video == nil || canvasOpener.Video != osdOpener.Video {
t.Fatal("layers must share one video presence signal")
}
// The opener and the template must share one presence signal so the
// video lifecycle observed by the session drives the fallback.
x11.Video.SetActive(true)
canvas.lines = nil
template.Draw(canvas)
if len(canvas.lines) != 0 {
t.Fatalf("lines=%q must disappear while video is present", canvas.lines)
// The canvas layer keeps the target-specific fallback template with the
// exact fallback text.
canvasSurface := &recordingCanvas{width: 800, height: 600}
canvas.Template.Draw(canvasSurface)
if len(canvasSurface.lines) != 1 || canvasSurface.lines[0] != "Brak połączenia WFB" {
t.Fatalf("canvas lines=%q want the exact fallback text", canvasSurface.lines)
}
x11.Video.SetActive(false)
canvas.lines = nil
template.Draw(canvas)
if len(canvas.lines) != 1 || canvas.lines[0] != "Brak połączenia WFB" {
t.Fatalf("lines=%q want the fallback back after video end", canvas.lines)
// The OSD layer is the OSD surface, never the fallback.
osdSurface := &recordingCanvas{width: 800, height: 600}
osd.Template.Draw(osdSurface)
if len(osdSurface.lines) != 0 {
t.Fatalf("OSD lines=%q want none: the overlay must not duplicate the fallback", osdSurface.lines)
}
// Video presence hides the fallback on the canvas layer only.
canvasOpener.Video.SetActive(true)
canvasSurface.lines = nil
canvas.Template.Draw(canvasSurface)
if len(canvasSurface.lines) != 0 {
t.Fatalf("canvas lines=%q must disappear while video is present", canvasSurface.lines)
}
canvasOpener.Video.SetActive(false)
}
func TestBuildTargetRejectsUnknownTarget(t *testing.T) {
if _, _, err := buildTarget("hdmi-mirror"); err == nil {
if _, err := buildTarget("hdmi-mirror"); err == nil {
t.Fatal("unknown target must fail")
}
}
+47 -10
View File
@@ -1,17 +1,21 @@
// Package display owns the Ground Station kiosk canvas lifecycle. The kiosk
// runs independently of WFB, the transmitter and the RC video knob: it waits
// for its rendering target to appear, keeps a fullscreen black canvas alive
// and stops only when told to. While decoded pilot video is absent the
// canvas shows the fallback; when video output is presented the video window
// takes rendering priority and the fallback disappears, returning when video
// ends. Presence is observed from the video output itself on the display
// server, never from WFB or launcher processes. Each target (X11/Viture
// today, MIPI/touch later) provides its own opener and templates instead of
// one global layout.
// Package display owns the Ground Station kiosk layers. The kiosk runs
// independently of WFB, the transmitter and the RC video knob: it waits for
// its rendering target to appear, keeps its windows alive and stops only
// when told to. Each target drives two independent visual layers: a
// fullscreen fallback/menu canvas below video, visible while decoded pilot
// video is absent, and a transparent, input-transparent OSD overlay above
// video, reserved as the future flight OSD rendering surface. When video
// output is presented the video window takes rendering priority and the
// fallback disappears, returning when video ends. Presence is observed from
// the video output itself on the display server, never from WFB or launcher
// processes, and the video pipeline is never moved into, coupled to or
// touched by this package. Each target (X11/Viture today, MIPI/touch later)
// provides its own openers and templates instead of one global layout.
package display
import (
"context"
"sync"
"time"
)
@@ -108,6 +112,39 @@ func Run(ctx context.Context, opener Opener, template Template, options Options)
}
}
// Layer is one independent visual layer of a display target: its own
// connection, window role and template. A lost or degraded layer never
// affects the others, so a missing overlay cannot disturb the fallback
// canvas and neither can disturb pilot video.
type Layer struct {
// Name identifies the layer in diagnostics.
Name string
// Opener connects to the layer's own window on the target.
Opener Opener
// Template renders the layer's target-specific layout.
Template Template
// Options tune the layer's lifecycle loop. Zero values select the
// defaults.
Options Options
}
// RunLayers drives every layer of a display target until ctx is cancelled,
// each on its own connection with its own reconnect backoff. A layer whose
// target keeps failing keeps retrying and never stops the other layers, so
// the Ground Station supervisor stays up. RunLayers returns only after every
// layer has stopped.
func RunLayers(ctx context.Context, layers []Layer) {
var wg sync.WaitGroup
for _, layer := range layers {
wg.Add(1)
go func(layer Layer) {
defer wg.Done()
_ = Run(ctx, layer.Opener, layer.Template, layer.Options)
}(layer)
}
wg.Wait()
}
func nextDelay(delay, max time.Duration) time.Duration {
delay *= 2
if delay > max {
+81
View File
@@ -242,6 +242,87 @@ func TestRunReconnectsAfterSessionFailure(t *testing.T) {
}
}
func TestRunLayersDrawsEveryLayerOnItsOwnCanvas(t *testing.T) {
canvasSession := newFakeSession()
osdSession := newFakeSession()
layers := []Layer{
{
Name: "fallback-canvas",
Opener: fakeOpener{open: func(int) (Session, error) { return canvasSession, nil }},
Template: NewStatusTemplate("canvas"),
},
{
Name: "osd-overlay",
Opener: fakeOpener{open: func(int) (Session, error) { return osdSession, nil }},
Template: NewOSDTemplate(NewVideoPresence()),
},
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
RunLayers(ctx, layers)
close(done)
}()
waitFor(t, func() bool { return canvasSession.canvas.lineCount() == 1 }, "canvas layer draw")
waitFor(t, func() bool {
clears, _ := osdSession.canvas.drawCounts()
return clears == 1
}, "osd layer transparent clear")
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("run layers did not stop after cancel")
}
if got := canvasSession.closeCount(); got != 1 {
t.Fatalf("canvas session closed %d times, want 1", got)
}
if got := osdSession.closeCount(); got != 1 {
t.Fatalf("osd session closed %d times, want 1", got)
}
}
func TestRunLayersKeepsOtherLayerAliveWhenOneTargetFails(t *testing.T) {
alive := newFakeSession()
layers := []Layer{
{
Name: "dead",
Opener: fakeOpener{open: func(int) (Session, error) { return nil, errors.New("target down") }},
Template: NewStatusTemplate("never drawn"),
},
{
Name: "alive",
Opener: fakeOpener{open: func(int) (Session, error) { return alive, nil }},
Template: NewOSDTemplate(NewVideoPresence()),
},
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
RunLayers(ctx, layers)
close(done)
}()
waitFor(t, func() bool {
clears, _ := alive.canvas.drawCounts()
return clears == 1
}, "alive layer keeps drawing while the other target fails")
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("run layers did not stop after cancel")
}
if got := alive.closeCount(); got != 1 {
t.Fatalf("alive session closed %d times, want 1", got)
}
}
func TestRunStopsWithoutConnectingWhenContextAlreadyCancelled(t *testing.T) {
var opens int32
opener := fakeOpener{open: func(int) (Session, error) {
+23
View File
@@ -0,0 +1,23 @@
package display
// OSDTemplate is the flight OSD layout of a display target: a transparent,
// input-transparent surface above pilot video, reserved as the future OSD
// rendering surface. It presents nothing yet and never draws the fallback:
// the OSD is a telemetry surface, not a video substitute, and WFB or video
// decode never move into the UI process to feed it. Flight telemetry
// rendering arrives in later Ground Station work; the shared presence keeps
// the OSD on the same video lifecycle as the fallback canvas, so the session
// redraws it on every presence transition.
type OSDTemplate struct {
video *VideoPresence
}
// NewOSDTemplate builds the OSD surface for a target.
func NewOSDTemplate(video *VideoPresence) OSDTemplate {
return OSDTemplate{video: video}
}
func (t OSDTemplate) Draw(canvas Canvas) {
canvas.Clear()
canvas.Flush()
}
+43
View File
@@ -0,0 +1,43 @@
package display
import "testing"
func TestOSDTemplateStaysTransparentWhileVideoAbsent(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
NewOSDTemplate(NewVideoPresence()).Draw(canvas)
clears, flushes := canvas.drawCounts()
if clears != 1 || flushes != 1 {
t.Fatalf("clears=%d flushes=%d want 1 and 1", clears, flushes)
}
if lines := canvas.lines(); len(lines) != 0 {
t.Fatalf("OSD lines=%q want none: the OSD is a telemetry surface, not a fallback", lines)
}
}
func TestOSDTemplateStaysTransparentWhileVideoPresent(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
presence.SetActive(true)
NewOSDTemplate(presence).Draw(canvas)
if lines := canvas.lines(); len(lines) != 0 {
t.Fatalf("OSD lines=%q want none while video is present", lines)
}
}
func TestOSDTemplateNeverDrawsTheFallbackText(t *testing.T) {
canvas := &fakeCanvas{width: 800, height: 600}
presence := NewVideoPresence()
template := NewOSDTemplate(presence)
for _, active := range []bool{false, true, false} {
presence.SetActive(active)
template.Draw(canvas)
if lines := canvas.lines(); len(lines) != 0 {
t.Fatalf("lines=%q after presence=%v want none", lines, active)
}
}
}
+291 -55
View File
@@ -11,6 +11,7 @@ import (
"unicode/utf16"
"github.com/BurntSushi/xgb"
"github.com/BurntSushi/xgb/shape"
"github.com/BurntSushi/xgb/xproto"
)
@@ -26,8 +27,93 @@ const (
maxImageText16Units = 127
// windowTypePropertyName is the EWMH property naming a window's role.
windowTypePropertyName = "_NET_WM_WINDOW_TYPE"
// layerMarkerPropertyName tags Ground Station layer windows so sibling
// layers — and their window-manager frames — never count as video
// output for each other.
layerMarkerPropertyName = "_GROUNDSTATION_LAYER"
)
// osdEventMask selects only repaint bookkeeping for the OSD overlay: no
// pointer, key or motion events, so the server never routes device input to
// the overlay even before its empty input shape is applied.
const osdEventMask = xproto.EventMaskExposure
// deviceInputEventMask are the pointer and key selection bits a layer above
// video must never select: device input belongs to the windows below.
const deviceInputEventMask = xproto.EventMaskKeyPress | xproto.EventMaskKeyRelease |
xproto.EventMaskButtonPress | xproto.EventMaskButtonRelease |
xproto.EventMaskPointerMotion
// LayerRole orders a Ground Station window against presented video output.
// The fallback/menu canvas stays below video; the OSD overlay stays above.
type LayerRole int
const (
// RoleBelow marks the fullscreen fallback/menu canvas under video.
RoleBelow LayerRole = iota
// RoleAbove marks the transparent, input-transparent OSD overlay above
// video.
RoleAbove
)
// layerMarkerValue is the layer marker payload identifying a role's window
// as our own.
func layerMarkerValue(role LayerRole) string {
if role == RoleAbove {
return "osd"
}
return "kiosk"
}
// markerIsGroundstation reports whether a layer marker payload marks one of
// our own windows: those are Ground Station layers, never video output.
func markerIsGroundstation(marker string) bool {
return marker == layerMarkerValue(RoleBelow) || marker == layerMarkerValue(RoleAbove)
}
// ewmhWindowTypeName is the EWMH window role announced for a layer. The OSD
// overlay announces DOCK, the always-above helper role, so window managers
// never decorate or bury it; the fallback canvas keeps announcing nothing,
// as before.
func ewmhWindowTypeName(role LayerRole) string {
if role == RoleAbove {
return "_NET_WM_WINDOW_TYPE_DOCK"
}
return ""
}
// fullscreenState is the wmctrl stacking state for a layer role.
func fullscreenState(role LayerRole) string {
if role == RoleAbove {
return "above"
}
return "below"
}
// stackModeForVideoAppear is where a layer goes relative to the first
// presented video window: the canvas parks below it, the OSD rises above it.
func stackModeForVideoAppear(role LayerRole) uint32 {
if role == RoleAbove {
return xproto.StackModeAbove
}
return xproto.StackModeBelow
}
// raisesOnVideoEnd reports whether a layer raises itself when video ends:
// only the below canvas needs that to make the fallback visible again; the
// OSD overlay is already above everything.
func raisesOnVideoEnd(role LayerRole) bool {
return role == RoleBelow
}
// transparentShapeKinds are the shape regions cleared to empty so the OSD
// overlay is transparent and passes input through: an empty input shape
// never receives pointer or key events, and an empty bounding shape presents
// nothing over the video below — with or without a compositor.
func transparentShapeKinds() []shape.Kind {
return []shape.Kind{shape.SkInput, shape.SkBounding}
}
// ewmhNonVideoTypes are EWMH auxiliary window roles that never count as
// video output: desktop shells, panels, menus, dialogs and notifications
// must not suppress the fallback. Windows without the property — typical
@@ -48,22 +134,27 @@ var ewmhNonVideoTypes = map[string]bool{
}
// X11Opener connects to an X server (selected by DISPLAY in the environment)
// and creates the fullscreen kiosk canvas. It is the display target for the
// Viture screen; future targets such as MIPI/touch provide their own Opener
// and templates.
// and maps one fullscreen kiosk window for the layer role it is built with:
// the fallback/menu canvas below video (RoleBelow) or the transparent,
// input-transparent OSD overlay above video (RoleAbove). It is the display
// target for the Viture screen; future targets such as MIPI/touch provide
// their own Opener and templates.
type X11Opener struct {
// PollInterval is how often X events are drained while waiting, so that
// cancellation stays responsive. Zero selects 100 ms.
PollInterval time.Duration
// Fullscreen applies the fullscreen/below window state. It defaults to
// Fullscreen applies the fullscreen window state. It defaults to
// shelling out to wmctrl; tests may replace it. A failure is logged and
// the canvas degrades to a plain window.
// the window degrades to a plain window.
Fullscreen func(args []string) error
// Video is the shared decoded-video presence signal. The session feeds
// it from the video output window's own lifecycle on the root screen;
// templates consume it to show or hide the fallback. When nil the
// opener tracks presence in a private signal.
// templates consume it to show or hide themselves. When nil the opener
// tracks presence in a private signal.
Video *VideoPresence
// Role orders this layer's window against presented video: RoleBelow
// for the fallback/menu canvas, RoleAbove for the OSD overlay.
Role LayerRole
}
func (o X11Opener) presence() *VideoPresence {
@@ -102,11 +193,46 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
return nil, fmt.Errorf("allocate window: %w", err)
}
// The OSD overlay selects no device input even before its empty input
// shape exists; the canvas keeps its legacy exposure-only mask.
eventMask := uint32(xproto.EventMaskExposure)
if o.Role == RoleAbove {
eventMask = osdEventMask
}
values := uint32(xproto.CwBackPixel | xproto.CwEventMask)
xproto.CreateWindow(conn, screen.RootDepth, window, screen.Root,
0, 0, screen.WidthInPixels, screen.HeightInPixels, 0,
xproto.WindowClassInputOutput, screen.RootVisual, values,
[]uint32{screen.BlackPixel, xproto.EventMaskExposure})
[]uint32{screen.BlackPixel, eventMask})
// Tag the window as a Ground Station layer and announce its EWMH role
// before mapping, so no observer or window manager ever sees an
// untagged frame of our own layers.
markerAtom := internAtom(conn, layerMarkerPropertyName)
if markerAtom != 0 {
marker := layerMarkerValue(o.Role)
xproto.ChangeProperty(conn, xproto.PropModeReplace, window, markerAtom,
xproto.AtomString, 8, uint32(len(marker)), []byte(marker))
}
if name := ewmhWindowTypeName(o.Role); name != "" {
typeProp := internAtom(conn, windowTypePropertyName)
roleAtom := internAtom(conn, name)
if typeProp != 0 && roleAtom != 0 {
value := make([]byte, 4)
binary.LittleEndian.PutUint32(value, uint32(roleAtom))
xproto.ChangeProperty(conn, xproto.PropModeReplace, window, typeProp,
xproto.AtomAtom, 32, 1, value)
}
}
// The OSD overlay must be transparent and input-transparent from its
// first mapped frame: the shapes are emptied before mapping, and a
// server without the shape extension never gets the overlay at all, so
// it can never present an unshaped frame over pilot video.
if o.Role == RoleAbove {
if err := applyTransparentShapes(conn, window); err != nil {
conn.Close()
return nil, err
}
}
xproto.MapWindow(conn, window)
// Watch the root screen so video output announces itself: a video
// window mapping is the first presented video frame, its unmap or
@@ -119,26 +245,27 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
return nil, fmt.Errorf("map kiosk window: %w", err)
}
// A kiosk restart must never cover running video: if video windows are
// already presented, record presence and hand rendering priority to the
// topmost one before the fallback is drawn anywhere.
// A layer reconnect must never cover running video: if video windows
// are already presented, record presence and take our side relative to
// the topmost one before anything is drawn.
presence := o.presence()
typeAtom := internWindowTypeAtom(conn)
videoWindows := scanVideoWindows(conn, screen.Root, window, typeAtom)
videoWindows := scanVideoWindows(conn, screen.Root, window, typeAtom, markerAtom)
if len(videoWindows) > 0 {
presence.SetActive(true)
stackWindowBelow(conn, window, videoWindows[len(videoWindows)-1])
stackWindowRelative(conn, window, videoWindows[len(videoWindows)-1],
stackModeForVideoAppear(o.Role))
} else {
presence.SetActive(false)
}
// Fullscreen on the root screen, and kept below the video window: pilot
// video owns rendering priority whenever it is available. The kiosk
// raises itself only after video ends, to bring the fallback back; a
// missing wmctrl only degrades to a plain window and must not kill the
// kiosk.
if err := o.applyFullscreen(x11FullscreenArgs(uint32(window))); err != nil {
log.Printf("kiosk fullscreen state: %v (canvas stays a plain window)", err)
// Fullscreen on the root screen, on our side of the video window: pilot
// video owns rendering priority whenever it is available. The canvas
// raises itself only after video ends, to bring the fallback back; the
// OSD overlay is already above video. A missing wmctrl only degrades to
// a plain window and must not kill the layer.
if err := o.applyFullscreen(x11FullscreenArgs(uint32(window), o.Role)); err != nil {
log.Printf("kiosk fullscreen state: %v (window stays a plain window)", err)
}
gc, err := xproto.NewGcontextId(conn)
@@ -171,6 +298,8 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
video: presence,
videoWindows: videoWindowSet(videoWindows),
typeAtom: typeAtom,
markerAtom: markerAtom,
role: o.Role,
unicodeFont: unicodeFont,
}, nil
}
@@ -183,53 +312,152 @@ func videoWindowSet(windows []xproto.Window) map[xproto.Window]struct{} {
return set
}
func x11FullscreenArgs(window uint32) []string {
return []string{"-i", "-r", fmt.Sprintf("0x%08x", window), "-b", "add,fullscreen,below"}
func x11FullscreenArgs(window uint32, role LayerRole) []string {
return []string{"-i", "-r", fmt.Sprintf("0x%08x", window),
"-b", "add,fullscreen," + fullscreenState(role)}
}
// applyTransparentShapes empties the overlay's input and bounding shapes
// before it is mapped: it then never intercepts pointer or key input and
// never presents pixels over pilot video, with or without a compositor.
// Future OSD content will punch its output shape per drawn region.
func applyTransparentShapes(conn *xgb.Conn, window xproto.Window) error {
if err := shape.Init(conn); err != nil {
return fmt.Errorf("shape extension: %w", err)
}
for _, kind := range transparentShapeKinds() {
if err := shape.RectanglesChecked(conn, shape.SoSet, kind, 0,
window, 0, 0, nil).Check(); err != nil {
return fmt.Errorf("empty shape region %d: %w", kind, err)
}
}
return nil
}
// scanVideoWindows lists already-presented video windows on the root screen
// in bottom-to-top stacking order, so a kiosk reconnect can yield priority
// to the topmost one and never cover running video.
func scanVideoWindows(conn *xgb.Conn, root, own xproto.Window, typeAtom xproto.Atom) []xproto.Window {
// in bottom-to-top stacking order, so a layer reconnect can take its side
// relative to the topmost one and never cover running video.
func scanVideoWindows(conn *xgb.Conn, root, own xproto.Window, typeAtom, markerAtom xproto.Atom) []xproto.Window {
tree, err := xproto.QueryTree(conn, root).Reply()
if err != nil {
return nil
}
var windows []xproto.Window
for _, w := range tree.Children {
if w != own && presentedVideoWindow(conn, w, typeAtom) {
if w != own && presentedVideoWindow(conn, w, typeAtom, markerAtom) {
windows = append(windows, w)
}
}
return windows
}
// presentedVideoWindow reports whether w is a visible output window. Only a
// real, presented window counts as video: input-only and override-redirect
// helpers such as menus or notifications never do, and neither do windows
// carrying an EWMH auxiliary window type.
func presentedVideoWindow(conn *xgb.Conn, w xproto.Window, typeAtom xproto.Atom) bool {
// windowFacts is the presentation evidence gathered for one candidate
// window, so the video decision stays a pure function.
type windowFacts struct {
inputOutput bool
overrideRedirect bool
viewable bool
auxiliary bool
groundstation bool
}
// isPresentedVideoWindow decides whether a window is video output. Only a
// real, presented output window counts as video: input-only and
// override-redirect helpers such as menus or notifications never do, and
// neither do windows carrying an EWMH auxiliary window type or a Ground
// Station layer marker.
func isPresentedVideoWindow(f windowFacts) bool {
return f.inputOutput && !f.overrideRedirect && f.viewable &&
!f.auxiliary && !f.groundstation
}
// presentedVideoWindow gathers the presentation facts of w.
func presentedVideoWindow(conn *xgb.Conn, w xproto.Window, typeAtom, markerAtom xproto.Atom) bool {
attrs, err := xproto.GetWindowAttributes(conn, w).Reply()
if err != nil {
return false
}
return attrs.Class == xproto.WindowClassInputOutput &&
!attrs.OverrideRedirect &&
attrs.MapState == xproto.MapStateViewable &&
!auxiliaryWindowType(conn, w, typeAtom)
return isPresentedVideoWindow(windowFacts{
inputOutput: attrs.Class == xproto.WindowClassInputOutput,
overrideRedirect: attrs.OverrideRedirect,
viewable: attrs.MapState == xproto.MapStateViewable,
auxiliary: auxiliaryWindowType(conn, w, typeAtom),
groundstation: groundstationLayerWindow(conn, w, markerAtom),
})
}
// internWindowTypeAtom resolves the EWMH window type property atom, or 0
// when the server refuses, disabling the auxiliary type filter.
func internWindowTypeAtom(conn *xgb.Conn) xproto.Atom {
// markerWalkBounds bound the descendant search for layer markers: window
// managers reparent client windows into frame windows, so a Ground Station
// layer must be recognized through its frame, and the search must stay
// cheap even on a busy desktop screen. depth must cover our window below
// one window-manager frame; nodes bounds the total windows inspected.
func markerWalkBounds() (depth, nodes int) {
return 4, 64
}
// groundstationLayerWindow reports whether w itself or any window below it
// carries our layer marker. Window managers frame our windows, so the
// marker must be found through frame windows too: otherwise a framed
// Ground Station layer could count as video output and permanently
// suppress the fallback. When the search hits its bounds or the server
// refuses, it reports not-found, degrading to the plain window checks.
func groundstationLayerWindow(conn *xgb.Conn, w xproto.Window, markerAtom xproto.Atom) bool {
if markerAtom == 0 {
return false
}
depth, nodes := markerWalkBounds()
visited := 0
var walk func(xproto.Window, int) bool
walk = func(w xproto.Window, depth int) bool {
if visited >= nodes {
return false
}
visited++
if carriesLayerMarker(conn, w, markerAtom) {
return true
}
if depth <= 0 {
return false
}
tree, err := xproto.QueryTree(conn, w).Reply()
if err != nil {
return false
}
for _, child := range tree.Children {
if walk(child, depth-1) {
return true
}
}
return false
}
return walk(w, depth)
}
// carriesLayerMarker reports whether w itself carries our layer marker.
func carriesLayerMarker(conn *xgb.Conn, w xproto.Window, markerAtom xproto.Atom) bool {
reply, err := xproto.GetProperty(conn, false, w, markerAtom,
xproto.GetPropertyTypeAny, 0, 64).Reply()
if err != nil || reply.ValueLen == 0 {
return false
}
return markerIsGroundstation(string(reply.Value))
}
// internAtom resolves an atom by name, or 0 when the server refuses,
// disabling the checks that would use it.
func internAtom(conn *xgb.Conn, name string) xproto.Atom {
reply, err := xproto.InternAtom(conn, false,
uint16(len(windowTypePropertyName)), windowTypePropertyName).Reply()
uint16(len(name)), name).Reply()
if err != nil {
return 0
}
return reply.Atom
}
func internWindowTypeAtom(conn *xgb.Conn) xproto.Atom {
return internAtom(conn, windowTypePropertyName)
}
// auxiliaryWindowType reports whether the window carries an EWMH window
// type naming a non-video helper role. A missing property means the window
// counts as video.
@@ -255,10 +483,11 @@ func auxiliaryWindowType(conn *xgb.Conn, w xproto.Window, typeAtom xproto.Atom)
return false
}
func stackWindowBelow(conn *xgb.Conn, window, sibling xproto.Window) {
// stackWindowRelative moves window to the given side of sibling.
func stackWindowRelative(conn *xgb.Conn, window, sibling xproto.Window, mode uint32) {
xproto.ConfigureWindow(conn, window,
xproto.ConfigWindowSibling|xproto.ConfigWindowStackMode,
[]uint32{uint32(sibling), xproto.StackModeBelow})
[]uint32{uint32(sibling), mode})
}
// videoChange is the video-lifecycle meaning of one root structure event.
@@ -328,6 +557,8 @@ type x11Session struct {
video *VideoPresence
videoWindows map[xproto.Window]struct{}
typeAtom xproto.Atom
markerAtom xproto.Atom
role LayerRole
unicodeFont bool
}
@@ -395,34 +626,39 @@ func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
}
// applyVideoChange folds one root structure event into the shared video
// presence and restacks the kiosk so video keeps rendering priority. It
// reports whether the template must be redrawn: video appearing makes the
// fallback disappear and video ending brings it back. Presence follows the
// set of presented video windows, so an unrelated window appearing or
// ending can never hide or resurrect the fallback on its own.
// presence and restacks this layer so video keeps rendering priority: the
// canvas parks below video and the OSD overlay rises above it. It reports
// whether the template must be redrawn: video appearing makes the fallback
// disappear and video ending brings it back. Presence follows the set of
// presented video windows, so an unrelated window appearing or ending can
// never hide or resurrect a template on its own.
func (s *x11Session) applyVideoChange(event xgb.Event) bool {
change, w := classifyVideoChange(event, s.window)
switch change {
case videoAppeared:
// Menus, notifications and other auxiliary windows never count as
// video; only a presented output window does.
if !presentedVideoWindow(s.conn, w, s.typeAtom) {
// Menus, notifications, other auxiliary windows and our own sibling
// layers never count as video; only a presented output window does.
if !presentedVideoWindow(s.conn, w, s.typeAtom, s.markerAtom) {
return false
}
if !addVideoWindow(s.videoWindows, w) {
return false
}
// The first presented video output takes priority: park below it.
stackWindowBelow(s.conn, s.window, w)
// The first presented video output takes priority: park on our side
// of it.
stackWindowRelative(s.conn, s.window, w, stackModeForVideoAppear(s.role))
return s.video.SetActive(true)
case videoDisappeared:
if !removeVideoWindow(s.videoWindows, w) {
return false
}
// Raise the kiosk so the fallback returns visibly even if some
// other window was stacked above it.
xproto.ConfigureWindow(s.conn, s.window,
xproto.ConfigWindowStackMode, []uint32{xproto.StackModeAbove})
// Only the below canvas raises itself so the fallback returns
// visibly even if some other window was stacked above it; the OSD
// overlay is already above video.
if raisesOnVideoEnd(s.role) {
xproto.ConfigureWindow(s.conn, s.window,
xproto.ConfigWindowStackMode, []uint32{xproto.StackModeAbove})
}
return s.video.SetActive(false)
default:
return false
+117 -9
View File
@@ -5,18 +5,25 @@ import (
"strings"
"testing"
"github.com/BurntSushi/xgb/shape"
"github.com/BurntSushi/xgb/xproto"
)
func TestX11FullscreenArgsKeepCanvasBelowVideo(t *testing.T) {
args := x11FullscreenArgs(0x1a2b)
joined := strings.Join(args, " ")
if !strings.Contains(joined, "add,fullscreen,below") {
t.Fatalf("args %v must request fullscreen with below stacking", args)
func TestX11FullscreenArgsOrderByRole(t *testing.T) {
below := strings.Join(x11FullscreenArgs(0x1a2b, RoleBelow), " ")
if !strings.Contains(below, "add,fullscreen,below") {
t.Fatalf("args %q must request fullscreen with below stacking", below)
}
if !strings.Contains(joined, "0x00001a2b") {
t.Fatalf("args %v must reference the kiosk window id", args)
if !strings.Contains(below, "0x00001a2b") {
t.Fatalf("args %q must reference the kiosk window id", below)
}
above := strings.Join(x11FullscreenArgs(0x1a2b, RoleAbove), " ")
if strings.Contains(above, "below") {
t.Fatalf("args %q must never stack the OSD overlay below video", above)
}
if !strings.Contains(above, "add,fullscreen,above") {
t.Fatalf("args %q must request fullscreen with above stacking", above)
}
}
@@ -36,7 +43,7 @@ func TestX11OpenerAppliesFullscreenArgsThroughHook(t *testing.T) {
return errors.New("wmctrl missing")
}}
if err := opener.applyFullscreen(x11FullscreenArgs(0x42)); err == nil {
if err := opener.applyFullscreen(x11FullscreenArgs(0x42, RoleBelow)); err == nil {
t.Fatal("hook error must propagate for logging")
}
if len(got) != 1 || !strings.Contains(strings.Join(got[0], " "), "add,fullscreen,below") {
@@ -189,3 +196,104 @@ func TestX11FontPatternRequestsUnicodeFixedFont(t *testing.T) {
t.Fatalf("font pattern %q must select a Unicode encoding for the exact fallback text", iso10646FontPattern)
}
}
func TestLayerMarkerIdentifiesOwnLayerWindows(t *testing.T) {
if layerMarkerValue(RoleBelow) == layerMarkerValue(RoleAbove) {
t.Fatal("roles must carry distinct marker payloads")
}
for _, role := range []LayerRole{RoleBelow, RoleAbove} {
if !markerIsGroundstation(layerMarkerValue(role)) {
t.Fatalf("marker %q must identify our own window", layerMarkerValue(role))
}
}
for _, foreign := range []string{"", "video", "desktop", "groundstation-fake"} {
if markerIsGroundstation(foreign) {
t.Fatalf("foreign marker payload %q must not identify our windows", foreign)
}
}
}
func TestMarkerWalkBoundsStayBoundedAndCoverFrameDepth(t *testing.T) {
depth, nodes := markerWalkBounds()
if depth <= 0 || nodes <= 0 {
t.Fatalf("marker walk bounds depth=%d nodes=%d must be positive", depth, nodes)
}
if nodes < depth {
t.Fatalf("node budget %d must cover at least a depth-%d ancestor chain", nodes, depth)
}
// A reparenting window manager puts each of our two layer windows inside
// its own frame, so the walk must reach our window below one frame.
if depth < 2 {
t.Fatalf("marker walk depth %d must cover our window below a window-manager frame", depth)
}
}
func TestEWMHWindowTypeAnnouncesOverlayRoleOnly(t *testing.T) {
if name := ewmhWindowTypeName(RoleAbove); !strings.Contains(name, "DOCK") {
t.Fatalf("OSD overlay window type %q must be the always-above helper role", name)
}
if name := ewmhWindowTypeName(RoleBelow); name != "" {
t.Fatalf("fallback canvas must keep announcing no window type, got %q", name)
}
}
func TestPresentedVideoDecisionExcludesOwnLayersAndHelpers(t *testing.T) {
video := windowFacts{inputOutput: true, viewable: true}
if !isPresentedVideoWindow(video) {
t.Fatal("a presented output window must count as video")
}
for name, facts := range map[string]windowFacts{
"input-only": {viewable: true},
"override-redirect": {inputOutput: true, viewable: true, overrideRedirect: true},
"unmapped": {inputOutput: true},
"auxiliary": {inputOutput: true, viewable: true, auxiliary: true},
"groundstation-canvas": {inputOutput: true, viewable: true, groundstation: true},
"groundstation-osd": {inputOutput: true, viewable: true, groundstation: true},
} {
if isPresentedVideoWindow(facts) {
t.Fatalf("%s window must never count as video", name)
}
}
// Window-manager frames around our layer windows carry no marker of
// their own; groundstationLayerWindow finds the marker below the frame,
// so framed Ground Station layers land in these same excluded facts.
}
func TestStackOrderByRoleKeepsLayersOnTheirSideOfVideo(t *testing.T) {
if mode := stackModeForVideoAppear(RoleBelow); mode != xproto.StackModeBelow {
t.Fatalf("canvas must park below appearing video, got %v", mode)
}
if mode := stackModeForVideoAppear(RoleAbove); mode != xproto.StackModeAbove {
t.Fatalf("OSD overlay must rise above appearing video, got %v", mode)
}
if !raisesOnVideoEnd(RoleBelow) {
t.Fatal("canvas must raise itself when video ends so the fallback returns")
}
if raisesOnVideoEnd(RoleAbove) {
t.Fatal("OSD overlay is already above video and must not restack on video end")
}
}
func TestOSDEventMaskNeverSelectsDeviceInput(t *testing.T) {
if osdEventMask&deviceInputEventMask != 0 {
t.Fatalf("OSD event mask must not select pointer or key input: %b", osdEventMask)
}
if osdEventMask&xproto.EventMaskExposure == 0 {
t.Fatal("OSD event mask must still select exposure for redraws")
}
}
func TestTransparentShapesEmptyInputAndOutput(t *testing.T) {
var input, output bool
for _, kind := range transparentShapeKinds() {
switch kind {
case shape.SkInput:
input = true
case shape.SkBounding:
output = true
}
}
if !input || !output {
t.Fatalf("shape kinds %v must empty both the input and the bounding region", transparentShapeKinds())
}
}