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.
187 lines
5.1 KiB
Go
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
|
|
}
|