mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-21 10:44:01 -04:00
aqueous: move IPC integration to QML, delete test fixtures (since we
dont have a QML test structure even setup), delete in-repo docs
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-422
@@ -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/<instance>/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.
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
import qs.DankCommon.Common as DankCommon
|
||||
|
||||
DankCommon.DankSocket {
|
||||
function reconnect() {
|
||||
_teardown();
|
||||
if (connected)
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
DankCommon.DankSocket {}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -1729,6 +1729,7 @@ Item {
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.spacingM
|
||||
visible: KeybindsService.currentProvider !== "aqueous"
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr("Title")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, "[email protected]");
|
||||
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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
-1
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
-20
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
-42
@@ -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"]}
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user