513 lines
16 KiB
Go
513 lines
16 KiB
Go
package display
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"log"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf16"
|
|
|
|
"github.com/BurntSushi/xgb"
|
|
"github.com/BurntSushi/xgb/xproto"
|
|
)
|
|
|
|
const (
|
|
// iso10646FontPattern selects a Unicode-capable fixed font so the exact
|
|
// fallback text keeps its diacritics on screen.
|
|
iso10646FontPattern = "*fixed*iso10646-1"
|
|
// asciiFontName is the degradation path when no Unicode font exists;
|
|
// text drawn with it is folded to ASCII.
|
|
asciiFontName = "fixed"
|
|
// maxImageText16Units bounds one ImageText16 request to the protocol's
|
|
// 1-byte string length (2 bytes per unit).
|
|
maxImageText16Units = 127
|
|
// windowTypePropertyName is the EWMH property naming a window's role.
|
|
windowTypePropertyName = "_NET_WM_WINDOW_TYPE"
|
|
)
|
|
|
|
// 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
|
|
// for plain video output — count as video.
|
|
var ewmhNonVideoTypes = map[string]bool{
|
|
"DESKTOP": true,
|
|
"DOCK": true,
|
|
"TOOLBAR": true,
|
|
"MENU": true,
|
|
"UTILITY": true,
|
|
"SPLASH": true,
|
|
"DIALOG": true,
|
|
"NOTIFICATION": true,
|
|
"TOOLTIP": true,
|
|
"COMBO": true,
|
|
"DROPDOWN_MENU": true,
|
|
"POPUP_MENU": true,
|
|
}
|
|
|
|
// 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
|
|
// 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.
|
|
Video *VideoPresence
|
|
}
|
|
|
|
func (o X11Opener) presence() *VideoPresence {
|
|
if o.Video != nil {
|
|
return o.Video
|
|
}
|
|
return NewVideoPresence()
|
|
}
|
|
|
|
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)
|
|
// Watch the root screen so video output announces itself: a video
|
|
// window mapping is the first presented video frame, its unmap or
|
|
// destroy is video end. WFB and launcher processes are never consulted.
|
|
xproto.ChangeWindowAttributes(conn, screen.Root,
|
|
xproto.CwEventMask, []uint32{xproto.EventMaskSubstructureNotify})
|
|
// 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)
|
|
}
|
|
|
|
// 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.
|
|
presence := o.presence()
|
|
typeAtom := internWindowTypeAtom(conn)
|
|
videoWindows := scanVideoWindows(conn, screen.Root, window, typeAtom)
|
|
if len(videoWindows) > 0 {
|
|
presence.SetActive(true)
|
|
stackWindowBelow(conn, window, videoWindows[len(videoWindows)-1])
|
|
} 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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
// Prefer a Unicode font so the exact fallback text renders with its
|
|
// diacritics; without one, drawn text degrades to ASCII.
|
|
fontName, unicodeFont := selectFontName(conn)
|
|
xproto.OpenFont(conn, font, uint16(len(fontName)), fontName)
|
|
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(),
|
|
video: presence,
|
|
videoWindows: videoWindowSet(videoWindows),
|
|
typeAtom: typeAtom,
|
|
unicodeFont: unicodeFont,
|
|
}, nil
|
|
}
|
|
|
|
func videoWindowSet(windows []xproto.Window) map[xproto.Window]struct{} {
|
|
set := make(map[xproto.Window]struct{}, len(windows))
|
|
for _, w := range windows {
|
|
set[w] = struct{}{}
|
|
}
|
|
return set
|
|
}
|
|
|
|
func x11FullscreenArgs(window uint32) []string {
|
|
return []string{"-i", "-r", fmt.Sprintf("0x%08x", window), "-b", "add,fullscreen,below"}
|
|
}
|
|
|
|
// 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 {
|
|
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) {
|
|
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 {
|
|
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)
|
|
}
|
|
|
|
// 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 {
|
|
reply, err := xproto.InternAtom(conn, false,
|
|
uint16(len(windowTypePropertyName)), windowTypePropertyName).Reply()
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return reply.Atom
|
|
}
|
|
|
|
// 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.
|
|
func auxiliaryWindowType(conn *xgb.Conn, w xproto.Window, typeAtom xproto.Atom) bool {
|
|
if typeAtom == 0 {
|
|
return false
|
|
}
|
|
reply, err := xproto.GetProperty(conn, false, w, typeAtom,
|
|
xproto.GetPropertyTypeAny, 0, 32).Reply()
|
|
if err != nil || reply.ValueLen < 4 {
|
|
return false
|
|
}
|
|
for i := 0; i+4 <= len(reply.Value); i += 4 {
|
|
atom := xproto.Atom(binary.LittleEndian.Uint32(reply.Value[i : i+4]))
|
|
name, err := xproto.GetAtomName(conn, atom).Reply()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if ewmhNonVideoTypes[name.Name] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func stackWindowBelow(conn *xgb.Conn, window, sibling xproto.Window) {
|
|
xproto.ConfigureWindow(conn, window,
|
|
xproto.ConfigWindowSibling|xproto.ConfigWindowStackMode,
|
|
[]uint32{uint32(sibling), xproto.StackModeBelow})
|
|
}
|
|
|
|
// videoChange is the video-lifecycle meaning of one root structure event.
|
|
type videoChange int
|
|
|
|
const (
|
|
videoUnrelated videoChange = iota
|
|
videoAppeared
|
|
videoDisappeared
|
|
)
|
|
|
|
// classifyVideoChange maps a root structure event onto the video lifecycle.
|
|
// own is the kiosk window. The video output windows themselves are the
|
|
// presence signal: their maps are the first presented video output and
|
|
// their unmaps or destroys are video ends, so neither WFB nor launcher
|
|
// processes are ever consulted. Whether an announced window actually is
|
|
// video is decided by the session using the presented-window checks.
|
|
func classifyVideoChange(event xgb.Event, own xproto.Window) (videoChange, xproto.Window) {
|
|
switch e := event.(type) {
|
|
case xproto.MapNotifyEvent:
|
|
if e.Window == own {
|
|
return videoUnrelated, 0
|
|
}
|
|
return videoAppeared, e.Window
|
|
case xproto.UnmapNotifyEvent:
|
|
if e.Window == own {
|
|
return videoUnrelated, 0
|
|
}
|
|
return videoDisappeared, e.Window
|
|
case xproto.DestroyNotifyEvent:
|
|
return videoDisappeared, e.Window
|
|
}
|
|
return videoUnrelated, 0
|
|
}
|
|
|
|
// addVideoWindow records w as presented video output and reports whether
|
|
// presence changed (the set went from empty to non-empty).
|
|
func addVideoWindow(windows map[xproto.Window]struct{}, w xproto.Window) bool {
|
|
if _, tracked := windows[w]; tracked {
|
|
return false
|
|
}
|
|
wasEmpty := len(windows) == 0
|
|
windows[w] = struct{}{}
|
|
return wasEmpty
|
|
}
|
|
|
|
// removeVideoWindow drops w from the presented video windows and reports
|
|
// whether presence changed (the set became empty).
|
|
func removeVideoWindow(windows map[xproto.Window]struct{}, w xproto.Window) bool {
|
|
if _, tracked := windows[w]; !tracked {
|
|
return false
|
|
}
|
|
delete(windows, w)
|
|
return len(windows) == 0
|
|
}
|
|
|
|
// 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
|
|
video *VideoPresence
|
|
videoWindows map[xproto.Window]struct{}
|
|
typeAtom xproto.Atom
|
|
unicodeFont bool
|
|
}
|
|
|
|
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) {
|
|
xproto.ChangeGC(s.conn, s.gc, xproto.GcForeground, []uint32{s.white})
|
|
if s.unicodeFont {
|
|
chars := utf16Char2b(message, maxImageText16Units)
|
|
xproto.ImageText16(s.conn, byte(len(chars)), xproto.Drawable(s.window), s.gc,
|
|
int16(x), int16(y), chars)
|
|
return
|
|
}
|
|
message = foldToASCII(message)
|
|
if len(message) > 255 {
|
|
message = message[:255]
|
|
}
|
|
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
|
|
}
|
|
if s.applyVideoChange(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:
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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) {
|
|
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)
|
|
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})
|
|
return s.video.SetActive(false)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func x11WantsRedraw(event xgb.Event) bool {
|
|
switch event.(type) {
|
|
case xproto.ExposeEvent:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// selectFontName resolves a Unicode-capable font for the fallback text. It
|
|
// reports false when none is installed, in which case drawn text is folded
|
|
// to ASCII.
|
|
func selectFontName(conn *xgb.Conn) (string, bool) {
|
|
reply, err := xproto.ListFontsWithInfo(conn, 1,
|
|
uint16(len(iso10646FontPattern)), iso10646FontPattern).Reply()
|
|
if err != nil || reply.NameLen == 0 {
|
|
return asciiFontName, false
|
|
}
|
|
return reply.Name, true
|
|
}
|
|
|
|
// utf16Char2b encodes the message as UTF-16 units for ImageText16 so the
|
|
// exact fallback text keeps its diacritics, truncating to maxUnits because
|
|
// the request carries a 1-byte string length. A cut never splits a
|
|
// surrogate pair.
|
|
func utf16Char2b(message string, maxUnits int) []xproto.Char2b {
|
|
if maxUnits <= 0 {
|
|
return nil
|
|
}
|
|
units := utf16.Encode([]rune(message))
|
|
if len(units) > maxUnits {
|
|
units = units[:maxUnits]
|
|
if last := units[len(units)-1]; last >= 0xD800 && last <= 0xDBFF {
|
|
units = units[:len(units)-1]
|
|
}
|
|
}
|
|
chars := make([]xproto.Char2b, len(units))
|
|
for i, unit := range units {
|
|
chars[i] = xproto.Char2b{Byte1: byte(unit >> 8), Byte2: byte(unit)}
|
|
}
|
|
return chars
|
|
}
|
|
|
|
// latinFolds maps the Polish diacritics to plain ASCII for fonts without
|
|
// Unicode glyphs.
|
|
var latinFolds = map[rune]byte{
|
|
'ą': 'a', 'ć': 'c', 'ę': 'e', 'ł': 'l', 'ń': 'n', 'ó': 'o', 'ś': 's', 'ź': 'z', 'ż': 'z',
|
|
'Ą': 'A', 'Ć': 'C', 'Ę': 'E', 'Ł': 'L', 'Ń': 'N', 'Ó': 'O', 'Ś': 'S', 'Ź': 'Z', 'Ż': 'Z',
|
|
}
|
|
|
|
// foldToASCII degrades text for legacy fonts: Polish diacritics fold to
|
|
// their base letters and other non-ASCII runes are dropped.
|
|
func foldToASCII(message string) string {
|
|
ascii := true
|
|
for _, r := range message {
|
|
if r >= 0x80 {
|
|
ascii = false
|
|
break
|
|
}
|
|
}
|
|
if ascii {
|
|
return message
|
|
}
|
|
var b strings.Builder
|
|
for _, r := range message {
|
|
if r < 0x80 {
|
|
b.WriteRune(r)
|
|
continue
|
|
}
|
|
if folded, ok := latinFolds[r]; ok {
|
|
b.WriteByte(folded)
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (s *x11Session) Close() error {
|
|
// Closing the connection makes the X server destroy the kiosk window.
|
|
s.conn.Close()
|
|
return nil
|
|
}
|