Files
groundstation/internal/display/x11.go
T
piotr a31bed8aeb feat: independent fullscreen kiosk display foundation (#1)
Kiosk process owns its display lifecycle: waits for X11, keeps the
fullscreen black canvas below video, stops deterministically on signal.
Supervisor starts it before video, restarts only on process death, and
shuts the whole process group down; X11 downtime never restarts the
supervisor. Per-target opener/template abstractions for future MIPI.
2026-09-30 23:12:06 +02:00

187 lines
5.1 KiB
Go

package display
import (
"context"
"fmt"
"log"
"os/exec"
"time"
"github.com/BurntSushi/xgb"
"github.com/BurntSushi/xgb/xproto"
)
// 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.
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
// shelling out to wmctrl; tests may replace it. A failure is logged and
// the canvas degrades to a plain window.
Fullscreen func(args []string) error
}
func (o X11Opener) applyFullscreen(args []string) error {
if o.Fullscreen != nil {
return o.Fullscreen(args)
}
return exec.Command("wmctrl", args...).Run()
}
func (o X11Opener) pollInterval() time.Duration {
if o.PollInterval > 0 {
return o.PollInterval
}
return 100 * time.Millisecond
}
// Open connects to the X server and maps the kiosk window. Failure is
// expected while the display manager is starting; the caller retries.
func (o X11Opener) Open(ctx context.Context) (Session, error) {
conn, err := xgb.NewConn()
if err != nil {
return nil, fmt.Errorf("connect X11: %w", err)
}
screen := xproto.Setup(conn).DefaultScreen(conn)
window, err := xproto.NewWindowId(conn)
if err != nil {
conn.Close()
return nil, fmt.Errorf("allocate window: %w", err)
}
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})
xproto.MapWindow(conn, window)
// Round trip confirms the window is mapped and the server is alive.
if err := xproto.GetInputFocus(conn).Check(); err != nil {
conn.Close()
return nil, fmt.Errorf("map kiosk window: %w", err)
}
// Fullscreen on the root screen, but kept below other windows: pilot
// video owns the rendering priority whenever it is available. The kiosk
// never raises itself, so video always stays on top. 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)
}
gc, err := xproto.NewGcontextId(conn)
if err != nil {
conn.Close()
return nil, fmt.Errorf("allocate graphics context: %w", err)
}
font, err := xproto.NewFontId(conn)
if err != nil {
conn.Close()
return nil, fmt.Errorf("allocate font: %w", err)
}
xproto.OpenFont(conn, font, uint16(len("fixed")), "fixed")
xproto.CreateGC(conn, gc, xproto.Drawable(window),
xproto.GcForeground|xproto.GcBackground|xproto.GcFont,
[]uint32{screen.WhitePixel, screen.BlackPixel, uint32(font)})
return &x11Session{
conn: conn,
window: window,
gc: gc,
width: int(screen.WidthInPixels),
height: int(screen.HeightInPixels),
black: screen.BlackPixel,
white: screen.WhitePixel,
poll: o.pollInterval(),
}, nil
}
func x11FullscreenArgs(window uint32) []string {
return []string{"-i", "-r", fmt.Sprintf("0x%08x", window), "-b", "add,fullscreen,below"}
}
// x11Session implements both Session and Canvas for one X11 connection.
type x11Session struct {
conn *xgb.Conn
window xproto.Window
gc xproto.Gcontext
width int
height int
black uint32
white uint32
poll time.Duration
}
func (s *x11Session) Canvas() Canvas { return s }
func (s *x11Session) Size() (int, int) { return s.width, s.height }
func (s *x11Session) Clear() {
// ClearArea tiles the window background (black, set at creation); it
// involves no graphics context.
xproto.ClearArea(s.conn, false, s.window, 0, 0,
uint16(s.width), uint16(s.height))
}
func (s *x11Session) Text(x, y int, message string) {
if len(message) > 255 {
message = message[:255]
}
xproto.ChangeGC(s.conn, s.gc, xproto.GcForeground, []uint32{s.white})
xproto.ImageText8(s.conn, byte(len(message)), xproto.Drawable(s.window), s.gc,
int16(x), int16(y), message)
}
func (s *x11Session) Flush() {
s.conn.Sync()
}
func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
ticker := time.NewTicker(s.poll)
defer ticker.Stop()
for {
for {
event, err := s.conn.PollForEvent()
if err != nil {
return false, err
}
if event == nil {
break
}
if x11WantsRedraw(event) {
return true, nil
}
}
// Round trip keeps the connection liveness visible even without
// incoming events.
if err := xproto.GetInputFocus(s.conn).Check(); err != nil {
return false, err
}
select {
case <-ctx.Done():
return false, ctx.Err()
case <-ticker.C:
}
}
}
func x11WantsRedraw(event xgb.Event) bool {
switch event.(type) {
case xproto.ExposeEvent:
return true
default:
return false
}
}
func (s *x11Session) Close() error {
// Closing the connection makes the X server destroy the kiosk window.
s.conn.Close()
return nil
}