mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-18 17:24:01 -04:00
updated dank to support aqueous
This commit is contained in:
+1
-1
@@ -50,7 +50,7 @@ sync-shell:
|
||||
@rm -rf $(EMBED_DIR)
|
||||
@mkdir -p $(EMBED_DIR)
|
||||
@tar -C $(SHELL_SRC) --exclude=.qmlls.ini -chf - . | tar -C $(EMBED_DIR) -xf -
|
||||
@rm -rf $(EMBED_DIR)/.git* $(EMBED_DIR)/.github
|
||||
@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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/keybinds"
|
||||
)
|
||||
|
||||
type AqueousProvider struct{}
|
||||
|
||||
func NewAqueousProvider() *AqueousProvider { return &AqueousProvider{} }
|
||||
func (*AqueousProvider) Name() string { return "aqueous" }
|
||||
|
||||
func aqueousObjects(snapshot aqueousConfig, key string) []aqueousConfig {
|
||||
values, _ := snapshot[key].([]any)
|
||||
result := make([]aqueousConfig, 0, len(values))
|
||||
for _, value := range values {
|
||||
if object, ok := value.(map[string]any); ok {
|
||||
result = append(result, aqueousConfig(object))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func AqueousCheatSheet(snapshot aqueousConfig) (*keybinds.CheatSheet, error) {
|
||||
if err := requireAqueousCapabilities(snapshot, "keybinds"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sheet := &keybinds.CheatSheet{Title: "Aqueous", Provider: "aqueous", Generation: snapshot.String("generation"), ModKey: "Super", DMSBindsIncluded: true, Binds: map[string][]keybinds.Keybind{"Compositor": {}, "Custom": {}}}
|
||||
for _, field := range aqueousObjects(snapshot, "fields") {
|
||||
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
|
||||
continue
|
||||
}
|
||||
values, _ := field["value"].([]any)
|
||||
if len(values) == 0 {
|
||||
values = []any{""}
|
||||
}
|
||||
for _, value := range values {
|
||||
key, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid keybinding list")
|
||||
}
|
||||
sheet.Binds["Compositor"] = append(sheet.Binds["Compositor"], keybinds.Keybind{Key: key, Action: field.String("id"), Description: field.String("label"), Source: "dms"})
|
||||
}
|
||||
}
|
||||
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
|
||||
sheet.Binds["Custom"] = append(sheet.Binds["Custom"], keybinds.Keybind{Key: binding.String("chord"), Action: "spawn " + binding.String("command"), Description: binding.String("command"), Source: "dms"})
|
||||
}
|
||||
seen := map[string]keybinds.Keybind{}
|
||||
for _, category := range []string{"Compositor", "Custom"} {
|
||||
for i, bind := range sheet.Binds[category] {
|
||||
if bind.Key == "" {
|
||||
continue
|
||||
}
|
||||
if previous, exists := seen[bind.Key]; exists {
|
||||
sheet.Binds[category][i].Conflict = &previous
|
||||
}
|
||||
seen[bind.Key] = bind
|
||||
}
|
||||
}
|
||||
return sheet, nil
|
||||
}
|
||||
|
||||
func (*AqueousProvider) GetCheatSheet() (*keybinds.CheatSheet, error) {
|
||||
snapshot, err := aqueousHelper(context.Background(), "snapshot", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return AqueousCheatSheet(snapshot)
|
||||
}
|
||||
|
||||
type AqueousBindEdit struct {
|
||||
Generation string
|
||||
OriginalKey string
|
||||
Key string
|
||||
Action string
|
||||
Remove bool
|
||||
}
|
||||
|
||||
func AqueousBindRequest(snapshot aqueousConfig, edit AqueousBindEdit) (aqueousConfig, error) {
|
||||
if edit.Generation == "" || edit.Generation != snapshot.String("generation") {
|
||||
return nil, errors.New("external_change: reload and reconcile the retained keybind draft")
|
||||
}
|
||||
if err := requireAqueousCapabilities(snapshot, "keybinds"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if edit.Key == "" {
|
||||
return nil, errors.New("missing key")
|
||||
}
|
||||
original := edit.OriginalKey
|
||||
if original == "" {
|
||||
original = edit.Key
|
||||
}
|
||||
changes := []any{}
|
||||
customChanges := []any{}
|
||||
foundAction := false
|
||||
for _, field := range aqueousObjects(snapshot, "fields") {
|
||||
if field.String("category") != "keybinds" || field.String("type") != "string_list" {
|
||||
continue
|
||||
}
|
||||
old, _ := field["value"].([]any)
|
||||
values := make([]any, 0, len(old)+1)
|
||||
for _, value := range old {
|
||||
if value == original {
|
||||
continue
|
||||
}
|
||||
if !edit.Remove && value == edit.Key && field.String("id") != edit.Action {
|
||||
return nil, fmt.Errorf("key conflict: %s", edit.Key)
|
||||
}
|
||||
values = append(values, value)
|
||||
}
|
||||
if field.String("id") == edit.Action && !edit.Remove {
|
||||
foundAction = true
|
||||
if !slices.Contains(values, any(edit.Key)) {
|
||||
values = append(values, edit.Key)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(old, values) {
|
||||
changes = append(changes, map[string]any{"id": field.String("id"), "value": values})
|
||||
}
|
||||
}
|
||||
for _, binding := range aqueousObjects(snapshot, "custom_keybinds") {
|
||||
if binding.String("chord") == original {
|
||||
customChanges = append(customChanges, map[string]any{"op": "delete", "id": binding.String("id")})
|
||||
continue
|
||||
}
|
||||
if !edit.Remove && binding.String("chord") == edit.Key {
|
||||
return nil, fmt.Errorf("key conflict: %s", edit.Key)
|
||||
}
|
||||
}
|
||||
if !edit.Remove && !foundAction {
|
||||
command, ok := strings.CutPrefix(edit.Action, "spawn ")
|
||||
if !ok || strings.TrimSpace(command) == "" {
|
||||
return nil, errors.New("unknown Aqueous action; custom commands use spawn")
|
||||
}
|
||||
customChanges = append(customChanges, map[string]any{"op": "add", "chord": edit.Key, "command": command})
|
||||
}
|
||||
return aqueousConfig{"expected_generation": edit.Generation, "changes": changes, "custom_keybind_changes": customChanges, "create_user_override": true}, nil
|
||||
}
|
||||
|
||||
func (*AqueousProvider) Edit(ctx context.Context, edit AqueousBindEdit) (aqueousConfig, error) {
|
||||
snapshot, err := aqueousHelper(ctx, "snapshot", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request, err := AqueousBindRequest(snapshot, edit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aqueousHelper(ctx, "apply", request)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
func helperResult(ctx context.Context, verb string, input []byte) (aqueousConfig, error) {
|
||||
var result aqueousConfig
|
||||
args := []string{verb, "--shell", "dms"}
|
||||
if input != nil {
|
||||
args = append(args, "--request", "-")
|
||||
}
|
||||
err := utils.RunJSON(ctx, "aqueous-config", args, input, &result)
|
||||
if err != nil || !result.Bool("ok") {
|
||||
if result.String("code") != "" {
|
||||
return result, fmt.Errorf("%s: %s", result.String("code"), result.String("message"))
|
||||
}
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, errors.New("aqueous-config rejected the request")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func requireAqueousCapabilities(result aqueousConfig, required ...string) error {
|
||||
if result.Number("protocol") != 1 {
|
||||
return errors.New("unsupported aqueous-config protocol")
|
||||
}
|
||||
values, ok := result["capabilities"].([]any)
|
||||
if !ok {
|
||||
return errors.New("unsupported aqueous-config: capability discovery required")
|
||||
}
|
||||
for _, capability := range required {
|
||||
if !slices.Contains(values, any(capability)) {
|
||||
return fmt.Errorf("unsupported aqueous-config capability: %s", capability)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func aqueousHelper(ctx context.Context, verb string, request aqueousConfig) (aqueousConfig, error) {
|
||||
if verb == "snapshot" {
|
||||
result, err := helperResult(ctx, verb, nil)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if err := requireAqueousCapabilities(result, "schema_fields", "shell_dms"); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if result.String("generation") == "" {
|
||||
return result, errors.New("missing configuration generation")
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
if verb != "validate" && verb != "apply" {
|
||||
return nil, errors.New("unsupported helper operation")
|
||||
}
|
||||
if request.String("expected_generation") == "" {
|
||||
return nil, errors.New("missing expected_generation; reload configuration")
|
||||
}
|
||||
version, err := helperResult(ctx, "version", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
required := []string{"keybinds", "validate", "stdin_requests", "generation_check", "atomic_file_replace", "shell_dms"}
|
||||
if err := requireAqueousCapabilities(version, required...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request["protocol"] = 1
|
||||
if _, exists := request["backup_dir"]; !exists {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request["backup_dir"] = filepath.Join(configDir, "DankMaterialShell", "aqueous-backups")
|
||||
}
|
||||
input, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validated, err := helperResult(ctx, "validate", input)
|
||||
if err != nil || verb == "validate" {
|
||||
return validated, err
|
||||
}
|
||||
return helperResult(ctx, "apply", input)
|
||||
}
|
||||
|
||||
type aqueousConfig map[string]any
|
||||
|
||||
func (e aqueousConfig) String(key string) string { value, _ := e[key].(string); return value }
|
||||
func (e aqueousConfig) Bool(key string) bool { value, _ := e[key].(bool); return value }
|
||||
func (e aqueousConfig) Number(key string) float64 {
|
||||
value, _ := e[key].(json.Number)
|
||||
number, _ := value.Float64()
|
||||
return number
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func aqueousFixture(t *testing.T) aqueousConfig {
|
||||
t.Helper()
|
||||
var snapshot aqueousConfig
|
||||
err := utils.DecodeJSON([]byte(`{"ok":true,"protocol":1,"generation":"abc","capabilities":["keybinds"],"fields":[{"id":"spawn_terminal","category":"keybinds","type":"string_list","label":"Terminal","value":["Super+Return","Super+T"]},{"id":"close","category":"keybinds","type":"string_list","label":"Close","value":[]}],"custom_keybinds":[{"id":"custom:12","chord":"Super+R","command":"printf 'hello; $world'"}]}`), &snapshot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func TestAqueousCheatsheetIncludesUnboundAndCustom(t *testing.T) {
|
||||
sheet, err := AqueousCheatSheet(aqueousFixture(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sheet.Generation != "abc" || len(sheet.Binds["Compositor"]) != 3 {
|
||||
t.Fatal("lost generation or multiple bindings")
|
||||
}
|
||||
if sheet.Binds["Compositor"][2].Key != "" || sheet.Binds["Compositor"][2].Action != "close" {
|
||||
t.Fatal("unbound action lost")
|
||||
}
|
||||
if sheet.Binds["Custom"][0].Action != "spawn printf 'hello; $world'" {
|
||||
t.Fatal("custom command modified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAqueousBindingReplacementIsOneRequest(t *testing.T) {
|
||||
snapshot := aqueousFixture(t)
|
||||
request, err := AqueousBindRequest(snapshot, AqueousBindEdit{Generation: "abc", OriginalKey: "Super+Return", Key: "Super+Enter", Action: "spawn_terminal"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := json.Marshal(request["changes"])
|
||||
if string(data) != `[{"id":"spawn_terminal","value":["Super+T","Super+Enter"]}]` {
|
||||
t.Fatalf("wrong atomic replacement: %s", data)
|
||||
}
|
||||
if request["expected_generation"] != "abc" {
|
||||
t.Fatal("generation replaced")
|
||||
}
|
||||
if !reflect.DeepEqual(snapshot, aqueousFixture(t)) {
|
||||
t.Fatal("draft mutated snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAqueousBindingConflictAndRemove(t *testing.T) {
|
||||
for _, edit := range []AqueousBindEdit{
|
||||
{Generation: "stale", Key: "Super+T", Remove: true},
|
||||
{Generation: "abc", OriginalKey: "Super+Return", Key: "Super+R", Action: "close"},
|
||||
{Generation: "abc", Key: "Super+Q", Action: "invented_action"},
|
||||
} {
|
||||
if _, err := AqueousBindRequest(aqueousFixture(t), edit); err == nil {
|
||||
t.Fatal("invalid edit accepted")
|
||||
}
|
||||
}
|
||||
request, err := AqueousBindRequest(aqueousFixture(t), AqueousBindEdit{Generation: "abc", Key: "Super+R", Remove: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := json.Marshal(request["custom_keybind_changes"])
|
||||
if string(data) != `[{"id":"custom:12","op":"delete"}]` {
|
||||
t.Fatalf("wrong removal: %s", data)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"image"
|
||||
"math"
|
||||
)
|
||||
|
||||
func aqueousWindowGeometry(model aqueousSnapshotModel, seatName string) (*WindowGeometry, error) {
|
||||
seat, err := model.Seat(seatName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if seat.FocusKind != "window" {
|
||||
return nil, errors.New("no active window: seat focus is not a window")
|
||||
}
|
||||
window := model.Entities["window:"+seat.Window]
|
||||
if window == nil || !window.Visible || window.Minimized || !window.CanActivate {
|
||||
return nil, errors.New("no eligible active window")
|
||||
}
|
||||
output := model.Entities["output:"+window.Output]
|
||||
if output == nil || !output.Enabled || !output.Powered {
|
||||
return nil, errors.New("active window output is unavailable")
|
||||
}
|
||||
box, bounds := window.OuterGeometry, output.Bounds
|
||||
if box == nil || bounds == nil || !box.valid() || !bounds.valid() || box.Width <= 0 || box.Height <= 0 || output.Scale <= 0 || math.IsInf(output.Scale, 0) || math.IsNaN(output.Scale) {
|
||||
return nil, errors.New("invalid active window geometry")
|
||||
}
|
||||
return &WindowGeometry{X: int32(box.X), Y: int32(box.Y), Width: int32(box.Width), Height: int32(box.Height), Output: output.Name, Scale: output.Scale, OutputX: int32(bounds.X), OutputY: int32(bounds.Y)}, nil
|
||||
}
|
||||
|
||||
func aqueousCropRect(geom *WindowGeometry, pixels image.Rectangle) image.Rectangle {
|
||||
left := math.Floor((float64(geom.X) - float64(geom.OutputX)) * geom.Scale)
|
||||
top := math.Floor((float64(geom.Y) - float64(geom.OutputY)) * geom.Scale)
|
||||
right := math.Ceil((float64(geom.X) + float64(geom.Width) - float64(geom.OutputX)) * geom.Scale)
|
||||
bottom := math.Ceil((float64(geom.Y) + float64(geom.Height) - float64(geom.OutputY)) * geom.Scale)
|
||||
return image.Rect(int(left), int(top), int(right), int(bottom)).Intersect(pixels)
|
||||
}
|
||||
|
||||
func aqueousFocusedOutput(seatName string) (string, error) {
|
||||
model, err := aqueousSnapshot(context.Background())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
seat, err := model.Seat(seatName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output := model.Entities["output:"+seat.Output]
|
||||
if output == nil || !output.Enabled || !output.Powered {
|
||||
return "", errors.New("selected output is unavailable")
|
||||
}
|
||||
return output.Name, nil
|
||||
}
|
||||
|
||||
func (s *Screenshoter) captureAqueousWindow() (*CaptureResult, error) {
|
||||
model, err := aqueousSnapshot(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
geom, err := aqueousWindowGeometry(model, s.config.Seat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
region := Region{X: geom.X, Y: geom.Y, Width: geom.Width, Height: geom.Height, Output: geom.Output}
|
||||
if s.config.Geometry {
|
||||
return geometryResult(region), nil
|
||||
}
|
||||
output := s.findOutputByName(geom.Output)
|
||||
if output == nil {
|
||||
return nil, errors.New("active window output disappeared")
|
||||
}
|
||||
result, err := s.captureWholeOutput(output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer result.Buffer.Close()
|
||||
// captureWholeOutput already normalizes inversion and output transform.
|
||||
rect := aqueousCropRect(geom, image.Rect(0, 0, result.Buffer.Width, result.Buffer.Height))
|
||||
if rect.Empty() {
|
||||
return nil, errors.New("active window is outside the output")
|
||||
}
|
||||
bpp := PixelFormat(result.Format).BytesPerPixel()
|
||||
cropped, err := CreateShmBuffer(rect.Dx(), rect.Dy(), rect.Dx()*bpp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
src, dst := result.Buffer.Data(), cropped.Data()
|
||||
for y := 0; y < rect.Dy(); y++ {
|
||||
start := (rect.Min.Y+y)*result.Buffer.Stride + rect.Min.X*bpp
|
||||
copy(dst[y*cropped.Stride:y*cropped.Stride+rect.Dx()*bpp], src[start:start+rect.Dx()*bpp])
|
||||
}
|
||||
cropped.Format = PixelFormat(result.Format)
|
||||
return &CaptureResult{Buffer: cropped, Region: region, Format: result.Format, Scale: geom.Scale, CICP: result.CICP}, nil
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"regexp"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/utils"
|
||||
)
|
||||
|
||||
type aqueousBox struct {
|
||||
X, Y, Width, Height int64
|
||||
}
|
||||
|
||||
func (b aqueousBox) valid() bool {
|
||||
for _, value := range []int64{b.X, b.Y, b.Width, b.Height} {
|
||||
if value < math.MinInt32 || value > math.MaxInt32 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type aqueousCaptureEntity struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Output string `json:"output"`
|
||||
Window string `json:"window"`
|
||||
FocusKind string `json:"focus_kind"`
|
||||
Visible bool `json:"visible"`
|
||||
Minimized bool `json:"minimized"`
|
||||
CanActivate bool `json:"can_activate"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Powered bool `json:"powered"`
|
||||
Scale float64 `json:"scale"`
|
||||
Bounds *aqueousBox `json:"bounds"`
|
||||
OuterGeometry *aqueousBox `json:"outer_geometry"`
|
||||
}
|
||||
|
||||
type aqueousSnapshotModel struct {
|
||||
Entities map[string]*aqueousCaptureEntity
|
||||
}
|
||||
|
||||
func (m aqueousSnapshotModel) Seat(name string) (*aqueousCaptureEntity, error) {
|
||||
if name != "" {
|
||||
if seat := m.Entities["seat:"+name]; seat != nil {
|
||||
return seat, nil
|
||||
}
|
||||
return nil, errors.New("not_found: seat")
|
||||
}
|
||||
var seat *aqueousCaptureEntity
|
||||
for _, entity := range m.Entities {
|
||||
if entity.Kind != "seat" {
|
||||
continue
|
||||
}
|
||||
if seat != nil {
|
||||
return nil, errors.New("ambiguous_seat")
|
||||
}
|
||||
seat = entity
|
||||
}
|
||||
if seat == nil {
|
||||
return nil, errors.New("unavailable: no seat")
|
||||
}
|
||||
return seat, nil
|
||||
}
|
||||
|
||||
func parseAqueousSnapshot(data []byte) (aqueousSnapshotModel, error) {
|
||||
var batch struct {
|
||||
Schema int `json:"schema"`
|
||||
Session string `json:"session"`
|
||||
Sequence string `json:"sequence"`
|
||||
BaseSequence json.RawMessage `json:"base_sequence"`
|
||||
Type string `json:"type"`
|
||||
Upsert []aqueousCaptureEntity `json:"upsert"`
|
||||
Removed []string `json:"removed"`
|
||||
}
|
||||
var model aqueousSnapshotModel
|
||||
if err := utils.DecodeJSON(data, &batch); err != nil {
|
||||
return model, err
|
||||
}
|
||||
if batch.Schema != 1 || batch.Type != "snapshot" || !bytes.Equal(batch.BaseSequence, []byte("null")) || batch.Upsert == nil || batch.Removed == nil || len(batch.Removed) != 0 || !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(batch.Session) || !regexp.MustCompile(`^[0-9]+$`).MatchString(batch.Sequence) {
|
||||
return model, errors.New("invalid Aqueous shell snapshot")
|
||||
}
|
||||
model.Entities = make(map[string]*aqueousCaptureEntity, len(batch.Upsert))
|
||||
for i := range batch.Upsert {
|
||||
entity := &batch.Upsert[i]
|
||||
key := entity.Kind + ":" + entity.ID
|
||||
if entity.ID == "" || entity.Kind == "" || model.Entities[key] != nil {
|
||||
return aqueousSnapshotModel{}, errors.New("invalid or duplicate shell entity")
|
||||
}
|
||||
model.Entities[key] = entity
|
||||
}
|
||||
if model.Entities["session:session"] == nil {
|
||||
return aqueousSnapshotModel{}, errors.New("missing session entity")
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func aqueousSnapshot(ctx context.Context) (aqueousSnapshotModel, error) {
|
||||
var data json.RawMessage
|
||||
if err := utils.RunJSON(ctx, "aqueousctl", []string{"shell", "snapshot", "--json"}, nil, &data); err != nil {
|
||||
return aqueousSnapshotModel{}, err
|
||||
}
|
||||
return parseAqueousSnapshot(data)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
CompositorScroll
|
||||
CompositorMiracle
|
||||
CompositorMango
|
||||
CompositorAqueous
|
||||
)
|
||||
|
||||
var detectedCompositor Compositor = -1
|
||||
@@ -29,6 +31,16 @@ func DetectCompositor() Compositor {
|
||||
if detectedCompositor >= 0 {
|
||||
return detectedCompositor
|
||||
}
|
||||
owner := waylandSocketOwner()
|
||||
known := map[string]Compositor{"aqueous": CompositorAqueous, "hyprland": CompositorHyprland, "niri": CompositorNiri, "sway": CompositorSway, "scroll": CompositorScroll, "miracle-wm": CompositorMiracle, "mango": CompositorMango}
|
||||
if compositor, ok := known[owner]; ok {
|
||||
detectedCompositor = compositor
|
||||
return compositor
|
||||
}
|
||||
if owner != "" {
|
||||
detectedCompositor = CompositorUnknown
|
||||
return detectedCompositor
|
||||
}
|
||||
|
||||
candidates := []struct {
|
||||
socket string
|
||||
@@ -78,6 +90,12 @@ func GetActiveWindow() (*WindowGeometry, error) {
|
||||
return getHyprlandActiveWindow()
|
||||
case CompositorMango:
|
||||
return getMangoActiveWindow()
|
||||
case CompositorAqueous:
|
||||
model, err := aqueousSnapshot(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aqueousWindowGeometry(model, "")
|
||||
default:
|
||||
return nil, fmt.Errorf("window capture requires Hyprland, Mango, or niri")
|
||||
}
|
||||
@@ -355,6 +373,9 @@ func getNiriFocusedMonitor() string {
|
||||
|
||||
func GetFocusedMonitor() string {
|
||||
switch DetectCompositor() {
|
||||
case CompositorAqueous:
|
||||
name, _ := aqueousFocusedOutput("")
|
||||
return name
|
||||
case CompositorHyprland:
|
||||
return getHyprlandFocusedMonitor()
|
||||
case CompositorSway:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package screenshot
|
||||
|
||||
import (
|
||||
"golang.org/x/sys/unix"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func waylandSocketOwner() string {
|
||||
display := os.Getenv("WAYLAND_DISPLAY")
|
||||
if display == "" {
|
||||
display = "wayland-0"
|
||||
}
|
||||
if !filepath.IsAbs(display) {
|
||||
display = filepath.Join(os.Getenv("XDG_RUNTIME_DIR"), display)
|
||||
}
|
||||
connection, err := net.DialTimeout("unix", display, time.Second)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer connection.Close()
|
||||
raw, err := connection.(*net.UnixConn).SyscallConn()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var pid int32
|
||||
err = raw.Control(func(fd uintptr) {
|
||||
cred, err := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
|
||||
if err == nil {
|
||||
pid = cred.Pid
|
||||
}
|
||||
})
|
||||
if err != nil || pid <= 0 {
|
||||
return ""
|
||||
}
|
||||
comm, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(int(pid)), "comm"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(string(comm)))
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//go:build !linux
|
||||
|
||||
package screenshot
|
||||
|
||||
func waylandSocketOwner() string { return "" }
|
||||
@@ -220,6 +220,9 @@ func (s *Screenshoter) captureRegion() (*CaptureResult, error) {
|
||||
}
|
||||
|
||||
func (s *Screenshoter) captureWindow() (*CaptureResult, error) {
|
||||
if DetectCompositor() == CompositorAqueous {
|
||||
return s.captureAqueousWindow()
|
||||
}
|
||||
if DetectCompositor() == CompositorNiri {
|
||||
if s.config.Geometry {
|
||||
return nil, fmt.Errorf("window geometry mode is not supported on niri")
|
||||
@@ -362,6 +365,13 @@ func (s *Screenshoter) captureMangoWindow(output *WaylandOutput, region Region,
|
||||
}
|
||||
|
||||
func (s *Screenshoter) captureFullScreen() (*CaptureResult, error) {
|
||||
if DetectCompositor() == CompositorAqueous {
|
||||
name, err := aqueousFocusedOutput(s.config.Seat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.captureOutput(name)
|
||||
}
|
||||
output := s.findFocusedOutput()
|
||||
if output == nil {
|
||||
s.outputsMu.Lock()
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
aqueous-snapshot.json is the first focused-window state reconstructed from the real watch fixtures in quickshell/tests/fixtures/aqueous. Captured against Aqueous master 2d8b07fb1ff2354ea0c3acddd5cc84ceb823d6eb in an isolated native session on 2026-09-05; the session token is sanitized. This fixture does not certify physical capture.
|
||||
@@ -0,0 +1 @@
|
||||
{"schema": 1, "session": "0123456789abcdef0123456789abcdef", "sequence": "5", "base_sequence": null, "type": "snapshot", "upsert": [{"kind": "workspace", "id": "12", "output": "2", "name": "same \"name\"", "number": 3, "active": true, "urgent": false}, {"kind": "workspace", "id": "2", "output": "1", "name": "2", "number": 2, "active": false, "urgent": false}, {"kind": "session", "id": "session", "locked": false, "default_seat": "default", "overview_output": null, "overview_window": null}, {"kind": "workspace", "id": "11", "output": "2", "name": "2", "number": 2, "active": false, "urgent": false}, {"kind": "output", "id": "1", "name": "HEADLESS-2", "enabled": true, "powered": true, "bounds": {"x": 0, "y": 0, "width": 1280, "height": 720}, "usable_bounds": {"x": 0, "y": 0, "width": 1280, "height": 720}, "scale": 1, "transform": "normal", "active_workspace": "1"}, {"kind": "workspace", "id": "18", "output": "2", "name": "9", "number": 9, "active": false, "urgent": false}, {"kind": "workspace", "id": "16", "output": "2", "name": "7", "number": 7, "active": false, "urgent": false}, {"kind": "workspace", "id": "15", "output": "2", "name": "6", "number": 6, "active": false, "urgent": false}, {"kind": "workspace", "id": "10", "output": "2", "name": "1", "number": 1, "active": false, "urgent": false}, {"kind": "workspace", "id": "5", "output": "1", "name": "5", "number": 5, "active": false, "urgent": false}, {"kind": "workspace", "id": "9", "output": "1", "name": "9", "number": 9, "active": false, "urgent": false}, {"kind": "workspace", "id": "4", "output": "1", "name": "4", "number": 4, "active": false, "urgent": false}, {"kind": "seat", "id": "default", "output": "1", "window": "ca89b2eb6850b81d12b92763969d82ca", "focus_kind": "window", "keyboard": "1"}, {"kind": "workspace", "id": "3", "output": "1", "name": "3", "number": 3, "active": false, "urgent": false}, {"kind": "workspace", "id": "14", "output": "2", "name": "5", "number": 5, "active": false, "urgent": false}, {"kind": "workspace", "id": "7", "output": "1", "name": "7", "number": 7, "active": false, "urgent": false}, {"kind": "workspace", "id": "1", "output": "1", "name": "1", "number": 1, "active": true, "urgent": false}, {"kind": "workspace", "id": "6", "output": "1", "name": "6", "number": 6, "active": false, "urgent": false}, {"kind": "workspace", "id": "8", "output": "1", "name": "8", "number": 8, "active": false, "urgent": false}, {"kind": "workspace", "id": "13", "output": "2", "name": "4", "number": 4, "active": false, "urgent": false}, {"kind": "workspace", "id": "17", "output": "2", "name": "8", "number": 8, "active": false, "urgent": false}, {"kind": "output", "id": "2", "name": "HEADLESS-1", "enabled": true, "powered": true, "bounds": {"x": 1280, "y": 0, "width": 1280, "height": 720}, "usable_bounds": {"x": 1280, "y": 0, "width": 1280, "height": 720}, "scale": 1, "transform": "normal", "active_workspace": "12"}, {"kind": "window", "id": "ca89b2eb6850b81d12b92763969d82ca", "backend": "xdg", "app_id": "aq-shell-test", "class": null, "title": "same title", "workspace": "1", "output": "1", "geometry": {"x": 10, "y": 10, "width": 628, "height": 700}, "outer_geometry": {"x": 8, "y": 8, "width": 632, "height": 704}, "focused": true, "visible": true, "floating": false, "minimized": false, "maximized": false, "fullscreen": false, "skip_taskbar": false, "skip_switcher": false, "always_above": false, "always_below": false, "snapped": false, "fixed_position": false, "layout": "tiled", "can_minimize": false, "can_maximize": false, "can_activate": true}, {"kind": "keyboard", "id": "1", "seat": "default", "layouts": ["English (US)", "German"], "index": 0}, {"kind": "keyboard_device", "id": "1", "name": "wlr_virtual_keyboard_v1", "seat": "default", "group": "1", "virtual": true}], "removed": []}
|
||||
@@ -56,6 +56,7 @@ type Output struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Seat string
|
||||
Mode Mode
|
||||
OutputName string
|
||||
Cursor CursorMode
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestJSONProcessFixture(t *testing.T) {
|
||||
mode := os.Getenv("DMS_JSON_PROCESS_FIXTURE")
|
||||
if mode == "" {
|
||||
return
|
||||
}
|
||||
switch mode {
|
||||
case "accepted":
|
||||
fmt.Print(`{"ok":true,"status":"accepted","sequence":"9007199254740993"}`)
|
||||
case "failure":
|
||||
fmt.Print(`{"ok":false,"status":"locked","sequence":"1"}`)
|
||||
os.Exit(1)
|
||||
case "malformed":
|
||||
fmt.Print("invalid JSON")
|
||||
case "timeout":
|
||||
time.Sleep(time.Minute)
|
||||
case "oversize":
|
||||
fmt.Print(strings.Repeat("x", maxJSONProcessBytes+2))
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func TestRunJSONResultsAndTimeout(t *testing.T) {
|
||||
for _, mode := range []string{"accepted", "failure", "malformed", "oversize", "timeout"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
t.Setenv("DMS_JSON_PROCESS_FIXTURE", mode)
|
||||
limit := 4 * time.Second
|
||||
if mode == "timeout" {
|
||||
limit = 100 * time.Millisecond
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), limit)
|
||||
defer cancel()
|
||||
var result jsonProcessResult
|
||||
err := RunJSON(ctx, os.Args[0], []string{"-test.run=^TestJSONProcessFixture$"}, nil, &result)
|
||||
if mode == "accepted" {
|
||||
if err != nil || !result.OK || result.Status != "accepted" || result.Sequence != "9007199254740993" {
|
||||
t.Fatalf("lost acknowledgement: %v %#v", err, result)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("failure accepted")
|
||||
}
|
||||
if mode == "failure" && result.Status != "locked" {
|
||||
t.Fatal("operation failure lost")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type jsonProcessResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Status string `json:"status"`
|
||||
Sequence string `json:"sequence"`
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+210
@@ -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.
|
||||
@@ -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};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -156,6 +156,8 @@ Item {
|
||||
|
||||
function getRealWorkspaces() {
|
||||
const screenName = _barScreenName;
|
||||
if (CompositorService.isAqueous && AqueousService.available)
|
||||
return AqueousService.workspacesForOutput(SettingsData.workspaceFollowFocus ? AqueousService.focusedOutput : screenName);
|
||||
if (CompositorService.isNiri) {
|
||||
const fallbackWorkspaces = [
|
||||
{
|
||||
@@ -238,6 +240,8 @@ Item {
|
||||
|
||||
function getCurrentWorkspace() {
|
||||
const screenName = _barScreenName;
|
||||
if (CompositorService.isAqueous && AqueousService.available)
|
||||
return getRealWorkspaces().find(ws => ws.active)?.id || "";
|
||||
if (CompositorService.isNiri) {
|
||||
if (!screenName || SettingsData.workspaceFollowFocus) {
|
||||
return NiriService.getCurrentWorkspaceNumber();
|
||||
@@ -279,7 +283,14 @@ Item {
|
||||
return;
|
||||
}
|
||||
|
||||
if (CompositorService.isNiri) {
|
||||
if (CompositorService.isAqueous && AqueousService.available) {
|
||||
const index = realWorkspaces.findIndex(ws => ws.id === getCurrentWorkspace());
|
||||
if (index < 0)
|
||||
return;
|
||||
const next = Math.max(0, Math.min(realWorkspaces.length - 1, index + (direction > 0 ? 1 : -1)));
|
||||
if (next !== index)
|
||||
AqueousService.activateWorkspace(realWorkspaces[next]);
|
||||
} else if (CompositorService.isNiri) {
|
||||
const currentWs = getCurrentWorkspace();
|
||||
const currentIndex = realWorkspaces.findIndex(ws => ws && ws.idx === currentWs);
|
||||
const validIndex = currentIndex === -1 ? 0 : currentIndex;
|
||||
@@ -323,7 +334,7 @@ Item {
|
||||
}
|
||||
|
||||
function switchApp(deltaY) {
|
||||
const windows = sortedToplevels;
|
||||
const windows = sortedToplevels.filter(w => !w.skipSwitcher);
|
||||
if (windows.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ PanelWindow {
|
||||
return [];
|
||||
|
||||
const toplevels = [];
|
||||
const allToplevels = ToplevelManager.toplevels.values;
|
||||
const allToplevels = CompositorService.isAqueous && AqueousService.available ? CompositorService.sortedToplevels : ToplevelManager.toplevels.values;
|
||||
for (let i = 0; i < allToplevels.length; i++) {
|
||||
const toplevel = allToplevels[i];
|
||||
if (toplevel.appId === root.appData.appId) {
|
||||
|
||||
@@ -31,7 +31,7 @@ BasePill {
|
||||
property int availableWidth: maxWidth
|
||||
readonly property real effectiveHorizontalWidth: Math.max(0, Math.min(maxWidth, availableWidth))
|
||||
readonly property real effectiveHorizontalInnerWidth: Math.max(0, effectiveHorizontalWidth - horizontalPadding * 2)
|
||||
property Toplevel activeWindow: null
|
||||
property var activeWindow: null
|
||||
property var activeDesktopEntry: null
|
||||
property bool isHovered: mouseArea.containsMouse
|
||||
property bool isAutoHideBar: false
|
||||
@@ -99,6 +99,11 @@ BasePill {
|
||||
}
|
||||
|
||||
function updateActiveWindow() {
|
||||
if (CompositorService.isAqueous && AqueousService.available) {
|
||||
const focused = AqueousService.focusedWindow;
|
||||
activeWindow = focused && (!parentScreen || focused.screens.some(s => s.name === parentScreen.name)) ? focused : null;
|
||||
return;
|
||||
}
|
||||
let active = ToplevelManager.activeToplevel;
|
||||
|
||||
if (!active && CompositorService.isNiri) {
|
||||
|
||||
@@ -16,6 +16,8 @@ BasePill {
|
||||
property bool showIcon: widgetData?.keyboardLayoutNameShowIcon !== undefined ? widgetData.keyboardLayoutNameShowIcon : SettingsData.keyboardLayoutNameShowIcon
|
||||
readonly property var validVariants: ["US", "UK", "GB", "AZERTY", "QWERTY", "Dvorak", "Colemak", "Mac", "Intl", "International"]
|
||||
property string currentLayout: {
|
||||
if (CompositorService.isAqueous)
|
||||
return AqueousService.keyboardLayout;
|
||||
if (CompositorService.isNiri) {
|
||||
return NiriService.getCurrentKeyboardLayoutName();
|
||||
} else if (CompositorService.isMango) {
|
||||
@@ -25,7 +27,7 @@ BasePill {
|
||||
}
|
||||
property string hyprlandKeyboard: ""
|
||||
property var hyprlandLayoutLabels: []
|
||||
readonly property var _allLayoutLabels: CompositorService.isNiri ? (NiriService.keyboardLayoutNames || []).map(n => displayLabel(n)) : hyprlandLayoutLabels
|
||||
readonly property var _allLayoutLabels: CompositorService.isAqueous ? AqueousService.keyboardLayouts.map(n => displayLabel(n)) : CompositorService.isNiri ? (NiriService.keyboardLayoutNames || []).map(n => displayLabel(n)) : hyprlandLayoutLabels
|
||||
readonly property string reserveLabel: widestLabel(_allLayoutLabels)
|
||||
readonly property string verticalReserveLabel: widestLabel(_allLayoutLabels.map(n => LayoutCodes.layoutCode(n)))
|
||||
|
||||
@@ -131,6 +133,8 @@ BasePill {
|
||||
onClicked: {
|
||||
if (CompositorService.isNiri) {
|
||||
NiriService.cycleKeyboardLayout();
|
||||
} else if (CompositorService.isAqueous) {
|
||||
AqueousService.cycleKeyboardLayout();
|
||||
} else if (CompositorService.isHyprland) {
|
||||
Quickshell.execDetached(["hyprctl", "switchxkblayout", root.hyprlandKeyboard, "next"]);
|
||||
} else if (CompositorService.isMango) {
|
||||
|
||||
@@ -163,7 +163,7 @@ BasePill {
|
||||
const deltaY = wheelEvent.angleDelta.y;
|
||||
const isMouseWheel = Math.abs(deltaY) >= 120 && (Math.abs(deltaY) % 120) === 0;
|
||||
|
||||
const windows = root.sortedToplevels;
|
||||
const windows = root.sortedToplevels.filter(w => !w.skipSwitcher);
|
||||
if (windows.length < 2)
|
||||
return;
|
||||
|
||||
@@ -236,7 +236,7 @@ BasePill {
|
||||
id: windowRepeater
|
||||
model: ScriptModel {
|
||||
values: _groupByApp ? groupedWindows : sortedToplevels
|
||||
objectProp: _groupByApp ? "appId" : "address"
|
||||
objectProp: _groupByApp ? "appId" : (CompositorService.isAqueous && AqueousService.available ? "aqueousKey" : "address")
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
@@ -494,7 +494,7 @@ BasePill {
|
||||
id: windowRepeater
|
||||
model: ScriptModel {
|
||||
values: _groupByApp ? groupedWindows : sortedToplevels
|
||||
objectProp: _groupByApp ? "appId" : "address"
|
||||
objectProp: _groupByApp ? "appId" : (CompositorService.isAqueous && AqueousService.available ? "aqueousKey" : "address")
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
|
||||
@@ -24,6 +24,7 @@ Item {
|
||||
property real crossEdgeExtension: 0
|
||||
|
||||
readonly property bool isMango: CompositorService.isMango
|
||||
readonly property bool useAqueous: CompositorService.isAqueous && AqueousService.available
|
||||
|
||||
readonly property real _leftMargin: {
|
||||
if (isVertical)
|
||||
@@ -84,6 +85,8 @@ Item {
|
||||
|
||||
readonly property var extProjection: (useExtWorkspace && parentScreen) ? WindowManager.screenProjection(parentScreen) : null
|
||||
readonly property bool useExtWorkspace: {
|
||||
if (useAqueous)
|
||||
return false;
|
||||
if (Quickshell.env("DMS_FORCE_EXTWS") === "1")
|
||||
return (WindowManager.windowsets?.length ?? 0) > 0;
|
||||
if (!CompositorService.compositorDetected)
|
||||
@@ -117,6 +120,8 @@ Item {
|
||||
}
|
||||
|
||||
property var currentWorkspace: {
|
||||
if (useAqueous)
|
||||
return AqueousService.workspacesForOutput(effectiveScreenName).find(w => w.active)?.id || "";
|
||||
if (useExtWorkspace)
|
||||
return getExtWorkspaceActiveWorkspace();
|
||||
|
||||
@@ -143,6 +148,8 @@ Item {
|
||||
return [];
|
||||
}
|
||||
property var workspaceList: {
|
||||
if (useAqueous)
|
||||
return AqueousService.workspacesForOutput(effectiveScreenName);
|
||||
if (useExtWorkspace) {
|
||||
const baseList = getExtWorkspaceWorkspaces();
|
||||
return SettingsData.showWorkspacePadding ? padWorkspaces(baseList) : baseList;
|
||||
@@ -327,7 +334,9 @@ Item {
|
||||
}
|
||||
|
||||
let targetWorkspaceId;
|
||||
if (CompositorService.isNiri) {
|
||||
if (useAqueous) {
|
||||
targetWorkspaceId = ws.id;
|
||||
} else if (CompositorService.isNiri) {
|
||||
if (!ws || typeof ws !== "object") {
|
||||
const wsNumber = typeof ws === "number" ? ws : -1;
|
||||
if (wsNumber <= 0) {
|
||||
@@ -387,7 +396,9 @@ Item {
|
||||
return;
|
||||
} else {
|
||||
let winWs = null;
|
||||
if (CompositorService.isNiri) {
|
||||
if (useAqueous) {
|
||||
winWs = w.aqueousWorkspaceId;
|
||||
} else if (CompositorService.isNiri) {
|
||||
winWs = w.workspace_id;
|
||||
} else if (CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle) {
|
||||
winWs = w.workspace?.num;
|
||||
@@ -405,7 +416,7 @@ Item {
|
||||
const keyBase = (w.app_id || w.appId || w.class || w.windowClass || "unknown");
|
||||
const moddedId = Paths.moddedAppId(keyBase);
|
||||
const groupThisWs = SettingsData.groupWorkspaceApps && (!isActiveWs || SettingsData.groupActiveWorkspaceApps);
|
||||
const key = groupThisWs ? moddedId : `${moddedId}_${i}`;
|
||||
const key = groupThisWs ? moddedId : (w.aqueousKey || `${moddedId}_${i}`);
|
||||
|
||||
if (!byApp[key]) {
|
||||
const isQuickshell = keyBase === "org.quickshell" || keyBase === "com.danklinux.dms";
|
||||
@@ -677,6 +688,11 @@ Item {
|
||||
if (!data)
|
||||
return;
|
||||
|
||||
if (useAqueous) {
|
||||
AqueousService.activateWorkspace(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.useExtWorkspace) {
|
||||
if (typeof data.activate === "function")
|
||||
data.activate();
|
||||
@@ -727,6 +743,14 @@ Item {
|
||||
}
|
||||
|
||||
function switchWorkspace(direction) {
|
||||
if (useAqueous) {
|
||||
const workspaces = getRealWorkspaces();
|
||||
const index = workspaces.findIndex(w => w.id === currentWorkspace);
|
||||
const next = Math.max(0, Math.min(workspaces.length - 1, index + (direction > 0 ? 1 : -1)));
|
||||
if (next !== index)
|
||||
AqueousService.activateWorkspace(workspaces[next]);
|
||||
return;
|
||||
}
|
||||
if (useExtWorkspace) {
|
||||
const realWorkspaces = getRealWorkspaces();
|
||||
if (realWorkspaces.length < 2) {
|
||||
@@ -812,6 +836,8 @@ Item {
|
||||
}
|
||||
|
||||
function getWorkspaceIndexFallback(modelData, index) {
|
||||
if (useAqueous)
|
||||
return modelData?.number ?? "";
|
||||
if (root.useExtWorkspace)
|
||||
return index + 1;
|
||||
if (CompositorService.isNiri)
|
||||
@@ -868,7 +894,7 @@ Item {
|
||||
return getWorkspaceIndexFallback(modelData, index);
|
||||
}
|
||||
|
||||
readonly property bool hasNativeWorkspaceSupport: CompositorService.isNiri || CompositorService.isHyprland || root.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
|
||||
readonly property bool hasNativeWorkspaceSupport: useAqueous || CompositorService.isNiri || CompositorService.isHyprland || root.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle
|
||||
readonly property bool hasWorkspaces: getRealWorkspaces().length > 0
|
||||
readonly property bool shouldShow: hasNativeWorkspaceSupport || (useExtWorkspace && hasWorkspaces)
|
||||
|
||||
@@ -962,7 +988,9 @@ Item {
|
||||
const rootPos = edgeMouseArea.mapToItem(root, mouse.x, mouse.y);
|
||||
switch (mouse.button) {
|
||||
case Qt.RightButton:
|
||||
if (CompositorService.isNiri) {
|
||||
if (root.useAqueous) {
|
||||
AqueousService.toggleOverview(root.effectiveScreenName);
|
||||
} else if (CompositorService.isNiri) {
|
||||
NiriService.toggleOverview();
|
||||
} else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) {
|
||||
root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen;
|
||||
@@ -1144,6 +1172,8 @@ Item {
|
||||
}
|
||||
|
||||
property bool isActive: {
|
||||
if (root.useAqueous)
|
||||
return modelData?.active === true;
|
||||
if (root.useExtWorkspace)
|
||||
return (modelData?.id || modelData?.name) === root.currentWorkspace;
|
||||
if (CompositorService.isNiri)
|
||||
@@ -1157,6 +1187,8 @@ Item {
|
||||
return modelData === root.currentWorkspace;
|
||||
}
|
||||
property bool isOccupied: {
|
||||
if (root.useAqueous)
|
||||
return AqueousService.windows.some(w => w.workspace === modelData?.id && !w.skip_taskbar);
|
||||
if (CompositorService.isHyprland)
|
||||
return Array.from(Hyprland.toplevels?.values || []).some(tl => tl.workspace?.id === modelData?.id);
|
||||
if (root.isMango)
|
||||
@@ -1183,6 +1215,8 @@ Item {
|
||||
property var loadedWorkspaceData: null
|
||||
property bool loadedIsUrgent: false
|
||||
property bool isUrgent: {
|
||||
if (root.useAqueous)
|
||||
return modelData?.urgent === true;
|
||||
if (root.useExtWorkspace)
|
||||
return modelData?.urgent ?? false;
|
||||
if (CompositorService.isHyprland)
|
||||
@@ -1481,7 +1515,9 @@ Item {
|
||||
if (mouse.button === Qt.LeftButton) {
|
||||
if (delegateRoot.focusWindowAt(mouse.x, mouse.y))
|
||||
return;
|
||||
if (root.useExtWorkspace) {
|
||||
if (root.useAqueous) {
|
||||
AqueousService.activateWorkspace(modelData);
|
||||
} else if (root.useExtWorkspace) {
|
||||
if (typeof modelData?.activate === "function")
|
||||
modelData.activate();
|
||||
} else if (CompositorService.isNiri) {
|
||||
@@ -1496,7 +1532,9 @@ Item {
|
||||
CompositorService.dispatchSwayWorkspace(modelData);
|
||||
}
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
if (CompositorService.isNiri) {
|
||||
if (root.useAqueous) {
|
||||
AqueousService.toggleOverview(root.effectiveScreenName);
|
||||
} else if (CompositorService.isNiri) {
|
||||
NiriService.toggleOverview();
|
||||
} else if (CompositorService.isHyprland && root.hyprlandOverviewLoader?.item) {
|
||||
root.hyprlandOverviewLoader.item.overviewOpen = !root.hyprlandOverviewLoader.item.overviewOpen;
|
||||
@@ -1568,6 +1606,10 @@ Item {
|
||||
const winId = delegateRoot.windowIdAt(x, y);
|
||||
if (!winId)
|
||||
return false;
|
||||
if (root.useAqueous) {
|
||||
AqueousService.command("window.activate", {id: winId});
|
||||
return true;
|
||||
}
|
||||
if (CompositorService.isHyprland) {
|
||||
HyprlandService.focusWindow(winId);
|
||||
return true;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -874,15 +874,15 @@ Item {
|
||||
height: 1
|
||||
color: Theme.outline
|
||||
opacity: 0.15
|
||||
visible: CompositorService.isNiri && !dankBarTab.islandOwnsSelectedBarTop
|
||||
visible: (CompositorService.isNiri || CompositorService.isAqueous) && !dankBarTab.islandOwnsSelectedBarTop
|
||||
}
|
||||
|
||||
SettingsToggleRow {
|
||||
settingKey: "barOpenOnOverview"
|
||||
tags: ["bar", "overview", "niri", "show", "frame"]
|
||||
visible: CompositorService.isNiri && !dankBarTab.islandOwnsSelectedBarTop
|
||||
visible: (CompositorService.isNiri || CompositorService.isAqueous) && !dankBarTab.islandOwnsSelectedBarTop
|
||||
text: I18n.tr("Show on Overview")
|
||||
description: dankBarTab.selectedBarFrameStyled ? I18n.tr("Show during Niri overview") : I18n.tr("Show the bar when niri overview is active")
|
||||
description: CompositorService.isAqueous ? I18n.tr("Show on Overview") : dankBarTab.selectedBarFrameStyled ? I18n.tr("Show during Niri overview") : I18n.tr("Show the bar when niri overview is active")
|
||||
checked: dankBarTab.selectedBarFrameStyled ? SettingsData.frameShowOnOverview : (selectedBarConfig?.openOnOverview ?? false)
|
||||
onToggled: toggled => {
|
||||
if (dankBarTab.selectedBarFrameStyled) {
|
||||
|
||||
@@ -14,7 +14,11 @@ DankFloatingWindow {
|
||||
property int selectedIndex: -1
|
||||
property bool keyboardNavigationActive: false
|
||||
property var parentModal: null
|
||||
parentWindow: parentModal
|
||||
Binding {
|
||||
target: "parentWindow" in root ? root : null
|
||||
property: "parentWindow"
|
||||
value: root.parentModal
|
||||
}
|
||||
|
||||
signal widgetAdded(string widgetType)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import Quickshell.Io
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import "../../../Common/ConfigIncludeResolve.js" as ConfigIncludeResolve
|
||||
import "../../../Common/AqueousDisplays.js" as AqueousDisplays
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
@@ -41,6 +42,132 @@ Singleton {
|
||||
property bool hasPendingChanges: Object.keys(pendingChanges).length > 0 || Object.keys(pendingNiriChanges).length > 0 || Object.keys(pendingHyprlandChanges).length > 0 || formatChanged
|
||||
|
||||
property bool validatingConfig: false
|
||||
property var _cancelOutputWrite: null
|
||||
property var aqueousPreview: null
|
||||
|
||||
function freshAqueousOutputs(callback) {
|
||||
DMSService.sendRequest("wlroutput.getState", null, response => {
|
||||
callback(response.result?.outputs || null, response.error || "");
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function aqueousDisplayError(message) {
|
||||
validatingConfig = false;
|
||||
validationError = message;
|
||||
ToastService.showError(I18n.tr("Error"), message);
|
||||
}
|
||||
|
||||
function discardAqueousPreview() {
|
||||
if (validatingConfig)
|
||||
return;
|
||||
aqueousPreview = null;
|
||||
validationError = "";
|
||||
clearPendingChanges();
|
||||
WlrOutputService.requestState();
|
||||
}
|
||||
|
||||
function previewAqueousOutputs(descriptions) {
|
||||
if (validatingConfig)
|
||||
return;
|
||||
if (aqueousPreview) {
|
||||
observeAqueousPreview(aqueousPreview, descriptions, "");
|
||||
return;
|
||||
}
|
||||
validatingConfig = true;
|
||||
AqueousConfigService.load((snapshot, error) => {
|
||||
if (!snapshot) {
|
||||
aqueousDisplayError(error);
|
||||
return;
|
||||
}
|
||||
freshAqueousOutputs((original, error) => {
|
||||
if (!original) {
|
||||
aqueousDisplayError(error);
|
||||
return;
|
||||
}
|
||||
const candidate = WlrOutputService.outputsConfigHeads(buildOutputsWithPendingChanges(), outputs);
|
||||
WlrOutputService.testConfiguration(candidate, (success, message) => {
|
||||
if (!success) {
|
||||
aqueousDisplayError(message);
|
||||
return;
|
||||
}
|
||||
const preview = {snapshot: snapshot, original: original, heads: candidate,
|
||||
fingerprint: null, session: AqueousService.session};
|
||||
aqueousPreview = preview;
|
||||
WlrOutputService.applyConfiguration(candidate, (success, message) => {
|
||||
observeAqueousPreview(preview, descriptions, success ? "" : message);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function observeAqueousPreview(preview, descriptions, applyError) {
|
||||
validatingConfig = true;
|
||||
freshAqueousOutputs((actual, error) => {
|
||||
if (aqueousPreview !== preview)
|
||||
return;
|
||||
if (!actual || AqueousService.session !== preview.session || !AqueousDisplays.matches(preview.heads, actual, preview.original)) {
|
||||
if (actual && AqueousDisplays.fingerprint(actual) === AqueousDisplays.fingerprint(preview.original))
|
||||
aqueousPreview = null;
|
||||
aqueousDisplayError(error || applyError || I18n.tr("Display preview changed externally; review the current configuration"));
|
||||
return;
|
||||
}
|
||||
preview.fingerprint = AqueousDisplays.fingerprint(actual);
|
||||
validatingConfig = false;
|
||||
validationError = applyError;
|
||||
changesApplied(descriptions);
|
||||
});
|
||||
}
|
||||
|
||||
function finishAqueousPreview(keep) {
|
||||
if (validatingConfig || !aqueousPreview)
|
||||
return;
|
||||
const preview = aqueousPreview;
|
||||
validatingConfig = true;
|
||||
freshAqueousOutputs((actual, error) => {
|
||||
if (!actual || AqueousService.session !== preview.session || AqueousDisplays.fingerprint(actual) !== preview.fingerprint) {
|
||||
aqueousDisplayError(error || "conflict: output configuration changed after preview");
|
||||
return;
|
||||
}
|
||||
if (keep) {
|
||||
let draft;
|
||||
try {
|
||||
draft = AqueousDisplays.request(preview.snapshot, actual, preview.original);
|
||||
} catch (e) {
|
||||
aqueousDisplayError(String(e));
|
||||
return;
|
||||
}
|
||||
AqueousConfigService.apply(draft, (snapshot, message) => {
|
||||
if (!snapshot) {
|
||||
aqueousDisplayError(message);
|
||||
return;
|
||||
}
|
||||
aqueousPreview = null;
|
||||
clearPendingChanges();
|
||||
freshAqueousOutputs((live, error) => {
|
||||
validatingConfig = false;
|
||||
if (!live || AqueousService.session !== preview.session || AqueousDisplays.fingerprint(live) !== preview.fingerprint) {
|
||||
aqueousDisplayError(I18n.tr("Configuration saved, but live display state changed; refresh displays"));
|
||||
return;
|
||||
}
|
||||
changesConfirmed();
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
WlrOutputService.applyConfiguration(AqueousDisplays.heads(preview.original), (success, message) => {
|
||||
if (!success) {
|
||||
aqueousDisplayError(message);
|
||||
return;
|
||||
}
|
||||
validatingConfig = false;
|
||||
aqueousPreview = null;
|
||||
clearPendingChanges();
|
||||
WlrOutputService.requestState();
|
||||
changesReverted();
|
||||
});
|
||||
});
|
||||
}
|
||||
property string validationError: ""
|
||||
|
||||
property var currentOutputSet: []
|
||||
@@ -1674,10 +1801,19 @@ Singleton {
|
||||
const callback = typeof settingsOrCallback === "function" ? settingsOrCallback : maybeCallback;
|
||||
const hasExplicitSettings = settings !== null && settings !== undefined;
|
||||
|
||||
if (_cancelOutputWrite)
|
||||
_cancelOutputWrite();
|
||||
let completed = false;
|
||||
|
||||
function finish(success) {
|
||||
if (completed)
|
||||
return;
|
||||
completed = true;
|
||||
root._cancelOutputWrite = null;
|
||||
if (callback)
|
||||
callback(success);
|
||||
}
|
||||
_cancelOutputWrite = () => finish(false);
|
||||
|
||||
switch (CompositorService.compositor) {
|
||||
case "niri":
|
||||
@@ -1707,8 +1843,7 @@ Singleton {
|
||||
MangoService.generateOutputsConfig(outputsData, finish);
|
||||
break;
|
||||
default:
|
||||
WlrOutputService.applyOutputsConfig(outputsData, outputs);
|
||||
finish(true);
|
||||
WlrOutputService.applyOutputsConfig(outputsData, outputs, finish);
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
@@ -2221,16 +2356,25 @@ Singleton {
|
||||
return;
|
||||
}
|
||||
|
||||
changesApplied(changeDescriptions);
|
||||
|
||||
if (formatChanged)
|
||||
SettingsData.saveSettings();
|
||||
|
||||
if (CompositorService.isHyprland)
|
||||
commitHyprlandSettingsChanges();
|
||||
if (CompositorService.isAqueous) {
|
||||
previewAqueousOutputs(changeDescriptions);
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedOutputs = buildOutputsWithPendingChanges();
|
||||
backendWriteOutputsConfig(mergedOutputs);
|
||||
validatingConfig = true;
|
||||
backendWriteOutputsConfig(mergedOutputs, success => {
|
||||
validatingConfig = false;
|
||||
if (!success) {
|
||||
ToastService.showError(I18n.tr("Error"), I18n.tr("Failed to apply profile"));
|
||||
return;
|
||||
}
|
||||
if (formatChanged)
|
||||
SettingsData.saveSettings();
|
||||
if (CompositorService.isHyprland)
|
||||
commitHyprlandSettingsChanges();
|
||||
changesApplied(changeDescriptions);
|
||||
});
|
||||
}
|
||||
|
||||
function validateAndApplyNiriConfig(changeDescriptions) {
|
||||
@@ -2341,6 +2485,10 @@ Singleton {
|
||||
}
|
||||
|
||||
function confirmChanges(profileId) {
|
||||
if (CompositorService.isAqueous) {
|
||||
finishAqueousPreview(true);
|
||||
return;
|
||||
}
|
||||
const outputConfigs = buildCurrentOutputConfigs();
|
||||
lastAppliedEntry = {
|
||||
outputs: outputConfigs
|
||||
@@ -2372,6 +2520,10 @@ Singleton {
|
||||
}
|
||||
|
||||
function revertChanges() {
|
||||
if (CompositorService.isAqueous && aqueousPreview) {
|
||||
finishAqueousPreview(false);
|
||||
return;
|
||||
}
|
||||
const hadFormatChange = originalDisplayNameMode !== "";
|
||||
const hadNiriChanges = originalNiriSettings !== null;
|
||||
const hadHyprlandChanges = originalHyprlandSettings !== null;
|
||||
|
||||
@@ -99,6 +99,25 @@ Item {
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingS
|
||||
visible: CompositorService.isAqueous && DisplayConfigState.validationError !== ""
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
text: DisplayConfigState.validationError
|
||||
color: Theme.error
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
DankButton {
|
||||
text: I18n.tr("Discard draft and reload")
|
||||
enabled: !DisplayConfigState.validatingConfig
|
||||
onClicked: DisplayConfigState.discardAqueousPreview()
|
||||
}
|
||||
}
|
||||
|
||||
StyledRect {
|
||||
width: parent.width
|
||||
height: profileSection.implicitHeight + Theme.spacingL * 2
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,7 +15,11 @@ DankFloatingWindow {
|
||||
property bool keyboardNavigationActive: false
|
||||
property bool isLoading: false
|
||||
property var parentModal: null
|
||||
parentWindow: parentModal
|
||||
Binding {
|
||||
target: "parentWindow" in root ? root : null
|
||||
property: "parentWindow"
|
||||
value: root.parentModal
|
||||
}
|
||||
property bool pendingInstallHandled: false
|
||||
property string pendingApplyThemeId: ""
|
||||
|
||||
|
||||
@@ -455,6 +455,17 @@ Item {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Theme.spacingXL
|
||||
|
||||
Loader {
|
||||
width: parent.width
|
||||
active: CompositorService.isAqueous
|
||||
sourceComponent: AqueousAppearanceSettings {
|
||||
cursor: true
|
||||
settingKey: "aqueousCursor"
|
||||
title: I18n.tr("Cursor Theme") + " · Aqueous"
|
||||
visible: CompositorService.isAqueous
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
tab: "theme"
|
||||
tags: ["color", "palette", "theme", "appearance"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Widgets
|
||||
import qs.Services
|
||||
import qs.Modules.Settings.Widgets
|
||||
|
||||
Item {
|
||||
@@ -55,6 +56,16 @@ Item {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: Theme.spacingXL
|
||||
|
||||
Loader {
|
||||
width: parent.width
|
||||
active: CompositorService.isAqueous
|
||||
sourceComponent: AqueousAppearanceSettings {
|
||||
settingKey: "aqueousTypography"
|
||||
title: I18n.tr("Typography") + " · Aqueous"
|
||||
visible: CompositorService.isAqueous
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
tab: "typography"
|
||||
tags: ["font", "family", "text", "typography"]
|
||||
|
||||
@@ -13,7 +13,11 @@ DankFloatingWindow {
|
||||
property int selectedIndex: -1
|
||||
property bool keyboardNavigationActive: false
|
||||
property var parentModal: null
|
||||
parentWindow: parentModal
|
||||
Binding {
|
||||
target: "parentWindow" in root ? root : null
|
||||
property: "parentWindow"
|
||||
value: root.parentModal
|
||||
}
|
||||
readonly property bool blurActive: Theme.blurLayersActive
|
||||
readonly property bool floatingForegroundLayers: Theme.floatingWindowForegroundLayers
|
||||
readonly property bool transparentBlurLayers: Theme.blurLayersActive && !floatingForegroundLayers
|
||||
|
||||
@@ -12,7 +12,11 @@ DankFloatingWindow {
|
||||
property bool keyboardNavigationActive: false
|
||||
property var appsModel: []
|
||||
property var parentModal: null
|
||||
parentWindow: parentModal
|
||||
Binding {
|
||||
target: "parentWindow" in root ? root : null
|
||||
property: "parentWindow"
|
||||
value: root.parentModal
|
||||
}
|
||||
|
||||
signal appSelected(string appId)
|
||||
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import QtQuick
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
import qs.Widgets
|
||||
|
||||
SettingsCard {
|
||||
id: root
|
||||
property bool cursor: false
|
||||
property var snapshot: null
|
||||
property var changes: ({})
|
||||
property bool busy: false
|
||||
property string error: ""
|
||||
readonly property var report: cursor ? snapshot?.desktop_cursor : snapshot?.desktop_typography
|
||||
readonly property bool partial: (report?.failed_count || 0) > 0
|
||||
readonly property bool supported: snapshot?.capabilities?.includes(cursor ? "cursor_sync" : "typography_sync") || false
|
||||
title: cursor ? I18n.tr("Cursor Theme") + " · Aqueous" : I18n.tr("Typography") + " · Aqueous"
|
||||
iconName: cursor ? "mouse" : "text_fields"
|
||||
tab: cursor ? "theme" : "typography"
|
||||
tags: ["aqueous", "appearance"]
|
||||
|
||||
function reload() {
|
||||
if (busy)
|
||||
return;
|
||||
busy = true;
|
||||
AqueousConfigService.load((data, message) => {
|
||||
busy = false;
|
||||
error = message;
|
||||
if (!data)
|
||||
return;
|
||||
snapshot = data;
|
||||
changes = ({});
|
||||
if (!supported)
|
||||
error = I18n.tr("Unavailable");
|
||||
});
|
||||
}
|
||||
|
||||
function value(id) {
|
||||
if (Object.prototype.hasOwnProperty.call(changes, id))
|
||||
return changes[id];
|
||||
return snapshot?.fields?.find(f => f.id === id)?.value;
|
||||
}
|
||||
|
||||
function stage(id, value) {
|
||||
const updated = Object.assign({}, changes);
|
||||
updated[id] = value;
|
||||
changes = updated;
|
||||
}
|
||||
|
||||
function apply(retry) {
|
||||
if (busy || !snapshot)
|
||||
return;
|
||||
const capability = cursor ? "cursor_sync" : "typography_sync";
|
||||
if (!snapshot.capabilities?.includes(capability))
|
||||
return;
|
||||
const draft = {expected_generation: snapshot.generation, create_user_override: true,
|
||||
changes: Object.keys(changes).map(id => ({id: id, value: changes[id]}))};
|
||||
if (retry)
|
||||
draft[cursor ? "sync_cursor" : "sync_typography"] = true;
|
||||
busy = true;
|
||||
AqueousConfigService.apply(draft, (data, message) => {
|
||||
busy = false;
|
||||
error = message;
|
||||
if (!data)
|
||||
return;
|
||||
snapshot = data;
|
||||
changes = ({});
|
||||
if (!cursor) {
|
||||
const typography = data.desktop_typography;
|
||||
SettingsData.set("fontFamily", typography.family);
|
||||
SettingsData.set("fontWeight", typography.weight);
|
||||
SettingsData.set("fontScale", typography.size_pt * 96 / 72 / 14);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Component.onCompleted: reload()
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: Theme.spacingM
|
||||
|
||||
SettingsDropdownRow {
|
||||
width: parent.width
|
||||
visible: root.cursor
|
||||
enabled: !!root.snapshot && !root.busy
|
||||
text: I18n.tr("Cursor Theme")
|
||||
options: root.snapshot?.desktop_cursor?.themes || []
|
||||
currentValue: root.value("desktop.cursor.theme") || "default"
|
||||
onValueChanged: value => {
|
||||
root.stage("desktop.cursor.theme", value);
|
||||
root.stage("desktop.cursor.managed", true);
|
||||
}
|
||||
}
|
||||
|
||||
SettingsDropdownRow {
|
||||
width: parent.width
|
||||
visible: !root.cursor
|
||||
enabled: !!root.snapshot && !root.busy
|
||||
text: I18n.tr("Normal Font")
|
||||
options: root.snapshot?.desktop_typography?.families || []
|
||||
currentValue: root.value("desktop.font.family") || "sans-serif"
|
||||
onValueChanged: value => {
|
||||
root.stage("desktop.font.family", value);
|
||||
root.stage("desktop.font.style", "");
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSliderRow {
|
||||
width: parent.width
|
||||
enabled: !!root.snapshot && !root.busy
|
||||
text: root.cursor ? I18n.tr("Cursor Size") : I18n.tr("Font Size")
|
||||
minimum: root.cursor ? 12 : 6
|
||||
maximum: root.cursor ? 128 : 30
|
||||
value: root.value(root.cursor ? "desktop.cursor.size" : "desktop.font.size_pt") || (root.cursor ? 24 : 12)
|
||||
unit: root.cursor ? "px" : "pt"
|
||||
onSliderValueChanged: value => {
|
||||
root.stage(root.cursor ? "desktop.cursor.size" : "desktop.font.size_pt", value);
|
||||
if (root.cursor)
|
||||
root.stage("desktop.cursor.managed", true);
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
visible: root.error !== "" || root.partial
|
||||
text: root.error || I18n.tr("Error")
|
||||
color: Theme.error
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.report?.targets || []
|
||||
StyledText {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
text: modelData.id + ": " + I18n.tr(modelData.state)
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
width: parent.width
|
||||
visible: !root.cursor
|
||||
text: I18n.tr("DMS uses the font family, weight and scale. Exact face, slant, width and separately scaled bars may differ.")
|
||||
color: Theme.surfaceVariantText
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Row {
|
||||
spacing: Theme.spacingS
|
||||
DankButton {
|
||||
text: I18n.tr("Apply")
|
||||
enabled: root.supported && !root.busy && Object.keys(root.changes).length > 0
|
||||
onClicked: root.apply(false)
|
||||
}
|
||||
DankButton {
|
||||
text: I18n.tr("Retry")
|
||||
enabled: root.supported && !root.busy && root.partial
|
||||
onClicked: root.apply(true)
|
||||
}
|
||||
DankButton {
|
||||
text: I18n.tr("Reload")
|
||||
enabled: !root.busy
|
||||
onClicked: root.reload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
readonly property var log: Log.scoped("AqueousConfigService")
|
||||
property bool busy: false
|
||||
|
||||
function requireCapabilities(result, required) {
|
||||
if (result?.protocol !== 1 || !Array.isArray(result.capabilities))
|
||||
throw new Error("unsupported aqueous-config protocol or discovery");
|
||||
for (const capability of required) {
|
||||
if (!result.capabilities.includes(capability))
|
||||
throw new Error("unsupported aqueous-config capability: " + capability);
|
||||
}
|
||||
}
|
||||
|
||||
function helper(operation, input, callback) {
|
||||
const args = ["aqueous-config", operation, "--shell", "dms"];
|
||||
if (input !== null)
|
||||
args.push("--request", "-");
|
||||
AqueousService.runJson(args, input, (result, error) => {
|
||||
if (!error && result?.ok !== true)
|
||||
error = result?.code ? result.code + ": " + (result.message || "") : "aqueous-config rejected the request";
|
||||
callback(result, error);
|
||||
});
|
||||
}
|
||||
|
||||
function request(operation, draft, callback) {
|
||||
if (!CompositorService.isAqueous || busy) {
|
||||
callback(null, busy ? "busy" : "unavailable");
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
const complete = (result, error) => {
|
||||
busy = false;
|
||||
if (error)
|
||||
log.warn("helper operation failed:", operation, error);
|
||||
callback(error ? null : result, error);
|
||||
};
|
||||
if (operation === "snapshot") {
|
||||
helper("snapshot", null, (result, error) => {
|
||||
try {
|
||||
if (error)
|
||||
throw new Error(error);
|
||||
requireCapabilities(result, ["schema_fields", "shell_dms"]);
|
||||
if (typeof result.generation !== "string" || !result.generation)
|
||||
throw new Error("missing configuration generation");
|
||||
complete(result, "");
|
||||
} catch (e) {
|
||||
complete(null, String(e));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!["apply", "validate"].includes(operation) || typeof draft?.expected_generation !== "string" || !draft.expected_generation) {
|
||||
complete(null, "invalid operation or missing expected_generation; reload configuration");
|
||||
return;
|
||||
}
|
||||
const request = Object.assign({}, draft, {
|
||||
protocol: 1
|
||||
});
|
||||
request.backup_dir = request.backup_dir || (Quickshell.env("XDG_CONFIG_HOME") || Quickshell.env("HOME") + "/.config") + "/DankMaterialShell/aqueous-backups";
|
||||
// Freeze the complete draft before asynchronous discovery and validation.
|
||||
const input = JSON.stringify(request);
|
||||
const required = ["validate", "stdin_requests", "generation_check", "atomic_file_replace", "shell_dms"];
|
||||
for (const [key, capability] of Object.entries({
|
||||
monitor_changes: "monitor_modes",
|
||||
custom_keybind_changes: "keybinds",
|
||||
sync_cursor: "cursor_sync",
|
||||
sync_typography: "typography_sync"
|
||||
})) {
|
||||
if (Object.prototype.hasOwnProperty.call(request, key))
|
||||
required.push(capability);
|
||||
}
|
||||
helper("version", null, (version, error) => {
|
||||
try {
|
||||
if (error)
|
||||
throw new Error(error);
|
||||
requireCapabilities(version, required);
|
||||
} catch (e) {
|
||||
complete(null, String(e));
|
||||
return;
|
||||
}
|
||||
helper("validate", input, (validated, error) => {
|
||||
if (error || operation === "validate") {
|
||||
complete(validated, error);
|
||||
return;
|
||||
}
|
||||
helper("apply", input, complete);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function load(callback) {
|
||||
request("snapshot", null, callback);
|
||||
}
|
||||
|
||||
function apply(draft, callback) {
|
||||
request("apply", draft, callback);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -176,20 +176,20 @@ Singleton {
|
||||
|
||||
Process {
|
||||
id: uwsmLogout
|
||||
property bool notRunning: false
|
||||
command: ["uwsm", "stop"]
|
||||
running: false
|
||||
|
||||
stdout: SplitParser {
|
||||
splitMarker: "\n"
|
||||
onRead: data => {
|
||||
if (data.trim().toLowerCase().includes("not running")) {
|
||||
_logout();
|
||||
}
|
||||
if (data.trim().toLowerCase().includes("not running"))
|
||||
uwsmLogout.notRunning = true;
|
||||
}
|
||||
}
|
||||
|
||||
onExited: function (exitCode) {
|
||||
if (exitCode === 0) {
|
||||
if (exitCode === 0 && !notRunning) {
|
||||
return;
|
||||
}
|
||||
_logout();
|
||||
@@ -393,13 +393,21 @@ Singleton {
|
||||
// * Session management
|
||||
function logout() {
|
||||
if (hasUwsm) {
|
||||
if (uwsmLogout.running)
|
||||
return;
|
||||
uwsmLogout.notRunning = false;
|
||||
uwsmLogout.running = true;
|
||||
return;
|
||||
}
|
||||
_logout();
|
||||
}
|
||||
|
||||
function _logout() {
|
||||
if (SettingsData.customPowerActionLogout.length === 0) {
|
||||
if (CompositorService.isAqueous) {
|
||||
AqueousService.quit();
|
||||
return;
|
||||
}
|
||||
if (CompositorService.isNiri) {
|
||||
NiriService.quit();
|
||||
return;
|
||||
|
||||
@@ -44,6 +44,9 @@ Singleton {
|
||||
"isNiri": () => CompositorService.isNiri,
|
||||
"isHyprland": () => CompositorService.isHyprland,
|
||||
"isMango": () => CompositorService.isMango,
|
||||
"isAqueous": () => CompositorService.isAqueous,
|
||||
"nativeOverviewCapable": () => CompositorService.isNiri || CompositorService.isAqueous,
|
||||
"smartDockCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango || CompositorService.isAqueous,
|
||||
"isHyprlandOrNiri": () => CompositorService.isHyprland || CompositorService.isNiri,
|
||||
"windowRulesCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango,
|
||||
"layoutCapable": () => CompositorService.isNiri || CompositorService.isHyprland || CompositorService.isMango,
|
||||
|
||||
@@ -122,11 +122,11 @@ Singleton {
|
||||
DMSService.sendRequest("wlroutput.applyConfiguration", {
|
||||
"heads": heads
|
||||
}, response => {
|
||||
const success = !response.error;
|
||||
const success = !response.error && response.result?.success === true;
|
||||
const message = response.error || response.result?.message || "";
|
||||
|
||||
if (response.error) {
|
||||
log.warn("applyConfiguration error:", response.error);
|
||||
if (!success) {
|
||||
log.warn("applyConfiguration error:", message);
|
||||
} else {
|
||||
log.debug("Configuration applied successfully");
|
||||
}
|
||||
@@ -135,7 +135,7 @@ Singleton {
|
||||
if (callback) {
|
||||
callback(success, message);
|
||||
}
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function testConfiguration(heads, callback) {
|
||||
@@ -151,11 +151,11 @@ Singleton {
|
||||
DMSService.sendRequest("wlroutput.testConfiguration", {
|
||||
"heads": heads
|
||||
}, response => {
|
||||
const success = !response.error;
|
||||
const success = !response.error && response.result?.success === true;
|
||||
const message = response.error || response.result?.message || "";
|
||||
|
||||
if (response.error) {
|
||||
log.warn("testConfiguration error:", response.error);
|
||||
if (!success) {
|
||||
log.warn("testConfiguration error:", message);
|
||||
} else {
|
||||
log.debug("Configuration test passed");
|
||||
}
|
||||
@@ -163,7 +163,7 @@ Singleton {
|
||||
if (callback) {
|
||||
callback(success, message);
|
||||
}
|
||||
});
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function setOutputEnabled(outputName, enabled, callback) {
|
||||
@@ -282,9 +282,22 @@ Singleton {
|
||||
// NiriService, HyprlandService and MangoService. Instead of writing a
|
||||
// config file, the changes are applied directly via the
|
||||
// wlr-output-management protocol.
|
||||
function applyOutputsConfig(outputsData, connectedOutputs) {
|
||||
if (!wlrOutputAvailable)
|
||||
function applyOutputsConfig(outputsData, connectedOutputs, callback) {
|
||||
if (!wlrOutputAvailable) {
|
||||
if (callback)
|
||||
callback(false, "Not connected");
|
||||
return;
|
||||
}
|
||||
const heads = outputsConfigHeads(outputsData, connectedOutputs);
|
||||
if (heads.length === 0) {
|
||||
if (callback)
|
||||
callback(false, "No connected outputs");
|
||||
return;
|
||||
}
|
||||
applyConfiguration(heads, callback);
|
||||
}
|
||||
|
||||
function outputsConfigHeads(outputsData, connectedOutputs) {
|
||||
const heads = [];
|
||||
for (const name in outputsData) {
|
||||
if (!connectedOutputs[name])
|
||||
@@ -319,8 +332,7 @@ Singleton {
|
||||
heads.push(head);
|
||||
}
|
||||
|
||||
if (heads.length > 0)
|
||||
applyConfiguration(heads);
|
||||
return heads;
|
||||
}
|
||||
|
||||
function transformFromName(name) {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {readFileSync} from "node:fs";
|
||||
import vm from "node:vm";
|
||||
|
||||
const adapter = vm.createContext({});
|
||||
vm.runInContext(readFileSync(new URL("../Common/AqueousDisplays.js", import.meta.url), "utf8").replace(".pragma library", ""), adapter);
|
||||
const mode = {id: "mode", width: 1920, height: 1080, refresh: 59940};
|
||||
const output = {id: "head", name: "DP-1", enabled: true, x: -1920, y: 0, scale: 1, transform: 0, modes: [mode], currentMode: mode};
|
||||
const original = [output];
|
||||
const candidate = adapter.heads(original);
|
||||
assert.equal(adapter.matches(candidate, original, original), true);
|
||||
assert.equal(adapter.matches(candidate, [{...output, id: "recreated"}], original), false);
|
||||
assert.equal(adapter.matches(candidate, [{...output, scale: 1.25}], original), false);
|
||||
assert.equal(adapter.matches(candidate, [], original), false);
|
||||
assert.notEqual(adapter.fingerprint(original), adapter.fingerprint([{...output, x: 0}]));
|
||||
assert.equal(adapter.fingerprint([output, {...output, id: "head2", name: "DP-2"}]), adapter.fingerprint([{...output, id: "head2", name: "DP-2"}, output]));
|
||||
const snapshot = {capabilities: ["monitor_modes"], generation: "generation", monitors: [{id: "output:0", name: "DP-1", scale: 1}, {id: "output:1", name: "offline", scale: 2}]};
|
||||
const request = adapter.request(snapshot, original, original);
|
||||
assert.equal(request.expected_generation, "generation");
|
||||
assert.equal(request.monitor_changes.length, 1);
|
||||
assert.equal(request.monitor_changes[0].mode, "[email protected]");
|
||||
assert.equal(request.monitor_changes[0].x, -1920);
|
||||
assert.throws(() => adapter.request(snapshot, [{...output, scale: 1.25}], original), /scale/);
|
||||
assert.throws(() => adapter.request(snapshot, [{...output, enabled: false}], original), /enablement/);
|
||||
assert.throws(() => adapter.request({...snapshot, raw_files: {outputs: '[[output]]\nedid = "sha256:test"'}}, original, original), /EDID/);
|
||||
assert.throws(() => adapter.request({...snapshot, capabilities: []}, original, original), /unsupported/);
|
||||
process.stdout.write("PASS: Aqueous display identity, conflicts, offline preservation and helper limits\n");
|
||||
@@ -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");
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {readFileSync} from "node:fs";
|
||||
import vm from "node:vm";
|
||||
|
||||
function loadMethods(context, path, names) {
|
||||
const source = readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
for (const name of names) {
|
||||
const start = source.indexOf(" function " + name + "(");
|
||||
assert.notEqual(start, -1);
|
||||
const end = source.indexOf("\n }", start) + 6;
|
||||
vm.runInContext(source.slice(start, end), context);
|
||||
}
|
||||
}
|
||||
|
||||
const callbacks = [];
|
||||
const context = vm.createContext({
|
||||
outputs: {}, _cancelOutputWrite: null,
|
||||
CompositorService: {compositor: "aqueous"},
|
||||
WlrOutputService: {applyOutputsConfig: (_, __, callback) => callbacks.push(callback)},
|
||||
});
|
||||
context.root = context;
|
||||
loadMethods(context, "../Modules/Settings/DisplayConfig/DisplayConfigState.qml", ["backendWriteOutputsConfig"]);
|
||||
const results = [];
|
||||
context.backendWriteOutputsConfig({}, success => results.push(["first", success]));
|
||||
assert.equal(results.length, 0, "UI reported success before backend response");
|
||||
callbacks[0](false);
|
||||
assert.deepEqual(results, [["first", false]]);
|
||||
context.backendWriteOutputsConfig({}, success => results.push(["second", success]));
|
||||
context.backendWriteOutputsConfig({}, success => results.push(["third", success]));
|
||||
assert.deepEqual(results.at(-1), ["second", false], "superseded operation did not cancel");
|
||||
callbacks[1](true);
|
||||
assert.equal(results.length, 2, "late completion changed a superseded operation");
|
||||
callbacks[2](true);
|
||||
callbacks[2](false);
|
||||
assert.deepEqual(results.at(-1), ["third", true]);
|
||||
assert.equal(results.length, 3, "callback fired twice");
|
||||
|
||||
const requests = [];
|
||||
const timers = [];
|
||||
const transport = vm.createContext({
|
||||
isConnected: true, pendingRequests: {}, clipboardRequestIds: {}, requestIdCounter: 0,
|
||||
log: {warn() {}, debug() {}}, requestSocket: {send: request => requests.push(request)},
|
||||
requestTimeoutComponent: {createObject: (_, properties) => {
|
||||
const timer = {...properties, start() {}, stop() {}, destroy() { this.destroyed = true; }};
|
||||
timers.push(timer);
|
||||
return timer;
|
||||
}},
|
||||
});
|
||||
transport.root = transport;
|
||||
loadMethods(transport, "../Services/DMSService.qml", ["sendRequest", "handleResponse", "failPendingRequests"]);
|
||||
const replies = [];
|
||||
transport.sendRequest("wlroutput.applyConfiguration", {}, response => replies.push(response), 5000);
|
||||
assert.equal(replies.length, 0);
|
||||
assert.equal(timers[0].interval, 5000);
|
||||
transport.handleResponse({id: requests[0].id, error: "timeout"});
|
||||
transport.handleResponse({id: requests[0].id, result: {success: true}});
|
||||
assert.equal(replies.length, 1, "late reply after timeout was dispatched");
|
||||
assert.equal(replies[0].error, "timeout");
|
||||
assert.equal(timers[0].destroyed, true);
|
||||
transport.sendRequest("wlroutput.applyConfiguration", {}, response => replies.push(response), 5000);
|
||||
transport.failPendingRequests();
|
||||
assert.equal(replies.length, 2);
|
||||
assert.equal(timers[1].destroyed, true, "disconnect retained timeout timer");
|
||||
|
||||
const displayAdapter = vm.createContext({});
|
||||
vm.runInContext(readFileSync(new URL("../Common/AqueousDisplays.js", import.meta.url), "utf8").replace(".pragma library", ""), displayAdapter);
|
||||
const outputReplies = [];
|
||||
const previews = [];
|
||||
let helperCalls = 0;
|
||||
const previewContext = vm.createContext({
|
||||
aqueousPreview: null, validatingConfig: false, validationError: "",
|
||||
AqueousService: {session: "session"}, AqueousDisplays: displayAdapter,
|
||||
I18n: {tr: value => value}, ToastService: {showError() {}},
|
||||
freshAqueousOutputs: callback => outputReplies.push(callback),
|
||||
changesApplied: value => previews.push(value),
|
||||
AqueousConfigService: {apply() { helperCalls++; }},
|
||||
});
|
||||
previewContext.root = previewContext;
|
||||
loadMethods(previewContext, "../Modules/Settings/DisplayConfig/DisplayConfigState.qml", ["aqueousDisplayError", "observeAqueousPreview", "finishAqueousPreview"]);
|
||||
const mode = {id: "mode", width: 1280, height: 720, refresh: 60000};
|
||||
const original = {id: "head", name: "DP-1", enabled: true, x: 0, y: 0, scale: 1, transform: 0, currentMode: mode, modes: [mode]};
|
||||
const applied = {...original, x: 100};
|
||||
const preview = {session: "session", original: [original], heads: displayAdapter.heads([applied]), fingerprint: null};
|
||||
previewContext.aqueousPreview = preview;
|
||||
previewContext.observeAqueousPreview(preview, ["position"], "uncertain timeout");
|
||||
assert.equal(previews.length, 0);
|
||||
outputReplies.shift()([applied], "");
|
||||
assert.equal(previews.length, 1, "committed preview was not recovered after timeout");
|
||||
assert.equal(preview.fingerprint, displayAdapter.fingerprint([applied]));
|
||||
previewContext.finishAqueousPreview(true);
|
||||
outputReplies.shift()([{...original, x: 200}], "");
|
||||
assert.equal(helperCalls, 0, "saved a preview after another client changed live state");
|
||||
assert.equal(previewContext.aqueousPreview, preview, "conflict discarded the draft");
|
||||
process.stdout.write("PASS: actual display callbacks, supersession, timeout, late response and disconnect cleanup\n");
|
||||
+1
@@ -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.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"session": "0123456789abcdef0123456789abcdef",
|
||||
"max_batch_bytes": 4194304,
|
||||
"state": true,
|
||||
"commands": true,
|
||||
"keyboard": true,
|
||||
"overview": true,
|
||||
"shortcut_inhibition": true,
|
||||
"geometry": "committed-content-global-logical"
|
||||
}
|
||||
+42
@@ -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"]}
|
||||
@@ -0,0 +1,130 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {readFileSync} from "node:fs";
|
||||
import vm from "node:vm";
|
||||
|
||||
function methods(context, path, names) {
|
||||
const source = readFileSync(new URL(path, import.meta.url), "utf8");
|
||||
for (const name of names) {
|
||||
const start = source.indexOf(" function " + name + "(");
|
||||
assert.notEqual(start, -1, name);
|
||||
const end = source.indexOf("\n }", start) + 6;
|
||||
vm.runInContext(source.slice(start, end), context);
|
||||
}
|
||||
}
|
||||
function property(context, name, prefix = " property var ", indent = " ") {
|
||||
const source = readFileSync(new URL("../Modules/DankBar/Widgets/WorkspaceSwitcher.qml", import.meta.url), "utf8");
|
||||
const token = prefix + name + ": {";
|
||||
const start = source.indexOf(token);
|
||||
assert.notEqual(start, -1, name);
|
||||
const end = source.indexOf("\n" + indent + "}", start);
|
||||
return vm.runInContext("(function(){" + source.slice(start + token.length, end) + "})()", context);
|
||||
}
|
||||
const context = vm.createContext({workspaceBackend: "ext", workspaceViewGeneration: 1, WindowManager: {windowsets: []}, Quickshell: {screens: [{name: "DP-1"}, {name: "DP-2"}]}});
|
||||
context.root = context;
|
||||
methods(context, "../Services/CompositorService.qml", ["selectWorkspaceBackend", "extWorkspaceRows", "workspaceViewForOutput"]);
|
||||
for (const [inputs, expected] of [
|
||||
[["aqueous", true, true, true, true], "ext"],
|
||||
[["aqueous", true, true, false, true], "none"],
|
||||
[["aqueous", true, false, true, true], "aqueous"],
|
||||
[["aqueous", true, false, true, false], "ext"],
|
||||
[["aqueous", true, false, false, false], "none"],
|
||||
[["unknown", false, false, true, true], "none"],
|
||||
[["unknown", false, true, true, false], "ext"],
|
||||
[["labwc", true, false, true, false], "ext"],
|
||||
]) assert.equal(context.selectWorkspaceBackend(...inputs), expected);
|
||||
for (const backend of ["niri", "hyprland", "mango", "sway", "scroll", "miracle"]) {
|
||||
assert.equal(context.selectWorkspaceBackend(backend, true, false, true, false), "legacy");
|
||||
assert.equal(context.selectWorkspaceBackend(backend, true, true, true, false), "ext");
|
||||
assert.equal(context.selectWorkspaceBackend(backend, true, true, false, false), "legacy");
|
||||
}
|
||||
let activations = 0;
|
||||
const handle = {id: "", name: "same", active: true, urgent: false, canActivate: true, shouldDisplay: true, coordinates: [1], activate() {activations++;}};
|
||||
const second = {...handle, coordinates: [0]};
|
||||
context.WindowManager.windowsets = [handle, second];
|
||||
let rows = context.extWorkspaceRows([handle, second], 1);
|
||||
assert.equal(rows[0].key, second);
|
||||
assert.equal(rows[1].key, handle);
|
||||
assert.notEqual(rows[0].key, rows[1].key, "duplicate names were used as identity");
|
||||
assert.equal(rows[0].windows, null);
|
||||
handle.name = "renamed"; handle.urgent = true;
|
||||
let updated = context.extWorkspaceRows([handle], 1)[0];
|
||||
assert.equal(updated.key, handle);
|
||||
assert.equal(updated.urgent, true);
|
||||
assert.equal(updated.name, "renamed");
|
||||
rows[1].activate(); assert.equal(activations, 1);
|
||||
handle.canActivate = false; rows[1].activate(); assert.equal(activations, 1);
|
||||
handle.canActivate = true;
|
||||
context.WindowManager.windowsets = [second]; rows[1].activate(); assert.equal(activations, 1);
|
||||
context.WindowManager.windowsets = [handle];
|
||||
context.workspaceViewGeneration = 2; rows[1].activate(); assert.equal(activations, 1);
|
||||
context.WindowManager.screenProjection = screen => ({windowsets: screen.name === "DP-1" ? [handle] : []});
|
||||
assert.equal(context.workspaceViewForOutput("DP-2").rows.length, 0, "empty output switched backend");
|
||||
assert.equal(context.workspaceViewForOutput("removed").rows.length, 0);
|
||||
|
||||
const actions = [];
|
||||
const aq = vm.createContext({available: true, capabilities: {commands: true}, locked: false, seat: {id: "seat0"}, session: "session-a", CompositorService: {workspaceBackend: "aqueous", workspaceViewGeneration: 1},
|
||||
workspaces: [{id: "w", name: "same", number: 1, output: "o1", active: true, urgent: false}],
|
||||
outputs: [{id: "o1", name: "DP-1"}], focusedOutput: "DP-1", command: (...args) => actions.push(args)});
|
||||
aq.root = aq;
|
||||
methods(aq, "../Services/AqueousService.qml", ["taskbarEligible", "workspaceRowsForOutput", "workspacesForOutput", "outputId", "windowFacade"]);
|
||||
aq.seat = {id: "seat0", window: "window-a"};
|
||||
aq.Quickshell = {screens: [{name: "DP-1"}]};
|
||||
const window = {id: "window-a", workspace: "w", output: "o1", app_id: "demo", visible: true, can_activate: true};
|
||||
aq.toplevels = [aq.windowFacade(window)];
|
||||
rows = aq.workspaceRowsForOutput("DP-1", 1);
|
||||
assert.equal(rows[0].key, "session-a:workspace:w");
|
||||
assert.equal(rows[0].windows[0].key, "session-a:window:window-a");
|
||||
rows[0].activate(); assert.equal(actions[0][1].session, "session-a");
|
||||
aq.session = "session-b";
|
||||
rows[0].windows[0].activate(); assert.equal(actions[1][1].session, "session-a", "cached icon adopted a new session");
|
||||
aq.CompositorService.workspaceViewGeneration = 2;
|
||||
rows[0].activate(); rows[0].windows[0].activate(); assert.equal(actions.length, 2, "cached action survived backend change");
|
||||
aq.locked = true;
|
||||
assert.equal(aq.workspaceRowsForOutput("DP-1", 2)[0].canActivate, false);
|
||||
aq.locked = false; aq.seat = null;
|
||||
assert.equal(aq.workspaceRowsForOutput("DP-1", 2)[0].canActivate, false);
|
||||
aq.seat = {id: "seat0"}; aq.capabilities.commands = false;
|
||||
assert.equal(aq.workspaceRowsForOutput("DP-1", 2)[0].canActivate, false);
|
||||
assert(aq.taskbarEligible({...window, minimized: true, visible: false}));
|
||||
assert(!aq.taskbarEligible({...window, skip_taskbar: true}));
|
||||
assert(!aq.taskbarEligible({...window, visible: false}));
|
||||
aq.workspaces[0].active = false;
|
||||
assert(aq.taskbarEligible({...window, visible: false}));
|
||||
|
||||
const settings = {showWorkspacePadding: true, showOccupiedWorkspacesOnly: false, showWorkspaceApps: true, groupWorkspaceApps: false, groupActiveWorkspaceApps: false, showWorkspaceName: true, showWorkspaceIndex: true};
|
||||
const widget = vm.createContext({usesWorkspaceView: true, workspaceView: {rows: []}, _placeholderPool: [], SettingsData: settings, CompositorService: {},
|
||||
DesktopEntries: {heuristicLookup: id => ({id})}, Paths: {moddedAppId: id => id, isSteamApp: () => false, getAppIcon: id => id, getAppName: id => id}, _desktopEntriesUpdateTrigger: 0});
|
||||
widget.root = widget;
|
||||
methods(widget, "../Modules/DankBar/Widgets/WorkspaceSwitcher.qml", ["padWorkspaces", "_makePlaceholder", "getRealWorkspaces", "switchToWorkspaceByModelData", "switchWorkspace", "getWorkspaceIcons", "getWorkspaceIndex", "getWorkspaceIndexFallback"]);
|
||||
let clicked = [];
|
||||
const windowFacade = (key, appId) => ({key, id: key, appId, activate: () => clicked.push(key)});
|
||||
const first = {key: "one", name: "1", number: 1, active: true, windows: [windowFacade("a", "demo"), windowFacade("b", "demo")], canActivate: true, activate: () => clicked.push("one")};
|
||||
const other = {key: "two", name: "1", number: 2, active: false, windows: [], canActivate: true, activate: () => clicked.push("two")};
|
||||
widget.workspaceView.rows = [first, other];
|
||||
widget.workspaceList = property(widget, "workspaceList");
|
||||
assert.equal(widget.workspaceList.length, 3, "Aqueous padding was skipped");
|
||||
const placeholder = widget.workspaceList[2];
|
||||
widget.switchToWorkspaceByModelData(placeholder); assert.equal(clicked.length, 0);
|
||||
widget.currentWorkspace = "one";
|
||||
widget.switchWorkspace(-1); assert.equal(clicked.length, 0);
|
||||
widget.switchWorkspace(1); assert.deepEqual(clicked, ["two"]);
|
||||
widget.currentWorkspace = "two"; widget.switchWorkspace(1); assert.equal(clicked.length, 1);
|
||||
widget.currentWorkspace = null; widget.switchWorkspace(-1); assert.equal(clicked.at(-1), "one");
|
||||
assert.equal(widget.getWorkspaceIndex(other, 1), "2: 1");
|
||||
assert.equal(widget.getWorkspaceIndex(placeholder, 2), 3);
|
||||
let icons = widget.getWorkspaceIcons(first);
|
||||
assert.equal(icons.length, 2);
|
||||
Object.assign(widget, {workspaceIcons: icons, isPlaceholder: false});
|
||||
assert.equal(property(widget, "stableIconCount", " readonly property int ", " "), icons.length, "icon count disagrees with renderer");
|
||||
icons[0].windowAction(); assert.equal(clicked.at(-1), "a");
|
||||
settings.groupWorkspaceApps = true; settings.groupActiveWorkspaceApps = true;
|
||||
icons = widget.getWorkspaceIcons(first); assert.equal(icons.length, 1); assert.equal(icons[0].count, 2);
|
||||
assert.equal(widget.getWorkspaceIcons({...first, windows: null}).length, 0, "ext membership was guessed");
|
||||
settings.showOccupiedWorkspacesOnly = true;
|
||||
widget.workspaceView.rows = [first, other, {...other, key: "unknown", windows: null}];
|
||||
assert.equal(property(widget, "workspaceList").filter(row => !row.placeholder).length, 2, "unknown occupancy was treated as empty");
|
||||
settings.showWorkspacePadding = false;
|
||||
assert.equal(property(widget, "workspaceList").length, 2);
|
||||
widget.workspaceView.rows = [];
|
||||
assert.equal(property(widget, "workspaceList").length, 0, "empty output fabricated a workspace");
|
||||
console.log("PASS: workspace selection, identities, stale actions, padding, navigation, icons and legacy backend selection");
|
||||
@@ -407,7 +407,14 @@ def find_settings_components(content, filename, wrappers):
|
||||
visible_raw = extract_property(block, "visible")
|
||||
condition_key = FILE_CONDITION_MAP.get(filename)
|
||||
if visible_raw:
|
||||
if all(c in visible_raw for c in ("CompositorService.isNiri", "CompositorService.isHyprland", "CompositorService.isMango")):
|
||||
if "CompositorService.isAqueous" in visible_raw:
|
||||
if "CompositorService.isHyprland" in visible_raw:
|
||||
condition_key = "smartDockCapable"
|
||||
elif "CompositorService.isNiri" in visible_raw:
|
||||
condition_key = "nativeOverviewCapable"
|
||||
else:
|
||||
condition_key = "isAqueous"
|
||||
elif all(c in visible_raw for c in ("CompositorService.isNiri", "CompositorService.isHyprland", "CompositorService.isMango")):
|
||||
condition_key = "windowRulesCapable"
|
||||
elif "CompositorService.isNiri" in visible_raw:
|
||||
condition_key = "isNiri"
|
||||
|
||||
@@ -1240,7 +1240,6 @@
|
||||
"category": "Bar",
|
||||
"keywords": [
|
||||
"bar",
|
||||
"during",
|
||||
"frame",
|
||||
"niri",
|
||||
"overview",
|
||||
@@ -1249,8 +1248,8 @@
|
||||
"statusbar",
|
||||
"topbar"
|
||||
],
|
||||
"description": "Show during Niri overview",
|
||||
"conditionKey": "isNiri"
|
||||
"description": "Show on Overview",
|
||||
"conditionKey": "nativeOverviewCapable"
|
||||
},
|
||||
{
|
||||
"section": "barSize",
|
||||
@@ -2015,7 +2014,7 @@
|
||||
"windows"
|
||||
],
|
||||
"description": "Show dock when floating windows don",
|
||||
"conditionKey": "windowRulesCapable"
|
||||
"conditionKey": "smartDockCapable"
|
||||
},
|
||||
{
|
||||
"section": "dockIsolateDisplays",
|
||||
@@ -2311,17 +2310,15 @@
|
||||
"tabIndex": 5,
|
||||
"category": "Dock",
|
||||
"keywords": [
|
||||
"always",
|
||||
"dock",
|
||||
"launcher bar",
|
||||
"niri",
|
||||
"overview",
|
||||
"panel",
|
||||
"show",
|
||||
"taskbar"
|
||||
],
|
||||
"description": "Always show the dock when niri",
|
||||
"conditionKey": "isNiri"
|
||||
"description": "Show on Overview",
|
||||
"conditionKey": "nativeOverviewCapable"
|
||||
},
|
||||
{
|
||||
"section": "dockLauncherLogoSizeOffset",
|
||||
@@ -3848,6 +3845,24 @@
|
||||
],
|
||||
"description": "Mouse pointer size in pixels"
|
||||
},
|
||||
{
|
||||
"section": "aqueousCursor",
|
||||
"label": "Cursor Theme",
|
||||
"tabIndex": 10,
|
||||
"category": "Theme & Colors",
|
||||
"keywords": [
|
||||
"appearance",
|
||||
"aqueous",
|
||||
"colors",
|
||||
"colour",
|
||||
"cursor",
|
||||
"look",
|
||||
"scheme",
|
||||
"style",
|
||||
"theme"
|
||||
],
|
||||
"conditionKey": "isAqueous"
|
||||
},
|
||||
{
|
||||
"section": "cursorTheme",
|
||||
"label": "Cursor Theme",
|
||||
@@ -6552,6 +6567,24 @@
|
||||
],
|
||||
"icon": "text_format"
|
||||
},
|
||||
{
|
||||
"section": "aqueousTypography",
|
||||
"label": "Typography",
|
||||
"tabIndex": 14,
|
||||
"category": "Typography & Motion",
|
||||
"keywords": [
|
||||
"animation",
|
||||
"appearance",
|
||||
"aqueous",
|
||||
"font",
|
||||
"fonts",
|
||||
"motion",
|
||||
"text",
|
||||
"typeface",
|
||||
"typography"
|
||||
],
|
||||
"conditionKey": "isAqueous"
|
||||
},
|
||||
{
|
||||
"section": "typography",
|
||||
"label": "Typography",
|
||||
@@ -9962,7 +9995,6 @@
|
||||
"keywords": [
|
||||
"border",
|
||||
"decoration",
|
||||
"during",
|
||||
"frame",
|
||||
"hide",
|
||||
"integrations",
|
||||
@@ -9972,8 +10004,8 @@
|
||||
"window"
|
||||
],
|
||||
"icon": "toolbar",
|
||||
"description": "Show during Niri overview",
|
||||
"conditionKey": "isNiri"
|
||||
"description": "Show on Overview",
|
||||
"conditionKey": "nativeOverviewCapable"
|
||||
},
|
||||
{
|
||||
"section": "frameLauncherEmergeSide",
|
||||
@@ -10056,7 +10088,6 @@
|
||||
"keywords": [
|
||||
"border",
|
||||
"decoration",
|
||||
"during",
|
||||
"frame",
|
||||
"hide",
|
||||
"niri",
|
||||
@@ -10064,7 +10095,7 @@
|
||||
"show",
|
||||
"window"
|
||||
],
|
||||
"description": "Show during Niri overview",
|
||||
"description": "Show on Overview",
|
||||
"conditionKey": "frameEnabled"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user