package display import ( "context" "encoding/binary" "fmt" "log" "os/exec" "strings" "time" "unicode/utf16" "github.com/BurntSushi/xgb" "github.com/BurntSushi/xgb/shape" "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" // windowStatePropertyName is the EWMH property carrying a window's // initial states; a compliant window manager must honor it when the // window is mapped. windowStatePropertyName = "_NET_WM_STATE" // 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" } // fullscreenStateAtoms are the initial EWMH states announced as a property // before mapping: every layer is genuinely fullscreen and undecorated from // its first frame, with the canvas additionally parking below and the OSD // overlay above. A compliant window manager must honor pre-map states; the // post-map wmctrl request remains as reinforcement, never as the only // fullscreen mechanism. func fullscreenStateAtoms(role LayerRole) []string { atoms := []string{"_NET_WM_STATE_FULLSCREEN"} if role == RoleAbove { return append(atoms, "_NET_WM_STATE_ABOVE") } return append(atoms, "_NET_WM_STATE_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 } // stackReassert decides the OSD overlay's stacking correction on its // refresh: above the tracked video window while video is live, and raised // to the top while video is absent — the fallback canvas raises itself to // the top when video ends and would otherwise bury the overlay's text under // the fullscreen presentation indefinitely. Only the OSD overlay ever // re-asserts; the canvas and video windows are never moved by this. func stackReassert(role LayerRole, videoActive bool, topVideo xproto.Window, videoWindows map[xproto.Window]struct{}) (sibling xproto.Window, mode uint32, ok bool) { if role != RoleAbove { return 0, 0, false } if !videoActive { // No sibling: raise the overlay to the top of the stack. return 0, xproto.StackModeAbove, true } if topVideo == 0 { return 0, 0, false } if _, tracked := videoWindows[topVideo]; !tracked { return 0, 0, false } return topVideo, xproto.StackModeAbove, true } // 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 // 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, } // osdShapePadding widens each punched output region by this many pixels on // every side, so glyph antialiasing artifacts at fixed-font edges never // clip visibly. const osdShapePadding = 2 // osdFallbackFontMetrics are the font metrics used when the font query // fails: close to the common 6x13 fixed cell so a punched region still // covers the glyphs. The OSD only ever draws terse ASCII telemetry. func osdFallbackFontMetrics() (ascent, descent, cell int16) { return 11, 3, 6 } // createWindowValues builds the CreateWindow value mask and values; the // order must follow the mask bit order (back pixel, event mask, cursor) so // the server reads each value correctly. A zero cursor means cursor setup // failed: the cursor bit is dropped entirely so the window maps with the // server default cursor — an always-visible main window takes priority // over cursor hiding. func createWindowValues(backPixel uint32, eventMask uint32, cursor xproto.Cursor) (uint32, []uint32) { if cursor == 0 { return xproto.CwBackPixel | xproto.CwEventMask, []uint32{backPixel, eventMask} } values := []uint32{backPixel, eventMask, uint32(cursor)} return xproto.CwBackPixel | xproto.CwEventMask | xproto.CwCursor, values } // cursorBitmapDepth is the protocol-required depth of a cursor's source and // mask bitmaps. Creating them at any other depth makes CreateCursor fail // asynchronously — which previously poisoned the window id and surfaced // only as a downstream BadWindow. const cursorBitmapDepth = 1 // eventDrainBatch bounds how many pending events one wait cycle drains. A // busy server (video fullscreen repaints) can serve unrelated non-Expose // events faster than they arrive; an unbounded drain would never reach the // timer checks, so the OSD overlay's initially empty bounding shape would // never be punched and its telemetry would never appear. Thirty-two events // per 100 ms poll cycle sustains well over three thousand events per // second — orders of magnitude above any plausible root-event rate — while // bounding liveness and cancellation latency to a single batch. const eventDrainBatch = 32 // blankCursorX is the checked X-request surface of the blank cursor setup, // so the sequence can be driven by tests that script failures at exact // operations and pin request order and resource reclamation. type blankCursorX interface { CreatePixmapChecked(pid xproto.Pixmap, drawable xproto.Drawable, depth uint8, width, height uint16) error CreateGCChecked(cid xproto.Gcontext, drawable xproto.Drawable) error PolyFillRectangleChecked(drawable xproto.Drawable, gc xproto.Gcontext, rectangles []xproto.Rectangle) error FreeGC(cid xproto.Gcontext) CreateCursorChecked(cid xproto.Cursor, source, mask xproto.Pixmap) error FreePixmap(pid xproto.Pixmap) } type connCursorX struct{ conn *xgb.Conn } func (x connCursorX) CreatePixmapChecked(pid xproto.Pixmap, drawable xproto.Drawable, depth uint8, width, height uint16) error { return xproto.CreatePixmapChecked(x.conn, depth, pid, drawable, width, height).Check() } func (x connCursorX) CreateGCChecked(cid xproto.Gcontext, drawable xproto.Drawable) error { return xproto.CreateGCChecked(x.conn, cid, drawable, 0, nil).Check() } func (x connCursorX) PolyFillRectangleChecked(drawable xproto.Drawable, gc xproto.Gcontext, rectangles []xproto.Rectangle) error { return xproto.PolyFillRectangleChecked(x.conn, drawable, gc, rectangles).Check() } func (x connCursorX) FreeGC(cid xproto.Gcontext) { xproto.FreeGC(x.conn, cid) } func (x connCursorX) CreateCursorChecked(cid xproto.Cursor, source, mask xproto.Pixmap) error { return xproto.CreateCursorChecked(x.conn, cid, source, mask, 0, 0, 0, 0, 0, 0, 0, 0).Check() } func (x connCursorX) FreePixmap(pid xproto.Pixmap) { xproto.FreePixmap(x.conn, pid) } // blankCursor allocates the ids for the blank cursor sequence and runs it // over the connection. On failure the caller closes the connection, which // reclaims every partially created resource. func blankCursor(conn *xgb.Conn, screen *xproto.ScreenInfo) (xproto.Cursor, error) { pixmap, _ := xproto.NewPixmapId(conn) mask, _ := xproto.NewPixmapId(conn) gc, _ := xproto.NewGcontextId(conn) cursor, _ := xproto.NewCursorId(conn) return blankCursorOver(connCursorX{conn}, xproto.Drawable(screen.Root), cursorBitmapDepth, pixmap, mask, gc, cursor) } // blankCursorOver performs the blank-cursor resource sequence over the // checked request surface: 1-bit source and mask bitmaps, a matching-depth // graphics context, both bitmaps cleared to zero (an all-zero mask displays // nothing regardless of the source), the cursor created, then the bitmaps // and context reclaimed. Every request is checked at its own operation: the // first failure stops the sequence, names the exact operation, frees what // was already created, and returns before any window request runs. func blankCursorOver(x blankCursorX, root xproto.Drawable, depth uint8, pixmap, mask xproto.Pixmap, gc xproto.Gcontext, cursor xproto.Cursor) (xproto.Cursor, error) { if err := x.CreatePixmapChecked(pixmap, root, depth, 1, 1); err != nil { return 0, fmt.Errorf("create cursor source bitmap: %w", err) } if err := x.CreatePixmapChecked(mask, root, depth, 1, 1); err != nil { x.FreePixmap(pixmap) return 0, fmt.Errorf("create cursor mask bitmap: %w", err) } if err := x.CreateGCChecked(gc, xproto.Drawable(pixmap)); err != nil { x.FreePixmap(mask) x.FreePixmap(pixmap) return 0, fmt.Errorf("create cursor graphics context: %w", err) } unit := []xproto.Rectangle{{Width: 1, Height: 1}} if err := x.PolyFillRectangleChecked(xproto.Drawable(pixmap), gc, unit); err != nil { x.FreeGC(gc) x.FreePixmap(mask) x.FreePixmap(pixmap) return 0, fmt.Errorf("clear cursor source bitmap: %w", err) } if err := x.PolyFillRectangleChecked(xproto.Drawable(mask), gc, unit); err != nil { x.FreeGC(gc) x.FreePixmap(mask) x.FreePixmap(pixmap) return 0, fmt.Errorf("clear cursor mask bitmap: %w", err) } x.FreeGC(gc) if err := x.CreateCursorChecked(cursor, pixmap, mask); err != nil { x.FreePixmap(mask) x.FreePixmap(pixmap) return 0, fmt.Errorf("create blank cursor: %w", err) } x.FreePixmap(pixmap) x.FreePixmap(mask) return cursor, nil } // osdTextRect is the output region covering one drawn text: its glyph cell // around the baseline, widened by the shape padding. func osdTextRect(x, y, units int, cell, ascent, descent int16) xproto.Rectangle { pad := int16(osdShapePadding) return xproto.Rectangle{ X: int16(x) - pad, Y: int16(y) - ascent - pad, Width: uint16(units*int(cell) + 2*int(pad)), Height: uint16(int(ascent+descent) + 2*int(pad)), } } // X11Opener connects to an X server (selected by DISPLAY in the environment) // and maps one fullscreen kiosk window for the layer role it is built with: // the fallback/menu canvas below video (RoleBelow) or the transparent, // 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 { // PollInterval is how often X events are drained while waiting, so that // cancellation stays responsive. Zero selects 100 ms. PollInterval time.Duration // RefreshInterval is how often the layer redraws without an X event, // so template data that changes independently of the display server — // the OSD overlay's published snapshots — appears at the OSD's own // slower cadence. Zero keeps the purely event-driven redraws of the // fallback canvas. RefreshInterval time.Duration // Fullscreen applies the fullscreen window state. It defaults to // shelling out to wmctrl; tests may replace it. A failure is logged and // the window 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 themselves. When nil the opener // tracks presence in a private signal. 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 { 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 } // livenessRequest is one liveness round trip on a connection: it resolves // its request and reports only the error. type livenessRequest func() error // connLivenessProbe returns the liveness round trip used after mapping a // window and while a session waits for events. GetInputFocus carries a // reply, so its cookie must be resolved with Reply: calling Check on a // reply-bearing cookie fails at runtime with the XGB reply-cookie error // ("Cannot call 'Check' on a cookie that is expecting a *reply*"), which // killed every layer open and reconnect on the device. func connLivenessProbe(conn *xgb.Conn) livenessRequest { return func() error { return resolveLiveness(xproto.GetInputFocus(conn)) } } // resolveLiveness consumes one issued reply-bearing liveness cookie: the // reply is read and discarded, and only the error propagates. The cookie // must be resolved with Reply exactly once — Check on a reply-bearing // cookie returns the XGB reply-cookie error instead of ever reaching the // server, so a probe resolved with Check fails on every call regardless of // server health. func resolveLiveness(cookie xproto.GetInputFocusCookie) error { _, err := cookie.Reply() return err } // confirmMapped runs the post-map liveness stage of Open: a successful // reply proves the window is mapped and the server alive; a failure is // wrapped as the map-kiosk-window error so the caller closes the // connection and drops the mapped window. func confirmMapped(probe livenessRequest) error { if err := probe(); err != nil { return fmt.Errorf("map kiosk window: %w", err) } return nil } // 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) } // 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 } // The blank cursor hides the pointer over this layer from its first // frame and stays on the layer across no-video/video transitions. It // is optional: a cursor setup failure is surfaced as a bounded warning // and the window still maps with the server default cursor — an // always-visible main window takes priority over cursor hiding, and a // cursor failure must never destroy or reconnect the display. cursor, cursorErr := blankCursor(conn, screen) if cursorErr != nil { log.Printf("blank cursor unavailable (%v); mapping with the default cursor", cursorErr) cursor = 0 } valueMask, values := createWindowValues(screen.BlackPixel, eventMask, cursor) // The window creation is checked at its own operation: a failure here // is reported exactly, and no dependent request — properties, shapes, // map, liveness — ever runs against an invalid window. if err := xproto.CreateWindowChecked(conn, screen.RootDepth, window, screen.Root, 0, 0, screen.WidthInPixels, screen.HeightInPixels, 0, xproto.WindowClassInputOutput, screen.RootVisual, valueMask, values).Check(); err != nil { // The window was never created: reclaim the connection (and the // live cursor with a best-effort free) exactly like the other // open failures, so reconnect attempts cannot leak either. if cursor != 0 { xproto.FreeCursor(conn, cursor) } conn.Close() return nil, fmt.Errorf("create kiosk window: %w", err) } // 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) } } // Announce the initial fullscreen state before mapping: a compliant // window manager then maps the layer fullscreen and undecorated from // its first frame, instead of a decorated, repositioned window that // only becomes fullscreen after a later wmctrl round trip (or never, // when wmctrl is missing). stateProp := internAtom(conn, windowStatePropertyName) if stateProp != 0 { var stateData []byte for _, name := range fullscreenStateAtoms(o.Role) { if atom := internAtom(conn, name); atom != 0 { value := make([]byte, 4) binary.LittleEndian.PutUint32(value, uint32(atom)) stateData = append(stateData, value...) } } if len(stateData) > 0 { xproto.ChangeProperty(conn, xproto.PropModeReplace, window, stateProp, xproto.AtomAtom, 32, uint32(len(stateData)/4), stateData) } } // 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) // 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; // the reply-bearing cookie is resolved with Reply (see // connLivenessProbe). if err := confirmMapped(connLivenessProbe(conn)); err != nil { conn.Close() return nil, err } // A layer reconnect must never cover running video: if video windows // are already presented, record presence and take our side relative to // the topmost one before anything is drawn. presence := o.presence() typeAtom := internWindowTypeAtom(conn) videoWindows := scanVideoWindows(conn, screen.Root, window, typeAtom, markerAtom) var topVideo xproto.Window if len(videoWindows) > 0 { presence.SetActive(true) topVideo = videoWindows[len(videoWindows)-1] stackWindowRelative(conn, window, topVideo, stackModeForVideoAppear(o.Role)) } else { presence.SetActive(false) } // Fullscreen on the root screen, on our side of the video window: pilot // video owns rendering priority whenever it is available. The canvas // raises itself only after video ends, to bring the fallback back; the // OSD overlay is already above video. A missing wmctrl only degrades to // a plain window and must not kill the layer. if err := o.applyFullscreen(x11FullscreenArgs(uint32(window), o.Role)); err != nil { log.Printf("kiosk fullscreen state: %v (window 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)}) // The OSD overlay punches its output shape per drawn region, so it // needs the real glyph metrics; the canvas never punches shapes. ascent, descent, cell := osdFallbackFontMetrics() if o.Role == RoleAbove { if info, err := xproto.QueryFont(conn, xproto.Fontable(font)).Reply(); err == nil { ascent, descent = int16(info.FontAscent), int16(info.FontDescent) if width := int16(info.MaxBounds.CharacterWidth); width > 0 { cell = width } } } 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(), refresh: o.RefreshInterval, video: presence, videoWindows: videoWindowSet(videoWindows), topVideo: topVideo, typeAtom: typeAtom, markerAtom: markerAtom, role: o.Role, unicodeFont: unicodeFont, cursor: cursor, fontAscent: ascent, fontDescent: descent, fontCell: cell, pollEvent: conn.PollForEvent, probe: connLivenessProbe(conn), }, 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, role LayerRole) []string { 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 // in bottom-to-top stacking order, so a layer reconnect can take its side // relative to the topmost one and never cover running video. func scanVideoWindows(conn *xgb.Conn, root, own xproto.Window, typeAtom, markerAtom 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, markerAtom) { windows = append(windows, w) } } return windows } // windowFacts is the presentation evidence gathered for one candidate // window, so the video decision stays a pure function. type windowFacts struct { inputOutput 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() if err != nil { return false } return isPresentedVideoWindow(windowFacts{ inputOutput: attrs.Class == xproto.WindowClassInputOutput, overrideRedirect: attrs.OverrideRedirect, viewable: attrs.MapState == xproto.MapStateViewable, auxiliary: auxiliaryWindowType(conn, w, typeAtom), groundstation: groundstationLayerWindow(conn, w, markerAtom), }) } // markerWalkBounds bound the descendant search for layer markers: window // managers reparent client windows into frame windows, so a Ground Station // 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, uint16(len(name)), name).Reply() if err != nil { return 0 } return reply.Atom } func internWindowTypeAtom(conn *xgb.Conn) xproto.Atom { return internAtom(conn, windowTypePropertyName) } // 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 } // 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.ConfigWindowSibling|xproto.ConfigWindowStackMode, []uint32{uint32(sibling), mode}) } // 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 refresh time.Duration video *VideoPresence videoWindows map[xproto.Window]struct{} // topVideo is the video window the OSD overlay must sit above; it is // re-asserted on the overlay's refresh because window managers may // promote fullscreened video back on top after the appear restack. topVideo xproto.Window typeAtom xproto.Atom markerAtom xproto.Atom role LayerRole unicodeFont bool // cursor is the blank cursor applied to the layer window at creation; // it is freed when the session closes. cursor xproto.Cursor // fontAscent, fontDescent and fontCell describe the drawn font so the // OSD overlay can punch its output shape around each text's glyph cell. fontAscent int16 fontDescent int16 fontCell int16 // drawn accumulates the output regions of texts drawn since the last // clear; the OSD overlay's flush punches its bounding shape over it. drawn []xproto.Rectangle // pollEvent drains one pending X event; conn.PollForEvent in // production. Tests script it. pollEvent func() (xgb.Event, xgb.Error) // probe performs one reply-bearing liveness round trip; its error ends // the session. connLivenessProbe in production. Tests script it. probe livenessRequest } func (s *x11Session) Canvas() Canvas { return s } func (s *x11Session) Size() (int, int) { return s.width, s.height } func (s *x11Session) Clear() { // A fresh draw pass starts with no output regions: the OSD overlay's // next flush re-punches its shape over exactly what this pass draws. s.resetOSDRegions() // 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)) } // resetOSDRegions forgets the output regions of the previous draw pass. func (s *x11Session) resetOSDRegions() { s.drawn = s.drawn[:0] } 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) s.recordOSDText(x, y, len(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) s.recordOSDText(x, y, len(message)) } // recordOSDText remembers one drawn text's output region so the overlay's // flush can punch its bounding shape over exactly what was drawn. Only the // OSD overlay punches shapes; the fallback canvas always stays plain. func (s *x11Session) recordOSDText(x, y, units int) { if s.role != RoleAbove || units <= 0 { return } s.drawn = append(s.drawn, osdTextRect(x, y, units, s.fontCell, s.fontAscent, s.fontDescent)) } func (s *x11Session) Flush() { // The OSD overlay presents pixels only where this draw pass put text: // its bounding shape is re-punched over the recorded regions while its // input shape stays empty, so it never covers video elsewhere and // never intercepts input. A failure here is a broken connection; the // liveness probe below ends the session and the layer reconnects. if s.role == RoleAbove { if err := applyOSDShapes(s.conn, s.window, s.drawn); err != nil { log.Printf("osd overlay shapes: %v", err) } } s.conn.Sync() } // applyOSDShapes sets the overlay's bounding shape to the drawn regions // (possibly none) and keeps its input shape empty. func applyOSDShapes(conn *xgb.Conn, window xproto.Window, rects []xproto.Rectangle) error { if err := shape.RectanglesChecked(conn, shape.SoSet, shape.SkBounding, 0, window, 0, 0, rects).Check(); err != nil { return fmt.Errorf("bounding shape: %w", err) } if err := shape.RectanglesChecked(conn, shape.SoSet, shape.SkInput, 0, window, 0, 0, nil).Check(); err != nil { return fmt.Errorf("empty input shape: %w", err) } return nil } func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) { ticker := time.NewTicker(s.poll) defer ticker.Stop() lastRefresh := time.Now() for { // Drain a bounded batch of pending events: a busy server (video // fullscreen repaints) can serve unrelated non-Expose events faster // than they arrive, and an unbounded drain would never reach the // timer checks — the OSD overlay's initially empty bounding shape // would never be punched and its telemetry would never appear. for drained := 0; drained < eventDrainBatch; drained++ { event, err := s.pollEvent() if err != nil { return false, err } if event == nil { break } if x11WantsRedraw(event) { return true, nil } if s.applyVideoChange(event) { return true, nil } } // The OSD overlay redraws on its own slower cadence than video so // newly published snapshots appear without waiting for X events. // A zero interval disables it, keeping the fallback canvas purely // event-driven. if s.refresh > 0 && time.Since(lastRefresh) >= s.refresh { lastRefresh = time.Now() // The redraw is also where the overlay re-claims its place // above fullscreen video: window managers may have promoted // the video window back on top since the appear restack. s.reassertStacking() return true, nil } // Round trip keeps the connection liveness visible even without // incoming events; the reply-bearing cookie is resolved with Reply // (see connLivenessProbe). if err := s.probe(); err != nil { return false, err } select { case <-ctx.Done(): return false, ctx.Err() case <-ticker.C: } } } // reassertStacking re-claims the overlay's stacking place on the OSD's // slower refresh cadence: above the live video window, or on top of the // stack while video is absent so the fallback canvas can never bury it. A // no-op for the canvas layer. func (s *x11Session) reassertStacking() { videoActive := s.video != nil && s.video.Active() sibling, mode, ok := stackReassert(s.role, videoActive, s.topVideo, s.videoWindows) if !ok { return } if sibling != 0 { stackWindowRelative(s.conn, s.window, sibling, mode) return } xproto.ConfigureWindow(s.conn, s.window, xproto.ConfigWindowStackMode, []uint32{mode}) } // applyVideoChange folds one root structure event into the shared video // presence and restacks this layer so video keeps rendering priority: the // canvas parks below video and the OSD overlay rises above it. 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 a template on its own. func (s *x11Session) applyVideoChange(event xgb.Event) bool { change, w := classifyVideoChange(event, s.window) switch change { case videoAppeared: // Menus, notifications, other auxiliary windows and our own sibling // layers never count as video; only a presented output window does. if !presentedVideoWindow(s.conn, w, s.typeAtom, s.markerAtom) { return false } if !addVideoWindow(s.videoWindows, w) { return false } // The first presented video output takes priority: park on our side // of it, and remember it as the restack target for later re-claims. s.topVideo = w stackWindowRelative(s.conn, s.window, w, stackModeForVideoAppear(s.role)) return s.video.SetActive(true) case videoDisappeared: if !removeVideoWindow(s.videoWindows, w) { return false } if s.topVideo == w { s.topVideo = 0 } // Only the below canvas raises itself so the fallback returns // visibly even if some other window was stacked above it; the OSD // overlay is already above video. if raisesOnVideoEnd(s.role) { 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 { // Free the blank cursor when one was created; closing the connection // then makes the X server reclaim everything else, including the kiosk // window. if s.cursor != 0 { xproto.FreeCursor(s.conn, s.cursor) } s.conn.Close() return nil }