From 9029d48d5c5b4da864340184fd596753afc3ad71 Mon Sep 17 00:00:00 2001 From: robertjamesprior <83608739+robertjamesprior@users.noreply.github.com> Date: Fri, 6 Nov 2026 06:16:02 +0000 Subject: [PATCH 1/2] Keep the headful window maximized while the session runs --- server/cmd/api/api/window_reconcile.go | 125 ++++++++++++++++++++ server/cmd/api/api/window_reconcile_test.go | 75 ++++++++++++ server/cmd/api/main.go | 4 + 3 files changed, 204 insertions(+) create mode 100644 server/cmd/api/api/window_reconcile.go create mode 100644 server/cmd/api/api/window_reconcile_test.go diff --git a/server/cmd/api/api/window_reconcile.go b/server/cmd/api/api/window_reconcile.go new file mode 100644 index 00000000..836c0d37 --- /dev/null +++ b/server/cmd/api/api/window_reconcile.go @@ -0,0 +1,125 @@ +package api + +import ( + "context" + "log/slog" + "time" + + "github.com/kernel/kernel-images/server/lib/cdpclient" + "github.com/kernel/kernel-images/server/lib/logger" +) + +const ( + // windowReconcileInterval spaces reconcile passes. A pass is two small + // local CDP round trips, and the interval trades that cost against how long + // a demoted window can stay demoted — short enough that someone watching + // the live view is unlikely to catch one. + windowReconcileInterval = 5 * time.Second + // windowReconcileTimeout bounds a single pass, so a stalled chromium cannot + // delay the next tick. + windowReconcileTimeout = 3 * time.Second +) + +// StartWindowReconcile keeps a headful window maximized for the life of the +// session. +// +// Promotion on start restores the maximized contract across a chromium restart. +// This covers everything that can demote the window while the session runs: +// a CDP command over the proxy, a CDP command issued inside the playwright +// executor, a window manager restore, or a geometry a saved profile carries +// into a restart. None of those are distinguishable from the window state +// itself, so reasserting the state is what covers all of them — a client that +// shrinks the window gets a window that fills the desktop again on the next +// pass. +// +// It is a no-op on the headless image, which has no window manager. +func (s *ApiService) StartWindowReconcile() { + if s.detectDisplayMode(context.Background()) != "xorg" { + return + } + go s.reconcileWindowLoop() +} + +// reconcileWindowLoop reconciles on a ticker, holding one devtools connection +// for the current upstream and redialing when chromium restarts or the +// transport drops. +func (s *ApiService) reconcileWindowLoop() { + log := logger.FromContext(s.lifecycleCtx) + ticker := time.NewTicker(windowReconcileInterval) + defer ticker.Stop() + + var client *cdpclient.Client + clientURL := "" + defer func() { + if client != nil { + client.Close() + } + }() + + for { + select { + case <-s.lifecycleCtx.Done(): + return + case <-ticker.C: + } + + url := s.upstreamMgr.Current() + if url == "" { + continue + } + if client != nil && (clientURL != url || client.IsClosed()) { + client.Close() + client = nil + } + if client == nil { + c, err := s.dialWindowReconcile(url) + if err != nil { + continue + } + client, clientURL = c, url + } + + ctx, cancel := context.WithTimeout(s.lifecycleCtx, windowReconcileTimeout) + reconcileWindow(ctx, client, log) + cancel() + } +} + +// dialWindowReconcile dials the devtools upstream for one reconcile +// connection. +func (s *ApiService) dialWindowReconcile(url string) (*cdpclient.Client, error) { + ctx, cancel := context.WithTimeout(s.lifecycleCtx, windowReconcileTimeout) + defer cancel() + return cdpclient.Dial(ctx, url) +} + +// reconcileWindow restores the maximized state when the window has been taken +// out of it and reports whether it acted. +// +// The line is emitted only on a pass that acts, which paces itself: the next +// pass sees a maximized window unless someone demoted it again, so one line +// means one demotion, and its timestamp can be matched against the client's own +// call timeline to attribute it. +// +// A maximized window and a fullscreen one are both left alone, so a kiosk +// session is unaffected. +func reconcileWindow(ctx context.Context, client *cdpclient.Client, log *slog.Logger) bool { + bounds, err := client.GetWindowBounds(ctx) + if err != nil { + return false + } + if bounds.WindowState == "maximized" || bounds.WindowState == "fullscreen" { + return false + } + log.Info("window was not maximized, restoring", + "window_id", bounds.WindowID, + "window_state", bounds.WindowState, + "width", bounds.Width, + "height", bounds.Height, + ) + if err := client.SetWindowBoundsMaximized(ctx); err != nil { + log.Warn("window restore failed", "error", err) + return false + } + return true +} diff --git a/server/cmd/api/api/window_reconcile_test.go b/server/cmd/api/api/window_reconcile_test.go new file mode 100644 index 00000000..7d469f41 --- /dev/null +++ b/server/cmd/api/api/window_reconcile_test.go @@ -0,0 +1,75 @@ +package api + +import ( + "bytes" + "context" + "log/slog" + "testing" + "time" + + "github.com/kernel/kernel-images/server/lib/cdpclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// dialFakeDevTools dials a fakeDevTools fixture, the same CDP surface +// reconcileWindow talks to in production. +func dialFakeDevTools(t *testing.T, url string) *cdpclient.Client { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client, err := cdpclient.Dial(ctx, url) + require.NoError(t, err) + t.Cleanup(func() { client.Close() }) + return client +} + +// windowRestoreCalls reads the fixture's counter without racing its handler. +func windowRestoreCalls(f *fakeDevTools) int { + f.mu.Lock() + defer f.mu.Unlock() + return f.setMaximized +} + +func TestReconcileWindowRestoresDemotedWindow(t *testing.T) { + f := &fakeDevTools{pageTargetID: "target-1", windowState: "normal"} + client := dialFakeDevTools(t, f.start(t)) + + var logs bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + assert.True(t, reconcileWindow(ctx, client, slog.New(slog.NewTextHandler(&logs, nil))), + "a window taken out of maximized is restored") + assert.Equal(t, 1, windowRestoreCalls(f)) + + // The replaced bounds are what make a demotion attributable after the fact, + // so the line has to carry them. + for _, want := range []string{"window was not maximized", "normal", "800", "600"} { + assert.Contains(t, logs.String(), want) + } +} + +func TestReconcileWindowLeavesMaximizedWindow(t *testing.T) { + f := &fakeDevTools{pageTargetID: "target-1", windowState: "maximized"} + client := dialFakeDevTools(t, f.start(t)) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + assert.False(t, reconcileWindow(ctx, client, slog.Default())) + assert.Zero(t, windowRestoreCalls(f)) +} + +func TestReconcileWindowLeavesFullscreenWindow(t *testing.T) { + // A kiosk session is fullscreen, and demoting it to maximized would break + // kiosk mode. + f := &fakeDevTools{pageTargetID: "target-1", windowState: "fullscreen"} + client := dialFakeDevTools(t, f.start(t)) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + assert.False(t, reconcileWindow(ctx, client, slog.Default())) + assert.Zero(t, windowRestoreCalls(f)) +} diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index c5515778..ffa729ad 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -241,6 +241,10 @@ func main() { // (no-op on the headless image). apiService.StartWindowPromotion() + // Headful: keep it maximized while the session runs too, so a client that + // demotes the window does not leave a partially covered desktop. + apiService.StartWindowReconcile() + // api_call event emission. Off until the telemetry handlers flip it on. r.Use(api.TelemetryHTTPMiddleware(telemetrySession.Publish)) r.Use(api.WebMCPRequestSizeMiddleware) From 4a15839c9fa21a96e0aa790df6dd0aedc9f5a9f3 Mon Sep 17 00:00:00 2001 From: robertjamesprior <83608739+robertjamesprior@users.noreply.github.com> Date: Fri, 6 Nov 2026 20:10:57 +0000 Subject: [PATCH 2/2] Correct window demotions from the X state, with a budget Replaces the five-second reconcile poll with a watch on the window's _NET_WM_STATE property, which the window manager rewrites the moment the window leaves or re-enters the maximized state. A correction happens as the demotion does rather than up to an interval later, and a session whose window never moves costs nothing: no timer, no devtools connection, no round trips. Caps corrections at three per browser start. One correction covers what this guard is for, a window that leaves maximized once and stays there. Repeated demotions are a client that wants a window that size, and past the budget it keeps it, rather than being fought for the life of the session by something it cannot see or turn off. Co-Authored-By: Claude Opus 5 --- server/cmd/api/api/window_reconcile.go | 226 +++++++++++++++----- server/cmd/api/api/window_reconcile_test.go | 73 +++++++ server/lib/x11/x11.go | 84 ++++++++ server/lib/x11/x11_test.go | 87 ++++++++ 4 files changed, 415 insertions(+), 55 deletions(-) diff --git a/server/cmd/api/api/window_reconcile.go b/server/cmd/api/api/window_reconcile.go index 836c0d37..d25524cc 100644 --- a/server/cmd/api/api/window_reconcile.go +++ b/server/cmd/api/api/window_reconcile.go @@ -7,99 +7,204 @@ import ( "github.com/kernel/kernel-images/server/lib/cdpclient" "github.com/kernel/kernel-images/server/lib/logger" + "github.com/kernel/kernel-images/server/lib/x11" ) const ( - // windowReconcileInterval spaces reconcile passes. A pass is two small - // local CDP round trips, and the interval trades that cost against how long - // a demoted window can stay demoted — short enough that someone watching - // the live view is unlikely to catch one. - windowReconcileInterval = 5 * time.Second - // windowReconcileTimeout bounds a single pass, so a stalled chromium cannot - // delay the next tick. - windowReconcileTimeout = 3 * time.Second + // windowCorrectionBudget caps how many times one browser start puts the + // window back. One correction covers the case this guard exists for: a + // window that leaves maximized once, through a saved geometry surfacing + // after a restart or a stray resize, and stays there for the rest of the + // session. Repeated demotions are a client that wants a window this size, + // and past the budget it keeps it: a guard that always wins turns into a + // fight the client cannot see or stop. + windowCorrectionBudget = 3 + // windowCorrectionTimeout bounds one correction, so a stalled chromium + // cannot wedge the watch. + windowCorrectionTimeout = 3 * time.Second + // windowProbeTimeout bounds the lookup of the window to watch. + windowProbeTimeout = 2 * time.Second + // windowWatchRetryDelay spaces retries while no window is mapped yet. + windowWatchRetryDelay = time.Second ) -// StartWindowReconcile keeps a headful window maximized for the life of the -// session. +// StartWindowReconcile keeps a headful window covering the display after the +// promotion that runs on browser start. // -// Promotion on start restores the maximized contract across a chromium restart. -// This covers everything that can demote the window while the session runs: -// a CDP command over the proxy, a CDP command issued inside the playwright -// executor, a window manager restore, or a geometry a saved profile carries -// into a restart. None of those are distinguishable from the window state -// itself, so reasserting the state is what covers all of them — a client that -// shrinks the window gets a window that fills the desktop again on the next -// pass. +// Promotion restores the maximized contract once per chromium start. It cannot +// cover what happens next: a CDP Browser.setWindowBounds over the proxy, the +// same command inside the playwright executor, a window manager restore, or a +// geometry a saved profile carries into the restart and wins with after the +// promotion has already run. None of those are distinguishable from the window +// state itself, so the guard watches the state. // // It is a no-op on the headless image, which has no window manager. func (s *ApiService) StartWindowReconcile() { if s.detectDisplayMode(context.Background()) != "xorg" { return } - go s.reconcileWindowLoop() + ch, unsubscribe := s.upstreamMgr.Subscribe() + go s.reconcileWindowLoop(ch, unsubscribe) } -// reconcileWindowLoop reconciles on a ticker, holding one devtools connection -// for the current upstream and redialing when chromium restarts or the -// transport drops. -func (s *ApiService) reconcileWindowLoop() { - log := logger.FromContext(s.lifecycleCtx) - ticker := time.NewTicker(windowReconcileInterval) - defer ticker.Stop() +// reconcileWindowLoop runs one guard per browser start, replacing it when +// chromium restarts under a new devtools upstream. The initial URL is read +// through Current before entering the select so an upstream discovered before +// Subscribe ran is not missed. +func (s *ApiService) reconcileWindowLoop(ch <-chan string, unsubscribe func()) { + defer unsubscribe() - var client *cdpclient.Client - clientURL := "" + var cancel context.CancelFunc defer func() { - if client != nil { - client.Close() + if cancel != nil { + cancel() } }() + seen := "" + guard := func(url string) { + if cancel != nil { + cancel() + } + ctx, c := context.WithCancel(s.lifecycleCtx) + cancel = c + go s.guardWindowCoverage(ctx, url) + } + for { + if url := s.upstreamMgr.Current(); url != "" && url != seen { + seen = url + guard(url) + } select { case <-s.lifecycleCtx.Done(): return - case <-ticker.C: + case url, ok := <-ch: + if !ok { + return + } + if url == "" || url == seen { + continue + } + seen = url + guard(url) } + } +} + +// guardWindowCoverage watches the browser window for this chromium start and +// restores it when it stops covering the display, until the correction budget +// is spent. The watch ends with the window: xprop exits when it is destroyed, +// and the next browser start brings its own guard and its own budget. +func (s *ApiService) guardWindowCoverage(ctx context.Context, url string) { + log := logger.FromContext(s.lifecycleCtx) + display := s.resolveDisplayFromEnv() + g := &windowGuard{url: url, budget: windowCorrectionBudget, log: log} + defer g.close() - url := s.upstreamMgr.Current() - if url == "" { + for ctx.Err() == nil { + windowID, ok := x11.ActiveWindowID(display, windowProbeTimeout) + if !ok { + if !sleepUntil(ctx, windowWatchRetryDelay) { + return + } continue } - if client != nil && (clientURL != url || client.IsClosed()) { - client.Close() - client = nil - } - if client == nil { - c, err := s.dialWindowReconcile(url) - if err != nil { - continue + + watchCtx, stop := context.WithCancel(ctx) + err := x11.WatchWindowCoverage(watchCtx, display, windowID, func(covered bool) { + if !g.onCoverage(watchCtx, covered) { + stop() } - client, clientURL = c, url + }) + stop() + if g.spent() { + return + } + if err != nil { + log.Warn("window state watch ended", "error", err) + } + if !sleepUntil(ctx, windowWatchRetryDelay) { + return } - - ctx, cancel := context.WithTimeout(s.lifecycleCtx, windowReconcileTimeout) - reconcileWindow(ctx, client, log) - cancel() } } -// dialWindowReconcile dials the devtools upstream for one reconcile -// connection. -func (s *ApiService) dialWindowReconcile(url string) (*cdpclient.Client, error) { - ctx, cancel := context.WithTimeout(s.lifecycleCtx, windowReconcileTimeout) +// windowGuard holds the state one browser start's guard carries between +// changes: the devtools upstream to correct through, the connection it reuses, +// and what is left of the correction budget. +type windowGuard struct { + url string + budget int + log *slog.Logger + client *cdpclient.Client +} + +// onCoverage handles one reported window state and reports whether the guard +// keeps watching. A window that covers the display is left alone and costs +// nothing. One that does not is read over CDP before anything is changed, +// which is what decides a correction: the X atoms say the window is no longer +// maximized, the CDP state says whether it is something the guard should leave +// alone, like the fullscreen window of a kiosk session. +func (g *windowGuard) onCoverage(ctx context.Context, covered bool) bool { + if covered || g.spent() { + return !g.spent() + } + + ctx, cancel := context.WithTimeout(ctx, windowCorrectionTimeout) defer cancel() - return cdpclient.Dial(ctx, url) + + client, err := g.connect(ctx) + if err != nil { + g.log.Warn("window restore could not reach the browser", "error", err) + return true + } + if !reconcileWindow(ctx, client, g.log) { + return true + } + + g.budget-- + if g.spent() { + g.log.Info("window correction budget spent, leaving the window as the client sets it") + return false + } + return true +} + +func (g *windowGuard) spent() bool { return g.budget <= 0 } + +// connect reuses the guard's devtools connection, redialing once the transport +// is gone. A dropped connection never recovers on the same client. +func (g *windowGuard) connect(ctx context.Context) (*cdpclient.Client, error) { + if g.client != nil && g.client.IsClosed() { + g.client.Close() + g.client = nil + } + if g.client != nil { + return g.client, nil + } + client, err := cdpclient.Dial(ctx, g.url) + if err != nil { + return nil, err + } + g.client = client + return client, nil +} + +func (g *windowGuard) close() { + if g.client != nil { + g.client.Close() + g.client = nil + } } // reconcileWindow restores the maximized state when the window has been taken // out of it and reports whether it acted. // -// The line is emitted only on a pass that acts, which paces itself: the next -// pass sees a maximized window unless someone demoted it again, so one line -// means one demotion, and its timestamp can be matched against the client's own -// call timeline to attribute it. +// The line is emitted only on a pass that acts, so one line means one +// demotion, and its timestamp can be matched against the client's own call +// timeline to attribute it — including for a command that comes through the +// playwright executor, which the devtools proxy does not see. // // A maximized window and a fullscreen one are both left alone, so a kiosk // session is unaffected. @@ -123,3 +228,14 @@ func reconcileWindow(ctx context.Context, client *cdpclient.Client, log *slog.Lo } return true } + +// sleepUntil waits for d and reports whether the wait finished rather than the +// context ending. +func sleepUntil(ctx context.Context, d time.Duration) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(d): + return true + } +} diff --git a/server/cmd/api/api/window_reconcile_test.go b/server/cmd/api/api/window_reconcile_test.go index 7d469f41..5272ea3b 100644 --- a/server/cmd/api/api/window_reconcile_test.go +++ b/server/cmd/api/api/window_reconcile_test.go @@ -73,3 +73,76 @@ func TestReconcileWindowLeavesFullscreenWindow(t *testing.T) { assert.False(t, reconcileWindow(ctx, client, slog.Default())) assert.Zero(t, windowRestoreCalls(f)) } + +// newWindowGuard builds a guard against a fake devtools, the way +// guardWindowCoverage builds one per browser start. +func newWindowGuard(t *testing.T, url string, logs *bytes.Buffer) *windowGuard { + t.Helper() + g := &windowGuard{url: url, budget: windowCorrectionBudget, log: slog.New(slog.NewTextHandler(logs, nil))} + t.Cleanup(g.close) + return g +} + +func TestWindowGuardCorrectsAnUncoveredWindow(t *testing.T) { + f := &fakeDevTools{pageTargetID: "target-1", windowState: "normal"} + var logs bytes.Buffer + g := newWindowGuard(t, f.start(t), &logs) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + assert.True(t, g.onCoverage(ctx, false), "the guard keeps watching while it has budget") + assert.Equal(t, 1, windowRestoreCalls(f)) +} + +func TestWindowGuardIgnoresACoveredWindow(t *testing.T) { + // A maximized window reports a state change of its own when it is restored, + // and reconnecting to the browser to confirm what the atoms already say + // would double every correction. + f := &fakeDevTools{pageTargetID: "target-1", windowState: "normal"} + var logs bytes.Buffer + g := newWindowGuard(t, f.start(t), &logs) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + assert.True(t, g.onCoverage(ctx, true)) + assert.Zero(t, windowRestoreCalls(f)) +} + +func TestWindowGuardStopsAfterTheBudget(t *testing.T) { + // A client that keeps taking the window back wants it that size. The guard + // stands down rather than fighting it for the life of the session. + f := &fakeDevTools{pageTargetID: "target-1", windowState: "normal"} + var logs bytes.Buffer + g := newWindowGuard(t, f.start(t), &logs) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + for i := 1; i < windowCorrectionBudget; i++ { + require.True(t, g.onCoverage(ctx, false), "correction %d is within budget", i) + } + assert.False(t, g.onCoverage(ctx, false), "the last correction ends the watch") + assert.Equal(t, windowCorrectionBudget, windowRestoreCalls(f)) + assert.Contains(t, logs.String(), "budget spent") + + // Spent is spent: a later change costs nothing and starts no work. + assert.False(t, g.onCoverage(ctx, false)) + assert.Equal(t, windowCorrectionBudget, windowRestoreCalls(f)) +} + +func TestWindowGuardKeepsWatchingWhenTheWindowIsAlreadyMaximized(t *testing.T) { + // The atoms can lag a window that CDP already reports as maximized. That + // is not a correction, so it does not spend budget. + f := &fakeDevTools{pageTargetID: "target-1", windowState: "maximized"} + var logs bytes.Buffer + g := newWindowGuard(t, f.start(t), &logs) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + assert.True(t, g.onCoverage(ctx, false)) + assert.Zero(t, windowRestoreCalls(f)) + assert.Equal(t, windowCorrectionBudget, g.budget) +} diff --git a/server/lib/x11/x11.go b/server/lib/x11/x11.go index 7b5b3682..f8e466b5 100644 --- a/server/lib/x11/x11.go +++ b/server/lib/x11/x11.go @@ -2,6 +2,7 @@ package x11 import ( + "bufio" "context" "net" "os/exec" @@ -152,3 +153,86 @@ func supportingWindowID(out string) (string, bool) { } return id, true } + +// ActiveWindowID reads the root window's _NET_ACTIVE_WINDOW property, which +// openbox points at the browser window once chromium maps it. Returns false +// while no client window is mapped yet. +func ActiveWindowID(display string, timeout time.Duration) (string, bool) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + out, err := exec.CommandContext(ctx, "xprop", "-display", display, "-root", "_NET_ACTIVE_WINDOW").Output() + if err != nil { + return "", false + } + return activeWindowID(string(out)) +} + +// activeWindowID extracts the window ID from `xprop -root +// _NET_ACTIVE_WINDOW`, which prints `...: window id # 0x...` when a client +// window has focus and `0x0` when none does. +func activeWindowID(out string) (string, bool) { + _, id, found := strings.Cut(out, "window id # ") + if !found { + return "", false + } + id, _, _ = strings.Cut(strings.TrimSpace(id), ",") + if id == "" || id == "0x0" { + return "", false + } + return id, true +} + +// WatchWindowCoverage reports whether the window covers the display, once for +// the state it is in when the watch starts and again on every later change. +// It returns when ctx is cancelled, when the window goes away, or when xprop +// cannot be run. +// +// The signal is _NET_WM_STATE, which openbox rewrites the moment the window +// leaves or re-enters the maximized state. Watching the property costs one +// long-lived xprop process per browser start and reports a change as it +// happens, where polling the state over CDP costs a round trip per pass and +// reports it up to one interval late. A window manager that does not maintain +// the EWMH atoms would simply never call onCoverage; the promotion on browser +// start is what guarantees the window starts covered. +func WatchWindowCoverage(ctx context.Context, display, windowID string, onCoverage func(covered bool)) error { + cmd := exec.CommandContext(ctx, "xprop", "-display", display, "-spy", "-id", windowID, "_NET_WM_STATE") + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + defer func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }() + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + if covered, ok := windowCovered(scanner.Text()); ok { + onCoverage(covered) + } + if ctx.Err() != nil { + break + } + } + return scanner.Err() +} + +// windowCovered reads one _NET_WM_STATE line from xprop. A window covers the +// display when it is fullscreen (kiosk) or maximized in both directions; +// maximized in one direction leaves desktop visible beside it, so it counts as +// uncovered. The second return reports whether the line was a state line at +// all, so unrelated xprop output is ignored rather than read as a demotion. +func windowCovered(line string) (bool, bool) { + _, atoms, found := strings.Cut(line, "_NET_WM_STATE(ATOM) =") + if !found { + return false, false + } + if strings.Contains(atoms, "_NET_WM_STATE_FULLSCREEN") { + return true, true + } + return strings.Contains(atoms, "_NET_WM_STATE_MAXIMIZED_VERT") && + strings.Contains(atoms, "_NET_WM_STATE_MAXIMIZED_HORZ"), true +} diff --git a/server/lib/x11/x11_test.go b/server/lib/x11/x11_test.go index 9b78bde0..7757f4e9 100644 --- a/server/lib/x11/x11_test.go +++ b/server/lib/x11/x11_test.go @@ -87,3 +87,90 @@ func TestRootSize(t *testing.T) { }) } } + +func TestActiveWindowID(t *testing.T) { + tests := []struct { + name string + out string + id string + ok bool + }{ + { + name: "mapped window", + out: "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x800003\n", + id: "0x800003", + ok: true, + }, + { + // openbox lists the focus window alongside the active one. + name: "window list", + out: "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x800003, 0x0\n", + id: "0x800003", + ok: true, + }, + { + name: "no client window mapped yet", + out: "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x0\n", + ok: false, + }, + { + name: "property unset", + out: "_NET_ACTIVE_WINDOW: not found.\n", + ok: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, ok := activeWindowID(tt.out) + if ok != tt.ok || (ok && id != tt.id) { + t.Errorf("got (%q, %v), want (%q, %v) for %q", id, ok, tt.id, tt.ok, tt.out) + } + }) + } +} + +func TestWindowCovered(t *testing.T) { + tests := []struct { + name string + line string + covered bool + ok bool + }{ + { + name: "maximized", + line: "_NET_WM_STATE(ATOM) = _NET_WM_STATE_MAXIMIZED_VERT, _NET_WM_STATE_MAXIMIZED_HORZ", + covered: true, + ok: true, + }, + { + name: "demoted", + line: "_NET_WM_STATE(ATOM) = ", + ok: true, + }, + { + // Half a maximize leaves desktop beside the window, which is the + // shape this guard exists to correct. + name: "maximized vertically only", + line: "_NET_WM_STATE(ATOM) = _NET_WM_STATE_MAXIMIZED_VERT", + ok: true, + }, + { + name: "kiosk", + line: "_NET_WM_STATE(ATOM) = _NET_WM_STATE_FULLSCREEN", + covered: true, + ok: true, + }, + { + name: "unrelated output", + line: "xprop: unable to open display", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + covered, ok := windowCovered(tt.line) + if ok != tt.ok || covered != tt.covered { + t.Errorf("got (%v, %v), want (%v, %v) for %q", covered, ok, tt.covered, tt.ok, tt.line) + } + }) + } +}