1163 lines
42 KiB
Go
1163 lines
42 KiB
Go
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
|
|
}
|