diff --git a/core/Makefile b/core/Makefile index 1ddc9d754..c3ee3960d 100644 --- a/core/Makefile +++ b/core/Makefile @@ -50,7 +50,7 @@ sync-shell: @rm -rf $(EMBED_DIR) @mkdir -p $(EMBED_DIR) @tar -C $(SHELL_SRC) --exclude=.qmlls.ini -chf - . | tar -C $(EMBED_DIR) -xf - - @rm -rf $(EMBED_DIR)/.git* $(EMBED_DIR)/.github + @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 diff --git a/core/cmd/dms/commands_aqueous_keybinds.go b/core/cmd/dms/commands_aqueous_keybinds.go new file mode 100644 index 000000000..559a62a19 --- /dev/null +++ b/core/cmd/dms/commands_aqueous_keybinds.go @@ -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)) +} diff --git a/core/cmd/dms/commands_keybinds.go b/core/cmd/dms/commands_keybinds.go index 915011426..4966375d9 100644 --- a/core/cmd/dms/commands_keybinds.go +++ b/core/cmd/dms/commands_keybinds.go @@ -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) diff --git a/core/cmd/dms/commands_screenshot.go b/core/cmd/dms/commands_screenshot.go index 9a78c77e1..6c018959e 100644 --- a/core/cmd/dms/commands_screenshot.go +++ b/core/cmd/dms/commands_screenshot.go @@ -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 } diff --git a/core/internal/keybinds/providers/aqueous.go b/core/internal/keybinds/providers/aqueous.go new file mode 100644 index 000000000..6b51efa5e --- /dev/null +++ b/core/internal/keybinds/providers/aqueous.go @@ -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) +} diff --git a/core/internal/keybinds/providers/aqueous_config.go b/core/internal/keybinds/providers/aqueous_config.go new file mode 100644 index 000000000..102e38086 --- /dev/null +++ b/core/internal/keybinds/providers/aqueous_config.go @@ -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 +} diff --git a/core/internal/keybinds/providers/aqueous_test.go b/core/internal/keybinds/providers/aqueous_test.go new file mode 100644 index 000000000..638e7a6cd --- /dev/null +++ b/core/internal/keybinds/providers/aqueous_test.go @@ -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) + } +} diff --git a/core/internal/keybinds/types.go b/core/internal/keybinds/types.go index 0c4abbb07..7eda09543 100644 --- a/core/internal/keybinds/types.go +++ b/core/internal/keybinds/types.go @@ -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"` diff --git a/core/internal/screenshot/aqueous.go b/core/internal/screenshot/aqueous.go new file mode 100644 index 000000000..40d4d68a0 --- /dev/null +++ b/core/internal/screenshot/aqueous.go @@ -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 +} diff --git a/core/internal/screenshot/aqueous_snapshot.go b/core/internal/screenshot/aqueous_snapshot.go new file mode 100644 index 000000000..6c66a18d4 --- /dev/null +++ b/core/internal/screenshot/aqueous_snapshot.go @@ -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) +} diff --git a/core/internal/screenshot/aqueous_test.go b/core/internal/screenshot/aqueous_test.go new file mode 100644 index 000000000..3dca4f29e --- /dev/null +++ b/core/internal/screenshot/aqueous_test.go @@ -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") + } + } +} diff --git a/core/internal/screenshot/compositor.go b/core/internal/screenshot/compositor.go index 77673ed7d..cd73c2343 100644 --- a/core/internal/screenshot/compositor.go +++ b/core/internal/screenshot/compositor.go @@ -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: diff --git a/core/internal/screenshot/compositor_linux.go b/core/internal/screenshot/compositor_linux.go new file mode 100644 index 000000000..12b9358db --- /dev/null +++ b/core/internal/screenshot/compositor_linux.go @@ -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))) +} diff --git a/core/internal/screenshot/compositor_other.go b/core/internal/screenshot/compositor_other.go new file mode 100644 index 000000000..b1c903cd5 --- /dev/null +++ b/core/internal/screenshot/compositor_other.go @@ -0,0 +1,5 @@ +//go:build !linux + +package screenshot + +func waylandSocketOwner() string { return "" } diff --git a/core/internal/screenshot/screenshot.go b/core/internal/screenshot/screenshot.go index 3963635c5..4a52a15eb 100644 --- a/core/internal/screenshot/screenshot.go +++ b/core/internal/screenshot/screenshot.go @@ -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() diff --git a/core/internal/screenshot/testdata/README.md b/core/internal/screenshot/testdata/README.md new file mode 100644 index 000000000..7b5835813 --- /dev/null +++ b/core/internal/screenshot/testdata/README.md @@ -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. diff --git a/core/internal/screenshot/testdata/aqueous-snapshot.json b/core/internal/screenshot/testdata/aqueous-snapshot.json new file mode 100644 index 000000000..6d20f1442 --- /dev/null +++ b/core/internal/screenshot/testdata/aqueous-snapshot.json @@ -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": []} diff --git a/core/internal/screenshot/types.go b/core/internal/screenshot/types.go index 5762aee7a..16c8cdd72 100644 --- a/core/internal/screenshot/types.go +++ b/core/internal/screenshot/types.go @@ -56,6 +56,7 @@ type Output struct { } type Config struct { + Seat string Mode Mode OutputName string Cursor CursorMode diff --git a/core/internal/utils/process_json.go b/core/internal/utils/process_json.go new file mode 100644 index 000000000..3710bd86a --- /dev/null +++ b/core/internal/utils/process_json.go @@ -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 +} diff --git a/core/internal/utils/process_json_test.go b/core/internal/utils/process_json_test.go new file mode 100644 index 000000000..ca38da873 --- /dev/null +++ b/core/internal/utils/process_json_test.go @@ -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"` +} diff --git a/docs/aqueous-integration-plan.md b/docs/aqueous-integration-plan.md new file mode 100644 index 000000000..90404b145 --- /dev/null +++ b/docs/aqueous-integration-plan.md @@ -0,0 +1,1099 @@ +# Aqueous integration: repository implementation plan + +Status: implemented in the working tree against Aqueous master +`2d8b07fb1ff2354ea0c3acddd5cc84ceb823d6eb`, with the limitations and test evidence +recorded in [Aqueous integration](aqueous.md). No commits or PRs were created. +Audit and implementation date: 2026-09-05. + +This document retains the implementation sequence, runtime/configuration contracts, +complete batch schema, and Wayland XML from the planning audit. The initial audit +table and work-package instructions below are historical planning context. The +helper sources have now been inspected and built, and real runtime fixtures have +been captured. In particular, the pinned helper cannot persist output scale, +enablement or adaptive-sync changes through its monitor adapter, and cannot safely +represent EDID-only monitor edits. Those portions of P8 need an Aqueous follow-up. + +The implementation follows the direct QML service design: `AqueousService.qml` +owns capability discovery, the single watcher, atomic reducer, reconnect lifecycle +and typed CLI actions. `AqueousConfigService.qml` handles stdin helper requests. +Standalone screenshot and keybind adapters remain in their existing core packages; +there is no `core/internal/aqueous` package or Aqueous server route. See +[implementation notes](aqueous.md) for the direct parser boundary and the CLI's +lack of an atomic expected-session command check. + +## Scope and audited baseline + +Implement DMS P0–P10. Keep generic DPMS and display-result fixes independently reviewable. DankLinux-Docs, packaging, plugin registry, and optional Quickshell bindings belong in their owning repositories. Proposed PR boundaries below do not authorize creating PRs, committing, or pushing. + +Read `AGENTS.md` and `CONTRIBUTING.md` again at implementation time. Use Theme tokens, Dank wrappers, guard clauses, scoped Log, and existing I18n terms. Do not manually edit translation catalogs, generated mocks, or embedded shell output. Current hooks also enforce an I18n term freeze; inspect it before adding messages. + +| Component | Evidence from this audit | Consequence | +| --- | --- | --- | +| DMS | `5baef07048656867a374d210d4305911b8a76e4f`; clean before this plan | Rebase the handoff's historical audit onto this revision. | +| Common QML | Gitlink `26396ce432d6c71c3f5367438f96f4a8d667e160`; submodule uninitialized | Initialize the pinned submodule before inspecting shared Proc/Compositor behavior or running UI checks. | +| Installed Quickshell | `noctalia-qs 0.0.12 (revision , distributed by Arch Linux)` | Exact source revision and compatibility with this DMS checkout remain unverified. | +| Installed Aqueous | `aqueous -version` reports `aqueous 0.4.8 +xwayland` | This version is an observation, not a supported minimum. | +| Installed aqueousctl | Help lists windows/inspect/scene/outputs/overlay-planes/layout/cursor; `shell capabilities --json` exits 2 with usage | Installed CLI cannot exercise the supplied shell contract. | +| Installed helper | `aqueous-config version` returns `{"ok":true,"protocol":1,"version":"0.1.0"}`, without capabilities | Obtain the actual 0.7.1 capability-aware build and pin its revision. | +| Toolchain | `core/go.mod` requires Go 1.26.5; Go was not found on PATH; Qt 6 qmllint/qmltestrunner/qmlformat are installed | Provision the prescribed development environment before implementation checks. | + +The previous DMS revision `59a03f450dbf5ae5dd8aa2cd301b89d9293c68a3` is historical, not a released minimum. No compatible isolated Aqueous session, real snapshot/delta fixtures, helper snapshot, hardware tests, or application test suite was run during this planning audit. + +Source inspection confirmed: + +- No Aqueous integration was found under quickshell, core, or docs. +- `CompositorService.qml` already detects the Wayland socket owner before environment fallbacks. Extend that mechanism. +- `SessionService.logout()` starts UWSM and immediately calls `_logout()`; UWSM output/failure handlers can call it again. Detected Aqueous would otherwise reach `HyprlandService.exit()`. +- `DisplayConfigState.backendWriteOutputsConfig()` still invokes `WlrOutputService.applyOutputsConfig()` then `finish(true)`. The high-level helper drops the callback, although lower-level `applyConfiguration()` has one. This is source evidence; runtime reproduction remains required. +- Core screenshot detection still uses environment candidates; it needs socket-owner precedence independently of QML. +- Taskbar/dock consumers both call window methods and directly assign `minimized`. Adding rows to a model alone will not make actions correct. +- `commands_keybinds.go` removes a replaced key before `SetBind`. The writable provider API has no generation argument for removal. Aqueous requires an atomic helper request carrying the generation observed by the UI. +- Installed Quickshell IO metadata exposes QString reads and a SplitParser delimiter, but no configurable byte bound. It does not prove bounded buffering or strict UTF-8 handling. Native toplevel identity correlation is also unverified. + +## Architecture decisions and P0 gates + +Use one `AqueousService.qml` singleton, gated on positive session detection. Expose detection, capabilities, model availability, session/sequence, seat selection, and command errors separately. Never spawn helpers merely because a widget imports the service. + +Prefer one direct `aqueousctl shell watch --json` process owned by that service. Use argument-array processes for explicit actions. No snapshot polling, heartbeats, per-widget watchers, background configuration polling, or quiet-stream timeout. + +Use Quickshell Process and SplitParser with the supported aqueousctl adapter. +The CLI enforces bounded UTF-8 protocol batches; SplitParser reassembles its NDJSON +and QML validates complete record sizes, schema and continuity. The installed +Quickshell parser has no pre-delimiter byte bound or raw UTF-8 error reporting, +so that part of the contract is delegated to aqueousctl. Do not claim independent +protection from a faulty CLI's unterminated or invalid-byte output. A future parser +capability can close that gap without moving compositor state or actions to core. +Test the production QML reducer and actual Process lifecycle. + +Default to Aqueous's authoritative windows/workspaces and ID-based actions. A native identity bridge is optional only after confirming the exact ext-foreign-toplevel identifier on the tested Quickshell build. Never match title, app ID, window order, connector reuse, or workspace display number to establish identity. + +Publish each accepted transaction as one model revision so consumers cannot observe half a move/removal. A facade may expose existing appId/title/activated/state fields and methods, but action setters must route through capability-aware commands. Alternatively replace affected direct mutations with shared CompositorService action methods. Preserve existing backends and validate stale actions against both captured session and current entity membership. + +Persistent settings use one shared aqueous-config adapter with version/capability discovery, stdin JSON, validation, and the UI's retained generation. Do not introduce a TOML parser/writer. Keep the runtime shell service independent of helper availability. + +## Delivery sequence + +```mermaid +flowchart TD + P0["P0: audit, compatible builds, fixtures"] + P1["P1: detection, capabilities, logout"] + P2A["P2a: stream and model"] + P2B["P2b: shell consumers"] + H["Helper adapter and fixture gate"] + P0 --> P1 --> P2A --> P2B + P2B --> P3["P3: keyboard"] + P2B --> P10["P10: overview"] + P0 --> P4["P4: screenshot geometry and detection"] + P0 --> P5["P5: generic DPMS"] + P0 --> P6["P6: asynchronous display result"] + P0 --> H + H --> P7["P7: keybinds"] + H --> P8["P8: persistent displays"] + P6 --> P8 + H --> P9["P9: appearance"] +``` + +This expresses dependencies, not a requirement for parallel agents. P4 can use a fresh standalone snapshot without waiting for consumer wiring; it still needs verified socket detection and the same validated shell geometry contract. + +### P0 — Finish prerequisite audit and capture fixtures + +1. Initialize the pinned common submodule without updating its revision. Inspect its applicable instructions, Proc implementation and shared Compositor API. Pin Quickshell source/package revision and DMS daemon used for UI tests. +2. Obtain the Aqueous working-tree patch set implementing this contract and helper 0.7.1. Record source commits, dirty patch identifiers and binary versions. Installing similarly numbered builds is not evidence of capability. +3. Launch an isolated internal-policy Aqueous session with private runtime/config directories and explicit Wayland display. Inspect the Aqueous test harness before using it; never run action tests against the user's session. +4. Capture sanitized capabilities, initial snapshot, deltas, command results/errors, helper version and helper snapshot. Preserve all identity relationships when redacting titles/app IDs/paths. Capture actual helper requests from `plugin/helper/` and `dms-plugin/README.md`; document adapter reports, retry flags and generation types. +5. Resolve the transport gate above and test native identity only if considering correlation. Record the final implementation file map and where executable fixtures/tests will live. + +Exit: exact dependency manifest, usable isolated session, fixture set, transport choice, complete helper request examples. P0 is partially complete from this audit; those remaining tasks are not checked off. + +### P1 — Detection, capability lifecycle and logout + +Proposed PR: `compositor: recognize Aqueous and support session exit`. + +Targets: `quickshell/Services/CompositorService.qml`, `SessionService.qml`, new `AqueousService.qml`; inspect shell/service initialization and shared Proc before wiring startup. + +- Add `isAqueous`, socket-owner mapping for `aqueous`, and verified desktop/session fallbacks using existing precedence. A parent compositor's inherited variables must not override the socket owner. +- Probe `aqueousctl shell capabilities --json` only after detection. Reject unsupported schema; classify executable/protocol absence without rapid retries. Preserve runtime observation when commands are unsupported. +- Dispatch direct logout as `aqueousctl session exit --json`, checking exit status and accepted JSON. EOF alone is not acceptance. Old CLI or locked-session failure must not fall through to Hyprland. +- Preserve custom actions and UWSM precedence. Fix single-dispatch UWSM control flow: wait for completion before any fallback, and ensure output plus exit callbacks cannot trigger duplicates. Make this small generic fix separately reviewable if necessary. + +Verify direct/nested/UWSM/custom sessions, missing/old CLI, schema mismatch, lock, command failure and acknowledged orderly exit. Verify no Aqueous executable is needed for other compositors. + +### P2a — Live stream, reducer and command layer + +Proposed PR: `compositor: add the Aqueous live state service`. Depends on P1. + +Targets: AqueousService, a pure reducer module or core package selected at P0, and transport-specific fixtures/tests. + +- Store IDs and decimal sequences as strings. Entity key is session + kind + id. Do not parse sequences through Number, including comparison/sorting helpers. +- First record must be a snapshot. Require supported schema, valid envelope/entities, matching session and exact delta base sequence. Sequence jumps are valid. Replace upserts completely, stage all changes/removals, validate references in the completed state, then publish once. +- Keep missing optional fields cleared and ignore additive unknown fields. The supplied schema permits only the listed entity kinds; do not silently accept new kinds without an explicit compatibility decision. +- Validate invariants beyond JSON schema: exactly the fixed session entity, unique entity keys, usable seat defaults, reference integrity and effective keyboard indices. Resolve any uncertain invariant against real fixtures; do not require contiguous sequences. +- Bound incomplete byte buffers, decoded payloads, pending operations and diagnostic output. On malformed/truncated data, wrong continuity or EOF, mark unavailable immediately and disable old-ID actions. +- Suggested reconnect policy: one-shot delay starting at 500 ms, doubling to a 30 s cap with up to 20% jitter within the cap. Avoid resetting to minimum after repeated snapshot-then-EOF failures; reset only after sustained healthy connection. Permanent missing/unsupported prerequisites wait for explicit retry or backend/session change. +- Use generation tokens to discard old process callbacks after backend changes. Stop/reap children, release command handles and cancel retry timers on teardown. +- Commands recheck capabilities, lock, session and target membership immediately before dispatch. Bound concurrent requests; no blind retry after timeout. Use authoritative subsequent state to reflect mutation outcomes. + +Verify fragmented/coalesced records, split Unicode, invalid UTF-8, payload boundary and missing delimiter, malformed envelopes/entities, >2^53 sequences, jumping sequences, full replacement, reset, delta mismatch, restart/session reuse, same-batch moves/removals, command failures and cleanup. Assert one watcher and zero idle polling. + +### P2b — Workspace, window and focused-output consumers + +Proposed PR: `compositor: integrate Aqueous workspaces and windows`. Depends on P2a. + +Targets discovered here: + +- `CompositorService.qml`: sortedToplevels, equivalence checks, activate/toggle/minimize helpers, workspace/output filtering, fullscreen and overlap queries, focused screen. +- `Services/BarWidgetService.qml`: selected-output routing. +- `Modules/DankBar/Widgets/WorkspaceSwitcher.qml`, `RunningApps.qml`, `AppsDock.qml`, and `FocusedWindowContextMenu.qml`. +- `Modules/Dock/DockApps.qml`, `DockAppButton.qml`, `DockContextMenu.qml`, and `DockBody.qml`. +- Trace launcher window actions and workspace-overlay consumers from the shared interfaces after model wiring; do not assume every consumer uses sortedToplevels. + +Use runtime workspace IDs for activation, membership and moves; resolve output IDs to connector names. Seat-selected output remains correct for empty workspaces and layer focus. Explicit seat identity is required for focus actions; an ambiguous default disables the action or requests seat selection. + +Honor skip_taskbar/skip_switcher, minimized, visibility, active workspace and per-window eligibility. Hidden/minimized windows may remain actionable where the existing UI intentionally restores them; visibility must not be mistaken for occlusion. Preserve app grouping/icon lookup as presentation only, using XWayland class when app_id is null. + +Wire every existing activation/close/state/move affordance to the authoritative adapter. Update list equality checks to include changes that affect output/workspace/state/eligibility. A move alone must not activate its destination. Disable actions on stale menu objects even if the same ID string reappears in a new session. Keep verified generic workspace switching when the adapter is unavailable. + +Verify duplicate workspace “1” on two outputs, duplicate app IDs/titles, native/XWayland, rename/reaping/migration, layer focus, empty workspaces, target loss, capability loss, restart, taskbar/dock filtering and idle cost. + +### P3 — Keyboard layout state and actions + +Proposed PR: `keyboard: support Aqueous layout state and switching`. Depends on P2. + +Target: `Modules/DankBar/Widgets/KeyboardLayoutName.qml`. + +Read the chosen seat's active keyboard group, layouts and effective zero-based index. Use explicit seat/group for set/next; include all layout labels in existing width-reservation logic. Physical/client switches, hotplug and keymap reload update via the stream, never inferred from TOML. + +Verify no keyboard, multiple seats/layouts/groups, index wrapping, independent groups, physical changes, active-group replacement, stale IDs, missing capability and reconnect. + +### P4 — Active-window screenshot backend + +Proposed PR: `screenshot: support active-window capture on Aqueous`. + +Targets: `core/internal/screenshot/compositor.go`, `screenshot.go`, callers/config/CLI seat selection, and focused geometry tests. Add a reusable core shell decoder if the chosen transport already needs one; avoid independent conflicting schema interpretations. + +Bring standalone detection to socket-owner precedence so a nested session cannot invoke the parent's screenshot backend. Obtain one fresh atomic snapshot; resolve explicit or unambiguous seat, focused eligible window, output and geometry from it. Error on ambiguous seat, layer/no focus, hidden target, missing output or lost target. + +Initial behavior: crop the composed output using outer_geometry, including compositor borders and any client decorations already within content. Occluders remain visible; this is not isolated toplevel capture. Trace the existing capture backend's transforms before adding math. Convert global logical bounds to the backend's coordinate space exactly once, round outward, and clip to output pixel extent. Verify fractional scale/rotation, transformed buffers and negative origins. Cross-output bounds are clipped to the selected output for this initial backend. + +Geometry describes committed destination during animation; do not promise exact animated-clone capture. A snapshot cannot freeze a target through the pixel capture: use removal evidence when available, document unavoidable race limits, and never substitute another window. + +Verify native/XWayland decoration cases, fractional scale, rotations, clipping, negative origins in native-only mode, moving/closing windows and no-focus errors. Attach actual crop images/dimensions. XWayland mode currently rejects negative output origins per the supplied reference; verify on the tested build. + +### P5 — Generic protocol DPMS fallback + +Proposed PR: `compositor: use protocol DPMS for capable fallback compositors`. Independent of P2. + +Targets: CompositorService power methods; `core/cmd/dms/commands_dpms.go`, `dpms_client.go`; existing daemon capability discovery as needed. + +Retain specialized paths and labwc behavior. Discover output-power availability separately from the `wlroutput` output-management capability. Reuse Wayland connection/registry lifecycle; if no exported capability exists, add a small explicit capability to existing discovery. Run `[Proc.dmsBin, "dpms", "off"|"on"]` with a result callback, surface failures and return after dispatch. Audit mode/failed event handling in the current client. + +Verify unsupported protocol, reconnect/resume and specialized backends. Physical idle-off/wake-on is mandatory; headless success does not certify DPMS. Do not persist power state. + +### P6 — Propagate real asynchronous display results + +Proposed PR: `displays: propagate the actual output apply result`. Independent generic fix, required for P8. + +Targets: `WlrOutputService.qml`, `DisplayConfig/DisplayConfigState.qml`, `DMSService.qml` request lifecycle if needed; existing `core/internal/server/wlroutput/` handlers/tests. + +Reproduce the source-confirmed premature success with a controllable delayed backend. Add callback propagation through applyOutputsConfig and finish from the actual response. Define exactly-once completion for unavailable backend, empty/no-op candidate, rejected test/apply, disconnect, cancellation and timeout. Check server result semantics instead of assuming a lack of top-level error means success. + +Update applyChanges/profile/revert callers that currently ignore completion; add operation identities and timeout cleanup. Ignore stale callbacks after supersession. A timeout is uncertain runtime state, not permission to retry blindly or report rollback. + +Verify rejected mode, delayed success, disconnect, timeout and late/out-of-order completion; prove no success UI before backend response. Skip only if a re-audit finds this fixed upstream. + +### Shared helper gate for P7–P9 + +Targets: proposed shared core aqueous-config client and thin QML service/provider surface, following the existing provider architecture. + +Pin actual helper/plugin sources and capture exact request/response fixtures before implementing writes. Check protocol 1 plus each used capability; helper version alone is insufficient. No guessed JSON fields. + +Discover on demand; keep generation with each draft. Send complete validated stdin requests with expected_generation. Preserve drafts on conflict, and inspect canonical saved state after uncertain timeout. Return structured complete/partial/conflict/error results without discarding adapter details. Keep manual retries explicit. + +### P7 — Keybind provider and cheatsheet + +Proposed PR: `keybinds: add the Aqueous configuration provider`. + +Targets: `Services/KeybindsService.qml`, `Common/KeybindActions.js`, `core/cmd/dms/commands_keybinds.go`, `core/internal/keybinds/types.go`, new provider under `providers/`, and provider tests. + +Implement schema-backed built-ins including unbound actions, multiple bindings, custom commands, modifiers, conflicts and validation. Extend the request/response path to carry the generation observed when the draft was loaded through set/remove/reset/replace. Do not take a new generation immediately before saving and thereby bypass conflict protection. + +Aqueous binding replacement must submit removal and addition in one validated helper request. Do not use the existing delete-then-set path. Preserve other providers' conventions and do not invent compositor fragment/include files for Aqueous. + +Verify read/edit/remove, multiple bindings, custom commands, invalid draft, atomic replacement, retained stale draft, unsupported helper and concurrent plugin editing. + +### P8 — Persistent displays and safe preview + +Proposed PR: `displays: persist Aqueous configuration through aqueous-config`. Depends on helper gate and P6. + +Targets: DisplayConfigState and display confirmation/settings components, WlrOutputService, shared helper adapter. + +Separate runtime preview from canonical persistence. Keep the original live configuration plus candidate identity, test/apply via wlr output management, start Keep/Revert only after actual successful apply, and persist through helper only after Keep. A timeout/cancel before Keep may revert only if current live state still matches the provider's preview. + +Compare complete relevant configuration, not incidental protocol serials. Hotplug, external change or reload requires fresh state; report conflict instead of overwriting newer settings. Keep original/candidate information across partial failure until resolved. + +Preserve configured offline monitors, EDID identity, custom modes and fractional refresh. Existing DMS profile/auto-apply and battery-refresh paths must not silently write canonical Aqueous preferences. Saving a profile and enabling automatic policy require explicit ownership decisions in the implementation. + +Verify rejected preview, countdown/Keep/Revert, restart persistence, hotplug/external edits during preview, helper conflicts, uncertain timeout, partial file saves and offline entries. Distinguish preview success, canonical save success and subsequent physical application. + +### P9 — Cursor, typography and appearance ownership + +Proposed PR: `appearance: integrate Aqueous configuration adapters`; split cursor/typography if clearer. + +Targets: `Common/SettingsData.qml` cursor/font setters, existing appearance/settings controls, `ThemeAutoService.qml` where relevant, shared helper adapter and plugin coexistence settings. + +Audit current toolkit/Xresources/compositor writes before routing Aqueous through cursor_sync/typography_sync. Opening settings loads state only. Default automatic synchronization to no new owner until the user explicitly selects the native provider or plugin; preserve existing plugin enablement. Both UIs retain manual Apply with generation protection. + +Expose successful canonical save plus partial toolkit failure and explicit sync_cursor/sync_typography retries. Preserve limits for font face/slant/width, independently scaled bars and client-owned cursor surfaces. No additional background color/font/cursor writer. + +Verify manual Apply, live cursor, partial toolkit failure/retry, stale generation and plugin coexistence without feedback loops. Portal plugin enablement is independent. + +### P10 — Native overview and frame verification + +Proposed PR: `overview: control the Aqueous compositor overview`. Depends on P2. + +Targets: WorkspaceSwitcher overview controls, existing overview entry points, CompositorService/AqueousService; inspect `Modules/WorkspaceOverlays/` for interaction. Route existing controls to show/hide/toggle using connector names and observe global overview_output/overview_window. + +Verify idempotence, repeated toggling, output switch, empty workspace/unavailable policy, lock/cancellation, target removal and animations disabled. Do not build a new DMS thumbnail overview. + +Validate `Modules/Frame/FrameExclusions.qml`, readiness/layout refresh, all four bar edges, dock placement, fullscreen, multiple outputs and crash cleanup. Standard `dms:frame-exclusion` positive exclusive zones already have reference evidence. Make a separate frame change only for a reproduced gap. No new margin lease, unsupported command or duplicate persistent gap reservation. + +## Required verification and review record + +For each proposed PR record: before/after behavior, DMS/common/Quickshell/Aqueous/helper revisions and patches, required capabilities, fallback behavior, commands/results, representative UI evidence and unverified cases. + +Implementation checks: + +- `make lint-qml` with the matching Quickshell VFS and initialized common submodule. +- `make -C core fmt`, `make -C core test`; `go mod tidy` from core when relevant, inspecting resulting dependency changes. +- Focused production-reducer/process and helper fixture tests, meaningful screenshot input/output geometry tests, provider tests and asynchronous display tests. Add a runnable QML test harness if needed; do not replace behavior tests with source-text assertions. +- Applicable `prek` hooks, including settings search index, term freeze/variants, YAML/shell checks and no-console rule. Inspect actual lint configuration: CONTRIBUTING mentions golangci-lint, but the audited pre-commit file does not list it. +- Match affected Go CI checks: `go test -v ./...`, ordinary dms build, `make build`, `distro_binary withshell` build, dankinstall build, and FreeBSD compilation when shared/platform code changes. Full test CI provisions Flatpak runtime; reproduce prerequisites before attributing environment failures to the patch. +- Measure one watcher per session, bounded buffers/queues/retries, no periodic idle subprocesses and cleanup on reload/disconnect. Avoid broad repeat tests after unchanged successful checks. + +Run real daemon + Quickshell in an isolated Aqueous session for each integrated feature. Physical checks cover DPMS, scale/rotation, hotplug, resume, screenshots, lock, connected frame and overview. Browser/recorder portal streaming is a separate release gate. + +The handoff reports 393 Aqueous unit tests, native/XWayland protocol fixtures, helper/plugin tests and a pinned DMS settings-host harness. This audit did not rerun them. They do not certify future DMS code or physical session support. + +## DankLinux-Docs and release handoff + +After merged implementation and released dependencies are established, audit the docs repository's instructions, guide structure, navigation/support table and portal troubleshooting. + +Add the verified Aqueous session guide covering supported packaging, exact released startup/keybinding syntax, direct/UWSM sessions and avoiding duplicate DMS processes. Map each documented feature to merged DMS support and tested versions; label development builds explicitly. Do not invent minimum versions. + +Explain Aqueous TOML ownership, helper writes, optional native/provider/plugin availability, one automatic synchronization owner, and runtime-only DPMS/layout/keyboard changes. Preserve independent aqueousPortal enablement and verify actual portal packages, services and routing separately from screenshots. + +Document native overview, standard frame exclusive zones, and diagnostics below. Explain missing/old CLI, wrong/nested session, schema mismatch, disconnect, stale IDs, lock, ambiguous seat and partial toolkit sync. Distinguish unsupported from untested. Run docs build/link checks and place packaging/registry changes in their owning repos. Native Quickshell binding changes are optional. + +Suggested future PR: `docs: add the verified Aqueous session setup guide`. + +## Embedded shell and CLI contract + +This is the supplied schema-1 reference, not a claim about the installed binaries. + +### Discovery and policy + +Bind aqueous_shell_manager_v1 on the current Wayland connection through aqueousctl. No separate socket or external window-manager connection is configured. Capabilities include schema 1, random 128-bit session token, maximum batch size and independent state, commands, keyboard, overview and shortcut_inhibition booleans. Tokens change on compositor restart. Unknown additive fields may be ignored; unsupported schema versions must fail. + +Internal policy supports mutations. External/comparison policy is observation-only. Lock rejects all mutations but trusted shell observation remains available. The shell global is hidden from Wayland security-context clients. Ordinary application shortcut inhibitors remain focus/seat scoped. + +```sh +aqueousctl shell capabilities --json +aqueousctl shell snapshot --json +aqueousctl shell watch --json +``` + +Watch emits an initial full snapshot then NDJSON deltas. The CLI allows five seconds for connection/initial state or command completion; an established quiet watch has no idle timeout. Invalid/truncated data or continuity failure terminates the CLI nonzero. Restart with bounded backoff and replace old state. A timed-out mutation may have executed already. + +### Identity, batches and references + +IDs/sequences are JSON strings, scoped to the session. Window IDs are exact ext_foreign_toplevel_handle_v1.identifier values. Outputs/workspaces/keyboard groups/devices use runtime identities; seats use names. Workspace number is a one-based label. IDs survive workspace rename/renumber/output migration while the workspace exists, but never compositor restart. There is no ext-workspace persistent ID promise. Native identify_workspace resolves a client's own ext-workspace handle, returning empty for removed handles. + +Outputs include connector name, enabled/powered, logical bounds/usable bounds, scale/transform and active workspace. Enabled includes temporarily powered-off outputs. Windows include backend, app_id/class/title, output/workspace, geometry/outer_geometry, presentation flags and eligibility. Use class for XWayland identity presentation when app_id is null. + +Visible means mapped on an active workspace and not policy-hidden, not unoccluded. can_activate is focus eligibility. can_minimize/can_maximize generally require floating presentation; tiled requests may fail, and compositor-owned maximize may not be reversible by a client. Focused means at least one seat; explicit seat.window is authoritative for a particular seat. Selected output persists during layer focus. No keyboard or ambiguous defaults use null. + +Session entity has fixed id "session", locked, default_seat, overview_output and overview_window. Keyboard group has seat, layout names and effective zero-based index; devices reference seat/group. + +Full replacement clears absent optional fields. Atomic batches permit moving a workspace to a surviving output and removing the old output together. Removal keys are kind:id. First snapshot has null base_sequence and empty removed; deltas require the exact previous sequence as base_sequence. Sequence jumps are valid. + +```json +{"schema":1,"session":"6b94a179d09456846b94a179d0945684","sequence":"12","base_sequence":null,"type":"snapshot","upsert":[{"kind":"workspace","id":"7","output":"2","name":"Code","number":1,"active":true,"urgent":false}],"removed":[]} +``` + +The example is abbreviated; a real snapshot contains all current entities and valid references. + +```json +{"schema":1,"session":"6b94a179d09456846b94a179d0945684","sequence":"15","base_sequence":"12","type":"delta","upsert":[{"kind":"workspace","id":"7","output":"2","name":"Review","number":1,"active":true,"urgent":false}],"removed":["window:closed-window-id"]} +``` + +Wire subscribe is allowed once. Server sends begin(serial), data byte fragments, done(serial); concatenate bytes before UTF-8 decoding. Complete JSON max is 4 MiB, with NDJSON newline allowed separately. Entity state is limited to 2 MiB including accounting overhead, and at most 16 managers may bind. + +A native client acknowledges exact done serial only after accepting a batch. aqueousctl handles wire acknowledgement for the CLI adapter. One batch is in flight per manager; slow consumption pauses delivery and changes coalesce against the acknowledged baseline. Invalid acknowledgement/duplicate subscribe is a protocol error. Limits fail explicitly rather than truncating. Publication is event-driven after settled policy/render/workspace transactions, with no repeating scan. + +### Geometry + +geometry is committed global logical content bounds; negative origins are valid. outer_geometry adds enabled compositor border edges, not client shadow guesses. Client decorations inside content remain included; fullscreen removes compositor borders. + +Animation geometry is committed destination, not the compositor's frozen visual clone. Composed output crops include occluders. Subtract output origin, transform/scale once according to the capture backend, round outward and intersect pixel extent. Use standard capture protocols and existing output management for modes/transforms. Do not capture a hidden window on another workspace or claim isolated capture from an output crop. + +### Typed commands and results + +```sh +aqueousctl window activate --id ID [--seat SEAT] --json +aqueousctl window close --id ID --json +aqueousctl window state --id ID --minimized true --json +aqueousctl window state --id ID --maximized false --json +aqueousctl window state --id ID --fullscreen true --json +aqueousctl window move --id ID --workspace-id ID --json +aqueousctl window move --id ID --output CONNECTOR --json +aqueousctl workspace activate --id ID [--seat SEAT] --json +aqueousctl workspace rename --id ID --name NAME --json +aqueousctl keyboard query --json +aqueousctl keyboard set [--seat SEAT] [--group ID] --index 1 --json +aqueousctl keyboard next [--seat SEAT] [--group ID] --json +aqueousctl overview show --output CONNECTOR --json +aqueousctl overview hide --json +aqueousctl overview toggle --output CONNECTOR --json +aqueousctl session exit --json +``` + +Brackets above denote optional arguments, not literal shell syntax. Omit seat only for exactly one seat; omitted group targets that seat's active keyboard group. Query returns a complete atomic snapshot. Set uses zero-based index, next wraps, and neither writes TOML. Physical/client layout switching and hotplug/reload produce deltas. + +Move-to-output selects the output's currently active workspace; it does not activate workspace or follow focus. Normal policy repairs focus when needed. Activate reveals workspace and restores eligible minimized windows. Move/state/activation cancels overview. Rename preserves identity; names are UTF-8, at most 1024 bytes, without newlines. + +Overview is compositor-owned and global. Show/hide are idempotent; showing on a new output cancels old overview first. Empty workspace or prohibiting policy returns unavailable. Lock/cancellation and global selected output/window remain authoritative. + +One command is outstanding per native manager. Requests resolve targets at a settled transaction; disappearing targets return not_found. Queue storage is bounded. Results include request ID, status and current committed sequence on the wire. CLI prints success such as `{"ok":true,"status":"applied","sequence":"15"}` or a corresponding error object. + +Applied means committed. Close/exit return accepted acknowledgement, not proof a client disappeared. Exit flushes acknowledgement before orderly compositor termination; EOF without acknowledgement is an error. Errors include invalid, not_found, locked, unsupported, busy, ambiguous_seat, unavailable. stdout is JSON; stderr is diagnostics. Exit codes: 0 success, 2 malformed arguments, 1 protocol/operation failure. No arbitrary execution/raw policy dispatcher exists. + +### Shortcut inhibition and reference tests + +An inhibitor is active only for its focused surface/seat while unlocked and outside overview. Focus loss, destruction and lock deactivate it. Normal/internal and external XKB bindings are bypassed, but VT switching remains reserved and secure lock works. Releases retain the consumer selected on press even if inhibition disappears. Existing XWayland grab policy remains. DMS already consumes inhibition; change only for a reproduced gap. + +Aqueous reference checks: `zig build test` and `python3 scripts/test-shell-integration.py` using a Pixman-compatible diagnostic build or documented binary overrides. The isolated harness covers two-output state/actions, keyboard, inhibition, flow control, lock and frame cleanup without connecting to the user's display. Hardware capture/DPMS/resume remain separate. + +## Embedded configuration and frame contract + +aqueous-config owns persistence and toolkit synchronization; aqueousctl owns runtime state/actions. Helper 0.7.1 adds capabilities to version/snapshot while retaining protocol 1 and compatibility for existing 0.7.0 frontends. An old helper without capabilities needs its documented version-specific contract; absence is not permission to ignore fields. + +```sh +aqueous-config version +aqueous-config snapshot --shell dms +aqueous-config validate --shell dms --request - +aqueous-config apply --shell dms --request - +``` + +Requests use JSON stdin, bounded to 4 MiB, with expected_generation. Capture complete requests from tested `plugin/helper/` and `dms-plugin/README.md`; their detailed JSON shape is not contained in this handoff and must not be invented. + +| Capability | Meaning | +| --- | --- | +| schema_fields | Snapshot fields, categories, defaults, aliases, typed constraints | +| validate | Complete-request validation without saving | +| generation_check | expected_generation rejects external-edit overwrite | +| stdin_requests | Bounded JSON via --request - | +| atomic_file_replace | Atomic replacement/backups per TOML file; multi-file save is not one filesystem transaction | +| monitor_modes | Configured mode, position, scale, transform | +| live_outputs | Live modes alongside configured/offline monitors | +| keybinds | Built-ins, unbound actions, custom bindings | +| window_rules | Ordered rules and raw configuration | +| cursor_sync | Canonical cursor, live update, toolkit reports | +| typography_sync | Canonical typography, fonts/faces, toolkit reports | +| shell_dms | Avoid Noctalia writes/reloads | + +Capabilities do not guarantee available toolkit adapters, fonts, monitor modes or runtime support. Inspect adapter reports and live state independently. + +Editing flow: fresh snapshot/generation, stage typed/raw edits and resolve their conflicts, validate complete request, apply with retained generation, inspect response, observe runtime separately. Preserve stale drafts for reload/reconciliation. On uncertain timeout inspect saved state before retry. Canonical save followed by toolkit failure is partial success; explicit retry flags are sync_cursor and sync_typography. + +There is no helper display-preview API. Use wlr runtime test/apply with previous state and Keep/Revert; save only after Keep. Revert only while state still matches the preview. Preserve offline monitors, EDID, custom modes and fractional refresh. Runtime DPMS/battery refresh is separate from preferences. + +TOML remains canonical across plugin/native providers. Opening UI does not authorize rewrites. One explicitly enabled owner synchronizes colors/fonts/cursor automatically; either frontend may manually Apply through the generation contract. Typography adaptation maps DMS family/weight/scale but partially represents exact face/slant/width and independently scaled bars. Existing cursor-surface clients retain their own policy. The portal plugin has independent purpose/enablement. + +Frame exclusion surfaces reserve each edge via positive layer-shell exclusive zones and namespace dms:frame-exclusion. Existing Aqueous tests map all four, verify sum-of-opposite-edge shrink, kill the client and verify restoration. Resource lifetime already releases reservations; no margin lease or duplicate persistent gaps are needed. Mixed-scale physical rendering and connected-mode UI remain unverified gates. + +Layer rules in rules.toml are ordered, first match wins. Invisible exclusions should not receive blur; put this before broader intentional rules: + +```toml +[[layer]] +namespace = "dms:frame-exclusion" +blur = false +blur_popups = false +``` + +Inspect aqueousctl scene for exact visible DMS namespaces; do not blanket-match all Quickshell apps. For ordinary DMS windows inspect aqueousctl windows --json or inspect --rule and match actual identity. No appearance rules should be installed/replaced merely by adding the shell adapter. + +Diagnostic watch remains running until Ctrl+C. Titles and configuration paths can appear in snapshots; request only relevant redacted output in bug reports. Portal streaming is separate from screenshots and settings-provider enablement. + +## Complete JSON batch schema + +The schema below preserves the supplied schema-1 definition. It may be extracted as `aqueous-shell-v1.schema.json`. Additional semantic/continuity checks described above are still required. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Aqueous shell JSON batch schema 1", + "type": "object", + "required": [ + "schema", + "session", + "sequence", + "base_sequence", + "type", + "upsert", + "removed" + ], + "properties": { + "schema": { + "const": 1 + }, + "session": { + "type": "string", + "pattern": "^[0-9a-f]{32}$" + }, + "sequence": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "base_sequence": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9]+$" + }, + "type": { + "enum": [ + "snapshot", + "delta" + ] + }, + "upsert": { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/$defs/output" + }, + { + "$ref": "#/$defs/workspace" + }, + { + "$ref": "#/$defs/window" + }, + { + "$ref": "#/$defs/seat" + }, + { + "$ref": "#/$defs/keyboard" + }, + { + "$ref": "#/$defs/keyboard_device" + }, + { + "$ref": "#/$defs/session" + } + ] + } + }, + "removed": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(output|workspace|window|seat|keyboard|keyboard_device|session):.+" + }, + "uniqueItems": true + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "snapshot" + } + } + }, + "then": { + "properties": { + "base_sequence": { + "type": "null" + }, + "removed": { + "maxItems": 0 + } + } + }, + "else": { + "properties": { + "base_sequence": { + "type": "string" + } + } + } + } + ], + "$defs": { + "output": { + "type": "object", + "required": [ + "kind", + "id", + "name", + "bounds", + "usable_bounds", + "scale", + "transform", + "active_workspace", + "enabled", + "powered" + ], + "properties": { + "kind": { + "const": "output" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "bounds": { + "type": "object", + "required": [ + "x", + "y", + "width", + "height" + ], + "properties": { + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + }, + "usable_bounds": { + "type": "object", + "required": [ + "x", + "y", + "width", + "height" + ], + "properties": { + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + }, + "scale": { + "type": "number" + }, + "transform": { + "type": "string" + }, + "active_workspace": { + "type": [ + "string", + "null" + ] + }, + "enabled": { + "type": "boolean" + }, + "powered": { + "type": "boolean" + } + } + }, + "workspace": { + "type": "object", + "required": [ + "kind", + "id", + "output", + "name", + "number", + "active", + "urgent" + ], + "properties": { + "kind": { + "const": "workspace" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "output": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1 + }, + "active": { + "type": "boolean" + }, + "urgent": { + "type": "boolean" + } + } + }, + "window": { + "type": "object", + "required": [ + "kind", + "id", + "backend", + "app_id", + "class", + "title", + "workspace", + "output", + "geometry", + "outer_geometry", + "layout", + "focused", + "visible", + "floating", + "minimized", + "maximized", + "fullscreen", + "skip_taskbar", + "skip_switcher", + "always_above", + "always_below", + "snapped", + "fixed_position", + "can_minimize", + "can_maximize", + "can_activate" + ], + "properties": { + "kind": { + "const": "window" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "backend": { + "enum": [ + "xdg", + "xwayland" + ] + }, + "app_id": { + "type": [ + "string", + "null" + ] + }, + "class": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "workspace": { + "type": [ + "string", + "null" + ] + }, + "output": { + "type": [ + "string", + "null" + ] + }, + "geometry": { + "type": "object", + "required": [ + "x", + "y", + "width", + "height" + ], + "properties": { + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + }, + "outer_geometry": { + "type": "object", + "required": [ + "x", + "y", + "width", + "height" + ], + "properties": { + "x": { + "type": "integer" + }, + "y": { + "type": "integer" + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + } + } + }, + "layout": { + "type": "string" + }, + "focused": { + "type": "boolean" + }, + "visible": { + "type": "boolean" + }, + "floating": { + "type": "boolean" + }, + "minimized": { + "type": "boolean" + }, + "maximized": { + "type": "boolean" + }, + "fullscreen": { + "type": "boolean" + }, + "skip_taskbar": { + "type": "boolean" + }, + "skip_switcher": { + "type": "boolean" + }, + "always_above": { + "type": "boolean" + }, + "always_below": { + "type": "boolean" + }, + "snapped": { + "type": "boolean" + }, + "fixed_position": { + "type": "boolean" + }, + "can_minimize": { + "type": "boolean" + }, + "can_maximize": { + "type": "boolean" + }, + "can_activate": { + "type": "boolean" + } + } + }, + "seat": { + "type": "object", + "required": [ + "kind", + "id", + "output", + "window", + "focus_kind", + "keyboard" + ], + "properties": { + "kind": { + "const": "seat" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "output": { + "type": [ + "string", + "null" + ] + }, + "window": { + "type": [ + "string", + "null" + ] + }, + "focus_kind": { + "enum": [ + "window", + "shell_surface", + "layer_surface", + "override_redirect", + "lock_surface", + "none" + ] + }, + "keyboard": { + "type": [ + "string", + "null" + ] + } + } + }, + "keyboard": { + "type": "object", + "required": [ + "kind", + "id", + "seat", + "layouts", + "index" + ], + "properties": { + "kind": { + "const": "keyboard" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "seat": { + "type": "string", + "minLength": 1 + }, + "layouts": { + "type": "array", + "items": { + "type": "string" + } + }, + "index": { + "type": "integer", + "minimum": 0 + } + } + }, + "keyboard_device": { + "type": "object", + "required": [ + "kind", + "id", + "name", + "seat", + "group", + "virtual" + ], + "properties": { + "kind": { + "const": "keyboard_device" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "seat": { + "type": "string", + "minLength": 1 + }, + "group": { + "type": [ + "string", + "null" + ] + }, + "virtual": { + "type": "boolean" + } + } + }, + "session": { + "type": "object", + "required": [ + "kind", + "id", + "locked", + "default_seat", + "overview_output", + "overview_window" + ], + "properties": { + "kind": { + "const": "session" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "locked": { + "type": "boolean" + }, + "default_seat": { + "type": [ + "string", + "null" + ] + }, + "overview_output": { + "type": [ + "string", + "null" + ] + }, + "overview_window": { + "type": [ + "string", + "null" + ] + } + } + } + } +} +``` + +## Complete Wayland protocol XML + +Extract as `aqueous-shell-v1.xml` only if native bindings are needed; preserve copyright and license. The referenced aqueous-shell-v1.md contract is embedded above. ext_workspace_handle_v1 comes from the standard ext-workspace-v1 protocol in the target build's dependencies. + +```xml + + + SPDX-FileCopyrightText: © 2026 Seafoam Labs +SPDX-License-Identifier: MIT + + Privileged same-session shell interface. JSON schema 1 is documented in + aqueous-shell-v1.md. IDs are opaque strings scoped to the session token. + State is committed policy state, not animation frame geometry. Clients + acknowledge each bounded batch before another is sent. Intermediate states + may be coalesced; base_sequence identifies the state to which a delta applies. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + May be sent once. The server sends at most one unacknowledged batch. + + + + + + + + One outstanding command per object. Empty strings select documented + defaults. Targets are opaque window/workspace/group IDs; output values + are connector names. State values are true/false. Strings are bounded + to 1024 bytes. Failures are recoverable result events. Window and session + mutations are unavailable while locked or under external or comparison policy. + + + + + + + + + + + + + + + + + + + + Concatenate fragments until done. At most 4 MiB per complete batch. + A fragment may split a UTF-8 codepoint; parse only the complete batch. + + + + + + + + + + + + + + + + + +``` diff --git a/docs/aqueous.md b/docs/aqueous.md new file mode 100644 index 000000000..64b557c31 --- /dev/null +++ b/docs/aqueous.md @@ -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. diff --git a/quickshell/Common/AqueousDisplays.js b/quickshell/Common/AqueousDisplays.js new file mode 100644 index 000000000..0590dfa18 --- /dev/null +++ b/quickshell/Common/AqueousDisplays.js @@ -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}; +} diff --git a/quickshell/Modules/DankBar/DankBarBody.qml b/quickshell/Modules/DankBar/DankBarBody.qml index e1a9404fb..077c30e20 100644 --- a/quickshell/Modules/DankBar/DankBarBody.qml +++ b/quickshell/Modules/DankBar/DankBarBody.qml @@ -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) diff --git a/quickshell/Modules/DankBar/DankBarContent.qml b/quickshell/Modules/DankBar/DankBarContent.qml index 775a938f6..b7a2cb9ea 100644 --- a/quickshell/Modules/DankBar/DankBarContent.qml +++ b/quickshell/Modules/DankBar/DankBarContent.qml @@ -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; } diff --git a/quickshell/Modules/DankBar/Widgets/AppsDockContextMenu.qml b/quickshell/Modules/DankBar/Widgets/AppsDockContextMenu.qml index e0cc5ef99..e2f503311 100644 --- a/quickshell/Modules/DankBar/Widgets/AppsDockContextMenu.qml +++ b/quickshell/Modules/DankBar/Widgets/AppsDockContextMenu.qml @@ -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) { diff --git a/quickshell/Modules/DankBar/Widgets/FocusedApp.qml b/quickshell/Modules/DankBar/Widgets/FocusedApp.qml index 1df05ed7d..3f8c5993b 100644 --- a/quickshell/Modules/DankBar/Widgets/FocusedApp.qml +++ b/quickshell/Modules/DankBar/Widgets/FocusedApp.qml @@ -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) { diff --git a/quickshell/Modules/DankBar/Widgets/KeyboardLayoutName.qml b/quickshell/Modules/DankBar/Widgets/KeyboardLayoutName.qml index bc33d3bd7..cf9f15c45 100644 --- a/quickshell/Modules/DankBar/Widgets/KeyboardLayoutName.qml +++ b/quickshell/Modules/DankBar/Widgets/KeyboardLayoutName.qml @@ -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) { diff --git a/quickshell/Modules/DankBar/Widgets/RunningApps.qml b/quickshell/Modules/DankBar/Widgets/RunningApps.qml index 22f044831..e3baac049 100644 --- a/quickshell/Modules/DankBar/Widgets/RunningApps.qml +++ b/quickshell/Modules/DankBar/Widgets/RunningApps.qml @@ -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 { diff --git a/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml b/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml index f72ba985c..a422d9220 100644 --- a/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml +++ b/quickshell/Modules/DankBar/Widgets/WorkspaceSwitcher.qml @@ -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; diff --git a/quickshell/Modules/Dock/DockAppButton.qml b/quickshell/Modules/Dock/DockAppButton.qml index 702af3934..e0fa9e045 100644 --- a/quickshell/Modules/Dock/DockAppButton.qml +++ b/quickshell/Modules/Dock/DockAppButton.qml @@ -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) { diff --git a/quickshell/Modules/Dock/DockApps.qml b/quickshell/Modules/Dock/DockApps.qml index 10e4426da..ac8f8426a 100644 --- a/quickshell/Modules/Dock/DockApps.qml +++ b/quickshell/Modules/Dock/DockApps.qml @@ -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++) { diff --git a/quickshell/Modules/Dock/DockBody.qml b/quickshell/Modules/Dock/DockBody.qml index f410a553b..155856dfd 100644 --- a/quickshell/Modules/Dock/DockBody.qml +++ b/quickshell/Modules/Dock/DockBody.qml @@ -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: { diff --git a/quickshell/Modules/Frame/FrameWindow.qml b/quickshell/Modules/Frame/FrameWindow.qml index d852514f3..144fe11cd 100644 --- a/quickshell/Modules/Frame/FrameWindow.qml +++ b/quickshell/Modules/Frame/FrameWindow.qml @@ -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"); diff --git a/quickshell/Modules/Settings/DankBarTab.qml b/quickshell/Modules/Settings/DankBarTab.qml index 7535b4dfd..86de7bef8 100644 --- a/quickshell/Modules/Settings/DankBarTab.qml +++ b/quickshell/Modules/Settings/DankBarTab.qml @@ -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) { diff --git a/quickshell/Modules/Settings/DesktopWidgetBrowser.qml b/quickshell/Modules/Settings/DesktopWidgetBrowser.qml index 786a0fc50..8f7bbba54 100644 --- a/quickshell/Modules/Settings/DesktopWidgetBrowser.qml +++ b/quickshell/Modules/Settings/DesktopWidgetBrowser.qml @@ -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) diff --git a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml index 5fbdc1d7f..17a8c7496 100644 --- a/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml +++ b/quickshell/Modules/Settings/DisplayConfig/DisplayConfigState.qml @@ -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; diff --git a/quickshell/Modules/Settings/DisplayConfigTab.qml b/quickshell/Modules/Settings/DisplayConfigTab.qml index 351adfa01..34468d931 100644 --- a/quickshell/Modules/Settings/DisplayConfigTab.qml +++ b/quickshell/Modules/Settings/DisplayConfigTab.qml @@ -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 diff --git a/quickshell/Modules/Settings/DockTab.qml b/quickshell/Modules/Settings/DockTab.qml index bbd097298..40602f0cc 100644 --- a/quickshell/Modules/Settings/DockTab.qml +++ b/quickshell/Modules/Settings/DockTab.qml @@ -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) } diff --git a/quickshell/Modules/Settings/FrameTab.qml b/quickshell/Modules/Settings/FrameTab.qml index 8b407c36c..523bab69a 100644 --- a/quickshell/Modules/Settings/FrameTab.qml +++ b/quickshell/Modules/Settings/FrameTab.qml @@ -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) } diff --git a/quickshell/Modules/Settings/KeybindItem.qml b/quickshell/Modules/Settings/KeybindItem.qml index dd6ea69d9..860e6d08f 100644 --- a/quickshell/Modules/Settings/KeybindItem.qml +++ b/quickshell/Modules/Settings/KeybindItem.qml @@ -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() { diff --git a/quickshell/Modules/Settings/KeybindsTab.qml b/quickshell/Modules/Settings/KeybindsTab.qml index 4ac1a482d..78b95b9b8 100644 --- a/quickshell/Modules/Settings/KeybindsTab.qml +++ b/quickshell/Modules/Settings/KeybindsTab.qml @@ -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 diff --git a/quickshell/Modules/Settings/PluginBrowser.qml b/quickshell/Modules/Settings/PluginBrowser.qml index 5f46321df..2ff18025e 100644 --- a/quickshell/Modules/Settings/PluginBrowser.qml +++ b/quickshell/Modules/Settings/PluginBrowser.qml @@ -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; diff --git a/quickshell/Modules/Settings/ThemeBrowser.qml b/quickshell/Modules/Settings/ThemeBrowser.qml index 409aa458d..e9da30078 100644 --- a/quickshell/Modules/Settings/ThemeBrowser.qml +++ b/quickshell/Modules/Settings/ThemeBrowser.qml @@ -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: "" diff --git a/quickshell/Modules/Settings/ThemeColorsTab.qml b/quickshell/Modules/Settings/ThemeColorsTab.qml index aeb53a009..c2cb56cc4 100644 --- a/quickshell/Modules/Settings/ThemeColorsTab.qml +++ b/quickshell/Modules/Settings/ThemeColorsTab.qml @@ -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"] diff --git a/quickshell/Modules/Settings/TypographyMotionTab.qml b/quickshell/Modules/Settings/TypographyMotionTab.qml index 164e7578d..f28014efe 100644 --- a/quickshell/Modules/Settings/TypographyMotionTab.qml +++ b/quickshell/Modules/Settings/TypographyMotionTab.qml @@ -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"] diff --git a/quickshell/Modules/Settings/WidgetSelectionPopup.qml b/quickshell/Modules/Settings/WidgetSelectionPopup.qml index 1e9f1b751..65cc93c52 100644 --- a/quickshell/Modules/Settings/WidgetSelectionPopup.qml +++ b/quickshell/Modules/Settings/WidgetSelectionPopup.qml @@ -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 diff --git a/quickshell/Modules/Settings/Widgets/AppBrowserPopup.qml b/quickshell/Modules/Settings/Widgets/AppBrowserPopup.qml index 92e6d4d49..7234ddc48 100644 --- a/quickshell/Modules/Settings/Widgets/AppBrowserPopup.qml +++ b/quickshell/Modules/Settings/Widgets/AppBrowserPopup.qml @@ -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) diff --git a/quickshell/Modules/Settings/Widgets/AqueousAppearanceSettings.qml b/quickshell/Modules/Settings/Widgets/AqueousAppearanceSettings.qml new file mode 100644 index 000000000..09887de23 --- /dev/null +++ b/quickshell/Modules/Settings/Widgets/AqueousAppearanceSettings.qml @@ -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() + } + } + } +} diff --git a/quickshell/Services/AqueousConfigService.qml b/quickshell/Services/AqueousConfigService.qml new file mode 100644 index 000000000..9c383a7b8 --- /dev/null +++ b/quickshell/Services/AqueousConfigService.qml @@ -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); + } +} diff --git a/quickshell/Services/AqueousService.qml b/quickshell/Services/AqueousService.qml new file mode 100644 index 000000000..8a161e17f --- /dev/null +++ b/quickshell/Services/AqueousService.qml @@ -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"); + }); + } + } + } +} diff --git a/quickshell/Services/BarWidgetService.qml b/quickshell/Services/BarWidgetService.qml index e0af9cbab..55cee3ada 100644 --- a/quickshell/Services/BarWidgetService.qml +++ b/quickshell/Services/BarWidgetService.qml @@ -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) diff --git a/quickshell/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml index ceb9a50ed..5cfc90b6d 100644 --- a/quickshell/Services/CompositorService.qml +++ b/quickshell/Services/CompositorService.qml @@ -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"); } diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index 32b37f911..ab98bc61e 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -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; diff --git a/quickshell/Services/KeybindsService.qml b/quickshell/Services/KeybindsService.qml index f2bccebc0..3bd5bef62 100644 --- a/quickshell/Services/KeybindsService.qml +++ b/quickshell/Services/KeybindsService.qml @@ -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); } diff --git a/quickshell/Services/SessionService.qml b/quickshell/Services/SessionService.qml index a7eaabc0a..bd2f641b2 100644 --- a/quickshell/Services/SessionService.qml +++ b/quickshell/Services/SessionService.qml @@ -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; diff --git a/quickshell/Services/SettingsSearchService.qml b/quickshell/Services/SettingsSearchService.qml index 0a0c53e6e..8fce5028f 100644 --- a/quickshell/Services/SettingsSearchService.qml +++ b/quickshell/Services/SettingsSearchService.qml @@ -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, diff --git a/quickshell/Services/WlrOutputService.qml b/quickshell/Services/WlrOutputService.qml index 0d1b11c5a..7a3283648 100644 --- a/quickshell/Services/WlrOutputService.qml +++ b/quickshell/Services/WlrOutputService.qml @@ -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) { diff --git a/quickshell/tests/aqueous-displays.test.mjs b/quickshell/tests/aqueous-displays.test.mjs new file mode 100644 index 000000000..00b0f8dca --- /dev/null +++ b/quickshell/tests/aqueous-displays.test.mjs @@ -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, "1920x1080@59.94"); +assert.equal(request.monitor_changes[0].x, -1920); +assert.throws(() => adapter.request(snapshot, [{...output, scale: 1.25}], original), /scale/); +assert.throws(() => adapter.request(snapshot, [{...output, enabled: false}], original), /enablement/); +assert.throws(() => adapter.request({...snapshot, raw_files: {outputs: '[[output]]\nedid = "sha256:test"'}}, original, original), /EDID/); +assert.throws(() => adapter.request({...snapshot, capabilities: []}, original, original), /unsupported/); +process.stdout.write("PASS: Aqueous display identity, conflicts, offline preservation and helper limits\n"); diff --git a/quickshell/tests/aqueous-service.test.mjs b/quickshell/tests/aqueous-service.test.mjs new file mode 100644 index 000000000..96b49d285 --- /dev/null +++ b/quickshell/tests/aqueous-service.test.mjs @@ -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"); diff --git a/quickshell/tests/display-apply.test.mjs b/quickshell/tests/display-apply.test.mjs new file mode 100644 index 000000000..0e36b65bf --- /dev/null +++ b/quickshell/tests/display-apply.test.mjs @@ -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"); diff --git a/quickshell/tests/fixtures/aqueous/README.md b/quickshell/tests/fixtures/aqueous/README.md new file mode 100644 index 000000000..f7fa6e2f5 --- /dev/null +++ b/quickshell/tests/fixtures/aqueous/README.md @@ -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. diff --git a/quickshell/tests/fixtures/aqueous/capabilities.json b/quickshell/tests/fixtures/aqueous/capabilities.json new file mode 100644 index 000000000..d52d89149 --- /dev/null +++ b/quickshell/tests/fixtures/aqueous/capabilities.json @@ -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" +} diff --git a/quickshell/tests/fixtures/aqueous/watch.ndjson b/quickshell/tests/fixtures/aqueous/watch.ndjson new file mode 100644 index 000000000..02379d057 --- /dev/null +++ b/quickshell/tests/fixtures/aqueous/watch.ndjson @@ -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"]} diff --git a/quickshell/tests/workspace-view.test.mjs b/quickshell/tests/workspace-view.test.mjs new file mode 100644 index 000000000..d4e890a6b --- /dev/null +++ b/quickshell/tests/workspace-view.test.mjs @@ -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"); diff --git a/quickshell/translations/extract_settings_index.py b/quickshell/translations/extract_settings_index.py index 330605dcb..bfe4e07fd 100755 --- a/quickshell/translations/extract_settings_index.py +++ b/quickshell/translations/extract_settings_index.py @@ -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" diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index ce7ae7444..c4ac7cfd8 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -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" }, { diff --git a/scripts/test-aqueous-integration.py b/scripts/test-aqueous-integration.py new file mode 100644 index 000000000..43f60f034 --- /dev/null +++ b/scripts/test-aqueous-integration.py @@ -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() diff --git a/scripts/test-aqueous-service.py b/scripts/test-aqueous-service.py new file mode 100644 index 000000000..7755d7bfa --- /dev/null +++ b/scripts/test-aqueous-service.py @@ -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()