updated dank to support aqueous

This commit is contained in:
Zoey
2026-09-05 17:03:36 -04:00
parent 5baef07048
commit 8874a804b6
69 changed files with 4884 additions and 105 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
@rm -rf $(EMBED_DIR)/.git* $(EMBED_DIR)/.github $(EMBED_DIR)/tests
@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
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"encoding/json"
"fmt"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds/providers"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
"github.com/spf13/cobra"
)
func runAqueousBindEdit(cmd *cobra.Command, args []string, remove bool) {
generation, _ := cmd.Flags().GetString("expected-generation")
edit := providers.AqueousBindEdit{Generation: generation, Key: args[1], Remove: remove}
if !remove {
for _, flag := range []string{"desc", "allow-when-locked", "cooldown-ms", "no-repeat", "no-inhibiting", "flags"} {
if cmd.Flags().Changed(flag) {
log.Fatalf("Aqueous does not support --%s in this provider", flag)
}
}
edit.Action = args[2]
edit.OriginalKey, _ = cmd.Flags().GetString("replace-key")
}
result, err := providers.NewAqueousProvider().Edit(cmd.Context(), edit)
if err != nil {
log.Fatalf("Failed to save Aqueous keybind: %v", err)
}
data, _ := json.Marshal(map[string]any{"success": true, "generation": result.String("generation"), "key": edit.Key})
fmt.Fprintln(cmd.OutOrStdout(), string(data))
}
+18 -2
View File
@@ -66,6 +66,7 @@ var keybindsResetCmd = &cobra.Command{
}
func init() {
keybindsCmd.PersistentFlags().String("expected-generation", "", "Configuration generation observed when editing Aqueous bindings")
keybindsListCmd.Flags().BoolP("json", "j", false, "Output as JSON")
keybindsShowCmd.Flags().String("path", "", "Override config path for the provider")
keybindsSetCmd.Flags().String("desc", "", "Description for hotkey overlay")
@@ -93,6 +94,9 @@ func initializeProviders() {
registry := keybinds.GetDefaultRegistry()
hyprlandProvider := providers.NewHyprlandProvider("")
if err := registry.Register(providers.NewAqueousProvider()); err != nil {
log.Warnf("Failed to register Aqueous provider: %v", err)
}
if err := registry.Register(hyprlandProvider); err != nil {
log.Warnf("Failed to register Hyprland provider: %v", err)
}
@@ -219,6 +223,10 @@ func getWritableProvider(name string) keybinds.WritableProvider {
}
func runKeybindsSet(cmd *cobra.Command, args []string) {
if args[0] == "aqueous" {
runAqueousBindEdit(cmd, args, false)
return
}
providerName, key, action := args[0], args[1], args[2]
writable := getWritableProvider(providerName)
@@ -257,7 +265,11 @@ func runKeybindsSet(cmd *cobra.Command, args []string) {
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsRemove(_ *cobra.Command, args []string) {
func runKeybindsRemove(cmd *cobra.Command, args []string) {
if args[0] == "aqueous" {
runAqueousBindEdit(cmd, args, true)
return
}
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
@@ -273,7 +285,11 @@ func runKeybindsRemove(_ *cobra.Command, args []string) {
fmt.Fprintln(os.Stdout, string(output))
}
func runKeybindsReset(_ *cobra.Command, args []string) {
func runKeybindsReset(cmd *cobra.Command, args []string) {
if args[0] == "aqueous" {
runAqueousBindEdit(cmd, args, true)
return
}
providerName, key := args[0], args[1]
writable := getWritableProvider(providerName)
+6 -3
View File
@@ -20,6 +20,7 @@ import (
var (
ssOutputName string
ssSeat string
ssCursor string
ssFormat string
ssQuality int
@@ -58,7 +59,7 @@ Modes:
full - Capture the focused output
all - Capture all outputs combined
output - Capture a specific output by name
window - Capture the focused window (Hyprland/Mango/niri)
window - Capture the focused window (Hyprland/Mango/niri/Aqueous)
last - Capture the last selected region
scroll - Select a region, then scroll to capture a stitched tall image
@@ -72,7 +73,7 @@ Examples:
dms screenshot full # Full screen of focused output
dms screenshot all # All screens combined
dms screenshot output -o DP-1 # Specific output
dms screenshot window # Focused window (Hyprland)
dms screenshot window # Focused window
dms screenshot last # Last region (pre-selected)
dms screenshot --reset # Reset last region pre-selection
dms screenshot --no-clipboard # Save file only
@@ -122,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, and niri.`,
Long: `Capture the currently focused window. Supported on Hyprland, Mango, niri, and Aqueous. Aqueous crops the composed output including borders and occluders.`,
Run: runScreenshotWindow,
}
@@ -165,6 +166,7 @@ var notifyActionCmd = &cobra.Command{
func init() {
screenshotCmd.PersistentFlags().StringVarP(&ssOutputName, "output", "o", "", "Output name for 'output' mode")
screenshotCmd.PersistentFlags().StringVar(&ssSeat, "seat", "", "Seat for Aqueous capture (required with multiple seats)")
screenshotCmd.PersistentFlags().StringVar(&ssCursor, "cursor", "off", "Include cursor in screenshot (on/off)")
screenshotCmd.PersistentFlags().StringVarP(&ssFormat, "format", "f", "png", "Output format (png, jpg, ppm)")
screenshotCmd.PersistentFlags().IntVarP(&ssQuality, "quality", "q", 90, "JPEG quality (1-100)")
@@ -198,6 +200,7 @@ func getScreenshotConfig(mode screenshot.Mode) screenshot.Config {
config := screenshot.DefaultConfig()
config.Mode = mode
config.OutputName = ssOutputName
config.Seat = ssSeat
if strings.EqualFold(ssCursor, "on") {
config.Cursor = screenshot.CursorOn
}
+155
View File
@@ -0,0 +1,155 @@
package providers
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
)
type AqueousProvider struct{}
func NewAqueousProvider() *AqueousProvider { return &AqueousProvider{} }
func (*AqueousProvider) Name() string { return "aqueous" }
func aqueousObjects(snapshot aqueousConfig, key string) []aqueousConfig {
values, _ := snapshot[key].([]any)
result := make([]aqueousConfig, 0, len(values))
for _, value := range values {
if object, ok := value.(map[string]any); ok {
result = append(result, aqueousConfig(object))
}
}
return result
}
func AqueousCheatSheet(snapshot aqueousConfig) (*keybinds.CheatSheet, error) {
if err := requireAqueousCapabilities(snapshot, "keybinds"); err != nil {
return nil, err
}
sheet := &keybinds.CheatSheet{Title: "Aqueous", Provider: "aqueous", Generation: snapshot.String("generation"), ModKey: "Super", DMSBindsIncluded: true, Binds: map[string][]keybinds.Keybind{"Compositor": {}, "Custom": {}}}
for _, field := range aqueousObjects(snapshot, "fields") {
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
continue
}
values, _ := field["value"].([]any)
if len(values) == 0 {
values = []any{""}
}
for _, value := range values {
key, ok := value.(string)
if !ok {
return nil, errors.New("invalid keybinding list")
}
sheet.Binds["Compositor"] = append(sheet.Binds["Compositor"], keybinds.Keybind{Key: key, Action: field.String("id"), Description: field.String("label"), Source: "dms"})
}
}
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
sheet.Binds["Custom"] = append(sheet.Binds["Custom"], keybinds.Keybind{Key: binding.String("chord"), Action: "spawn " + binding.String("command"), Description: binding.String("command"), Source: "dms"})
}
seen := map[string]keybinds.Keybind{}
for _, category := range []string{"Compositor", "Custom"} {
for i, bind := range sheet.Binds[category] {
if bind.Key == "" {
continue
}
if previous, exists := seen[bind.Key]; exists {
sheet.Binds[category][i].Conflict = &previous
}
seen[bind.Key] = bind
}
}
return sheet, nil
}
func (*AqueousProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
snapshot, err := aqueousHelper(context.Background(), "snapshot", nil)
if err != nil {
return nil, err
}
return AqueousCheatSheet(snapshot)
}
type AqueousBindEdit struct {
Generation string
OriginalKey string
Key string
Action string
Remove bool
}
func AqueousBindRequest(snapshot aqueousConfig, edit AqueousBindEdit) (aqueousConfig, error) {
if edit.Generation == "" || edit.Generation != snapshot.String("generation") {
return nil, errors.New("external_change: reload and reconcile the retained keybind draft")
}
if err := requireAqueousCapabilities(snapshot, "keybinds"); err != nil {
return nil, err
}
if edit.Key == "" {
return nil, errors.New("missing key")
}
original := edit.OriginalKey
if original == "" {
original = edit.Key
}
changes := []any{}
customChanges := []any{}
foundAction := false
for _, field := range aqueousObjects(snapshot, "fields") {
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
continue
}
old, _ := field["value"].([]any)
values := make([]any, 0, len(old)+1)
for _, value := range old {
if value == original {
continue
}
if !edit.Remove && value == edit.Key && field.String("id") != edit.Action {
return nil, fmt.Errorf("key conflict: %s", edit.Key)
}
values = append(values, value)
}
if field.String("id") == edit.Action && !edit.Remove {
foundAction = true
if !slices.Contains(values, any(edit.Key)) {
values = append(values, edit.Key)
}
}
if !slices.Equal(old, values) {
changes = append(changes, map[string]any{"id": field.String("id"), "value": values})
}
}
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
if binding.String("chord") == original {
customChanges = append(customChanges, map[string]any{"op": "delete", "id": binding.String("id")})
continue
}
if !edit.Remove && binding.String("chord") == edit.Key {
return nil, fmt.Errorf("key conflict: %s", edit.Key)
}
}
if !edit.Remove && !foundAction {
command, ok := strings.CutPrefix(edit.Action, "spawn ")
if !ok || strings.TrimSpace(command) == "" {
return nil, errors.New("unknown Aqueous action; custom commands use spawn")
}
customChanges = append(customChanges, map[string]any{"op": "add", "chord": edit.Key, "command": command})
}
return aqueousConfig{"expected_generation": edit.Generation, "changes": changes, "custom_keybind_changes": customChanges, "create_user_override": true}, nil
}
func (*AqueousProvider) Edit(ctx context.Context, edit AqueousBindEdit) (aqueousConfig, error) {
snapshot, err := aqueousHelper(ctx, "snapshot", nil)
if err != nil {
return nil, err
}
request, err := AqueousBindRequest(snapshot, edit)
if err != nil {
return nil, err
}
return aqueousHelper(ctx, "apply", request)
}
@@ -0,0 +1,105 @@
package providers
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
func helperResult(ctx context.Context, verb string, input []byte) (aqueousConfig, error) {
var result aqueousConfig
args := []string{verb, "--shell", "dms"}
if input != nil {
args = append(args, "--request", "-")
}
err := utils.RunJSON(ctx, "aqueous-config", args, input, &result)
if err != nil || !result.Bool("ok") {
if result.String("code") != "" {
return result, fmt.Errorf("%s: %s", result.String("code"), result.String("message"))
}
if err != nil {
return result, err
}
return result, errors.New("aqueous-config rejected the request")
}
return result, nil
}
func requireAqueousCapabilities(result aqueousConfig, required ...string) error {
if result.Number("protocol") != 1 {
return errors.New("unsupported aqueous-config protocol")
}
values, ok := result["capabilities"].([]any)
if !ok {
return errors.New("unsupported aqueous-config: capability discovery required")
}
for _, capability := range required {
if !slices.Contains(values, any(capability)) {
return fmt.Errorf("unsupported aqueous-config capability: %s", capability)
}
}
return nil
}
func aqueousHelper(ctx context.Context, verb string, request aqueousConfig) (aqueousConfig, error) {
if verb == "snapshot" {
result, err := helperResult(ctx, verb, nil)
if err != nil {
return result, err
}
if err := requireAqueousCapabilities(result, "schema_fields", "shell_dms"); err != nil {
return result, err
}
if result.String("generation") == "" {
return result, errors.New("missing configuration generation")
}
return result, nil
}
if verb != "validate" && verb != "apply" {
return nil, errors.New("unsupported helper operation")
}
if request.String("expected_generation") == "" {
return nil, errors.New("missing expected_generation; reload configuration")
}
version, err := helperResult(ctx, "version", nil)
if err != nil {
return nil, err
}
required := []string{"keybinds", "validate", "stdin_requests", "generation_check", "atomic_file_replace", "shell_dms"}
if err := requireAqueousCapabilities(version, required...); err != nil {
return nil, err
}
request["protocol"] = 1
if _, exists := request["backup_dir"]; !exists {
configDir, err := os.UserConfigDir()
if err != nil {
return nil, err
}
request["backup_dir"] = filepath.Join(configDir, "DankMaterialShell", "aqueous-backups")
}
input, err := json.Marshal(request)
if err != nil {
return nil, err
}
validated, err := helperResult(ctx, "validate", input)
if err != nil || verb == "validate" {
return validated, err
}
return helperResult(ctx, "apply", input)
}
type aqueousConfig map[string]any
func (e aqueousConfig) String(key string) string { value, _ := e[key].(string); return value }
func (e aqueousConfig) Bool(key string) bool { value, _ := e[key].(bool); return value }
func (e aqueousConfig) Number(key string) float64 {
value, _ := e[key].(json.Number)
number, _ := value.Float64()
return number
}
@@ -0,0 +1,72 @@
package providers
import (
"encoding/json"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
"reflect"
"testing"
)
func aqueousFixture(t *testing.T) aqueousConfig {
t.Helper()
var snapshot aqueousConfig
err := utils.DecodeJSON([]byte(`{"ok":true,"protocol":1,"generation":"abc","capabilities":["keybinds"],"fields":[{"id":"spawn_terminal","category":"keybinds","type":"string_list","label":"Terminal","value":["Super+Return","Super+T"]},{"id":"close","category":"keybinds","type":"string_list","label":"Close","value":[]}],"custom_keybinds":[{"id":"custom:12","chord":"Super+R","command":"printf 'hello; $world'"}]}`), &snapshot)
if err != nil {
t.Fatal(err)
}
return snapshot
}
func TestAqueousCheatsheetIncludesUnboundAndCustom(t *testing.T) {
sheet, err := AqueousCheatSheet(aqueousFixture(t))
if err != nil {
t.Fatal(err)
}
if sheet.Generation != "abc" || len(sheet.Binds["Compositor"]) != 3 {
t.Fatal("lost generation or multiple bindings")
}
if sheet.Binds["Compositor"][2].Key != "" || sheet.Binds["Compositor"][2].Action != "close" {
t.Fatal("unbound action lost")
}
if sheet.Binds["Custom"][0].Action != "spawn printf 'hello; $world'" {
t.Fatal("custom command modified")
}
}
func TestAqueousBindingReplacementIsOneRequest(t *testing.T) {
snapshot := aqueousFixture(t)
request, err := AqueousBindRequest(snapshot, AqueousBindEdit{Generation: "abc", OriginalKey: "Super+Return", Key: "Super+Enter", Action: "spawn_terminal"})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(request["changes"])
if string(data) != `[{"id":"spawn_terminal","value":["Super+T","Super+Enter"]}]` {
t.Fatalf("wrong atomic replacement: %s", data)
}
if request["expected_generation"] != "abc" {
t.Fatal("generation replaced")
}
if !reflect.DeepEqual(snapshot, aqueousFixture(t)) {
t.Fatal("draft mutated snapshot")
}
}
func TestAqueousBindingConflictAndRemove(t *testing.T) {
for _, edit := range []AqueousBindEdit{
{Generation: "stale", Key: "Super+T", Remove: true},
{Generation: "abc", OriginalKey: "Super+Return", Key: "Super+R", Action: "close"},
{Generation: "abc", Key: "Super+Q", Action: "invented_action"},
} {
if _, err := AqueousBindRequest(aqueousFixture(t), edit); err == nil {
t.Fatal("invalid edit accepted")
}
}
request, err := AqueousBindRequest(aqueousFixture(t), AqueousBindEdit{Generation: "abc", Key: "Super+R", Remove: true})
if err != nil {
t.Fatal(err)
}
data, _ := json.Marshal(request["custom_keybind_changes"])
if string(data) != `[{"id":"custom:12","op":"delete"}]` {
t.Fatalf("wrong removal: %s", data)
}
}
+1
View File
@@ -30,6 +30,7 @@ type DMSBindsStatus struct {
}
type CheatSheet struct {
Generation string `json:"generation,omitempty"`
Title string `json:"title"`
Provider string `json:"provider"`
ModKey string `json:"modKey,omitempty"`
+96
View File
@@ -0,0 +1,96 @@
package screenshot
import (
"context"
"errors"
"image"
"math"
)
func aqueousWindowGeometry(model aqueousSnapshotModel, seatName string) (*WindowGeometry, error) {
seat, err := model.Seat(seatName)
if err != nil {
return nil, err
}
if seat.FocusKind != "window" {
return nil, errors.New("no active window: seat focus is not a window")
}
window := model.Entities["window:"+seat.Window]
if window == nil || !window.Visible || window.Minimized || !window.CanActivate {
return nil, errors.New("no eligible active window")
}
output := model.Entities["output:"+window.Output]
if output == nil || !output.Enabled || !output.Powered {
return nil, errors.New("active window output is unavailable")
}
box, bounds := window.OuterGeometry, output.Bounds
if box == nil || bounds == nil || !box.valid() || !bounds.valid() || box.Width <= 0 || box.Height <= 0 || output.Scale <= 0 || math.IsInf(output.Scale, 0) || math.IsNaN(output.Scale) {
return nil, errors.New("invalid active window geometry")
}
return &WindowGeometry{X: int32(box.X), Y: int32(box.Y), Width: int32(box.Width), Height: int32(box.Height), Output: output.Name, Scale: output.Scale, OutputX: int32(bounds.X), OutputY: int32(bounds.Y)}, nil
}
func aqueousCropRect(geom *WindowGeometry, pixels image.Rectangle) image.Rectangle {
left := math.Floor((float64(geom.X) - float64(geom.OutputX)) * geom.Scale)
top := math.Floor((float64(geom.Y) - float64(geom.OutputY)) * geom.Scale)
right := math.Ceil((float64(geom.X) + float64(geom.Width) - float64(geom.OutputX)) * geom.Scale)
bottom := math.Ceil((float64(geom.Y) + float64(geom.Height) - float64(geom.OutputY)) * geom.Scale)
return image.Rect(int(left), int(top), int(right), int(bottom)).Intersect(pixels)
}
func aqueousFocusedOutput(seatName string) (string, error) {
model, err := aqueousSnapshot(context.Background())
if err != nil {
return "", err
}
seat, err := model.Seat(seatName)
if err != nil {
return "", err
}
output := model.Entities["output:"+seat.Output]
if output == nil || !output.Enabled || !output.Powered {
return "", errors.New("selected output is unavailable")
}
return output.Name, nil
}
func (s *Screenshoter) captureAqueousWindow() (*CaptureResult, error) {
model, err := aqueousSnapshot(context.Background())
if err != nil {
return nil, err
}
geom, err := aqueousWindowGeometry(model, s.config.Seat)
if err != nil {
return nil, err
}
region := Region{X: geom.X, Y: geom.Y, Width: geom.Width, Height: geom.Height, Output: geom.Output}
if s.config.Geometry {
return geometryResult(region), nil
}
output := s.findOutputByName(geom.Output)
if output == nil {
return nil, errors.New("active window output disappeared")
}
result, err := s.captureWholeOutput(output)
if err != nil {
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")
}
bpp := PixelFormat(result.Format).BytesPerPixel()
cropped, err := CreateShmBuffer(rect.Dx(), rect.Dy(), rect.Dx()*bpp)
if err != nil {
return nil, err
}
src, dst := result.Buffer.Data(), cropped.Data()
for y := 0; y < rect.Dy(); y++ {
start := (rect.Min.Y+y)*result.Buffer.Stride + rect.Min.X*bpp
copy(dst[y*cropped.Stride:y*cropped.Stride+rect.Dx()*bpp], src[start:start+rect.Dx()*bpp])
}
cropped.Format = PixelFormat(result.Format)
return &CaptureResult{Buffer: cropped, Region: region, Format: result.Format, Scale: geom.Scale, CICP: result.CICP}, nil
}
@@ -0,0 +1,109 @@
package screenshot
import (
"bytes"
"context"
"encoding/json"
"errors"
"math"
"regexp"
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
)
type aqueousBox struct {
X, Y, Width, Height int64
}
func (b aqueousBox) valid() bool {
for _, value := range []int64{b.X, b.Y, b.Width, b.Height} {
if value < math.MinInt32 || value > math.MaxInt32 {
return false
}
}
return true
}
type aqueousCaptureEntity struct {
Kind string `json:"kind"`
ID string `json:"id"`
Name string `json:"name"`
Output string `json:"output"`
Window string `json:"window"`
FocusKind string `json:"focus_kind"`
Visible bool `json:"visible"`
Minimized bool `json:"minimized"`
CanActivate bool `json:"can_activate"`
Enabled bool `json:"enabled"`
Powered bool `json:"powered"`
Scale float64 `json:"scale"`
Bounds *aqueousBox `json:"bounds"`
OuterGeometry *aqueousBox `json:"outer_geometry"`
}
type aqueousSnapshotModel struct {
Entities map[string]*aqueousCaptureEntity
}
func (m aqueousSnapshotModel) Seat(name string) (*aqueousCaptureEntity, error) {
if name != "" {
if seat := m.Entities["seat:"+name]; seat != nil {
return seat, nil
}
return nil, errors.New("not_found: seat")
}
var seat *aqueousCaptureEntity
for _, entity := range m.Entities {
if entity.Kind != "seat" {
continue
}
if seat != nil {
return nil, errors.New("ambiguous_seat")
}
seat = entity
}
if seat == nil {
return nil, errors.New("unavailable: no seat")
}
return seat, nil
}
func parseAqueousSnapshot(data []byte) (aqueousSnapshotModel, error) {
var batch struct {
Schema int `json:"schema"`
Session string `json:"session"`
Sequence string `json:"sequence"`
BaseSequence json.RawMessage `json:"base_sequence"`
Type string `json:"type"`
Upsert []aqueousCaptureEntity `json:"upsert"`
Removed []string `json:"removed"`
}
var model aqueousSnapshotModel
if err := utils.DecodeJSON(data, &batch); err != nil {
return model, err
}
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")
}
model.Entities = make(map[string]*aqueousCaptureEntity, len(batch.Upsert))
for i := range batch.Upsert {
entity := &batch.Upsert[i]
key := entity.Kind + ":" + entity.ID
if entity.ID == "" || entity.Kind == "" || model.Entities[key] != nil {
return aqueousSnapshotModel{}, errors.New("invalid or duplicate shell entity")
}
model.Entities[key] = entity
}
if model.Entities["session:session"] == nil {
return aqueousSnapshotModel{}, errors.New("missing session entity")
}
return model, nil
}
func aqueousSnapshot(ctx context.Context) (aqueousSnapshotModel, error) {
var data json.RawMessage
if err := utils.RunJSON(ctx, "aqueousctl", []string{"shell", "snapshot", "--json"}, nil, &data); err != nil {
return aqueousSnapshotModel{}, err
}
return parseAqueousSnapshot(data)
}
+79
View File
@@ -0,0 +1,79 @@
package screenshot
import (
"bytes"
"image"
"os"
"testing"
)
func TestAqueousCropRoundsOutwardAndClips(t *testing.T) {
for _, test := range []struct {
geometry WindowGeometry
pixels image.Rectangle
want image.Rectangle
}{
{WindowGeometry{X: -1919, Y: 1, Width: 101, Height: 51, OutputX: -1920, Scale: 1.25}, image.Rect(0, 0, 2400, 1350), image.Rect(1, 1, 128, 65)},
{WindowGeometry{X: -20, Y: -10, Width: 100, Height: 60, Scale: 1.5}, image.Rect(0, 0, 800, 600), image.Rect(0, 0, 120, 75)},
{WindowGeometry{X: 90, Y: 80, Width: 100, Height: 100, Scale: 1}, image.Rect(0, 0, 100, 100), image.Rect(90, 80, 100, 100)},
{WindowGeometry{X: 200, Y: 200, Width: 20, Height: 20, Scale: 1}, image.Rect(0, 0, 100, 100), image.Rectangle{}},
} {
if got := aqueousCropRect(&test.geometry, test.pixels); got != test.want {
t.Fatalf("got %v want %v", got, test.want)
}
}
}
func TestAqueousGeometryUsesSeatAndOuterBounds(t *testing.T) {
data, err := os.ReadFile("testdata/aqueous-snapshot.json")
if err != nil {
t.Fatal(err)
}
model, err := parseAqueousSnapshot(data)
if err != nil {
t.Fatal(err)
}
geometry, err := aqueousWindowGeometry(model, "")
if err != nil {
t.Fatal(err)
}
seat, _ := model.Seat("")
window := model.Entities["window:"+seat.Window]
if geometry.Width != int32(window.OuterGeometry.Width) || geometry.X != int32(window.OuterGeometry.X) {
t.Fatal("wrong bounds")
}
seat.FocusKind = "layer_surface"
if _, err := aqueousWindowGeometry(model, ""); err == nil {
t.Fatal("layer focus captured")
}
seat.FocusKind = "window"
model.Entities["seat:second"] = &aqueousCaptureEntity{Kind: "seat", ID: "second"}
if _, err := aqueousWindowGeometry(model, ""); err == nil {
t.Fatal("ambiguous seat captured")
}
if _, err := aqueousWindowGeometry(model, seat.ID); err != nil {
t.Fatal(err)
}
delete(model.Entities, "window:"+seat.Window)
if _, err := aqueousWindowGeometry(model, seat.ID); err == nil {
t.Fatal("removed window captured")
}
}
func TestAqueousSnapshotRejectsInvalidInput(t *testing.T) {
data, err := os.ReadFile("testdata/aqueous-snapshot.json")
if err != nil {
t.Fatal(err)
}
for _, input := range [][]byte{
[]byte(`{}`),
bytes.Replace(data, []byte(`"schema": 1`), []byte(`"schema": 2`), 1),
bytes.Replace(data, []byte(`"type": "snapshot"`), []byte(`"type": "delta"`), 1),
append(data, []byte(`{}`)...),
{0xff},
} {
if _, err := parseAqueousSnapshot(input); err == nil {
t.Fatal("invalid snapshot accepted")
}
}
}
+21
View File
@@ -1,6 +1,7 @@
package screenshot
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -21,6 +22,7 @@ const (
CompositorScroll
CompositorMiracle
CompositorMango
CompositorAqueous
)
var detectedCompositor Compositor = -1
@@ -29,6 +31,16 @@ func DetectCompositor() Compositor {
if detectedCompositor >= 0 {
return detectedCompositor
}
owner := waylandSocketOwner()
known := map[string]Compositor{"aqueous": CompositorAqueous, "hyprland": CompositorHyprland, "niri": CompositorNiri, "sway": CompositorSway, "scroll": CompositorScroll, "miracle-wm": CompositorMiracle, "mango": CompositorMango}
if compositor, ok := known[owner]; ok {
detectedCompositor = compositor
return compositor
}
if owner != "" {
detectedCompositor = CompositorUnknown
return detectedCompositor
}
candidates := []struct {
socket string
@@ -78,6 +90,12 @@ func GetActiveWindow() (*WindowGeometry, error) {
return getHyprlandActiveWindow()
case CompositorMango:
return getMangoActiveWindow()
case CompositorAqueous:
model, err := aqueousSnapshot(context.Background())
if err != nil {
return nil, err
}
return aqueousWindowGeometry(model, "")
default:
return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
}
@@ -355,6 +373,9 @@ func getNiriFocusedMonitor() string {
func GetFocusedMonitor() string {
switch DetectCompositor() {
case CompositorAqueous:
name, _ := aqueousFocusedOutput("")
return name
case CompositorHyprland:
return getHyprlandFocusedMonitor()
case CompositorSway:
@@ -0,0 +1,45 @@
package screenshot
import (
"golang.org/x/sys/unix"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
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)))
}
@@ -0,0 +1,5 @@
//go:build !linux
package screenshot
func waylandSocketOwner() string { return "" }
+10
View File
@@ -220,6 +220,9 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
}
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
if DetectCompositor() == CompositorAqueous {
return s.captureAqueousWindow()
}
if DetectCompositor() == CompositorNiri {
if s.config.Geometry {
return nil, fmt.Errorf("window geometry mode is not supported on niri")
@@ -362,6 +365,13 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
}
func (s *Screenshoter) captureFullScreen() (*CaptureResult, error) {
if DetectCompositor() == CompositorAqueous {
name, err := aqueousFocusedOutput(s.config.Seat)
if err != nil {
return nil, err
}
return s.captureOutput(name)
}
output := s.findFocusedOutput()
if output == nil {
s.outputsMu.Lock()
+1
View File
@@ -0,0 +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.
@@ -0,0 +1 @@
{"schema": 1, "session": "0123456789abcdef0123456789abcdef", "sequence": "5", "base_sequence": null, "type": "snapshot", "upsert": [{"kind": "workspace", "id": "12", "output": "2", "name": "same \"name\"", "number": 3, "active": true, "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": false, "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": "ca89b2eb6850b81d12b92763969d82ca", "focus_kind": "window", "keyboard": "1"}, {"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": "12"}, {"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": "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}], "removed": []}
+1
View File
@@ -56,6 +56,7 @@ type Output struct {
}
type Config struct {
Seat string
Mode Mode
OutputName string
Cursor CursorMode
+74
View File
@@ -0,0 +1,74 @@
package utils
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os/exec"
"time"
"unicode/utf8"
)
const maxJSONProcessBytes = 4 * 1024 * 1024
type boundedWriter struct {
bytes.Buffer
limit int
}
func (w *boundedWriter) Write(p []byte) (int, error) {
if len(p) > w.limit-w.Len() {
return 0, errors.New("process output exceeds size bound")
}
return w.Buffer.Write(p)
}
func RunJSON(ctx context.Context, executable string, args []string, input []byte, result any) error {
if len(input) > maxJSONProcessBytes {
return errors.New("request exceeds size bound")
}
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, executable, args...)
cmd.WaitDelay = time.Second
stdout := &boundedWriter{limit: maxJSONProcessBytes + 1}
stderr := &boundedWriter{limit: 16 * 1024}
cmd.Stdout, cmd.Stderr = stdout, stderr
if input != nil {
cmd.Stdin = bytes.NewReader(input)
}
err := cmd.Run()
if ctx.Err() != nil {
return fmt.Errorf("unavailable: command completion uncertain: %w", ctx.Err())
}
if stdout.Len() != 0 {
if decodeErr := DecodeJSON(bytes.TrimSuffix(stdout.Bytes(), []byte{'\n'}), result); decodeErr != nil {
return errors.Join(err, fmt.Errorf("invalid process JSON: %w", decodeErr))
}
}
if err != nil {
return fmt.Errorf("%s: %w", executable, err)
}
if stdout.Len() == 0 {
return errors.New("missing command acknowledgement")
}
return nil
}
func DecodeJSON(data []byte, target any) error {
if len(data) > maxJSONProcessBytes || !utf8.Valid(data) {
return errors.New("invalid UTF-8 or oversized JSON record")
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(new(any)); err != io.EOF {
return errors.New("trailing JSON data")
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
package utils
import (
"context"
"fmt"
"os"
"strings"
"testing"
"time"
)
func TestJSONProcessFixture(t *testing.T) {
mode := os.Getenv("DMS_JSON_PROCESS_FIXTURE")
if mode == "" {
return
}
switch mode {
case "accepted":
fmt.Print(`{"ok":true,"status":"accepted","sequence":"9007199254740993"}`)
case "failure":
fmt.Print(`{"ok":false,"status":"locked","sequence":"1"}`)
os.Exit(1)
case "malformed":
fmt.Print("invalid JSON")
case "timeout":
time.Sleep(time.Minute)
case "oversize":
fmt.Print(strings.Repeat("x", maxJSONProcessBytes+2))
}
os.Exit(0)
}
func TestRunJSONResultsAndTimeout(t *testing.T) {
for _, mode := range []string{"accepted", "failure", "malformed", "oversize", "timeout"} {
t.Run(mode, func(t *testing.T) {
t.Setenv("DMS_JSON_PROCESS_FIXTURE", mode)
limit := 4 * time.Second
if mode == "timeout" {
limit = 100 * time.Millisecond
}
ctx, cancel := context.WithTimeout(context.Background(), limit)
defer cancel()
var result jsonProcessResult
err := RunJSON(ctx, os.Args[0], []string{"-test.run=^TestJSONProcessFixture$"}, nil, &result)
if mode == "accepted" {
if err != nil || !result.OK || result.Status != "accepted" || result.Sequence != "9007199254740993" {
t.Fatalf("lost acknowledgement: %v %#v", err, result)
}
return
}
if err == nil {
t.Fatal("failure accepted")
}
if mode == "failure" && result.Status != "locked" {
t.Fatal("operation failure lost")
}
})
}
}
type jsonProcessResult struct {
OK bool `json:"ok"`
Status string `json:"status"`
Sequence string `json:"sequence"`
}
File diff suppressed because it is too large Load Diff
+210
View File
@@ -0,0 +1,210 @@
# Aqueous integration
This working tree integrates DMS with Aqueous master
`2d8b07fb1ff2354ea0c3acddd5cc84ceb823d6eb`. It is development-build support;
no released minimum Aqueous or DMS version has been established.
The tested DMS baseline is `5baef07048656867a374d210d4305911b8a76e4f`, with
common QML `26396ce432d6c71c3f5367438f96f4a8d667e160` plus the working-tree
optional `parentWindow` compatibility fix. The runtime was Arch's
`noctalia-qs 0.0.12`; its version output supplies no source revision.
The Aqueous compositor, CLI and `aqueous-config` helper were built from the same
pinned master. The helper reports 0.7.1, protocol 1. The system-installed older
Aqueous and helper binaries were not used for integration testing.
The installed Quickshell toplevel metadata exposes no foreign-toplevel identifier,
so the adapter uses Aqueous's own model without a native Quickshell binding change.
## 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
Put the matching `aqueousctl` and optional `aqueous-config` binaries on DMS's PATH.
Start DMS in the Aqueous session using the project's existing direct-session or
UWSM launch arrangement. Run only one DMS instance for that session.
Other compositors do not launch the Aqueous watcher or configuration helper.
`AqueousService.qml` owns one persistent `aqueousctl shell watch --json` process.
Its reducer validates schema, string IDs/sequences and delta continuity, applies
full replacements/removals atomically, and shares the authoritative model with
shell consumers. Discovery, reconnect backoff with jitter, seat selection and
short-lived action processes also live in this service. There is no Aqueous core
manager, subscription or server API. Backend changes and shell shutdown stop its
processes; a quiet established watcher has no timer or polling subprocess.
Quickshell's `SplitParser` reassembles complete lines, preserving split UTF-8.
The pinned `aqueousctl` validates bounded UTF-8 batches before emitting NDJSON;
QML additionally checks complete record and retained model sizes. Quickshell does
not expose a pre-delimiter byte limit or raw-byte validity check on this parser.
Consequently the direct service relies on the supported CLI for those guarantees;
it does not independently bound a broken CLI's unterminated output. Enforcing that
boundary independently requires a Quickshell parser capability.
Mutation arguments are arrays, never shell command strings. The service validates
the captured session, explicit seat, capabilities, lock and current target before
dispatch. Disconnects disable stale-ID actions and require a fresh snapshot.
Commands connect directly through `aqueousctl`; the current CLI has no expected-session
argument, so a restart between validation and the child connecting cannot be made
atomic by QML. Close and exit return `accepted`, while window removal follows the
stream. Timed-out mutations are not automatically retried. Qt may exit on Wayland
disconnect before the shell can display the orderly-logout acknowledgement.
Standalone screenshots obtain one fresh snapshot in `core/internal/screenshot`.
Core keybind tooling invokes the helper from its existing provider package. Their
only shared process utility is a bounded, short-lived JSON command runner.
All persistent writes go through `aqueous-config validate/apply --shell dms
--request -`, with JSON on stdin and `expected_generation`. Backups go into
`$XDG_CONFIG_HOME/DankMaterialShell/aqueous-backups`. There is no DMS TOML writer.
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.
### 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.
## 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 CLI/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.
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 resolved to connector names there.
Settings use `AqueousConfigService` and direct stdin helper requests. See the
[complete contract and schema](aqueous-integration-plan.md) for protocol semantics.
## Verification
The following passed against the pinned build on 2026-09-05:
- 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.
The UI runs included the ungrouped taskbar, dock and keyboard widget.
The XWayland DMS run also captured a synthetic XWayland window (632×612), 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/aqueous-service.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 --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 fixes and optional `parentWindow` compatibility changes
independently reviewable. The common-QML fix is an uncommitted submodule change;
an upstream landing will need a common-QML commit and coordinated dependency pin.
+67
View File
@@ -0,0 +1,67 @@
.pragma library
function fingerprint(outputs) {
return JSON.stringify(outputs.map(o => ({name: o.name, id: o.id, enabled: o.enabled,
x: o.x, y: o.y, scale: o.scale, transform: o.transform,
mode: o.currentMode ? [o.currentMode.width, o.currentMode.height, o.currentMode.refresh] : null,
adaptiveSync: o.adaptiveSync})).sort((a, b) => a.name.localeCompare(b.name)));
}
function heads(outputs) {
return outputs.map(o => ({name: o.name, enabled: o.enabled,
modeId: o.currentMode?.id, position: {x: o.x, y: o.y},
scale: o.scale, transform: o.transform, adaptiveSync: o.adaptiveSync}));
}
function matches(candidate, actual, original) {
if (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);
if (!output || output.enabled !== h.enabled)
return false;
if (!h.enabled)
return true;
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
&& !!mode && output.currentMode?.width === mode.width && output.currentMode?.height === mode.height
&& output.currentMode?.refresh === mode.refresh;
});
}
function request(snapshot, outputs, original) {
if (!snapshot.capabilities?.includes("monitor_modes"))
throw new Error("unsupported: monitor_modes");
const raw = (snapshot.raw_files?.outputs || "") + "\n" + (snapshot.raw_files?.wm || "");
if (/^\s*edid\s*=/m.test(raw))
throw new Error("unsupported: this aqueous-config version does not expose EDID monitor edits");
const transforms = ["normal", "90", "180", "270", "flipped", "flipped-90", "flipped-180", "flipped-270"];
const changes = [];
if ((snapshot.monitors || []).some(m => /[*?\[]/.test(m.name)))
throw new Error("unsupported: reconcile wildcard monitor configuration before persisting displays");
for (const output of outputs) {
const before = original.find(o => o.name === output.name);
if (!before)
throw new Error("conflict: output changed during preview");
if (output.enabled !== before.enabled)
throw new Error("unsupported: this aqueous-config version cannot persist output enablement");
if (output.adaptiveSync !== before.adaptiveSync)
throw new Error("unsupported: this aqueous-config version cannot persist adaptive sync");
if (!output.enabled)
continue;
const configured = (snapshot.monitors || []).filter(m => m.name === output.name);
if (configured.length > 1)
throw new Error("conflict: multiple configured monitor entries");
const monitor = configured[0];
if (Math.abs(output.scale - (monitor?.scale ?? 1)) > 0.0001)
throw new Error("unsupported: this aqueous-config version cannot persist output scale");
const mode = output.currentMode;
if (!mode)
throw new Error("unavailable: output mode");
changes.push({id: monitor?.id || "live:" + output.name, name: output.name,
x: output.x, y: output.y, transform: transforms[output.transform],
mode: mode.width + "x" + mode.height + "@" + (mode.refresh / 1000)});
}
return {expected_generation: snapshot.generation, monitor_changes: changes, create_user_override: true};
}
+6 -6
View File
@@ -496,7 +496,7 @@ Item {
hasMaximizedToplevel = false;
return;
}
if (!CompositorService.isHyprland && !CompositorService.isNiri) {
if (!CompositorService.isHyprland && !CompositorService.isNiri && !CompositorService.isAqueous) {
hasMaximizedToplevel = false;
return;
}
@@ -520,7 +520,7 @@ Item {
shouldHideForWindows = false;
return;
}
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango) {
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango && !CompositorService.isAqueous) {
shouldHideForWindows = false;
return;
}
@@ -795,7 +795,7 @@ Item {
readonly property int barThickness: Theme.px(barWindow.effectiveBarThickness + barWindow.effectiveSpacing, barWindow._dpr)
readonly property int lengthPaddingPx: Theme.px(barWindow.effectiveBarLengthPadding, barWindow._dpr)
readonly property bool inOverviewWithShow: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
readonly property bool inOverviewWithShow: CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview
readonly property bool effectiveVisible: (barConfig?.visible ?? true) || inOverviewWithShow
readonly property bool showing: effectiveVisible && (topBarCore.reveal || inOverviewWithShow)
@@ -986,7 +986,7 @@ Item {
}
property bool reveal: {
const inOverviewWithShow = CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview;
const inOverviewWithShow = CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview;
if (inOverviewWithShow)
return true;
@@ -997,7 +997,7 @@ Item {
return true;
}
if (CompositorService.isNiri && NiriService.inOverview)
if (CompositorService.overviewActiveOnScreen(barWindow.screenName))
return hoverReveal || popoutPinsReveal || revealSticky || ipcReveal;
return (barConfig?.visible ?? true) && (!autoHide || hoverReveal || popoutPinsReveal || revealSticky || ipcReveal);
@@ -1062,7 +1062,7 @@ Item {
top: barWindow.isVertical ? parent.top : undefined
bottom: barWindow.isVertical ? parent.bottom : undefined
}
readonly property bool inOverview: CompositorService.isNiri && NiriService.inOverview && barWindow.effectiveOpenOnOverview
readonly property bool inOverview: CompositorService.overviewActiveOnScreen(barWindow.screenName) && barWindow.effectiveOpenOnOverview
hoverEnabled: (barConfig?.autoHide ?? false) && !inOverview && !topBarCore.popoutPinsReveal
acceptedButtons: barWindow.clickThroughEnabled ? Qt.NoButton : Qt.RightButton
enabled: !inOverview && ((barConfig?.autoHide ?? false) || !barWindow.clickThroughEnabled)
+13 -2
View File
@@ -156,6 +156,8 @@ Item {
function getRealWorkspaces() {
const screenName = _barScreenName;
if (CompositorService.isAqueous && AqueousService.available)
return AqueousService.workspacesForOutput(SettingsData.workspaceFollowFocus ? AqueousService.focusedOutput : screenName);
if (CompositorService.isNiri) {
const fallbackWorkspaces = [
{
@@ -238,6 +240,8 @@ Item {
function getCurrentWorkspace() {
const screenName = _barScreenName;
if (CompositorService.isAqueous && AqueousService.available)
return getRealWorkspaces().find(ws => ws.active)?.id || "";
if (CompositorService.isNiri) {
if (!screenName || SettingsData.workspaceFollowFocus) {
return NiriService.getCurrentWorkspaceNumber();
@@ -279,7 +283,14 @@ Item {
return;
}
if (CompositorService.isNiri) {
if (CompositorService.isAqueous && AqueousService.available) {
const index = realWorkspaces.findIndex(ws => ws.id === getCurrentWorkspace());
if (index < 0)
return;
const next = Math.max(0, Math.min(realWorkspaces.length - 1, index + (direction > 0 ? 1 : -1)));
if (next !== index)
AqueousService.activateWorkspace(realWorkspaces[next]);
} else if (CompositorService.isNiri) {
const currentWs = getCurrentWorkspace();
const currentIndex = realWorkspaces.findIndex(ws => ws && ws.idx === currentWs);
const validIndex = currentIndex === -1 ? 0 : currentIndex;
@@ -323,7 +334,7 @@ Item {
}
function switchApp(deltaY) {
const windows = sortedToplevels;
const windows = sortedToplevels.filter(w => !w.skipSwitcher);
if (windows.length < 2) {
return;
}
@@ -160,7 +160,7 @@ PanelWindow {
return [];
const toplevels = [];
const allToplevels = ToplevelManager.toplevels.values;
const allToplevels = CompositorService.isAqueous && AqueousService.available ? CompositorService.sortedToplevels : ToplevelManager.toplevels.values;
for (let i = 0; i < allToplevels.length; i++) {
const toplevel = allToplevels[i];
if (toplevel.appId === root.appData.appId) {
@@ -31,7 +31,7 @@ BasePill {
property int availableWidth: maxWidth
readonly property real effectiveHorizontalWidth: Math.max(0, Math.min(maxWidth, availableWidth))
readonly property real effectiveHorizontalInnerWidth: Math.max(0, effectiveHorizontalWidth - horizontalPadding * 2)
property Toplevel activeWindow: null
property var activeWindow: null
property var activeDesktopEntry: null
property bool isHovered: mouseArea.containsMouse
property bool isAutoHideBar: false
@@ -99,6 +99,11 @@ BasePill {
}
function updateActiveWindow() {
if (CompositorService.isAqueous && AqueousService.available) {
const focused = AqueousService.focusedWindow;
activeWindow = focused && (!parentScreen || focused.screens.some(s => s.name === parentScreen.name)) ? focused : null;
return;
}
let active = ToplevelManager.activeToplevel;
if (!active && CompositorService.isNiri) {
@@ -16,6 +16,8 @@ BasePill {
property bool showIcon: widgetData?.keyboardLayoutNameShowIcon !== undefined ? widgetData.keyboardLayoutNameShowIcon : SettingsData.keyboardLayoutNameShowIcon
readonly property var validVariants: ["US", "UK", "GB", "AZERTY", "QWERTY", "Dvorak", "Colemak", "Mac", "Intl", "International"]
property string currentLayout: {
if (CompositorService.isAqueous)
return AqueousService.keyboardLayout;
if (CompositorService.isNiri) {
return NiriService.getCurrentKeyboardLayoutName();
} else if (CompositorService.isMango) {
@@ -25,7 +27,7 @@ BasePill {
}
property string hyprlandKeyboard: ""
property var hyprlandLayoutLabels: []
readonly property var _allLayoutLabels: CompositorService.isNiri ? (NiriService.keyboardLayoutNames || []).map(n => displayLabel(n)) : hyprlandLayoutLabels
readonly property var _allLayoutLabels: CompositorService.isAqueous ? AqueousService.keyboardLayouts.map(n => displayLabel(n)) : CompositorService.isNiri ? (NiriService.keyboardLayoutNames || []).map(n => displayLabel(n)) : hyprlandLayoutLabels
readonly property string reserveLabel: widestLabel(_allLayoutLabels)
readonly property string verticalReserveLabel: widestLabel(_allLayoutLabels.map(n => LayoutCodes.layoutCode(n)))
@@ -131,6 +133,8 @@ BasePill {
onClicked: {
if (CompositorService.isNiri) {
NiriService.cycleKeyboardLayout();
} else if (CompositorService.isAqueous) {
AqueousService.cycleKeyboardLayout();
} else if (CompositorService.isHyprland) {
Quickshell.execDetached(["hyprctl", "switchxkblayout", root.hyprlandKeyboard, "next"]);
} else if (CompositorService.isMango) {
@@ -163,7 +163,7 @@ BasePill {
const deltaY = wheelEvent.angleDelta.y;
const isMouseWheel = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0;
const windows = root.sortedToplevels;
const windows = root.sortedToplevels.filter(w => !w.skipSwitcher);
if (windows.length < 2)
return;
@@ -236,7 +236,7 @@ BasePill {
id: windowRepeater
model: ScriptModel {
values: _groupByApp ? groupedWindows : sortedToplevels
objectProp: _groupByApp ? "appId" : "address"
objectProp: _groupByApp ? "appId" : (CompositorService.isAqueous && AqueousService.available ? "aqueousKey" : "address")
}
delegate: Item {
@@ -494,7 +494,7 @@ BasePill {
id: windowRepeater
model: ScriptModel {
values: _groupByApp ? groupedWindows : sortedToplevels
objectProp: _groupByApp ? "appId" : "address"
objectProp: _groupByApp ? "appId" : (CompositorService.isAqueous && AqueousService.available ? "aqueousKey" : "address")
}
delegate: Item {
@@ -24,6 +24,7 @@ Item {
property real crossEdgeExtension: 0
readonly property bool isMango: CompositorService.isMango
readonly property bool useAqueous: CompositorService.isAqueous && AqueousService.available
readonly property real _leftMargin: {
if (isVertical)
@@ -84,6 +85,8 @@ Item {
readonly property var extProjection: (useExtWorkspace && parentScreen) ? WindowManager.screenProjection(parentScreen) : null
readonly property bool useExtWorkspace: {
if (useAqueous)
return false;
if (Quickshell.env("DMS_FORCE_EXTWS") === "1")
return (WindowManager.windowsets?.length ?? 0) > 0;
if (!CompositorService.compositorDetected)
@@ -117,6 +120,8 @@ Item {
}
property var currentWorkspace: {
if (useAqueous)
return AqueousService.workspacesForOutput(effectiveScreenName).find(w => w.active)?.id || "";
if (useExtWorkspace)
return getExtWorkspaceActiveWorkspace();
@@ -143,6 +148,8 @@ Item {
return [];
}
property var workspaceList: {
if (useAqueous)
return AqueousService.workspacesForOutput(effectiveScreenName);
if (useExtWorkspace) {
const baseList = getExtWorkspaceWorkspaces();
return SettingsData.showWorkspacePadding ? padWorkspaces(baseList) : baseList;
@@ -327,7 +334,9 @@ Item {
}
let targetWorkspaceId;
if (CompositorService.isNiri) {
if (useAqueous) {
targetWorkspaceId = ws.id;
} else if (CompositorService.isNiri) {
if (!ws || typeof ws !== "object") {
const wsNumber = typeof ws === "number" ? ws : -1;
if (wsNumber <= 0) {
@@ -387,7 +396,9 @@ Item {
return;
} else {
let winWs = null;
if (CompositorService.isNiri) {
if (useAqueous) {
winWs = w.aqueousWorkspaceId;
} else if (CompositorService.isNiri) {
winWs = w.workspace_id;
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
winWs = w.workspace?.num;
@@ -405,7 +416,7 @@ Item {
const keyBase = (w.app_id || w.appId || w.class || w.windowClass || "unknown");
const moddedId = Paths.moddedAppId(keyBase);
const groupThisWs = SettingsData.groupWorkspaceApps && (!isActiveWs || SettingsData.groupActiveWorkspaceApps);
const key = groupThisWs ? moddedId : `${moddedId}_${i}`;
const key = groupThisWs ? moddedId : (w.aqueousKey || `${moddedId}_${i}`);
if (!byApp[key]) {
const isQuickshell = keyBase === "org.quickshell" || keyBase === "com.danklinux.dms";
@@ -677,6 +688,11 @@ Item {
if (!data)
return;
if (useAqueous) {
AqueousService.activateWorkspace(data);
return;
}
if (root.useExtWorkspace) {
if (typeof data.activate === "function")
data.activate();
@@ -727,6 +743,14 @@ Item {
}
function switchWorkspace(direction) {
if (useAqueous) {
const workspaces = getRealWorkspaces();
const index = workspaces.findIndex(w => w.id === currentWorkspace);
const next = Math.max(0, Math.min(workspaces.length - 1, index + (direction > 0 ? 1 : -1)));
if (next !== index)
AqueousService.activateWorkspace(workspaces[next]);
return;
}
if (useExtWorkspace) {
const realWorkspaces = getRealWorkspaces();
if (realWorkspaces.length < 2) {
@@ -812,6 +836,8 @@ Item {
}
function getWorkspaceIndexFallback(modelData, index) {
if (useAqueous)
return modelData?.number ?? "";
if (root.useExtWorkspace)
return index + 1;
if (CompositorService.isNiri)
@@ -868,7 +894,7 @@ Item {
return getWorkspaceIndexFallback(modelData, index);
}
readonly property bool hasNativeWorkspaceSupport: CompositorService.isNiri || CompositorService.isHyprland || root.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
readonly property bool hasNativeWorkspaceSupport: useAqueous || CompositorService.isNiri || CompositorService.isHyprland || root.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
readonly property bool hasWorkspaces: getRealWorkspaces().length > 0
readonly property bool shouldShow: hasNativeWorkspaceSupport || (useExtWorkspace && hasWorkspaces)
@@ -962,7 +988,9 @@ Item {
const rootPos = edgeMouseArea.mapToItem(root, mouse.x, mouse.y);
switch (mouse.button) {
case Qt.RightButton:
if (CompositorService.isNiri) {
if (root.useAqueous) {
AqueousService.toggleOverview(root.effectiveScreenName);
} else if (CompositorService.isNiri) {
NiriService.toggleOverview();
} else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) {
root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen;
@@ -1144,6 +1172,8 @@ Item {
}
property bool isActive: {
if (root.useAqueous)
return modelData?.active === true;
if (root.useExtWorkspace)
return (modelData?.id || modelData?.name) === root.currentWorkspace;
if (CompositorService.isNiri)
@@ -1157,6 +1187,8 @@ Item {
return modelData === root.currentWorkspace;
}
property bool isOccupied: {
if (root.useAqueous)
return AqueousService.windows.some(w => w.workspace === modelData?.id && !w.skip_taskbar);
if (CompositorService.isHyprland)
return Array.from(Hyprland.toplevels?.values || []).some(tl => tl.workspace?.id === modelData?.id);
if (root.isMango)
@@ -1183,6 +1215,8 @@ Item {
property var loadedWorkspaceData: null
property bool loadedIsUrgent: false
property bool isUrgent: {
if (root.useAqueous)
return modelData?.urgent === true;
if (root.useExtWorkspace)
return modelData?.urgent ?? false;
if (CompositorService.isHyprland)
@@ -1481,7 +1515,9 @@ Item {
if (mouse.button === Qt.LeftButton) {
if (delegateRoot.focusWindowAt(mouse.x, mouse.y))
return;
if (root.useExtWorkspace) {
if (root.useAqueous) {
AqueousService.activateWorkspace(modelData);
} else if (root.useExtWorkspace) {
if (typeof modelData?.activate === "function")
modelData.activate();
} else if (CompositorService.isNiri) {
@@ -1496,7 +1532,9 @@ Item {
CompositorService.dispatchSwayWorkspace(modelData);
}
} else if (mouse.button === Qt.RightButton) {
if (CompositorService.isNiri) {
if (root.useAqueous) {
AqueousService.toggleOverview(root.effectiveScreenName);
} else if (CompositorService.isNiri) {
NiriService.toggleOverview();
} else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) {
root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen;
@@ -1568,6 +1606,10 @@ Item {
const winId = delegateRoot.windowIdAt(x, y);
if (!winId)
return false;
if (root.useAqueous) {
AqueousService.command("window.activate", {id: winId});
return true;
}
if (CompositorService.isHyprland) {
HyprlandService.focusWindow(winId);
return true;
+1 -1
View File
@@ -96,7 +96,7 @@ Item {
return toplevel.activated;
} else if (appData.type === "grouped") {
// For grouped apps, check if any window is focused
const allToplevels = ToplevelManager.toplevels.values;
const allToplevels = CompositorService.isAqueous && AqueousService.available ? CompositorService.sortedToplevels : ToplevelManager.toplevels.values;
for (let i = 0; i < allToplevels.length; i++) {
const toplevel = allToplevels[i];
if (toplevel.appId === appData.appId && toplevel.activated) {
+1 -1
View File
@@ -262,7 +262,7 @@ Item {
const separatePinnedAndRunning = SettingsData.dockSeparatePinnedAndRunningApps;
sortedToplevels.forEach((toplevel, index) => {
let uniqueKey = "window_" + index;
let uniqueKey = toplevel.aqueousKey || "window_" + index;
if (CompositorService.isHyprland && Hyprland.toplevels) {
const hyprlandToplevels = Array.from(Hyprland.toplevels.values);
for (let i = 0; i < hyprlandToplevels.length; i++) {
+5 -2
View File
@@ -249,7 +249,7 @@ Item {
readonly property bool shouldHideForWindows: {
if (!SettingsData.dockSmartAutoHide)
return false;
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango)
if (!CompositorService.isNiri && !CompositorService.isHyprland && !CompositorService.isMango && !CompositorService.isAqueous)
return false;
const screenName = dock.modelData?.name ?? "";
@@ -313,6 +313,9 @@ Item {
return false;
}
if (CompositorService.isAqueous && AqueousService.available)
return AqueousService.overlapsDock(screenName, SettingsData.dockPosition, dockThickness, screenWidth, screenHeight);
if (CompositorService.isMango) {
MangoService.windows;
MangoService.outputs;
@@ -348,7 +351,7 @@ Item {
onTriggered: dock.startupRevealDone = true
}
readonly property bool overviewReveal: CompositorService.isNiri && NiriService.inOverview && SettingsData.dockOpenOnOverview
readonly property bool overviewReveal: CompositorService.overviewActiveOnScreen(screenName) && SettingsData.dockOpenOnOverview
readonly property bool hoverOrActive: dockMouseArea.containsMouse || dockApps.requestDockShow || contextMenuOpen || revealSticky
onOverviewRevealChanged: {
+1 -1
View File
@@ -87,7 +87,7 @@ PanelWindow {
readonly property bool _connectedActive: CompositorService.usesConnectedFrameChromeForScreen(win.targetScreen)
readonly property bool _dockHostedHere: {
const dockVisible = SettingsData.showDock || (CompositorService.isNiri && SettingsData.dockOpenOnOverview);
const dockVisible = SettingsData.showDock || ((CompositorService.isNiri || CompositorService.isAqueous) && SettingsData.dockOpenOnOverview);
if (!win._connectedActive || !dockVisible || SettingsData.dockUseOverlayLayer)
return false;
const screens = SettingsData.getFilteredScreens("dock");
+3 -3
View File
@@ -874,15 +874,15 @@ Item {
height: 1
color: Theme.outline
opacity: 0.15
visible: CompositorService.isNiri && !dankBarTab.islandOwnsSelectedBarTop
visible: (CompositorService.isNiri || CompositorService.isAqueous) && !dankBarTab.islandOwnsSelectedBarTop
}
SettingsToggleRow {
settingKey: "barOpenOnOverview"
tags: ["bar", "overview", "niri", "show", "frame"]
visible: CompositorService.isNiri && !dankBarTab.islandOwnsSelectedBarTop
visible: (CompositorService.isNiri || CompositorService.isAqueous) && !dankBarTab.islandOwnsSelectedBarTop
text: I18n.tr("Show on Overview")
description: dankBarTab.selectedBarFrameStyled ? I18n.tr("Show during Niri overview") : I18n.tr("Show the bar when niri overview is active")
description: CompositorService.isAqueous ? I18n.tr("Show on Overview") : dankBarTab.selectedBarFrameStyled ? I18n.tr("Show during Niri overview") : I18n.tr("Show the bar when niri overview is active")
checked: dankBarTab.selectedBarFrameStyled ? SettingsData.frameShowOnOverview : (selectedBarConfig?.openOnOverview ?? false)
onToggled: toggled => {
if (dankBarTab.selectedBarFrameStyled) {
@@ -14,7 +14,11 @@ DankFloatingWindow {
property int selectedIndex: -1
property bool keyboardNavigationActive: false
property var parentModal: null
parentWindow: parentModal
Binding {
target: "parentWindow" in root ? root : null
property: "parentWindow"
value: root.parentModal
}
signal widgetAdded(string widgetType)
@@ -8,6 +8,7 @@ import Quickshell.Io
import qs.Common
import qs.Services
import "../../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve
import "../../../Common/AqueousDisplays.js" as AqueousDisplays
Singleton {
id: root
@@ -41,6 +42,132 @@ Singleton {
property bool hasPendingChanges: Object.keys(pendingChanges).length > 0 || Object.keys(pendingNiriChanges).length > 0 || Object.keys(pendingHyprlandChanges).length > 0 || formatChanged
property bool validatingConfig: false
property var _cancelOutputWrite: null
property var aqueousPreview: null
function freshAqueousOutputs(callback) {
DMSService.sendRequest("wlroutput.getState", null, response => {
callback(response.result?.outputs || null, response.error || "");
}, 5000);
}
function aqueousDisplayError(message) {
validatingConfig = false;
validationError = message;
ToastService.showError(I18n.tr("Error"), message);
}
function discardAqueousPreview() {
if (validatingConfig)
return;
aqueousPreview = null;
validationError = "";
clearPendingChanges();
WlrOutputService.requestState();
}
function previewAqueousOutputs(descriptions) {
if (validatingConfig)
return;
if (aqueousPreview) {
observeAqueousPreview(aqueousPreview, descriptions, "");
return;
}
validatingConfig = true;
AqueousConfigService.load((snapshot, error) => {
if (!snapshot) {
aqueousDisplayError(error);
return;
}
freshAqueousOutputs((original, error) => {
if (!original) {
aqueousDisplayError(error);
return;
}
const candidate = WlrOutputService.outputsConfigHeads(buildOutputsWithPendingChanges(), outputs);
WlrOutputService.testConfiguration(candidate, (success, message) => {
if (!success) {
aqueousDisplayError(message);
return;
}
const preview = {snapshot: snapshot, original: original, heads: candidate,
fingerprint: null, session: AqueousService.session};
aqueousPreview = preview;
WlrOutputService.applyConfiguration(candidate, (success, message) => {
observeAqueousPreview(preview, descriptions, success ? "" : message);
});
});
});
});
}
function observeAqueousPreview(preview, descriptions, applyError) {
validatingConfig = true;
freshAqueousOutputs((actual, error) => {
if (aqueousPreview !== preview)
return;
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"));
return;
}
preview.fingerprint = AqueousDisplays.fingerprint(actual);
validatingConfig = false;
validationError = applyError;
changesApplied(descriptions);
});
}
function finishAqueousPreview(keep) {
if (validatingConfig || !aqueousPreview)
return;
const preview = aqueousPreview;
validatingConfig = true;
freshAqueousOutputs((actual, error) => {
if (!actual || AqueousService.session !== preview.session || AqueousDisplays.fingerprint(actual) !== preview.fingerprint) {
aqueousDisplayError(error || "conflict: output configuration changed after preview");
return;
}
if (keep) {
let draft;
try {
draft = AqueousDisplays.request(preview.snapshot, actual, preview.original);
} catch (e) {
aqueousDisplayError(String(e));
return;
}
AqueousConfigService.apply(draft, (snapshot, message) => {
if (!snapshot) {
aqueousDisplayError(message);
return;
}
aqueousPreview = null;
clearPendingChanges();
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"));
return;
}
changesConfirmed();
});
});
return;
}
WlrOutputService.applyConfiguration(AqueousDisplays.heads(preview.original), (success, message) => {
if (!success) {
aqueousDisplayError(message);
return;
}
validatingConfig = false;
aqueousPreview = null;
clearPendingChanges();
WlrOutputService.requestState();
changesReverted();
});
});
}
property string validationError: ""
property var currentOutputSet: []
@@ -1674,10 +1801,19 @@ Singleton {
const callback = typeof settingsOrCallback === "function" ? settingsOrCallback : maybeCallback;
const hasExplicitSettings = settings !== null && settings !== undefined;
if (_cancelOutputWrite)
_cancelOutputWrite();
let completed = false;
function finish(success) {
if (completed)
return;
completed = true;
root._cancelOutputWrite = null;
if (callback)
callback(success);
}
_cancelOutputWrite = () => finish(false);
switch (CompositorService.compositor) {
case "niri":
@@ -1707,8 +1843,7 @@ Singleton {
MangoService.generateOutputsConfig(outputsData, finish);
break;
default:
WlrOutputService.applyOutputsConfig(outputsData, outputs);
finish(true);
WlrOutputService.applyOutputsConfig(outputsData, outputs, finish);
break;
}
return true;
@@ -2221,16 +2356,25 @@ Singleton {
return;
}
changesApplied(changeDescriptions);
if (formatChanged)
SettingsData.saveSettings();
if (CompositorService.isHyprland)
commitHyprlandSettingsChanges();
if (CompositorService.isAqueous) {
previewAqueousOutputs(changeDescriptions);
return;
}
const mergedOutputs = buildOutputsWithPendingChanges();
backendWriteOutputsConfig(mergedOutputs);
validatingConfig = true;
backendWriteOutputsConfig(mergedOutputs, success => {
validatingConfig = false;
if (!success) {
ToastService.showError(I18n.tr("Error"), I18n.tr("Failed to apply profile"));
return;
}
if (formatChanged)
SettingsData.saveSettings();
if (CompositorService.isHyprland)
commitHyprlandSettingsChanges();
changesApplied(changeDescriptions);
});
}
function validateAndApplyNiriConfig(changeDescriptions) {
@@ -2341,6 +2485,10 @@ Singleton {
}
function confirmChanges(profileId) {
if (CompositorService.isAqueous) {
finishAqueousPreview(true);
return;
}
const outputConfigs = buildCurrentOutputConfigs();
lastAppliedEntry = {
outputs: outputConfigs
@@ -2372,6 +2520,10 @@ Singleton {
}
function revertChanges() {
if (CompositorService.isAqueous && aqueousPreview) {
finishAqueousPreview(false);
return;
}
const hadFormatChange = originalDisplayNameMode !== "";
const hadNiriChanges = originalNiriSettings !== null;
const hadHyprlandChanges = originalHyprlandSettings !== null;
@@ -99,6 +99,25 @@ Item {
width: parent.width
}
Column {
width: parent.width
spacing: Theme.spacingS
visible: CompositorService.isAqueous && DisplayConfigState.validationError !== ""
StyledText {
width: parent.width
text: DisplayConfigState.validationError
color: Theme.error
wrapMode: Text.WordWrap
}
DankButton {
text: I18n.tr("Discard draft and reload")
enabled: !DisplayConfigState.validatingConfig
onClicked: DisplayConfigState.discardAqueousPreview()
}
}
StyledRect {
width: parent.width
height: profileSection.implicitHeight + Theme.spacingL * 2
+3 -3
View File
@@ -70,7 +70,7 @@ Item {
text: I18n.tr("Intelligent Auto-hide")
description: I18n.tr("Show dock when floating windows don't overlap its area")
checked: SettingsData.dockSmartAutoHide
visible: SettingsData.showDock && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango)
visible: SettingsData.showDock && (CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous)
onToggled: checked => {
if (checked && SettingsData.dockAutoHide) {
SettingsData.set("dockAutoHide", false);
@@ -83,9 +83,9 @@ Item {
settingKey: "dockOpenOnOverview"
tags: ["dock", "overview", "niri"]
text: I18n.tr("Show on Overview")
description: I18n.tr("Always show the dock when niri's overview is open")
description: CompositorService.isAqueous ? I18n.tr("Show on Overview") : I18n.tr("Always show the dock when niri's overview is open")
checked: SettingsData.dockOpenOnOverview
visible: CompositorService.isNiri
visible: CompositorService.isNiri || CompositorService.isAqueous
onToggled: checked => SettingsData.set("dockOpenOnOverview", checked)
}
+2 -2
View File
@@ -369,13 +369,13 @@ Item {
settingKey: "frameBarIntegration"
collapsible: true
expanded: true
visible: SettingsData.frameEnabled && CompositorService.isNiri
visible: SettingsData.frameEnabled && (CompositorService.isNiri || CompositorService.isAqueous)
SettingsToggleRow {
settingKey: "frameShowOnOverview"
tags: ["frame", "overview", "show", "hide", "niri"]
text: I18n.tr("Show on Overview")
description: I18n.tr("Show during Niri overview")
description: CompositorService.isAqueous ? I18n.tr("Show on Overview") : I18n.tr("Show during Niri overview")
checked: SettingsData.frameShowOnOverview
onToggled: checked => SettingsData.set("frameShowOnOverview", checked)
}
+2 -2
View File
@@ -151,7 +151,7 @@ Item {
}
hasChanges = false;
_actionType = Actions.getActionType(editAction);
useCustomCompositor = _actionType === "compositor" && editAction && !Actions.isKnownCompositorAction(KeybindsService.currentProvider, editAction);
useCustomCompositor = _actionType === "compositor" && editAction && !KeybindsService.isKnownCompositorAction(editAction);
return;
}
}
@@ -175,7 +175,7 @@ Item {
editAllowInhibiting = editingKeyIndex >= 0 ? keys[editingKeyIndex].allowInhibiting : undefined;
hasChanges = false;
_actionType = Actions.getActionType(editAction);
useCustomCompositor = _actionType === "compositor" && editAction && !Actions.isKnownCompositorAction(KeybindsService.currentProvider, editAction);
useCustomCompositor = _actionType === "compositor" && editAction && !KeybindsService.isKnownCompositorAction(editAction);
}
function startAddingNewKey() {
+2 -2
View File
@@ -296,8 +296,8 @@ Item {
}
StyledText {
readonly property string bindsFile: KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf"
text: 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)
readonly property string bindsFile: KeybindsService.currentProvider === "aqueous" ? "aqueous-config" : KeybindsService.currentProvider === "niri" ? "dms/binds.kdl" : KeybindsService.currentProvider === "hyprland" ? "dms/binds-user.lua" : "dms/binds.conf"
text: KeybindsService.currentProvider === "aqueous" ? 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)
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
+10 -2
View File
@@ -17,7 +17,11 @@ DankFloatingWindow {
property bool keyboardNavigationActive: false
property bool isLoading: false
property var parentModal: null
parentWindow: parentModal
Binding {
target: "parentWindow" in root ? root : null
property: "parentWindow"
value: root.parentModal
}
property bool pendingInstallHandled: false
property string typeFilter: ""
property string categoryFilter: "all"
@@ -1539,7 +1543,11 @@ DankFloatingWindow {
DankFloatingWindow {
id: thirdPartyConfirmModal
parentWindow: root
Binding {
target: "parentWindow" in thirdPartyConfirmModal ? thirdPartyConfirmModal : null
property: "parentWindow"
value: root
}
function show() {
visible = true;
+5 -1
View File
@@ -15,7 +15,11 @@ DankFloatingWindow {
property bool keyboardNavigationActive: false
property bool isLoading: false
property var parentModal: null
parentWindow: parentModal
Binding {
target: "parentWindow" in root ? root : null
property: "parentWindow"
value: root.parentModal
}
property bool pendingInstallHandled: false
property string pendingApplyThemeId: ""
@@ -455,6 +455,17 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
Loader {
width: parent.width
active: CompositorService.isAqueous
sourceComponent: AqueousAppearanceSettings {
cursor: true
settingKey: "aqueousCursor"
title: I18n.tr("Cursor Theme") + " · Aqueous"
visible: CompositorService.isAqueous
}
}
SettingsCard {
tab: "theme"
tags: ["color", "palette", "theme", "appearance"]
@@ -1,6 +1,7 @@
import QtQuick
import qs.Common
import qs.Widgets
import qs.Services
import qs.Modules.Settings.Widgets
Item {
@@ -55,6 +56,16 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
Loader {
width: parent.width
active: CompositorService.isAqueous
sourceComponent: AqueousAppearanceSettings {
settingKey: "aqueousTypography"
title: I18n.tr("Typography") + " · Aqueous"
visible: CompositorService.isAqueous
}
}
SettingsCard {
tab: "typography"
tags: ["font", "family", "text", "typography"]
@@ -13,7 +13,11 @@ DankFloatingWindow {
property int selectedIndex: -1
property bool keyboardNavigationActive: false
property var parentModal: null
parentWindow: parentModal
Binding {
target: "parentWindow" in root ? root : null
property: "parentWindow"
value: root.parentModal
}
readonly property bool blurActive: Theme.blurLayersActive
readonly property bool floatingForegroundLayers: Theme.floatingWindowForegroundLayers
readonly property bool transparentBlurLayers: Theme.blurLayersActive && !floatingForegroundLayers
@@ -12,7 +12,11 @@ DankFloatingWindow {
property bool keyboardNavigationActive: false
property var appsModel: []
property var parentModal: null
parentWindow: parentModal
Binding {
target: "parentWindow" in root ? root : null
property: "parentWindow"
value: root.parentModal
}
signal appSelected(string appId)
@@ -0,0 +1,169 @@
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
SettingsCard {
id: root
property bool cursor: false
property var snapshot: null
property var changes: ({})
property bool busy: false
property string error: ""
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"
iconName: cursor ? "mouse" : "text_fields"
tab: cursor ? "theme" : "typography"
tags: ["aqueous", "appearance"]
function reload() {
if (busy)
return;
busy = true;
AqueousConfigService.load((data, message) => {
busy = false;
error = message;
if (!data)
return;
snapshot = data;
changes = ({});
if (!supported)
error = I18n.tr("Unavailable");
});
}
function value(id) {
if (Object.prototype.hasOwnProperty.call(changes, id))
return changes[id];
return snapshot?.fields?.find(f => f.id === id)?.value;
}
function stage(id, value) {
const updated = Object.assign({}, changes);
updated[id] = value;
changes = updated;
}
function apply(retry) {
if (busy || !snapshot)
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;
busy = true;
AqueousConfigService.apply(draft, (data, message) => {
busy = false;
error = 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);
}
});
}
Component.onCompleted: reload()
Column {
width: parent.width
spacing: Theme.spacingM
SettingsDropdownRow {
width: parent.width
visible: root.cursor
enabled: !!root.snapshot && !root.busy
text: I18n.tr("Cursor Theme")
options: root.snapshot?.desktop_cursor?.themes || []
currentValue: root.value("desktop.cursor.theme") || "default"
onValueChanged: value => {
root.stage("desktop.cursor.theme", value);
root.stage("desktop.cursor.managed", true);
}
}
SettingsDropdownRow {
width: parent.width
visible: !root.cursor
enabled: !!root.snapshot && !root.busy
text: I18n.tr("Normal Font")
options: root.snapshot?.desktop_typography?.families || []
currentValue: root.value("desktop.font.family") || "sans-serif"
onValueChanged: value => {
root.stage("desktop.font.family", value);
root.stage("desktop.font.style", "");
}
}
SettingsSliderRow {
width: parent.width
enabled: !!root.snapshot && !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"
onSliderValueChanged: value => {
root.stage(root.cursor ? "desktop.cursor.size" : "desktop.font.size_pt", value);
if (root.cursor)
root.stage("desktop.cursor.managed", true);
}
}
StyledText {
width: parent.width
visible: root.error !== "" || root.partial
text: root.error || I18n.tr("Error")
color: Theme.error
wrapMode: Text.WordWrap
}
Repeater {
model: root.report?.targets || []
StyledText {
required property var modelData
width: parent.width
text: modelData.id + ": " + I18n.tr(modelData.state)
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
}
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.")
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
Row {
spacing: Theme.spacingS
DankButton {
text: I18n.tr("Apply")
enabled: root.supported && !root.busy && Object.keys(root.changes).length > 0
onClicked: root.apply(false)
}
DankButton {
text: I18n.tr("Retry")
enabled: root.supported && !root.busy && root.partial
onClicked: root.apply(true)
}
DankButton {
text: I18n.tr("Reload")
enabled: !root.busy
onClicked: root.reload()
}
}
}
}
@@ -0,0 +1,106 @@
pragma Singleton
pragma ComponentBehavior: Bound
import Quickshell
import qs.Common
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("AqueousConfigService")
property bool busy: false
function requireCapabilities(result, required) {
if (result?.protocol !== 1 || !Array.isArray(result.capabilities))
throw new Error("unsupported aqueous-config protocol or discovery");
for (const capability of required) {
if (!result.capabilities.includes(capability))
throw new Error("unsupported aqueous-config capability: " + capability);
}
}
function helper(operation, input, callback) {
const args = ["aqueous-config", operation, "--shell", "dms"];
if (input !== null)
args.push("--request", "-");
AqueousService.runJson(args, input, (result, error) => {
if (!error && result?.ok !== true)
error = result?.code ? result.code + ": " + (result.message || "") : "aqueous-config rejected the request";
callback(result, error);
});
}
function request(operation, draft, callback) {
if (!CompositorService.isAqueous || busy) {
callback(null, busy ? "busy" : "unavailable");
return;
}
busy = true;
const complete = (result, error) => {
busy = false;
if (error)
log.warn("helper operation failed:", operation, error);
callback(error ? null : result, error);
};
if (operation === "snapshot") {
helper("snapshot", null, (result, error) => {
try {
if (error)
throw new Error(error);
requireCapabilities(result, ["schema_fields", "shell_dms"]);
if (typeof result.generation !== "string" || !result.generation)
throw new Error("missing configuration generation");
complete(result, "");
} catch (e) {
complete(null, String(e));
}
});
return;
}
if (!["apply", "validate"].includes(operation) || typeof draft?.expected_generation !== "string" || !draft.expected_generation) {
complete(null, "invalid operation or missing expected_generation; reload configuration");
return;
}
const request = Object.assign({}, draft, {
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({
monitor_changes: "monitor_modes",
custom_keybind_changes: "keybinds",
sync_cursor: "cursor_sync",
sync_typography: "typography_sync"
})) {
if (Object.prototype.hasOwnProperty.call(request, key))
required.push(capability);
}
helper("version", null, (version, error) => {
try {
if (error)
throw new Error(error);
requireCapabilities(version, required);
} catch (e) {
complete(null, String(e));
return;
}
helper("validate", input, (validated, error) => {
if (error || operation === "validate") {
complete(validated, error);
return;
}
helper("apply", input, complete);
});
});
}
function load(callback) {
request("snapshot", null, callback);
}
function apply(draft, callback) {
request("apply", draft, callback);
}
}
+694
View File
@@ -0,0 +1,694 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Common
import qs.Services
Singleton {
id: root
readonly property var log: Log.scoped("AqueousService")
property var state: ({})
property string seatName: ""
readonly property bool enabled: CompositorService.isAqueous
readonly property bool available: enabled && state.available === true
property var discoveredCapabilities: ({})
property var watchProcess: null
property var pendingProcesses: []
property int lifecycle: 0
property bool discovering: false
property bool commandPending: false
property int retryDelay: 500
readonly property int maxBatchBytes: 4 * 1024 * 1024
readonly property var capabilities: discoveredCapabilities
readonly property string session: available ? state.batch.session : ""
readonly property string sequence: available ? state.batch.sequence : ""
readonly property var entities: available ? state.batch.upsert : []
readonly property var outputs: entities.filter(e => e.kind === "output")
readonly property var workspaces: entities.filter(e => e.kind === "workspace").map(w => Object.assign({}, w, {
aqueousSession: session
}))
readonly property var windows: entities.filter(e => e.kind === "window")
readonly property var seats: entities.filter(e => e.kind === "seat")
readonly property var sessionState: entities.find(e => e.kind === "session") || ({})
readonly property bool locked: sessionState.locked === true
readonly property var seat: seatName ? seats.find(s => s.id === seatName) : (seats.length === 1 ? seats[0] : null)
readonly property var keyboard: entities.find(e => e.kind === "keyboard" && e.id === seat?.keyboard)
readonly property var keyboardLayouts: keyboard?.layouts || []
readonly property string keyboardLayout: keyboardLayouts[keyboard?.index] || ""
readonly property string focusedOutput: outputs.find(o => o.id === seat?.output)?.name || ""
readonly property bool inOverview: !!sessionState.overview_output
readonly property var focusedWindow: {
const window = windows.find(w => w.id === seat?.window);
return window ? windowFacade(window) : null;
}
readonly property var toplevels: windows.filter(w => !w.skip_taskbar && (w.visible || w.minimized || !workspaces.find(ws => ws.id === w.workspace)?.active)).map(w => windowFacade(w))
function outputId(name) {
return outputs.find(o => o.name === name)?.id || "";
}
function workspacesForOutput(name) {
const id = outputId(name || focusedOutput);
return workspaces.filter(w => w.output === id).sort((a, b) => a.number - b.number);
}
function byteLength(text) {
return encodeURIComponent(text).replace(/%[0-9A-F]{2}/g, "x").length;
}
function parseJson(text) {
if (byteLength(text) > maxBatchBytes + (text.endsWith("\n") ? 1 : 0))
throw new Error("JSON exceeds 4 MiB");
return JSON.parse(text);
}
function validateCapabilities(value) {
if (!value || value.schema !== 1 || typeof value.session !== "string" || !/^[0-9a-f]{32}$/.test(value.session) || !Number.isInteger(value.max_batch_bytes) || value.max_batch_bytes <= 0 || value.max_batch_bytes > maxBatchBytes)
throw new Error("unsupported: Aqueous shell capabilities");
for (const name of ["state", "commands", "keyboard", "overview", "shortcut_inhibition"]) {
if (typeof value[name] !== "boolean")
throw new Error("unsupported: missing capability " + name);
}
if (!value.state)
throw new Error("unsupported: shell state");
return value;
}
function validateEntity(entity) {
const fields = {
output: "id:s name:s bounds:r usable_bounds:r scale:n transform:s active_workspace:? enabled:b powered:b",
workspace: "id:s output:s name:s number:i active:b urgent:b",
window: "id:s backend:s app_id:? class:? title:? workspace:? output:? geometry:r outer_geometry:r layout:s focused:b visible:b floating:b minimized:b maximized:b fullscreen:b skip_taskbar:b skip_switcher:b always_above:b always_below:b snapped:b fixed_position:b can_minimize:b can_maximize:b can_activate:b",
seat: "id:s output:? window:? focus_kind:s keyboard:?",
keyboard: "id:s seat:s layouts:a index:i",
keyboard_device: "id:s name:? seat:s group:? virtual:b",
session: "id:s locked:b default_seat:? overview_output:? overview_window:?"
};
if (!entity || !Object.prototype.hasOwnProperty.call(fields, entity.kind) || typeof entity.id !== "string" || !entity.id)
throw new Error("invalid entity kind or ID");
for (const field of fields[entity.kind].split(" ")) {
const [name, type] = field.split(":");
const value = entity[name];
let valid = false;
switch (type) {
case "s":
valid = typeof value === "string";
break;
case "?":
valid = value === null || typeof value === "string";
break;
case "b":
valid = typeof value === "boolean";
break;
case "i":
valid = Number.isSafeInteger(value);
break;
case "n":
valid = typeof value === "number" && Number.isFinite(value);
break;
case "a":
valid = Array.isArray(value) && value.every(v => typeof v === "string");
break;
case "r":
valid = value && ["x", "y", "width", "height"].every(k => Number.isSafeInteger(value[k]));
break;
}
if (!valid)
throw new Error("invalid " + entity.kind + "." + name);
}
if ((entity.kind === "session" && entity.id !== "session") || (entity.kind === "workspace" && entity.number < 1) || (entity.kind === "output" && entity.scale <= 0) || (entity.kind === "keyboard" && (entity.index < 0 || (entity.layouts.length && entity.index >= entity.layouts.length))) || (entity.kind === "window" && !["xdg", "xwayland"].includes(entity.backend)) || (entity.kind === "seat" && !["window", "shell_surface", "layer_surface", "override_redirect", "lock_surface", "none"].includes(entity.focus_kind)))
throw new Error("invalid " + entity.kind);
}
function reduceBatch(previous, batch) {
if (!batch || batch.schema !== 1 || typeof batch.session !== "string" || !/^[0-9a-f]{32}$/.test(batch.session) || typeof batch.sequence !== "string" || !/^[0-9]+$/.test(batch.sequence) || !Array.isArray(batch.upsert) || !Array.isArray(batch.removed))
throw new Error("invalid shell batch envelope");
if (batch.type !== "snapshot" && batch.type !== "delta")
throw new Error("invalid shell batch type");
if (batch.type === "snapshot" && (batch.base_sequence !== null || batch.removed.length))
throw new Error("invalid snapshot baseline");
if (batch.type === "delta" && (!previous || batch.session !== previous.session || batch.base_sequence !== previous.sequence || batch.sequence === previous.sequence))
throw new Error("shell delta continuity mismatch");
const model = Object.assign({}, batch.type === "delta" ? previous.model : {});
const seen = new Set();
for (const entity of batch.upsert) {
validateEntity(entity);
const key = entity.kind + ":" + entity.id;
if (seen.has(key))
throw new Error("duplicate entity key");
seen.add(key);
model[key] = entity;
}
for (const key of batch.removed) {
if (typeof key !== "string" || !/^(output|workspace|window|seat|keyboard|keyboard_device|session):.+$/.test(key) || seen.has(key))
throw new Error("invalid or duplicate removal");
seen.add(key);
delete model[key];
}
if (!model["session:session"])
throw new Error("missing session entity");
const references = {
output: {
active_workspace: "workspace"
},
workspace: {
output: "output"
},
window: {
output: "output",
workspace: "workspace"
},
seat: {
output: "output",
window: "window",
keyboard: "keyboard"
},
keyboard: {
seat: "seat"
},
keyboard_device: {
seat: "seat",
group: "keyboard"
},
session: {
default_seat: "seat",
overview_output: "output",
overview_window: "window"
}
};
for (const entity of Object.values(model)) {
for (const [field, kind] of Object.entries(references[entity.kind])) {
if (entity[field] !== null && !model[kind + ":" + entity[field]])
throw new Error("dangling " + entity.kind + "." + field);
}
}
if (byteLength(JSON.stringify(model)) > maxBatchBytes)
throw new Error("shell model exceeds size bound");
return {
session: batch.session,
sequence: batch.sequence,
model: model,
upsert: Object.values(model)
};
}
function acceptLine(line) {
if (byteLength(line) > capabilities.max_batch_bytes)
throw new Error("shell batch exceeds advertised limit");
const batch = parseJson(line);
if (batch.session !== capabilities.session)
throw new Error("shell session changed after discovery");
const next = reduceBatch(state.available ? state.batch : null, batch);
state = {
available: true,
batch: next
};
}
function commandArguments(action, fields) {
const request = Object.assign({
session: session,
seat: seat?.id || ""
}, fields || {});
if (!available || request.session !== session)
throw new Error("unavailable: stale session");
if (locked)
throw new Error("locked");
const [kind, operation] = action.split(".");
const capability = kind === "keyboard" ? "keyboard" : kind === "overview" ? "overview" : "commands";
if (!capabilities[capability])
throw new Error("unsupported: " + capability);
for (const key of ["id", "seat", "group", "output", "workspace", "name"]) {
if (request[key] !== undefined && (typeof request[key] !== "string" || byteLength(request[key]) > 1024))
throw new Error("invalid: command string");
}
const target = entities.find(e => e.kind === kind && e.id === request.id);
if ((kind === "window" || kind === "workspace") && !target)
throw new Error("not_found");
const selectedSeat = request.seat ? seats.find(s => s.id === request.seat) : (seats.length === 1 ? seats[0] : null);
const output = outputs.find(o => o.id === request.output);
let args;
switch (action) {
case "window.activate":
case "workspace.activate":
if (!selectedSeat)
throw new Error(request.seat ? "not_found: seat" : "ambiguous_seat");
if (kind === "window" && !target.can_activate)
throw new Error("unavailable: window activation");
args = [kind, operation, "--id", target.id, "--seat", selectedSeat.id];
break;
case "window.close":
args = ["window", "close", "--id", target.id];
break;
case "window.minimized":
case "window.maximized":
case "window.fullscreen":
if (typeof request.value !== "boolean")
throw new Error("invalid: missing state");
if (operation !== "fullscreen" && !target["can_" + operation.slice(0, -1)])
throw new Error("unavailable: window state");
args = ["window", "state", "--id", target.id, "--" + operation, String(request.value)];
break;
case "window.move":
if (!!request.workspace === !!request.output)
throw new Error("invalid: move destination");
if (request.workspace && !workspaces.some(w => w.id === request.workspace))
throw new Error("not_found: workspace");
if (request.output && !output)
throw new Error("not_found: output");
args = ["window", "move", "--id", target.id, request.workspace ? "--workspace-id" : "--output", request.workspace || output.name];
break;
case "workspace.rename":
if (typeof request.name !== "string" || /[\r\n]/.test(request.name))
throw new Error("invalid: workspace name");
args = ["workspace", "rename", "--id", target.id, "--name", request.name];
break;
case "keyboard.set":
case "keyboard.next":
{
if (!selectedSeat)
throw new Error(request.seat ? "not_found: seat" : "ambiguous_seat");
const group = entities.find(e => e.kind === "keyboard" && e.id === (request.group || selectedSeat.keyboard) && e.seat === selectedSeat.id);
if (!group)
throw new Error("not_found: keyboard group");
args = ["keyboard", operation, "--seat", selectedSeat.id, "--group", group.id];
if (operation === "set") {
if (!Number.isInteger(request.index) || request.index < 0 || request.index >= group.layouts.length)
throw new Error("invalid: keyboard index");
args.push("--index", String(request.index));
}
break;
}
case "overview.show":
case "overview.toggle":
case "overview.hide":
args = ["overview", operation];
if (operation !== "hide") {
if (!output)
throw new Error("not_found: output");
args.push("--output", output.name);
}
break;
case "session.exit":
args = ["session", "exit"];
break;
default:
throw new Error("unsupported: action");
}
return ["aqueousctl"].concat(args, ["--json"]);
}
function commandResult(action, result, error) {
if (error)
return error;
const status = action === "window.close" || action === "session.exit" ? "accepted" : "applied";
if (result?.ok !== true || result.status !== status || typeof result.sequence !== "string" || !/^[0-9]+$/.test(result.sequence))
return result?.status || "missing command acknowledgement";
return "";
}
function reportCommand(action, result, error, callback) {
const failure = commandResult(action, result, error);
if (failure) {
log.warn("command failed:", action, failure);
ToastService.showError(I18n.tr("Error"), failure);
}
if (callback)
callback(!failure, failure || result.status);
}
function command(action, fields, callback) {
let args;
try {
if (commandPending)
throw new Error("busy");
args = commandArguments(action, fields);
} catch (e) {
reportCommand(action, null, String(e), callback);
return;
}
commandPending = true;
runJson(args, null, (result, error) => {
commandPending = false;
reportCommand(action, result, error, callback);
});
}
function runJson(args, input, callback) {
if (!enabled) {
callback(null, "unavailable");
return;
}
if (input !== null && byteLength(input) > maxBatchBytes) {
callback(null, "request exceeds 4 MiB");
return;
}
const process = jsonProcessComponent.createObject(root, {
command: args,
input: input,
callback: callback
});
pendingProcesses = pendingProcesses.concat([process]);
process.running = true;
process.deadline.start();
}
function startWatching() {
if (!enabled || discovering || watchProcess)
return;
discovering = true;
const attempt = lifecycle;
runJson(["aqueousctl", "shell", "capabilities", "--json"], null, (result, error) => {
if (attempt !== lifecycle)
return;
discovering = false;
try {
if (error)
throw new Error(error);
discoveredCapabilities = validateCapabilities(result);
watchProcess = watchComponent.createObject(root);
watchProcess.running = true;
watchProcess.deadline.start();
} catch (e) {
disconnected(String(e));
}
});
}
function disconnected(error) {
state = {
error: error
};
if (!enabled || retryTimer.running)
return;
retryTimer.interval = Math.floor(retryDelay * (0.8 + Math.random() * 0.2));
retryDelay = Math.min(retryDelay * 2, 30000);
retryTimer.start();
}
function stopWatching() {
lifecycle++;
retryTimer.stop();
state = ({});
discoveredCapabilities = ({});
discovering = false;
if (watchProcess)
watchProcess.finish("unavailable");
for (const process of pendingProcesses.slice())
process.finish(null, "unavailable: backend changed or shell stopped");
retryTimer.stop();
commandPending = false;
retryDelay = 500;
}
onEnabledChanged: {
stopWatching();
if (enabled)
startWatching();
}
Component.onCompleted: startWatching()
Component.onDestruction: stopWatching()
function windowFacade(window) {
const windowSession = session;
const output = outputs.find(o => o.id === window.output);
return {
id: window.id,
aqueousWindowId: window.id,
aqueousKey: windowSession + ":window:" + window.id,
aqueousSession: windowSession,
aqueousWorkspaceId: window.workspace,
aqueousOutputId: window.output,
appId: window.app_id || window.class || "",
title: window.title || "",
activated: root.seat?.window === window.id,
get fullscreen() {
return window.fullscreen;
},
set fullscreen(value) {
root.command("window.fullscreen", {
id: window.id,
session: windowSession,
value: value
});
},
get maximized() {
return window.maximized;
},
set maximized(value) {
root.command("window.maximized", {
id: window.id,
session: windowSession,
value: value
});
},
visible: window.visible,
skipSwitcher: window.skip_switcher,
canMinimize: window.can_minimize,
canMaximize: window.can_maximize,
canActivate: window.can_activate,
screens: Quickshell.screens.filter(s => s.name === output?.name),
get minimized() {
return window.minimized;
},
set minimized(value) {
root.command("window.minimized", {
id: window.id,
session: windowSession,
value: value
});
},
activate: function () {
root.command("window.activate", {
id: window.id,
session: windowSession
});
},
close: function () {
root.command("window.close", {
id: window.id,
session: windowSession
});
}
};
}
function activateWorkspace(workspace) {
if (!seat || !workspace)
return;
command("workspace.activate", {
id: workspace.id,
session: workspace.aqueousSession || session
});
}
function cycleKeyboardLayout() {
if (!seat || !keyboard || !capabilities.keyboard)
return;
command("keyboard.next", {
group: keyboard.id
});
}
function toggleOverview(screenName) {
if (!capabilities.overview)
return;
command("overview.toggle", {
output: outputId(screenName || focusedOutput)
});
}
function quit() {
command("session.exit");
}
IpcHandler {
target: "aqueous"
function status(): string {
return JSON.stringify({
available: root.available,
session: root.session,
sequence: root.sequence,
focusedOutput: root.focusedOutput,
outputs: root.outputs.length,
windows: root.windows.length,
workspaces: root.workspaces.length,
keyboardLayout: root.keyboardLayout,
overviewOutput: root.sessionState.overview_output || null,
overviewWindow: root.sessionState.overview_window || null,
error: root.state.error || ""
});
}
function overview(action: string, output: string): string {
if (!["show", "hide", "toggle"].includes(action))
return "INVALID_ACTION";
if (!root.available || root.locked || !root.capabilities.overview)
return "UNAVAILABLE";
root.command("overview." + action, {
output: root.outputId(output || root.focusedOutput)
});
return "OVERVIEW_REQUESTED";
}
function selectSeat(name: string): string {
if (name && !root.seats.some(s => s.id === name))
return "SEAT_NOT_FOUND";
root.seatName = name;
return "SEAT_SELECTED";
}
}
function overlapsDock(screenName, position, thickness, width, height) {
const output = outputs.find(o => o.name === screenName);
if (!output)
return false;
return windows.some(w => {
if (w.output !== output.id || !w.visible || w.minimized)
return false;
const box = w.outer_geometry;
const x = box.x - output.bounds.x;
const y = box.y - output.bounds.y;
if (position === SettingsData.Position.Top)
return y < thickness && y + box.height > 0;
if (position === SettingsData.Position.Left)
return x < thickness && x + box.width > 0;
if (position === SettingsData.Position.Right)
return x < width && x + box.width > width - thickness;
return y < height && y + box.height > height - thickness;
});
}
Timer {
id: retryTimer
onTriggered: root.startWatching()
}
Component {
id: watchComponent
Process {
id: watcher
command: ["aqueousctl", "shell", "watch", "--json"]
property bool finished: false
property double establishedAt: 0
property Timer deadline: Timer {
id: initialDeadline
interval: 8000
onTriggered: watcher.finish("shell initial snapshot timed out")
}
function finish(error) {
if (finished)
return;
finished = true;
initialDeadline.stop();
if (running)
signal(9);
if (root.watchProcess === watcher) {
root.watchProcess = null;
if (establishedAt && Date.now() - establishedAt >= 60000)
root.retryDelay = 500;
root.disconnected(error);
}
Qt.callLater(() => watcher.destroy());
}
stdout: SplitParser {
onRead: line => {
if (watcher.finished)
return;
try {
root.acceptLine(line);
initialDeadline.stop();
if (!watcher.establishedAt)
watcher.establishedAt = Date.now();
} catch (e) {
watcher.finish(String(e));
}
}
}
// Drain diagnostics without retaining an unbounded history.
stderr: SplitParser {
splitMarker: ""
}
onExited: code => finish("shell stream disconnected (" + code + ")")
onRunningChanged: {
if (!running)
Qt.callLater(() => {
if (!watcher.finished)
watcher.finish("aqueousctl could not start");
});
}
}
}
Component {
id: jsonProcessComponent
Process {
id: jsonProcess
property var input: null
property var callback: null
property bool finished: false
property Timer deadline: Timer {
id: jsonDeadline
interval: 8000
onTriggered: jsonProcess.finish(null, "command completion uncertain: timeout")
}
stdinEnabled: input !== null
function finish(result, error) {
if (finished)
return;
finished = true;
jsonDeadline.stop();
if (running)
signal(9);
root.pendingProcesses = root.pendingProcesses.filter(p => p !== jsonProcess);
const complete = callback;
callback = null;
if (complete)
complete(result, error);
Qt.callLater(() => jsonProcess.destroy());
}
onStarted: {
if (input !== null) {
write(input);
stdinEnabled = false;
}
}
stdout: StdioCollector {
waitForEnd: false
onDataChanged: {
if (data.byteLength > root.maxBatchBytes + 1)
jsonProcess.finish(null, "process output exceeds 4 MiB");
}
}
stderr: SplitParser {
splitMarker: ""
}
onExited: (code, status) => {
if (finished)
return;
let result = null;
let error = "";
try {
result = root.parseJson(stdout.text);
if (code !== 0 || status !== 0)
error = result?.code ? result.code + ": " + (result.message || "") : (result?.status || "command failed (" + code + ")");
} catch (e) {
error = String(e);
}
finish(result, error);
}
onRunningChanged: {
if (!running)
Qt.callLater(() => {
if (!jsonProcess.finished)
jsonProcess.finish(null, "command could not start");
});
}
}
}
}
+3 -1
View File
@@ -135,9 +135,11 @@ Singleton {
return screens.length > 0 ? widgetRegistry[widgetId][screens[0]] : null;
}
readonly property bool focusedScreenDetectionSupported: CompositorService.isHyprland || CompositorService.isNiri || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
readonly property bool focusedScreenDetectionSupported: (CompositorService.isAqueous && AqueousService.available) || CompositorService.isHyprland || CompositorService.isNiri || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
function getFocusedScreenName() {
if (CompositorService.isAqueous && AqueousService.available)
return AqueousService.focusedOutput;
if (CompositorService.isHyprland && Hyprland.focusedWorkspace?.monitor)
return Hyprland.focusedWorkspace.monitor.name;
if (CompositorService.isNiri && NiriService.currentOutput)
+80 -6
View File
@@ -21,8 +21,47 @@ Singleton {
property bool isScroll: false
property bool isMiracle: false
property bool isLabwc: false
property bool isAqueous: false
property string compositor: "unknown"
property bool compositorDetected: false
property bool outputPowerAvailable: false
readonly property bool genericPowerBackend: compositorDetected && !isNiri && !isHyprland && !isMango && !isSway && !isScroll && !isMiracle
onGenericPowerBackendChanged: probeOutputPower()
function probeOutputPower() {
outputPowerAvailable = false;
if (!genericPowerBackend)
return;
const backend = compositor;
Proc.runCommand("output-power-probe", [Proc.dmsBin, "dpms", "list"], (output, code) => {
if (root.compositor === backend && root.genericPowerBackend)
root.outputPowerAvailable = code === 0;
}, 0, 5000);
}
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"));
}, 0, 12000);
}
Connections {
target: DMSService
function onConnectionStateChanged() {
if (DMSService.isConnected)
root.probeOutputPower();
else
root.outputPowerAvailable = false;
}
}
Connections {
target: Quickshell
function onScreensChanged() {
root.probeOutputPower();
}
}
readonly property bool frameCompositorLayoutReady: (!isNiri || NiriService.frameLayoutReady) && (!isHyprland || HyprlandService.frameLayoutReady)
readonly property bool useHyprlandFocusGrab: isHyprland && Quickshell.env("DMS_HYPRLAND_EXCLUSIVE_FOCUS") !== "1"
@@ -45,9 +84,19 @@ Singleton {
signal toplevelsChanged
readonly property bool supportsMinimize: DankCommon.Compositor.supportsMinimize
readonly property bool supportsMinimize: isAqueous && AqueousService.available ? AqueousService.capabilities.commands && !AqueousService.locked : DankCommon.Compositor.supportsMinimize
Connections {
target: AqueousService
function onStateChanged() {
if (root.isAqueous)
root.scheduleSort();
}
}
function canMinimize(toplevel) {
if (isAqueous && toplevel?.aqueousWindowId)
return AqueousService.available && !AqueousService.locked && AqueousService.capabilities.commands && toplevel.canMinimize;
return supportsMinimize && toplevel && toplevel.minimized !== undefined;
}
@@ -59,6 +108,10 @@ Singleton {
function activateToplevel(toplevel) {
if (!toplevel)
return;
if (isAqueous && toplevel.aqueousWindowId) {
toplevel.activate();
return;
}
closeNiriOverviewOnWindowFocus();
if (canMinimize(toplevel) && toplevel.minimized)
toplevel.minimized = false;
@@ -147,7 +200,9 @@ Singleton {
function getFocusedScreen() {
let screenName = "";
if (isHyprland && Hyprland.focusedWorkspace?.monitor)
if (isAqueous && AqueousService.available)
screenName = AqueousService.focusedOutput;
else if (isHyprland && Hyprland.focusedWorkspace?.monitor)
screenName = Hyprland.focusedWorkspace.monitor.name;
else if (isNiri && NiriService.currentOutput)
screenName = NiriService.currentOutput;
@@ -267,6 +322,8 @@ Singleton {
}
function computeSortedToplevels() {
if (isAqueous && AqueousService.available)
return AqueousService.toplevels;
if (!ToplevelManager.toplevels || !ToplevelManager.toplevels.values)
return [];
@@ -517,6 +574,10 @@ Singleton {
}
function filterCurrentWorkspace(toplevels, screen) {
if (isAqueous && AqueousService.available) {
const active = AqueousService.workspacesForOutput(_screenName(screen)).filter(w => w.active).map(w => w.id);
return toplevels.filter(t => active.includes(t.aqueousWorkspaceId));
}
if (useNiriSorting)
return NiriService.filterCurrentWorkspace(toplevels, screen);
if (useMangoSorting)
@@ -528,6 +589,8 @@ Singleton {
function fullscreenToplevelOnScreen(screenOrName) {
const screenName = _screenName(screenOrName);
if (isAqueous && AqueousService.available)
return AqueousService.windows.some(w => w.output === AqueousService.outputId(screenName) && w.visible && w.fullscreen);
if (!screenName || !ToplevelManager.toplevels?.values)
return false;
@@ -657,6 +720,12 @@ Singleton {
return frameConfiguredForScreen(screenOrName);
}
function overviewActiveOnScreen(screenOrName) {
if (isAqueous && AqueousService.available)
return !!AqueousService.sessionState.overview_output && AqueousService.sessionState.overview_output === AqueousService.outputId(_screenName(screenOrName));
return isNiri && NiriService.inOverview;
}
function usesConnectedFrameChromeForScreen(screenOrName) {
return FrameTransitionState.effectiveConnectedFrameModeActive && frameWindowVisibleForScreen(screenOrName);
}
@@ -930,6 +999,8 @@ Singleton {
return "miracle";
case "labwc":
return "labwc";
case "aqueous":
return "aqueous";
default:
return "";
}
@@ -943,6 +1014,7 @@ Singleton {
isScroll = name === "scroll";
isMiracle = name === "miracle";
isLabwc = name === "labwc";
isAqueous = name === "aqueous";
compositor = name;
compositorDetected = true;
if (isNiri)
@@ -1074,8 +1146,9 @@ Singleton {
} catch (_) {}
return;
}
if (isLabwc) {
Quickshell.execDetached(["dms", "dpms", "off"]);
if (isLabwc || outputPowerAvailable) {
setOutputPower(false);
return;
}
log.warn("Cannot power off monitors, unknown compositor");
}
@@ -1093,8 +1166,9 @@ Singleton {
} catch (_) {}
return;
}
if (isLabwc) {
Quickshell.execDetached(["dms", "dpms", "on"]);
if (isLabwc || outputPowerAvailable) {
setOutputPower(true);
return;
}
log.warn("Cannot power on monitors, unknown compositor");
}
+23 -3
View File
@@ -402,7 +402,16 @@ Singleton {
}
}
function sendRequest(method, params, callback) {
Component {
id: requestTimeoutComponent
Timer {
property var requestId
repeat: false
onTriggered: root.handleResponse({id: requestId, error: "Request timed out; operation completion is uncertain"})
}
}
function sendRequest(method, params, callback, timeoutMs) {
if (!isConnected) {
log.warn("DMSService.sendRequest: Not connected, method:", method);
if (callback) {
@@ -424,8 +433,19 @@ Singleton {
request.params = params;
}
if (callback)
pendingRequests[id] = callback;
if (callback) {
if (timeoutMs > 0) {
const timeout = requestTimeoutComponent.createObject(root, {requestId: id, interval: timeoutMs});
pendingRequests[id] = response => {
timeout.stop();
timeout.destroy();
callback(response);
};
timeout.start();
} else {
pendingRequests[id] = callback;
}
}
if (method.startsWith("clipboard")) {
clipboardRequestIds[id] = true;
+51 -5
View File
@@ -14,8 +14,10 @@ Singleton {
id: root
readonly property var log: Log.scoped("KeybindsService")
property bool available: CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
property bool available: CompositorService.isAqueous || CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango
property string currentProvider: {
if (CompositorService.isAqueous)
return "aqueous";
if (CompositorService.isNiri)
return "niri";
if (CompositorService.isHyprland)
@@ -26,6 +28,8 @@ Singleton {
}
readonly property string cheatsheetProvider: {
if (CompositorService.isAqueous)
return "aqueous";
if (CompositorService.isNiri)
return "niri";
if (CompositorService.isHyprland)
@@ -119,7 +123,7 @@ Singleton {
Connections {
target: CompositorService
function onCompositorChanged() {
if (!CompositorService.isNiri && !CompositorService.isMango)
if (!CompositorService.isNiri && !CompositorService.isMango && !CompositorService.isAqueous)
return;
Qt.callLater(root.loadBinds);
}
@@ -213,6 +217,7 @@ Singleton {
Process {
id: removeProcess
running: false
property string pendingKey: ""
stderr: StdioCollector {
onStreamFinished: {
@@ -229,6 +234,10 @@ Singleton {
return;
}
root.lastError = "";
if (pendingKey) {
root.bindRemoved(pendingKey);
pendingKey = "";
}
if (CompositorService.isMango)
MangoService.reloadConfig();
root.loadBinds(false);
@@ -512,7 +521,11 @@ Singleton {
if (!bindData.key || !Actions.isValidAction(bindData.action))
return;
saving = true;
const cmd = ["dms", "keybinds", "set", currentProvider, bindData.key, bindData.action, "--desc", bindData.desc || ""];
const cmd = ["dms", "keybinds", "set", currentProvider, bindData.key, bindData.action];
if (currentProvider === "aqueous")
cmd.push("--expected-generation", _rawData?.generation || "");
else
cmd.push("--desc", bindData.desc || "");
if (originalKey && originalKey !== bindData.key)
cmd.push("--replace-key", originalKey);
if (bindData.cooldownMs > 0)
@@ -553,6 +566,8 @@ Singleton {
}
function removeBind(key) {
if (removeProcess.running)
return;
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
@@ -560,11 +575,18 @@ Singleton {
if (!key)
return;
removeProcess.command = ["dms", "keybinds", "remove", currentProvider, key];
if (currentProvider === "aqueous")
removeProcess.command = removeProcess.command.concat(["--expected-generation", _rawData?.generation || ""]);
removeProcess.running = true;
bindRemoved(key);
if (currentProvider === "aqueous")
removeProcess.pendingKey = key;
else
bindRemoved(key);
}
function resetBind(key) {
if (removeProcess.running)
return;
if (readOnly) {
showHyprlandReadOnlyWarning();
return;
@@ -572,19 +594,43 @@ Singleton {
if (!key)
return;
removeProcess.command = ["dms", "keybinds", "reset", currentProvider, key];
if (currentProvider === "aqueous")
removeProcess.command = removeProcess.command.concat(["--expected-generation", _rawData?.generation || ""]);
removeProcess.running = true;
bindRemoved(key);
if (currentProvider === "aqueous")
removeProcess.pendingKey = key;
else
bindRemoved(key);
}
function getActionLabel(action) {
if (currentProvider === "aqueous")
return (_rawData?.binds?.Compositor || []).find(b => b.action === action)?.desc || Actions.getActionLabel(action, currentProvider);
return Actions.getActionLabel(action, currentProvider);
}
function isKnownCompositorAction(action) {
if (currentProvider === "aqueous")
return (_rawData?.binds?.Compositor || []).some(b => b.action === action);
return Actions.isKnownCompositorAction(currentProvider, action);
}
function getCompositorCategories() {
if (currentProvider === "aqueous")
return ["Compositor"];
return Actions.getCompositorCategories(currentProvider);
}
function getCompositorActions(category) {
if (currentProvider === "aqueous") {
const seen = new Set();
return (_rawData?.binds?.Compositor || []).filter(b => {
if (seen.has(b.action))
return false;
seen.add(b.action);
return true;
}).map(b => ({id: b.action, label: b.desc}));
}
return Actions.getCompositorActions(currentProvider, category);
}
+12 -4
View File
@@ -176,20 +176,20 @@ Singleton {
Process {
id: uwsmLogout
property bool notRunning: false
command: ["uwsm", "stop"]
running: false
stdout: SplitParser {
splitMarker: "\n"
onRead: data => {
if (data.trim().toLowerCase().includes("not running")) {
_logout();
}
if (data.trim().toLowerCase().includes("not running"))
uwsmLogout.notRunning = true;
}
}
onExited: function (exitCode) {
if (exitCode === 0) {
if (exitCode === 0 && !notRunning) {
return;
}
_logout();
@@ -393,13 +393,21 @@ Singleton {
// * Session management
function logout() {
if (hasUwsm) {
if (uwsmLogout.running)
return;
uwsmLogout.notRunning = false;
uwsmLogout.running = true;
return;
}
_logout();
}
function _logout() {
if (SettingsData.customPowerActionLogout.length === 0) {
if (CompositorService.isAqueous) {
AqueousService.quit();
return;
}
if (CompositorService.isNiri) {
NiriService.quit();
return;
@@ -44,6 +44,9 @@ Singleton {
"isNiri": () => CompositorService.isNiri,
"isHyprland": () => CompositorService.isHyprland,
"isMango": () => CompositorService.isMango,
"isAqueous": () => CompositorService.isAqueous,
"nativeOverviewCapable": () => CompositorService.isNiri || CompositorService.isAqueous,
"smartDockCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous,
"isHyprlandOrNiri": () => CompositorService.isHyprland || CompositorService.isNiri,
"windowRulesCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango,
"layoutCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango,
+24 -12
View File
@@ -122,11 +122,11 @@ Singleton {
DMSService.sendRequest("wlroutput.applyConfiguration", {
"heads": heads
}, response => {
const success = !response.error;
const success = !response.error && response.result?.success === true;
const message = response.error || response.result?.message || "";
if (response.error) {
log.warn("applyConfiguration error:", response.error);
if (!success) {
log.warn("applyConfiguration error:", message);
} else {
log.debug("Configuration applied successfully");
}
@@ -135,7 +135,7 @@ Singleton {
if (callback) {
callback(success, message);
}
});
}, 5000);
}
function testConfiguration(heads, callback) {
@@ -151,11 +151,11 @@ Singleton {
DMSService.sendRequest("wlroutput.testConfiguration", {
"heads": heads
}, response => {
const success = !response.error;
const success = !response.error && response.result?.success === true;
const message = response.error || response.result?.message || "";
if (response.error) {
log.warn("testConfiguration error:", response.error);
if (!success) {
log.warn("testConfiguration error:", message);
} else {
log.debug("Configuration test passed");
}
@@ -163,7 +163,7 @@ Singleton {
if (callback) {
callback(success, message);
}
});
}, 5000);
}
function setOutputEnabled(outputName, enabled, callback) {
@@ -282,9 +282,22 @@ Singleton {
// NiriService, HyprlandService and MangoService. Instead of writing a
// config file, the changes are applied directly via the
// wlr-output-management protocol.
function applyOutputsConfig(outputsData, connectedOutputs) {
if (!wlrOutputAvailable)
function applyOutputsConfig(outputsData, connectedOutputs, callback) {
if (!wlrOutputAvailable) {
if (callback)
callback(false, "Not connected");
return;
}
const heads = outputsConfigHeads(outputsData, connectedOutputs);
if (heads.length === 0) {
if (callback)
callback(false, "No connected outputs");
return;
}
applyConfiguration(heads, callback);
}
function outputsConfigHeads(outputsData, connectedOutputs) {
const heads = [];
for (const name in outputsData) {
if (!connectedOutputs[name])
@@ -319,8 +332,7 @@ Singleton {
heads.push(head);
}
if (heads.length > 0)
applyConfiguration(heads);
return heads;
}
function transformFromName(name) {
@@ -0,0 +1,27 @@
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");
+136
View File
@@ -0,0 +1,136 @@
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, state: {}});
context.root = context;
loadMethods(context, "../Services/AqueousService.qml", ["byteLength", "parseJson", "validateCapabilities", "validateEntity", "reduceBatch", "acceptLine", "commandArguments", "commandResult"]);
const caps = JSON.parse(readFileSync(new URL("fixtures/aqueous/capabilities.json", import.meta.url)));
context.capabilities = context.validateCapabilities(caps);
assert.throws(() => context.validateCapabilities({...caps, schema: 2}), /unsupported/);
assert.throws(() => context.validateCapabilities({...caps, commands: undefined}), /capability/);
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.acceptLine(JSON.stringify(batch));
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);
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.commandArguments(action, fields));
assert.deepEqual(args("window.activate", {id: window.id}), ["aqueousctl", "window", "activate", "--id", window.id, "--seat", context.seat.id, "--json"]);
assert.deepEqual(args("window.move", {id: window.id, output: context.outputs[0].id}), ["aqueousctl", "window", "move", "--id", window.id, "--output", context.outputs[0].name, "--json"]);
assert.deepEqual(args("workspace.rename", {id: workspace.id, name: "a; $(echo unsafe)"}).slice(-3), ["--name", "a; $(echo unsafe)", "--json"]);
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(args("window.activate", {id: window.id, seat: context.seats[0].id}).includes(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");
+94
View File
@@ -0,0 +1,94 @@
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");
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
@@ -0,0 +1 @@
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
@@ -0,0 +1,11 @@
{
"schema": 1,
"session": "0123456789abcdef0123456789abcdef",
"max_batch_bytes": 4194304,
"state": true,
"commands": true,
"keyboard": true,
"overview": true,
"shortcut_inhibition": true,
"geometry": "committed-content-global-logical"
}
+42
View File
@@ -0,0 +1,42 @@
{"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"]}
+130
View File
@@ -0,0 +1,130 @@
import assert from "node:assert/strict";
import {readFileSync} from "node:fs";
import vm from "node:vm";
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 + "(");
assert.notEqual(start, -1, name);
const end = source.indexOf("\n }", start) + 6;
vm.runInContext(source.slice(start, end), context);
}
}
function property(context, name, prefix = " property var ", indent = " ") {
const source = readFileSync(new URL("../Modules/DankBar/Widgets/WorkspaceSwitcher.qml", import.meta.url), "utf8");
const token = prefix + name + ": {";
const start = source.indexOf(token);
assert.notEqual(start, -1, name);
const end = source.indexOf("\n" + indent + "}", start);
return vm.runInContext("(function(){" + source.slice(start + token.length, end) + "})()", context);
}
const context = vm.createContext({workspaceBackend: "ext", workspaceViewGeneration: 1, WindowManager: {windowsets: []}, Quickshell: {screens: [{name: "DP-1"}, {name: "DP-2"}]}});
context.root = context;
methods(context, "../Services/CompositorService.qml", ["selectWorkspaceBackend", "extWorkspaceRows", "workspaceViewForOutput"]);
for (const [inputs, expected] of [
[["aqueous", true, true, true, true], "ext"],
[["aqueous", true, true, false, true], "none"],
[["aqueous", true, false, true, true], "aqueous"],
[["aqueous", true, false, true, false], "ext"],
[["aqueous", true, false, false, false], "none"],
[["unknown", false, false, true, true], "none"],
[["unknown", false, true, true, false], "ext"],
[["labwc", true, false, true, false], "ext"],
]) assert.equal(context.selectWorkspaceBackend(...inputs), expected);
for (const backend of ["niri", "hyprland", "mango", "sway", "scroll", "miracle"]) {
assert.equal(context.selectWorkspaceBackend(backend, true, false, true, false), "legacy");
assert.equal(context.selectWorkspaceBackend(backend, true, true, true, false), "ext");
assert.equal(context.selectWorkspaceBackend(backend, true, true, false, false), "legacy");
}
let activations = 0;
const handle = {id: "", name: "same", active: true, urgent: false, canActivate: true, shouldDisplay: true, coordinates: [1], activate() {activations++;}};
const second = {...handle, coordinates: [0]};
context.WindowManager.windowsets = [handle, second];
let rows = context.extWorkspaceRows([handle, second], 1);
assert.equal(rows[0].key, second);
assert.equal(rows[1].key, handle);
assert.notEqual(rows[0].key, rows[1].key, "duplicate names were used as identity");
assert.equal(rows[0].windows, null);
handle.name = "renamed"; handle.urgent = true;
let updated = context.extWorkspaceRows([handle], 1)[0];
assert.equal(updated.key, handle);
assert.equal(updated.urgent, true);
assert.equal(updated.name, "renamed");
rows[1].activate(); assert.equal(activations, 1);
handle.canActivate = false; rows[1].activate(); assert.equal(activations, 1);
handle.canActivate = true;
context.WindowManager.windowsets = [second]; rows[1].activate(); assert.equal(activations, 1);
context.WindowManager.windowsets = [handle];
context.workspaceViewGeneration = 2; rows[1].activate(); assert.equal(activations, 1);
context.WindowManager.screenProjection = screen => ({windowsets: screen.name === "DP-1" ? [handle] : []});
assert.equal(context.workspaceViewForOutput("DP-2").rows.length, 0, "empty output switched backend");
assert.equal(context.workspaceViewForOutput("removed").rows.length, 0);
const actions = [];
const aq = vm.createContext({available: true, capabilities: {commands: true}, locked: false, seat: {id: "seat0"}, session: "session-a", CompositorService: {workspaceBackend: "aqueous", workspaceViewGeneration: 1},
workspaces: [{id: "w", name: "same", number: 1, output: "o1", active: true, urgent: false}],
outputs: [{id: "o1", name: "DP-1"}], focusedOutput: "DP-1", command: (...args) => actions.push(args)});
aq.root = aq;
methods(aq, "../Services/AqueousService.qml", ["taskbarEligible", "workspaceRowsForOutput", "workspacesForOutput", "outputId", "windowFacade"]);
aq.seat = {id: "seat0", window: "window-a"};
aq.Quickshell = {screens: [{name: "DP-1"}]};
const window = {id: "window-a", workspace: "w", output: "o1", app_id: "demo", visible: true, can_activate: true};
aq.toplevels = [aq.windowFacade(window)];
rows = aq.workspaceRowsForOutput("DP-1", 1);
assert.equal(rows[0].key, "session-a:workspace:w");
assert.equal(rows[0].windows[0].key, "session-a:window:window-a");
rows[0].activate(); assert.equal(actions[0][1].session, "session-a");
aq.session = "session-b";
rows[0].windows[0].activate(); assert.equal(actions[1][1].session, "session-a", "cached icon adopted a new session");
aq.CompositorService.workspaceViewGeneration = 2;
rows[0].activate(); rows[0].windows[0].activate(); assert.equal(actions.length, 2, "cached action survived backend change");
aq.locked = true;
assert.equal(aq.workspaceRowsForOutput("DP-1", 2)[0].canActivate, false);
aq.locked = false; aq.seat = null;
assert.equal(aq.workspaceRowsForOutput("DP-1", 2)[0].canActivate, false);
aq.seat = {id: "seat0"}; aq.capabilities.commands = false;
assert.equal(aq.workspaceRowsForOutput("DP-1", 2)[0].canActivate, false);
assert(aq.taskbarEligible({...window, minimized: true, visible: false}));
assert(!aq.taskbarEligible({...window, skip_taskbar: true}));
assert(!aq.taskbarEligible({...window, visible: false}));
aq.workspaces[0].active = false;
assert(aq.taskbarEligible({...window, visible: false}));
const settings = {showWorkspacePadding: true, showOccupiedWorkspacesOnly: false, showWorkspaceApps: true, groupWorkspaceApps: false, groupActiveWorkspaceApps: false, showWorkspaceName: true, showWorkspaceIndex: true};
const widget = vm.createContext({usesWorkspaceView: true, workspaceView: {rows: []}, _placeholderPool: [], SettingsData: settings, CompositorService: {},
DesktopEntries: {heuristicLookup: id => ({id})}, Paths: {moddedAppId: id => id, isSteamApp: () => false, getAppIcon: id => id, getAppName: id => id}, _desktopEntriesUpdateTrigger: 0});
widget.root = widget;
methods(widget, "../Modules/DankBar/Widgets/WorkspaceSwitcher.qml", ["padWorkspaces", "_makePlaceholder", "getRealWorkspaces", "switchToWorkspaceByModelData", "switchWorkspace", "getWorkspaceIcons", "getWorkspaceIndex", "getWorkspaceIndexFallback"]);
let clicked = [];
const windowFacade = (key, appId) => ({key, id: key, appId, activate: () => clicked.push(key)});
const first = {key: "one", name: "1", number: 1, active: true, windows: [windowFacade("a", "demo"), windowFacade("b", "demo")], canActivate: true, activate: () => clicked.push("one")};
const other = {key: "two", name: "1", number: 2, active: false, windows: [], canActivate: true, activate: () => clicked.push("two")};
widget.workspaceView.rows = [first, other];
widget.workspaceList = property(widget, "workspaceList");
assert.equal(widget.workspaceList.length, 3, "Aqueous padding was skipped");
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.deepEqual(clicked, ["two"]);
widget.currentWorkspace = "two"; widget.switchWorkspace(1); assert.equal(clicked.length, 1);
widget.currentWorkspace = null; widget.switchWorkspace(-1); assert.equal(clicked.at(-1), "one");
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);
Object.assign(widget, {workspaceIcons: icons, isPlaceholder: false});
assert.equal(property(widget, "stableIconCount", " readonly property int ", " "), icons.length, "icon count disagrees with renderer");
icons[0].windowAction(); assert.equal(clicked.at(-1), "a");
settings.groupWorkspaceApps = true; settings.groupActiveWorkspaceApps = true;
icons = widget.getWorkspaceIcons(first); assert.equal(icons.length, 1); assert.equal(icons[0].count, 2);
assert.equal(widget.getWorkspaceIcons({...first, windows: null}).length, 0, "ext membership was guessed");
settings.showOccupiedWorkspacesOnly = true;
widget.workspaceView.rows = [first, other, {...other, key: "unknown", windows: null}];
assert.equal(property(widget, "workspaceList").filter(row => !row.placeholder).length, 2, "unknown occupancy was treated as empty");
settings.showWorkspacePadding = false;
assert.equal(property(widget, "workspaceList").length, 2);
widget.workspaceView.rows = [];
assert.equal(property(widget, "workspaceList").length, 0, "empty output fabricated a workspace");
console.log("PASS: workspace selection, identities, stale actions, padding, navigation, icons and legacy backend selection");
@@ -407,7 +407,14 @@ def find_settings_components(content, filename, wrappers):
visible_raw = extract_property(block, "visible")
condition_key = FILE_CONDITION_MAP.get(filename)
if visible_raw:
if all(c in visible_raw for c in ("CompositorService.isNiri", "CompositorService.isHyprland", "CompositorService.isMango")):
if "CompositorService.isAqueous" in visible_raw:
if "CompositorService.isHyprland" in visible_raw:
condition_key = "smartDockCapable"
elif "CompositorService.isNiri" in visible_raw:
condition_key = "nativeOverviewCapable"
else:
condition_key = "isAqueous"
elif all(c in visible_raw for c in ("CompositorService.isNiri", "CompositorService.isHyprland", "CompositorService.isMango")):
condition_key = "windowRulesCapable"
elif "CompositorService.isNiri" in visible_raw:
condition_key = "isNiri"
@@ -1240,7 +1240,6 @@
"category": "Bar",
"keywords": [
"bar",
"during",
"frame",
"niri",
"overview",
@@ -1249,8 +1248,8 @@
"statusbar",
"topbar"
],
"description": "Show during Niri overview",
"conditionKey": "isNiri"
"description": "Show on Overview",
"conditionKey": "nativeOverviewCapable"
},
{
"section": "barSize",
@@ -2015,7 +2014,7 @@
"windows"
],
"description": "Show dock when floating windows don",
"conditionKey": "windowRulesCapable"
"conditionKey": "smartDockCapable"
},
{
"section": "dockIsolateDisplays",
@@ -2311,17 +2310,15 @@
"tabIndex": 5,
"category": "Dock",
"keywords": [
"always",
"dock",
"launcher bar",
"niri",
"overview",
"panel",
"show",
"taskbar"
],
"description": "Always show the dock when niri",
"conditionKey": "isNiri"
"description": "Show on Overview",
"conditionKey": "nativeOverviewCapable"
},
{
"section": "dockLauncherLogoSizeOffset",
@@ -3848,6 +3845,24 @@
],
"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",
@@ -6552,6 +6567,24 @@
],
"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",
@@ -9962,7 +9995,6 @@
"keywords": [
"border",
"decoration",
"during",
"frame",
"hide",
"integrations",
@@ -9972,8 +10004,8 @@
"window"
],
"icon": "toolbar",
"description": "Show during Niri overview",
"conditionKey": "isNiri"
"description": "Show on Overview",
"conditionKey": "nativeOverviewCapable"
},
{
"section": "frameLauncherEmergeSide",
@@ -10056,7 +10088,6 @@
"keywords": [
"border",
"decoration",
"during",
"frame",
"hide",
"niri",
@@ -10064,7 +10095,7 @@
"show",
"window"
],
"description": "Show during Niri overview",
"description": "Show on Overview",
"conditionKey": "frameEnabled"
},
{
+420
View File
@@ -0,0 +1,420 @@
#!/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 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"):
(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(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"))
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-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)
def watchers():
result = []
for process in Path("/proc").iterdir():
if not process.name.isdigit():
continue
try:
argv = (process / "cmdline").read_bytes().split(b"\0")
if argv[1:4] != [b"shell", b"watch", b"--json"]:
continue
environment = (process / "environ").read_bytes().split(b"\0")
except OSError:
continue
if argv[1:4] == [b"shell", b"watch", b"--json"] and ("XDG_RUNTIME_DIR=" + str(base / "runtime")).encode() in environment:
parent = next(line.split()[1] for line in (process / "status").read_text().splitlines() if line.startswith("PPid:"))
assert "dms" not in Path("/proc", parent, "comm").read_text().lower(), "core owns the watcher"
result.append(process.name)
return result
# 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
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 && typeof child.windowAction === "function") {
child.windowAction();
return true;
}
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: child.occupancyKnown, rawWindows: child.workspaceRowData?.windows?.length ?? null, count: child.stableIconCount,
icons: child.workspaceIcons.length, width: child.width, height: child.height,
extraWidth: child.iconsExtraWidth, extraHeight: child.iconsExtraHeight});
} else {
result = result.concat(workspaceDelegates(child));
}
}
return result;
}
IpcHandler {
target: "aqueousTest"
function state(): string { return JSON.stringify(AqueousService.state); }
function workspaceStatus(): string {
return JSON.stringify({backend: CompositorService.workspaceBackend, output: workspaceTest.effectiveScreenName,
rows: workspaceTest.workspaceList.map(w => ({name: w.name, active: w.active, number: w.number,
placeholder: w.placeholder, windows: w.windows?.length ?? null, canActivate: w.canActivate})),
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 workspaceReplay(): string { entrypoint.rememberedWorkspace.activate(); return "REQUESTED"; }
function workspaceOverview(): string { return String(CompositorService.toggleWorkspaceOverview(workspaceTest.effectiveScreenName)); }
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 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"'], "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)
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", "typography").startswith("SETTINGS_OPEN_SUCCESS"))
time.sleep(2)
assert "Type SettingsModal unavailable" not in (base / "dms.log").read_text()
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)
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)
wait_for(lambda: ipc("aqueousTest", "workspaceIcon", clients[1]["id"]) == "true")
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)
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)
watch_pids = watchers()
assert len(watch_pids) == 1, watch_pids
assert ipc("aqueousTest", "workspaceRemember") == "true"
os.kill(int(watch_pids[0]), signal.SIGKILL)
wait_for(lambda: not state().get("available", False))
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")
before = [r["number"] for r in workspace_status()["rows"] if r["active"]]
assert ipc("aqueousTest", "workspaceReplay") == "REQUESTED"
time.sleep(.2)
assert [r["number"] for r in workspace_status()["rows"] if r["active"]] == before, "cached row survived backend generation change"
restarted_pids = watchers()
assert len(restarted_pids) == 1 and restarted_pids != watch_pids
watch_pids += restarted_pids
time.sleep(1)
assert state()["available"], "quiet watch 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)
assert not any(Path("/proc", pid).exists() for pid in watch_pids), "watcher survived DMS shutdown"
print("PASS: daemon/UI, duplicate identities, keyboard, overview, screenshots, helper conflicts, keybinds, single watcher, workspace widget 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()
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Exercise AqueousService's real Quickshell processes using private fake CLI fixtures."""
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import tempfile
import time
def wait_for(fn, timeout=12):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = fn()
if result:
return result
time.sleep(0.05)
raise AssertionError("timed out waiting for QML service")
def main():
root = Path(__file__).resolve().parents[1]
base = Path(tempfile.mkdtemp(prefix="dms-aqueous-service-"))
print(f"Evidence: {base}", flush=True)
for directory in ("Common", "Services", "bin", "runtime", "home", "config", "cache"):
(base / directory).mkdir(mode=0o700)
for name in ("AqueousService.qml", "AqueousConfigService.qml"):
shutil.copy(root / "quickshell/Services" / name, base / "Services" / name)
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, stubs in (("Common", common), ("Services", services)):
for name, body in stubs.items():
(base / directory / (name + ".qml")).write_text("pragma Singleton\nimport QtQuick\nQtObject { " + body + " }\n")
for directory in ("Common", "Services"):
(base / directory / "qmldir").write_text("\n".join("singleton " + path.stem + " 1.0 " + path.name for path 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 reply: ({pending: true})
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.pendingProcesses.length, commandPending: AqueousService.commandPending,
retry: AqueousService.retryDelay, watching: !!AqueousService.watchProcess});
}
function command(): string {
shell.reply = {pending: true};
AqueousService.command("workspace.activate", {id: AqueousService.workspaces[0].id},
(ok, message) => { shell.reply = {ok: ok, message: message}; });
return "REQUESTED";
}
function result(): string { return JSON.stringify(shell.reply); }
}
}''')
shutil.copy(root / "quickshell/tests/fixtures/aqueous/capabilities.json", base / "capabilities.json")
batch = json.loads((root / "quickshell/tests/fixtures/aqueous/watch.ndjson").read_text().splitlines()[0])
(base / "snapshot.json").write_text(json.dumps(batch))
binary = base / "bin/aqueousctl"
binary.write_text('''#!/usr/bin/python3
import json, os, sys, time
from pathlib import Path
base = Path(os.environ["AQ_TEST_DIR"])
args = sys.argv[1:]
mode = (base / "mode").read_text()
with (base / "calls").open("a") as f:
f.write(json.dumps({"args": args, "pid": os.getpid()}) + "\\n")
if args[:2] == ["shell", "capabilities"]:
caps = json.loads((base / "capabilities.json").read_text())
if mode == "unsupported": caps["schema"] = 2
print(json.dumps(caps))
sys.exit(0)
if args[:2] != ["shell", "watch"]:
if mode == "timeout": time.sleep(30)
if mode == "no-ack": sys.exit(0)
print(json.dumps({"ok": False, "status": "locked", "sequence": "9007199254740999"}))
sys.exit(1)
batch = json.loads((base / "snapshot.json").read_text())
batch["sequence"] = "9007199254740993"
workspace = next(e for e in batch["upsert"] if e["kind"] == "workspace")
workspace["name"] = "🫧 日本語"
data = (json.dumps(batch, ensure_ascii=False) + "\\n").encode()
split = data.index("🫧".encode()) + 1
sys.stdout.buffer.write(data[:split]); sys.stdout.buffer.flush()
time.sleep(.02)
sys.stdout.buffer.write(data[split:]); sys.stdout.buffer.flush()
delta = dict(schema=1, session=batch["session"], sequence="9007199254740999", base_sequence=batch["sequence"], type="delta", upsert=[], removed=[])
time.sleep(.15)
if mode == "malformed": print("{bad}", flush=True)
elif mode == "oversize": print("x" * (4 * 1024 * 1024 + 1), flush=True)
elif mode == "eof": sys.exit(0)
else:
if mode == "mismatch": delta["base_sequence"] = "wrong"
# Multiple records in one write exercise coalesced reads.
second = dict(delta, sequence="9007199254741999", base_sequence=delta["sequence"])
print(json.dumps(delta) + "\\n" + json.dumps(second), flush=True)
time.sleep(60)
''')
binary.chmod(0o755)
env = dict(os.environ)
for name in ("WAYLAND_DISPLAY", "WAYLAND_SOCKET", "DISPLAY", "DMS_SOCKET"):
env.pop(name, None)
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"),
QT_QPA_PLATFORM="offscreen", QT_QUICK_BACKEND="software")
(base / "mode").write_text("normal")
(base / "calls").touch()
qs = shutil.which("qs") or shutil.which("quickshell")
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)
return result.stdout.strip()
def state():
return json.loads(ipc("status"))
def calls():
return [json.loads(line) for line in (base / "calls").read_text().splitlines()]
def reply():
response = json.loads(ipc("result"))
return response if not response.get("pending") else None
def stop():
ipc("enabled", "false")
wait_for(lambda: not state()["watching"] and not state()["pending"])
time.sleep(.1)
assert not any(Path("/proc", str(call["pid"])).exists() for call in calls()), "child process survived backend change"
with (base / "quickshell.log").open("w") as log:
process = subprocess.Popen([qs, "-p", str(base / "shell.qml")], env=env, stdout=log, stderr=log, start_new_session=True)
try:
wait_for(lambda: subprocess.run([qs, "ipc", "-p", str(base / "shell.qml"), "call", "test", "status"], env=env, capture_output=True).returncode == 0)
assert not calls(), "Aqueous helper initialized in unrelated backend"
ipc("enabled", "true")
current = wait_for(lambda: (s if (s := state())["available"] and s["state"]["batch"]["sequence"] == "9007199254741999" else None))
assert any(e.get("name") == "🫧 日本語" for e in current["state"]["batch"]["upsert"])
count = len(calls())
time.sleep(1)
assert state()["available"] and len(calls()) == count, "quiet stream spawned subprocesses"
ipc("command")
assert "locked" in wait_for(reply)["message"]
for mode in ("malformed", "mismatch", "oversize", "eof", "unsupported"):
stop()
(base / "mode").write_text(mode)
ipc("enabled", "true")
wait_for(lambda: state()["state"].get("error"))
assert not state()["available"], mode
assert state()["retry"] >= 1000
stop()
binary.rename(base / "bin/aqueousctl-disabled")
ipc("enabled", "true")
wait_for(lambda: state()["state"].get("error"))
assert not state()["pending"]
time.sleep(.7)
assert state()["retry"] <= 4000, "missing CLI caused a tight retry loop"
stop()
(base / "bin/aqueousctl-disabled").rename(binary)
(base / "mode").write_text("no-ack")
ipc("enabled", "true")
wait_for(lambda: state()["available"])
ipc("command")
assert not wait_for(reply)["ok"], "EOF without acknowledgement accepted"
stop()
(base / "mode").write_text("timeout")
ipc("enabled", "true")
wait_for(lambda: state()["available"])
count = len([c for c in calls() if c["args"][0] == "workspace"])
ipc("command")
assert "uncertain" in wait_for(reply)["message"]
assert len([c for c in calls() if c["args"][0] == "workspace"]) == count + 1, "timed-out mutation retried"
stop()
(base / "mode").write_text("normal")
ipc("enabled", "true")
wait_for(lambda: state()["available"])
finally:
os.killpg(process.pid, signal.SIGTERM)
process.wait(timeout=5)
wait_for(lambda: not any(Path("/proc", str(call["pid"])).exists() for call in calls()))
print("PASS: QML process framing, Unicode, continuity, errors, retry, timeout, idle and cleanup", flush=True)
if __name__ == "__main__":
main()