Copy settings over to lightdm with --greeter flag.
This commit is contained in:
1 parent
ab14244404
commit
2c6ab2ce76
10 files changed
+666
-3
No files matched your search
@@ -0,0 +1,86 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// WriteGreeterSettings installs the effective saved profile at path. The caller
|
||||
// must run as the destination account; no privileged filesystem writes are needed.
|
||||
func WriteGreeterSettings(path string, settings Settings) error {
|
||||
contents, err := GreeterSettings(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.Path != "" {
|
||||
sourcePath, err := filepath.Abs(settings.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source, sourceErr := os.Stat(settings.Path)
|
||||
target, targetErr := os.Stat(path)
|
||||
if sourcePath == targetPath || sourceErr == nil && targetErr == nil && os.SameFile(source, target) {
|
||||
return errors.New("greeter copy cannot replace the source settings")
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create greeter settings directory: %w", err)
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(path), ".greeter-settings-*.toml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create greeter settings copy: %w", err)
|
||||
}
|
||||
defer os.Remove(temporary.Name())
|
||||
if _, err := temporary.Write(contents); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
temporary.Close()
|
||||
return err
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(temporary.Name(), path); err != nil {
|
||||
return fmt.Errorf("replace greeter settings copy: %w", err)
|
||||
}
|
||||
return syncDirectory(filepath.Dir(path))
|
||||
}
|
||||
|
||||
// GreeterSettings encodes a complete active profile without application mappings
|
||||
// or other profiles. Tentacle registries are stored separately and are not copied.
|
||||
func GreeterSettings(settings Settings) ([]byte, error) {
|
||||
if err := validate(settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeDocument(writableDocument{
|
||||
SchemaVersion: CurrentSchemaVersion,
|
||||
ActiveProfile: "greeter",
|
||||
Profiles: map[string]writableProfile{"greeter": profileFromSettings(settings)},
|
||||
})
|
||||
}
|
||||
|
||||
// ReadGreeterSettings reads the bounded snapshot from an already-open pipe.
|
||||
// Reopening /dev/stdin after switching users can fail its ownership checks.
|
||||
func ReadGreeterSettings(reader io.Reader) (Settings, error) {
|
||||
contents, err := io.ReadAll(io.LimitReader(reader, maxConfigSize+1))
|
||||
if err != nil {
|
||||
return Settings{}, fmt.Errorf("read greeter settings: %w", err)
|
||||
}
|
||||
if len(contents) > maxConfigSize {
|
||||
return Settings{}, fmt.Errorf("greeter settings exceed %d bytes", maxConfigSize)
|
||||
}
|
||||
return decodeWritable(contents)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGreeterCopyUsesOnlyEffectiveActiveProfile(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
sourcePath := filepath.Join(directory, "source.toml")
|
||||
contents := []byte("schema-version = 2\nactive-profile = 'custom'\n" +
|
||||
"[profiles.custom.speech.default-voice]\nrate = 73.0\n" +
|
||||
"[profiles.custom.keyboard]\nlayout = 2\nscreen-reader-keys = ['Caps_Lock']\n" +
|
||||
"[profiles.custom.braille]\nenabled = true\n" +
|
||||
"[profiles.application.speech]\nenabled = false\n" +
|
||||
"[applications]\n'example-editor' = 'application'\n")
|
||||
if err := os.WriteFile(sourcePath, contents, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := Load(sourcePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targetPath := filepath.Join(directory, "greeter.toml")
|
||||
// Repeated exports must replace stale profiles and mappings too.
|
||||
if err := os.WriteFile(targetPath, contents, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WriteGreeterSettings(targetPath, settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exported, err := Load(targetPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.Path, settings.ActiveProfile = targetPath, "greeter"
|
||||
if !reflect.DeepEqual(exported, settings) {
|
||||
t.Fatalf("export changed effective settings: got %#v, want %#v", exported, settings)
|
||||
}
|
||||
data, _ := os.ReadFile(targetPath)
|
||||
document, _, err := decodeDocument(data)
|
||||
if err != nil || len(document.Profiles) != 1 || len(document.Applications) != 0 {
|
||||
t.Fatalf("export retained other profiles: %+v, %v", document, err)
|
||||
}
|
||||
info, _ := os.Stat(targetPath)
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("export permissions = %o", info.Mode().Perm())
|
||||
}
|
||||
unchanged, _ := os.ReadFile(sourcePath)
|
||||
if string(unchanged) != string(contents) {
|
||||
t.Fatal("export changed source settings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreeterPipeRejectsInvalidOrOversizedSettings(t *testing.T) {
|
||||
for _, contents := range []string{"", "schema-version = 999", strings.Repeat(" ", maxConfigSize+1)} {
|
||||
if _, err := ReadGreeterSettings(strings.NewReader(contents)); err == nil {
|
||||
t.Fatal("accepted invalid or oversized settings pipe")
|
||||
}
|
||||
}
|
||||
settings := DefaultSettings()
|
||||
settings.EnableEchoByWord = true
|
||||
contents, err := GreeterSettings(settings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
copied, err := ReadGreeterSettings(bytes.NewReader(contents))
|
||||
if err != nil || copied.ActiveProfile != "greeter" || !copied.EnableEchoByWord {
|
||||
t.Fatalf("pipe round trip = %#v, error %v", copied, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGreeterCopyPreservesSourceAndExistingOutputOnFailure(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
source := filepath.Join(directory, "source.toml")
|
||||
if err := os.WriteFile(source, []byte("original"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := DefaultSettings()
|
||||
settings.Path = source
|
||||
alias := filepath.Join(directory, "alias.toml")
|
||||
if err := os.Symlink(source, alias); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{source, alias} {
|
||||
if err := WriteGreeterSettings(path, settings); err == nil {
|
||||
t.Fatal("export replaced its own source")
|
||||
}
|
||||
}
|
||||
target := filepath.Join(directory, "target.toml")
|
||||
if err := os.WriteFile(target, []byte("previous export"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.SoundVolume = 2.0
|
||||
if err := WriteGreeterSettings(target, settings); err == nil {
|
||||
t.Fatal("invalid settings were exported")
|
||||
}
|
||||
data, _ := os.ReadFile(target)
|
||||
if string(data) != "previous export" {
|
||||
t.Fatal("failed export changed existing output")
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,73 @@ static unsigned char *cthulhu_window_property(
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
static unsigned long cthulhu_single_window_property(Display *display, Window window,
|
||||
Atom property, Atom expectedType) {
|
||||
Atom type = None;
|
||||
int format = 0;
|
||||
unsigned long items = 0, value = 0;
|
||||
unsigned char *data = cthulhu_window_property(display, window, property, &type, &format, &items);
|
||||
if (data != NULL && type == expectedType && format == 32 && items == 1)
|
||||
value = ((unsigned long *)data)[0];
|
||||
if (data != NULL) XFree(data);
|
||||
return value;
|
||||
}
|
||||
|
||||
// A minimal window manager can focus GTK's input-only keyboard grab window
|
||||
// when a combo popup appears. Resolve only a mapped transient popup in that
|
||||
// grab's explicit window group, with matching process metadata throughout.
|
||||
// The caller holds the X11 error guard; disappearing windows fail closed.
|
||||
static Window cthulhu_input_grab_owner(Display *display, Window focus) {
|
||||
Window root = DefaultRootWindow(display);
|
||||
XWindowAttributes attributes;
|
||||
if (focus == None || focus == PointerRoot || focus == root ||
|
||||
!XGetWindowAttributes(display, focus, &attributes) ||
|
||||
attributes.class != InputOnly || !attributes.override_redirect ||
|
||||
attributes.map_state != IsViewable)
|
||||
return focus;
|
||||
|
||||
Atom leaderAtom = XInternAtom(display, "WM_CLIENT_LEADER", False);
|
||||
Atom pidAtom = XInternAtom(display, "_NET_WM_PID", False);
|
||||
Atom typeAtom = XInternAtom(display, "_NET_WM_WINDOW_TYPE", False);
|
||||
Window leader = cthulhu_single_window_property(display, focus, leaderAtom, XA_WINDOW);
|
||||
unsigned long pid = cthulhu_single_window_property(display, focus, pidAtom, XA_CARDINAL);
|
||||
if (leader == None || pid == 0) return focus;
|
||||
|
||||
Window treeRoot, parent, *children = NULL;
|
||||
unsigned int count = 0;
|
||||
if (!XQueryTree(display, root, &treeRoot, &parent, &children, &count)) return focus;
|
||||
Window result = focus;
|
||||
if (count <= 256) {
|
||||
Atom comboType = XInternAtom(display, "_NET_WM_WINDOW_TYPE_COMBO", False);
|
||||
Atom popupType = XInternAtom(display, "_NET_WM_WINDOW_TYPE_POPUP_MENU", False);
|
||||
Atom dropdownType = XInternAtom(display, "_NET_WM_WINDOW_TYPE_DROPDOWN_MENU", False);
|
||||
// XQueryTree returns stacking order, bottom to top.
|
||||
for (unsigned int index = count; index > 0; index--) {
|
||||
Window candidate = children[index - 1], owner = None;
|
||||
if (candidate == focus || !XGetWindowAttributes(display, candidate, &attributes) ||
|
||||
attributes.class != InputOutput || !attributes.override_redirect ||
|
||||
attributes.map_state != IsViewable)
|
||||
continue;
|
||||
Atom type = cthulhu_single_window_property(display, candidate, typeAtom, XA_ATOM);
|
||||
if (type != comboType && type != popupType && type != dropdownType) continue;
|
||||
if (cthulhu_single_window_property(display, candidate, leaderAtom, XA_WINDOW) != leader ||
|
||||
cthulhu_single_window_property(display, candidate, pidAtom, XA_CARDINAL) != pid ||
|
||||
!XGetTransientForHint(display, candidate, &owner) || owner == None ||
|
||||
owner == root || owner == candidate ||
|
||||
!XGetWindowAttributes(display, owner, &attributes) ||
|
||||
attributes.class != InputOutput || attributes.override_redirect ||
|
||||
attributes.map_state != IsViewable ||
|
||||
cthulhu_single_window_property(display, owner, leaderAtom, XA_WINDOW) != leader ||
|
||||
cthulhu_single_window_property(display, owner, pidAtom, XA_CARDINAL) != pid)
|
||||
continue;
|
||||
result = owner;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (children != NULL) XFree(children);
|
||||
return result;
|
||||
}
|
||||
// Follow the mapped child under the pointer. The client PID is usually on a
|
||||
// descendant of the window-manager frame; keep the first PID found on that path.
|
||||
PointerPosition cthulhu_read_pointer(Display *display) {
|
||||
@@ -124,7 +191,7 @@ Foreground read_foreground(Display *display) {
|
||||
result.known=1; window=((Window *)data)[0];
|
||||
} else {
|
||||
// Non-EWMH X11 sessions (including the native fixture): resolve
|
||||
// input focus to its client ancestor. Keyboard grabs do not change it.
|
||||
// input focus to its client ancestor and resolve input-only popup grabs.
|
||||
int revert; XGetInputFocus(display,&window,&revert);
|
||||
result.known=1;
|
||||
for (int depth=0; window!=None && window!=PointerRoot && window!=root && depth<64; depth++) {
|
||||
@@ -139,6 +206,7 @@ Foreground read_foreground(Display *display) {
|
||||
if (parent==root || parent==None) break;
|
||||
window=parent;
|
||||
}
|
||||
window=cthulhu_input_grab_owner(display,window);
|
||||
}
|
||||
if (data) XFree(data);
|
||||
if (window!=None && window!=PointerRoot && window!=root) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package xorg
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNativeInputOnlyGrabForeground(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_GRAB_FOREGROUND") != "1" {
|
||||
t.Skip("requires private X11 window-group fixture")
|
||||
}
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
fixture := exec.CommandContext(ctx, "/usr/bin/python3", "-u", "../../../tests/x11_grab_fixture.py")
|
||||
input, err := fixture.StdinPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output, err := fixture.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fixture.Stderr = os.Stderr
|
||||
if err := fixture.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { input.Close(); fixture.Wait() }()
|
||||
scanner := bufio.NewScanner(output)
|
||||
if !scanner.Scan() {
|
||||
t.Fatal("window fixture did not start")
|
||||
}
|
||||
var windows map[string]uint64
|
||||
if err := json.Unmarshal(scanner.Bytes(), &windows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader := OpenForegroundReader()
|
||||
if reader == nil {
|
||||
t.Fatal("private X11 connection unavailable")
|
||||
}
|
||||
defer reader.Close()
|
||||
for _, test := range []struct{ action, want string }{
|
||||
{"popup", "owner"},
|
||||
{"hidden", "grab"},
|
||||
{"other-group", "grab"},
|
||||
{"other-pid", "grab"},
|
||||
{"ordinary-window", "grab"},
|
||||
{"no-owner", "grab"},
|
||||
{"foreign-owner", "grab"},
|
||||
{"input-output-focus", "other"},
|
||||
{"ewmh", "other"},
|
||||
{"popup", "owner"},
|
||||
} {
|
||||
t.Run(test.action, func(t *testing.T) {
|
||||
if _, err := fmt.Fprintln(input, test.action); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !scanner.Scan() || scanner.Text() != "ready" {
|
||||
t.Fatal("fixture command failed")
|
||||
}
|
||||
got := reader.Read()
|
||||
if !got.Known || got.Window != windows[test.want] || got.PID != fixture.Process.Pid {
|
||||
t.Fatalf("foreground = %+v; want %s owned by PID %d", got, test.want, fixture.Process.Pid)
|
||||
}
|
||||
if test.want == "owner" && (got.X != 20 || got.Y != 30 || got.Width != 600 || got.Height != 400) {
|
||||
t.Fatalf("grab did not retain transient owner's bounds: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user