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:
bbedward
2026-09-07 09:08:46 -04:00
parent ce1ecfc6bc
commit 5a72cd9616
45 changed files with 331 additions and 4335 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
}
-260
View File
@@ -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
}
-188
View File
@@ -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
-1
View File
@@ -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")
}
+2 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+19 -4
View File
@@ -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
+2 -1
View File
@@ -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;
});
+25 -6
View File
@@ -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 -7
View File
@@ -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")
+12 -11
View File
@@ -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({
+50 -6
View File
@@ -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;
}
});
}
+8 -1
View File
@@ -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,
+18 -14
View File
@@ -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);
}
+5 -4
View File
@@ -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");
-86
View File
@@ -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");
-348
View File
@@ -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");
-168
View File
@@ -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");
-105
View File
@@ -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
View File
@@ -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.
-11
View File
@@ -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
View File
@@ -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
View File
@@ -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");
-103
View File
@@ -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",
-549
View File
@@ -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()
-286
View File
@@ -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()
-301
View File
@@ -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()