diff --git a/core/Makefile b/core/Makefile index c3ee3960d..1ddc9d754 100644 --- a/core/Makefile +++ b/core/Makefile @@ -50,7 +50,7 @@ sync-shell: @rm -rf $(EMBED_DIR) @mkdir -p $(EMBED_DIR) @tar -C $(SHELL_SRC) --exclude=.qmlls.ini -chf - . | tar -C $(EMBED_DIR) -xf - - @rm -rf $(EMBED_DIR)/.git* $(EMBED_DIR)/.github $(EMBED_DIR)/tests + @rm -rf $(EMBED_DIR)/.git* $(EMBED_DIR)/.github @find $(EMBED_DIR) -type d \( -name .claude -o -name .vscode \) -prune -exec rm -rf {} + @rm -f $(EMBED_DIR)/AGENTS.md $(EMBED_DIR)/qmlformat-all.sh @rm -f $(EMBED_DIR)/scripts/i18nsync.py $(EMBED_DIR)/scripts/build-vscode-vsix.sh $(EMBED_DIR)/scripts/qmllint-entrypoints.sh diff --git a/core/cmd/dms/commands_screenshot.go b/core/cmd/dms/commands_screenshot.go index 6c018959e..f8362c286 100644 --- a/core/cmd/dms/commands_screenshot.go +++ b/core/cmd/dms/commands_screenshot.go @@ -123,7 +123,7 @@ If no previous region exists, falls back to interactive selection.`, var ssWindowCmd = &cobra.Command{ Use: "window", Short: "Capture the focused window", - Long: `Capture the currently focused window. Supported on Hyprland, Mango, niri, and Aqueous. Aqueous crops the composed output including borders and occluders.`, + Long: `Capture the currently focused window. Supported on Hyprland, Mango, niri, and Aqueous. Aqueous requires a running DMS shell and crops the output including borders and overlapping windows.`, Run: runScreenshotWindow, } diff --git a/core/internal/aqueous_ipc.go b/core/internal/aqueous_ipc.go deleted file mode 100644 index 369198015..000000000 --- a/core/internal/aqueous_ipc.go +++ /dev/null @@ -1,260 +0,0 @@ -//go:build linux - -package internal - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "os" - "path/filepath" - "regexp" - "syscall" - "time" - "unicode/utf8" - - "golang.org/x/sys/unix" -) - -const aqueousMaxFrameBytes = 4*1024*1024 + 65536 -const aqueousMaxBatchBytes = 4 * 1024 * 1024 - -var aqueousSessionPattern = regexp.MustCompile(`^[0-9a-f]{32}$`) -var aqueousSequencePattern = regexp.MustCompile(`^[0-9]{1,20}$`) - -type aqueousHello struct { - Session string `json:"session"` - Schema int `json:"schema"` - MaxRequest int `json:"max_request_bytes"` - MaxFrame int `json:"max_frame_bytes"` - MaxBatch int `json:"max_batch_bytes"` - MaxPending int `json:"max_pending_requests"` - Capabilities map[string]*bool `json:"capabilities"` -} - -func validateAqueousSocketPath(path string) error { - runtime := os.Getenv("XDG_RUNTIME_DIR") - if !filepath.IsAbs(runtime) || !filepath.IsAbs(path) || filepath.Clean(path) != path || filepath.Dir(filepath.Dir(filepath.Dir(path))) != runtime || filepath.Base(filepath.Dir(filepath.Dir(path))) != "aqueous" || filepath.Base(path) != "ipc.sock" { - return errors.New("unavailable: invalid AQUEOUS_SOCKET or XDG_RUNTIME_DIR") - } - for _, dir := range []string{runtime, filepath.Dir(filepath.Dir(path)), filepath.Dir(path)} { - if err := validateAqueousDirectory(dir); err != nil { - return err - } - } - info, err := os.Lstat(path) - if err != nil { - return err - } - if info.Mode()&os.ModeSocket == 0 || info.Mode().Perm() != 0600 { - return errors.New("unavailable: socket must have mode 0600") - } - return nil -} - -func readAqueousFrame(reader *bufio.Reader, limit int) ([]byte, error) { - var frame []byte - for { - part, err := reader.ReadSlice('\n') - count := len(part) - if count > 0 && part[count-1] == '\n' { - count-- - } - if len(frame)+count > limit { - return nil, errors.New("IPC frame exceeds size bound") - } - frame = append(frame, part[:count]...) - if err == bufio.ErrBufferFull { - continue - } - if err != nil { - return nil, fmt.Errorf("truncated IPC frame: %w", err) - } - if !utf8.Valid(frame) || !json.Valid(frame) { - return nil, errors.New("invalid IPC JSON/UTF-8") - } - depth, quoted, escaped := 0, false, false - for _, c := range frame { - if quoted { - if escaped { - escaped = false - } else if c == '\\' { - escaped = true - } else if c == '"' { - quoted = false - } - continue - } - if c == '"' { - quoted = true - } - if c == '{' || c == '[' { - depth++ - if depth > 32 { - return nil, errors.New("IPC nesting exceeds limit") - } - } - if c == '}' || c == ']' { - depth-- - } - } - return frame, nil - } -} - -func exchangeAqueousRequest(conn net.Conn, reader *bufio.Reader, id, op, session string, maxRequest, limit int) (json.RawMessage, error) { - request := map[string]any{"ipc": 1, "id": id, "op": op, "params": map[string]any{}} - if session != "" { - request["session"] = session - } - data, err := json.Marshal(request) - if err != nil { - return nil, err - } - if len(data) > maxRequest { - return nil, errors.New("IPC request exceeds advertised size bound") - } - if _, err = io.Copy(conn, bytes.NewReader(append(data, '\n'))); err != nil { - return nil, err - } - frame, err := readAqueousFrame(reader, limit) - if err != nil { - return nil, err - } - var response struct { - IPC int `json:"ipc"` - ID string `json:"id"` - OK *bool `json:"ok"` - Event json.RawMessage `json:"event"` - Result json.RawMessage `json:"result"` - Error *struct { - Code string `json:"code"` - Message *string `json:"message"` - } `json:"error"` - } - if err := json.Unmarshal(frame, &response); err != nil { - return nil, err - } - if response.IPC != 1 || response.ID != id || response.OK == nil || response.Event != nil { - return nil, errors.New("invalid IPC response envelope") - } - if !*response.OK { - if response.Error == nil || response.Error.Code == "" || len(response.Error.Code) > 128 || (response.Error.Message == nil || len(*response.Error.Message) > 4096) || response.Result != nil { - return nil, errors.New("malformed IPC error") - } - return nil, fmt.Errorf("%s: %s", response.Error.Code, *response.Error.Message) - } - if response.Error != nil || len(response.Result) == 0 || response.Result[0] != '{' { - return nil, errors.New("missing IPC result") - } - return response.Result, nil -} - -// AqueousSnapshot uses only the inherited endpoint; no discovery or helper subprocesses. -func AqueousSnapshot(ctx context.Context) (json.RawMessage, error) { - path := os.Getenv("AQUEOUS_SOCKET") - if err := validateAqueousSocketPath(path); err != nil { - return nil, err - } - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - conn, err := (&net.Dialer{}).DialContext(ctx, "unix", path) - if err != nil { - return nil, err - } - defer conn.Close() - stop := context.AfterFunc(ctx, func() { conn.Close() }) - defer stop() - deadline, _ := ctx.Deadline() - if err := conn.SetDeadline(deadline); err != nil { - return nil, err - } - if err := verifyAqueousPeer(conn.(*net.UnixConn)); err != nil { - return nil, err - } - reader := bufio.NewReaderSize(conn, 16384) - raw, err := exchangeAqueousRequest(conn, reader, "1", "hello", "", 65536, aqueousMaxFrameBytes) - if err != nil { - return nil, err - } - var h aqueousHello - if err := json.Unmarshal(raw, &h); err != nil { - return nil, err - } - if !aqueousSessionPattern.MatchString(h.Session) || h.Schema != 1 || h.MaxRequest <= 0 || h.MaxRequest > 65536 || h.MaxFrame <= 0 || h.MaxFrame > aqueousMaxFrameBytes || h.MaxBatch <= 0 || h.MaxBatch > aqueousMaxBatchBytes || h.MaxPending != 1 { - return nil, errors.New("unsupported: IPC hello") - } - for _, name := range []string{"state", "commands", "keyboard", "overview", "shortcut_inhibition"} { - if h.Capabilities[name] == nil { - return nil, errors.New("unsupported: missing IPC capability") - } - } - if !*h.Capabilities["state"] { - return nil, errors.New("unsupported: state query") - } - raw, err = exchangeAqueousRequest(conn, reader, "2", "snapshot", h.Session, h.MaxRequest, h.MaxFrame) - if err != nil { - return nil, err - } - var result struct { - Batch json.RawMessage `json:"batch"` - } - if err := json.Unmarshal(raw, &result); err != nil { - return nil, err - } - var batch struct { - Session string `json:"session"` - Schema int `json:"schema"` - Sequence string `json:"sequence"` - Type string `json:"type"` - } - if len(result.Batch) > h.MaxBatch { - return nil, errors.New("snapshot exceeds size bound") - } - if err := json.Unmarshal(result.Batch, &batch); err != nil { - return nil, err - } - if batch.Session != h.Session || batch.Schema != 1 || batch.Type != "snapshot" || !aqueousSequencePattern.MatchString(batch.Sequence) { - return nil, errors.New("invalid snapshot identity/schema") - } - return result.Batch, nil -} - -func validateAqueousDirectory(path string) error { - info, err := os.Lstat(path) - if err != nil { - return err - } - stat, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.IsDir() || info.Mode().Perm() != 0700 || stat.Uid != uint32(os.Getuid()) { - return errors.New("unavailable: runtime directory must be private and owned by this user") - } - return nil -} - -func verifyAqueousPeer(conn *net.UnixConn) error { - raw, err := conn.SyscallConn() - if err != nil { - return err - } - var peerErr error - err = raw.Control(func(fd uintptr) { - cred, err := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) - if err != nil { - peerErr = err - return - } - if cred.Uid != uint32(os.Getuid()) { - peerErr = errors.New("IPC peer UID mismatch") - } - }) - if err != nil { - return err - } - return peerErr -} diff --git a/core/internal/aqueous_ipc_test.go b/core/internal/aqueous_ipc_test.go deleted file mode 100644 index db17b6fce..000000000 --- a/core/internal/aqueous_ipc_test.go +++ /dev/null @@ -1,188 +0,0 @@ -//go:build linux - -package internal - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "net" - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestAqueousIPCReadFrame(t *testing.T) { - for _, tc := range []struct { - name, input string - limit int - valid bool - }{ - {"unicode", "{\"title\":\"🫧 日本語\"}\n", 64, true}, - {"truncated", "{}", 64, false}, - {"invalid utf8", "{\"title\":\"\xff\"}\n", 64, false}, - {"oversize without newline", strings.Repeat("x", 17000), 16000, false}, - {"exact limit", "{}\n", 2, true}, - {"oversize", "{} \n", 2, false}, - {"deep", strings.Repeat("[", 33) + strings.Repeat("]", 33) + "\n", 100, false}, - } { - t.Run(tc.name, func(t *testing.T) { - _, err := readAqueousFrame(bufio.NewReaderSize(strings.NewReader(tc.input), 16), tc.limit) - if (err == nil) != tc.valid { - t.Fatalf("unexpected error: %v", err) - } - }) - } - reader := bufio.NewReader(strings.NewReader("{}\n{\"next\":true}\n")) - for _, expected := range []string{"{}", `{"next":true}`} { - got, err := readAqueousFrame(reader, 64) - if err != nil || string(got) != expected { - t.Fatalf("got %q, %v", got, err) - } - } -} - -func fakeAqueousIPCServer(t *testing.T, serve func(net.Conn)) { - t.Helper() - runtime, err := os.MkdirTemp("/tmp", "aq-ipc-") - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { os.RemoveAll(runtime) }) - path := filepath.Join(runtime, "aqueous", "test", "ipc.sock") - if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { - t.Fatal(err) - } - listener, err := net.Listen("unix", path) - if err != nil { - t.Fatal(err) - } - if err := os.Chmod(path, 0600); err != nil { - t.Fatal(err) - } - t.Setenv("XDG_RUNTIME_DIR", runtime) - t.Setenv("AQUEOUS_SOCKET", path) - done := make(chan struct{}) - go func() { - defer close(done) - conn, err := listener.Accept() - if err != nil { - return - } - defer conn.Close() - conn.SetDeadline(time.Now().Add(3 * time.Second)) - serve(conn) - }() - t.Cleanup(func() { listener.Close(); <-done }) -} - -func TestAqueousIPCSnapshot(t *testing.T) { - fixture, err := os.ReadFile("../../quickshell/tests/fixtures/aqueous/hello.json") - if err != nil { - t.Fatal(err) - } - snapshot, err := os.ReadFile("screenshot/testdata/aqueous-snapshot.json") - if err != nil { - t.Fatal(err) - } - var batch map[string]any - if err := json.Unmarshal(snapshot, &batch); err != nil { - t.Fatal(err) - } - // Both clients consume the same wire hello fixture. - var h map[string]any - json.Unmarshal(fixture, &h) - batch["session"] = h["result"].(map[string]any)["session"] - batch["sequence"] = "9007199254740993123" - for _, mode := range []string{"valid", "bad hello", "bad id", "malformed error", "stale session", "truncated", "oversize", "cancel"} { - t.Run(mode, func(t *testing.T) { - fakeAqueousIPCServer(t, func(conn net.Conn) { - reader := bufio.NewReader(conn) - first, err := readAqueousFrame(reader, 65536) - if err != nil { - return - } - if !bytes.Contains(first, []byte(`"op":"hello"`)) { - t.Error("missing hello") - return - } - if mode == "cancel" { - reader.ReadByte() - return - } - var compact bytes.Buffer - json.Compact(&compact, fixture) - helloReply := append(compact.Bytes(), '\n') - if mode == "bad hello" { - helloReply = bytes.Replace(helloReply, []byte(`"schema":1`), []byte(`"schema":2`), 1) - } - conn.Write(helloReply) - if mode == "bad hello" { - return - } - req, err := readAqueousFrame(reader, 65536) - if err != nil { - return - } - var request map[string]any - json.Unmarshal(req, &request) - if request["session"] != batch["session"] || request["op"] != "snapshot" || request["id"] != "2" { - t.Error("invalid snapshot request") - return - } - reply := map[string]any{"ipc": 1, "id": "2", "ok": true, "result": map[string]any{"batch": batch}} - switch mode { - case "bad id": - reply["id"] = "3" - case "malformed error": - reply = map[string]any{"ipc": 1, "id": "2", "ok": false, "error": map[string]any{"code": 7}} - case "stale session": - reply = map[string]any{"ipc": 1, "id": "2", "ok": false, "error": map[string]any{"code": "stale_session", "message": "session changed"}} - case "truncated": - conn.Write([]byte(`{"ipc":`)) - return - case "oversize": - conn.Write(bytes.Repeat([]byte("x"), aqueousMaxFrameBytes+1)) - return - } - data, _ := json.Marshal(reply) - data = append(data, '\n') - for len(data) > 0 { - n := min(7, len(data)) - if _, err := conn.Write(data[:n]); err != nil { - return - } - data = data[n:] - } - }) - ctx, cancel := context.WithTimeout(context.Background(), time.Second) - defer cancel() - if mode == "cancel" { - go func() { time.Sleep(20 * time.Millisecond); cancel() }() - } - result, err := AqueousSnapshot(ctx) - if mode != "valid" { - if err == nil { - t.Fatal("invalid server accepted") - } - return - } - if err != nil { - t.Fatal(err) - } - if !bytes.Contains(result, []byte("9007199254740993123")) { - t.Fatal("sequence lost precision") - } - }) - } -} - -func TestAqueousIPCMissingSocket(t *testing.T) { - t.Setenv("AQUEOUS_SOCKET", "") - if _, err := AqueousSnapshot(context.Background()); err == nil { - t.Fatal("missing socket accepted") - } -} diff --git a/core/internal/keybinds/providers/aqueous.go b/core/internal/keybinds/providers/aqueous.go index 59a453d17..e6cc2ac0d 100644 --- a/core/internal/keybinds/providers/aqueous.go +++ b/core/internal/keybinds/providers/aqueous.go @@ -95,6 +95,29 @@ func AqueousBindRequest(snapshot aqueousConfig, edit AqueousBindEdit) (aqueousCo if original == "" { original = edit.Key } + originalCount := 0 + for _, field := range aqueousObjects(snapshot, "fields") { + if field.String("category") != "keybinds" || field.String("type") != "string_list" { + continue + } + values, _ := field["value"].([]any) + for _, value := range values { + if value == original { + originalCount++ + } + } + } + for _, binding := range aqueousObjects(snapshot, "custom_keybinds") { + if binding.String("chord") == original { + originalCount++ + } + } + if originalCount > 1 { + return nil, &AqueousError{Code: "ambiguous_target", Message: "multiple bindings use the original shortcut"} + } + if originalCount == 0 && (edit.Remove || edit.OriginalKey != "") { + return nil, &AqueousError{Code: "target_removed", Message: "the original shortcut no longer exists"} + } changes := []any{} customChanges := []any{} foundAction := false diff --git a/core/internal/keybinds/providers/aqueous_test.go b/core/internal/keybinds/providers/aqueous_test.go index b9f59f71c..2d7b62581 100644 --- a/core/internal/keybinds/providers/aqueous_test.go +++ b/core/internal/keybinds/providers/aqueous_test.go @@ -4,11 +4,12 @@ import ( "context" "encoding/json" "errors" - "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "os" "path/filepath" "reflect" "testing" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" ) func aqueousFixture(t *testing.T) aqueousConfig { @@ -85,6 +86,26 @@ func TestAqueousGenerationConflictCode(t *testing.T) { } } +func TestAqueousBindingRequiresUniqueOriginal(t *testing.T) { + for _, edit := range []AqueousBindEdit{ + {Generation: "abc", Key: "Super+Missing", Remove: true}, + {Generation: "abc", OriginalKey: "Super+Missing", Key: "Super+X", Action: "spawn_terminal"}, + } { + _, err := AqueousBindRequest(aqueousFixture(t), edit) + var conflict *AqueousError + if !errors.As(err, &conflict) || conflict.Code != "target_removed" { + t.Fatalf("expected missing original, got %v", err) + } + } + snapshot := aqueousFixture(t) + snapshot["custom_keybinds"] = append(snapshot["custom_keybinds"].([]any), map[string]any{"id": "duplicate", "chord": "Super+T", "command": "echo duplicate"}) + _, err := AqueousBindRequest(snapshot, AqueousBindEdit{Generation: "abc", Key: "Super+T", Remove: true}) + var conflict *AqueousError + if !errors.As(err, &conflict) || conflict.Code != "ambiguous_target" { + t.Fatalf("expected ambiguous original, got %v", err) + } +} + func TestAqueousHelperFailureCodes(t *testing.T) { for _, tc := range []struct { name string diff --git a/core/internal/screenshot/aqueous.go b/core/internal/screenshot/aqueous.go index 40d4d68a0..34faf63e0 100644 --- a/core/internal/screenshot/aqueous.go +++ b/core/internal/screenshot/aqueous.go @@ -76,7 +76,6 @@ func (s *Screenshoter) captureAqueousWindow() (*CaptureResult, error) { return nil, err } defer result.Buffer.Close() - // captureWholeOutput already normalizes inversion and output transform. rect := aqueousCropRect(geom, image.Rect(0, 0, result.Buffer.Width, result.Buffer.Height)) if rect.Empty() { return nil, errors.New("active window is outside the output") diff --git a/core/internal/screenshot/aqueous_snapshot.go b/core/internal/screenshot/aqueous_snapshot.go index eeee43068..de01aec5d 100644 --- a/core/internal/screenshot/aqueous_snapshot.go +++ b/core/internal/screenshot/aqueous_snapshot.go @@ -2,14 +2,29 @@ package screenshot import ( "bytes" + "context" "encoding/json" "errors" + "fmt" "math" + "os" "regexp" "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" ) +func aqueousSnapshot(ctx context.Context) (aqueousSnapshotModel, error) { + executable, err := os.Executable() + if err != nil { + return aqueousSnapshotModel{}, err + } + var data json.RawMessage + if err := utils.RunJSON(ctx, executable, []string{"ipc", "call", "aqueous", "snapshot", os.Getenv("AQUEOUS_SOCKET")}, nil, &data); err != nil { + return aqueousSnapshotModel{}, fmt.Errorf("get Aqueous state from DMS; a shell must be running in this session: %w", err) + } + return parseAqueousSnapshot(data) +} + type aqueousBox struct { X, Y, Width, Height int64 } @@ -69,6 +84,7 @@ func (m aqueousSnapshotModel) Seat(name string) (*aqueousCaptureEntity, error) { func parseAqueousSnapshot(data []byte) (aqueousSnapshotModel, error) { var batch struct { + Error string `json:"error"` Schema int `json:"schema"` Session string `json:"session"` Sequence string `json:"sequence"` @@ -81,6 +97,9 @@ func parseAqueousSnapshot(data []byte) (aqueousSnapshotModel, error) { if err := utils.DecodeJSON(data, &batch); err != nil { return model, err } + if batch.Error != "" { + return model, errors.New("Aqueous state is unavailable in the running DMS shell") + } if batch.Schema != 1 || batch.Type != "snapshot" || !bytes.Equal(batch.BaseSequence, []byte("null")) || batch.Upsert == nil || batch.Removed == nil || len(batch.Removed) != 0 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(batch.Session) || !regexp.MustCompile(`^[0-9]+$`).MatchString(batch.Sequence) { return model, errors.New("invalid Aqueous shell snapshot") } diff --git a/core/internal/screenshot/compositor.go b/core/internal/screenshot/compositor.go index f2320b061..2f21966de 100644 --- a/core/internal/screenshot/compositor.go +++ b/core/internal/screenshot/compositor.go @@ -31,16 +31,13 @@ func DetectCompositor() Compositor { if detectedCompositor >= 0 { return detectedCompositor } - if waylandSocketOwner() == "aqueous" { - detectedCompositor = CompositorAqueous - return detectedCompositor - } candidates := []struct { socket string needsStat bool compositor Compositor }{ + {os.Getenv("AQUEOUS_SOCKET"), true, CompositorAqueous}, {os.Getenv("MANGO_INSTANCE_SIGNATURE"), true, CompositorMango}, {os.Getenv("NIRI_SOCKET"), true, CompositorNiri}, {os.Getenv("SCROLLSOCK"), true, CompositorScroll}, @@ -91,7 +88,7 @@ func GetActiveWindow() (*WindowGeometry, error) { } return aqueousWindowGeometry(model, "") default: - return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri") + return nil, fmt.Errorf("window capture requires Hyprland, Mango, niri, or Aqueous") } } diff --git a/core/internal/screenshot/compositor_linux.go b/core/internal/screenshot/compositor_linux.go deleted file mode 100644 index 9c7425fea..000000000 --- a/core/internal/screenshot/compositor_linux.go +++ /dev/null @@ -1,56 +0,0 @@ -package screenshot - -import ( - "context" - "net" - "os" - "path/filepath" - "strconv" - "strings" - "time" - - "github.com/AvengeMedia/DankMaterialShell/core/internal" - "golang.org/x/sys/unix" -) - -func waylandSocketOwner() string { - display := os.Getenv("WAYLAND_DISPLAY") - if display == "" { - display = "wayland-0" - } - if !filepath.IsAbs(display) { - display = filepath.Join(os.Getenv("XDG_RUNTIME_DIR"), display) - } - connection, err := net.DialTimeout("unix", display, time.Second) - if err != nil { - return "" - } - defer connection.Close() - raw, err := connection.(*net.UnixConn).SyscallConn() - if err != nil { - return "" - } - var pid int32 - err = raw.Control(func(fd uintptr) { - cred, err := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED) - if err == nil { - pid = cred.Pid - } - }) - if err != nil || pid <= 0 { - return "" - } - comm, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(int(pid)), "comm")) - if err != nil { - return "" - } - return strings.ToLower(strings.TrimSpace(string(comm))) -} - -func aqueousSnapshot(ctx context.Context) (aqueousSnapshotModel, error) { - data, err := internal.AqueousSnapshot(ctx) - if err != nil { - return aqueousSnapshotModel{}, err - } - return parseAqueousSnapshot(data) -} diff --git a/core/internal/screenshot/compositor_other.go b/core/internal/screenshot/compositor_other.go deleted file mode 100644 index b30057a12..000000000 --- a/core/internal/screenshot/compositor_other.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !linux - -package screenshot - -import ( - "context" - "errors" -) - -func waylandSocketOwner() string { return "" } - -func aqueousSnapshot(context.Context) (aqueousSnapshotModel, error) { - return aqueousSnapshotModel{}, errors.New("Aqueous IPC is only supported on Linux") -} diff --git a/core/internal/screenshot/testdata/README.md b/core/internal/screenshot/testdata/README.md index 7b5835813..4437bb617 100644 --- a/core/internal/screenshot/testdata/README.md +++ b/core/internal/screenshot/testdata/README.md @@ -1 +1 @@ -aqueous-snapshot.json is the first focused-window state reconstructed from the real watch fixtures in quickshell/tests/fixtures/aqueous. Captured against Aqueous master 2d8b07fb1ff2354ea0c3acddd5cc84ceb823d6eb in an isolated native session on 2026-09-05; the session token is sanitized. This fixture does not certify physical capture. +aqueous-snapshot.json contains focused-window state captured from Aqueous 2d8b07fb1ff2354ea0c3acddd5cc84ceb823d6eb in an isolated session on 2026-09-05. The session token is sanitized. diff --git a/docs/aqueous-integration-plan.md b/docs/aqueous-integration-plan.md deleted file mode 100644 index 90404b145..000000000 --- a/docs/aqueous-integration-plan.md +++ /dev/null @@ -1,1099 +0,0 @@ -# Aqueous integration: repository implementation plan - -Status: implemented in the working tree against Aqueous master -`2d8b07fb1ff2354ea0c3acddd5cc84ceb823d6eb`, with the limitations and test evidence -recorded in [Aqueous integration](aqueous.md). No commits or PRs were created. -Audit and implementation date: 2026-09-05. - -This document retains the implementation sequence, runtime/configuration contracts, -complete batch schema, and Wayland XML from the planning audit. The initial audit -table and work-package instructions below are historical planning context. The -helper sources have now been inspected and built, and real runtime fixtures have -been captured. In particular, the pinned helper cannot persist output scale, -enablement or adaptive-sync changes through its monitor adapter, and cannot safely -represent EDID-only monitor edits. Those portions of P8 need an Aqueous follow-up. - -The implementation follows the direct QML service design: `AqueousService.qml` -owns capability discovery, the single watcher, atomic reducer, reconnect lifecycle -and typed CLI actions. `AqueousConfigService.qml` handles stdin helper requests. -Standalone screenshot and keybind adapters remain in their existing core packages; -there is no `core/internal/aqueous` package or Aqueous server route. See -[implementation notes](aqueous.md) for the direct parser boundary and the CLI's -lack of an atomic expected-session command check. - -## Scope and audited baseline - -Implement DMS P0–P10. Keep generic DPMS and display-result fixes independently reviewable. DankLinux-Docs, packaging, plugin registry, and optional Quickshell bindings belong in their owning repositories. Proposed PR boundaries below do not authorize creating PRs, committing, or pushing. - -Read `AGENTS.md` and `CONTRIBUTING.md` again at implementation time. Use Theme tokens, Dank wrappers, guard clauses, scoped Log, and existing I18n terms. Do not manually edit translation catalogs, generated mocks, or embedded shell output. Current hooks also enforce an I18n term freeze; inspect it before adding messages. - -| Component | Evidence from this audit | Consequence | -| --- | --- | --- | -| DMS | `5baef07048656867a374d210d4305911b8a76e4f`; clean before this plan | Rebase the handoff's historical audit onto this revision. | -| Common QML | Gitlink `26396ce432d6c71c3f5367438f96f4a8d667e160`; submodule uninitialized | Initialize the pinned submodule before inspecting shared Proc/Compositor behavior or running UI checks. | -| Installed Quickshell | `noctalia-qs 0.0.12 (revision , distributed by Arch Linux)` | Exact source revision and compatibility with this DMS checkout remain unverified. | -| Installed Aqueous | `aqueous -version` reports `aqueous 0.4.8 +xwayland` | This version is an observation, not a supported minimum. | -| Installed aqueousctl | Help lists windows/inspect/scene/outputs/overlay-planes/layout/cursor; `shell capabilities --json` exits 2 with usage | Installed CLI cannot exercise the supplied shell contract. | -| Installed helper | `aqueous-config version` returns `{"ok":true,"protocol":1,"version":"0.1.0"}`, without capabilities | Obtain the actual 0.7.1 capability-aware build and pin its revision. | -| Toolchain | `core/go.mod` requires Go 1.26.5; Go was not found on PATH; Qt 6 qmllint/qmltestrunner/qmlformat are installed | Provision the prescribed development environment before implementation checks. | - -The previous DMS revision `59a03f450dbf5ae5dd8aa2cd301b89d9293c68a3` is historical, not a released minimum. No compatible isolated Aqueous session, real snapshot/delta fixtures, helper snapshot, hardware tests, or application test suite was run during this planning audit. - -Source inspection confirmed: - -- No Aqueous integration was found under quickshell, core, or docs. -- `CompositorService.qml` already detects the Wayland socket owner before environment fallbacks. Extend that mechanism. -- `SessionService.logout()` starts UWSM and immediately calls `_logout()`; UWSM output/failure handlers can call it again. Detected Aqueous would otherwise reach `HyprlandService.exit()`. -- `DisplayConfigState.backendWriteOutputsConfig()` still invokes `WlrOutputService.applyOutputsConfig()` then `finish(true)`. The high-level helper drops the callback, although lower-level `applyConfiguration()` has one. This is source evidence; runtime reproduction remains required. -- Core screenshot detection still uses environment candidates; it needs socket-owner precedence independently of QML. -- Taskbar/dock consumers both call window methods and directly assign `minimized`. Adding rows to a model alone will not make actions correct. -- `commands_keybinds.go` removes a replaced key before `SetBind`. The writable provider API has no generation argument for removal. Aqueous requires an atomic helper request carrying the generation observed by the UI. -- Installed Quickshell IO metadata exposes QString reads and a SplitParser delimiter, but no configurable byte bound. It does not prove bounded buffering or strict UTF-8 handling. Native toplevel identity correlation is also unverified. - -## Architecture decisions and P0 gates - -Use one `AqueousService.qml` singleton, gated on positive session detection. Expose detection, capabilities, model availability, session/sequence, seat selection, and command errors separately. Never spawn helpers merely because a widget imports the service. - -Prefer one direct `aqueousctl shell watch --json` process owned by that service. Use argument-array processes for explicit actions. No snapshot polling, heartbeats, per-widget watchers, background configuration polling, or quiet-stream timeout. - -Use Quickshell Process and SplitParser with the supported aqueousctl adapter. -The CLI enforces bounded UTF-8 protocol batches; SplitParser reassembles its NDJSON -and QML validates complete record sizes, schema and continuity. The installed -Quickshell parser has no pre-delimiter byte bound or raw UTF-8 error reporting, -so that part of the contract is delegated to aqueousctl. Do not claim independent -protection from a faulty CLI's unterminated or invalid-byte output. A future parser -capability can close that gap without moving compositor state or actions to core. -Test the production QML reducer and actual Process lifecycle. - -Default to Aqueous's authoritative windows/workspaces and ID-based actions. A native identity bridge is optional only after confirming the exact ext-foreign-toplevel identifier on the tested Quickshell build. Never match title, app ID, window order, connector reuse, or workspace display number to establish identity. - -Publish each accepted transaction as one model revision so consumers cannot observe half a move/removal. A facade may expose existing appId/title/activated/state fields and methods, but action setters must route through capability-aware commands. Alternatively replace affected direct mutations with shared CompositorService action methods. Preserve existing backends and validate stale actions against both captured session and current entity membership. - -Persistent settings use one shared aqueous-config adapter with version/capability discovery, stdin JSON, validation, and the UI's retained generation. Do not introduce a TOML parser/writer. Keep the runtime shell service independent of helper availability. - -## Delivery sequence - -```mermaid -flowchart TD - P0["P0: audit, compatible builds, fixtures"] - P1["P1: detection, capabilities, logout"] - P2A["P2a: stream and model"] - P2B["P2b: shell consumers"] - H["Helper adapter and fixture gate"] - P0 --> P1 --> P2A --> P2B - P2B --> P3["P3: keyboard"] - P2B --> P10["P10: overview"] - P0 --> P4["P4: screenshot geometry and detection"] - P0 --> P5["P5: generic DPMS"] - P0 --> P6["P6: asynchronous display result"] - P0 --> H - H --> P7["P7: keybinds"] - H --> P8["P8: persistent displays"] - P6 --> P8 - H --> P9["P9: appearance"] -``` - -This expresses dependencies, not a requirement for parallel agents. P4 can use a fresh standalone snapshot without waiting for consumer wiring; it still needs verified socket detection and the same validated shell geometry contract. - -### P0 — Finish prerequisite audit and capture fixtures - -1. Initialize the pinned common submodule without updating its revision. Inspect its applicable instructions, Proc implementation and shared Compositor API. Pin Quickshell source/package revision and DMS daemon used for UI tests. -2. Obtain the Aqueous working-tree patch set implementing this contract and helper 0.7.1. Record source commits, dirty patch identifiers and binary versions. Installing similarly numbered builds is not evidence of capability. -3. Launch an isolated internal-policy Aqueous session with private runtime/config directories and explicit Wayland display. Inspect the Aqueous test harness before using it; never run action tests against the user's session. -4. Capture sanitized capabilities, initial snapshot, deltas, command results/errors, helper version and helper snapshot. Preserve all identity relationships when redacting titles/app IDs/paths. Capture actual helper requests from `plugin/helper/` and `dms-plugin/README.md`; document adapter reports, retry flags and generation types. -5. Resolve the transport gate above and test native identity only if considering correlation. Record the final implementation file map and where executable fixtures/tests will live. - -Exit: exact dependency manifest, usable isolated session, fixture set, transport choice, complete helper request examples. P0 is partially complete from this audit; those remaining tasks are not checked off. - -### P1 — Detection, capability lifecycle and logout - -Proposed PR: `compositor: recognize Aqueous and support session exit`. - -Targets: `quickshell/Services/CompositorService.qml`, `SessionService.qml`, new `AqueousService.qml`; inspect shell/service initialization and shared Proc before wiring startup. - -- Add `isAqueous`, socket-owner mapping for `aqueous`, and verified desktop/session fallbacks using existing precedence. A parent compositor's inherited variables must not override the socket owner. -- Probe `aqueousctl shell capabilities --json` only after detection. Reject unsupported schema; classify executable/protocol absence without rapid retries. Preserve runtime observation when commands are unsupported. -- Dispatch direct logout as `aqueousctl session exit --json`, checking exit status and accepted JSON. EOF alone is not acceptance. Old CLI or locked-session failure must not fall through to Hyprland. -- Preserve custom actions and UWSM precedence. Fix single-dispatch UWSM control flow: wait for completion before any fallback, and ensure output plus exit callbacks cannot trigger duplicates. Make this small generic fix separately reviewable if necessary. - -Verify direct/nested/UWSM/custom sessions, missing/old CLI, schema mismatch, lock, command failure and acknowledged orderly exit. Verify no Aqueous executable is needed for other compositors. - -### P2a — Live stream, reducer and command layer - -Proposed PR: `compositor: add the Aqueous live state service`. Depends on P1. - -Targets: AqueousService, a pure reducer module or core package selected at P0, and transport-specific fixtures/tests. - -- Store IDs and decimal sequences as strings. Entity key is session + kind + id. Do not parse sequences through Number, including comparison/sorting helpers. -- First record must be a snapshot. Require supported schema, valid envelope/entities, matching session and exact delta base sequence. Sequence jumps are valid. Replace upserts completely, stage all changes/removals, validate references in the completed state, then publish once. -- Keep missing optional fields cleared and ignore additive unknown fields. The supplied schema permits only the listed entity kinds; do not silently accept new kinds without an explicit compatibility decision. -- Validate invariants beyond JSON schema: exactly the fixed session entity, unique entity keys, usable seat defaults, reference integrity and effective keyboard indices. Resolve any uncertain invariant against real fixtures; do not require contiguous sequences. -- Bound incomplete byte buffers, decoded payloads, pending operations and diagnostic output. On malformed/truncated data, wrong continuity or EOF, mark unavailable immediately and disable old-ID actions. -- Suggested reconnect policy: one-shot delay starting at 500 ms, doubling to a 30 s cap with up to 20% jitter within the cap. Avoid resetting to minimum after repeated snapshot-then-EOF failures; reset only after sustained healthy connection. Permanent missing/unsupported prerequisites wait for explicit retry or backend/session change. -- Use generation tokens to discard old process callbacks after backend changes. Stop/reap children, release command handles and cancel retry timers on teardown. -- Commands recheck capabilities, lock, session and target membership immediately before dispatch. Bound concurrent requests; no blind retry after timeout. Use authoritative subsequent state to reflect mutation outcomes. - -Verify fragmented/coalesced records, split Unicode, invalid UTF-8, payload boundary and missing delimiter, malformed envelopes/entities, >2^53 sequences, jumping sequences, full replacement, reset, delta mismatch, restart/session reuse, same-batch moves/removals, command failures and cleanup. Assert one watcher and zero idle polling. - -### P2b — Workspace, window and focused-output consumers - -Proposed PR: `compositor: integrate Aqueous workspaces and windows`. Depends on P2a. - -Targets discovered here: - -- `CompositorService.qml`: sortedToplevels, equivalence checks, activate/toggle/minimize helpers, workspace/output filtering, fullscreen and overlap queries, focused screen. -- `Services/BarWidgetService.qml`: selected-output routing. -- `Modules/DankBar/Widgets/WorkspaceSwitcher.qml`, `RunningApps.qml`, `AppsDock.qml`, and `FocusedWindowContextMenu.qml`. -- `Modules/Dock/DockApps.qml`, `DockAppButton.qml`, `DockContextMenu.qml`, and `DockBody.qml`. -- Trace launcher window actions and workspace-overlay consumers from the shared interfaces after model wiring; do not assume every consumer uses sortedToplevels. - -Use runtime workspace IDs for activation, membership and moves; resolve output IDs to connector names. Seat-selected output remains correct for empty workspaces and layer focus. Explicit seat identity is required for focus actions; an ambiguous default disables the action or requests seat selection. - -Honor skip_taskbar/skip_switcher, minimized, visibility, active workspace and per-window eligibility. Hidden/minimized windows may remain actionable where the existing UI intentionally restores them; visibility must not be mistaken for occlusion. Preserve app grouping/icon lookup as presentation only, using XWayland class when app_id is null. - -Wire every existing activation/close/state/move affordance to the authoritative adapter. Update list equality checks to include changes that affect output/workspace/state/eligibility. A move alone must not activate its destination. Disable actions on stale menu objects even if the same ID string reappears in a new session. Keep verified generic workspace switching when the adapter is unavailable. - -Verify duplicate workspace “1” on two outputs, duplicate app IDs/titles, native/XWayland, rename/reaping/migration, layer focus, empty workspaces, target loss, capability loss, restart, taskbar/dock filtering and idle cost. - -### P3 — Keyboard layout state and actions - -Proposed PR: `keyboard: support Aqueous layout state and switching`. Depends on P2. - -Target: `Modules/DankBar/Widgets/KeyboardLayoutName.qml`. - -Read the chosen seat's active keyboard group, layouts and effective zero-based index. Use explicit seat/group for set/next; include all layout labels in existing width-reservation logic. Physical/client switches, hotplug and keymap reload update via the stream, never inferred from TOML. - -Verify no keyboard, multiple seats/layouts/groups, index wrapping, independent groups, physical changes, active-group replacement, stale IDs, missing capability and reconnect. - -### P4 — Active-window screenshot backend - -Proposed PR: `screenshot: support active-window capture on Aqueous`. - -Targets: `core/internal/screenshot/compositor.go`, `screenshot.go`, callers/config/CLI seat selection, and focused geometry tests. Add a reusable core shell decoder if the chosen transport already needs one; avoid independent conflicting schema interpretations. - -Bring standalone detection to socket-owner precedence so a nested session cannot invoke the parent's screenshot backend. Obtain one fresh atomic snapshot; resolve explicit or unambiguous seat, focused eligible window, output and geometry from it. Error on ambiguous seat, layer/no focus, hidden target, missing output or lost target. - -Initial behavior: crop the composed output using outer_geometry, including compositor borders and any client decorations already within content. Occluders remain visible; this is not isolated toplevel capture. Trace the existing capture backend's transforms before adding math. Convert global logical bounds to the backend's coordinate space exactly once, round outward, and clip to output pixel extent. Verify fractional scale/rotation, transformed buffers and negative origins. Cross-output bounds are clipped to the selected output for this initial backend. - -Geometry describes committed destination during animation; do not promise exact animated-clone capture. A snapshot cannot freeze a target through the pixel capture: use removal evidence when available, document unavoidable race limits, and never substitute another window. - -Verify native/XWayland decoration cases, fractional scale, rotations, clipping, negative origins in native-only mode, moving/closing windows and no-focus errors. Attach actual crop images/dimensions. XWayland mode currently rejects negative output origins per the supplied reference; verify on the tested build. - -### P5 — Generic protocol DPMS fallback - -Proposed PR: `compositor: use protocol DPMS for capable fallback compositors`. Independent of P2. - -Targets: CompositorService power methods; `core/cmd/dms/commands_dpms.go`, `dpms_client.go`; existing daemon capability discovery as needed. - -Retain specialized paths and labwc behavior. Discover output-power availability separately from the `wlroutput` output-management capability. Reuse Wayland connection/registry lifecycle; if no exported capability exists, add a small explicit capability to existing discovery. Run `[Proc.dmsBin, "dpms", "off"|"on"]` with a result callback, surface failures and return after dispatch. Audit mode/failed event handling in the current client. - -Verify unsupported protocol, reconnect/resume and specialized backends. Physical idle-off/wake-on is mandatory; headless success does not certify DPMS. Do not persist power state. - -### P6 — Propagate real asynchronous display results - -Proposed PR: `displays: propagate the actual output apply result`. Independent generic fix, required for P8. - -Targets: `WlrOutputService.qml`, `DisplayConfig/DisplayConfigState.qml`, `DMSService.qml` request lifecycle if needed; existing `core/internal/server/wlroutput/` handlers/tests. - -Reproduce the source-confirmed premature success with a controllable delayed backend. Add callback propagation through applyOutputsConfig and finish from the actual response. Define exactly-once completion for unavailable backend, empty/no-op candidate, rejected test/apply, disconnect, cancellation and timeout. Check server result semantics instead of assuming a lack of top-level error means success. - -Update applyChanges/profile/revert callers that currently ignore completion; add operation identities and timeout cleanup. Ignore stale callbacks after supersession. A timeout is uncertain runtime state, not permission to retry blindly or report rollback. - -Verify rejected mode, delayed success, disconnect, timeout and late/out-of-order completion; prove no success UI before backend response. Skip only if a re-audit finds this fixed upstream. - -### Shared helper gate for P7–P9 - -Targets: proposed shared core aqueous-config client and thin QML service/provider surface, following the existing provider architecture. - -Pin actual helper/plugin sources and capture exact request/response fixtures before implementing writes. Check protocol 1 plus each used capability; helper version alone is insufficient. No guessed JSON fields. - -Discover on demand; keep generation with each draft. Send complete validated stdin requests with expected_generation. Preserve drafts on conflict, and inspect canonical saved state after uncertain timeout. Return structured complete/partial/conflict/error results without discarding adapter details. Keep manual retries explicit. - -### P7 — Keybind provider and cheatsheet - -Proposed PR: `keybinds: add the Aqueous configuration provider`. - -Targets: `Services/KeybindsService.qml`, `Common/KeybindActions.js`, `core/cmd/dms/commands_keybinds.go`, `core/internal/keybinds/types.go`, new provider under `providers/`, and provider tests. - -Implement schema-backed built-ins including unbound actions, multiple bindings, custom commands, modifiers, conflicts and validation. Extend the request/response path to carry the generation observed when the draft was loaded through set/remove/reset/replace. Do not take a new generation immediately before saving and thereby bypass conflict protection. - -Aqueous binding replacement must submit removal and addition in one validated helper request. Do not use the existing delete-then-set path. Preserve other providers' conventions and do not invent compositor fragment/include files for Aqueous. - -Verify read/edit/remove, multiple bindings, custom commands, invalid draft, atomic replacement, retained stale draft, unsupported helper and concurrent plugin editing. - -### P8 — Persistent displays and safe preview - -Proposed PR: `displays: persist Aqueous configuration through aqueous-config`. Depends on helper gate and P6. - -Targets: DisplayConfigState and display confirmation/settings components, WlrOutputService, shared helper adapter. - -Separate runtime preview from canonical persistence. Keep the original live configuration plus candidate identity, test/apply via wlr output management, start Keep/Revert only after actual successful apply, and persist through helper only after Keep. A timeout/cancel before Keep may revert only if current live state still matches the provider's preview. - -Compare complete relevant configuration, not incidental protocol serials. Hotplug, external change or reload requires fresh state; report conflict instead of overwriting newer settings. Keep original/candidate information across partial failure until resolved. - -Preserve configured offline monitors, EDID identity, custom modes and fractional refresh. Existing DMS profile/auto-apply and battery-refresh paths must not silently write canonical Aqueous preferences. Saving a profile and enabling automatic policy require explicit ownership decisions in the implementation. - -Verify rejected preview, countdown/Keep/Revert, restart persistence, hotplug/external edits during preview, helper conflicts, uncertain timeout, partial file saves and offline entries. Distinguish preview success, canonical save success and subsequent physical application. - -### P9 — Cursor, typography and appearance ownership - -Proposed PR: `appearance: integrate Aqueous configuration adapters`; split cursor/typography if clearer. - -Targets: `Common/SettingsData.qml` cursor/font setters, existing appearance/settings controls, `ThemeAutoService.qml` where relevant, shared helper adapter and plugin coexistence settings. - -Audit current toolkit/Xresources/compositor writes before routing Aqueous through cursor_sync/typography_sync. Opening settings loads state only. Default automatic synchronization to no new owner until the user explicitly selects the native provider or plugin; preserve existing plugin enablement. Both UIs retain manual Apply with generation protection. - -Expose successful canonical save plus partial toolkit failure and explicit sync_cursor/sync_typography retries. Preserve limits for font face/slant/width, independently scaled bars and client-owned cursor surfaces. No additional background color/font/cursor writer. - -Verify manual Apply, live cursor, partial toolkit failure/retry, stale generation and plugin coexistence without feedback loops. Portal plugin enablement is independent. - -### P10 — Native overview and frame verification - -Proposed PR: `overview: control the Aqueous compositor overview`. Depends on P2. - -Targets: WorkspaceSwitcher overview controls, existing overview entry points, CompositorService/AqueousService; inspect `Modules/WorkspaceOverlays/` for interaction. Route existing controls to show/hide/toggle using connector names and observe global overview_output/overview_window. - -Verify idempotence, repeated toggling, output switch, empty workspace/unavailable policy, lock/cancellation, target removal and animations disabled. Do not build a new DMS thumbnail overview. - -Validate `Modules/Frame/FrameExclusions.qml`, readiness/layout refresh, all four bar edges, dock placement, fullscreen, multiple outputs and crash cleanup. Standard `dms:frame-exclusion` positive exclusive zones already have reference evidence. Make a separate frame change only for a reproduced gap. No new margin lease, unsupported command or duplicate persistent gap reservation. - -## Required verification and review record - -For each proposed PR record: before/after behavior, DMS/common/Quickshell/Aqueous/helper revisions and patches, required capabilities, fallback behavior, commands/results, representative UI evidence and unverified cases. - -Implementation checks: - -- `make lint-qml` with the matching Quickshell VFS and initialized common submodule. -- `make -C core fmt`, `make -C core test`; `go mod tidy` from core when relevant, inspecting resulting dependency changes. -- Focused production-reducer/process and helper fixture tests, meaningful screenshot input/output geometry tests, provider tests and asynchronous display tests. Add a runnable QML test harness if needed; do not replace behavior tests with source-text assertions. -- Applicable `prek` hooks, including settings search index, term freeze/variants, YAML/shell checks and no-console rule. Inspect actual lint configuration: CONTRIBUTING mentions golangci-lint, but the audited pre-commit file does not list it. -- Match affected Go CI checks: `go test -v ./...`, ordinary dms build, `make build`, `distro_binary withshell` build, dankinstall build, and FreeBSD compilation when shared/platform code changes. Full test CI provisions Flatpak runtime; reproduce prerequisites before attributing environment failures to the patch. -- Measure one watcher per session, bounded buffers/queues/retries, no periodic idle subprocesses and cleanup on reload/disconnect. Avoid broad repeat tests after unchanged successful checks. - -Run real daemon + Quickshell in an isolated Aqueous session for each integrated feature. Physical checks cover DPMS, scale/rotation, hotplug, resume, screenshots, lock, connected frame and overview. Browser/recorder portal streaming is a separate release gate. - -The handoff reports 393 Aqueous unit tests, native/XWayland protocol fixtures, helper/plugin tests and a pinned DMS settings-host harness. This audit did not rerun them. They do not certify future DMS code or physical session support. - -## DankLinux-Docs and release handoff - -After merged implementation and released dependencies are established, audit the docs repository's instructions, guide structure, navigation/support table and portal troubleshooting. - -Add the verified Aqueous session guide covering supported packaging, exact released startup/keybinding syntax, direct/UWSM sessions and avoiding duplicate DMS processes. Map each documented feature to merged DMS support and tested versions; label development builds explicitly. Do not invent minimum versions. - -Explain Aqueous TOML ownership, helper writes, optional native/provider/plugin availability, one automatic synchronization owner, and runtime-only DPMS/layout/keyboard changes. Preserve independent aqueousPortal enablement and verify actual portal packages, services and routing separately from screenshots. - -Document native overview, standard frame exclusive zones, and diagnostics below. Explain missing/old CLI, wrong/nested session, schema mismatch, disconnect, stale IDs, lock, ambiguous seat and partial toolkit sync. Distinguish unsupported from untested. Run docs build/link checks and place packaging/registry changes in their owning repos. Native Quickshell binding changes are optional. - -Suggested future PR: `docs: add the verified Aqueous session setup guide`. - -## Embedded shell and CLI contract - -This is the supplied schema-1 reference, not a claim about the installed binaries. - -### Discovery and policy - -Bind aqueous_shell_manager_v1 on the current Wayland connection through aqueousctl. No separate socket or external window-manager connection is configured. Capabilities include schema 1, random 128-bit session token, maximum batch size and independent state, commands, keyboard, overview and shortcut_inhibition booleans. Tokens change on compositor restart. Unknown additive fields may be ignored; unsupported schema versions must fail. - -Internal policy supports mutations. External/comparison policy is observation-only. Lock rejects all mutations but trusted shell observation remains available. The shell global is hidden from Wayland security-context clients. Ordinary application shortcut inhibitors remain focus/seat scoped. - -```sh -aqueousctl shell capabilities --json -aqueousctl shell snapshot --json -aqueousctl shell watch --json -``` - -Watch emits an initial full snapshot then NDJSON deltas. The CLI allows five seconds for connection/initial state or command completion; an established quiet watch has no idle timeout. Invalid/truncated data or continuity failure terminates the CLI nonzero. Restart with bounded backoff and replace old state. A timed-out mutation may have executed already. - -### Identity, batches and references - -IDs/sequences are JSON strings, scoped to the session. Window IDs are exact ext_foreign_toplevel_handle_v1.identifier values. Outputs/workspaces/keyboard groups/devices use runtime identities; seats use names. Workspace number is a one-based label. IDs survive workspace rename/renumber/output migration while the workspace exists, but never compositor restart. There is no ext-workspace persistent ID promise. Native identify_workspace resolves a client's own ext-workspace handle, returning empty for removed handles. - -Outputs include connector name, enabled/powered, logical bounds/usable bounds, scale/transform and active workspace. Enabled includes temporarily powered-off outputs. Windows include backend, app_id/class/title, output/workspace, geometry/outer_geometry, presentation flags and eligibility. Use class for XWayland identity presentation when app_id is null. - -Visible means mapped on an active workspace and not policy-hidden, not unoccluded. can_activate is focus eligibility. can_minimize/can_maximize generally require floating presentation; tiled requests may fail, and compositor-owned maximize may not be reversible by a client. Focused means at least one seat; explicit seat.window is authoritative for a particular seat. Selected output persists during layer focus. No keyboard or ambiguous defaults use null. - -Session entity has fixed id "session", locked, default_seat, overview_output and overview_window. Keyboard group has seat, layout names and effective zero-based index; devices reference seat/group. - -Full replacement clears absent optional fields. Atomic batches permit moving a workspace to a surviving output and removing the old output together. Removal keys are kind:id. First snapshot has null base_sequence and empty removed; deltas require the exact previous sequence as base_sequence. Sequence jumps are valid. - -```json -{"schema":1,"session":"6b94a179d09456846b94a179d0945684","sequence":"12","base_sequence":null,"type":"snapshot","upsert":[{"kind":"workspace","id":"7","output":"2","name":"Code","number":1,"active":true,"urgent":false}],"removed":[]} -``` - -The example is abbreviated; a real snapshot contains all current entities and valid references. - -```json -{"schema":1,"session":"6b94a179d09456846b94a179d0945684","sequence":"15","base_sequence":"12","type":"delta","upsert":[{"kind":"workspace","id":"7","output":"2","name":"Review","number":1,"active":true,"urgent":false}],"removed":["window:closed-window-id"]} -``` - -Wire subscribe is allowed once. Server sends begin(serial), data byte fragments, done(serial); concatenate bytes before UTF-8 decoding. Complete JSON max is 4 MiB, with NDJSON newline allowed separately. Entity state is limited to 2 MiB including accounting overhead, and at most 16 managers may bind. - -A native client acknowledges exact done serial only after accepting a batch. aqueousctl handles wire acknowledgement for the CLI adapter. One batch is in flight per manager; slow consumption pauses delivery and changes coalesce against the acknowledged baseline. Invalid acknowledgement/duplicate subscribe is a protocol error. Limits fail explicitly rather than truncating. Publication is event-driven after settled policy/render/workspace transactions, with no repeating scan. - -### Geometry - -geometry is committed global logical content bounds; negative origins are valid. outer_geometry adds enabled compositor border edges, not client shadow guesses. Client decorations inside content remain included; fullscreen removes compositor borders. - -Animation geometry is committed destination, not the compositor's frozen visual clone. Composed output crops include occluders. Subtract output origin, transform/scale once according to the capture backend, round outward and intersect pixel extent. Use standard capture protocols and existing output management for modes/transforms. Do not capture a hidden window on another workspace or claim isolated capture from an output crop. - -### Typed commands and results - -```sh -aqueousctl window activate --id ID [--seat SEAT] --json -aqueousctl window close --id ID --json -aqueousctl window state --id ID --minimized true --json -aqueousctl window state --id ID --maximized false --json -aqueousctl window state --id ID --fullscreen true --json -aqueousctl window move --id ID --workspace-id ID --json -aqueousctl window move --id ID --output CONNECTOR --json -aqueousctl workspace activate --id ID [--seat SEAT] --json -aqueousctl workspace rename --id ID --name NAME --json -aqueousctl keyboard query --json -aqueousctl keyboard set [--seat SEAT] [--group ID] --index 1 --json -aqueousctl keyboard next [--seat SEAT] [--group ID] --json -aqueousctl overview show --output CONNECTOR --json -aqueousctl overview hide --json -aqueousctl overview toggle --output CONNECTOR --json -aqueousctl session exit --json -``` - -Brackets above denote optional arguments, not literal shell syntax. Omit seat only for exactly one seat; omitted group targets that seat's active keyboard group. Query returns a complete atomic snapshot. Set uses zero-based index, next wraps, and neither writes TOML. Physical/client layout switching and hotplug/reload produce deltas. - -Move-to-output selects the output's currently active workspace; it does not activate workspace or follow focus. Normal policy repairs focus when needed. Activate reveals workspace and restores eligible minimized windows. Move/state/activation cancels overview. Rename preserves identity; names are UTF-8, at most 1024 bytes, without newlines. - -Overview is compositor-owned and global. Show/hide are idempotent; showing on a new output cancels old overview first. Empty workspace or prohibiting policy returns unavailable. Lock/cancellation and global selected output/window remain authoritative. - -One command is outstanding per native manager. Requests resolve targets at a settled transaction; disappearing targets return not_found. Queue storage is bounded. Results include request ID, status and current committed sequence on the wire. CLI prints success such as `{"ok":true,"status":"applied","sequence":"15"}` or a corresponding error object. - -Applied means committed. Close/exit return accepted acknowledgement, not proof a client disappeared. Exit flushes acknowledgement before orderly compositor termination; EOF without acknowledgement is an error. Errors include invalid, not_found, locked, unsupported, busy, ambiguous_seat, unavailable. stdout is JSON; stderr is diagnostics. Exit codes: 0 success, 2 malformed arguments, 1 protocol/operation failure. No arbitrary execution/raw policy dispatcher exists. - -### Shortcut inhibition and reference tests - -An inhibitor is active only for its focused surface/seat while unlocked and outside overview. Focus loss, destruction and lock deactivate it. Normal/internal and external XKB bindings are bypassed, but VT switching remains reserved and secure lock works. Releases retain the consumer selected on press even if inhibition disappears. Existing XWayland grab policy remains. DMS already consumes inhibition; change only for a reproduced gap. - -Aqueous reference checks: `zig build test` and `python3 scripts/test-shell-integration.py` using a Pixman-compatible diagnostic build or documented binary overrides. The isolated harness covers two-output state/actions, keyboard, inhibition, flow control, lock and frame cleanup without connecting to the user's display. Hardware capture/DPMS/resume remain separate. - -## Embedded configuration and frame contract - -aqueous-config owns persistence and toolkit synchronization; aqueousctl owns runtime state/actions. Helper 0.7.1 adds capabilities to version/snapshot while retaining protocol 1 and compatibility for existing 0.7.0 frontends. An old helper without capabilities needs its documented version-specific contract; absence is not permission to ignore fields. - -```sh -aqueous-config version -aqueous-config snapshot --shell dms -aqueous-config validate --shell dms --request - -aqueous-config apply --shell dms --request - -``` - -Requests use JSON stdin, bounded to 4 MiB, with expected_generation. Capture complete requests from tested `plugin/helper/` and `dms-plugin/README.md`; their detailed JSON shape is not contained in this handoff and must not be invented. - -| Capability | Meaning | -| --- | --- | -| schema_fields | Snapshot fields, categories, defaults, aliases, typed constraints | -| validate | Complete-request validation without saving | -| generation_check | expected_generation rejects external-edit overwrite | -| stdin_requests | Bounded JSON via --request - | -| atomic_file_replace | Atomic replacement/backups per TOML file; multi-file save is not one filesystem transaction | -| monitor_modes | Configured mode, position, scale, transform | -| live_outputs | Live modes alongside configured/offline monitors | -| keybinds | Built-ins, unbound actions, custom bindings | -| window_rules | Ordered rules and raw configuration | -| cursor_sync | Canonical cursor, live update, toolkit reports | -| typography_sync | Canonical typography, fonts/faces, toolkit reports | -| shell_dms | Avoid Noctalia writes/reloads | - -Capabilities do not guarantee available toolkit adapters, fonts, monitor modes or runtime support. Inspect adapter reports and live state independently. - -Editing flow: fresh snapshot/generation, stage typed/raw edits and resolve their conflicts, validate complete request, apply with retained generation, inspect response, observe runtime separately. Preserve stale drafts for reload/reconciliation. On uncertain timeout inspect saved state before retry. Canonical save followed by toolkit failure is partial success; explicit retry flags are sync_cursor and sync_typography. - -There is no helper display-preview API. Use wlr runtime test/apply with previous state and Keep/Revert; save only after Keep. Revert only while state still matches the preview. Preserve offline monitors, EDID, custom modes and fractional refresh. Runtime DPMS/battery refresh is separate from preferences. - -TOML remains canonical across plugin/native providers. Opening UI does not authorize rewrites. One explicitly enabled owner synchronizes colors/fonts/cursor automatically; either frontend may manually Apply through the generation contract. Typography adaptation maps DMS family/weight/scale but partially represents exact face/slant/width and independently scaled bars. Existing cursor-surface clients retain their own policy. The portal plugin has independent purpose/enablement. - -Frame exclusion surfaces reserve each edge via positive layer-shell exclusive zones and namespace dms:frame-exclusion. Existing Aqueous tests map all four, verify sum-of-opposite-edge shrink, kill the client and verify restoration. Resource lifetime already releases reservations; no margin lease or duplicate persistent gaps are needed. Mixed-scale physical rendering and connected-mode UI remain unverified gates. - -Layer rules in rules.toml are ordered, first match wins. Invisible exclusions should not receive blur; put this before broader intentional rules: - -```toml -[[layer]] -namespace = "dms:frame-exclusion" -blur = false -blur_popups = false -``` - -Inspect aqueousctl scene for exact visible DMS namespaces; do not blanket-match all Quickshell apps. For ordinary DMS windows inspect aqueousctl windows --json or inspect --rule and match actual identity. No appearance rules should be installed/replaced merely by adding the shell adapter. - -Diagnostic watch remains running until Ctrl+C. Titles and configuration paths can appear in snapshots; request only relevant redacted output in bug reports. Portal streaming is separate from screenshots and settings-provider enablement. - -## Complete JSON batch schema - -The schema below preserves the supplied schema-1 definition. It may be extracted as `aqueous-shell-v1.schema.json`. Additional semantic/continuity checks described above are still required. - -```json -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Aqueous shell JSON batch schema 1", - "type": "object", - "required": [ - "schema", - "session", - "sequence", - "base_sequence", - "type", - "upsert", - "removed" - ], - "properties": { - "schema": { - "const": 1 - }, - "session": { - "type": "string", - "pattern": "^[0-9a-f]{32}$" - }, - "sequence": { - "type": "string", - "pattern": "^[0-9]+$" - }, - "base_sequence": { - "type": [ - "string", - "null" - ], - "pattern": "^[0-9]+$" - }, - "type": { - "enum": [ - "snapshot", - "delta" - ] - }, - "upsert": { - "type": "array", - "items": { - "oneOf": [ - { - "$ref": "#/$defs/output" - }, - { - "$ref": "#/$defs/workspace" - }, - { - "$ref": "#/$defs/window" - }, - { - "$ref": "#/$defs/seat" - }, - { - "$ref": "#/$defs/keyboard" - }, - { - "$ref": "#/$defs/keyboard_device" - }, - { - "$ref": "#/$defs/session" - } - ] - } - }, - "removed": { - "type": "array", - "items": { - "type": "string", - "pattern": "^(output|workspace|window|seat|keyboard|keyboard_device|session):.+" - }, - "uniqueItems": true - } - }, - "allOf": [ - { - "if": { - "properties": { - "type": { - "const": "snapshot" - } - } - }, - "then": { - "properties": { - "base_sequence": { - "type": "null" - }, - "removed": { - "maxItems": 0 - } - } - }, - "else": { - "properties": { - "base_sequence": { - "type": "string" - } - } - } - } - ], - "$defs": { - "output": { - "type": "object", - "required": [ - "kind", - "id", - "name", - "bounds", - "usable_bounds", - "scale", - "transform", - "active_workspace", - "enabled", - "powered" - ], - "properties": { - "kind": { - "const": "output" - }, - "id": { - "type": "string", - "minLength": 1 - }, - "name": { - "type": "string" - }, - "bounds": { - "type": "object", - "required": [ - "x", - "y", - "width", - "height" - ], - "properties": { - "x": { - "type": "integer" - }, - "y": { - "type": "integer" - }, - "width": { - "type": "integer" - }, - "height": { - "type": "integer" - } - } - }, - "usable_bounds": { - "type": "object", - "required": [ - "x", - "y", - "width", - "height" - ], - "properties": { - "x": { - "type": "integer" - }, - "y": { - "type": "integer" - }, - "width": { - "type": "integer" - }, - "height": { - "type": "integer" - } - } - }, - "scale": { - "type": "number" - }, - "transform": { - "type": "string" - }, - "active_workspace": { - "type": [ - "string", - "null" - ] - }, - "enabled": { - "type": "boolean" - }, - "powered": { - "type": "boolean" - } - } - }, - "workspace": { - "type": "object", - "required": [ - "kind", - "id", - "output", - "name", - "number", - "active", - "urgent" - ], - "properties": { - "kind": { - "const": "workspace" - }, - "id": { - "type": "string", - "minLength": 1 - }, - "output": { - "type": "string", - "minLength": 1 - }, - "name": { - "type": "string" - }, - "number": { - "type": "integer", - "minimum": 1 - }, - "active": { - "type": "boolean" - }, - "urgent": { - "type": "boolean" - } - } - }, - "window": { - "type": "object", - "required": [ - "kind", - "id", - "backend", - "app_id", - "class", - "title", - "workspace", - "output", - "geometry", - "outer_geometry", - "layout", - "focused", - "visible", - "floating", - "minimized", - "maximized", - "fullscreen", - "skip_taskbar", - "skip_switcher", - "always_above", - "always_below", - "snapped", - "fixed_position", - "can_minimize", - "can_maximize", - "can_activate" - ], - "properties": { - "kind": { - "const": "window" - }, - "id": { - "type": "string", - "minLength": 1 - }, - "backend": { - "enum": [ - "xdg", - "xwayland" - ] - }, - "app_id": { - "type": [ - "string", - "null" - ] - }, - "class": { - "type": [ - "string", - "null" - ] - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "workspace": { - "type": [ - "string", - "null" - ] - }, - "output": { - "type": [ - "string", - "null" - ] - }, - "geometry": { - "type": "object", - "required": [ - "x", - "y", - "width", - "height" - ], - "properties": { - "x": { - "type": "integer" - }, - "y": { - "type": "integer" - }, - "width": { - "type": "integer" - }, - "height": { - "type": "integer" - } - } - }, - "outer_geometry": { - "type": "object", - "required": [ - "x", - "y", - "width", - "height" - ], - "properties": { - "x": { - "type": "integer" - }, - "y": { - "type": "integer" - }, - "width": { - "type": "integer" - }, - "height": { - "type": "integer" - } - } - }, - "layout": { - "type": "string" - }, - "focused": { - "type": "boolean" - }, - "visible": { - "type": "boolean" - }, - "floating": { - "type": "boolean" - }, - "minimized": { - "type": "boolean" - }, - "maximized": { - "type": "boolean" - }, - "fullscreen": { - "type": "boolean" - }, - "skip_taskbar": { - "type": "boolean" - }, - "skip_switcher": { - "type": "boolean" - }, - "always_above": { - "type": "boolean" - }, - "always_below": { - "type": "boolean" - }, - "snapped": { - "type": "boolean" - }, - "fixed_position": { - "type": "boolean" - }, - "can_minimize": { - "type": "boolean" - }, - "can_maximize": { - "type": "boolean" - }, - "can_activate": { - "type": "boolean" - } - } - }, - "seat": { - "type": "object", - "required": [ - "kind", - "id", - "output", - "window", - "focus_kind", - "keyboard" - ], - "properties": { - "kind": { - "const": "seat" - }, - "id": { - "type": "string", - "minLength": 1 - }, - "output": { - "type": [ - "string", - "null" - ] - }, - "window": { - "type": [ - "string", - "null" - ] - }, - "focus_kind": { - "enum": [ - "window", - "shell_surface", - "layer_surface", - "override_redirect", - "lock_surface", - "none" - ] - }, - "keyboard": { - "type": [ - "string", - "null" - ] - } - } - }, - "keyboard": { - "type": "object", - "required": [ - "kind", - "id", - "seat", - "layouts", - "index" - ], - "properties": { - "kind": { - "const": "keyboard" - }, - "id": { - "type": "string", - "minLength": 1 - }, - "seat": { - "type": "string", - "minLength": 1 - }, - "layouts": { - "type": "array", - "items": { - "type": "string" - } - }, - "index": { - "type": "integer", - "minimum": 0 - } - } - }, - "keyboard_device": { - "type": "object", - "required": [ - "kind", - "id", - "name", - "seat", - "group", - "virtual" - ], - "properties": { - "kind": { - "const": "keyboard_device" - }, - "id": { - "type": "string", - "minLength": 1 - }, - "name": { - "type": [ - "string", - "null" - ] - }, - "seat": { - "type": "string", - "minLength": 1 - }, - "group": { - "type": [ - "string", - "null" - ] - }, - "virtual": { - "type": "boolean" - } - } - }, - "session": { - "type": "object", - "required": [ - "kind", - "id", - "locked", - "default_seat", - "overview_output", - "overview_window" - ], - "properties": { - "kind": { - "const": "session" - }, - "id": { - "type": "string", - "minLength": 1 - }, - "locked": { - "type": "boolean" - }, - "default_seat": { - "type": [ - "string", - "null" - ] - }, - "overview_output": { - "type": [ - "string", - "null" - ] - }, - "overview_window": { - "type": [ - "string", - "null" - ] - } - } - } - } -} -``` - -## Complete Wayland protocol XML - -Extract as `aqueous-shell-v1.xml` only if native bindings are needed; preserve copyright and license. The referenced aqueous-shell-v1.md contract is embedded above. ext_workspace_handle_v1 comes from the standard ext-workspace-v1 protocol in the target build's dependencies. - -```xml - - - SPDX-FileCopyrightText: © 2026 Seafoam Labs -SPDX-License-Identifier: MIT - - Privileged same-session shell interface. JSON schema 1 is documented in - aqueous-shell-v1.md. IDs are opaque strings scoped to the session token. - State is committed policy state, not animation frame geometry. Clients - acknowledge each bounded batch before another is sent. Intermediate states - may be coalesced; base_sequence identifies the state to which a delta applies. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - May be sent once. The server sends at most one unacknowledged batch. - - - - - - - - One outstanding command per object. Empty strings select documented - defaults. Targets are opaque window/workspace/group IDs; output values - are connector names. State values are true/false. Strings are bounded - to 1024 bytes. Failures are recoverable result events. Window and session - mutations are unavailable while locked or under external or comparison policy. - - - - - - - - - - - - - - - - - - - - Concatenate fragments until done. At most 4 MiB per complete batch. - A fragment may split a UTF-8 codepoint; parse only the complete batch. - - - - - - - - - - - - - - - - - -``` diff --git a/docs/aqueous.md b/docs/aqueous.md deleted file mode 100644 index deb2af6db..000000000 --- a/docs/aqueous.md +++ /dev/null @@ -1,422 +0,0 @@ -# Aqueous integration - -DMS uses Aqueous runtime IPC v1 through the existing QML `DankSocket` and -`SplitParser` stack, as used by the other compositor services. Build a compositor -that exports `AQUEOUS_SOCKET` and verify its `hello`; an installed executable's -version does not establish support. No released minimum version is established. - -Earlier desktop integration was tested against Aqueous -`2d8b07fb1ff2354ea0c3acddd5cc84ceb823d6eb`. The runtime socket checks below use the -new IPC build, with Quickshell `2d3b3e9c70ef380dff751b61d334dc88df016c29` and its -existing `parentWindow` API. No native Quickshell module or shared QML submodule -change is needed for this migration. - -## Implemented paths - -| Work | Implementation | -| --- | --- | -| P0–P2 | Socket-owner detection, capability discovery, orderly logout, bounded atomic stream reducer, reconnect, stable window/workspace identities, selected-seat output routing, taskbars/docks, focused app and workspace controls. | -| P3 | Effective keyboard group/layout state and runtime switching with explicit seat/group identities. | -| P4 | Active-window and selected-output screenshots. Window capture crops the composed output's outer bounds, including compositor borders and occluders. | -| P5 | Generic output-power discovery and DPMS dispatch, preserving specialized compositor paths. | -| P6 | Actual asynchronous display results, request timeouts, cancellation and suppression of late callbacks. | -| P7 | Helper-backed keybinding inventory, unbound built-ins, multiple bindings, custom commands, edits/removal and generation conflicts. | -| P8 | Runtime test/apply and Keep/Revert; generation-checked persistence of supported monitor mode, position and transform changes. Unsupported helper operations fail explicitly. | -| P9 | Manual cursor and typography Apply, adapter reports, partial-success retry and generation checking. | -| P10 | Native compositor overview control/state and existing connected-frame reservations. No extra margin protocol or persistent gap writes. | - -The native DMS appearance provider performs no background synchronization. The -existing Aqueous Settings plugin retains ownership of any automatic synchronization -the user enabled there. Opening either settings frontend does not save Aqueous -configuration. Both frontends must use the helper's observed generation when saving. -The independent portal plugin and its enablement preference are unchanged. - -## Runtime and configuration - -Start DMS as an Aqueous session child, inheriting `AQUEOUS_SOCKET` and -`XDG_RUNTIME_DIR`. The endpoint is an absolute path under -`$XDG_RUNTIME_DIR/aqueous//ipc.sock`. DMS does not scan sessions or use -subprocess discovery. A new compositor instance needs DMS relaunched with its new -environment. Keep `aqueous-config` and its matching `aqueousctl` on PATH for the -retained configuration paths. - -`AqueousService.qml` owns two persistent QML connections: one for hello/commands, -one for hello/subscription/acks. `AqueousConnection.qml` separates wire validation, -request correlation and deadlines from the service's atomic entity reducer and -existing UI facades. Both handshakes must agree on session, schema and capabilities; -readiness requires a validated initial snapshot. Events are acknowledged only after -installation. A broken stream invalidates the model, discards its parser and -baseline, and reconnects through `DankSocket`'s existing backoff with jitter. -There is no heartbeat, snapshot polling, watch process or runtime core manager. - -Commands use JSON action/field objects and runtime output IDs directly. One request -is outstanding, with at most 32 waiting commands. The service revalidates the -captured session, seat, capability, lock and target before dispatch. Waiting -commands expire after five seconds when dequeued. Replies complete callbacks; -`applied` confirms commitment without waiting for the other connection's sequence, -and close/exit use `accepted`. A sent command without a valid acknowledgement has -an uncertain outcome and is never replayed. Unsent commands fail separately. -Handshake and request deadlines are five seconds; the initial subscription has an -eight-second deadline. Established subscriptions have no idle deadline. - -The QML client checks complete frame sizes (4 MiB + 64 KiB), request sizes -(64 KiB), nesting, envelopes and advertised limits. Its retained entity model is -limited to 2 MiB. `SplitParser` preserves fragmented UTF-8 while assembling lines, -but does **not** expose a byte limit before LF, strict raw UTF-8 validation or peer -credentials. This QML implementation therefore relies on the compositor's bounded -output and private socket directory for those boundaries; it does not claim the -native framing/peer-verification guarantees in the original handoff. There is no -native transport dependency or subprocess fallback. - -Standalone screenshots use the Linux-only `core/internal/aqueous_ipc.go`. -A short-lived Go Unix connection performs hello and snapshot, then closes. It enforces bounded framing -before LF, strict UTF-8, private directory/socket modes, peer UID and context -cancellation. Snapshot state enters the existing screenshot parser; composed pixel -capture, outer geometry, clipping and transforms are unchanged. - -Settings and keybind editing remain a separate delivery. `AqueousConfigService` -still runs `aqueous-config`; `KeybindsService` still calls `dms keybinds`, whose Go -provider runs that helper. The helper itself currently invokes `aqueousctl outputs` -and `aqueousctl cursor`. These calls are outside the migrated runtime paths and -were observed by the integration launch counter. `AqueousService.runJson` remains -for these consumers. The separately shipped Aqueous settings plugin also remains -owned by Aqueous. A configuration socket contract is required before migrating -these operations. - -All persistent writes go through `aqueous-config validate/apply --shell dms ---request -`, with JSON on stdin and `expected_generation`. The backup directory -passed to the helper is `$XDG_CONFIG_HOME/DankMaterialShell/aqueous-backups`; -the helper decides when backups are required. Background blur uses the Wayland -protocol and does not invoke the helper or write compositor configuration. -Display previews retain the previous live configuration and refuse to revert over -external changes or output recreation. A failed/uncertain preview is reconciled -against live state before offering Keep/Revert. A conflict retains the draft until -the user explicitly discards it and reloads. - -### Background blur - -DMS uses its standard `ext-background-effect-v1` path on Aqueous. Native support -was added in Aqueous `c6e5e566f892db1d7b2944f31404d037884fb0c6`; use a Vulkan -effects build and a Quickshell version providing `BackgroundEffect.blurRegion`. -Older Aqueous versions and builds without effects report blur as unavailable. - -Enable global blur in Aqueous's `wm.toml` first, with positive radius and passes: - -```toml -[blur] -enabled = true -radius = 8 -passes = 2 -``` - -Then enable Background Blur in DMS and set surface opacity below 100%. The -separate frame blur preference still controls frame regions. DMS supplies exact -regions, including rounded corners and clipping, through Quickshell. Requests -are per surface; popups need their own regions. No DMS namespace rule is required. -User-authored deny rules and Aqueous's global blur setting remain authoritative. - -`dms blur check` detects the protocol global, not the runtime compositor blur -setting. It can report `supported` while global blur is disabled. DMS does not -change that setting, create layer rules, or run a configuration helper when blur -is toggled or the shell starts. Existing DMS blur preferences are preserved. - -Run Aqueous's protocol and pixel regression suite against this checkout: - -```sh -cd /path/to/Aqueous/compositor -LD_LIBRARY_PATH="$PWD/.deps/wlroots-render-hook/lib" \ - DMS_SOURCE=/path/to/DankMaterialShell \ - python3 scripts/test-background-effect.py -``` - -Use the updated DMS fixture that reads `BlurService.enabled`. The suite needs -matching Aqueous binaries, `qs`, `dms`, a C compiler, Wayland development files, -`wayland-scanner`, `grim`, and Python/Pillow. It runs on private outputs with -isolated configuration and checks protocol discovery, rounded/intersected regions, -toggles, hide/remap, and configuration preservation. The compositor checks also -cover popup/subsurface masks, policy reload, fractional output transforms, and -cached/uncached rendering. A separate `-Dvulkan-effects=false` build can be checked -with `AQUEOUS_TEST_NO_EFFECTS=1` and `AQUEOUS_COMPOSITOR_BIN=/path/to/aqueous`. -These headless checks do not establish physical mixed-scale or HDR rendering. - -### Limitations in the pinned helper - -`plugin/helper/src/main.zig::applyMonitorChanges` accepts monitor ID/name, position, -transform and mode. It does not apply scale, enablement or adaptive-sync fields. -`writeConfiguredMonitors` also requires a name and omits EDID-only entries. -DMS rejects unsupported persistence, EDID edits and ambiguous wildcard monitor -configuration instead of sending fields the helper would silently ignore. -Offline entries and unrelated fields remain owned by the helper. - -Completing those P8 cases requires an Aqueous helper change, tests demonstrating -preservation of EDID/offline entries, and capability discovery for the expanded -request. Native DMS can then use that verified contract. Runtime scale/rotation -preview and capture work independently of this persistence limitation. - -DMS typography represents family, weight and scale. Exact face/slant/width and -separately scaled bars may differ. Toolkit synchronization can partially fail after -the canonical save; inspect the adapter report and use explicit Retry. - -### Keybind conflict recovery - -Aqueous uses the same inline KeybindItem rows, new-binding form, confirmation -dialog and service save/remove/reset entry points as Hyprland and Niri. The tab -retains one Aqueous draft independently of the binding list, including its original -chord/action, compositor session and observed inventory. Draft state and review -controls live in KeybindsTab; KeybindsService handles the provider's snapshot, -generation and reconciliation checks. - -Save, Remove and Reset read fresh bindings before dispatch. If only unrelated -configuration changed (for example, user layer rules), the operation uses the new -generation. Actual keybinding changes open a review state without writing. - -Reload retains the proposal and displays the previous/current bindings and -destination chord. Accept reviewed changes adopts that baseline; Save still -performs a fresh check. Removal requires a new confirmation after review. -Deleted targets and duplicate/occupied chords must be resolved explicitly, or -the draft can be discarded. Session/provider changes invalidate pending work -while retaining the draft text. Filtering, collapsing the row and hiding/reopening -the tab do not reset it. Cancelling removal leaves the original edit intact. - -Configuration changes between preparation and helper validation/apply still -produce a conflict. Failed and uncertain mutations are never automatically -replayed. Successful saves clear the draft even if the subsequent list refresh -fails, and failed operations cannot emit a later success event merely because -the list was refreshed. Refreshes run on session readiness, tab visibility, -explicit actions and mutation completion; there is no idle polling. - -The UI and CLI must be updated together. Aqueous mutation commands support -`--json` to return `success`, `code` and a failure `message` or successful -`generation`. Failure exit codes remain nonzero. `external_change` identifies a -generation conflict; `uncertain` means helper application lacked a reliable -acknowledgement. CLI callers must still supply their observed -`--expected-generation`; only the UI has the retained baseline needed to compare -inventories before updating that generation. - -Focused verification: - -```sh -node quickshell/tests/aqueous-keybinds.test.mjs -python3 scripts/test-aqueous-keybinds.py --bin-dir /path/to/test/binaries -``` - -The integration test requires a Pixman-compatible Aqueous build, `aqueousctl`, -`aqueous-config`, Quickshell and the matching DMS binary. It creates a private -headless display and temporary configuration, then exercises the real Keybinds -tab, draft editing, blur-only changes, review/save/discard, removal reconfirmation, -structured CLI failures and idle behavior. It never connects to the user's display. - -## Diagnostics - -Run these inside the session being diagnosed: - -```sh -aqueousctl shell capabilities --json -aqueousctl shell snapshot --json -aqueousctl shell watch --json -aqueous-config version -aqueous-config snapshot --shell dms -dms ipc call aqueous status -``` - -The watch command remains running until interrupted. Redact window titles and -configuration paths before sharing output. Missing socket/protocol/helper support is -reported as unavailable; a similarly numbered executable is not proof that the -running compositor has the required capabilities. - -Multiple seats require a choice. For shell widgets use -`dms ipc call aqueous selectSeat SEAT`; this selection lasts for the shell session. -For screenshots use `dms screenshot window --seat SEAT` or -`dms screenshot full --seat SEAT`. Layer focus and no eligible window produce -active-window capture errors. Selected-output routing still works on empty -workspaces and during layer focus. - -WorkspaceSwitcher uses direct Aqueous branches alongside its existing compositor -branches. AqueousService supplies authoritative window/workspace membership and -session identities; the existing ext-workspace path keeps native handles. No -shared workspace adapter or new provider interface is required. Aqueous icons -reuse the widget's existing grouping, cached icon list, sizing and hit-test path, -carrying the captured session with the window ID. - -Set `DMS_FORCE_EXTWS=1` in the shell's launch environment to select ext-workspace -even when the Aqueous adapter is healthy. If the protocol is unavailable, the -forced workspace path is unavailable. Without the override, a disconnected -Aqueous adapter falls back to ext-workspace when available and returns to its -richer model after reconnection. The override changes workspace selection only; -other Aqueous consumers and native overview remain available. Ext activation uses -the protocol's native method and does not expose explicit-seat selection. - -Workspace settings expose app icons, follow-monitor-focus, occupied-only filtering, -reverse scrolling and workspace state colors. The app-icon and occupied-only -switches are disabled while the Aqueous model is unavailable or `DMS_FORCE_EXTWS=1`, -because the generic workspace path has no authoritative window membership. -Saved preferences are retained for when the Aqueous model is used again. - -The existing workspace rename dialog supports Aqueous: - -```sh -dms ipc call workspace-rename open -dms ipc call workspace-rename toggle -dms ipc call workspace-rename close -``` - -Opening captures the selected seat's active workspace ID and compositor session. -Changing focus while the dialog is open does not redirect the rename. The dialog -closes after an `applied` result and retains the draft on failure. Missing state, -ambiguous seat selection, lock or observation-only policy prevents opening it. -Rename changes runtime state; it does not write persistent configuration. - -Native overview controls are available through workspace right-click and: - -```sh -dms ipc call aqueous overview show DP-1 -dms ipc call aqueous overview hide DP-1 -dms ipc call aqueous overview toggle DP-1 -``` - -`OVERVIEW_REQUESTED` means dispatch, not committed success; `aqueous status` -reports the authoritative output/selection and command failures produce a toast. -The output argument is a connector label; entity identity remains the runtime ID. - -There are no Aqueous methods on the core socket. UI consumers call the singleton's -typed `command` method; output runtime IDs are sent directly to the compositor. -Settings use `AqueousConfigService` and direct stdin helper requests. See the -[complete contract and schema](aqueous-integration-plan.md) for protocol semantics. - -## Verification - -The following historical checks passed against the pre-IPC pinned build on 2026-09-05 -(the watcher-specific checks describe the earlier transport): - -- Aqueous `zig build test`: 393 tests. -- Aqueous native and XWayland `scripts/test-shell-integration.py`: state/actions, - keyboard, inhibition, flow control, four-edge reservations and cleanup, lock, - overview and orderly exit. -- Real helper DMS-mode integration suite: validation, persistence, stale generation, - toolkit retry and monitor requests. -- DMS Go suite and focused race tests for the JSON process utility, keybind provider and screenshots. -- Embedded and distro DMS builds, dankinstall, FreeBSD cross-builds, QML entrypoint - lint, `go mod tidy`, generated settings search index and translation-term checks. -- Production QML reducer/command/helper tests: atomicity, reference migration, - Unicode, string sequences above 2^53, full replacement, stale IDs, capabilities, - command results and generation preservation. -- An offscreen Quickshell process harness loads the production services with fake - CLI fixtures: split/coalesced writes, Unicode, malformed/oversize records, - continuity mismatch, EOF, retry backoff, uncertain command timeout, idle behavior - and child cleanup. This does not test a parser byte bound before a delimiter. -- JavaScript tests executing production display methods: delayed success/failure, - supersession, timeout, late callback and disconnect cleanup; display identity, - clipping-related configuration values, conflicts and helper capability limits. -- Real DMS daemon and Quickshell on two private Aqueous headless outputs, both - standalone bars and connected frame: duplicate window titles/app IDs, keyboard - switching, overview, active-window crops, helper validation/save/conflicts, - keybind writes, one QML-owned watcher, reconnect after killing the watcher, - orderly logout and process cleanup. -- Workspace tests exercise the established compositor selection, the forced-ext - override, Aqueous padding/grouping/icon counts, native handle identity and - captured sessions. The runtime harness exercises the actual icon hit test, - duplicate-name renames, output following and fallback/reconnect. Settings/dialog - tests cover control visibility and search conditions, forced-ext membership - controls, captured rename targets, retained drafts, lock/stale-target rejection - and late callbacks after reopening. Real UI runs exercise the settings controls - and rename IPC/dialog, including focus changes and correction after failure. - -Specialized Niri/Hyprland/Mango/I3-family selection was checked with synthetic inputs; -new live sessions for those compositors were not available for this correction. - -The UI runs included the ungrouped taskbar, dock and keyboard widget. -The XWayland DMS run also captured a synthetic XWayland window (404×304), with -rotated/fractional native capture at 350×1145 and positive output origins. - -Earlier standalone-bar runs produced 632×660 and 350×1205 window crops. The connected -frame run produced 616×648 and 330×1190 crops; the latter used scale 1.25, -90° rotation and output origin (-3000, -100). These are dimensions from those -synthetic fixtures, not universal expected window sizes. - -Reproduce the focused desktop checks with a Pixman-compatible diagnostic build: - -```sh -node quickshell/tests/workspace-view.test.mjs -node quickshell/tests/workspace-settings.test.mjs -node quickshell/tests/aqueous-service.test.mjs -node quickshell/tests/aqueous-ipc.test.mjs -python3 scripts/test-aqueous-service.py -node quickshell/tests/display-apply.test.mjs -node quickshell/tests/aqueous-displays.test.mjs -LD_LIBRARY_PATH=/path/to/patched-wlroots/lib \ - python3 scripts/test-aqueous-integration.py \ - --aqueous-source /path/to/Aqueous --bin-dir /path/to/matching/binaries -# Add --force-ext to verify the workspace widget through ext-workspace. -# Add --frame to exercise connected frame reservations. -# Add --xwayland for the XWayland capture check (positive output origins). -``` - -The binary directory must contain `aqueous`, `aqueousctl`, `aqueous-config` and -the DMS build under test. The harness compiles Aqueous's existing synthetic client -from the supplied source; it never connects to the user's display. It retains -logs, PNGs and helper snapshots in the printed temporary directory. -`qmlformat` 6.11.2 exits unsuccessfully without diagnostics for both the unchanged -baseline and modified `WorkspaceSwitcher.qml`; the other changed QML files parse, -and entrypoint lint and the real widget runs pass. - -Physical DPMS off/wake, hotplug/resume, physical mixed-scale rendering, decorations -across real applications, direct/nested/UWSM logout combinations and full lock UI -integration still need desktop verification. Browser/recorder portal streaming is -a separate release gate. Headless checks do not establish those results. -DankLinux-Docs support tables, packaging and plugin registry publication remain -work for their owning repositories after release dependencies are established. - -Keep the generic DPMS/result and UWSM logout fixes independently reviewable. -Popup compatibility changes are outside this integration and have been removed. - -## Runtime IPC verification (2026-09-06) - -The QML reducer, protocol, workspace, keybind and display JavaScript tests pass. -The offscreen socket harness checks hello, mismatched sessions, unsupported schema, -fragmented Unicode, coalesced response/events, ack ordering, malformed/oversized -complete frames, mid-frame disconnects, broken deltas, command queue limits, before/after-send timeouts, -accepted logout, unavailable startup and same-endpoint recovery. Its private -`aqueousctl` interceptor records zero launches. It tests the production QML files, -without connecting to the user's Wayland display or DMS sockets. - -The two-second idle sample after fault/reconnect tests recorded zero CPU ticks, -zero socket bytes and no child processes. RSS remained 104524 KiB and voluntary -context-switch count did not change in the final offscreen sample. These numbers describe -the offscreen harness process, including Qt and prior oversized-frame allocations; -they are not whole-desktop resource measurements. Each run retains `/proc` samples, -traffic counters and QML logs in its printed temporary evidence directory. - -Go socket and screenshot tests pass with the race detector -(`cd core && go test -race ./internal ./internal/screenshot`). They share the QML -hello fixture and cover bounded fake Unix servers, fragmented/coalesced frames, -malformed/truncated/oversized input, exact string sequences, stale-session errors, -cancellation and snapshot parsing. - -The full DMS integration passed on two private headless outputs using: - -```sh -# Build the modified DMS binary; the directory also needs the matching -# freshly built aqueous, aqueousctl and aqueous-config binaries. -cd core -go build -o /tmp/dms-aqueous-ipc-bin/dms ./cmd/dms -cd .. -LD_LIBRARY_PATH=/path/to/Aqueous/compositor/.deps/wlroots-render-hook/lib \ - python3 scripts/test-aqueous-integration.py \ - --aqueous-source /path/to/Aqueous --bin-dir /tmp/dms-aqueous-ipc-bin -``` - -The verified run used `/tmp/aqueous-ipc-build/bin/aqueous` and `aqueousctl`, with -`aqueous-config` from `/home/zoey/RiderProjects/Aqueous/plugin/helper/zig-out/bin`. -The harness obtains the socket path from the private compositor's child environment -and saves a successful hello before starting DMS. It exercises window activation, -close/fullscreen/move and eligibility failures, workspace widget/rename, keyboard selection, overview, screenshot -geometry, retained helper conflicts, lock/unlock, reconnect and orderly session exit. Native -window captures were 632×612 and 350×1145 for the rotated/fractional negative-origin -case. Runtime and screenshot state paths launched zero `aqueousctl` processes; -22 calls were attributed to retained `aqueous-config` output/cursor operations. -The interceptor saves argv and parent executable for every launch and fails if the -parent is outside that retained helper. Evidence: `/tmp/dms-aqueous-integration-0_4nvb4o`. - -Physical hotplug, full lock UI, mixed-scale hardware and new-instance desktop -relaunch remain manual verification. The QML transport limitations above are -explicit deviations from the native hardening requirements in the original plan. diff --git a/quickshell/Common/AqueousConnection.qml b/quickshell/Common/AqueousConnection.qml index 321f194c2..ee4ec919e 100644 --- a/quickshell/Common/AqueousConnection.qml +++ b/quickshell/Common/AqueousConnection.qml @@ -16,6 +16,8 @@ Item { property bool subscribed: false property bool installed: false property bool resetting: false + property bool retrying: false + property int reconnectAttempt: 0 property string lastDelivery: "" property var frameParser: null readonly property bool ready: socket.linkUp && handshake !== null @@ -43,7 +45,10 @@ Item { function reconnect() { resetting = true; clear(); - socket.reconnect(); + retrying = true; + const delay = Math.min(400 * Math.pow(2, Math.min(reconnectAttempt++, 6)), 15000); + reconnectTimer.interval = delay + Math.floor(Math.random() * delay / 4); + reconnectTimer.restart(); resetting = false; } @@ -91,6 +96,7 @@ Item { if (!ready || resetting) return; installed = true; + reconnectAttempt = 0; initialDeadline.stop(); lastDelivery = value.delivery; request("ack", { @@ -105,6 +111,7 @@ Item { pending = null; deadline.stop(); if (operation.op === "command") { + reconnectAttempt = 0; reply(value.result || null, error); return; } @@ -138,14 +145,18 @@ Item { } onConnectedChanged: { - if (!connected) - clear(); + if (connected) + return; + reconnectTimer.stop(); + retrying = false; + reconnectAttempt = 0; + clear(); } DankSocket { id: socket path: root.path - connected: root.connected + connected: root.connected && !root.retrying onConnectionStateChanged: { if (root.resetting) return; @@ -175,6 +186,10 @@ Item { } } + Timer { + id: reconnectTimer + onTriggered: root.retrying = false + } Timer { id: deadline interval: 5000 diff --git a/quickshell/Common/AqueousDisplays.js b/quickshell/Common/AqueousDisplays.js index 0590dfa18..2e8bae35e 100644 --- a/quickshell/Common/AqueousDisplays.js +++ b/quickshell/Common/AqueousDisplays.js @@ -14,7 +14,7 @@ function heads(outputs) { } function matches(candidate, actual, original) { - if (original.length !== actual.length || original.some(o => !actual.some(a => a.id === o.id && a.name === o.name))) + if (candidate.length !== actual.length || original.length !== actual.length || original.some(o => !actual.some(a => a.id === o.id && a.name === o.name))) return false; return candidate.every(h => { const output = actual.find(o => o.name === h.name); @@ -25,6 +25,7 @@ function matches(candidate, actual, original) { const mode = h.customMode || original.find(o => o.name === h.name)?.modes?.find(m => m.id === h.modeId); return output.x === h.position.x && output.y === h.position.y && Math.abs(output.scale - h.scale) < 0.0001 && output.transform === h.transform + && (h.adaptiveSync === undefined || output.adaptiveSync === h.adaptiveSync) && !!mode && output.currentMode?.width === mode.width && output.currentMode?.height === mode.height && output.currentMode?.refresh === mode.refresh; }); diff --git a/quickshell/Common/AqueousIpc.js b/quickshell/Common/AqueousIpc.js index 7df0d9116..b607ddc7e 100644 --- a/quickshell/Common/AqueousIpc.js +++ b/quickshell/Common/AqueousIpc.js @@ -57,14 +57,33 @@ function envelope(line, limit) { let depth = 0, quoted = false, escaped = false; for (const c of line) { if (quoted) { - if (escaped) escaped = false; - else if (c === "\\") escaped = true; - else if (c === '"') quoted = false; + if (escaped) { + escaped = false; + continue; + } + switch (c) { + case "\\": + escaped = true; + break; + case '"': + quoted = false; + break; + } continue; } - if (c === '"') quoted = true; - if (c === "{" || c === "[") depth++; - if (c === "}" || c === "]") depth--; + switch (c) { + case '"': + quoted = true; + break; + case "{": + case "[": + depth++; + break; + case "}": + case "]": + depth--; + break; + } if (depth > 32) throw new Error("IPC nesting exceeds limit"); } diff --git a/quickshell/Common/DankSocket.qml b/quickshell/Common/DankSocket.qml index 4a84ba328..6aa88ec82 100644 --- a/quickshell/Common/DankSocket.qml +++ b/quickshell/Common/DankSocket.qml @@ -1,9 +1,3 @@ import qs.DankCommon.Common as DankCommon -DankCommon.DankSocket { - function reconnect() { - _teardown(); - if (connected) - _scheduleReconnect(); - } -} +DankCommon.DankSocket {} diff --git a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml index bffb7d928..120eaf7fc 100644 --- a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml +++ b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml @@ -53,8 +53,8 @@ Singleton { function aqueousDisplayError(message) { validatingConfig = false; - validationError = message; - ToastService.showError(I18n.tr("Error"), message); + validationError = AqueousService.errorMessage(message); + ToastService.showError(I18n.tr("Error"), validationError, message); } function discardAqueousPreview() { @@ -69,10 +69,15 @@ Singleton { function previewAqueousOutputs(descriptions) { if (validatingConfig) return; + if (!AqueousService.available) { + aqueousDisplayError("unavailable: compositor state"); + return; + } if (aqueousPreview) { observeAqueousPreview(aqueousPreview, descriptions, ""); return; } + const session = AqueousService.session; validatingConfig = true; AqueousConfigService.load((snapshot, error) => { if (!snapshot) { @@ -80,18 +85,31 @@ Singleton { return; } freshAqueousOutputs((original, error) => { + if (AqueousService.session !== session) { + aqueousDisplayError("conflict: compositor session changed"); + return; + } if (!original) { aqueousDisplayError(error); return; } const candidate = WlrOutputService.outputsConfigHeads(buildOutputsWithPendingChanges(), outputs); WlrOutputService.testConfiguration(candidate, (success, message) => { + if (AqueousService.session !== session) { + aqueousDisplayError("conflict: compositor session changed"); + return; + } if (!success) { aqueousDisplayError(message); return; } - const preview = {snapshot: snapshot, original: original, heads: candidate, - fingerprint: null, session: AqueousService.session}; + const preview = { + snapshot: snapshot, + original: original, + heads: candidate, + fingerprint: null, + session: session + }; aqueousPreview = preview; WlrOutputService.applyConfiguration(candidate, (success, message) => { observeAqueousPreview(preview, descriptions, success ? "" : message); @@ -109,12 +127,12 @@ Singleton { if (!actual || AqueousService.session !== preview.session || !AqueousDisplays.matches(preview.heads, actual, preview.original)) { if (actual && AqueousDisplays.fingerprint(actual) === AqueousDisplays.fingerprint(preview.original)) aqueousPreview = null; - aqueousDisplayError(error || applyError || I18n.tr("Display preview changed externally; review the current configuration")); + aqueousDisplayError(error || applyError || "conflict: display preview changed externally"); return; } preview.fingerprint = AqueousDisplays.fingerprint(actual); validatingConfig = false; - validationError = applyError; + validationError = applyError ? AqueousService.errorMessage(applyError) : ""; changesApplied(descriptions); }); } @@ -147,7 +165,7 @@ Singleton { freshAqueousOutputs((live, error) => { validatingConfig = false; if (!live || AqueousService.session !== preview.session || AqueousDisplays.fingerprint(live) !== preview.fingerprint) { - aqueousDisplayError(I18n.tr("Configuration saved, but live display state changed; refresh displays")); + aqueousDisplayError("conflict: configuration saved but live display state changed"); return; } changesConfirmed(); diff --git a/quickshell/Modules/Settings/DisplayConfigTab.qml b/quickshell/Modules/Settings/DisplayConfigTab.qml index 34468d931..e2bb83f6e 100644 --- a/quickshell/Modules/Settings/DisplayConfigTab.qml +++ b/quickshell/Modules/Settings/DisplayConfigTab.qml @@ -112,7 +112,7 @@ Item { } DankButton { - text: I18n.tr("Discard draft and reload") + text: I18n.tr("Discard draft and reload", "Discard unsaved Aqueous display settings and refresh the current display state") enabled: !DisplayConfigState.validatingConfig onClicked: DisplayConfigState.discardAqueousPreview() } diff --git a/quickshell/Modules/Settings/KeybindItem.qml b/quickshell/Modules/Settings/KeybindItem.qml index 7e9f33d06..8a14b181b 100644 --- a/quickshell/Modules/Settings/KeybindItem.qml +++ b/quickshell/Modules/Settings/KeybindItem.qml @@ -1729,6 +1729,7 @@ Item { RowLayout { Layout.fillWidth: true spacing: Theme.spacingM + visible: KeybindsService.currentProvider !== "aqueous" StyledText { text: I18n.tr("Title") diff --git a/quickshell/Modules/Settings/KeybindsTab.qml b/quickshell/Modules/Settings/KeybindsTab.qml index b0620b8c4..152de8f3e 100644 --- a/quickshell/Modules/Settings/KeybindsTab.qml +++ b/quickshell/Modules/Settings/KeybindsTab.qml @@ -54,7 +54,7 @@ Item { function beginEdit(binding, key) { if (hasEditDraft || editBusy || KeybindsService.bindMutationBusy) { - ToastService.showInfo(I18n.tr("Save or discard the current edit before editing another shortcut.")); + ToastService.showInfo(I18n.tr("Save or discard the current edit before editing another shortcut.", "Aqueous keyboard shortcut editor, retaining an unsaved edit while reviewing current bindings")); return false; } try { @@ -64,7 +64,7 @@ Item { reviewingEdit = false; return true; } catch (e) { - ToastService.showError(I18n.tr("Failed to load keybinds"), String(e)); + ToastService.showError(I18n.tr("Failed to load keybinds", "Aqueous shortcut editor could not load the current bindings"), AqueousService.errorMessage(String(e)), String(e)); return false; } } @@ -97,7 +97,7 @@ Item { } keybindsTab.reviewSnapshot = result.snapshot || null; keybindsTab.reviewingEdit = result.code !== "load_failed" && result.code !== "busy"; - keybindsTab.editError = KeybindsService.bindEditError(result.code) + (result.message ? "\n" + result.message : ""); + keybindsTab.editError = KeybindsService.bindEditError(result.code); }; switch (editDraft.operation) { case "set": @@ -145,10 +145,11 @@ Item { function confirmEditRemoval() { if (!editDraft) return; - if (editDraft.operation === "reset") + if (editDraft.operation === "reset") { confirmResetBind(editDraft.originalKey, ""); - else - confirmRemoveBind(editDraft.originalKey, ""); + return; + } + confirmRemoveBind(editDraft.originalKey, ""); } function discardEdit() { @@ -230,7 +231,7 @@ Item { if (!binding || !keybindsTab.beginEdit(binding, binding.keys[0]?.key || "")) return; } else if (keybindsTab.hasEditDraft && action !== keybindsTab.editDraft.action) { - ToastService.showInfo(I18n.tr("Save or discard the current edit before editing another shortcut.")); + ToastService.showInfo(I18n.tr("Save or discard the current edit before editing another shortcut.", "Aqueous keyboard shortcut editor, retaining an unsaved edit while reviewing current bindings")); return; } expandedKey = expandedKey === action ? "" : action; @@ -509,7 +510,7 @@ Item { StyledText { readonly property string bindsFile: KeybindsService.requiresBindReview ? "aqueous-config" : KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf" - text: KeybindsService.requiresBindReview ? I18n.tr("Click any shortcut to edit Aqueous configuration") : KeybindsService.readOnly ? I18n.tr("Hyprland conf mode is read-only in Settings") : I18n.tr("Click any shortcut to edit. Changes save to %1").arg(bindsFile) + text: KeybindsService.requiresBindReview ? I18n.tr("Click any shortcut to edit Aqueous configuration", "Aqueous keyboard shortcut editor, retaining an unsaved edit while reviewing current bindings") : KeybindsService.readOnly ? I18n.tr("Hyprland conf mode is read-only in Settings") : I18n.tr("Click any shortcut to edit. Changes save to %1").arg(bindsFile) font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText wrapMode: Text.WordWrap @@ -729,7 +730,7 @@ Item { StyledText { width: parent.width visible: keybindsTab.reviewingEdit || keybindsTab.editError !== "" - text: keybindsTab.editError || I18n.tr("Review the current bindings before saving your retained edit.") + text: keybindsTab.editError || I18n.tr("Review the current bindings before saving this edit.", "Aqueous keyboard shortcut editor, retaining an unsaved edit while reviewing current bindings") color: Theme.error wrapMode: Text.WordWrap } @@ -746,12 +747,12 @@ Item { width: parent.width spacing: Theme.spacingS DankButton { - text: I18n.tr("Reload") + text: I18n.tr("Refresh") enabled: !keybindsTab.editBusy && !keybindsTab.editInvalidated onClicked: keybindsTab.reloadEdit() } DankButton { - text: I18n.tr("Accept reviewed changes") + text: I18n.tr("Accept reviewed changes", "Aqueous keyboard shortcut editor, retaining an unsaved edit while reviewing current bindings") visible: keybindsTab.reviewingEdit && !!keybindsTab.reviewSnapshot enabled: !keybindsTab.editBusy && !keybindsTab.editInvalidated onClicked: keybindsTab.acceptReview() diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index c2cb56cc4..2ad01e8eb 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -461,7 +461,7 @@ Item { sourceComponent: AqueousAppearanceSettings { cursor: true settingKey: "aqueousCursor" - title: I18n.tr("Cursor Theme") + " · Aqueous" + title: I18n.tr("Aqueous cursor", "Aqueous compositor cursor synchronization settings") visible: CompositorService.isAqueous } } diff --git a/quickshell/Modules/Settings/TypographyMotionTab.qml b/quickshell/Modules/Settings/TypographyMotionTab.qml index f28014efe..bfcc6cac6 100644 --- a/quickshell/Modules/Settings/TypographyMotionTab.qml +++ b/quickshell/Modules/Settings/TypographyMotionTab.qml @@ -61,7 +61,7 @@ Item { active: CompositorService.isAqueous sourceComponent: AqueousAppearanceSettings { settingKey: "aqueousTypography" - title: I18n.tr("Typography") + " · Aqueous" + title: I18n.tr("Aqueous typography", "Aqueous compositor font synchronization settings") visible: CompositorService.isAqueous } } diff --git a/quickshell/Modules/Settings/Widgets/AqueousAppearanceSettings.qml b/quickshell/Modules/Settings/Widgets/AqueousAppearanceSettings.qml index 09887de23..905ae68ef 100644 --- a/quickshell/Modules/Settings/Widgets/AqueousAppearanceSettings.qml +++ b/quickshell/Modules/Settings/Widgets/AqueousAppearanceSettings.qml @@ -1,3 +1,5 @@ +pragma ComponentBehavior: Bound + import QtQuick import qs.Common import qs.Services @@ -13,18 +15,18 @@ SettingsCard { readonly property var report: cursor ? snapshot?.desktop_cursor : snapshot?.desktop_typography readonly property bool partial: (report?.failed_count || 0) > 0 readonly property bool supported: snapshot?.capabilities?.includes(cursor ? "cursor_sync" : "typography_sync") || false - title: cursor ? I18n.tr("Cursor Theme") + " · Aqueous" : I18n.tr("Typography") + " · Aqueous" + title: cursor ? I18n.tr("Aqueous cursor", "Aqueous compositor cursor synchronization settings") : I18n.tr("Aqueous typography", "Aqueous compositor font synchronization settings") iconName: cursor ? "mouse" : "text_fields" tab: cursor ? "theme" : "typography" tags: ["aqueous", "appearance"] function reload() { - if (busy) + if (busy || AqueousConfigService.busy) return; busy = true; AqueousConfigService.load((data, message) => { busy = false; - error = message; + error = message ? AqueousService.errorMessage(message) : ""; if (!data) return; snapshot = data; @@ -34,6 +36,25 @@ SettingsCard { }); } + function targetStatus(target) { + switch (target.state) { + case "synced": + return I18n.tr("Synchronized", "Aqueous appearance settings match this application or toolkit"); + case "drifted": + return I18n.tr("Not synchronized", "Aqueous appearance settings differ from this application or toolkit"); + case "partial": + return I18n.tr("Partially synchronized", "The application or toolkit cannot represent every Aqueous font setting"); + case "failed": + return I18n.tr("Sync failed", "Applying Aqueous appearance settings to this application or toolkit failed"); + case "unavailable": + return I18n.tr("Unavailable"); + case "unmanaged": + return I18n.tr("Disabled"); + default: + return I18n.tr("Unknown"); + } + } + function value(id) { if (Object.prototype.hasOwnProperty.call(changes, id)) return changes[id]; @@ -47,29 +68,37 @@ SettingsCard { } function apply(retry) { - if (busy || !snapshot) + if (busy || !snapshot || AqueousConfigService.busy) return; const capability = cursor ? "cursor_sync" : "typography_sync"; if (!snapshot.capabilities?.includes(capability)) return; - const draft = {expected_generation: snapshot.generation, create_user_override: true, - changes: Object.keys(changes).map(id => ({id: id, value: changes[id]}))}; - if (retry) - draft[cursor ? "sync_cursor" : "sync_typography"] = true; + const draft = { + expected_generation: snapshot.generation, + create_user_override: true, + changes: retry ? [] : Object.keys(changes).map(id => ({ + id: id, + value: changes[id] + })) + }; + draft[cursor ? "sync_cursor" : "sync_typography"] = true; busy = true; AqueousConfigService.apply(draft, (data, message) => { busy = false; - error = message; + error = message ? AqueousService.errorMessage(message) : ""; if (!data) return; snapshot = data; - changes = ({}); - if (!cursor) { - const typography = data.desktop_typography; - SettingsData.set("fontFamily", typography.family); - SettingsData.set("fontWeight", typography.weight); - SettingsData.set("fontScale", typography.size_pt * 96 / 72 / 14); - } + if (!retry) + changes = ({}); + if (cursor) + return; + const typography = data.desktop_typography; + if (!typography || typeof typography.family !== "string" || !Number.isFinite(typography.weight) || !Number.isFinite(typography.size_pt) || typography.size_pt <= 0) + return; + SettingsData.set("fontFamily", typography.family); + SettingsData.set("fontWeight", typography.weight); + SettingsData.set("fontScale", typography.size_pt * 96 / 72 / 14); }); } @@ -82,7 +111,7 @@ SettingsCard { SettingsDropdownRow { width: parent.width visible: root.cursor - enabled: !!root.snapshot && !root.busy + enabled: root.supported && !root.busy text: I18n.tr("Cursor Theme") options: root.snapshot?.desktop_cursor?.themes || [] currentValue: root.value("desktop.cursor.theme") || "default" @@ -95,7 +124,7 @@ SettingsCard { SettingsDropdownRow { width: parent.width visible: !root.cursor - enabled: !!root.snapshot && !root.busy + enabled: root.supported && !root.busy text: I18n.tr("Normal Font") options: root.snapshot?.desktop_typography?.families || [] currentValue: root.value("desktop.font.family") || "sans-serif" @@ -107,12 +136,12 @@ SettingsCard { SettingsSliderRow { width: parent.width - enabled: !!root.snapshot && !root.busy + enabled: root.supported && !root.busy text: root.cursor ? I18n.tr("Cursor Size") : I18n.tr("Font Size") minimum: root.cursor ? 12 : 6 maximum: root.cursor ? 128 : 30 value: root.value(root.cursor ? "desktop.cursor.size" : "desktop.font.size_pt") || (root.cursor ? 24 : 12) - unit: root.cursor ? "px" : "pt" + unit: root.cursor ? I18n.tr("px", "Cursor size unit, pixels") : I18n.tr("pt", "Font size unit, points") onSliderValueChanged: value => { root.stage(root.cursor ? "desktop.cursor.size" : "desktop.font.size_pt", value); if (root.cursor) @@ -133,7 +162,7 @@ SettingsCard { StyledText { required property var modelData width: parent.width - text: modelData.id + ": " + I18n.tr(modelData.state) + text: I18n.tr("%1: %2", "Aqueous appearance sync target and its status").arg(modelData.id).arg(root.targetStatus(modelData)) color: Theme.surfaceVariantText wrapMode: Text.WordWrap } @@ -142,26 +171,27 @@ SettingsCard { StyledText { width: parent.width visible: !root.cursor - text: I18n.tr("DMS uses the font family, weight and scale. Exact face, slant, width and separately scaled bars may differ.") + text: I18n.tr("DMS uses the font family, weight and scale. Exact face, slant, width and separately scaled bars may differ.", "Aqueous font synchronization, describing which font settings DMS can represent") color: Theme.surfaceVariantText wrapMode: Text.WordWrap } - Row { + Flow { + width: parent.width spacing: Theme.spacingS DankButton { - text: I18n.tr("Apply") - enabled: root.supported && !root.busy && Object.keys(root.changes).length > 0 + text: I18n.tr("Apply Changes") + enabled: root.supported && !root.busy && !AqueousConfigService.busy && Object.keys(root.changes).length > 0 onClicked: root.apply(false) } DankButton { text: I18n.tr("Retry") - enabled: root.supported && !root.busy && root.partial + enabled: root.supported && !root.busy && !AqueousConfigService.busy && root.partial onClicked: root.apply(true) } DankButton { - text: I18n.tr("Reload") - enabled: !root.busy + text: I18n.tr("Refresh") + enabled: !root.busy && !AqueousConfigService.busy onClicked: root.reload() } } diff --git a/quickshell/Services/AqueousConfigService.qml b/quickshell/Services/AqueousConfigService.qml index 9c383a7b8..d7de397cb 100644 --- a/quickshell/Services/AqueousConfigService.qml +++ b/quickshell/Services/AqueousConfigService.qml @@ -2,7 +2,6 @@ pragma Singleton pragma ComponentBehavior: Bound import Quickshell -import qs.Common import qs.Services Singleton { @@ -65,7 +64,6 @@ Singleton { protocol: 1 }); request.backup_dir = request.backup_dir || (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/DankMaterialShell/aqueous-backups"; - // Freeze the complete draft before asynchronous discovery and validation. const input = JSON.stringify(request); const required = ["validate", "stdin_requests", "generation_check", "atomic_file_replace", "shell_dms"]; for (const [key, capability] of Object.entries({ diff --git a/quickshell/Services/AqueousService.qml b/quickshell/Services/AqueousService.qml index d83f08afc..ab16b2236 100644 --- a/quickshell/Services/AqueousService.qml +++ b/quickshell/Services/AqueousService.qml @@ -140,7 +140,6 @@ Singleton { if (seen.has(key)) throw new Error("duplicate entity key"); seen.add(key); - // Include the separator; the required session makes the model nonempty. const size = byteLength(JSON.stringify(key) + ":" + JSON.stringify(entity)) + 1; modelBytes += size - (entityBytes[key] || 0); entityBytes[key] = size; @@ -339,12 +338,35 @@ Singleton { const failure = commandResult(action, result, error); if (failure) { log.warn("command failed:", action, failure); - ToastService.showError(I18n.tr("Error"), failure); + ToastService.showError(I18n.tr("Error"), errorMessage(failure), failure); } if (callback) callback(!failure, failure || result.status); } + function errorMessage(error) { + const code = String(error).replace(/^Error: /, "").split(":")[0]; + switch (code) { + case "busy": + return I18n.tr("Another Aqueous operation is in progress.", "Aqueous request rejected because another operation is running"); + case "locked": + case "unavailable": + case "not_found": + return I18n.tr("Unavailable"); + case "unsupported": + return I18n.tr("This operation is not supported by the installed Aqueous version.", "Aqueous compositor or configuration helper lacks a required capability"); + case "conflict": + case "external_change": + case "stale_session": + return I18n.tr("Configuration changed. Refresh to continue.", "Aqueous configuration or compositor session changed while editing"); + case "uncertain": + case "command completion uncertain": + return I18n.tr("The result is unknown. Refresh before trying again.", "An Aqueous operation may have completed without a reply"); + default: + return I18n.tr("The Aqueous operation failed.", "Fallback error for an Aqueous compositor or configuration request"); + } + } + function command(action, fields, callback) { try { commandPayload(action, fields); @@ -625,8 +647,27 @@ Singleton { command("session.exit"); } + function snapshot(path) { + if (!available || path !== socketPath) + return JSON.stringify({ + error: "unavailable" + }); + return JSON.stringify({ + schema: 1, + session: session, + sequence: sequence, + type: "snapshot", + base_sequence: null, + upsert: entities, + removed: [] + }); + } + IpcHandler { target: "aqueous" + function snapshot(path: string): string { + return root.snapshot(path); + } function status(): string { return JSON.stringify({ available: root.available, @@ -670,13 +711,16 @@ Singleton { const box = w.outer_geometry; const x = box.x - output.bounds.x; const y = box.y - output.bounds.y; - if (position === SettingsData.Position.Top) + switch (position) { + case SettingsData.Position.Top: return y < thickness && y + box.height > 0; - if (position === SettingsData.Position.Left) + case SettingsData.Position.Left: return x < thickness && x + box.width > 0; - if (position === SettingsData.Position.Right) + case SettingsData.Position.Right: return x < width && x + box.width > width - thickness; - return y < height && y + box.height > height - thickness; + default: + return y < height && y + box.height > height - thickness; + } }); } diff --git a/quickshell/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml index 9cd57bbf1..e0cbfe0ba 100644 --- a/quickshell/Services/CompositorService.qml +++ b/quickshell/Services/CompositorService.qml @@ -42,7 +42,7 @@ Singleton { function setOutputPower(on) { Proc.runCommand("output-power-action", [Proc.dmsBin, "dpms", on ? "on" : "off"], (output, code) => { if (code !== 0) - ToastService.showError(I18n.tr("Error"), I18n.tr("Failed to change display power")); + ToastService.showError(I18n.tr("Error"), I18n.tr("Failed to change display power", "Error shown when changing monitor power fails")); }, 0, 12000); } @@ -1066,7 +1066,14 @@ Singleton { // of winning on a stale env var. function _envDetectionCandidates() { const runtimeDir = Quickshell.env("XDG_RUNTIME_DIR") || ""; + const aqueousSocket = Quickshell.env("AQUEOUS_SOCKET") || ""; return [ + { + name: "aqueous", + present: !!aqueousSocket, + test: ["test", "-S", aqueousSocket], + detail: "AQUEOUS_SOCKET " + aqueousSocket + }, { name: "mango", present: !!mangoSignature, diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index fa4927599..63d8584ed 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -425,7 +425,7 @@ Singleton { } AqueousService.runJson(["dms", "keybinds", "show", "aqueous"], null, (snapshot, error) => { if (session !== root.aqueousSession) { - callback(null, I18n.tr("Configuration changed. Reload to continue.")); + callback(null, I18n.tr("Configuration changed. Refresh to continue.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings")); return; } try { @@ -433,7 +433,8 @@ Singleton { throw new Error(error); AqueousKeybinds.inventory(snapshot); } catch (e) { - callback(null, String(e)); + log.warn("Failed to read Aqueous keybindings:", e); + callback(null, AqueousService.errorMessage(String(e))); return; } callback(snapshot, ""); @@ -499,21 +500,21 @@ Singleton { function bindEditError(code) { switch (code) { case "external_change": - return I18n.tr("Keybindings changed. Reload and review your edit before saving."); + return I18n.tr("Keybindings changed. Refresh and review your edit before saving.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings"); case "target_removed": - return I18n.tr("The original shortcut was removed. Discard this edit or explicitly add a new shortcut."); + return I18n.tr("The original shortcut was removed. Discard this edit or add a new shortcut.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings"); case "ambiguous_target": - return I18n.tr("Multiple bindings use the original shortcut. Resolve the duplicate bindings first."); + return I18n.tr("Multiple bindings use the original shortcut. Resolve the duplicate bindings first.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings"); case "destination_occupied": - return I18n.tr("The new shortcut is already in use. Choose another shortcut."); + return I18n.tr("The new shortcut is already in use. Choose another shortcut.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings"); case "target_changed": - return I18n.tr("The original shortcut changed. Reload and review your edit."); + return I18n.tr("The original shortcut changed. Refresh and review your edit.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings"); case "invalidated": - return I18n.tr("The compositor session changed. Your draft is retained; discard it before starting a new edit."); + return I18n.tr("The compositor session changed. Discard this edit before starting a new one.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings"); case "uncertain": - return I18n.tr("The save result is unknown. Reload and inspect the current bindings before continuing."); + return I18n.tr("The save result is unknown. Refresh and check the current bindings.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings"); case "invalid_action": - return I18n.tr("The selected action is no longer available."); + return I18n.tr("The selected action is no longer available.", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings"); default: return I18n.tr("Failed to save keybind"); } @@ -530,9 +531,9 @@ Singleton { const currentBind = describe(current, draft.originalKey); const proposed = draft.operation === "set" ? draft.data.key + " → " + draft.data.action : I18n.tr("Remove"); const destination = draft.operation === "set" ? describe(current, draft.data.key) : I18n.tr("None"); - const describeChange = bind => bind ? (bind[0] || I18n.tr("Unbound")) + " → " + bind[1] : I18n.tr("None"); - const changes = AqueousKeybinds.changes(draft.baseline, current).map(change => I18n.tr("Previous: %1\nCurrent: %2").arg(describeChange(change.before)).arg(describeChange(change.after))).join("\n\n"); - return I18n.tr("Original: %1\nCurrent: %2\nProposed: %3\nCurrent destination: %4").arg(original).arg(currentBind).arg(proposed).arg(destination) + (changes ? "\n\n" + changes : ""); + const describeChange = bind => bind ? (bind[0] || I18n.tr("Not bound")) + " → " + bind[1] : I18n.tr("None"); + const changes = AqueousKeybinds.changes(draft.baseline, current).map(change => I18n.tr("Previous: %1\nCurrent: %2", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings").arg(describeChange(change.before)).arg(describeChange(change.after))).join("\n\n"); + return I18n.tr("Original: %1\nCurrent: %2\nProposed: %3\nCurrent destination: %4", "Aqueous keyboard shortcut editor, explaining a conflict or comparing an unsaved edit with current bindings").arg(original).arg(currentBind).arg(proposed).arg(destination) + (changes ? "\n\n" + changes : ""); } function _mutateAqueous(draft, callback) { @@ -893,7 +894,10 @@ Singleton { return false; seen.add(b.action); return true; - }).map(b => ({id: b.action, label: b.desc})); + }).map(b => ({ + id: b.action, + label: b.desc + })); } return Actions.getCompositorActions(currentProvider, category); } diff --git a/quickshell/Services/WlrOutputService.qml b/quickshell/Services/WlrOutputService.qml index 7a3283648..06516d4a3 100644 --- a/quickshell/Services/WlrOutputService.qml +++ b/quickshell/Services/WlrOutputService.qml @@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell +import qs.Common import qs.Services Singleton { @@ -109,7 +110,7 @@ Singleton { function applyConfiguration(heads, callback) { if (!DMSService.isConnected || !wlrOutputAvailable) { if (callback) { - callback(false, "Not connected"); + callback(false, I18n.tr("Not connected")); } return; } @@ -141,7 +142,7 @@ Singleton { function testConfiguration(heads, callback) { if (!DMSService.isConnected || !wlrOutputAvailable) { if (callback) { - callback(false, "Not connected"); + callback(false, I18n.tr("Not connected")); } return; } @@ -285,13 +286,13 @@ Singleton { function applyOutputsConfig(outputsData, connectedOutputs, callback) { if (!wlrOutputAvailable) { if (callback) - callback(false, "Not connected"); + callback(false, I18n.tr("Not connected")); return; } const heads = outputsConfigHeads(outputsData, connectedOutputs); if (heads.length === 0) { if (callback) - callback(false, "No connected outputs"); + callback(false, I18n.tr("No monitors")); return; } applyConfiguration(heads, callback); diff --git a/quickshell/tests/aqueous-displays.test.mjs b/quickshell/tests/aqueous-displays.test.mjs deleted file mode 100644 index 00b0f8dca..000000000 --- a/quickshell/tests/aqueous-displays.test.mjs +++ /dev/null @@ -1,27 +0,0 @@ -import assert from "node:assert/strict"; -import {readFileSync} from "node:fs"; -import vm from "node:vm"; - -const adapter = vm.createContext({}); -vm.runInContext(readFileSync(new URL("../Common/AqueousDisplays.js", import.meta.url), "utf8").replace(".pragma library", ""), adapter); -const mode = {id: "mode", width: 1920, height: 1080, refresh: 59940}; -const output = {id: "head", name: "DP-1", enabled: true, x: -1920, y: 0, scale: 1, transform: 0, modes: [mode], currentMode: mode}; -const original = [output]; -const candidate = adapter.heads(original); -assert.equal(adapter.matches(candidate, original, original), true); -assert.equal(adapter.matches(candidate, [{...output, id: "recreated"}], original), false); -assert.equal(adapter.matches(candidate, [{...output, scale: 1.25}], original), false); -assert.equal(adapter.matches(candidate, [], original), false); -assert.notEqual(adapter.fingerprint(original), adapter.fingerprint([{...output, x: 0}])); -assert.equal(adapter.fingerprint([output, {...output, id: "head2", name: "DP-2"}]), adapter.fingerprint([{...output, id: "head2", name: "DP-2"}, output])); -const snapshot = {capabilities: ["monitor_modes"], generation: "generation", monitors: [{id: "output:0", name: "DP-1", scale: 1}, {id: "output:1", name: "offline", scale: 2}]}; -const request = adapter.request(snapshot, original, original); -assert.equal(request.expected_generation, "generation"); -assert.equal(request.monitor_changes.length, 1); -assert.equal(request.monitor_changes[0].mode, "1920x1080@59.94"); -assert.equal(request.monitor_changes[0].x, -1920); -assert.throws(() => adapter.request(snapshot, [{...output, scale: 1.25}], original), /scale/); -assert.throws(() => adapter.request(snapshot, [{...output, enabled: false}], original), /enablement/); -assert.throws(() => adapter.request({...snapshot, raw_files: {outputs: '[[output]]\nedid = "sha256:test"'}}, original, original), /EDID/); -assert.throws(() => adapter.request({...snapshot, capabilities: []}, original, original), /unsupported/); -process.stdout.write("PASS: Aqueous display identity, conflicts, offline preservation and helper limits\n"); diff --git a/quickshell/tests/aqueous-ipc.test.mjs b/quickshell/tests/aqueous-ipc.test.mjs deleted file mode 100644 index bd607c228..000000000 --- a/quickshell/tests/aqueous-ipc.test.mjs +++ /dev/null @@ -1,86 +0,0 @@ -import assert from "node:assert/strict"; -import {readFileSync} from "node:fs"; -import vm from "node:vm"; - -const Ipc = vm.createContext({}); -vm.runInContext(readFileSync(new URL("../Common/AqueousIpc.js", import.meta.url), "utf8"), Ipc); -assert(Ipc.socketPath("/private/runtime/aqueous/instance/ipc.sock", "/private/runtime")); -for (const path of ["", "relative", "/other/aqueous/instance/ipc.sock", "/private/runtime/aqueous/../ipc.sock", "/private/runtime/aqueous/one/two/ipc.sock", "/private/runtime/aqueous//ipc.sock"]) - assert.equal(Ipc.socketPath(path, "/private/runtime"), false, path); -assert.equal(Ipc.socketPath("/private/runtime/aqueous/instance/ipc.sock", ""), false); -const hello = JSON.parse(readFileSync(new URL("fixtures/aqueous/hello.json", import.meta.url))).result; -assert.equal(Ipc.hello(hello).session, hello.session); -for (const changed of [{schema: 2}, {session: "bad"}, {max_frame_bytes: 4259841}, {max_request_bytes: 65537}, {max_pending_requests: 2}, {capabilities: {}}, {capabilities: {...hello.capabilities, state: false}}]) - assert.throws(() => Ipc.hello({...hello, ...changed}), /unsupported/); -assert.equal(Ipc.nextId("9007199254740999"), "9007199254741000"); -assert.equal(Ipc.nextId("9999999999999999999"), "10000000000000000000"); -assert.throws(() => Ipc.nextId("99999999999999999999"), /exhausted/); -assert.equal(Ipc.envelope('{"ipc":1,"name":"🫧"}', 24).name, "🫧"); -for (const text of ['{"ipc":2}', "[]", "null", '{"ipc":1,"a":' + "[".repeat(33) + "0" + "]".repeat(33) + "}"]) - assert.throws(() => Ipc.envelope(text, 1024)); -assert.throws(() => Ipc.envelope('{"ipc":1,"name":"🫧"}', 20), /size bound/); -for (const value of [{ipc:1,id:"2",ok:true,result:{}}, {ipc:1,id:"1",ok:true,result:[]}, {ipc:1,id:"1",ok:false,error:{code:"locked"}}, {ipc:1,id:"1",ok:false,error:{code:7,message:"bad"}}, {ipc:1,id:"1",ok:true,result:{},event:"state"}]) - assert.throws(() => Ipc.response(value, "1")); -assert.equal(Ipc.response({id:"1",ok:false,error:{code:"busy",message:"queue full"}}, "1"), "busy: queue full"); - -function connection(events) { - const sent = [], errors = [], installed = [], replies = []; - const timer = () => ({running:false, restart(){this.running=true;},stop(){this.running=false;}}); - const ctx = vm.createContext({Ipc, events, connected:true, resetting:false, handshake:null,pending:null,lastId:"0",lastDelivery:"",subscribed:false,installed:false, - frameParser:null, deadline:timer(), initialDeadline:timer(), - socket:{linkUp:true,send(text){sent.push(JSON.parse(text));}}, - helloReceived(){},batchReceived(batch){installed.push(batch);},reply(result,error){replies.push({result,error});},failed(error){errors.push(error);}}); - ctx.root=ctx; - Object.defineProperty(ctx,"ready",{get:()=>ctx.socket.linkUp && ctx.handshake!==null}); - const source=readFileSync(new URL("../Common/AqueousConnection.qml",import.meta.url),"utf8"); - for(const name of ["clear","fail","request","receive","subscribe"]){ - const start=source.indexOf(" function "+name+"("); - vm.runInContext(source.slice(start,source.indexOf("\n }",start)+6),ctx); - } - return {ctx,sent,errors,installed,replies}; -} -const event={ipc:1,event:"state",delivery:"1",batch:{session:hello.session,type:"snapshot"}}; -const respond=(c,result)=>c.ctx.receive(JSON.stringify({ipc:1,id:c.sent.at(-1).id,ok:true,result})); -const handshake=c=>{c.ctx.request("hello",{});respond(c,hello);}; -const c=connection(true); -assert.throws(()=>c.ctx.request("subscribe",{}),/unavailable/); -handshake(c); -c.ctx.subscribe(); -assert.equal(c.sent[1].session,hello.session); -respond(c,{subscribed:true}); -c.ctx.receive(JSON.stringify(event)); -assert.equal(c.installed.length,1); -assert.equal(c.sent.at(-1).op,"ack"); -assert.equal(c.sent.at(-1).params.delivery,"1"); -assert.equal(c.ctx.initialDeadline.running,false); -respond(c,{acked:"1"}); -assert.equal(c.ctx.deadline.running,false); -c.ctx.receive(JSON.stringify({...event,delivery:"2",batch:{...event.batch,type:"delta"}})); -respond(c,{acked:"wrong"}); -assert.match(c.errors.at(-1),/ack/); -for(const scenario of ["before subscribe reply","before ack reply","duplicate delivery","wrong session","delta first","unsolicited reply"]){ - const c=connection(true);handshake(c);c.ctx.subscribe(); - if(scenario!=="before subscribe reply") respond(c,{subscribed:true}); - if(scenario==="before ack reply" || scenario==="duplicate delivery"){ - c.ctx.receive(JSON.stringify(event)); - if(scenario==="duplicate delivery")respond(c,{acked:"1"}); - } - let value=event; - if(scenario==="wrong session")value={...event,batch:{...event.batch,session:"f".repeat(32)}}; - if(scenario==="delta first")value={...event,batch:{...event.batch,type:"delta"}}; - if(scenario==="unsolicited reply")value={ipc:1,id:"10",ok:true,result:{}}; - c.ctx.receive(JSON.stringify(value)); - assert.equal(c.errors.length,1,scenario); -} -const command=connection(false);handshake(command); -command.ctx.request("command",{action:"session.exit",fields:{}}); -assert.throws(()=>command.ctx.request("command",{}),/unavailable/); -respond(command,{status:"accepted"}); -assert.equal(command.replies[0].result.status,"accepted"); -assert.equal(command.ctx.pending,null); -command.ctx.receive(JSON.stringify(event)); -assert.match(command.errors.at(-1),/unexpected/); -command.ctx.clear(); -assert.equal(command.ctx.handshake,null); -assert.equal(command.ctx.lastId,"0"); -console.log("PASS: IPC envelopes, exact IDs, handshake, correlation, subscription/ack order and accepted results"); diff --git a/quickshell/tests/aqueous-keybinds.test.mjs b/quickshell/tests/aqueous-keybinds.test.mjs deleted file mode 100644 index 31b839387..000000000 --- a/quickshell/tests/aqueous-keybinds.test.mjs +++ /dev/null @@ -1,348 +0,0 @@ -import assert from "node:assert/strict"; -import {readFileSync} from "node:fs"; -import vm from "node:vm"; - -const copy = value => JSON.parse(JSON.stringify(value)); -const adapter = vm.createContext({}); -vm.runInContext(readFileSync(new URL("../Common/AqueousKeybinds.js", import.meta.url), "utf8"), adapter); -function methods(context, path, names) { - const source = readFileSync(new URL(path, import.meta.url), "utf8"); - for (const name of names) { - const start = source.indexOf(" function " + name + "("); - const end = source.indexOf("\n }", start) + 6; - vm.runInContext(source.slice(start, end), context); - } -} -const snapshot = { - provider: "aqueous", generation: "before-blur", binds: { - Compositor: [{key: "Super+Return", action: "spawn_terminal"}, {key: "", action: "close"}], - Custom: [{key: "Super+Shift+S", action: "spawn old-screenshot", source: "dms"}] - } -}; -const draft = { - baseline: copy(snapshot), session: "session", operation: "set", - originalKey: "Super+Shift+S", originalAction: "spawn old-screenshot", - binding: {keys: [{key: "Super+Shift+S"}], action: "spawn old-screenshot"}, - data: {key: "Super+Shift+S", action: "spawn dms screenshot"} -}; -const fresh = {...copy(snapshot), generation: "after-blur"}; -assert(adapter.unchanged(snapshot, fresh)); -fresh.binds.Custom[0].desc = "Different display label"; -assert(adapter.unchanged(snapshot, fresh)); -for (const change of [ - s => s.binds.Compositor[0].key = "Super+T", - s => s.binds.Compositor.pop(), - s => s.binds.Custom[0].action = "spawn another-command", - s => s.binds.Custom.push(copy(s.binds.Custom[0])), - s => s.binds.Compositor.reverse() -]) { - const changed = copy(fresh); - change(changed); - assert(!adapter.unchanged(snapshot, changed)); - assert(adapter.changes(snapshot, changed).length); -} -assert.throws(() => adapter.inventory({...snapshot, generation: ""}), /invalid/); -assert.throws(() => adapter.inventory({...snapshot, binds: {Custom: []}}), /missing/); -assert.throws(() => adapter.inventory({...snapshot, binds: {Custom: [], Compositor: [{action: "x"}]}}), /invalid/); -assert.equal(adapter.issue(draft, fresh, false), ""); -assert.equal(adapter.issue({...draft, originalKey: "gone"}, fresh, true), "target_removed"); -assert.equal(adapter.issue({...draft, data: {...draft.data, key: "Super+Return"}}, fresh, true), "destination_occupied"); -const changed = copy(fresh); -changed.binds.Custom[0].action = "spawn external-command"; -assert.equal(adapter.issue(draft, changed, false), "target_changed"); -assert.equal(adapter.issue(draft, changed, true), ""); -const duplicate = copy(fresh); -duplicate.binds.Custom.push(copy(duplicate.binds.Custom[0])); -assert.equal(adapter.issue(draft, duplicate, true), "ambiguous_target"); -assert.equal(adapter.issue({...draft, data: {key: "Super+Q", action: "removed_action"}}, fresh, true), "invalid_action"); -assert.deepEqual(copy(adapter.argumentsFor(draft, fresh.generation)), ["dms", "keybinds", "set", "aqueous", "Super+Shift+S", "spawn dms screenshot", "--expected-generation", "after-blur", "--json"]); - -function service() { - const calls = [], results = [], events = [], deferred = []; - const c = vm.createContext({ - aqueousSession: "session", bindEditSession: "session", requiresBindReview: true, currentProvider: "aqueous", aqueousBusy: false, _aqueousRequest: 0, - _aqueousLoading: false, _loadPending: false, _pendingSavedKey: "", _rawData: copy(snapshot), - saving: false, loading: false, lastError: "", removeProcess: {running: false}, - AqueousKeybinds: adapter, I18n: {tr: s => s}, Qt: {callLater: (fn, ...args) => deferred.push(() => fn(...args))}, - AqueousService: {runJson: (args, input, callback) => calls.push({args: copy(args), callback})}, - bindSaveCompleted: ok => events.push(["completed", ok]), bindRemoved: key => events.push(["removed", key]), - _processData: () => events.push(["loaded"]) - }); - c.root = c; - methods(c, "../Services/KeybindsService.qml", ["readAqueousBinds", "_mutateAqueous", "loadBinds"]); - const flush = () => { - let n = 0; - while (deferred.length) { - assert(n++ < 10, "refresh loop"); - deferred.shift()(); - } - }; - const submit = d => c._mutateAqueous(d || copy(draft), result => results.push(result)); - return {c, calls, results, events, flush, submit}; -} -{ - const {c, calls, results, events, submit, flush} = service(); - const edit = copy(draft); - submit(edit); - edit.data.action = "spawn modified-after-click"; - assert.equal(c.aqueousBusy, true); - calls.shift().callback(fresh, ""); - const mutation = calls.shift(); - assert(mutation.args.includes("after-blur")); - assert(mutation.args.includes("spawn dms screenshot"), "mutation must capture the clicked draft"); - assert.equal(c._pendingSavedKey, "", "no success marker before acknowledgement"); - submit(); - assert.equal(results.pop().code, "busy"); - assert.equal(calls.length, 0); - mutation.callback({success: true, code: "applied", generation: "saved"}, ""); - assert.equal(results[0].success, true); - assert.equal(c.aqueousBusy, false); - assert.equal(c._pendingSavedKey, draft.data.key); - assert.deepEqual(events, [["completed", true]]); - flush(); - assert.equal(calls.shift().args[2], "show"); -} -for (const external of [changed, duplicate, {...fresh, binds: {...fresh.binds, Custom: []}}]) { - const {c, calls, results, submit} = service(); - submit(); - calls.shift().callback(external, ""); - assert.equal(results[0].code, "external_change"); - assert.equal(calls.length, 0, "changed inventory must not write"); - assert.equal(c.aqueousBusy, false); - assert.equal(c._pendingSavedKey, ""); -} -for (const operation of ["set", "remove", "reset"]) { - for (const code of ["external_change", "read_only", "uncertain"]) { - const {c, calls, results, events, submit, flush} = service(); - submit({...copy(draft), operation}); - calls.shift().callback(fresh, ""); - const response = code === "uncertain" ? null : {success: false, code}; - calls.shift().callback(response, code === "uncertain" ? "timeout" : code); - flush(); - assert.equal(results[0].code, code); - assert.equal(calls.length, 0, "failed mutation must not retry"); - assert.equal(events.length, 0, "failed mutation must not report success"); - assert.equal(c._pendingSavedKey, ""); - } -} -{ - const {c, calls, results, submit} = service(); - submit(); - calls.shift().callback(null, "helper unavailable"); - assert.equal(results[0].code, "load_failed"); - assert.equal(calls.length, 0); - assert.equal(c.aqueousBusy, false); - submit({...draft, session: "stale"}); - assert.equal(results.at(-1).code, "invalidated"); - assert.equal(calls.length, 0); -} -{ - const {c, calls, results, submit} = service(); - submit(); - c._aqueousRequest++; - c.aqueousSession = "other-session"; - calls.shift().callback(fresh, ""); - assert.equal(results.length, 0, "late preparation callback must be ignored"); - assert.equal(calls.length, 0); -} -{ - const {c, calls, results, events, submit} = service(); - submit(); - calls.shift().callback(fresh, ""); - c._aqueousRequest++; - c.aqueousSession = "other-session"; - calls.shift().callback({success: true, generation: "saved"}, ""); - assert.equal(results.length, 0, "late mutation callback must be ignored"); - assert.equal(events.length, 0); - assert.equal(c._pendingSavedKey, ""); -} -{ - const {c, calls, flush} = service(); - c.loadBinds(); c.loadBinds(); c.loadBinds(); - assert.equal(calls.length, 1); - calls.shift().callback(fresh, ""); flush(); - assert.equal(calls.length, 1, "refresh requests must coalesce into one trailing load"); - calls.shift().callback(fresh, ""); flush(); - assert.equal(calls.length, 0); - assert.equal(c._aqueousLoading, false); -} - -{ - // Execute the production save failure handler and then an unrelated data load. - const {c, events} = service(); - Object.assign(c, {provider: c.currentProvider, savedKey: "failed-key", exitCode: 1, log: {error() {}}, _maybeWarnHyprlandLegacyConf() {}, _dataVersion: 0, - bindsLoaded() {}, bindSaved: key => events.push(["saved", key]), _rawData: {}, CompositorService: {isMango: false}}); - const source = readFileSync(new URL("../Services/KeybindsService.qml", import.meta.url), "utf8"); - const start = source.indexOf(" onExited: exitCode => {", source.indexOf("id: saveProcess")); - const end = source.indexOf("\n }", start); - vm.runInContext("(function() {" + source.slice(source.indexOf("{", start) + 1, end) + "})()", c); - methods(c, "../Services/KeybindsService.qml", ["_processData"]); - c._processData(); - assert.equal(c._pendingSavedKey, ""); - assert.equal(c.savedKey, ""); - assert(!events.some(event => event[0] === "saved")); -} - -{ - const {c, events, calls} = service(); - Object.assign(c, {provider: "hyprland", savedKey: "old-provider-key", exitCode: 0, saving: true}); - const source = readFileSync(new URL("../Services/KeybindsService.qml", import.meta.url), "utf8"); - const start = source.indexOf(" onExited: exitCode => {", source.indexOf("id: saveProcess")); - const end = source.indexOf("\n }", start); - vm.runInContext("(function() {" + source.slice(source.indexOf("{", start) + 1, end) + "})()", c); - assert.equal(c.saving, false); - assert.equal(c.savedKey, ""); - assert.equal(c._pendingSavedKey, ""); - assert.equal(events.length, 0, "a previous provider's acknowledgement must not report an Aqueous save"); - assert.equal(calls.length, 0); -} - -function editor() { - const reads = [], writes = [], events = []; - const {c: svc} = service(); - methods(svc, "../Services/KeybindsService.qml", ["captureBindEdit", "updateBindEdit", "reconcileBindEdit", "bindEditError"]); - Object.assign(svc, { - bindMutationBusy: false, - saveBind: (key, data, d, cb) => writes.push({draft: copy(d), callback: cb}), - removeBind: (key, d, cb) => writes.push({draft: copy(d), callback: cb}), - resetBind: (key, d, cb) => writes.push({draft: copy(d), callback: cb}), - loadBindReview: cb => reads.push(cb), loadBinds: () => events.push("load") - }); - const c = vm.createContext({editDraft: copy(draft), reviewSnapshot: null, editBusy: false, reviewingEdit: false, editInvalidated: false, - _editRequest: 0, _editAlive: true, editError: "", I18n: {tr: s => s}, KeybindsService: svc, - confirmEditRemoval: () => events.push("confirm")}); - Object.defineProperty(c, "hasEditDraft", {get: () => c.editDraft !== null}); - c.keybindsTab = c; - methods(c, "../Modules/Settings/KeybindsTab.qml", ["beginEdit", "submitEdit", "reloadEdit", "acceptReview", "discardEdit", "updateEditDraft", "prepareRemoval"]); - return {c, reads, writes, events}; -} - -{ - const {c, reads, writes} = editor(); - c.submitEdit(); - writes.shift().callback({success: false, code: "external_change", snapshot: changed}); - const retained = copy(c.editDraft.data); - c.submitEdit(); assert.equal(writes.length, 0, "review must block Save"); - c.reloadEdit(); reads.shift()(changed, ""); - assert.deepEqual(copy(c.editDraft.data), retained); - c.acceptReview(); - assert.equal(c.reviewingEdit, false); - assert.equal(c.editDraft.originalAction, "spawn external-command"); - c.submitEdit(); - assert.equal(writes[0].draft.baseline.generation, changed.generation); - assert.deepEqual(writes[0].draft.data, retained); -} -{ - const {c, events, writes} = editor(); - c.editDraft.operation = "remove"; - c.reviewSnapshot = changed; c.reviewingEdit = true; - c.acceptReview(); - assert.deepEqual(events, ["confirm"], "reviewed removal must be confirmed again"); - assert.equal(writes.length, 0); - c.discardEdit(); - assert.equal(c.editDraft, null); - assert.equal(c.reviewSnapshot, null); -} -for (const current of [duplicate, {...fresh, binds: {...fresh.binds, Custom: []}}]) { - const {c, writes} = editor(); - c.reviewSnapshot = current; c.reviewingEdit = true; - c.acceptReview(); - assert.equal(c.reviewingEdit, true); - assert(c.editError); - assert.equal(writes.length, 0); -} -for (const provider of ["hyprland", "niri"]) { - const {c, calls} = service(); - Object.assign(c, { - currentProvider: provider, readOnly: false, saveProcess: {}, removeProcess: {}, - Actions: {isValidAction: () => true} - }); - methods(c, "../Services/KeybindsService.qml", ["saveBind", "removeBind", "resetBind"]); - c.saveBind("Super+S", {key: "Super+P", action: "spawn screenshot", desc: "Screenshot"}); - assert.deepEqual(copy(c.saveProcess.command), ["dms", "keybinds", "set", provider, "Super+P", "spawn screenshot", "--desc", "Screenshot", "--replace-key", "Super+S"]); - assert.equal(c.saveProcess.running, true); - c.removeBind("Super+P"); - assert.deepEqual(copy(c.removeProcess.command), ["dms", "keybinds", "remove", provider, "Super+P"]); - c.resetBind("Super+P"); - assert.deepEqual(copy(c.removeProcess.command), ["dms", "keybinds", "reset", provider, "Super+P"]); - assert.equal(calls.length, 0, "other providers must retain their existing subprocess flow"); -} -for (const provider of ["hyprland", "niri"]) { - const writes = []; - const c = vm.createContext({ - editDraft: null, hasEditDraft: false, expandedKey: "", showingNewBind: false, - KeybindsService: { - currentProvider: provider, requiresBindReview: false, readOnly: false, - saveBind: (key, data) => writes.push([key, copy(data)]), - captureBindEdit: () => assert.fail("ordinary edits must not capture review state") - } - }); - c.keybindsTab = c; - methods(c, "../Modules/Settings/KeybindsTab.qml", ["toggleExpanded", "startNewBind", "cancelNewBind", "saveBind"]); - c.toggleExpanded("spawn screenshot"); - assert.equal(c.expandedKey, "spawn screenshot"); - c.toggleExpanded("spawn screenshot"); - assert.equal(c.expandedKey, ""); - c.startNewBind(); - assert.equal(c.showingNewBind, true); - c.cancelNewBind(); - assert.equal(c.showingNewBind, false); - c.saveBind("Super+S", {key: "Super+P", action: "spawn screenshot"}); - assert.deepEqual(writes, [["Super+S", {key: "Super+P", action: "spawn screenshot"}]]); - assert.equal(c._editingKey, "Super+P"); - assert.equal(c.expandedKey, "spawn screenshot"); -} -{ - const {c} = service(); - methods(c, "../Services/KeybindsService.qml", ["captureBindEdit", "updateBindEdit", "reconcileBindEdit"]); - const binding = {action: draft.originalAction, keys: [{key: draft.originalKey}]}; - const captured = c.captureBindEdit(binding, draft.originalKey); - binding.keys[0].key = "changed-after-open"; - c._rawData.generation = "changed-after-open"; - assert.equal(captured.provider, "aqueous"); - assert.equal(captured.session, "session"); - assert.equal(captured.binding.keys[0].key, draft.originalKey); - assert.equal(captured.baseline.generation, snapshot.generation); - const edited = c.updateBindEdit(captured, draft.originalKey, draft.data); - const result = c.reconcileBindEdit(edited, changed); - assert.equal(result.draft.baseline.generation, changed.generation); - assert.equal(result.draft.originalAction, changed.binds.Custom[0].action); - assert.deepEqual(copy(result.draft.data), copy(draft.data)); - assert.equal(edited.baseline.generation, snapshot.generation); - assert.equal(c.reconcileBindEdit(edited, duplicate).code, "ambiguous_target"); -} -for (const operation of ["set", "remove", "reset"]) { - const {c, calls, results} = service(); - methods(c, "../Services/KeybindsService.qml", ["saveBind", "removeBind", "resetBind"]); - const edit = {...copy(draft), operation}; - const complete = result => results.push(result); - if (operation === "set") - c.saveBind(edit.originalKey, edit.data, edit, complete); - else - c[operation === "remove" ? "removeBind" : "resetBind"](edit.originalKey, edit, complete); - calls.shift().callback(fresh, ""); - assert.equal(calls[0].args[2], operation); - calls.shift().callback({success: true, generation: "saved"}, ""); - assert.equal(results[0].success, true); -} -{ - const {c, calls, submit, flush} = service(); - submit(); - c.loadBinds(); - calls.shift().callback(fresh, ""); - calls.shift().callback({success: true, generation: "saved"}, ""); - flush(); - assert.equal(calls.length, 1, "success and a pending refresh must share one read"); - calls.shift().callback(fresh, ""); - flush(); - assert.equal(calls.length, 0); -} -{ - const {c, writes} = editor(); - const before = copy(c.editDraft); - const removal = c.prepareRemoval(draft.originalKey, "remove"); - assert.equal(removal.operation, "remove"); - assert.deepEqual(copy(c.editDraft), before, "opening or cancelling removal must preserve the edit"); - assert.equal(writes.length, 0); -} -console.log("PASS: Aqueous keybind inventory, preparation, mutation results, refresh and draft review"); diff --git a/quickshell/tests/aqueous-service.test.mjs b/quickshell/tests/aqueous-service.test.mjs deleted file mode 100644 index 63c1e66c5..000000000 --- a/quickshell/tests/aqueous-service.test.mjs +++ /dev/null @@ -1,168 +0,0 @@ -import assert from "node:assert/strict"; -import {readFileSync} from "node:fs"; -import vm from "node:vm"; - -function loadMethods(context, path, names) { - const source = readFileSync(new URL(path, import.meta.url), "utf8"); - for (const name of names) { - const start = source.indexOf(" function " + name + "("); - assert.notEqual(start, -1); - const end = source.indexOf("\n }", start) + 6; - vm.runInContext(source.slice(start, end), context); - } -} -const clone = value => JSON.parse(JSON.stringify(value)); -const context = vm.createContext({maxBatchBytes: 4 * 1024 * 1024, maxModelBytes: 2 * 1024 * 1024, state: {}}); -context.root = context; -loadMethods(context, "../Services/AqueousService.qml", ["byteLength", "parseJson", "validateEntity", "reduceBatch", "acceptBatch", "commandPayload", "commandResult"]); -const caps = JSON.parse(readFileSync(new URL("fixtures/aqueous/capabilities.json", import.meta.url))); -context.capabilities = caps; -const batches = readFileSync(new URL("fixtures/aqueous/watch.ndjson", import.meta.url), "utf8").trim().split("\n").map(JSON.parse); -let live; -for (const batch of batches) { - context.acceptBatch(batch); - assert.equal(context.state.batch.modelBytes, Buffer.byteLength(JSON.stringify(context.state.batch.model))); - if (batch.upsert.some(e => e.kind === "window")) - live = clone(context.state.batch); -} -assert(live, "real fixture had no windows"); -assert.equal(context.state.batch.sequence, batches.at(-1).sequence); -const first = batches[0]; -const big = {...first, sequence: "900719925474099312345"}; -let state = context.reduceBatch(null, big); -const delta = {schema: 1, session: big.session, type: "delta", base_sequence: big.sequence, sequence: "900719925474099312999", upsert: [], removed: []}; -state = context.reduceBatch(state, delta); -assert.equal(state.sequence, delta.sequence); -assert.throws(() => context.reduceBatch(null, delta), /continuity/); -assert.throws(() => context.reduceBatch(state, delta), /continuity/); -assert.throws(() => context.reduceBatch(state, {...delta, base_sequence: state.sequence, session: "f".repeat(32)}), /continuity/); -assert.throws(() => context.reduceBatch(null, {...first, base_sequence: "1"}), /baseline/); -assert.throws(() => context.reduceBatch(null, {...first, sequence: 42}), /envelope/); -assert.throws(() => context.parseJson("{"), /JSON/); -assert.throws(() => context.parseJson('"' + "x".repeat(context.maxBatchBytes) + '"'), /4 MiB/); -const unicode = '"' + "é".repeat((context.maxBatchBytes - 2) / 2) + '"'; -assert.equal(context.byteLength(unicode), context.maxBatchBytes); -assert.equal(context.parseJson(unicode + "\n").length, (context.maxBatchBytes - 2) / 2); - -const old = context.reduceBatch(null, first); -const saved = JSON.stringify(old); -const workspace = old.upsert.find(e => e.kind === "workspace"); -const change = {schema: 1, session: old.session, type: "delta", sequence: "99999", base_sequence: old.sequence, upsert: [{...workspace, name: "🫧 日本語", extra: 1}], removed: []}; -let next = context.reduceBatch(old, change); -assert.equal(JSON.stringify(old), saved, "reducer mutated a published model"); -assert.equal(next.model["workspace:" + workspace.id].name, "🫧 日本語"); -next = context.reduceBatch(next, {...change, base_sequence: next.sequence, sequence: "100000", upsert: [{...workspace, name: "updated"}]}); -assert.equal(next.model["workspace:" + workspace.id].extra, undefined, "upsert merged old optional fields"); -assert.throws(() => context.reduceBatch(old, {...change, removed: ["output:" + workspace.output]}), /dangling/); -assert.equal(JSON.stringify(old), saved, "failed transaction mutated published state"); -assert.throws(() => context.reduceBatch(old, {...change, upsert: [workspace, workspace]}), /duplicate/); -assert.throws(() => context.reduceBatch(old, {...change, upsert: [{...workspace, number: "1"}]}), /invalid/); -assert.throws(() => context.reduceBatch(old, {...change, upsert: [{...workspace, kind: "constructor"}]}), /kind/); -assert.equal(context.reduceBatch(next, first).sequence, first.sequence, "snapshot did not reset model"); -// Move every association away from one output, then remove it in the same batch. -const outputs = old.upsert.filter(e => e.kind === "output"); -const migrating = old.upsert.filter(e => e.output === outputs[0].id).map(e => ({...e, output: outputs[1].id})); -const migrated = context.reduceBatch(old, {...change, upsert: migrating, removed: ["output:" + outputs[0].id]}); -assert.equal(migrated.model["output:" + outputs[0].id], undefined); - -const bounded = vm.createContext({maxBatchBytes: 4 * 1024 * 1024, maxModelBytes: old.modelBytes + 3000, state: {}, capabilities: caps}); -loadMethods(bounded, "../Services/AqueousService.qml", ["byteLength", "parseJson", "validateEntity", "reduceBatch", "acceptBatch"]); -bounded.acceptBatch(first); -const updateModel = (upsert, removed = []) => { - const previous = bounded.state.batch; - const line = JSON.stringify({...change, sequence: String(Number(previous.sequence) + 1), base_sequence: previous.sequence, upsert, removed}); - assert(Buffer.byteLength(line) < bounded.maxBatchBytes, "individual batch exceeded the test limit"); - bounded.acceptBatch(JSON.parse(line)); - assert.equal(bounded.state.batch.modelBytes, Buffer.byteLength(JSON.stringify(bounded.state.batch.model))); -}; -const added = {...workspace, id: 'extra-🫧"\\', name: "日".repeat(300)}; -updateModel([added]); -updateModel([{...added, name: "short"}]); -updateModel([added]); -updateModel([], ["workspace:" + added.id, "workspace:absent"]); -assert.equal(bounded.state.batch.modelBytes, old.modelBytes); -assert.deepEqual(clone(bounded.state.batch.entityBytes), clone(old.entityBytes)); -updateModel([added]); -updateModel([{...added, id: "second"}]); -const beforeOverflow = JSON.stringify(bounded.state); -assert.throws(() => updateModel([{...added, id: "third"}]), /model exceeds/); -assert.equal(JSON.stringify(bounded.state), beforeOverflow, "overflow changed published model or byte accounting"); -const limit = bounded.maxModelBytes; -bounded.maxModelBytes = bounded.state.batch.modelBytes; -updateModel([]); -assert.throws(() => updateModel([{...added, name: added.name + "x"}]), /model exceeds/); -bounded.maxModelBytes = limit; -// Removing another entity in the same batch can make room for an upsert. -updateModel([{...added, name: added.name + "x".repeat(1000)}], ["workspace:second"]); -bounded.acceptBatch(first); -assert.equal(bounded.state.batch.modelBytes, old.modelBytes, "snapshot retained old byte accounting"); -assert.deepEqual(clone(bounded.state.batch.entityBytes), clone(old.entityBytes)); - -Object.assign(context, { - available: true, locked: false, session: live.session, entities: live.upsert, - workspaces: live.upsert.filter(e => e.kind === "workspace"), outputs: live.upsert.filter(e => e.kind === "output"), - seats: live.upsert.filter(e => e.kind === "seat"), capabilities: {...caps, commands: true, keyboard: true, overview: true} -}); -context.seat = context.seats[0]; -const window = live.upsert.find(e => e.kind === "window" && e.can_activate); -const args = (action, fields) => clone(context.commandPayload(action, fields)); -assert.deepEqual(args("window.activate", {id: window.id}), {action: "window.activate", fields: {id: window.id, seat: context.seat.id}}); -assert.deepEqual(args("window.move", {id: window.id, output: context.outputs[0].id}), {action: "window.move", fields: {id: window.id, output: context.outputs[0].id}}); -assert.equal(args("workspace.rename", {id: workspace.id, name: "a; $(echo unsafe)"}).fields.name, "a; $(echo unsafe)"); -assert.throws(() => args("window.close", {id: window.id, session: "old"}), /stale session/); -assert.throws(() => args("window.close", {id: "removed"}), /not_found/); -assert.throws(() => args("window.fullscreen", {id: window.id, value: "true"}), /missing state/); -assert.throws(() => args("workspace.rename", {id: workspace.id, name: "a\nb"}), /name/); -assert.throws(() => args("window.move", {id: window.id, output: "missing"}), /not_found/); -assert.throws(() => args("keyboard.set", {group: "removed", index: 0}), /group/); -context.seats = [...context.seats, {...context.seat, id: "seat2"}]; -context.seat = null; -assert.throws(() => args("window.activate", {id: window.id}), /ambiguous_seat/); -assert.equal(args("window.activate", {id: window.id, seat: context.seats[0].id}).fields.seat, context.seats[0].id); -context.locked = true; -assert.throws(() => args("window.close", {id: window.id}), /locked/); -context.locked = false; -context.capabilities.commands = false; -assert.throws(() => args("window.close", {id: window.id}), /unsupported/); -context.available = false; -assert.throws(() => args("window.close", {id: window.id}), /stale/); -assert.equal(context.commandResult("window.close", {ok: true, status: "accepted", sequence: "9007199254740993"}, ""), ""); -assert(context.commandResult("window.activate", {ok: true, status: "accepted", sequence: "1"}, "")); -assert(context.commandResult("session.exit", null, "EOF")); -assert(context.commandResult("session.exit", {ok: true, status: "accepted", sequence: 1}, "")); -assert(context.commandResult("window.close", {ok: true, status: "accepted", sequence: "1"}, "exit status 1")); - -const calls = []; -const settings = vm.createContext({busy: false, CompositorService: {isAqueous: true}, log: {warn() {}}, Quickshell: {env: name => name === "HOME" ? "/private/home" : ""}, - AqueousService: {runJson: (args, input, callback) => calls.push({args: clone(args), input, callback})}}); -settings.root = settings; -loadMethods(settings, "../Services/AqueousConfigService.qml", ["requireCapabilities", "helper", "request"]); -const replies = []; -const helperCaps = ["schema_fields", "validate", "stdin_requests", "generation_check", "atomic_file_replace", "shell_dms", "cursor_sync"]; -const result = {ok: true, protocol: 1, capabilities: helperCaps, generation: "a"}; -settings.request("snapshot", null, (...reply) => replies.push(reply)); -assert.deepEqual(calls[0].args, ["aqueous-config", "snapshot", "--shell", "dms"]); -assert.equal(calls[0].input, null); -calls.shift().callback(result, ""); -assert.equal(replies[0][0].generation, "a"); -const draft = {expected_generation: "a", sync_cursor: true, changes: [{id: "cursor_size", value: 24}]}; -settings.request("apply", draft, (...reply) => replies.push(reply)); -draft.changes[0].value = 96; -assert.equal(calls[0].args[1], "version"); -calls.shift().callback(result, ""); -assert.equal(calls[0].args[1], "validate"); -const input = calls[0].input; -assert.equal(JSON.parse(input).changes[0].value, 24, "async helper request captured a modified draft"); -assert.equal(JSON.parse(input).expected_generation, "a"); -assert.equal(JSON.parse(input).backup_dir, "/private/home/.config/DankMaterialShell/aqueous-backups"); -calls.shift().callback({ok: true}, ""); -assert.equal(calls[0].args[1], "apply"); -assert.equal(calls[0].input, input, "apply differed from validated request"); -calls.shift().callback({ok: false, code: "external_change"}, "external_change"); -assert(replies.at(-1)[1].includes("external_change")); -assert.equal(calls.length, 0, "failed mutation was retried"); -settings.request("apply", draft, (...reply) => replies.push(reply)); -calls.shift().callback({...result, capabilities: []}, ""); -assert(replies.at(-1)[1].includes("unsupported")); -assert.equal(calls.length, 0); -process.stdout.write("PASS: production QML reducer, identity, atomicity, commands and helper generation flow\n"); diff --git a/quickshell/tests/display-apply.test.mjs b/quickshell/tests/display-apply.test.mjs deleted file mode 100644 index 118454e4a..000000000 --- a/quickshell/tests/display-apply.test.mjs +++ /dev/null @@ -1,105 +0,0 @@ -import assert from "node:assert/strict"; -import {readFileSync} from "node:fs"; -import vm from "node:vm"; - -function loadMethods(context, path, names) { - const source = readFileSync(new URL(path, import.meta.url), "utf8"); - for (const name of names) { - const start = source.indexOf(" function " + name + "("); - assert.notEqual(start, -1); - const end = source.indexOf("\n }", start) + 6; - vm.runInContext(source.slice(start, end), context); - } -} - -const callbacks = []; -const context = vm.createContext({ - outputs: {}, _cancelOutputWrite: null, - CompositorService: {compositor: "aqueous"}, - WlrOutputService: {applyOutputsConfig: (_, __, callback) => callbacks.push(callback)}, -}); -context.root = context; -loadMethods(context, "../Modules/Settings/DisplayConfig/DisplayConfigState.qml", ["backendWriteOutputsConfig"]); -const results = []; -context.backendWriteOutputsConfig({}, success => results.push(["first", success])); -assert.equal(results.length, 0, "UI reported success before backend response"); -callbacks[0](false); -assert.deepEqual(results, [["first", false]]); -context.backendWriteOutputsConfig({}, success => results.push(["second", success])); -context.backendWriteOutputsConfig({}, success => results.push(["third", success])); -assert.deepEqual(results.at(-1), ["second", false], "superseded operation did not cancel"); -callbacks[1](true); -assert.equal(results.length, 2, "late completion changed a superseded operation"); -callbacks[2](true); -callbacks[2](false); -assert.deepEqual(results.at(-1), ["third", true]); -assert.equal(results.length, 3, "callback fired twice"); - -// Existing compositor writers retain independent callbacks and are not superseded. -const mangoCallbacks = []; -context.CompositorService.compositor = "mango"; -context.MangoService = {generateOutputsConfig: (_, callback) => mangoCallbacks.push(callback)}; -const mangoResults = []; -context.backendWriteOutputsConfig({}, result => mangoResults.push(["first", result])); -context.backendWriteOutputsConfig({}, result => mangoResults.push(["second", result])); -assert.equal(mangoResults.length, 0); -mangoCallbacks[1](true); mangoCallbacks[0](false); -assert.deepEqual(mangoResults, [["second", true], ["first", false]]); - -const requests = []; -const timers = []; -const transport = vm.createContext({ - isConnected: true, pendingRequests: {}, clipboardRequestIds: {}, requestIdCounter: 0, - log: {warn() {}, debug() {}}, requestSocket: {send: request => requests.push(request)}, - requestTimeoutComponent: {createObject: (_, properties) => { - const timer = {...properties, start() {}, stop() {}, destroy() { this.destroyed = true; }}; - timers.push(timer); - return timer; - }}, -}); -transport.root = transport; -loadMethods(transport, "../Services/DMSService.qml", ["sendRequest", "handleResponse", "failPendingRequests"]); -const replies = []; -transport.sendRequest("wlroutput.applyConfiguration", {}, response => replies.push(response), 5000); -assert.equal(replies.length, 0); -assert.equal(timers[0].interval, 5000); -transport.handleResponse({id: requests[0].id, error: "timeout"}); -transport.handleResponse({id: requests[0].id, result: {success: true}}); -assert.equal(replies.length, 1, "late reply after timeout was dispatched"); -assert.equal(replies[0].error, "timeout"); -assert.equal(timers[0].destroyed, true); -transport.sendRequest("wlroutput.applyConfiguration", {}, response => replies.push(response), 5000); -transport.failPendingRequests(); -assert.equal(replies.length, 2); -assert.equal(timers[1].destroyed, true, "disconnect retained timeout timer"); - -const displayAdapter = vm.createContext({}); -vm.runInContext(readFileSync(new URL("../Common/AqueousDisplays.js", import.meta.url), "utf8").replace(".pragma library", ""), displayAdapter); -const outputReplies = []; -const previews = []; -let helperCalls = 0; -const previewContext = vm.createContext({ - aqueousPreview: null, validatingConfig: false, validationError: "", - AqueousService: {session: "session"}, AqueousDisplays: displayAdapter, - I18n: {tr: value => value}, ToastService: {showError() {}}, - freshAqueousOutputs: callback => outputReplies.push(callback), - changesApplied: value => previews.push(value), - AqueousConfigService: {apply() { helperCalls++; }}, -}); -previewContext.root = previewContext; -loadMethods(previewContext, "../Modules/Settings/DisplayConfig/DisplayConfigState.qml", ["aqueousDisplayError", "observeAqueousPreview", "finishAqueousPreview"]); -const mode = {id: "mode", width: 1280, height: 720, refresh: 60000}; -const original = {id: "head", name: "DP-1", enabled: true, x: 0, y: 0, scale: 1, transform: 0, currentMode: mode, modes: [mode]}; -const applied = {...original, x: 100}; -const preview = {session: "session", original: [original], heads: displayAdapter.heads([applied]), fingerprint: null}; -previewContext.aqueousPreview = preview; -previewContext.observeAqueousPreview(preview, ["position"], "uncertain timeout"); -assert.equal(previews.length, 0); -outputReplies.shift()([applied], ""); -assert.equal(previews.length, 1, "committed preview was not recovered after timeout"); -assert.equal(preview.fingerprint, displayAdapter.fingerprint([applied])); -previewContext.finishAqueousPreview(true); -outputReplies.shift()([{...original, x: 200}], ""); -assert.equal(helperCalls, 0, "saved a preview after another client changed live state"); -assert.equal(previewContext.aqueousPreview, preview, "conflict discarded the draft"); -process.stdout.write("PASS: actual display callbacks, supersession, timeout, late response and disconnect cleanup\n"); diff --git a/quickshell/tests/fixtures/aqueous/README.md b/quickshell/tests/fixtures/aqueous/README.md deleted file mode 100644 index f7fa6e2f5..000000000 --- a/quickshell/tests/fixtures/aqueous/README.md +++ /dev/null @@ -1 +0,0 @@ -Fixtures captured from Aqueous master `2d8b07fb1ff2354ea0c3acddd5cc84ceb823d6eb` using its isolated native `test-shell-integration.py` harness on 2026-09-05. Built with Zig 0.16.0, patched wlroots 0.20.2, XWayland enabled, Vulkan effects disabled. Session token replaced consistently; runtime identities and synthetic test titles preserved. These are protocol fixtures, not physical-output verification. diff --git a/quickshell/tests/fixtures/aqueous/capabilities.json b/quickshell/tests/fixtures/aqueous/capabilities.json deleted file mode 100644 index d52d89149..000000000 --- a/quickshell/tests/fixtures/aqueous/capabilities.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "schema": 1, - "session": "0123456789abcdef0123456789abcdef", - "max_batch_bytes": 4194304, - "state": true, - "commands": true, - "keyboard": true, - "overview": true, - "shortcut_inhibition": true, - "geometry": "committed-content-global-logical" -} diff --git a/quickshell/tests/fixtures/aqueous/hello.json b/quickshell/tests/fixtures/aqueous/hello.json deleted file mode 100644 index 170085bee..000000000 --- a/quickshell/tests/fixtures/aqueous/hello.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "ipc": 1, - "id": "1", - "ok": true, - "result": { - "session": "0123456789abcdef0123456789abcdef", - "schema": 1, - "max_batch_bytes": 4194304, - "max_request_bytes": 65536, - "max_frame_bytes": 4259840, - "max_pending_requests": 1, - "capabilities": { - "state": true, - "commands": true, - "keyboard": true, - "overview": true, - "shortcut_inhibition": true - } - } -} diff --git a/quickshell/tests/fixtures/aqueous/watch.ndjson b/quickshell/tests/fixtures/aqueous/watch.ndjson deleted file mode 100644 index 02379d057..000000000 --- a/quickshell/tests/fixtures/aqueous/watch.ndjson +++ /dev/null @@ -1,42 +0,0 @@ -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"1","base_sequence":null,"type":"snapshot","upsert":[{"kind":"workspace","id":"12","output":"2","name":"3","number":3,"active":false,"urgent":false},{"kind":"workspace","id":"2","output":"1","name":"2","number":2,"active":false,"urgent":false},{"kind":"session","id":"session","locked":false,"default_seat":"default","overview_output":null,"overview_window":null},{"kind":"workspace","id":"11","output":"2","name":"2","number":2,"active":false,"urgent":false},{"kind":"output","id":"1","name":"HEADLESS-2","enabled":true,"powered":true,"bounds":{"x":0,"y":0,"width":1280,"height":720},"usable_bounds":{"x":0,"y":0,"width":1280,"height":720},"scale":1,"transform":"normal","active_workspace":"1"},{"kind":"workspace","id":"18","output":"2","name":"9","number":9,"active":false,"urgent":false},{"kind":"workspace","id":"16","output":"2","name":"7","number":7,"active":false,"urgent":false},{"kind":"workspace","id":"15","output":"2","name":"6","number":6,"active":false,"urgent":false},{"kind":"workspace","id":"10","output":"2","name":"1","number":1,"active":true,"urgent":false},{"kind":"workspace","id":"5","output":"1","name":"5","number":5,"active":false,"urgent":false},{"kind":"workspace","id":"9","output":"1","name":"9","number":9,"active":false,"urgent":false},{"kind":"workspace","id":"4","output":"1","name":"4","number":4,"active":false,"urgent":false},{"kind":"seat","id":"default","output":"1","window":null,"focus_kind":"none","keyboard":null},{"kind":"workspace","id":"3","output":"1","name":"3","number":3,"active":false,"urgent":false},{"kind":"workspace","id":"14","output":"2","name":"5","number":5,"active":false,"urgent":false},{"kind":"workspace","id":"7","output":"1","name":"7","number":7,"active":false,"urgent":false},{"kind":"workspace","id":"1","output":"1","name":"1","number":1,"active":true,"urgent":false},{"kind":"workspace","id":"6","output":"1","name":"6","number":6,"active":false,"urgent":false},{"kind":"workspace","id":"8","output":"1","name":"8","number":8,"active":false,"urgent":false},{"kind":"workspace","id":"13","output":"2","name":"4","number":4,"active":false,"urgent":false},{"kind":"workspace","id":"17","output":"2","name":"8","number":8,"active":false,"urgent":false},{"kind":"output","id":"2","name":"HEADLESS-1","enabled":true,"powered":true,"bounds":{"x":1280,"y":0,"width":1280,"height":720},"usable_bounds":{"x":1280,"y":0,"width":1280,"height":720},"scale":1,"transform":"normal","active_workspace":"10"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"2","base_sequence":"1","type":"delta","upsert":[{"kind":"workspace","id":"12","output":"2","name":"same \"name\"","number":3,"active":false,"urgent":false}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"3","base_sequence":"2","type":"delta","upsert":[{"kind":"workspace","id":"12","output":"2","name":"same \"name\"","number":3,"active":true,"urgent":false},{"kind":"workspace","id":"10","output":"2","name":"1","number":1,"active":false,"urgent":false},{"kind":"seat","id":"default","output":"2","window":null,"focus_kind":"none","keyboard":null},{"kind":"output","id":"2","name":"HEADLESS-1","enabled":true,"powered":true,"bounds":{"x":1280,"y":0,"width":1280,"height":720},"usable_bounds":{"x":1280,"y":0,"width":1280,"height":720},"scale":1,"transform":"normal","active_workspace":"12"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"4","base_sequence":"3","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":10,"y":10,"width":628,"height":700},"outer_geometry":{"x":8,"y":8,"width":632,"height":704},"focused":false,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":false,"can_maximize":false,"can_activate":true},{"kind":"keyboard","id":"1","seat":"default","layouts":["English (US)","German"],"index":0},{"kind":"keyboard_device","id":"1","name":"wlr_virtual_keyboard_v1","seat":"default","group":"1","virtual":true},{"kind":"seat","id":"default","output":"2","window":null,"focus_kind":"none","keyboard":"1"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"5","base_sequence":"4","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":10,"y":10,"width":628,"height":700},"outer_geometry":{"x":8,"y":8,"width":632,"height":704},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":false,"can_maximize":false,"can_activate":true},{"kind":"seat","id":"default","output":"1","window":"ca89b2eb6850b81d12b92763969d82ca","focus_kind":"window","keyboard":"1"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"6","base_sequence":"5","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":261,"y":149,"width":758,"height":422},"outer_geometry":{"x":259,"y":147,"width":762,"height":426},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"7","base_sequence":"6","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":0,"y":0,"width":758,"height":422},"outer_geometry":{"x":0,"y":0,"width":758,"height":422},"focused":false,"visible":false,"floating":false,"minimized":true,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"minimized","can_minimize":true,"can_maximize":true,"can_activate":true},{"kind":"seat","id":"default","output":"1","window":null,"focus_kind":"none","keyboard":"1"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"8","base_sequence":"7","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":261,"y":149,"width":758,"height":422},"outer_geometry":{"x":259,"y":147,"width":762,"height":426},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true},{"kind":"seat","id":"default","output":"1","window":"ca89b2eb6850b81d12b92763969d82ca","focus_kind":"window","keyboard":"1"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"9","base_sequence":"8","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":0,"y":0,"width":1280,"height":720},"outer_geometry":{"x":-2,"y":-2,"width":1284,"height":724},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":true,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"maximized","can_minimize":true,"can_maximize":true,"can_activate":true}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"10","base_sequence":"9","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":261,"y":149,"width":758,"height":422},"outer_geometry":{"x":259,"y":147,"width":762,"height":426},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"11","base_sequence":"10","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":0,"y":0,"width":1280,"height":720},"outer_geometry":{"x":0,"y":0,"width":1280,"height":720},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":true,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"12","base_sequence":"11","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":261,"y":149,"width":758,"height":422},"outer_geometry":{"x":259,"y":147,"width":762,"height":426},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"13","base_sequence":"12","type":"delta","upsert":[{"kind":"keyboard","id":"1","seat":"default","layouts":["English (US)","German"],"index":1}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"14","base_sequence":"13","type":"delta","upsert":[{"kind":"keyboard","id":"1","seat":"default","layouts":["English (US)","German"],"index":0}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"15","base_sequence":"14","type":"delta","upsert":[{"kind":"keyboard","id":"1","seat":"default","layouts":["English (US)","German"],"index":1}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"16","base_sequence":"15","type":"delta","upsert":[{"kind":"keyboard","id":"1","seat":"default","layouts":["English (US)","German"],"index":0}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"17","base_sequence":"16","type":"delta","upsert":[{"kind":"session","id":"session","locked":false,"default_seat":"default","overview_output":"1","overview_window":"ca89b2eb6850b81d12b92763969d82ca"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"18","base_sequence":"17","type":"delta","upsert":[{"kind":"session","id":"session","locked":false,"default_seat":"default","overview_output":null,"overview_window":null}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"19","base_sequence":"18","type":"delta","upsert":[{"kind":"keyboard_device","id":"2","name":"wlr_virtual_keyboard_v1","seat":"default","group":"2","virtual":true},{"kind":"keyboard","id":"2","seat":"default","layouts":["English (US)","German"],"index":0},{"kind":"seat","id":"default","output":"1","window":"ca89b2eb6850b81d12b92763969d82ca","focus_kind":"window","keyboard":"2"},{"kind":"window","id":"79f94f50c1aaf0f41951709e38ae4033","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":293,"y":181,"width":758,"height":422},"outer_geometry":{"x":291,"y":179,"width":762,"height":426},"focused":false,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"20","base_sequence":"19","type":"delta","upsert":[{"kind":"keyboard","id":"2","seat":"default","layouts":["English (US)","German"],"index":1}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"21","base_sequence":"20","type":"delta","upsert":[{"kind":"window","id":"79f94f50c1aaf0f41951709e38ae4033","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"changed 🐟 \"quoted\"","workspace":"1","output":"1","geometry":{"x":293,"y":181,"width":758,"height":422},"outer_geometry":{"x":291,"y":179,"width":762,"height":426},"focused":false,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"22","base_sequence":"21","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":261,"y":149,"width":758,"height":422},"outer_geometry":{"x":259,"y":147,"width":762,"height":426},"focused":false,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true},{"kind":"seat","id":"default","output":"1","window":"79f94f50c1aaf0f41951709e38ae4033","focus_kind":"window","keyboard":"2"},{"kind":"window","id":"79f94f50c1aaf0f41951709e38ae4033","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"changed 🐟 \"quoted\"","workspace":"1","output":"1","geometry":{"x":293,"y":181,"width":758,"height":422},"outer_geometry":{"x":291,"y":179,"width":762,"height":426},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"23","base_sequence":"22","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":261,"y":149,"width":758,"height":422},"outer_geometry":{"x":259,"y":147,"width":762,"height":426},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true},{"kind":"seat","id":"default","output":"1","window":"ca89b2eb6850b81d12b92763969d82ca","focus_kind":"window","keyboard":"2"},{"kind":"window","id":"79f94f50c1aaf0f41951709e38ae4033","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"changed 🐟 \"quoted\"","workspace":"1","output":"1","geometry":{"x":293,"y":181,"width":758,"height":422},"outer_geometry":{"x":291,"y":179,"width":762,"height":426},"focused":false,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"24","base_sequence":"23","type":"delta","upsert":[{"kind":"seat","id":"default","output":"1","window":"ca89b2eb6850b81d12b92763969d82ca","focus_kind":"window","keyboard":"1"}],"removed":["keyboard_device:2","keyboard:2","window:79f94f50c1aaf0f41951709e38ae4033"]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"25","base_sequence":"24","type":"delta","upsert":[{"kind":"keyboard","id":"1","seat":"default","layouts":["English (US)","French","German"],"index":0}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"26","base_sequence":"25","type":"delta","upsert":[{"kind":"keyboard","id":"1","seat":"default","layouts":["English (US)","French","German"],"index":2}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"27","base_sequence":"26","type":"delta","upsert":[{"kind":"workspace","id":"12","output":"2","name":"one","number":3,"active":true,"urgent":false}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"28","base_sequence":"27","type":"delta","upsert":[{"kind":"workspace","id":"12","output":"2","name":"two","number":3,"active":true,"urgent":false}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"29","base_sequence":"28","type":"delta","upsert":[{"kind":"workspace","id":"12","output":"2","name":"three","number":3,"active":true,"urgent":false}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"30","base_sequence":"29","type":"delta","upsert":[{"kind":"output","id":"1","name":"HEADLESS-2","enabled":true,"powered":true,"bounds":{"x":0,"y":0,"width":1280,"height":720},"usable_bounds":{"x":20,"y":20,"width":1240,"height":680},"scale":1,"transform":"normal","active_workspace":"1"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"31","base_sequence":"30","type":"delta","upsert":[{"kind":"output","id":"1","name":"HEADLESS-2","enabled":true,"powered":true,"bounds":{"x":0,"y":0,"width":1280,"height":720},"usable_bounds":{"x":0,"y":0,"width":1280,"height":720},"scale":1,"transform":"normal","active_workspace":"1"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"32","base_sequence":"31","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":261,"y":149,"width":758,"height":422},"outer_geometry":{"x":259,"y":147,"width":762,"height":426},"focused":false,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true},{"kind":"session","id":"session","locked":true,"default_seat":"default","overview_output":null,"overview_window":null},{"kind":"seat","id":"default","output":"1","window":null,"focus_kind":"none","keyboard":"1"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"33","base_sequence":"32","type":"delta","upsert":[{"kind":"seat","id":"default","output":"1","window":null,"focus_kind":"lock_surface","keyboard":"1"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"34","base_sequence":"33","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"1","output":"1","geometry":{"x":261,"y":149,"width":758,"height":422},"outer_geometry":{"x":259,"y":147,"width":762,"height":426},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":true,"can_maximize":true,"can_activate":true},{"kind":"session","id":"session","locked":false,"default_seat":"default","overview_output":null,"overview_window":null},{"kind":"seat","id":"default","output":"1","window":"ca89b2eb6850b81d12b92763969d82ca","focus_kind":"window","keyboard":"1"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"35","base_sequence":"34","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"2","output":"1","geometry":{"x":261,"y":149,"width":758,"height":422},"outer_geometry":{"x":259,"y":147,"width":762,"height":426},"focused":false,"visible":false,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":false,"can_maximize":false,"can_activate":true},{"kind":"seat","id":"default","output":"1","window":null,"focus_kind":"none","keyboard":"1"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"36","base_sequence":"35","type":"delta","upsert":[{"kind":"workspace","id":"2","output":"1","name":"2","number":2,"active":true,"urgent":false},{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"2","output":"1","geometry":{"x":10,"y":10,"width":628,"height":700},"outer_geometry":{"x":8,"y":8,"width":632,"height":704},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":false,"can_maximize":false,"can_activate":true},{"kind":"output","id":"1","name":"HEADLESS-2","enabled":true,"powered":true,"bounds":{"x":0,"y":0,"width":1280,"height":720},"usable_bounds":{"x":0,"y":0,"width":1280,"height":720},"scale":1,"transform":"normal","active_workspace":"2"},{"kind":"seat","id":"default","output":"1","window":"ca89b2eb6850b81d12b92763969d82ca","focus_kind":"window","keyboard":"1"},{"kind":"workspace","id":"1","output":"1","name":"1","number":1,"active":false,"urgent":false}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"37","base_sequence":"36","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"12","output":"2","geometry":{"x":1290,"y":10,"width":628,"height":700},"outer_geometry":{"x":1288,"y":8,"width":632,"height":704},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":false,"can_maximize":false,"can_activate":true}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"38","base_sequence":"37","type":"delta","upsert":[{"kind":"seat","id":"default","output":"2","window":"ca89b2eb6850b81d12b92763969d82ca","focus_kind":"window","keyboard":"1"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"39","base_sequence":"38","type":"delta","upsert":[{"kind":"session","id":"session","locked":false,"default_seat":"default","overview_output":"2","overview_window":"ca89b2eb6850b81d12b92763969d82ca"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"40","base_sequence":"39","type":"delta","upsert":[{"kind":"session","id":"session","locked":false,"default_seat":"default","overview_output":null,"overview_window":null}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"41","base_sequence":"40","type":"delta","upsert":[{"kind":"window","id":"ca89b2eb6850b81d12b92763969d82ca","backend":"xdg","app_id":"aq-shell-test","class":null,"title":"same title","workspace":"12","output":"2","geometry":{"x":-2990,"y":-90,"width":276,"height":1004},"outer_geometry":{"x":-2992,"y":-92,"width":280,"height":1008},"focused":true,"visible":true,"floating":false,"minimized":false,"maximized":false,"fullscreen":false,"skip_taskbar":false,"skip_switcher":false,"always_above":false,"always_below":false,"snapped":false,"fixed_position":false,"layout":"tiled","can_minimize":false,"can_maximize":false,"can_activate":true},{"kind":"output","id":"2","name":"HEADLESS-1","enabled":true,"powered":true,"bounds":{"x":-3000,"y":-100,"width":576,"height":1024},"usable_bounds":{"x":-3000,"y":-100,"width":576,"height":1024},"scale":1.25,"transform":"90","active_workspace":"12"}],"removed":[]} -{"schema":1,"session":"0123456789abcdef0123456789abcdef","sequence":"42","base_sequence":"41","type":"delta","upsert":[{"kind":"seat","id":"default","output":"2","window":null,"focus_kind":"none","keyboard":null}],"removed":["window:ca89b2eb6850b81d12b92763969d82ca","keyboard:1","keyboard_device:1"]} diff --git a/quickshell/tests/workspace-settings.test.mjs b/quickshell/tests/workspace-settings.test.mjs deleted file mode 100644 index 8be9a635a..000000000 --- a/quickshell/tests/workspace-settings.test.mjs +++ /dev/null @@ -1,109 +0,0 @@ -import assert from "node:assert/strict"; -import {readFileSync} from "node:fs"; -import vm from "node:vm"; - -const read = path => readFileSync(new URL(path, import.meta.url), "utf8"); -function methods(context, source, names) { - for (const name of names) { - const start = source.indexOf(" function " + name + "("); - assert.notEqual(start, -1); - vm.runInContext(source.slice(start, source.indexOf("\n }", start) + 6), context); - } -} -const settings = read("../Modules/Settings/WorkspacesTab.qml"); -function setting(context, key, property) { - const start = settings.indexOf('settingKey: "' + key + '"'); - const row = settings.slice(start, settings.indexOf("onToggled:", start)); - const expression = row.match(new RegExp(property + ": ([^\\n]+)"))?.[1]; - return expression ? vm.runInContext(expression, context) : true; -} -const compositor = {}; -let forced = false; -const conditions = vm.createContext({CompositorService: compositor, AqueousService: {available: true}, Quickshell: {env: () => forced ? "1" : "0"}}); -const search = read("../Services/SettingsSearchService.qml"); -const conditionStart = search.indexOf("readonly property var conditionMap: (") + "readonly property var conditionMap: ".length; -const conditionMap = vm.runInContext(search.slice(conditionStart, search.indexOf("})", conditionStart) + 2), conditions); -const index = JSON.parse(read("../translations/settings_search_index.json")); -for (const backend of ["Aqueous", "Niri", "Hyprland", "Mango", "Sway", "Scroll", "Miracle", "Labwc"]) { - for (const key of Object.keys(compositor)) delete compositor[key]; - compositor["is" + backend] = true; - for (const key of ["showWorkspaceApps", "showOccupiedWorkspacesOnly", "reverseScrolling", "workspaceFollowFocus"]) { - const visible = setting(conditions, key, "visible"); - const expected = ["Aqueous", "Niri", "Hyprland", "Mango", ...(key === "workspaceFollowFocus" ? ["Sway", "Scroll", "Miracle"] : [])].includes(backend); - assert.equal(!!visible, expected, backend + ": " + key); - assert.equal(!!conditionMap[index.find(e => e.section === key).conditionKey](), expected, "search disagrees with settings"); - } -} -for (const key of Object.keys(compositor)) delete compositor[key]; -compositor.isAqueous = true; -for (const [available, force, expected] of [[true, false, true], [true, true, false], [false, false, false]]) { - conditions.AqueousService.available = available; forced = force; - for (const key of ["showWorkspaceApps", "showOccupiedWorkspacesOnly"]) - assert.equal(setting(conditions, key, "enabled"), expected); - assert.equal(setting(conditions, "workspaceFollowFocus", "enabled"), true); - assert.equal(setting(conditions, "reverseScrolling", "enabled"), true); -} -const appearance = read("../Modules/Settings/WorkspaceAppearanceCard.qml"); -assert.equal(vm.runInContext(appearance.match(/workspaceStateColorsVisible: ([^\n]+)/)[1], conditions), true); - -const service = vm.createContext({available: true, locked: false, session: "session-a", capabilities: {commands: true}, - seat: {id: "seat0", output: "output-a"}, outputs: [], workspaces: [ - {kind: "workspace", id: "one", output: "output-a", active: true, name: "Duplicate", aqueousSession: "session-a"}, - {kind: "workspace", id: "two", output: "output-b", active: true, name: "Duplicate", aqueousSession: "session-a"} - ]}); -service.entities = service.workspaces; service.seats = [service.seat]; -methods(service, read("../Services/AqueousService.qml"), ["commandPayload", "byteLength"]); -const callbacks = [], commands = [], errors = [], legacy = []; -service.command = (action, fields, callback) => { - try { commands.push(service.commandPayload(action, fields)); callbacks.push(callback); } - catch (error) { errors.push(String(error)); callback(false); } -}; -const modal = vm.createContext({CompositorService: {isAqueous: true}, AqueousService: service, - aqueousWorkspace: null, renaming: false, nameInput: {text: "", forceActiveFocus() {}}, - Qt: {callLater: fn => fn()}, I18n: {tr: text => text}, ToastService: {showError: (...args) => errors.push(args)}, - NiriService: {renameWorkspace: name => legacy.push(name)}, HyprlandService: {renameWorkspace: name => legacy.push(name)}}); -modal.root = modal; -const modalSource = read("../Modals/WorkspaceRenameModal.qml"); -methods(modal, modalSource, ["show", "hide", "submitAndClose", "renameWorkspace"]); -const visibleStart = modalSource.indexOf(" onVisibleChanged: {") + " onVisibleChanged: ".length; -vm.runInContext("function visibilityChanged() " + modalSource.slice(visibleStart, modalSource.indexOf("\n }", visibleStart) + 6), modal); -let visible = false; -Object.defineProperty(modal, "visible", {get: () => visible, set: value => { visible = value; modal.visibilityChanged(); }}); -assert(modal.show("ignored")); -assert.equal(modal.nameInput.text, "Duplicate"); -assert.equal(modal.aqueousWorkspace.id, "one"); -service.seat.output = "output-b"; -modal.nameInput.text = "日本語 🫧"; -modal.submitAndClose(); modal.submitAndClose(); -assert.equal(commands.length, 1, "double submission"); -assert.deepEqual(JSON.parse(JSON.stringify(commands[0])), {action: "workspace.rename", fields: {id: "one", name: "日本語 🫧"}}); -assert(modal.visible && modal.renaming, "closed before command result"); -callbacks.shift()(false); -assert(modal.visible && !modal.renaming); -assert.equal(modal.nameInput.text, "日本語 🫧", "failure discarded the draft"); -modal.submitAndClose(); callbacks.shift()(true); -assert(!modal.visible && !modal.aqueousWorkspace); - -for (const mutate of [() => service.locked = true, () => service.session = "session-b", () => service.entities = [], () => service.capabilities.commands = false, () => service.available = false]) { - Object.assign(service, {available: true, locked: false, session: "session-a", entities: service.workspaces, capabilities: {commands: true}}); - assert(modal.show("")); modal.nameInput.text = "Retained draft"; - mutate(); const previous = commands.length; modal.submitAndClose(); - assert.equal(commands.length, previous); - assert(modal.visible && !modal.renaming); - assert.equal(modal.nameInput.text, "Retained draft"); - modal.hide(); -} -Object.assign(service, {available: true, locked: false, session: "session-a", entities: service.workspaces, capabilities: {commands: true}}); -for (const name of ["bad\nname", "🫧".repeat(257)]) { - assert(modal.show("")); modal.nameInput.text = name; modal.submitAndClose(); - assert(modal.visible && !modal.renaming); modal.hide(); -} -assert(modal.show("")); modal.nameInput.text = "Old dialog"; modal.submitAndClose(); -modal.hide(); assert(modal.show("")); -callbacks.shift()(true); -assert(modal.visible, "late result closed a newly opened dialog"); modal.hide(); -service.seat = null; assert.equal(modal.show(""), false); assert(!modal.visible); -modal.CompositorService = {isNiri: true}; assert(modal.show("Niri")); modal.submitAndClose(); -modal.CompositorService = {isHyprland: true}; assert(modal.show("Hyprland")); modal.submitAndClose(); -assert.deepEqual(legacy, ["Niri", "Hyprland"]); -process.stdout.write("PASS: workspace settings/search, forced-ext controls, captured rename target, failures and existing compositor dispatch\n"); diff --git a/quickshell/tests/workspace-view.test.mjs b/quickshell/tests/workspace-view.test.mjs deleted file mode 100644 index d9a2442d1..000000000 --- a/quickshell/tests/workspace-view.test.mjs +++ /dev/null @@ -1,103 +0,0 @@ -import assert from "node:assert/strict"; -import {readFileSync} from "node:fs"; -import vm from "node:vm"; - -const source = readFileSync(new URL("../Modules/DankBar/Widgets/WorkspaceSwitcher.qml", import.meta.url), "utf8"); -function methods(context, text, names, indent = " ") { - for (const name of names) { - const start = text.indexOf(indent + "function " + name + "("); - assert.notEqual(start, -1, name); - const end = text.indexOf("\n" + indent + "}", start) + indent.length + 2; - vm.runInContext(text.slice(start, end), context); - } -} -function property(context, name, type = "property var", indent = " ") { - const token = indent + type + " " + name + ": "; - const start = source.indexOf(token) + token.length; - assert(start >= token.length, name); - if (source[start] !== "{") - return vm.runInContext(source.slice(start, source.indexOf("\n", start)), context); - const end = source.indexOf("\n" + indent + "}", start); - return vm.runInContext("(function()" + source.slice(start, end) + "})()", context); -} -const settings = {showWorkspacePadding: true, showOccupiedWorkspacesOnly: false, showWorkspaceApps: true, - groupWorkspaceApps: false, groupActiveWorkspaceApps: false, showWorkspaceName: true, showWorkspaceIndex: true}; -let forced = "0"; -const aq = {available: true, toplevels: []}; -const compositor = {compositor: "aqueous", compositorDetected: true, isAqueous: true}; -const widget = vm.createContext({AqueousService: aq, CompositorService: compositor, SettingsData: settings, - Quickshell: {env: () => forced}, WindowManager: {windowsets: [{}]}, _placeholderPool: [], effectiveScreenName: "DP-1", - DesktopEntries: {heuristicLookup: id => ({id})}, Paths: {moddedAppId: id => id, isSteamApp: () => false, getAppIcon: id => id, getAppName: id => id}, _desktopEntriesUpdateTrigger: 0}); -widget.root = widget; -methods(widget, source, ["padWorkspaces", "_makePlaceholder", "getRealWorkspaces", "switchToWorkspaceByModelData", "switchWorkspace", "getWorkspaceIcons", "getWorkspaceIndex", "getWorkspaceIndexFallback", "getExtWorkspaceActiveWorkspace"]); -for (const [name, force, available, expectedAq, expectedExt] of [ - ["aqueous", "0", true, true, false], ["aqueous", "1", true, false, true], - ["aqueous", "0", false, false, true], ["labwc", "0", false, false, true], - ...["niri", "hyprland", "mango", "sway", "scroll", "miracle"].flatMap(n => [[n, "0", false, false, false], [n, "1", false, false, true]]) -]) { - Object.assign(compositor, {compositor: name, isAqueous: name === "aqueous"}); - forced = force; aq.available = available; - widget.useAqueous = property(widget, "useAqueous", "readonly property bool"); - widget.useExtWorkspace = property(widget, "useExtWorkspace", "readonly property bool"); - assert.equal(widget.useAqueous, expectedAq, name); - assert.equal(widget.useExtWorkspace, expectedExt, name); -} -Object.assign(compositor, {compositor: "aqueous", isAqueous: true}); -Object.assign(widget, {useAqueous: true, useExtWorkspace: false}); -let clicked = []; -const first = {id: "one", aqueousSession: "session-a", name: "1", number: 1, active: true}; -const other = {id: "two", aqueousSession: "session-a", name: "1", number: 2, active: false}; -aq.workspacesForOutput = () => [first, other]; -aq.activateWorkspace = w => clicked.push(w); -aq.toplevels = ["a", "b"].map(id => ({id, aqueousKey: "session-a:window:" + id, aqueousSession: "session-a", aqueousWorkspaceId: "one", appId: "demo"})); -compositor.sortedToplevels = aq.toplevels; -widget.workspaceList = property(widget, "workspaceList"); -assert.equal(widget.workspaceList.length, 3); -const placeholder = widget.workspaceList[2]; -widget.switchToWorkspaceByModelData(placeholder); assert.equal(clicked.length, 0); -widget.currentWorkspace = "one"; -widget.switchWorkspace(-1); assert.equal(clicked.length, 0); -widget.switchWorkspace(1); assert.equal(clicked[0], other); -widget.currentWorkspace = "two"; widget.switchWorkspace(1); assert.equal(clicked.length, 1); -assert.equal(widget.getWorkspaceIndex(other, 1), "2: 1"); -assert.equal(widget.getWorkspaceIndex(placeholder, 2), 3); -let icons = widget.getWorkspaceIcons(first); -assert.equal(icons.length, 2); -assert.equal(icons[0].windowSession, "session-a"); -Object.assign(widget, {modelData: first, isPlaceholder: false, loadedIcons: icons}); -assert.equal(property(widget, "stableIconCount", "readonly property int", " "), icons.length); -settings.groupWorkspaceApps = true; settings.groupActiveWorkspaceApps = true; -assert.equal(widget.getWorkspaceIcons(first).length, 1); -settings.showOccupiedWorkspacesOnly = true; -assert.equal(property(widget, "workspaceList").length, 3); -settings.showWorkspacePadding = false; -assert.equal(property(widget, "workspaceList").length, 1); -aq.workspacesForOutput = () => []; -assert.equal(property(widget, "workspaceList").length, 0); - -// Native handles remain authoritative even with missing IDs and duplicate names. -Object.assign(widget, {useAqueous: false, useExtWorkspace: true}); -const handles = [0, 1].map(i => ({id: "", name: "Duplicate", active: i === 0, activate: () => clicked.push(i)})); -widget.extProjection = {windowsets: handles}; widget.workspaceList = handles; -widget.currentWorkspace = widget.getExtWorkspaceActiveWorkspace(); -assert.equal(widget.currentWorkspace, handles[0]); -widget.switchWorkspace(1); assert.equal(clicked.at(-1), 1); -assert.equal(widget.getWorkspaceIcons(handles[0]).length, 0); - -// Rendered icons carry their old session into the command validator after restart. -Object.assign(widget, {useAqueous: true, useExtWorkspace: false, appIconsLoader: {item: {iconsLayout: { - mapFromItem: () => ({x: 0, y: 0}), childAt: () => ({windowId: icons[0].windowId, windowSession: icons[0].windowSession}) -}}}, mouseArea: {}}); -widget.delegateRoot = widget; -methods(widget, source, ["windowIdAt", "focusWindowAt"], " "); -aq.command = (action, fields) => clicked.push({action, fields}); -assert(widget.focusWindowAt(0, 0)); -assert.equal(clicked.at(-1).fields.session, "session-a"); -const service = vm.createContext({available: true, session: "session-b", seat: {id: "seat0"}, command: null}); -service.root = service; -const serviceSource = readFileSync(new URL("../Services/AqueousService.qml", import.meta.url), "utf8"); -methods(service, serviceSource, ["activateWorkspace", "commandPayload"]); -service.command = (action, fields) => assert.throws(() => service.commandPayload(action, fields), /stale session/); -service.activateWorkspace(first); -assert.throws(() => service.commandPayload(clicked.at(-1).action, clicked.at(-1).fields), /stale session/); -process.stdout.write("PASS: existing backend selection, Aqueous padding/icons, native handle identity and captured sessions\n"); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index be7d0daa0..bef5e9e06 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -3612,6 +3612,23 @@ "icon": "apps", "description": "Sync dark mode with settings portals for system-wide theme hints" }, + { + "section": "aqueousCursor", + "label": "Aqueous cursor", + "tabIndex": 10, + "category": "Theme & Colors", + "keywords": [ + "appearance", + "aqueous", + "colors", + "cursor", + "look", + "scheme", + "style", + "theme" + ], + "conditionKey": "isAqueous" + }, { "section": "matugenSmartMode", "label": "Auto From Wallpaper", @@ -3845,24 +3862,6 @@ ], "description": "Mouse pointer size in pixels" }, - { - "section": "aqueousCursor", - "label": "Cursor Theme", - "tabIndex": 10, - "category": "Theme & Colors", - "keywords": [ - "appearance", - "aqueous", - "colors", - "colour", - "cursor", - "look", - "scheme", - "style", - "theme" - ], - "conditionKey": "isAqueous" - }, { "section": "cursorTheme", "label": "Cursor Theme", @@ -6269,6 +6268,24 @@ "icon": "animation", "description": "Globally scale animation durations with geometric motion & spring based physics with bezier curves" }, + { + "section": "aqueousTypography", + "label": "Aqueous typography", + "tabIndex": 14, + "category": "Typography & Motion", + "keywords": [ + "animation", + "appearance", + "aqueous", + "font", + "fonts", + "motion", + "text", + "typeface", + "typography" + ], + "conditionKey": "isAqueous" + }, { "section": "modalCustomAnimationDuration", "label": "Custom Duration", @@ -6567,24 +6584,6 @@ ], "icon": "text_format" }, - { - "section": "aqueousTypography", - "label": "Typography", - "tabIndex": 14, - "category": "Typography & Motion", - "keywords": [ - "animation", - "appearance", - "aqueous", - "font", - "fonts", - "motion", - "text", - "typeface", - "typography" - ], - "conditionKey": "isAqueous" - }, { "section": "typography", "label": "Typography", diff --git a/scripts/test-aqueous-integration.py b/scripts/test-aqueous-integration.py deleted file mode 100644 index 8435ee65e..000000000 --- a/scripts/test-aqueous-integration.py +++ /dev/null @@ -1,549 +0,0 @@ -#!/usr/bin/env python3 -"""Run the real DMS daemon/UI on a private Aqueous headless display. - -Requires a diagnostic Pixman-compatible Aqueous build and its source checkout. -All configuration, sockets, images and logs stay in the printed temporary directory. -""" - -import argparse -import json -import os -from pathlib import Path -import signal -import socket -import struct -import subprocess -import tempfile -import time - - -def wait_for(operation, timeout=15): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - value = operation() - if value: - return value - time.sleep(0.1) - raise AssertionError("timed out waiting for authoritative state") - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--aqueous-source", type=Path, required=True) - parser.add_argument("--bin-dir", type=Path, required=True, help="aqueous, aqueousctl, aqueous-config and dms binaries") - parser.add_argument("--frame", action="store_true", help="exercise connected frame reservations on both outputs") - parser.add_argument("--xwayland", action="store_true", help="also capture a synthetic XWayland window; uses positive output origins") - parser.add_argument("--force-ext", action="store_true", help="verify the actual workspace widget through ext-workspace") - args = parser.parse_args() - source = args.aqueous_source.resolve() / "compositor" - binaries = args.bin_dir.resolve() - root = Path(__file__).resolve().parents[1] - base = Path(tempfile.mkdtemp(prefix="dms-aqueous-integration-")) - print(f"Evidence: {base}", flush=True) - for name in ("runtime", "config", "state", "cache", "home", "protocols", "bin"): - (base / name).mkdir(mode=0o700) - env = dict(os.environ) - for key in list(env): - if key.startswith("AQUEOUS_") or key in ("WAYLAND_DISPLAY", "DISPLAY", "LD_PRELOAD", "DMS_SOCKET"): - env.pop(key) - env.update(HOME=str(base / "home"), XDG_RUNTIME_DIR=str(base / "runtime"), - XDG_CONFIG_HOME=str(base / "config"), XDG_STATE_HOME=str(base / "state"), - XDG_CACHE_HOME=str(base / "cache"), PATH=str(base / "bin") + ":" + str(binaries) + ":" + env["PATH"], - WLR_BACKENDS="headless", WLR_HEADLESS_OUTPUTS="2", WLR_RENDERER="pixman", - QT_QPA_PLATFORM="wayland", QT_QUICK_BACKEND="software", GSETTINGS_BACKEND="memory", - DMS_FORCE_EXTWS="1" if args.force_ext else "0", DMS_NO_DDC="1", DMS_DISABLE_MATUGEN="1", DMS_DISABLE_HOT_RELOAD="1", - DBUS_SESSION_BUS_ADDRESS="unix:path=" + str(base / "missing-session-bus"), - DBUS_SYSTEM_BUS_ADDRESS="unix:path=" + str(base / "missing-system-bus")) - (base / "runtime-helper-calls").touch() - env["AQ_TEST_REAL_CTL"] = str(binaries / "aqueousctl") - (base / "bin/aqueousctl").write_text('''#!/usr/bin/python3 -import json, os, sys -from pathlib import Path -parent = Path("/proc", str(os.getppid()), "cmdline").read_bytes().split(b"\\0") -record = {"args": sys.argv[1:], "parent": os.fsdecode(parent[0]), "pid": os.getpid()} -with (Path(os.environ["XDG_RUNTIME_DIR"]).parent / "runtime-helper-calls").open("a") as log: - log.write(json.dumps(record) + "\\n") -if Path(record["parent"]).name != "aqueous-config": - sys.exit(99) -real = os.environ["AQ_TEST_REAL_CTL"] -os.execv(real, [real, *sys.argv[1:]]) -''') - (base / "bin/aqueousctl").chmod(0o755) - config = base / "config/aqueous" - config.mkdir() - shell_config = base / "config/DankMaterialShell" - shell_config.mkdir() - (shell_config / ".firstlaunch").touch() - (shell_config / "settings.json").write_text(json.dumps(dict( - frameEnabled=args.frame, frameMode="connected", showDock=True, dockGroupByApp=False, - showWorkspaceApps=True, showWorkspacePadding=True, groupWorkspaceApps=False, - workspaceFollowFocus=False, showOccupiedWorkspacesOnly=False, - barConfigs=[dict(id="default", name="Test", enabled=True, position=0, - screenPreferences=["all"], showOnLastDisplay=True, - leftWidgets=["workspaceSwitcher", "focusedWindow", "runningApps", "keyboard_layout_name"], - centerWidgets=[], rightWidgets=[], spacing=4, innerPadding=4, visible=True)]))) - for name in ("wm", "layout", "input", "outputs", "rules", "appearance"): - path = config / f"{name}.toml" - path.write_text((source / "scripts/fixtures/overview-wm.toml").read_text() if name == "wm" else "") - env["AQUEOUS_" + ("CONFIG" if name == "wm" else name.upper())] = str(path) - - protocols = { - "xdg-activation": Path("/usr/share/wayland-protocols/staging/xdg-activation/xdg-activation-v1.xml"), - "pointer-constraints": Path("/usr/share/wayland-protocols/unstable/pointer-constraints/pointer-constraints-unstable-v1.xml"), - "xdg-shell": Path("/usr/share/wayland-protocols/stable/xdg-shell/xdg-shell.xml"), - "shortcuts": Path("/usr/share/wayland-protocols/unstable/keyboard-shortcuts-inhibit/keyboard-shortcuts-inhibit-unstable-v1.xml"), - "virtual-keyboard": source / "protocol/upstream/virtual-keyboard-unstable-v1.xml", - "layer-shell": source / "protocol/upstream/wlr-layer-shell-unstable-v1.xml", - "session-lock": Path("/usr/share/wayland-protocols/staging/ext-session-lock/ext-session-lock-v1.xml"), - "aqueous-shell": source / "protocol/aqueous-shell-v1.xml", - "ext-workspace": source / "protocol/upstream/ext-workspace-v1.xml", - } - generated = [] - for name, xml in protocols.items(): - subprocess.run(["wayland-scanner", "client-header", str(xml), str(base / "protocols" / f"{name}-client-protocol.h")], check=True) - code = base / "protocols" / f"{name}.c" - subprocess.run(["wayland-scanner", "private-code", str(xml), str(code)], check=True) - generated.append(str(code)) - fixture = base / "shell-client" - subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", "-I" + str(base / "protocols"), - str(source / "scripts/fixtures/shell-client.c"), *generated, - "-lwayland-client", "-lxkbcommon", "-o", str(fixture)], check=True) - if args.xwayland: - subprocess.run(["cc", "-Wall", "-Wextra", "-Werror", str(source / "scripts/fixtures/shell-x11.c"), - "-lX11", "-o", str(base / "shell-x11")], check=True) - children = [] - - def spawn(command, log_name): - with (base / log_name).open("w") as log: - child = subprocess.Popen(command, env=env, stdin=subprocess.PIPE, stdout=log, stderr=log, start_new_session=True) - children.append(child) - return child - - def run(command, success=True): - result = subprocess.run(command, env=env, capture_output=True, text=True, timeout=20) - assert (result.returncode == 0) == success, (command, result.stdout, result.stderr) - return result.stdout - - def ipc(*args): - return run([str(binaries / "dms"), "ipc", "call", *args]).strip() - - def rpc(method, params=None, success=True): - assert ipc("aqueousTest", "request", method, json.dumps(params)) == "REQUESTED" - def reply(): - output = "" - while True: - part = json.loads(ipc("aqueousTest", "result", str(len(output)))) - output += part["data"] - if part["complete"]: - return json.loads(output) - response = wait_for(reply) - assert ("error" not in response) == success, response - return response.get("result") if success else response["error"] - - def state(): - return json.loads(ipc("aqueousTest", "state")) - - def entities(kind): - return [e for e in state().get("batch", {}).get("upsert", []) if e["kind"] == kind] - - def command(action, success=True, **fields): - return rpc("aqueous.command", dict(action=action, session=session, **fields), success) - - # Test-only IPC invokes the production singleton inside the actual shell. - shell = base / "shell" - shell.mkdir() - for path in (root / "quickshell").iterdir(): - if path.name != "shell.qml": - (shell / path.name).symlink_to(path) - harness = """ - property var aqueousTestReply: null - property var rememberedWorkspace: null - PanelWindow { - screen: workspaceTest.parentScreen - implicitWidth: Math.max(1, workspaceTest.width) - implicitHeight: Math.max(1, workspaceTest.height) - color: "transparent" - anchors { top: true; left: true } - WlrLayershell.namespace: "dms:aqueous-workspace-test" - exclusiveZone: 0 - WorkspaceWidgets.WorkspaceSwitcher { - id: workspaceTest - parentScreen: Quickshell.screens.find(s => s.name === screenName) || null - screenName: Quickshell.screens[0]?.name || "" - widgetHeight: 30 - barThickness: 48 - } - } - function activateWorkspaceIcon(item, windowId) { - for (const child of item.children || []) { - if (child.windowId === windowId && child.windowSession) { - let delegate = child.parent; - while (delegate && typeof delegate.focusWindowAt !== "function") - delegate = delegate.parent; - if (!delegate) - return false; - const point = child.mapToItem(delegate, child.width / 2, child.height / 2); - return delegate.focusWindowAt(point.x, point.y); - } - if (activateWorkspaceIcon(child, windowId)) - return true; - } - return false; - } - function workspaceDelegates(item) { - let result = []; - for (const child of item.children || []) { - if (child.stableIconCount !== undefined) { - result.push({active: child.isActive, placeholder: child.isPlaceholder, - occupied: child.isOccupied, known: !workspaceTest.useExtWorkspace, count: child.stableIconCount, - icons: child.loadedIcons.length, width: child.width, height: child.height, - extraWidth: child.iconsExtraWidth, extraHeight: child.iconsExtraHeight}); - } else { - result = result.concat(workspaceDelegates(child)); - } - } - return result; - } - function renameModal() { - return dmsShellLoader.item?.children.find(c => c.workspaceRenameModalLoader !== undefined)?.workspaceRenameModalLoader?.item; - } - function renameInput(item) { - if (!item) - return null; - if (item.placeholderText === I18n.tr("Workspace name")) - return item; - for (const child of item.children || []) { - const found = renameInput(child); - if (found) - return found; - } - return null; - } - IpcHandler { - target: "aqueousTest" - function state(): string { return JSON.stringify(AqueousService.state); } - function workspaceSetting(key: string): string { - const row = SettingsSearchService.registeredCards[key]?.item; - return JSON.stringify(row ? {visible: row.visible, enabled: row.enabled, checked: row.checked} : {visible: false, tab: PopoutService.settingsModal?.currentTabIndex, registered: Object.keys(SettingsSearchService.registeredCards)}); - } - function toggleWorkspaceSetting(key: string): string { - const row = SettingsSearchService.registeredCards[key]?.item; - if (!row?.visible || !row.enabled) - return "UNAVAILABLE"; - row.toggled(!row.checked); - return "TOGGLED"; - } - function renameStatus(): string { - const modal = entrypoint.renameModal(); - const input = entrypoint.renameInput(modal?.contentItem); - return JSON.stringify(modal ? {visible: modal.visible, pending: modal.renaming, - target: modal.aqueousWorkspace, text: input?.text, inputEnabled: input?.enabled} : null); - } - function renameSubmit(name: string): string { - const modal = entrypoint.renameModal(); - const input = entrypoint.renameInput(modal?.contentItem); - if (!modal?.visible || !input) - return "UNAVAILABLE"; - input.text = name; - modal.submitAndClose(); - return "REQUESTED"; - } - function workspaceStatus(): string { - return JSON.stringify({backend: workspaceTest.useAqueous ? "aqueous" : workspaceTest.useExtWorkspace ? "ext" : "none", output: workspaceTest.effectiveScreenName, - rows: workspaceTest.workspaceList.map(w => ({name: w.name, active: w.active, number: w.number, - placeholder: !!w._placeholder, windows: workspaceTest.useAqueous ? AqueousService.toplevels.filter(t => t.aqueousWorkspaceId === w.id).length : null})), - delegates: entrypoint.workspaceDelegates(workspaceTest)}); - } - function workspaceOptions(options: string): string { - const value = JSON.parse(options); - if (value.output !== undefined) workspaceTest.screenName = value.output; - if (value.vertical !== undefined) workspaceTest.isVertical = value.vertical; - if (value.padding !== undefined) SettingsData.showWorkspacePadding = value.padding; - if (value.occupiedOnly !== undefined) SettingsData.showOccupiedWorkspacesOnly = value.occupiedOnly; - if (value.group !== undefined) { SettingsData.groupWorkspaceApps = value.group; SettingsData.groupActiveWorkspaceApps = value.group; } - if (value.follow !== undefined) SettingsData.workspaceFollowFocus = value.follow; - if (value.maxIcons !== undefined) SettingsData.maxWorkspaceIcons = value.maxIcons; - return "OK"; - } - function workspaceActivate(index: int): string { - workspaceTest.switchToWorkspaceByModelData(workspaceTest.workspaceList[index]); - return "REQUESTED"; - } - function workspaceWheel(direction: string): string { workspaceTest.switchWorkspace(direction === "previous" ? -1 : 1); return "REQUESTED"; } - function workspaceIcon(windowId: string): string { return String(entrypoint.activateWorkspaceIcon(workspaceTest, windowId)); } - function workspaceRemember(): string { - entrypoint.rememberedWorkspace = workspaceTest.workspaceList.find(w => !w.active && !w._placeholder); - return String(!!entrypoint.rememberedWorkspace); - } - function reconnect(): string { AqueousService.disconnected("test stream disconnected"); return "OK"; } - function workspaceReplay(): string { AqueousService.activateWorkspace(entrypoint.rememberedWorkspace); return "REQUESTED"; } - function workspaceOverview(): string { AqueousService.toggleOverview(workspaceTest.effectiveScreenName); return "true"; } - function result(offset: int): string { - const text = JSON.stringify(entrypoint.aqueousTestReply); - return JSON.stringify({data: text.slice(offset, offset + 8192), complete: offset + 8192 >= text.length}); - } - function request(method: string, params: string): string { - entrypoint.aqueousTestReply = null; - const fields = JSON.parse(params); - if (method === "aqueous.command") { - AqueousService.command(fields.action, fields, (ok, status) => { - entrypoint.aqueousTestReply = ok ? {result: {ok: true, status: status}} : {error: status}; - }); - } else { - AqueousConfigService.request(method.split(".").pop(), fields, (result, error) => { - entrypoint.aqueousTestReply = error ? {error: error} : {result: result}; - }); - } - return "REQUESTED"; - } - } - """ - source_shell = (root / "quickshell/shell.qml").read_text().replace("import Quickshell\n", "import Quickshell\nimport Quickshell.Io\nimport Quickshell.Wayland\nimport qs.Modules.DankBar.Widgets as WorkspaceWidgets\n") - (shell / "shell.qml").write_text(source_shell.replace(" id: entrypoint", " id: entrypoint\n" + harness)) - - try: - compositor = spawn([str(binaries / "aqueous"), *([] if args.xwayland else ["-no-xwayland"]), - "-c", 'printf %s "$DISPLAY" > "$XDG_RUNTIME_DIR/test-display"; printf %s "$AQUEOUS_SOCKET" > "$XDG_RUNTIME_DIR/test-ipc"'], "compositor.log") - display = wait_for(lambda: next((p for p in (base / "runtime").glob("wayland-*") if p.is_socket()), None)) - env["WAYLAND_DISPLAY"] = display.name - env["AQUEOUS_SOCKET"] = wait_for(lambda: (base / "runtime/test-ipc").read_text() if (base / "runtime/test-ipc").exists() else None) - with socket.socket(socket.AF_UNIX) as probe: - probe.settimeout(5) - probe.connect(env["AQUEOUS_SOCKET"]) - probe.sendall(b'{"ipc":1,"id":"1","op":"hello","params":{}}\n') - hello = json.loads(probe.makefile("rb").readline(65536)) - assert hello["ok"] and hello["result"]["schema"] == 1, hello - (base / "hello.json").write_text(json.dumps(hello, indent=2)) - env["DMS_SHELL_DIR"] = str(shell) - dms = spawn([str(binaries / "dms"), "-c", str(shell), "run"], "dms.log") - wait_for(lambda: subprocess.run([str(binaries / "dms"), "ipc", "call", "aqueous", "status"], env=env, capture_output=True, text=True, timeout=5).returncode == 0) - status = wait_for(lambda: (s if (s := json.loads(ipc("aqueous", "status")))["available"] else None)) - assert status["outputs"] == 2, status - session = status["session"] - def workspace_status(): - value = json.loads(ipc("aqueousTest", "workspaceStatus")) - (base / "workspace-status.json").write_text(json.dumps(value, indent=2)) - return value - def workspace_options(**options): - assert ipc("aqueousTest", "workspaceOptions", json.dumps(options)) == "OK" - view = wait_for(lambda: (v if (v := workspace_status())["delegates"] else None)) - assert view["backend"] == ("ext" if args.force_ext else "aqueous"), view - assert len(view["delegates"]) == len(view["rows"]), view - if args.frame: - wait_for(lambda: len(entities("output")) == 2 and all(o["usable_bounds"]["width"] < o["bounds"]["width"] and o["usable_bounds"]["height"] < o["bounds"]["height"] for o in entities("output"))) - wait_for(lambda: ipc("settings", "openWith", "workspaces").startswith("SETTINGS_OPEN_SUCCESS")) - time.sleep(2) - assert "Type SettingsModal unavailable" not in (base / "dms.log").read_text() - for key in ("showWorkspaceApps", "workspaceFollowFocus", "showOccupiedWorkspacesOnly", "reverseScrolling"): - def setting_state(): - value = json.loads(ipc("aqueousTest", "workspaceSetting", key)) - (base / "workspace-setting.json").write_text(json.dumps(dict(key=key, state=value), indent=2)) - return value - control = wait_for(lambda: (value if (value := setting_state()) and value["visible"] else None)) - assert control["enabled"] == (not args.force_ext or key not in ("showWorkspaceApps", "showOccupiedWorkspacesOnly")), (key, control) - if control["enabled"]: - assert ipc("aqueousTest", "toggleWorkspaceSetting", key) == "TOGGLED" - wait_for(lambda: setting_state()["checked"] != control["checked"]) - assert ipc("aqueousTest", "toggleWorkspaceSetting", key) == "TOGGLED" - wait_for(lambda: setting_state()["checked"] == control["checked"]) - else: - assert ipc("aqueousTest", "toggleWorkspaceSetting", key) == "UNAVAILABLE" - assert ipc("settings", "close") == "SETTINGS_CLOSE_SUCCESS" - windows = [spawn([str(fixture), "window"], f"window-{i}.log") for i in range(2)] - clients = wait_for(lambda: (w if len(w := [w for w in entities("window") if w["app_id"] == "aq-shell-test"]) == 2 else None)) - assert clients[0]["title"] == clients[1]["title"] and clients[0]["id"] != clients[1]["id"] - seat = entities("seat")[0]["id"] - window = clients[0]["id"] - assert command("window.activate", id=window, seat=seat)["status"] == "applied" - wait_for(lambda: next(s for s in entities("seat") if s["id"] == seat)["window"] == window) - def current_window(): - return next(w for w in entities("window") if w["id"] == window) - original_workspace = current_window()["workspace"] - other_output = next(o for o in entities("output") if o["id"] != current_window()["output"])["id"] - for field in ("fullscreen", "maximized", "minimized"): - if field != "fullscreen" and not current_window()["can_" + field[:-1]]: - assert "unavailable" in command("window." + field, id=window, value=True, success=False) - continue - for value in (True, False): - assert command("window." + field, id=window, value=value)["status"] == "applied" - wait_for(lambda: current_window()[field] == value) - assert command("window.move", id=window, output=other_output)["status"] == "applied" - wait_for(lambda: current_window()["output"] == other_output) - assert command("window.move", id=window, workspace=original_workspace)["status"] == "applied" - wait_for(lambda: current_window()["workspace"] == original_workspace) - locker = spawn([str(fixture), "lock"], "lock.log") - wait_for(lambda: entities("session")[0]["locked"]) - assert "locked" in command("window.activate", id=window, seat=seat, success=False) - locker.stdin.write(b"unlock\n") - locker.stdin.flush() - assert locker.wait(timeout=5) == 0 - wait_for(lambda: not entities("session")[0]["locked"]) - command("window.activate", id=window, seat=seat) - group = next(s for s in entities("seat") if s["id"] == seat)["keyboard"] - assert command("keyboard.set", seat=seat, group=group, index=1)["status"] == "applied" - wait_for(lambda: next(k for k in entities("keyboard") if k["id"] == group)["index"] == 1) - assert "German" in json.loads(ipc("aqueous", "status"))["keyboardLayout"] - output = next(o for o in entities("output") if o["id"] == next(w for w in entities("window") if w["id"] == window)["output"]) - workspace_options(output=output["name"], group=False, occupiedOnly=not args.force_ext) - if not args.force_ext: - view = wait_for(lambda: (v if (v := workspace_status()) and any(d["icons"] == 2 for d in v["delegates"]) else None)) - assert all(d["icons"] == d["count"] for d in view["delegates"]), view - assert len(view["rows"]) == 3 and sum(r["placeholder"] for r in view["rows"]) == 2, view - assert any(d["extraWidth"] > 0 for d in view["delegates"]), view - workspace_options(group=True) - wait_for(lambda: any(d["icons"] == 1 for d in workspace_status()["delegates"])) - workspace_options(group=False, vertical=True, maxIcons=1) - view = wait_for(lambda: (v if (v := workspace_status()) and any(d["icons"] == 2 and d["extraHeight"] > 0 for d in v["delegates"]) else None)) - assert all(d["icons"] == d["count"] for d in view["delegates"]), view - workspace_options(padding=False) - wait_for(lambda: len(workspace_status()["rows"]) == 1) - workspace_options(padding=True) - wait_for(lambda: len(workspace_status()["rows"]) == 3) - else: - view = workspace_status() - assert all(row["windows"] is None for row in view["rows"]), view - assert all(d["icons"] == d["count"] == 0 and not d["known"] for d in view["delegates"]), view - workspace_options(vertical=False, maxIcons=3, occupiedOnly=False) - view = workspace_status() - current = next(i for i, r in enumerate(view["rows"]) if r["active"]) - target = next(i for i, r in enumerate(view["rows"]) if not r["active"] and not r["placeholder"]) - assert ipc("aqueousTest", "workspaceActivate", str(target)) == "REQUESTED" - wait_for(lambda: workspace_status()["rows"][target]["active"]) - assert ipc("aqueousTest", "workspaceWheel", "previous" if target > current else "next") == "REQUESTED" - wait_for(lambda: workspace_status()["rows"][current]["active"]) - # Names change independently of identity, including collisions in one projection. - same_output = [w for w in entities("workspace") if w["output"] == output["id"]] - for workspace in same_output[:2]: - command("workspace.rename", id=workspace["id"], name="Duplicate") - wait_for(lambda: sum(r["name"] == "Duplicate" for r in workspace_status()["rows"]) == 2) - command("workspace.rename", id=same_output[0]["id"], name="Renamed") - wait_for(lambda: any(r["name"] == "Renamed" for r in workspace_status()["rows"])) - other_output = next(o for o in entities("output") if o["id"] != output["id"]) - workspace_options(output=other_output["name"], follow=True) - wait_for(lambda: workspace_status()["output"] == output["name"]) - workspace_options(follow=False) - assert workspace_status()["output"] == other_output["name"] - workspace_options(output=output["name"]) - if not args.force_ext: - command("window.activate", id=window, seat=seat) - def activate_icon(): - workspace_status() - return ipc("aqueousTest", "workspaceIcon", clients[1]["id"]) == "true" - wait_for(activate_icon) - wait_for(lambda: next(s for s in entities("seat") if s["id"] == seat)["window"] == clients[1]["id"]) - command("window.activate", id=window, seat=seat) - assert ipc("aqueousTest", "workspaceOverview") == "true" - wait_for(lambda: entities("session")[0]["overview_output"] == output["id"]) - command("overview.hide") - assert command("overview.show", output=output["id"])["status"] == "applied" - wait_for(lambda: entities("session")[0]["overview_output"] == output["id"]) - assert ipc("aqueous", "overview", "hide", output["name"]) == "OVERVIEW_REQUESTED" - wait_for(lambda: entities("session")[0]["overview_output"] is None) - - # Use the real rename IPC/dialog and keep its captured target across a focus change. - def rename_status(): - value = json.loads(ipc("aqueousTest", "renameStatus")) - (base / "rename-status.json").write_text(json.dumps(value, indent=2)) - return value - target = next(w for w in entities("workspace") if w["output"] == output["id"] and w["active"]) - destination = next(w for w in entities("workspace") if w["output"] == output["id"] and w["id"] != target["id"]) - assert ipc("workspace-rename", "open") == "WORKSPACE_RENAME_MODAL_OPENED" - draft = wait_for(lambda: (value if (value := rename_status()) and value["visible"] else None)) - assert draft["target"] == dict(id=target["id"], session=session), draft - assert draft["text"] == target["name"], draft - command("workspace.activate", id=destination["id"], seat=seat) - assert ipc("aqueousTest", "renameSubmit", "日本語 🫧") == "REQUESTED" - wait_for(lambda: not rename_status()["visible"]) - wait_for(lambda: next(w for w in entities("workspace") if w["id"] == target["id"])["name"] == "日本語 🫧") - assert next(w for w in entities("workspace") if w["id"] == destination["id"])["name"] == destination["name"] - assert ipc("workspace-rename", "toggle") == "WORKSPACE_RENAME_MODAL_OPENED" - wait_for(lambda: rename_status()["visible"]) - assert rename_status()["target"]["id"] == destination["id"] - invalid_name = "x" * 1025 - assert ipc("aqueousTest", "renameSubmit", invalid_name) == "REQUESTED" - draft = rename_status() - assert draft["visible"] and not draft["pending"] and draft["text"] == invalid_name, draft - assert ipc("aqueousTest", "renameSubmit", "Renamed after error") == "REQUESTED" - wait_for(lambda: not rename_status()["visible"]) - wait_for(lambda: next(w for w in entities("workspace") if w["id"] == destination["id"])["name"] == "Renamed after error") - command("window.activate", id=window, seat=seat) - - def capture(name): - command("window.activate", id=window, seat=seat) - run([str(binaries / "dms"), "screenshot", "window", "--seat", seat, "--no-clipboard", "--no-notify", "--dir", str(base), "--filename", name]) - data = (base / name).read_bytes() - assert data.startswith(b"\x89PNG\r\n\x1a\n") - dimensions = struct.unpack(">II", data[16:24]) - assert all(dimensions), dimensions - print(f"Screenshot {name}: {dimensions}", flush=True) - - capture("window.png") - run([str(binaries / "dms"), "screenshot", "full", "--seat", seat, "--geometry"]) - origin = "3000,100" if args.xwayland else "-3000,-100" - run(["wlr-randr", "--output", output["name"], "--scale", "1.25", "--transform", "90", "--pos=" + origin]) - wait_for(lambda: next(o for o in entities("output") if o["id"] == output["id"])["scale"] == 1.25) - capture("rotated-fractional.png" if args.xwayland else "rotated-fractional-negative-origin.png") - if args.xwayland: - env["DISPLAY"] = wait_for(lambda: (base / "runtime/test-display").read_text()) - spawn([str(base / "shell-x11")], "xwayland-window.log") - xwindow = wait_for(lambda: next((w for w in entities("window") if w["backend"] == "xwayland"), None)) - window = xwindow["id"] - capture("xwayland-window.png") - assert command("window.close", id=window)["status"] == "accepted" - - snapshot = rpc("aqueous.config.snapshot") - (base / "helper-snapshot.json").write_text(json.dumps(snapshot, indent=2) + "\n") - (base / "helper-version.json").write_text(run([str(binaries / "aqueous-config"), "version"])) - generation = snapshot["generation"] - edit = dict(expected_generation=generation, changes=[dict(id="toggle_overview", value=["Super+W", "Super+F12"])]) - rpc("aqueous.config.validate", edit) - assert rpc("aqueous.config.snapshot")["generation"] == generation - saved = rpc("aqueous.config.apply", edit) - assert saved["generation"] != generation - assert "external_change" in rpc("aqueous.config.apply", edit, success=False) - sheet = json.loads(run([str(binaries / "dms"), "keybinds", "show", "aqueous"])) - assert sheet["generation"] == saved["generation"] - run([str(binaries / "dms"), "keybinds", "set", "aqueous", "Super+F11", "spawn true", "--expected-generation", saved["generation"]]) - assert "not_found" in command("window.activate", id="removed-window", seat=seat, success=False) - assert "stale session" in rpc("aqueous.command", dict(action="window.close", session="0" * 32, id=window), success=False) - - assert ipc("aqueousTest", "workspaceRemember") == "true" - active_before = [w["id"] for w in entities("workspace") if w["active"]] - assert ipc("aqueousTest", "reconnect") == "OK" - wait_for(lambda: not state().get("available", False)) - if not args.force_ext: - assert ipc("aqueousTest", "workspaceReplay") == "REQUESTED" - wait_for(lambda: workspace_status()["backend"] == "ext") - wait_for(lambda: state().get("available", False)) - assert json.loads(ipc("aqueous", "status"))["session"] == session - if not args.force_ext: - wait_for(lambda: workspace_status()["backend"] == "aqueous") - assert [w["id"] for w in entities("workspace") if w["active"]] == active_before, "unavailable workspace action was replayed after reconnect" - time.sleep(1) - assert state()["available"], "quiet subscription became unavailable" - for client in clients: - assert command("window.close", id=client["id"])["status"] == "accepted" - wait_for(lambda: not [w for w in entities("window") if w["app_id"] == "aq-shell-test"]) - # Qt exits when its Wayland connection closes; it cannot answer another IPC call after logout. - assert ipc("aqueousTest", "request", "aqueous.command", json.dumps(dict(action="session.exit", session=session))) == "REQUESTED" - assert compositor.wait(timeout=10) == 0 - os.killpg(dms.pid, signal.SIGTERM) - dms.wait(timeout=10) - launches = [json.loads(line) for line in (base / "runtime-helper-calls").read_text().splitlines()] - assert all(Path(call["parent"]).name == "aqueous-config" for call in launches), launches - print(f"Retained configuration helper aqueousctl launches: {len(launches)}", flush=True) - print("PASS: daemon/UI, duplicate identities, state/move eligibility, lock/unlock, keyboard, overview, screenshots, helper conflicts, keybinds, persistent IPC and zero runtime aqueousctl launches, workspace settings/widget/rename and orderly exit", flush=True) - finally: - for child in reversed(children): - if child.poll() is None: - os.killpg(child.pid, signal.SIGTERM) - for child in reversed(children): - try: - child.wait(timeout=8) - except subprocess.TimeoutExpired: - os.killpg(child.pid, signal.SIGKILL) - child.wait() - - -if __name__ == "__main__": - main() diff --git a/scripts/test-aqueous-keybinds.py b/scripts/test-aqueous-keybinds.py deleted file mode 100644 index e72436d24..000000000 --- a/scripts/test-aqueous-keybinds.py +++ /dev/null @@ -1,286 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise keybind draft recovery on a private headless Aqueous display. - -Requires a Pixman-compatible Aqueous build, matching helper/CLI, Quickshell, -and the DMS binary under test in --bin-dir. Never connects to the user display. -""" - -import argparse -import json -import os -from pathlib import Path -import signal -import subprocess -import tempfile -import time - - -def wait_for(operation, timeout=20): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - value = operation() - if value: - return value - time.sleep(0.1) - raise AssertionError("timed out waiting for keybind state") - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--bin-dir", type=Path, required=True) - args = parser.parse_args() - binaries = args.bin_dir.resolve() - root = Path(__file__).resolve().parents[1] - base = Path(tempfile.mkdtemp(prefix="dms-aqueous-keybinds-")) - print(f"Evidence: {base}", flush=True) - for name in ("runtime", "config/aqueous", "config/DankMaterialShell", "cache", "state", "home", "shell", "bin"): - (base / name).mkdir(parents=True, mode=0o700) - env = {key: value for key, value in os.environ.items() if not key.startswith("AQUEOUS_") and key not in ("DISPLAY", "WAYLAND_DISPLAY", "LD_PRELOAD", "DMS_SOCKET")} - env.update(HOME=str(base / "home"), XDG_CONFIG_HOME=str(base / "config"), XDG_RUNTIME_DIR=str(base / "runtime"), - XDG_CACHE_HOME=str(base / "cache"), XDG_STATE_HOME=str(base / "state"), PATH=str(base / "bin") + ":" + str(binaries) + ":" + env["PATH"], - WLR_BACKENDS="headless", WLR_HEADLESS_OUTPUTS="1", WLR_RENDERER="pixman", QT_QPA_PLATFORM="wayland", - QT_QUICK_BACKEND="software", GSETTINGS_BACKEND="memory", DMS_NO_DDC="1", DMS_DISABLE_MATUGEN="1", DMS_DISABLE_HOT_RELOAD="1", - DBUS_SESSION_BUS_ADDRESS="unix:path=" + str(base / "missing-bus"), DBUS_SYSTEM_BUS_ADDRESS="unix:path=" + str(base / "missing-system-bus")) - env["KEYBIND_TEST_HELPER"] = str(binaries / "aqueous-config") - wrapper = base / "bin/aqueous-config" - wrapper.write_text('''#!/usr/bin/python3 -import os, sys -from pathlib import Path -with (Path(__file__).resolve().parents[1] / "helper-calls").open("a") as log: - log.write(sys.argv[1] + "\\n") -os.execv(os.environ["KEYBIND_TEST_HELPER"], ["aqueous-config"] + sys.argv[1:]) -''') - wrapper.chmod(0o755) - config = base / "config/aqueous" - for name in ("wm", "rules", "layout", "input", "outputs", "appearance"): - (config / f"{name}.toml").write_text("") - env["AQUEOUS_" + ("CONFIG" if name == "wm" else name.upper())] = str(config / f"{name}.toml") - (config / "wm.toml").write_text('[blur]\nenabled = true\n[keybinds.custom]\n"Super+Shift+S" = "old-screenshot"\n') - (base / "config/DankMaterialShell/.firstlaunch").touch() - (base / "config/DankMaterialShell/settings.json").write_text(json.dumps({"barConfigs": [], "blurEnabled": False})) - shell = base / "shell" - for path in (root / "quickshell").iterdir(): - if path.name not in ("shell.qml", ".qmlls.ini"): - (shell / path.name).symlink_to(path) - harness = ''' - property int savedCount: 0 - property int removedCount: 0 - Connections { - target: KeybindsService - function onBindSaved(key) { entrypoint.savedCount++; } - function onBindRemoved(key) { entrypoint.removedCount++; } - } - PanelWindow { - id: testWindow - implicitWidth: 900 - implicitHeight: 1000 - KeybindTests.KeybindsTab { - id: testTab - anchors.fill: parent - parentModal: testWindow - } - } - function findObject(item, method) { - if (typeof item?.[method] === "function") return item; - for (const child of item?.children || []) { - const found = findObject(child, method); - if (found) return found; - } - return null; - } - function editItem(item) { - if (typeof item?.updateEdit === "function" && (item.retainEdits || (!item.isNew && item.isExpanded))) return item; - for (const child of item?.children || []) { - const found = editItem(child); - if (found) return found; - } - return null; - } - IpcHandler { - target: "keybindTest" - function status(): string { - const editor = testTab; - const item = entrypoint.editItem(testTab); - return JSON.stringify({input: item ? {key: item.editKey, action: item.editAction, expanded: item.isExpanded} : null, - generation: KeybindsService._rawData?.generation, loading: KeybindsService.loading, - busy: KeybindsService.aqueousBusy, saved: entrypoint.savedCount, removed: entrypoint.removedCount, - editor: editor ? {active: editor.hasEditDraft, busy: editor.editBusy, reviewing: editor.reviewingEdit, - error: editor.editError, draft: editor.editDraft} : null}); - } - function open(key: string): string { - const binding = KeybindsService.getFlatBinds().find(b => b.keys.some(k => k.key === key)); - if (!binding) return "MISSING"; - if (testTab.expandedKey !== binding.action) - testTab.toggleExpanded(binding.action); - return "OK"; - } - function edit(key: string, action: string): string { - const editor = testTab; - const item = entrypoint.editItem(testTab); - item.updateEdit({key: key, action: action}); - return "OK"; - } - function filter(query: string): string { - testTab.searchQuery = query; - testTab._updateFiltered(); - return "OK"; - } - function invoke(method: string): string { - const editor = testTab; - if (method === "save") entrypoint.editItem(testTab).doSave(); - else if (method === "refreshList") KeybindsService.loadBinds(false); - else if (method === "remove") entrypoint.editItem(testTab).removeBind(entrypoint.editItem(testTab)._originalKey); - else if (method === "cancelRemove") entrypoint.findObject(testTab, "showWithOptions")._activate(0); - else if (method === "toggle") testTab.toggleExpanded(editor.editDraft.action); - else if (method === "new") testTab.startNewBind(); - else if (method === "confirm") entrypoint.findObject(testTab, "showWithOptions")._activate(1); - else if (method === "reopen") { testTab.visible = false; testTab.visible = true; } - else if (method === "reload") testTab.reloadEdit(); - else if (method === "discard") testTab.discardEdit(); - else editor[method](); - return "OK"; - } - } - ''' - source = (root / "quickshell/shell.qml").read_text().replace("import Quickshell\n", "import Quickshell\nimport Quickshell.Io\nimport qs.Modules.Settings as KeybindTests\n") - (shell / "shell.qml").write_text(source.replace(" id: entrypoint", " id: entrypoint\n" + harness)) - children = [] - - def spawn(command, name): - with (base / name).open("w") as log: - process = subprocess.Popen(command, env=env, stdout=log, stderr=log, start_new_session=True) - children.append(process) - - def run(command, success=True, input=None): - result = subprocess.run(command, env=env, input=input, text=True, capture_output=True, timeout=20) - assert (result.returncode == 0) == success, (command, result.stdout, result.stderr) - return result.stdout - - def ipc(*args): - return run([str(binaries / "dms"), "ipc", "call", "keybindTest", *args]).strip() - - def state(): - result = json.loads(ipc("status")) - (base / "state.json").write_text(json.dumps(result, indent=2)) - return result - - def helper(operation, request=None): - command = [str(binaries / "aqueous-config"), operation, "--shell", "dms"] - if request: - command += ["--request", "-"] - return json.loads(run(command, input=json.dumps(request) if request else None)) - - def apply(**changes): - snapshot = helper("snapshot") - return helper("apply", dict(protocol=1, expected_generation=snapshot["generation"], **changes)) - - try: - spawn([str(binaries / "aqueous"), "-no-xwayland", "-c", "true"], "compositor.log") - display = wait_for(lambda: next((p for p in (base / "runtime").glob("wayland-*") if p.is_socket()), None)) - env["WAYLAND_DISPLAY"] = display.name - env["DMS_SHELL_DIR"] = str(shell) - spawn([str(binaries / "dms"), "-c", str(shell), "run"], "dms.log") - wait_for(lambda: subprocess.run([str(binaries / "dms"), "ipc", "call", "keybindTest", "status"], env=env, capture_output=True).returncode == 0) - wait_for(lambda: state().get("generation") and not state()["loading"]) - assert ipc("open", "Super+Shift+S") == "OK" - ipc("edit", "Super+Shift+S", "spawn dms screenshot") - before = state()["editor"]["draft"] - assert before["data"]["action"] == "spawn dms screenshot" - rules = '[[layer]]\nnamespace = "dms:*"\nblur = true\n' - ipc("invoke", "toggle") - assert not state()["input"]["expanded"] - ipc("invoke", "toggle") - assert state()["input"]["action"] == "spawn dms screenshot" - apply(raw_files={"rules": rules}) - ipc("invoke", "save") - wait_for(lambda: state()["saved"] == 1 and not state()["loading"]) - assert not state()["editor"]["active"] - assert (config / "rules.toml").read_text() == rules - assert helper("snapshot")["custom_keybinds"][0]["command"] == "dms screenshot" - - assert ipc("open", "Super+Shift+S") == "OK" - ipc("edit", "Super+Shift+S", "spawn dms screenshot full") - apply(custom_keybind_changes=[{"op": "add", "chord": "Super+F12", "command": "external-command"}]) - ipc("invoke", "save") - wait_for(lambda: state()["editor"]["reviewing"] and not state()["busy"]) - assert state()["saved"] == 1 - ipc("invoke", "refreshList") - wait_for(lambda: not state()["loading"]) - ipc("invoke", "reopen") - ipc("filter", "no-matching-shortcut") - assert state()["input"] is None - ipc("filter", "") - assert state()["input"]["action"] == "spawn dms screenshot full" - assert state()["editor"]["draft"]["data"]["action"] == "spawn dms screenshot full" - ipc("invoke", "reload") - wait_for(lambda: not state()["editor"]["busy"]) - ipc("invoke", "acceptReview") - assert not state()["editor"]["reviewing"] - ipc("invoke", "save") - wait_for(lambda: state()["saved"] == 2 and not state()["loading"]) - snapshot = helper("snapshot") - assert any(b["command"] == "external-command" for b in snapshot["custom_keybinds"]) - - assert ipc("open", "Super+Shift+S") == "OK" - ipc("edit", "Super+Shift+S", "spawn dms screenshot full") - ipc("invoke", "remove") - ipc("invoke", "cancelRemove") - assert state()["editor"]["draft"]["operation"] == "set" - ipc("invoke", "remove") - apply(custom_keybind_changes=[{"op": "add", "chord": "Super+F11", "command": "another-external-command"}]) - ipc("invoke", "confirm") - wait_for(lambda: state()["editor"]["reviewing"]) - assert state()["removed"] == 0 - ipc("invoke", "reload") - wait_for(lambda: not state()["editor"]["busy"]) - ipc("invoke", "acceptReview") - assert state()["removed"] == 0 - ipc("invoke", "confirm") - wait_for(lambda: state()["removed"] == 1 and not state()["loading"]) - snapshot = helper("snapshot") - assert all(b["chord"] != "Super+Shift+S" for b in snapshot["custom_keybinds"]) - - assert ipc("open", "Super+F12") == "OK" - ipc("edit", "Super+F12", "spawn discarded-draft") - removed = next(b for b in snapshot["custom_keybinds"] if b["chord"] == "Super+F12") - apply(custom_keybind_changes=[{"op": "delete", "id": removed["id"]}]) - ipc("invoke", "save") - wait_for(lambda: state()["editor"]["reviewing"]) - ipc("invoke", "reload") - wait_for(lambda: not state()["editor"]["busy"]) - ipc("invoke", "acceptReview") - assert state()["editor"]["reviewing"], "removed target was silently accepted as a new binding" - assert state()["editor"]["draft"]["data"]["action"] == "spawn discarded-draft" - ipc("invoke", "discard") - wait_for(lambda: not state()["loading"]) - assert not state()["editor"]["active"] - assert all(b["command"] != "discarded-draft" for b in helper("snapshot")["custom_keybinds"]) - - ipc("invoke", "new") - ipc("edit", "Super+F10", "spawn inline-new-binding") - ipc("invoke", "save") - wait_for(lambda: state()["saved"] == 3 and not state()["loading"]) - assert state()["input"]["expanded"] - ipc("edit", "Super+F10", "spawn inline-second-save") - ipc("invoke", "save") - wait_for(lambda: state()["saved"] == 4 and not state()["loading"]) - assert any(b["command"] == "inline-second-save" for b in helper("snapshot")["custom_keybinds"]) - - stale = json.loads(run([str(binaries / "dms"), "keybinds", "remove", "aqueous", "Super+F12", "--expected-generation", before["baseline"]["generation"], "--json"], success=False)) - assert stale["success"] is False and stale["code"] == "external_change" - settled = state() - calls = (base / "helper-calls").read_text() - time.sleep(1) - assert state() == settled - assert (base / "helper-calls").read_text() == calls, "idle UI launched helper processes" - assert (config / "rules.toml").read_text() == rules - print("PASS: shared inline editor, repeated saves, filtering/collapse draft retention, blur conflict, review/discard, removal cancellation/reconfirmation, idle and structured CLI conflict", flush=True) - finally: - for child in reversed(children): - if child.poll() is None: - os.killpg(child.pid, signal.SIGTERM) - child.wait(timeout=8) - - -if __name__ == "__main__": - main() diff --git a/scripts/test-aqueous-service.py b/scripts/test-aqueous-service.py deleted file mode 100644 index 8aeebaf1a..000000000 --- a/scripts/test-aqueous-service.py +++ /dev/null @@ -1,301 +0,0 @@ -#!/usr/bin/env python3 -"""Exercise production Aqueous QML sockets on a private offscreen Quickshell.""" -import json -import os -from pathlib import Path -import shutil -import signal -import socket -import subprocess -import tempfile -import threading -import time - - -def wait_for(fn, timeout=15): - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - value = fn() - if value: - return value - time.sleep(.05) - raise AssertionError("timed out waiting for QML service") - - -def main(): - root = Path(__file__).resolve().parents[1] - base = Path(tempfile.mkdtemp(prefix="dms-aqueous-socket-")) - print(f"Evidence: {base}", flush=True) - for name in ("Common", "Services", "bin", "runtime", "home", "config", "cache"): - (base / name).mkdir(mode=0o700) - (base / "DankCommon").symlink_to(root / "dank-qml-common/DankCommon", target_is_directory=True) - shutil.copy(root / "quickshell/Services/AqueousService.qml", base / "Services") - for name in ("AqueousConnection.qml", "AqueousIpc.js", "DankSocket.qml"): - shutil.copy(root / "quickshell/Common" / name, base / "Common") - stubs = { - "Common": {"Log": 'function scoped(name) { return {warn: function() {}}; }', - "I18n": 'function tr(value) { return value; }', - "SettingsData": 'enum Position { Top, Left, Right }'}, - "Services": {"CompositorService": 'property bool isAqueous: false', - "ToastService": 'function showError(title, message) {}'}, - } - for directory, items in stubs.items(): - for name, body in items.items(): - (base / directory / (name + ".qml")).write_text("pragma Singleton\nimport QtQuick\nQtObject { " + body + " }\n") - (base / directory / "qmldir").write_text("\n".join( - ("singleton " if p.stem in items or p.stem == "AqueousService" else "") + p.stem + " 1.0 " + p.name - for p in (base / directory).glob("*.qml"))) - (base / "shell.qml").write_text('''import QtQuick -import Quickshell -import Quickshell.Io -import qs.Services -ShellRoot { - id: shell - property var replies: [] - IpcHandler { - target: "test" - function enabled(value: bool): string { CompositorService.isAqueous = value; return "OK"; } - function status(): string { - return JSON.stringify({available: AqueousService.available, state: AqueousService.state, - pending: AqueousService.commandPending, queued: AqueousService.commandQueue.length}); - } - function commands(count: int): string { - shell.replies = []; - for (let i = 0; i < count; i++) { - const number = i; - AqueousService.command("workspace.rename", {id: AqueousService.workspaces[0].id, name: String(i)}, - (ok, message) => { shell.replies = shell.replies.concat([{number: number, ok: ok, message: message}]); }); - } - return "REQUESTED"; - } - function exitSession(): string { - shell.replies = []; - AqueousService.command("session.exit", {}, (ok, message) => { shell.replies = [{ok: ok, message: message}]; }); - return "REQUESTED"; - } - function results(): string { return JSON.stringify(shell.replies); } - } -}''') - # Any legacy runtime helper launch is a test failure, including short-lived children. - (base / "calls").touch() - (base / "bin/aqueousctl").write_text('#!/bin/sh\nprintf "aqueousctl\\n" >> "$AQ_TEST_DIR/calls"\nexit 99\n') - (base / "bin/aqueousctl").chmod(0o755) - env = dict(os.environ) - for key in ("WAYLAND_DISPLAY", "WAYLAND_SOCKET", "DISPLAY", "DMS_SOCKET", "AQUEOUS_SOCKET"): - env.pop(key, None) - socket_dir = base / "runtime/aqueous/test" - socket_dir.mkdir(parents=True, mode=0o700) - (socket_dir.parent).chmod(0o700) - endpoint = socket_dir / "ipc.sock" - env.update(AQ_TEST_DIR=str(base), PATH=str(base / "bin") + ":/usr/bin", HOME=str(base / "home"), - XDG_RUNTIME_DIR=str(base / "runtime"), XDG_CONFIG_HOME=str(base / "config"), XDG_CACHE_HOME=str(base / "cache"), - AQUEOUS_SOCKET=str(endpoint), QT_QPA_PLATFORM="offscreen", QT_QUICK_BACKEND="software", - QT_QPA_PLATFORMTHEME="generic", QT_STYLE_OVERRIDE="Fusion") - qs = shutil.which("qs") or shutil.which("quickshell") - hello = json.loads((root / "quickshell/tests/fixtures/aqueous/hello.json").read_text())["result"] - snapshot = json.loads((root / "quickshell/tests/fixtures/aqueous/watch.ndjson").read_text().splitlines()[0]) - snapshot["sequence"] = "9007199254740993" - next(e for e in snapshot["upsert"] if e["kind"] == "workspace")["name"] = "🫧 日本語" - mode = "normal" - connections, requests, errors = [], [], [] - traffic = {"received": 0, "sent": 0} - stopping = threading.Event() - - def serve(conn): - try: - reader = conn.makefile("rb") - last = 0 - delivery = 0 - session = hello["session"] - while line := reader.readline(65538): - traffic["received"] += len(line) - request = json.loads(line) - requests.append(request) - assert int(request["id"]) > last - last = int(request["id"]) - op = request["op"] - reply = dict(ipc=1, id=request["id"], ok=True) - event = None - if op == "hello": - assert last == 1 and "session" not in request - result = dict(hello) - if mode == "unsupported": - result["schema"] = 2 - if mode == "session-mismatch" and len([r for r in requests if r["op"] == "hello"]) % 2: - result["session"] = "f" * 32 - reply["result"] = result - else: - assert request["session"] == session - if op == "subscribe": - reply["result"] = {"subscribed": True} - delivery = 1 - event = dict(ipc=1, event="state", delivery="1", batch=snapshot) - elif op == "ack": - assert request["params"]["delivery"] == str(delivery) - reply["result"] = {"acked": str(delivery)} - if delivery == 1: - delivery = 2 - batch = dict(schema=1, session=session, type="delta", sequence="9007199254741999", - base_sequence=snapshot["sequence"], upsert=[], removed=[]) - if mode == "baseline": - batch["base_sequence"] = "wrong" - event = dict(ipc=1, event="state", delivery="2", batch=batch) - elif op == "command": - if mode == "timeout": - conn.recv(1) - return - if mode == "disconnect": - conn.shutdown(socket.SHUT_RDWR) - return - if mode == "slow": - time.sleep(.2) - if mode == "locked": - reply.update(ok=False, error={"code": "locked", "message": "Session is locked"}) - elif mode == "malformed-error": - reply.update(ok=False, error={"code": 42}) - else: - action = request["params"]["action"] - reply["result"] = {"status": "accepted"} if action == "session.exit" else {"status": "applied", "sequence": "9007199254742999"} - else: - raise AssertionError(op) - data = (json.dumps(reply) + "\n").encode() - if event: - if mode == "partial": - conn.sendall(data + b'{"ipc":1,"event":"state","batch":') - conn.shutdown(socket.SHUT_RDWR) - return - if mode == "malformed": - data += b"{bad}\n" - elif mode == "oversize": - data += b"x" * 4259841 + b"\n" - elif mode == "no-snapshot": - pass - else: - data += (json.dumps(event, ensure_ascii=False) + "\n").encode() - # Split inside a UTF-8 codepoint, with reply/event coalesced in the tail. - split = data.find("🫧".encode()) + 1 - if split: - conn.sendall(data[:split]) - time.sleep(.01) - data = data[split:] - traffic["sent"] += split - conn.sendall(data) - traffic["sent"] += len(data) - except (BrokenPipeError, ConnectionResetError, OSError): - pass - except Exception as error: - errors.append(repr(error)) - finally: - conn.close() - - listener = socket.socket(socket.AF_UNIX) - - def accept(): - while not stopping.is_set(): - try: - conn, _ = listener.accept() - except OSError: - return - connections.append(conn) - threading.Thread(target=serve, args=(conn,), daemon=True).start() - - def disconnect(): - for conn in connections[:]: - try: - conn.shutdown(socket.SHUT_RDWR) - conn.close() - except OSError: - pass - connections.clear() - - def ipc(*args): - result = subprocess.run([qs, "ipc", "-p", str(base / "shell.qml"), "call", "test", *args], env=env, - capture_output=True, text=True, timeout=5) - assert result.returncode == 0, (result.stdout, result.stderr, (base / "qml.log").read_text()) - return result.stdout.strip() - - def state(): - return json.loads(ipc("status")) - - def results(count): - value = json.loads(ipc("results")) - return value if len(value) == count else None - - def restart(new_mode): - nonlocal mode - ipc("enabled", "false") - disconnect() - mode = new_mode - requests.clear() - ipc("enabled", "true") - - with (base / "qml.log").open("w") as log: - child = subprocess.Popen([qs, "-p", str(base / "shell.qml")], env=env, stdout=log, stderr=log, start_new_session=True) - try: - wait_for(lambda: (base / "qml.log").read_text().find("Configuration Loaded") >= 0) - ipc("enabled", "true") - assert not state()["available"] - # The compositor can be absent at startup; only DankSocket owns retries. - listener.bind(str(endpoint)) - endpoint.chmod(0o600) - listener.listen() - threading.Thread(target=accept, daemon=True).start() - wait_for(lambda: state()["available"] and state()["state"]["batch"]["sequence"] == "9007199254741999") - assert len([r for r in requests if r["op"] == "hello"]) == 2 - assert "🫧 日本語" in json.dumps(state(), ensure_ascii=False) - mode = "slow" - ipc("commands", "35") - replies = wait_for(lambda: results(35)) - assert len([r for r in replies if "queue full" in r["message"]]) == 2 - sent = [r["params"]["fields"]["name"] for r in requests if r["op"] == "command"] - assert sent == [str(i) for i in range(len(sent))], sent - assert any("before sending" in r["message"] for r in replies), replies - for failure in ("locked", "malformed-error", "disconnect", "timeout"): - mode = failure - ipc("commands", "2") - replies = wait_for(lambda: results(2)) - assert all(not r["ok"] for r in replies), replies - assert ("locked" if failure == "locked" else "uncertain") in replies[0]["message"], replies - mode = "normal" - wait_for(lambda: state()["available"]) - ipc("exitSession") - assert wait_for(lambda: results(1)) == [{"ok": True, "message": "accepted"}] - for failure in ("unsupported", "session-mismatch", "baseline", "malformed", "oversize", "partial", "no-snapshot"): - restart(failure) - wait_for(lambda: state()["state"].get("error", "").find("connecting") < 0 and bool(state()["state"].get("error")), timeout=20) - assert not state()["available"], failure - restart("normal") - wait_for(lambda: state()["available"]) - disconnect() - wait_for(lambda: len([r for r in requests if r["op"] == "hello"]) >= 4 and state()["available"]) - time.sleep(.2) - before = dict(traffic) - stat_before = Path(f"/proc/{child.pid}/stat").read_text().split() - status_before = Path(f"/proc/{child.pid}/status").read_text() - time.sleep(2) - stat_after = Path(f"/proc/{child.pid}/stat").read_text().split() - children = Path(f"/proc/{child.pid}/task/{child.pid}/children").read_text().strip() - assert not children, children - assert before == traffic, (before, traffic) - assert not (base / "calls").read_text(), "aqueousctl was launched" - assert not errors, errors - def fields(status): - return {line.split(":", 1)[0]: line.split(":", 1)[1].strip() for line in status.splitlines() if ":" in line} - status_after = Path(f"/proc/{child.pid}/status").read_text() - first, last = fields(status_before), fields(status_after) - (base / "resources.json").write_text(json.dumps(dict(idle_seconds=2, child_processes=0, traffic=traffic, idle_bytes=0, - rss_before=first["VmRSS"], rss_after=last["VmRSS"], - voluntary_context_switches=int(last["voluntary_ctxt_switches"])-int(first["voluntary_ctxt_switches"]), - idle_cpu_ticks=int(stat_after[13])+int(stat_after[14])-int(stat_before[13])-int(stat_before[14]), - status_before=status_before, status_after=Path(f"/proc/{child.pid}/status").read_text()), indent=2)) - print("PASS: QML hello, UTF-8, ack order, queue, deadlines, errors, reconnect, accepted exit, zero helper launches and idle traffic", flush=True) - finally: - stopping.set() - disconnect() - listener.close() - os.killpg(child.pid, signal.SIGTERM) - child.wait(timeout=10) - - -if __name__ == "__main__": - main()