feat: split kiosk into fallback canvas and OSD overlay layers (#8)
Build Ground Station / build (push) Successful in 37s
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:
@@ -16,9 +16,10 @@ Current capabilities:
|
|||||||
- logs channel changes instead of every frame;
|
- logs channel changes instead of every frame;
|
||||||
- starts/stops `fpv-video` from the configured CH6 power/lifecycle control;
|
- 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;
|
- 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
|
## Installation on Orange Pi
|
||||||
|
|
||||||
@@ -57,5 +58,5 @@ serial:
|
|||||||
## Runtime dependencies
|
## Runtime dependencies
|
||||||
|
|
||||||
- X11 session on `:0` owned by `orangepi`; the kiosk waits for X11 and reconnects after display-manager restarts;
|
- 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.
|
- `runuser` from util-linux for the unprivileged kiosk launch.
|
||||||
|
|||||||
@@ -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
|
// owns its display lifecycle independently of WFB, the transmitter and the
|
||||||
// video launcher: it waits for its target to appear, redraws as needed, and
|
// video launcher: it waits for its target to appear, redraws as needed, and
|
||||||
// stops only when it receives SIGINT or SIGTERM.
|
// stops only when it receives SIGINT or SIGTERM.
|
||||||
@@ -16,19 +16,31 @@ import (
|
|||||||
"git.piksar.eu/piotr/groundstation/internal/display"
|
"git.piksar.eu/piotr/groundstation/internal/display"
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildTarget selects the target-specific opener and template. Each target
|
// buildTarget selects the target-specific layers. Each target owns its own
|
||||||
// owns its own backend and layouts; there is no shared global template.
|
// backend and layouts; there is no shared global template. The Viture target
|
||||||
// buildTarget wires one shared video presence signal into both the opener
|
// builds two independent layers — the fullscreen fallback/menu canvas below
|
||||||
// (which observes the video output lifecycle) and the fallback template
|
// video and the transparent OSD overlay above it — wired to one shared video
|
||||||
// (which shows or hides itself from it).
|
// presence signal: the sessions observe the video output lifecycle on the
|
||||||
func buildTarget(name string) (display.Opener, display.Template, error) {
|
// 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 {
|
switch name {
|
||||||
case "x11":
|
case "x11":
|
||||||
video := display.NewVideoPresence()
|
video := display.NewVideoPresence()
|
||||||
return display.X11Opener{Video: video},
|
return []display.Layer{
|
||||||
display.NewFallbackTemplate(video, "Brak połączenia WFB"), nil
|
{
|
||||||
|
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:
|
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")
|
target := flag.String("target", "x11", "display target: x11 (Viture) today; mipi planned")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
opener, template, err := buildTarget(*target)
|
layers, err := buildTarget(*target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -44,9 +56,7 @@ func main() {
|
|||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
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.
|
// keeps retrying internally until the context is cancelled.
|
||||||
if err := display.Run(ctx, opener, template, display.Options{}); err != nil {
|
display.RunLayers(ctx, layers)
|
||||||
log.Fatalf("display: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,45 +19,67 @@ func (c *recordingCanvas) Text(_, _ int, message string) {
|
|||||||
}
|
}
|
||||||
func (c *recordingCanvas) Flush() { c.flushes++ }
|
func (c *recordingCanvas) Flush() { c.flushes++ }
|
||||||
|
|
||||||
func TestBuildTargetWiresSharedVideoPresenceToFallback(t *testing.T) {
|
func TestBuildTargetBuildsIndependentVitureLayers(t *testing.T) {
|
||||||
opener, template, err := buildTarget("x11")
|
layers, err := buildTarget("x11")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
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 {
|
if canvasOpener.Role != display.RoleBelow {
|
||||||
t.Fatal("x11 opener must observe the video presence signal")
|
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}
|
// Both layers must share one presence signal so the video lifecycle
|
||||||
template.Draw(canvas)
|
// observed by either session drives both templates.
|
||||||
if len(canvas.lines) != 1 || canvas.lines[0] != "Brak połączenia WFB" {
|
if canvasOpener.Video == nil || osdOpener.Video == nil || canvasOpener.Video != osdOpener.Video {
|
||||||
t.Fatalf("lines=%q want the exact fallback text", canvas.lines)
|
t.Fatal("layers must share one video presence signal")
|
||||||
}
|
}
|
||||||
|
|
||||||
// The opener and the template must share one presence signal so the
|
// The canvas layer keeps the target-specific fallback template with the
|
||||||
// video lifecycle observed by the session drives the fallback.
|
// exact fallback text.
|
||||||
x11.Video.SetActive(true)
|
canvasSurface := &recordingCanvas{width: 800, height: 600}
|
||||||
canvas.lines = nil
|
canvas.Template.Draw(canvasSurface)
|
||||||
template.Draw(canvas)
|
if len(canvasSurface.lines) != 1 || canvasSurface.lines[0] != "Brak połączenia WFB" {
|
||||||
if len(canvas.lines) != 0 {
|
t.Fatalf("canvas lines=%q want the exact fallback text", canvasSurface.lines)
|
||||||
t.Fatalf("lines=%q must disappear while video is present", canvas.lines)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
x11.Video.SetActive(false)
|
// The OSD layer is the OSD surface, never the fallback.
|
||||||
canvas.lines = nil
|
osdSurface := &recordingCanvas{width: 800, height: 600}
|
||||||
template.Draw(canvas)
|
osd.Template.Draw(osdSurface)
|
||||||
if len(canvas.lines) != 1 || canvas.lines[0] != "Brak połączenia WFB" {
|
if len(osdSurface.lines) != 0 {
|
||||||
t.Fatalf("lines=%q want the fallback back after video end", canvas.lines)
|
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) {
|
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")
|
t.Fatal("unknown target must fail")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-10
@@ -1,17 +1,21 @@
|
|||||||
// Package display owns the Ground Station kiosk canvas lifecycle. The kiosk
|
// Package display owns the Ground Station kiosk layers. The kiosk runs
|
||||||
// runs independently of WFB, the transmitter and the RC video knob: it waits
|
// independently of WFB, the transmitter and the RC video knob: it waits for
|
||||||
// for its rendering target to appear, keeps a fullscreen black canvas alive
|
// its rendering target to appear, keeps its windows alive and stops only
|
||||||
// and stops only when told to. While decoded pilot video is absent the
|
// when told to. Each target drives two independent visual layers: a
|
||||||
// canvas shows the fallback; when video output is presented the video window
|
// fullscreen fallback/menu canvas below video, visible while decoded pilot
|
||||||
// takes rendering priority and the fallback disappears, returning when video
|
// video is absent, and a transparent, input-transparent OSD overlay above
|
||||||
// ends. Presence is observed from the video output itself on the display
|
// video, reserved as the future flight OSD rendering surface. When video
|
||||||
// server, never from WFB or launcher processes. Each target (X11/Viture
|
// output is presented the video window takes rendering priority and the
|
||||||
// today, MIPI/touch later) provides its own opener and templates instead of
|
// fallback disappears, returning when video ends. Presence is observed from
|
||||||
// one global layout.
|
// 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
|
package display
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sync"
|
||||||
"time"
|
"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 {
|
func nextDelay(delay, max time.Duration) time.Duration {
|
||||||
delay *= 2
|
delay *= 2
|
||||||
if delay > max {
|
if delay > max {
|
||||||
|
|||||||
@@ -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) {
|
func TestRunStopsWithoutConnectingWhenContextAlreadyCancelled(t *testing.T) {
|
||||||
var opens int32
|
var opens int32
|
||||||
opener := fakeOpener{open: func(int) (Session, error) {
|
opener := fakeOpener{open: func(int) (Session, error) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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
@@ -11,6 +11,7 @@ import (
|
|||||||
"unicode/utf16"
|
"unicode/utf16"
|
||||||
|
|
||||||
"github.com/BurntSushi/xgb"
|
"github.com/BurntSushi/xgb"
|
||||||
|
"github.com/BurntSushi/xgb/shape"
|
||||||
"github.com/BurntSushi/xgb/xproto"
|
"github.com/BurntSushi/xgb/xproto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,8 +27,93 @@ const (
|
|||||||
maxImageText16Units = 127
|
maxImageText16Units = 127
|
||||||
// windowTypePropertyName is the EWMH property naming a window's role.
|
// windowTypePropertyName is the EWMH property naming a window's role.
|
||||||
windowTypePropertyName = "_NET_WM_WINDOW_TYPE"
|
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
|
// ewmhNonVideoTypes are EWMH auxiliary window roles that never count as
|
||||||
// video output: desktop shells, panels, menus, dialogs and notifications
|
// video output: desktop shells, panels, menus, dialogs and notifications
|
||||||
// must not suppress the fallback. Windows without the property — typical
|
// 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)
|
// 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
|
// and maps one fullscreen kiosk window for the layer role it is built with:
|
||||||
// Viture screen; future targets such as MIPI/touch provide their own Opener
|
// the fallback/menu canvas below video (RoleBelow) or the transparent,
|
||||||
// and templates.
|
// 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 {
|
type X11Opener struct {
|
||||||
// PollInterval is how often X events are drained while waiting, so that
|
// PollInterval is how often X events are drained while waiting, so that
|
||||||
// cancellation stays responsive. Zero selects 100 ms.
|
// cancellation stays responsive. Zero selects 100 ms.
|
||||||
PollInterval time.Duration
|
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
|
// 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
|
Fullscreen func(args []string) error
|
||||||
// Video is the shared decoded-video presence signal. The session feeds
|
// Video is the shared decoded-video presence signal. The session feeds
|
||||||
// it from the video output window's own lifecycle on the root screen;
|
// 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
|
// templates consume it to show or hide themselves. When nil the opener
|
||||||
// opener tracks presence in a private signal.
|
// tracks presence in a private signal.
|
||||||
Video *VideoPresence
|
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 {
|
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)
|
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)
|
values := uint32(xproto.CwBackPixel | xproto.CwEventMask)
|
||||||
xproto.CreateWindow(conn, screen.RootDepth, window, screen.Root,
|
xproto.CreateWindow(conn, screen.RootDepth, window, screen.Root,
|
||||||
0, 0, screen.WidthInPixels, screen.HeightInPixels, 0,
|
0, 0, screen.WidthInPixels, screen.HeightInPixels, 0,
|
||||||
xproto.WindowClassInputOutput, screen.RootVisual, values,
|
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)
|
xproto.MapWindow(conn, window)
|
||||||
// Watch the root screen so video output announces itself: a video
|
// Watch the root screen so video output announces itself: a video
|
||||||
// window mapping is the first presented video frame, its unmap or
|
// 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)
|
return nil, fmt.Errorf("map kiosk window: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A kiosk restart must never cover running video: if video windows are
|
// A layer reconnect must never cover running video: if video windows
|
||||||
// already presented, record presence and hand rendering priority to the
|
// are already presented, record presence and take our side relative to
|
||||||
// topmost one before the fallback is drawn anywhere.
|
// the topmost one before anything is drawn.
|
||||||
presence := o.presence()
|
presence := o.presence()
|
||||||
typeAtom := internWindowTypeAtom(conn)
|
typeAtom := internWindowTypeAtom(conn)
|
||||||
videoWindows := scanVideoWindows(conn, screen.Root, window, typeAtom)
|
videoWindows := scanVideoWindows(conn, screen.Root, window, typeAtom, markerAtom)
|
||||||
if len(videoWindows) > 0 {
|
if len(videoWindows) > 0 {
|
||||||
presence.SetActive(true)
|
presence.SetActive(true)
|
||||||
stackWindowBelow(conn, window, videoWindows[len(videoWindows)-1])
|
stackWindowRelative(conn, window, videoWindows[len(videoWindows)-1],
|
||||||
|
stackModeForVideoAppear(o.Role))
|
||||||
} else {
|
} else {
|
||||||
presence.SetActive(false)
|
presence.SetActive(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fullscreen on the root screen, and kept below the video window: pilot
|
// Fullscreen on the root screen, on our side of the video window: pilot
|
||||||
// video owns rendering priority whenever it is available. The kiosk
|
// video owns rendering priority whenever it is available. The canvas
|
||||||
// raises itself only after video ends, to bring the fallback back; a
|
// raises itself only after video ends, to bring the fallback back; the
|
||||||
// missing wmctrl only degrades to a plain window and must not kill the
|
// OSD overlay is already above video. A missing wmctrl only degrades to
|
||||||
// kiosk.
|
// a plain window and must not kill the layer.
|
||||||
if err := o.applyFullscreen(x11FullscreenArgs(uint32(window))); err != nil {
|
if err := o.applyFullscreen(x11FullscreenArgs(uint32(window), o.Role)); err != nil {
|
||||||
log.Printf("kiosk fullscreen state: %v (canvas stays a plain window)", err)
|
log.Printf("kiosk fullscreen state: %v (window stays a plain window)", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
gc, err := xproto.NewGcontextId(conn)
|
gc, err := xproto.NewGcontextId(conn)
|
||||||
@@ -171,6 +298,8 @@ func (o X11Opener) Open(ctx context.Context) (Session, error) {
|
|||||||
video: presence,
|
video: presence,
|
||||||
videoWindows: videoWindowSet(videoWindows),
|
videoWindows: videoWindowSet(videoWindows),
|
||||||
typeAtom: typeAtom,
|
typeAtom: typeAtom,
|
||||||
|
markerAtom: markerAtom,
|
||||||
|
role: o.Role,
|
||||||
unicodeFont: unicodeFont,
|
unicodeFont: unicodeFont,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -183,53 +312,152 @@ func videoWindowSet(windows []xproto.Window) map[xproto.Window]struct{} {
|
|||||||
return set
|
return set
|
||||||
}
|
}
|
||||||
|
|
||||||
func x11FullscreenArgs(window uint32) []string {
|
func x11FullscreenArgs(window uint32, role LayerRole) []string {
|
||||||
return []string{"-i", "-r", fmt.Sprintf("0x%08x", window), "-b", "add,fullscreen,below"}
|
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
|
// scanVideoWindows lists already-presented video windows on the root screen
|
||||||
// in bottom-to-top stacking order, so a kiosk reconnect can yield priority
|
// in bottom-to-top stacking order, so a layer reconnect can take its side
|
||||||
// to the topmost one and never cover running video.
|
// relative to the topmost one and never cover running video.
|
||||||
func scanVideoWindows(conn *xgb.Conn, root, own xproto.Window, typeAtom xproto.Atom) []xproto.Window {
|
func scanVideoWindows(conn *xgb.Conn, root, own xproto.Window, typeAtom, markerAtom xproto.Atom) []xproto.Window {
|
||||||
tree, err := xproto.QueryTree(conn, root).Reply()
|
tree, err := xproto.QueryTree(conn, root).Reply()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var windows []xproto.Window
|
var windows []xproto.Window
|
||||||
for _, w := range tree.Children {
|
for _, w := range tree.Children {
|
||||||
if w != own && presentedVideoWindow(conn, w, typeAtom) {
|
if w != own && presentedVideoWindow(conn, w, typeAtom, markerAtom) {
|
||||||
windows = append(windows, w)
|
windows = append(windows, w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return windows
|
return windows
|
||||||
}
|
}
|
||||||
|
|
||||||
// presentedVideoWindow reports whether w is a visible output window. Only a
|
// windowFacts is the presentation evidence gathered for one candidate
|
||||||
// real, presented window counts as video: input-only and override-redirect
|
// window, so the video decision stays a pure function.
|
||||||
// helpers such as menus or notifications never do, and neither do windows
|
type windowFacts struct {
|
||||||
// carrying an EWMH auxiliary window type.
|
inputOutput bool
|
||||||
func presentedVideoWindow(conn *xgb.Conn, w xproto.Window, typeAtom xproto.Atom) 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()
|
attrs, err := xproto.GetWindowAttributes(conn, w).Reply()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return attrs.Class == xproto.WindowClassInputOutput &&
|
return isPresentedVideoWindow(windowFacts{
|
||||||
!attrs.OverrideRedirect &&
|
inputOutput: attrs.Class == xproto.WindowClassInputOutput,
|
||||||
attrs.MapState == xproto.MapStateViewable &&
|
overrideRedirect: attrs.OverrideRedirect,
|
||||||
!auxiliaryWindowType(conn, w, typeAtom)
|
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
|
// markerWalkBounds bound the descendant search for layer markers: window
|
||||||
// when the server refuses, disabling the auxiliary type filter.
|
// managers reparent client windows into frame windows, so a Ground Station
|
||||||
func internWindowTypeAtom(conn *xgb.Conn) xproto.Atom {
|
// 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,
|
reply, err := xproto.InternAtom(conn, false,
|
||||||
uint16(len(windowTypePropertyName)), windowTypePropertyName).Reply()
|
uint16(len(name)), name).Reply()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
return reply.Atom
|
return reply.Atom
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func internWindowTypeAtom(conn *xgb.Conn) xproto.Atom {
|
||||||
|
return internAtom(conn, windowTypePropertyName)
|
||||||
|
}
|
||||||
|
|
||||||
// auxiliaryWindowType reports whether the window carries an EWMH window
|
// auxiliaryWindowType reports whether the window carries an EWMH window
|
||||||
// type naming a non-video helper role. A missing property means the window
|
// type naming a non-video helper role. A missing property means the window
|
||||||
// counts as video.
|
// counts as video.
|
||||||
@@ -255,10 +483,11 @@ func auxiliaryWindowType(conn *xgb.Conn, w xproto.Window, typeAtom xproto.Atom)
|
|||||||
return false
|
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.ConfigureWindow(conn, window,
|
||||||
xproto.ConfigWindowSibling|xproto.ConfigWindowStackMode,
|
xproto.ConfigWindowSibling|xproto.ConfigWindowStackMode,
|
||||||
[]uint32{uint32(sibling), xproto.StackModeBelow})
|
[]uint32{uint32(sibling), mode})
|
||||||
}
|
}
|
||||||
|
|
||||||
// videoChange is the video-lifecycle meaning of one root structure event.
|
// videoChange is the video-lifecycle meaning of one root structure event.
|
||||||
@@ -328,6 +557,8 @@ type x11Session struct {
|
|||||||
video *VideoPresence
|
video *VideoPresence
|
||||||
videoWindows map[xproto.Window]struct{}
|
videoWindows map[xproto.Window]struct{}
|
||||||
typeAtom xproto.Atom
|
typeAtom xproto.Atom
|
||||||
|
markerAtom xproto.Atom
|
||||||
|
role LayerRole
|
||||||
unicodeFont bool
|
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
|
// applyVideoChange folds one root structure event into the shared video
|
||||||
// presence and restacks the kiosk so video keeps rendering priority. It
|
// presence and restacks this layer so video keeps rendering priority: the
|
||||||
// reports whether the template must be redrawn: video appearing makes the
|
// canvas parks below video and the OSD overlay rises above it. It reports
|
||||||
// fallback disappear and video ending brings it back. Presence follows the
|
// whether the template must be redrawn: video appearing makes the fallback
|
||||||
// set of presented video windows, so an unrelated window appearing or
|
// disappear and video ending brings it back. Presence follows the set of
|
||||||
// ending can never hide or resurrect the fallback on its own.
|
// 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 {
|
func (s *x11Session) applyVideoChange(event xgb.Event) bool {
|
||||||
change, w := classifyVideoChange(event, s.window)
|
change, w := classifyVideoChange(event, s.window)
|
||||||
switch change {
|
switch change {
|
||||||
case videoAppeared:
|
case videoAppeared:
|
||||||
// Menus, notifications and other auxiliary windows never count as
|
// Menus, notifications, other auxiliary windows and our own sibling
|
||||||
// video; only a presented output window does.
|
// layers never count as video; only a presented output window does.
|
||||||
if !presentedVideoWindow(s.conn, w, s.typeAtom) {
|
if !presentedVideoWindow(s.conn, w, s.typeAtom, s.markerAtom) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if !addVideoWindow(s.videoWindows, w) {
|
if !addVideoWindow(s.videoWindows, w) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// The first presented video output takes priority: park below it.
|
// The first presented video output takes priority: park on our side
|
||||||
stackWindowBelow(s.conn, s.window, w)
|
// of it.
|
||||||
|
stackWindowRelative(s.conn, s.window, w, stackModeForVideoAppear(s.role))
|
||||||
return s.video.SetActive(true)
|
return s.video.SetActive(true)
|
||||||
case videoDisappeared:
|
case videoDisappeared:
|
||||||
if !removeVideoWindow(s.videoWindows, w) {
|
if !removeVideoWindow(s.videoWindows, w) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Raise the kiosk so the fallback returns visibly even if some
|
// Only the below canvas raises itself so the fallback returns
|
||||||
// other window was stacked above it.
|
// visibly even if some other window was stacked above it; the OSD
|
||||||
xproto.ConfigureWindow(s.conn, s.window,
|
// overlay is already above video.
|
||||||
xproto.ConfigWindowStackMode, []uint32{xproto.StackModeAbove})
|
if raisesOnVideoEnd(s.role) {
|
||||||
|
xproto.ConfigureWindow(s.conn, s.window,
|
||||||
|
xproto.ConfigWindowStackMode, []uint32{xproto.StackModeAbove})
|
||||||
|
}
|
||||||
return s.video.SetActive(false)
|
return s.video.SetActive(false)
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -5,18 +5,25 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/xgb/shape"
|
||||||
"github.com/BurntSushi/xgb/xproto"
|
"github.com/BurntSushi/xgb/xproto"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestX11FullscreenArgsKeepCanvasBelowVideo(t *testing.T) {
|
func TestX11FullscreenArgsOrderByRole(t *testing.T) {
|
||||||
args := x11FullscreenArgs(0x1a2b)
|
below := strings.Join(x11FullscreenArgs(0x1a2b, RoleBelow), " ")
|
||||||
|
if !strings.Contains(below, "add,fullscreen,below") {
|
||||||
joined := strings.Join(args, " ")
|
t.Fatalf("args %q must request fullscreen with below stacking", below)
|
||||||
if !strings.Contains(joined, "add,fullscreen,below") {
|
|
||||||
t.Fatalf("args %v must request fullscreen with below stacking", args)
|
|
||||||
}
|
}
|
||||||
if !strings.Contains(joined, "0x00001a2b") {
|
if !strings.Contains(below, "0x00001a2b") {
|
||||||
t.Fatalf("args %v must reference the kiosk window id", args)
|
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")
|
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")
|
t.Fatal("hook error must propagate for logging")
|
||||||
}
|
}
|
||||||
if len(got) != 1 || !strings.Contains(strings.Join(got[0], " "), "add,fullscreen,below") {
|
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)
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user