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 }