feat: independent fullscreen kiosk display foundation (#1)
Kiosk process owns its display lifecycle: waits for X11, keeps the fullscreen black canvas below video, stops deterministically on signal. Supervisor starts it before video, restarts only on process death, and shuts the whole process group down; X11 downtime never restarts the supervisor. Per-target opener/template abstractions for future MIPI.
This commit is contained in:
@@ -6,8 +6,18 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// DisplayConfig configures the independent kiosk canvas process.
|
||||
type DisplayConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Command string `yaml:"command"`
|
||||
User string `yaml:"user"`
|
||||
Display string `yaml:"display"`
|
||||
Xauthority string `yaml:"xauthority"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Serial struct {
|
||||
Display DisplayConfig `yaml:"display"`
|
||||
Serial struct {
|
||||
Path string `yaml:"path"`
|
||||
} `yaml:"serial"`
|
||||
Video struct {
|
||||
@@ -24,6 +34,7 @@ type Config struct {
|
||||
|
||||
func Default() Config {
|
||||
var c Config
|
||||
c.Display = DefaultDisplay()
|
||||
c.Serial.Path = "/dev/ttyS0"
|
||||
c.Video.Enabled = true
|
||||
c.Video.AuxIndex = 5 // AUX2, zero-based
|
||||
@@ -33,6 +44,18 @@ func Default() Config {
|
||||
return c
|
||||
}
|
||||
|
||||
// DefaultDisplay starts the kiosk canvas before video on the desktop X
|
||||
// server owned by the unprivileged display user.
|
||||
func DefaultDisplay() DisplayConfig {
|
||||
return DisplayConfig{
|
||||
Enabled: true,
|
||||
Command: "/usr/local/bin/groundstation-display",
|
||||
User: "orangepi",
|
||||
Display: ":0",
|
||||
Xauthority: "/home/orangepi/.Xauthority",
|
||||
}
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
config := Default()
|
||||
data, err := os.ReadFile(path)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfigStartsKioskDisplay(t *testing.T) {
|
||||
cfg := Default()
|
||||
|
||||
if !cfg.Display.Enabled {
|
||||
t.Fatal("kiosk display must be enabled by default")
|
||||
}
|
||||
if cfg.Display.Command != "/usr/local/bin/groundstation-display" {
|
||||
t.Fatalf("command=%q", cfg.Display.Command)
|
||||
}
|
||||
if cfg.Display.User != "orangepi" {
|
||||
t.Fatalf("user=%q", cfg.Display.User)
|
||||
}
|
||||
if cfg.Display.Display != ":0" {
|
||||
t.Fatalf("display=%q want :0", cfg.Display.Display)
|
||||
}
|
||||
if cfg.Display.Xauthority != "/home/orangepi/.Xauthority" {
|
||||
t.Fatalf("xauthority=%q", cfg.Display.Xauthority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOverridesDisplaySettings(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
body := "display:\n enabled: false\n command: /bin/kiosk\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if cfg.Display.Enabled {
|
||||
t.Fatal("display override must disable the kiosk")
|
||||
}
|
||||
if cfg.Display.Command != "/bin/kiosk" {
|
||||
t.Fatalf("command=%q want /bin/kiosk", cfg.Display.Command)
|
||||
}
|
||||
if cfg.Display.Display != ":0" {
|
||||
t.Fatalf("display=%q want default :0", cfg.Display.Display)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// Package display owns the Ground Station kiosk canvas lifecycle. The kiosk
|
||||
// runs independently of WFB, the transmitter and the RC video knob: it waits
|
||||
// for its rendering target to appear, keeps a fullscreen black canvas alive
|
||||
// and stops only when told to. Each target (X11/Viture today, MIPI/touch
|
||||
// later) provides its own opener and templates instead of one global layout.
|
||||
package display
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Canvas is the minimal drawing surface a Template renders onto.
|
||||
type Canvas interface {
|
||||
Size() (width, height int)
|
||||
Clear()
|
||||
Text(x, y int, message string)
|
||||
Flush()
|
||||
}
|
||||
|
||||
// Template renders one target-specific layout. Templates belong to a display
|
||||
// target; there is no shared global layout across targets.
|
||||
type Template interface {
|
||||
Draw(canvas Canvas)
|
||||
}
|
||||
|
||||
// Session is one live connection to a display target.
|
||||
type Session interface {
|
||||
Canvas() Canvas
|
||||
// WaitEvent blocks until the target asks for a refresh, the context is
|
||||
// done, or the session fails. redraw reports whether the template should
|
||||
// be drawn again.
|
||||
WaitEvent(ctx context.Context) (redraw bool, err error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// Opener connects to a display target. Implementations are called repeatedly
|
||||
// while the target is unavailable and must stay safe for that.
|
||||
type Opener interface {
|
||||
Open(ctx context.Context) (Session, error)
|
||||
}
|
||||
|
||||
// Options tune the kiosk lifecycle loop. Zero values select the defaults.
|
||||
type Options struct {
|
||||
// RetryDelay is the first wait after a failed connect or lost session.
|
||||
RetryDelay time.Duration
|
||||
// MaxDelay bounds the exponential reconnect backoff.
|
||||
MaxDelay time.Duration
|
||||
}
|
||||
|
||||
func (o Options) withDefaults() Options {
|
||||
if o.RetryDelay <= 0 {
|
||||
o.RetryDelay = 500 * time.Millisecond
|
||||
}
|
||||
if o.MaxDelay <= 0 {
|
||||
o.MaxDelay = 5 * time.Second
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// Run owns the kiosk display lifecycle until ctx is cancelled. Temporary
|
||||
// unavailability of the target, for example X11 not being up yet, never
|
||||
// returns an error: Run retries with bounded backoff and only stops when the
|
||||
// context is cancelled, so the Ground Station supervisor stays up.
|
||||
func Run(ctx context.Context, opener Opener, template Template, options Options) error {
|
||||
opts := options.withDefaults()
|
||||
delay := opts.RetryDelay
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
session, err := opener.Open(ctx)
|
||||
if err != nil {
|
||||
if !sleep(ctx, delay) {
|
||||
return nil
|
||||
}
|
||||
delay = nextDelay(delay, opts.MaxDelay)
|
||||
continue
|
||||
}
|
||||
|
||||
delay = opts.RetryDelay
|
||||
canvas := session.Canvas()
|
||||
template.Draw(canvas)
|
||||
for ctx.Err() == nil {
|
||||
redraw, err := session.WaitEvent(ctx)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if redraw {
|
||||
template.Draw(canvas)
|
||||
}
|
||||
}
|
||||
_ = session.Close()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if !sleep(ctx, delay) {
|
||||
return nil
|
||||
}
|
||||
delay = nextDelay(delay, opts.MaxDelay)
|
||||
}
|
||||
}
|
||||
|
||||
func nextDelay(delay, max time.Duration) time.Duration {
|
||||
delay *= 2
|
||||
if delay > max {
|
||||
return max
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func sleep(ctx context.Context, duration time.Duration) bool {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeCanvas struct {
|
||||
mu sync.Mutex
|
||||
width, height int
|
||||
clears int
|
||||
texts []string
|
||||
flushes int
|
||||
}
|
||||
|
||||
func (c *fakeCanvas) Size() (int, int) { return c.width, c.height }
|
||||
func (c *fakeCanvas) Clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.clears++
|
||||
}
|
||||
func (c *fakeCanvas) Text(_, _ int, message string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.texts = append(c.texts, message)
|
||||
}
|
||||
func (c *fakeCanvas) Flush() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.flushes++
|
||||
}
|
||||
|
||||
func (c *fakeCanvas) lineCount() int {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.texts)
|
||||
}
|
||||
|
||||
func (c *fakeCanvas) lines() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return append([]string(nil), c.texts...)
|
||||
}
|
||||
|
||||
func (c *fakeCanvas) drawCounts() (clears, flushes int) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.clears, c.flushes
|
||||
}
|
||||
|
||||
type eventResult struct {
|
||||
redraw bool
|
||||
err error
|
||||
}
|
||||
|
||||
type fakeSession struct {
|
||||
canvas *fakeCanvas
|
||||
events chan eventResult
|
||||
|
||||
mu sync.Mutex
|
||||
closed int
|
||||
}
|
||||
|
||||
func newFakeSession() *fakeSession {
|
||||
return &fakeSession{
|
||||
canvas: &fakeCanvas{width: 800, height: 600},
|
||||
events: make(chan eventResult, 8),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSession) Canvas() Canvas { return s.canvas }
|
||||
|
||||
func (s *fakeSession) WaitEvent(ctx context.Context) (bool, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
case result := <-s.events:
|
||||
return result.redraw, result.err
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSession) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.closed++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeSession) closeCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.closed
|
||||
}
|
||||
|
||||
type fakeOpener struct {
|
||||
open func(attempt int) (Session, error)
|
||||
}
|
||||
|
||||
func (o fakeOpener) Open(context.Context) (Session, error) {
|
||||
return o.open(0)
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, condition func() bool, description string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if condition() {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
t.Fatalf("timed out waiting for %s", description)
|
||||
}
|
||||
|
||||
func tinyOptions() Options {
|
||||
return Options{RetryDelay: time.Millisecond, MaxDelay: 2 * time.Millisecond}
|
||||
}
|
||||
|
||||
func TestRunDrawsTemplateWhenTargetConnects(t *testing.T) {
|
||||
session := newFakeSession()
|
||||
opener := fakeOpener{open: func(int) (Session, error) { return session, nil }}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
|
||||
}()
|
||||
|
||||
waitFor(t, func() bool { return session.canvas.lineCount() > 0 }, "initial template draw")
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("run did not stop after cancel")
|
||||
}
|
||||
|
||||
if clears, flushes := session.canvas.drawCounts(); clears == 0 || flushes == 0 {
|
||||
t.Fatalf("template must clear and flush the canvas (clears=%d flushes=%d)", clears, flushes)
|
||||
}
|
||||
if session.closeCount() != 1 {
|
||||
t.Fatalf("session closed %d times, want 1", session.closeCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunKeepsRetryingWhileTargetUnavailable(t *testing.T) {
|
||||
session := newFakeSession()
|
||||
var opens int32
|
||||
opener := fakeOpener{open: func(int) (Session, error) {
|
||||
if atomic.AddInt32(&opens, 1) <= 2 {
|
||||
return nil, errors.New("x11 down")
|
||||
}
|
||||
return session, nil
|
||||
}}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
|
||||
}()
|
||||
|
||||
waitFor(t, func() bool {
|
||||
return atomic.LoadInt32(&opens) >= 3 && session.canvas.lineCount() > 0
|
||||
}, "connect after retries")
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("run did not stop after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRedrawsTemplateOnEvent(t *testing.T) {
|
||||
session := newFakeSession()
|
||||
opener := fakeOpener{open: func(int) (Session, error) { return session, nil }}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
|
||||
}()
|
||||
|
||||
waitFor(t, func() bool { return session.canvas.lineCount() == 1 }, "initial template draw")
|
||||
session.events <- eventResult{redraw: true}
|
||||
waitFor(t, func() bool { return session.canvas.lineCount() == 2 }, "redraw on event")
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("run did not stop after cancel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunReconnectsAfterSessionFailure(t *testing.T) {
|
||||
first := newFakeSession()
|
||||
second := newFakeSession()
|
||||
var opens int32
|
||||
opener := fakeOpener{open: func(int) (Session, error) {
|
||||
if atomic.AddInt32(&opens, 1) == 1 {
|
||||
return first, nil
|
||||
}
|
||||
return second, nil
|
||||
}}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions())
|
||||
}()
|
||||
|
||||
waitFor(t, func() bool { return first.canvas.lineCount() == 1 }, "draw on first session")
|
||||
first.events <- eventResult{err: errors.New("connection lost")}
|
||||
waitFor(t, func() bool { return second.canvas.lineCount() == 1 }, "draw on reconnected session")
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("run did not stop after cancel")
|
||||
}
|
||||
|
||||
if first.closeCount() != 1 {
|
||||
t.Fatalf("failed session closed %d times, want 1", first.closeCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsWithoutConnectingWhenContextAlreadyCancelled(t *testing.T) {
|
||||
var opens int32
|
||||
opener := fakeOpener{open: func(int) (Session, error) {
|
||||
atomic.AddInt32(&opens, 1)
|
||||
return nil, errors.New("must not be called")
|
||||
}}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if err := Run(ctx, opener, NewStatusTemplate("hello"), tinyOptions()); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
if atomic.LoadInt32(&opens) != 0 {
|
||||
t.Fatalf("opener called %d times after cancellation", opens)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package display
|
||||
|
||||
// StatusTemplate is the fallback kiosk layout: a cleared black canvas with
|
||||
// status lines. It is the "no pilot video" state of a display target.
|
||||
type StatusTemplate struct {
|
||||
lines []string
|
||||
}
|
||||
|
||||
// NewStatusTemplate builds a template that draws the given status lines.
|
||||
func NewStatusTemplate(lines ...string) StatusTemplate {
|
||||
return StatusTemplate{lines: lines}
|
||||
}
|
||||
|
||||
func (t StatusTemplate) Draw(canvas Canvas) {
|
||||
canvas.Clear()
|
||||
_, height := canvas.Size()
|
||||
y := height / 2
|
||||
for _, line := range t.lines {
|
||||
canvas.Text(32, y, line)
|
||||
y += 32
|
||||
}
|
||||
canvas.Flush()
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package display
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStatusTemplateDrawsBlackCanvasWithLine(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
|
||||
NewStatusTemplate("Brak polaczenia WFB").Draw(canvas)
|
||||
|
||||
clears, flushes := canvas.drawCounts()
|
||||
if clears != 1 || flushes != 1 {
|
||||
t.Fatalf("clears=%d flushes=%d want 1 and 1", clears, flushes)
|
||||
}
|
||||
lines := canvas.lines()
|
||||
if len(lines) != 1 || lines[0] != "Brak polaczenia WFB" {
|
||||
t.Fatalf("lines=%v want [Brak polaczenia WFB]", lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusTemplateDrawsEveryProvidedLine(t *testing.T) {
|
||||
canvas := &fakeCanvas{width: 800, height: 600}
|
||||
|
||||
NewStatusTemplate("first", "second").Draw(canvas)
|
||||
|
||||
lines := canvas.lines()
|
||||
if len(lines) != 2 || lines[0] != "first" || lines[1] != "second" {
|
||||
t.Fatalf("lines=%v want [first second]", lines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/xgb"
|
||||
"github.com/BurntSushi/xgb/xproto"
|
||||
)
|
||||
|
||||
// X11Opener connects to an X server (selected by DISPLAY in the environment)
|
||||
// and creates the fullscreen kiosk canvas. It is the display target for the
|
||||
// Viture screen; future targets such as MIPI/touch provide their own Opener
|
||||
// and templates.
|
||||
type X11Opener struct {
|
||||
// PollInterval is how often X events are drained while waiting, so that
|
||||
// cancellation stays responsive. Zero selects 100 ms.
|
||||
PollInterval time.Duration
|
||||
// Fullscreen applies the fullscreen/below window state. It defaults to
|
||||
// shelling out to wmctrl; tests may replace it. A failure is logged and
|
||||
// the canvas degrades to a plain window.
|
||||
Fullscreen func(args []string) error
|
||||
}
|
||||
|
||||
func (o X11Opener) applyFullscreen(args []string) error {
|
||||
if o.Fullscreen != nil {
|
||||
return o.Fullscreen(args)
|
||||
}
|
||||
return exec.Command("wmctrl", args...).Run()
|
||||
}
|
||||
|
||||
func (o X11Opener) pollInterval() time.Duration {
|
||||
if o.PollInterval > 0 {
|
||||
return o.PollInterval
|
||||
}
|
||||
return 100 * time.Millisecond
|
||||
}
|
||||
|
||||
// Open connects to the X server and maps the kiosk window. Failure is
|
||||
// expected while the display manager is starting; the caller retries.
|
||||
func (o X11Opener) Open(ctx context.Context) (Session, error) {
|
||||
conn, err := xgb.NewConn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect X11: %w", err)
|
||||
}
|
||||
|
||||
screen := xproto.Setup(conn).DefaultScreen(conn)
|
||||
window, err := xproto.NewWindowId(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("allocate window: %w", err)
|
||||
}
|
||||
|
||||
values := uint32(xproto.CwBackPixel | xproto.CwEventMask)
|
||||
xproto.CreateWindow(conn, screen.RootDepth, window, screen.Root,
|
||||
0, 0, screen.WidthInPixels, screen.HeightInPixels, 0,
|
||||
xproto.WindowClassInputOutput, screen.RootVisual, values,
|
||||
[]uint32{screen.BlackPixel, xproto.EventMaskExposure})
|
||||
xproto.MapWindow(conn, window)
|
||||
// Round trip confirms the window is mapped and the server is alive.
|
||||
if err := xproto.GetInputFocus(conn).Check(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("map kiosk window: %w", err)
|
||||
}
|
||||
|
||||
// Fullscreen on the root screen, but kept below other windows: pilot
|
||||
// video owns the rendering priority whenever it is available. The kiosk
|
||||
// never raises itself, so video always stays on top. A missing wmctrl
|
||||
// only degrades to a plain window and must not kill the kiosk.
|
||||
if err := o.applyFullscreen(x11FullscreenArgs(uint32(window))); err != nil {
|
||||
log.Printf("kiosk fullscreen state: %v (canvas stays a plain window)", err)
|
||||
}
|
||||
|
||||
gc, err := xproto.NewGcontextId(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("allocate graphics context: %w", err)
|
||||
}
|
||||
font, err := xproto.NewFontId(conn)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("allocate font: %w", err)
|
||||
}
|
||||
xproto.OpenFont(conn, font, uint16(len("fixed")), "fixed")
|
||||
xproto.CreateGC(conn, gc, xproto.Drawable(window),
|
||||
xproto.GcForeground|xproto.GcBackground|xproto.GcFont,
|
||||
[]uint32{screen.WhitePixel, screen.BlackPixel, uint32(font)})
|
||||
|
||||
return &x11Session{
|
||||
conn: conn,
|
||||
window: window,
|
||||
gc: gc,
|
||||
width: int(screen.WidthInPixels),
|
||||
height: int(screen.HeightInPixels),
|
||||
black: screen.BlackPixel,
|
||||
white: screen.WhitePixel,
|
||||
poll: o.pollInterval(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func x11FullscreenArgs(window uint32) []string {
|
||||
return []string{"-i", "-r", fmt.Sprintf("0x%08x", window), "-b", "add,fullscreen,below"}
|
||||
}
|
||||
|
||||
// x11Session implements both Session and Canvas for one X11 connection.
|
||||
type x11Session struct {
|
||||
conn *xgb.Conn
|
||||
window xproto.Window
|
||||
gc xproto.Gcontext
|
||||
width int
|
||||
height int
|
||||
black uint32
|
||||
white uint32
|
||||
poll time.Duration
|
||||
}
|
||||
|
||||
func (s *x11Session) Canvas() Canvas { return s }
|
||||
|
||||
func (s *x11Session) Size() (int, int) { return s.width, s.height }
|
||||
|
||||
func (s *x11Session) Clear() {
|
||||
// ClearArea tiles the window background (black, set at creation); it
|
||||
// involves no graphics context.
|
||||
xproto.ClearArea(s.conn, false, s.window, 0, 0,
|
||||
uint16(s.width), uint16(s.height))
|
||||
}
|
||||
|
||||
func (s *x11Session) Text(x, y int, message string) {
|
||||
if len(message) > 255 {
|
||||
message = message[:255]
|
||||
}
|
||||
xproto.ChangeGC(s.conn, s.gc, xproto.GcForeground, []uint32{s.white})
|
||||
xproto.ImageText8(s.conn, byte(len(message)), xproto.Drawable(s.window), s.gc,
|
||||
int16(x), int16(y), message)
|
||||
}
|
||||
|
||||
func (s *x11Session) Flush() {
|
||||
s.conn.Sync()
|
||||
}
|
||||
|
||||
func (s *x11Session) WaitEvent(ctx context.Context) (bool, error) {
|
||||
ticker := time.NewTicker(s.poll)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
for {
|
||||
event, err := s.conn.PollForEvent()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if event == nil {
|
||||
break
|
||||
}
|
||||
if x11WantsRedraw(event) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
// Round trip keeps the connection liveness visible even without
|
||||
// incoming events.
|
||||
if err := xproto.GetInputFocus(s.conn).Check(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false, ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func x11WantsRedraw(event xgb.Event) bool {
|
||||
switch event.(type) {
|
||||
case xproto.ExposeEvent:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (s *x11Session) Close() error {
|
||||
// Closing the connection makes the X server destroy the kiosk window.
|
||||
s.conn.Close()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/BurntSushi/xgb/xproto"
|
||||
)
|
||||
|
||||
func TestX11FullscreenArgsKeepCanvasBelowVideo(t *testing.T) {
|
||||
args := x11FullscreenArgs(0x1a2b)
|
||||
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "add,fullscreen,below") {
|
||||
t.Fatalf("args %v must request fullscreen with below stacking", args)
|
||||
}
|
||||
if !strings.Contains(joined, "0x00001a2b") {
|
||||
t.Fatalf("args %v must reference the kiosk window id", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestX11SessionRedrawsOnlyOnExpose(t *testing.T) {
|
||||
if !x11WantsRedraw(xproto.ExposeEvent{}) {
|
||||
t.Fatal("expose events must trigger a redraw")
|
||||
}
|
||||
if x11WantsRedraw(xproto.MapNotifyEvent{}) {
|
||||
t.Fatal("map notify events must not trigger a redraw")
|
||||
}
|
||||
}
|
||||
|
||||
func TestX11OpenerAppliesFullscreenArgsThroughHook(t *testing.T) {
|
||||
var got [][]string
|
||||
opener := X11Opener{Fullscreen: func(args []string) error {
|
||||
got = append(got, args)
|
||||
return errors.New("wmctrl missing")
|
||||
}}
|
||||
|
||||
if err := opener.applyFullscreen(x11FullscreenArgs(0x42)); err == nil {
|
||||
t.Fatal("hook error must propagate for logging")
|
||||
}
|
||||
if len(got) != 1 || !strings.Contains(strings.Join(got[0], " "), "add,fullscreen,below") {
|
||||
t.Fatalf("hook args %v must request fullscreen with below stacking", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user