diff --git a/README.md b/README.md index 405d2ac..ed9559e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/groundstation-display/main.go b/cmd/groundstation-display/main.go index be751a8..ab5a295 100644 --- a/cmd/groundstation-display/main.go +++ b/cmd/groundstation-display/main.go @@ -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) } diff --git a/cmd/groundstation-display/main_test.go b/cmd/groundstation-display/main_test.go index 4a1a8f2..57d8bf4 100644 --- a/cmd/groundstation-display/main_test.go +++ b/cmd/groundstation-display/main_test.go @@ -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") } } diff --git a/internal/display/display.go b/internal/display/display.go index 1927f89..89868fb 100644 --- a/internal/display/display.go +++ b/internal/display/display.go @@ -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 { diff --git a/internal/display/display_test.go b/internal/display/display_test.go index 434a04a..25c0e55 100644 --- a/internal/display/display_test.go +++ b/internal/display/display_test.go @@ -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) { diff --git a/internal/display/osd.go b/internal/display/osd.go new file mode 100644 index 0000000..aac1b25 --- /dev/null +++ b/internal/display/osd.go @@ -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() +} diff --git a/internal/display/osd_test.go b/internal/display/osd_test.go new file mode 100644 index 0000000..d99fd38 --- /dev/null +++ b/internal/display/osd_test.go @@ -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) + } + } +} diff --git a/internal/display/x11.go b/internal/display/x11.go index 05221b5..9d79f68 100644 --- a/internal/display/x11.go +++ b/internal/display/x11.go @@ -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 diff --git a/internal/display/x11_test.go b/internal/display/x11_test.go index 7008067..2b39473 100644 --- a/internal/display/x11_test.go +++ b/internal/display/x11_test.go @@ -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()) + } +}