diff --git a/README.md b/README.md index bc5ac6d..954659e 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,43 @@ Cthulhu is a Linux screen reader written in Go. AI-assisted contributions are welcome. Contributors remain responsible for human review and testing before submission; see the [full policy](CONTRIBUTING.md#ai-assisted-contributions). +## Login greeter settings + +Copy your saved active profile into the LightDM account's default Cthulhu +settings location, without starting or replacing a reader: + +```sh +cthulhu --greeter lightdm +``` + +Run this as your normal user. Cthulhu requests sudo authentication to run the +copy as the `lightdm` account. It discovers that account's home directory and +writes `.local/share/cthulhu/user-settings-go.toml` there, creating directories +as needed. The destination belongs to that account and has mode `0600`. +Cthulhu must be installed where the greeter account can execute it. +LightDM's default greeter account is `lightdm`; custom greeter accounts are +not currently supported. + +Use `cthulhu --greeter lightdm --config FILE` to copy from an alternative +configuration. Your normal configuration remains unchanged. The copy contains +one complete active profile, including speech, Braille and keyboard preferences; +application profiles and Tentacles are not copied. Save preferences first: +unsaved changes are not included. Repeating the command updates the installed +settings. A missing source file selects built-in defaults, as at normal startup. + +Configure the LightDM GTK greeter's reader command as +`cthulhu --no-tentacles` so it uses that account's default settings. Remove any +explicit `--config` path from an existing reader command to use the new location. +Changes take effect when the greeter reader next starts. + +The greeter account needs its own accessibility bus and working audio setup. +Selected voices, Braille tables and custom sound assets must also be installed +and readable by that account. The command copies settings; it does not configure +LightDM or audio, restart a reader, or create a restricted login mode. + +Native X11 grab-window regression: +`python3 tests/native_test_session.py env CTHULHU_NATIVE_GRAB_FOREGROUND=1 go test ./internal/platform/xorg -run '^TestNativeInputOnlyGrabForeground$' -count=1`. + ## Key echo The Key Echo preferences page offers three radio choices: Disable key echo, diff --git a/cmd/cthulhu/greeter.go b/cmd/cthulhu/greeter.go new file mode 100644 index 0000000..8db837b --- /dev/null +++ b/cmd/cthulhu/greeter.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +func greeter_account(name string) (*user.User, error) { + if name != "lightdm" { + return nil, fmt.Errorf("unsupported greeter %q; supported: lightdm", name) + } + account, err := user.Lookup(name) + if err != nil { + return nil, fmt.Errorf("find %s greeter account: %w", name, err) + } + if account.Uid == "0" || !filepath.IsAbs(account.HomeDir) || account.HomeDir == "/" { + return nil, fmt.Errorf("%s greeter account needs a non-root UID and an absolute home directory other than /", name) + } + return account, nil +} + +func copy_greeter_settings(ctx context.Context, settings config.Settings, account *user.User, stdout, stderr io.Writer) error { + if account.Uid != strconv.Itoa(os.Geteuid()) { + executable, err := os.Executable() + if err != nil { + return fmt.Errorf("find Cthulhu executable: %w", err) + } + command, err := greeter_copy_command(ctx, executable, settings, account) + if err != nil { + return err + } + // sudo reads authentication from the terminal, leaving stdin available + // for the validated settings. Only the greeter account writes its files. + fmt.Fprintf(stdout, "Copying settings for %s; sudo authentication may be required.\n", account.Username) + command.Stdout, command.Stderr = stdout, stderr + if err := command.Run(); err != nil { + return fmt.Errorf("copy greeter settings as %s: %w", account.Username, err) + } + return nil + } + // Deliberately ignore the invoking user's XDG_DATA_HOME. This is the + // destination account's default, matching an ordinary Cthulhu startup. + path := filepath.Join(account.HomeDir, ".local", "share", "cthulhu", "user-settings-go.toml") + if err := config.WriteGreeterSettings(path, settings); err != nil { + return fmt.Errorf("copy greeter settings: %w", err) + } + fmt.Fprintf(stdout, "Copied greeter settings to %s. They take effect when the greeter reader next starts.\n", path) + return nil +} + +func greeter_copy_command(ctx context.Context, executable string, settings config.Settings, account *user.User) (*exec.Cmd, error) { + contents, err := config.GreeterSettings(settings) + if err != nil { + return nil, err + } + // Re-enter only the copy path, never the screen reader. Sending the + // snapshot over stdin avoids exposing the source file to another user. + command := exec.CommandContext(ctx, "sudo", "--user", account.Username, "--", + executable, "--greeter", "lightdm", "--greeter-settings-stdin") + command.Stdin = bytes.NewReader(contents) + return command, nil +} diff --git a/cmd/cthulhu/greeter_test.go b/cmd/cthulhu/greeter_test.go new file mode 100644 index 0000000..350cfa0 --- /dev/null +++ b/cmd/cthulhu/greeter_test.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package main + +import ( + "bytes" + "context" + "io" + "os" + "os/user" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + + "git.stormux.org/storm/cthulhu/internal/config" +) + +func TestGreeterCopyUsesDestinationHomeAndPreservesSource(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent/session-bus") + directory := t.TempDir() + source := filepath.Join(directory, "source.toml") + contents := []byte("[general]\nenableEchoByWord = true\n") + if err := os.WriteFile(source, contents, 0o600); err != nil { + t.Fatal(err) + } + settings, err := config.Load(source) + if err != nil { + t.Fatal(err) + } + account := &user.User{Username: "Username", Uid: strconv.Itoa(os.Geteuid()), HomeDir: filepath.Join(directory, "greeter-home")} + t.Setenv("XDG_DATA_HOME", filepath.Join(directory, "source-data")) + var output bytes.Buffer + if err := copy_greeter_settings(context.Background(), settings, account, &output, &output); err != nil { + t.Fatal(err) + } + target := filepath.Join(account.HomeDir, ".local", "share", "cthulhu", "user-settings-go.toml") + copied, err := config.Load(target) + if err != nil || !copied.Loaded || copied.ActiveProfile != "greeter" || !copied.EnableEchoByWord { + t.Fatalf("copied settings = %#v, error %v", copied, err) + } + info, err := os.Stat(target) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("destination mode = %v, error %v", info, err) + } + unchanged, err := os.ReadFile(source) + if err != nil || !bytes.Equal(contents, unchanged) { + t.Fatalf("source changed: %q, error %v", unchanged, err) + } + if !strings.Contains(output.String(), target) { + t.Fatalf("missing destination confirmation: %q", output.String()) + } + // A second copy must replace settings without leaving a stale value. + settings.EnableEchoByWord = false + if err := copy_greeter_settings(context.Background(), settings, account, &output, &output); err != nil { + t.Fatal(err) + } + copied, err = config.Load(target) + if err != nil || copied.EnableEchoByWord { + t.Fatalf("repeated copy retained old settings: %#v, %v", copied, err) + } +} + +func TestGreeterCopyRequestsOnlyDestinationAccountPrivileges(t *testing.T) { + settings := config.DefaultSettings() + settings.Path = "/private/source/settings.toml" + settings.EnableEchoByWord = true + account := &user.User{Username: "Username"} + command, err := greeter_copy_command(context.Background(), "/usr/bin/cthulhu", settings, account) + if err != nil { + t.Fatal(err) + } + want := []string{"sudo", "--user", "Username", "--", "/usr/bin/cthulhu", "--greeter", "lightdm", "--greeter-settings-stdin"} + if !reflect.DeepEqual(command.Args, want) { + t.Fatalf("copy arguments = %q, want %q", command.Args, want) + } + contents, err := io.ReadAll(command.Stdin) + if err != nil { + t.Fatal(err) + } + snapshot := filepath.Join(t.TempDir(), "snapshot.toml") + if err := os.WriteFile(snapshot, contents, 0o600); err != nil { + t.Fatal(err) + } + copied, err := config.Load(snapshot) + if err != nil || copied.ActiveProfile != "greeter" || !copied.EnableEchoByWord { + t.Fatalf("sudo stdin snapshot = %#v, error %v", copied, err) + } +} diff --git a/cmd/cthulhu/main.go b/cmd/cthulhu/main.go index 3bcef68..543a0a3 100644 --- a/cmd/cthulhu/main.go +++ b/cmd/cthulhu/main.go @@ -26,6 +26,7 @@ import ( "log/slog" "os" "os/signal" + "os/user" "syscall" "time" @@ -65,6 +66,8 @@ func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) (res replace := options.Bool("replace", false, "stop the running Cthulhu instance before starting") shortReplace := options.Bool("r", false, "stop the running Cthulhu instance before starting") checkConfig := options.Bool("check-config", false, "validate configuration and exit") + greeterStdin := options.Bool("greeter-settings-stdin", false, "receive an internal greeter settings snapshot") + greeter := options.String("greeter", "", "copy the saved active profile to a greeter account and exit (supported: lightdm)") shortSettings := options.Bool("s", false, "open the preferences window at startup") settingsFlag := options.Bool("settings", false, "open the preferences window at startup") preferencesUI := options.Bool("preferences-ui", false, "run the internal preferences helper (private pipe protocol)") @@ -102,7 +105,7 @@ func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) (res options.VisitAll(func(option *flag.Flag) { switch option.Name { case "preferences-ui", "notification-list-ui", "speech-history-ui", "shortcut-list-ui", - "clipboard-read", "clipboard-watch", "ocr-capture", "ocr-confirm", "tentacle-guardian": + "clipboard-read", "clipboard-watch", "ocr-capture", "ocr-confirm", "tentacle-guardian", "greeter-settings-stdin": return } visibleOptions.Var(option.Value, option.Name, option.Usage) @@ -155,6 +158,20 @@ func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) (res if *ocrCapture { return ocrcapture.Run(ctx, os.Stdin, stdout) } + var greeterAccount *user.User + if *greeterStdin && (*greeter == "" || *configPath != "") { + return errors.New("--greeter-settings-stdin requires --greeter and cannot be combined with --config") + } + if *greeter != "" { + var err error + greeterAccount, err = greeter_account(*greeter) + if err != nil { + return err + } + if os.Geteuid() == 0 && *configPath == "" { + return errors.New("run --greeter as your normal user; it requests sudo authentication when needed (root must supply --config FILE)") + } + } logger := slog.New(slog.DiscardHandler) if *debugFile != "" { fileWriter, err := logging.OpenFile(*debugFile, nil) @@ -177,7 +194,13 @@ func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) (res "build", version.RuntimeIdentity(), "atspi_version", atspi.Version(), "session_type", os.Getenv("XDG_SESSION_TYPE"), "session_desktop", os.Getenv("XDG_SESSION_DESKTOP"), "x11_display_available", os.Getenv("DISPLAY") != "", "wayland_display_available", os.Getenv("WAYLAND_DISPLAY") != "") - settings, err := config.Load(*configPath) + var settings config.Settings + var err error + if *greeterStdin { + settings, err = config.ReadGreeterSettings(os.Stdin) + } else { + settings, err = config.Load(*configPath) + } if err != nil { logger.Error("load configuration", "error", err) return err @@ -192,6 +215,9 @@ func run(ctx context.Context, arguments []string, stdout, stderr io.Writer) (res } return nil } + if greeterAccount != nil { + return copy_greeter_settings(ctx, settings, greeterAccount, stdout, stderr) + } if *replace || *shortReplace { if err := replaceRunning(ctx); err != nil { return fmt.Errorf("replace running Cthulhu: %w", err) diff --git a/cmd/cthulhu/main_test.go b/cmd/cthulhu/main_test.go index b959b82..e1012cb 100644 --- a/cmd/cthulhu/main_test.go +++ b/cmd/cthulhu/main_test.go @@ -90,6 +90,18 @@ func TestCheckConfig(t *testing.T) { } } +func TestUnsupportedGreeterDoesNotStartOrReplaceReader(t *testing.T) { + t.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent/session-bus") + var output bytes.Buffer + err := run(context.Background(), []string{"--greeter", "unsupported", "--replace"}, &output, &output) + if err == nil || !strings.Contains(err.Error(), "unsupported greeter") { + t.Fatalf("unsupported greeter error = %v", err) + } + if output.Len() != 0 { + t.Fatalf("unexpected output: %q", output.String()) + } +} + func TestCheckVersionedConfig(t *testing.T) { for _, test := range []struct { name string diff --git a/internal/config/greeter.go b/internal/config/greeter.go new file mode 100644 index 0000000..08c9b42 --- /dev/null +++ b/internal/config/greeter.go @@ -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) +} diff --git a/internal/config/greeter_test.go b/internal/config/greeter_test.go new file mode 100644 index 0000000..019dc7d --- /dev/null +++ b/internal/config/greeter_test.go @@ -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") + } +} diff --git a/internal/platform/xorg/commands_native.c b/internal/platform/xorg/commands_native.c index 9652190..47a577d 100644 --- a/internal/platform/xorg/commands_native.c +++ b/internal/platform/xorg/commands_native.c @@ -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) { diff --git a/internal/platform/xorg/grab_foreground_native_test.go b/internal/platform/xorg/grab_foreground_native_test.go new file mode 100644 index 0000000..7967033 --- /dev/null +++ b/internal/platform/xorg/grab_foreground_native_test.go @@ -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) + } + }) + } +} diff --git a/tests/x11_grab_fixture.py b/tests/x11_grab_fixture.py new file mode 100644 index 0000000..54550ff --- /dev/null +++ b/tests/x11_grab_fixture.py @@ -0,0 +1,75 @@ +"""Window-group and transient-popup fixtures for private X11 focus tests.""" + +import json +import os +import sys + +from Xlib import X, Xatom, display + + +connection = display.Display() +root = connection.screen().root +leaderAtom = connection.intern_atom("WM_CLIENT_LEADER") +pidAtom = connection.intern_atom("_NET_WM_PID") +typeAtom = connection.intern_atom("_NET_WM_WINDOW_TYPE") +activeAtom = connection.intern_atom("_NET_ACTIVE_WINDOW") + + +def make_window(parent, x, y, width, height, inputOnly=False, override=False): + window = parent.create_window( + x, y, width, height, 0, 0 if inputOnly else connection.screen().root_depth, + X.InputOnly if inputOnly else X.InputOutput, X.CopyFromParent, + override_redirect=override, + ) + window.change_property(pidAtom, Xatom.CARDINAL, 32, [os.getpid()]) + return window + + +leader = make_window(root, 0, 0, 1, 1, inputOnly=True) +owner = make_window(root, 20, 30, 600, 400) +grab = make_window(root, -100, -100, 10, 10, inputOnly=True, override=True) +popup = make_window(root, 40, 60, 200, 80, override=True) +other = make_window(root, 700, 30, 100, 100) +for window in [owner, grab, popup]: + window.change_property(leaderAtom, Xatom.WINDOW, 32, [leader.id]) +owner.map() +other.map() + + +def configure(action): + root.delete_property(activeAtom) + popup.change_property(leaderAtom, Xatom.WINDOW, 32, [leader.id]) + popup.change_property(pidAtom, Xatom.CARDINAL, 32, [os.getpid()]) + popup.change_property(typeAtom, Xatom.ATOM, 32, + [connection.intern_atom("_NET_WM_WINDOW_TYPE_COMBO")]) + popup.set_wm_transient_for(owner) + popup.map() + grab.map() + focus = grab + if action == "hidden": + popup.unmap() + elif action == "other-group": + popup.change_property(leaderAtom, Xatom.WINDOW, 32, [other.id]) + elif action == "other-pid": + popup.change_property(pidAtom, Xatom.CARDINAL, 32, [os.getpid() + 1]) + elif action == "ordinary-window": + popup.change_property(typeAtom, Xatom.ATOM, 32, + [connection.intern_atom("_NET_WM_WINDOW_TYPE_NORMAL")]) + elif action == "no-owner": + popup.delete_property(connection.intern_atom("WM_TRANSIENT_FOR")) + elif action == "foreign-owner": + popup.set_wm_transient_for(other) + elif action == "input-output-focus": + focus = other + elif action == "ewmh": + root.change_property(activeAtom, Xatom.WINDOW, 32, [other.id]) + focus.set_input_focus(X.RevertToParent, X.CurrentTime) + connection.sync() + + +configure("popup") +print(json.dumps(dict(owner=owner.id, grab=grab.id, other=other.id)), flush=True) +for line in sys.stdin: + configure(line.strip()) + print("ready", flush=True) +connection.close()