diff --git a/core/.golangci.yml b/core/.golangci.yml index 552624c4..7d59021c 100644 --- a/core/.golangci.yml +++ b/core/.golangci.yml @@ -102,7 +102,11 @@ linters: - linters: - ineffassign path: internal/proto/ - # binary.Write to bytes.Buffer can't fail + # binary.Write/Read to bytes.Buffer can't fail - linters: - errcheck - text: "Error return value of `binary\\.Write` is not checked" + text: "Error return value of `binary\\.(Write|Read)` is not checked" + # bytes.Reader.Read can't fail (reads from memory) + - linters: + - errcheck + text: "Error return value of `buf\\.Read` is not checked" diff --git a/core/cmd/dms/commands_clipboard.go b/core/cmd/dms/commands_clipboard.go new file mode 100644 index 00000000..c965fe27 --- /dev/null +++ b/core/cmd/dms/commands_clipboard.go @@ -0,0 +1,597 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/spf13/cobra" +) + +var clipboardCmd = &cobra.Command{ + Use: "clipboard", + Aliases: []string{"cl"}, + Short: "Manage clipboard", + Long: "Interact with the clipboard manager", +} + +var clipCopyCmd = &cobra.Command{ + Use: "copy [text]", + Short: "Copy text to clipboard", + Long: "Copy text to clipboard. If no text provided, reads from stdin. Works without server.", + Run: runClipCopy, +} + +var ( + clipCopyForeground bool + clipCopyPasteOnce bool + clipCopyType string + clipJSONOutput bool +) + +var clipPasteCmd = &cobra.Command{ + Use: "paste", + Short: "Paste text from clipboard", + Long: "Paste text from clipboard to stdout. Works without server.", + Run: runClipPaste, +} + +var clipWatchCmd = &cobra.Command{ + Use: "watch [command]", + Short: "Watch clipboard for changes", + Long: `Watch clipboard for changes and optionally execute a command. +Works like wl-paste --watch. Does not require server. + +If a command is provided, it will be executed each time the clipboard changes, +with the clipboard content piped to its stdin. + +Examples: + dms cl watch # Print clipboard changes to stdout + dms cl watch cat # Same as above + dms cl watch notify-send # Send notification on clipboard change`, + Run: runClipWatch, +} + +var clipHistoryCmd = &cobra.Command{ + Use: "history", + Short: "Show clipboard history", + Long: "Show clipboard history with previews (requires server)", + Run: runClipHistory, +} + +var clipGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get clipboard entry by ID", + Long: "Get full clipboard entry data by ID (requires server)", + Args: cobra.ExactArgs(1), + Run: runClipGet, +} + +var clipDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete clipboard entry", + Long: "Delete a clipboard history entry by ID (requires server)", + Args: cobra.ExactArgs(1), + Run: runClipDelete, +} + +var clipClearCmd = &cobra.Command{ + Use: "clear", + Short: "Clear clipboard history", + Long: "Clear all clipboard history (requires server)", + Run: runClipClear, +} + +var clipWatchStore bool + +var clipSearchCmd = &cobra.Command{ + Use: "search [query]", + Short: "Search clipboard history", + Long: "Search clipboard history with filters (requires server)", + Run: runClipSearch, +} + +var ( + clipSearchLimit int + clipSearchOffset int + clipSearchMimeType string + clipSearchImages bool + clipSearchText bool +) + +var clipConfigCmd = &cobra.Command{ + Use: "config", + Short: "Manage clipboard config", + Long: "Get or set clipboard configuration (requires server)", +} + +var clipConfigGetCmd = &cobra.Command{ + Use: "get", + Short: "Get clipboard config", + Run: runClipConfigGet, +} + +var clipConfigSetCmd = &cobra.Command{ + Use: "set", + Short: "Set clipboard config", + Long: `Set clipboard configuration options. + +Examples: + dms cl config set --max-history 200 + dms cl config set --auto-clear-days 7 + dms cl config set --clear-at-startup`, + Run: runClipConfigSet, +} + +var ( + clipConfigMaxHistory int + clipConfigAutoClearDays int + clipConfigClearAtStartup bool + clipConfigNoClearStartup bool + clipConfigDisabled bool + clipConfigEnabled bool + clipConfigDisableHistory bool + clipConfigEnableHistory bool + clipConfigDisablePersist bool + clipConfigEnablePersist bool +) + +func init() { + clipCopyCmd.Flags().BoolVarP(&clipCopyForeground, "foreground", "f", false, "Stay in foreground instead of forking") + clipCopyCmd.Flags().BoolVarP(&clipCopyPasteOnce, "paste-once", "o", false, "Exit after first paste") + clipCopyCmd.Flags().StringVarP(&clipCopyType, "type", "t", "text/plain;charset=utf-8", "MIME type") + + clipWatchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") + clipHistoryCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") + clipGetCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") + + clipSearchCmd.Flags().IntVarP(&clipSearchLimit, "limit", "l", 50, "Max results") + clipSearchCmd.Flags().IntVarP(&clipSearchOffset, "offset", "o", 0, "Result offset") + clipSearchCmd.Flags().StringVarP(&clipSearchMimeType, "mime", "m", "", "Filter by MIME type") + clipSearchCmd.Flags().BoolVar(&clipSearchImages, "images", false, "Only images") + clipSearchCmd.Flags().BoolVar(&clipSearchText, "text", false, "Only text") + clipSearchCmd.Flags().BoolVar(&clipJSONOutput, "json", false, "Output as JSON") + + clipConfigSetCmd.Flags().IntVar(&clipConfigMaxHistory, "max-history", 0, "Max history entries") + clipConfigSetCmd.Flags().IntVar(&clipConfigAutoClearDays, "auto-clear-days", -1, "Auto-clear entries older than N days (0 to disable)") + clipConfigSetCmd.Flags().BoolVar(&clipConfigClearAtStartup, "clear-at-startup", false, "Clear history on startup") + clipConfigSetCmd.Flags().BoolVar(&clipConfigNoClearStartup, "no-clear-at-startup", false, "Don't clear history on startup") + clipConfigSetCmd.Flags().BoolVar(&clipConfigDisabled, "disable", false, "Disable clipboard manager entirely") + clipConfigSetCmd.Flags().BoolVar(&clipConfigEnabled, "enable", false, "Enable clipboard manager") + clipConfigSetCmd.Flags().BoolVar(&clipConfigDisableHistory, "disable-history", false, "Disable clipboard history persistence") + clipConfigSetCmd.Flags().BoolVar(&clipConfigEnableHistory, "enable-history", false, "Enable clipboard history persistence") + clipConfigSetCmd.Flags().BoolVar(&clipConfigDisablePersist, "disable-persist", false, "Disable clipboard ownership persistence") + clipConfigSetCmd.Flags().BoolVar(&clipConfigEnablePersist, "enable-persist", false, "Enable clipboard ownership persistence") + + clipWatchCmd.Flags().BoolVarP(&clipWatchStore, "store", "s", false, "Store clipboard changes to history (no server required)") + + clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd) + clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd) +} + +func runClipCopy(cmd *cobra.Command, args []string) { + var data []byte + + if len(args) > 0 { + data = []byte(args[0]) + } else { + var err error + data, err = io.ReadAll(os.Stdin) + if err != nil { + log.Fatalf("read stdin: %v", err) + } + } + + if err := clipboard.CopyOpts(data, clipCopyType, clipCopyForeground, clipCopyPasteOnce); err != nil { + log.Fatalf("copy: %v", err) + } +} + +func runClipPaste(cmd *cobra.Command, args []string) { + data, _, err := clipboard.Paste() + if err != nil { + log.Fatalf("paste: %v", err) + } + os.Stdout.Write(data) +} + +func runClipWatch(cmd *cobra.Command, args []string) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + cancel() + }() + + switch { + case len(args) > 0: + if err := clipboard.Watch(ctx, func(data []byte, mimeType string) { + runCommand(args, data) + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + case clipWatchStore: + if err := clipboard.Watch(ctx, func(data []byte, mimeType string) { + if err := clipboard.Store(data, mimeType); err != nil { + log.Errorf("store: %v", err) + } + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + case clipJSONOutput: + if err := clipboard.Watch(ctx, func(data []byte, mimeType string) { + out := map[string]any{ + "data": string(data), + "mimeType": mimeType, + "timestamp": time.Now().Format(time.RFC3339), + "size": len(data), + } + j, _ := json.Marshal(out) + fmt.Println(string(j)) + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + default: + if err := clipboard.Watch(ctx, func(data []byte, mimeType string) { + os.Stdout.Write(data) + os.Stdout.WriteString("\n") + }); err != nil && err != context.Canceled { + log.Fatalf("Watch error: %v", err) + } + } +} + +func runCommand(args []string, stdin []byte) { + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if len(stdin) == 0 { + cmd.Run() + return + } + + r, w, err := os.Pipe() + if err != nil { + cmd.Run() + return + } + + cmd.Stdin = r + go func() { + w.Write(stdin) + w.Close() + }() + cmd.Run() +} + +func runClipHistory(cmd *cobra.Command, args []string) { + req := map[string]any{ + "id": 1, + "method": "clipboard.getHistory", + "params": map[string]any{}, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to get clipboard history: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + if resp.Result == nil { + if clipJSONOutput { + fmt.Println("[]") + } else { + fmt.Println("No clipboard history") + } + return + } + + historyList, ok := resp.Result.([]any) + if !ok { + log.Fatal("Invalid response format") + } + + if clipJSONOutput { + out, _ := json.MarshalIndent(historyList, "", " ") + fmt.Println(string(out)) + return + } + + if len(historyList) == 0 { + fmt.Println("No clipboard history") + return + } + + fmt.Println("Clipboard History:") + fmt.Println() + + for _, item := range historyList { + entry, ok := item.(map[string]any) + if !ok { + continue + } + + id := uint64(entry["id"].(float64)) + preview := entry["preview"].(string) + timestamp := entry["timestamp"].(string) + isImage := entry["isImage"].(bool) + + typeStr := "text" + if isImage { + typeStr = "image" + } + + fmt.Printf("ID: %d | %s | %s\n", id, typeStr, timestamp) + fmt.Printf(" %s\n", preview) + fmt.Println() + } +} + +func runClipGet(cmd *cobra.Command, args []string) { + id, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + log.Fatalf("Invalid ID: %v", err) + } + + req := map[string]any{ + "id": 1, + "method": "clipboard.getEntry", + "params": map[string]any{ + "id": id, + }, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to get clipboard entry: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + if resp.Result == nil { + log.Fatal("Entry not found") + } + + entry, ok := resp.Result.(map[string]any) + if !ok { + log.Fatal("Invalid response format") + } + + if clipJSONOutput { + output, _ := json.MarshalIndent(entry, "", " ") + fmt.Println(string(output)) + return + } + + if data, ok := entry["data"].(string); ok { + fmt.Print(data) + } else { + output, _ := json.MarshalIndent(entry, "", " ") + fmt.Println(string(output)) + } +} + +func runClipDelete(cmd *cobra.Command, args []string) { + id, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + log.Fatalf("Invalid ID: %v", err) + } + + req := map[string]any{ + "id": 1, + "method": "clipboard.deleteEntry", + "params": map[string]any{ + "id": id, + }, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to delete clipboard entry: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + fmt.Printf("Deleted entry %d\n", id) +} + +func runClipClear(cmd *cobra.Command, args []string) { + req := map[string]any{ + "id": 1, + "method": "clipboard.clearHistory", + "params": map[string]any{}, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to clear clipboard history: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + fmt.Println("Clipboard history cleared") +} + +func runClipSearch(cmd *cobra.Command, args []string) { + params := map[string]any{ + "limit": clipSearchLimit, + "offset": clipSearchOffset, + } + + if len(args) > 0 { + params["query"] = args[0] + } + if clipSearchMimeType != "" { + params["mimeType"] = clipSearchMimeType + } + if clipSearchImages { + params["isImage"] = true + } else if clipSearchText { + params["isImage"] = false + } + + req := map[string]any{ + "id": 1, + "method": "clipboard.search", + "params": params, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to search clipboard: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + result, ok := resp.Result.(map[string]any) + if !ok { + log.Fatal("Invalid response format") + } + + if clipJSONOutput { + out, _ := json.MarshalIndent(result, "", " ") + fmt.Println(string(out)) + return + } + + entries, _ := result["entries"].([]any) + total := int(result["total"].(float64)) + hasMore := result["hasMore"].(bool) + + if len(entries) == 0 { + fmt.Println("No results found") + return + } + + fmt.Printf("Results: %d of %d\n\n", len(entries), total) + + for _, item := range entries { + entry, ok := item.(map[string]any) + if !ok { + continue + } + + id := uint64(entry["id"].(float64)) + preview := entry["preview"].(string) + timestamp := entry["timestamp"].(string) + isImage := entry["isImage"].(bool) + + typeStr := "text" + if isImage { + typeStr = "image" + } + + fmt.Printf("ID: %d | %s | %s\n", id, typeStr, timestamp) + fmt.Printf(" %s\n\n", preview) + } + + if hasMore { + fmt.Printf("Use --offset %d to see more results\n", clipSearchOffset+clipSearchLimit) + } +} + +func runClipConfigGet(cmd *cobra.Command, args []string) { + req := map[string]any{ + "id": 1, + "method": "clipboard.getConfig", + "params": map[string]any{}, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to get config: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + cfg, ok := resp.Result.(map[string]any) + if !ok { + log.Fatal("Invalid response format") + } + + output, _ := json.MarshalIndent(cfg, "", " ") + fmt.Println(string(output)) +} + +func runClipConfigSet(cmd *cobra.Command, args []string) { + params := map[string]any{} + + if cmd.Flags().Changed("max-history") { + params["maxHistory"] = clipConfigMaxHistory + } + if cmd.Flags().Changed("auto-clear-days") { + params["autoClearDays"] = clipConfigAutoClearDays + } + if clipConfigClearAtStartup { + params["clearAtStartup"] = true + } + if clipConfigNoClearStartup { + params["clearAtStartup"] = false + } + if clipConfigDisabled { + params["disabled"] = true + } + if clipConfigEnabled { + params["disabled"] = false + } + if clipConfigDisableHistory { + params["disableHistory"] = true + } + if clipConfigEnableHistory { + params["disableHistory"] = false + } + if clipConfigDisablePersist { + params["disablePersist"] = true + } + if clipConfigEnablePersist { + params["disablePersist"] = false + } + + if len(params) == 0 { + fmt.Println("No config options specified") + return + } + + req := map[string]any{ + "id": 1, + "method": "clipboard.setConfig", + "params": params, + } + + resp, err := sendServerRequest(req) + if err != nil { + log.Fatalf("Failed to set config: %v", err) + } + + if resp.Error != "" { + log.Fatalf("Error: %s", resp.Error) + } + + fmt.Println("Config updated") +} diff --git a/core/cmd/dms/commands_common.go b/core/cmd/dms/commands_common.go index bb588b34..95ad59fd 100644 --- a/core/cmd/dms/commands_common.go +++ b/core/cmd/dms/commands_common.go @@ -513,5 +513,6 @@ func getCommonCommands() []*cobra.Command { screenshotCmd, notifyActionCmd, matugenCmd, + clipboardCmd, } } diff --git a/core/cmd/dms/server_client.go b/core/cmd/dms/server_client.go new file mode 100644 index 00000000..3a283750 --- /dev/null +++ b/core/cmd/dms/server_client.go @@ -0,0 +1,79 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server" +) + +type serverResponse struct { + ID int `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +func sendServerRequest(req map[string]any) (*serverResponse, error) { + socketPath := getServerSocketPath() + + conn, err := net.Dial("unix", socketPath) + if err != nil { + return nil, fmt.Errorf("failed to connect to server (is it running?): %w", err) + } + defer conn.Close() + + scanner := bufio.NewScanner(conn) + scanner.Scan() // discard initial capabilities message + + reqData, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + if _, err := conn.Write(reqData); err != nil { + return nil, fmt.Errorf("failed to write request: %w", err) + } + + if _, err := conn.Write([]byte("\n")); err != nil { + return nil, fmt.Errorf("failed to write newline: %w", err) + } + + if !scanner.Scan() { + return nil, fmt.Errorf("failed to read response") + } + + var resp serverResponse + if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &resp, nil +} + +func getServerSocketPath() string { + runtimeDir := os.Getenv("XDG_RUNTIME_DIR") + if runtimeDir == "" { + runtimeDir = os.TempDir() + } + + entries, err := os.ReadDir(runtimeDir) + if err != nil { + return filepath.Join(runtimeDir, "danklinux.sock") + } + + for _, entry := range entries { + name := entry.Name() + if name == "danklinux.sock" { + return filepath.Join(runtimeDir, name) + } + if len(name) > 10 && name[:10] == "danklinux-" && filepath.Ext(name) == ".sock" { + return filepath.Join(runtimeDir, name) + } + } + + return server.GetSocketPath() +} diff --git a/core/go.mod b/core/go.mod index f7416c4f..a33a2cf1 100644 --- a/core/go.mod +++ b/core/go.mod @@ -15,7 +15,9 @@ require ( github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 + go.etcd.io/bbolt v1.4.3 golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 + golang.org/x/image v0.34.0 ) require ( @@ -65,6 +67,6 @@ require ( github.com/spf13/pflag v1.0.10 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.38.0 - golang.org/x/text v0.31.0 + golang.org/x/text v0.32.0 gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/core/go.sum b/core/go.sum index a7b03664..55b2e392 100644 --- a/core/go.sum +++ b/core/go.sum @@ -131,20 +131,26 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= +golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= +golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/core/internal/clipboard/clipboard.go b/core/internal/clipboard/clipboard.go new file mode 100644 index 00000000..cce95e17 --- /dev/null +++ b/core/internal/clipboard/clipboard.go @@ -0,0 +1,332 @@ +package clipboard + +import ( + "fmt" + "io" + "os" + "os/exec" + "syscall" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +func Copy(data []byte, mimeType string) error { + return CopyOpts(data, mimeType, false, false) +} + +func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error { + if !foreground { + return copyFork(data, mimeType, pasteOnce) + } + return copyServe(data, mimeType, pasteOnce) +} + +func copyFork(data []byte, mimeType string, pasteOnce bool) error { + args := []string{os.Args[0], "cl", "copy", "--foreground"} + if pasteOnce { + args = append(args, "--paste-once") + } + args = append(args, "--type", mimeType) + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("stdin pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start: %w", err) + } + + if _, err := stdin.Write(data); err != nil { + stdin.Close() + return fmt.Errorf("write stdin: %w", err) + } + stdin.Close() + + return nil +} + +func copyServe(data []byte, mimeType string, pasteOnce bool) error { + display, err := wlclient.Connect("") + if err != nil { + return fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + ctx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return fmt.Errorf("registry bind: %w", bindErr) + } + + if dataControlMgr == nil { + return fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + + if seat == nil { + return fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + source, err := dataControlMgr.CreateDataSource() + if err != nil { + return fmt.Errorf("create data source: %w", err) + } + + if err := source.Offer(mimeType); err != nil { + return fmt.Errorf("offer mime type: %w", err) + } + if mimeType == "text/plain;charset=utf-8" || mimeType == "text/plain" { + if err := source.Offer("text/plain"); err != nil { + return fmt.Errorf("offer text/plain: %w", err) + } + if err := source.Offer("text/plain;charset=utf-8"); err != nil { + return fmt.Errorf("offer text/plain;charset=utf-8: %w", err) + } + if err := source.Offer("UTF8_STRING"); err != nil { + return fmt.Errorf("offer UTF8_STRING: %w", err) + } + if err := source.Offer("STRING"); err != nil { + return fmt.Errorf("offer STRING: %w", err) + } + if err := source.Offer("TEXT"); err != nil { + return fmt.Errorf("offer TEXT: %w", err) + } + } + + cancelled := make(chan struct{}) + pasted := make(chan struct{}, 1) + + source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { + defer syscall.Close(e.Fd) + file := os.NewFile(uintptr(e.Fd), "pipe") + defer file.Close() + file.Write(data) + select { + case pasted <- struct{}{}: + default: + } + }) + + source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) { + close(cancelled) + }) + + if err := device.SetSelection(source); err != nil { + return fmt.Errorf("set selection: %w", err) + } + + display.Roundtrip() + + for { + select { + case <-cancelled: + return nil + case <-pasted: + if pasteOnce { + return nil + } + default: + if err := ctx.Dispatch(); err != nil { + return nil + } + } + } +} + +func CopyText(text string) error { + return Copy([]byte(text), "text/plain;charset=utf-8") +} + +func Paste() ([]byte, string, error) { + display, err := wlclient.Connect("") + if err != nil { + return nil, "", fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + ctx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return nil, "", fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return nil, "", fmt.Errorf("registry bind: %w", bindErr) + } + + if dataControlMgr == nil { + return nil, "", fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + + if seat == nil { + return nil, "", fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return nil, "", fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string) + + device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { + if e.Id == nil { + return + } + offerMimeTypes[e.Id] = nil + e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) { + offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType) + }) + }) + + var selectionOffer *ext_data_control.ExtDataControlOfferV1 + gotSelection := false + + device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { + selectionOffer = e.Id + gotSelection = true + }) + + display.Roundtrip() + display.Roundtrip() + + if !gotSelection || selectionOffer == nil { + return nil, "", fmt.Errorf("no clipboard data") + } + + mimeTypes := offerMimeTypes[selectionOffer] + selectedMime := selectPreferredMimeType(mimeTypes) + if selectedMime == "" { + return nil, "", fmt.Errorf("no supported mime type") + } + + r, w, err := os.Pipe() + if err != nil { + return nil, "", fmt.Errorf("create pipe: %w", err) + } + defer r.Close() + + if err := selectionOffer.Receive(selectedMime, int(w.Fd())); err != nil { + w.Close() + return nil, "", fmt.Errorf("receive: %w", err) + } + w.Close() + + display.Roundtrip() + + data, err := io.ReadAll(r) + if err != nil { + return nil, "", fmt.Errorf("read: %w", err) + } + + return data, selectedMime, nil +} + +func PasteText() (string, error) { + data, _, err := Paste() + if err != nil { + return "", err + } + return string(data), nil +} + +func selectPreferredMimeType(mimes []string) string { + preferred := []string{ + "text/plain;charset=utf-8", + "text/plain", + "UTF8_STRING", + "STRING", + "TEXT", + "image/png", + "image/jpeg", + } + + for _, pref := range preferred { + for _, mime := range mimes { + if mime == pref { + return mime + } + } + } + + if len(mimes) > 0 { + return mimes[0] + } + return "" +} + +func IsImageMimeType(mime string) bool { + return len(mime) > 6 && mime[:6] == "image/" +} diff --git a/core/internal/clipboard/store.go b/core/internal/clipboard/store.go new file mode 100644 index 00000000..91dc1521 --- /dev/null +++ b/core/internal/clipboard/store.go @@ -0,0 +1,253 @@ +package clipboard + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "os" + "path/filepath" + "strings" + "time" + + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + + bolt "go.etcd.io/bbolt" +) + +type StoreConfig struct { + MaxHistory int + MaxEntrySize int64 +} + +func DefaultStoreConfig() StoreConfig { + return StoreConfig{ + MaxHistory: 100, + MaxEntrySize: 5 * 1024 * 1024, + } +} + +type Entry struct { + ID uint64 + Data []byte + MimeType string + Preview string + Size int + Timestamp time.Time + IsImage bool +} + +func Store(data []byte, mimeType string) error { + return StoreWithConfig(data, mimeType, DefaultStoreConfig()) +} + +func StoreWithConfig(data []byte, mimeType string, cfg StoreConfig) error { + if len(data) == 0 { + return nil + } + if int64(len(data)) > cfg.MaxEntrySize { + return fmt.Errorf("data too large: %d > %d", len(data), cfg.MaxEntrySize) + } + + dbPath, err := getDBPath() + if err != nil { + return fmt.Errorf("get db path: %w", err) + } + + db, err := bolt.Open(dbPath, 0644, &bolt.Options{Timeout: 1 * time.Second}) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer db.Close() + + entry := Entry{ + Data: data, + MimeType: mimeType, + Size: len(data), + Timestamp: time.Now(), + IsImage: IsImageMimeType(mimeType), + } + + switch { + case entry.IsImage: + entry.Preview = imagePreview(data, mimeType) + default: + entry.Preview = textPreview(data) + } + + return db.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte("clipboard")) + if err != nil { + return err + } + + if err := deduplicateInTx(b, data); err != nil { + return err + } + + id, err := b.NextSequence() + if err != nil { + return err + } + entry.ID = id + + encoded, err := encodeEntry(entry) + if err != nil { + return err + } + + if err := b.Put(itob(id), encoded); err != nil { + return err + } + + return trimLengthInTx(b, cfg.MaxHistory) + }) +} + +func getDBPath() (string, error) { + cacheDir := os.Getenv("XDG_CACHE_HOME") + if cacheDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + cacheDir = filepath.Join(homeDir, ".cache") + } + + dbDir := filepath.Join(cacheDir, "dms-clipboard") + if err := os.MkdirAll(dbDir, 0700); err != nil { + return "", err + } + + return filepath.Join(dbDir, "db"), nil +} + +func deduplicateInTx(b *bolt.Bucket, data []byte) error { + c := b.Cursor() + for k, v := c.Last(); k != nil; k, v = c.Prev() { + entry, err := decodeEntry(v) + if err != nil { + continue + } + if bytes.Equal(entry.Data, data) { + if err := b.Delete(k); err != nil { + return err + } + } + } + return nil +} + +func trimLengthInTx(b *bolt.Bucket, maxHistory int) error { + c := b.Cursor() + var count int + for k, _ := c.Last(); k != nil; k, _ = c.Prev() { + if count < maxHistory { + count++ + continue + } + if err := b.Delete(k); err != nil { + return err + } + } + return nil +} + +func encodeEntry(e Entry) ([]byte, error) { + buf := new(bytes.Buffer) + + binary.Write(buf, binary.BigEndian, e.ID) + binary.Write(buf, binary.BigEndian, uint32(len(e.Data))) + buf.Write(e.Data) + binary.Write(buf, binary.BigEndian, uint32(len(e.MimeType))) + buf.WriteString(e.MimeType) + binary.Write(buf, binary.BigEndian, uint32(len(e.Preview))) + buf.WriteString(e.Preview) + binary.Write(buf, binary.BigEndian, int32(e.Size)) + binary.Write(buf, binary.BigEndian, e.Timestamp.Unix()) + if e.IsImage { + buf.WriteByte(1) + } else { + buf.WriteByte(0) + } + + return buf.Bytes(), nil +} + +func decodeEntry(data []byte) (Entry, error) { + buf := bytes.NewReader(data) + var e Entry + + binary.Read(buf, binary.BigEndian, &e.ID) + + var dataLen uint32 + binary.Read(buf, binary.BigEndian, &dataLen) + e.Data = make([]byte, dataLen) + buf.Read(e.Data) + + var mimeLen uint32 + binary.Read(buf, binary.BigEndian, &mimeLen) + mimeBytes := make([]byte, mimeLen) + buf.Read(mimeBytes) + e.MimeType = string(mimeBytes) + + var prevLen uint32 + binary.Read(buf, binary.BigEndian, &prevLen) + prevBytes := make([]byte, prevLen) + buf.Read(prevBytes) + e.Preview = string(prevBytes) + + var size int32 + binary.Read(buf, binary.BigEndian, &size) + e.Size = int(size) + + var timestamp int64 + binary.Read(buf, binary.BigEndian, ×tamp) + e.Timestamp = time.Unix(timestamp, 0) + + var isImage byte + binary.Read(buf, binary.BigEndian, &isImage) + e.IsImage = isImage == 1 + + return e, nil +} + +func itob(v uint64) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, v) + return b +} + +func textPreview(data []byte) string { + text := string(data) + text = strings.TrimSpace(text) + text = strings.Join(strings.Fields(text), " ") + + if len(text) > 100 { + return text[:100] + "…" + } + return text +} + +func imagePreview(data []byte, format string) string { + config, imgFmt, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return fmt.Sprintf("[[ image %s %s ]]", sizeStr(len(data)), format) + } + return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height) +} + +func sizeStr(size int) string { + units := []string{"B", "KiB", "MiB"} + var i int + fsize := float64(size) + for fsize >= 1024 && i < len(units)-1 { + fsize /= 1024 + i++ + } + return fmt.Sprintf("%.0f %s", fsize, units[i]) +} diff --git a/core/internal/clipboard/watch.go b/core/internal/clipboard/watch.go new file mode 100644 index 00000000..f905d7af --- /dev/null +++ b/core/internal/clipboard/watch.go @@ -0,0 +1,160 @@ +package clipboard + +import ( + "context" + "fmt" + "io" + "os" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type ClipboardChange struct { + Data []byte + MimeType string +} + +func Watch(ctx context.Context, callback func(data []byte, mimeType string)) error { + display, err := wlclient.Connect("") + if err != nil { + return fmt.Errorf("wayland connect: %w", err) + } + defer display.Destroy() + + wlCtx := display.Context() + registry, err := display.GetRegistry() + if err != nil { + return fmt.Errorf("get registry: %w", err) + } + defer registry.Destroy() + + var dataControlMgr *ext_data_control.ExtDataControlManagerV1 + var seat *wlclient.Seat + var bindErr error + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + dataControlMgr = ext_data_control.NewExtDataControlManagerV1(wlCtx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + bindErr = err + } + case "wl_seat": + if seat != nil { + return + } + seat = wlclient.NewSeat(wlCtx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + bindErr = err + } + } + }) + + display.Roundtrip() + display.Roundtrip() + + if bindErr != nil { + return fmt.Errorf("registry bind: %w", bindErr) + } + + if dataControlMgr == nil { + return fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + defer dataControlMgr.Destroy() + + if seat == nil { + return fmt.Errorf("no seat available") + } + + device, err := dataControlMgr.GetDataDevice(seat) + if err != nil { + return fmt.Errorf("get data device: %w", err) + } + defer device.Destroy() + + offerMimeTypes := make(map[*ext_data_control.ExtDataControlOfferV1][]string) + + device.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { + if e.Id == nil { + return + } + offerMimeTypes[e.Id] = nil + e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) { + offerMimeTypes[e.Id] = append(offerMimeTypes[e.Id], me.MimeType) + }) + }) + + device.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { + if e.Id == nil { + return + } + + mimes := offerMimeTypes[e.Id] + selectedMime := selectPreferredMimeType(mimes) + if selectedMime == "" { + return + } + + r, w, err := os.Pipe() + if err != nil { + return + } + + if err := e.Id.Receive(selectedMime, int(w.Fd())); err != nil { + w.Close() + r.Close() + return + } + w.Close() + + go func() { + defer r.Close() + data, err := io.ReadAll(r) + if err != nil || len(data) == 0 { + return + } + callback(data, selectedMime) + }() + }) + + display.Roundtrip() + display.Roundtrip() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + if err := wlCtx.SetReadDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + return fmt.Errorf("set read deadline: %w", err) + } + if err := wlCtx.Dispatch(); err != nil && err != os.ErrDeadlineExceeded { + return fmt.Errorf("dispatch: %w", err) + } + } + } +} + +func WatchChan(ctx context.Context) (<-chan ClipboardChange, <-chan error) { + ch := make(chan ClipboardChange, 16) + errCh := make(chan error, 1) + + go func() { + defer close(ch) + err := Watch(ctx, func(data []byte, mimeType string) { + select { + case ch <- ClipboardChange{Data: data, MimeType: mimeType}: + default: + } + }) + if err != nil && err != context.Canceled { + errCh <- err + } + close(errCh) + }() + + time.Sleep(50 * time.Millisecond) + return ch, errCh +} diff --git a/core/internal/config/deployer.go b/core/internal/config/deployer.go index 56946055..4b0eaa16 100644 --- a/core/internal/config/deployer.go +++ b/core/internal/config/deployer.go @@ -615,10 +615,11 @@ func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalComma spawnDms := `spawn-at-startup "dms" "run"` if !strings.Contains(config, spawnDms) { - config = strings.Replace(config, - `spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"`, - `spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"`+"\n"+spawnDms, - 1) + // Insert spawn-at-startup for dms after the environment block + envBlockEnd := regexp.MustCompile(`environment \{[^}]*\}`) + if loc := envBlockEnd.FindStringIndex(config); loc != nil { + config = config[:loc[1]] + "\n" + spawnDms + config[loc[1]:] + } } return config diff --git a/core/internal/config/embedded/hyprland.conf b/core/internal/config/embedded/hyprland.conf index fa1a56fb..b14a335d 100644 --- a/core/internal/config/embedded/hyprland.conf +++ b/core/internal/config/embedded/hyprland.conf @@ -12,7 +12,6 @@ monitor = , preferred,auto,auto # ================== exec-once = dbus-update-activation-environment --systemd --all exec-once = systemctl --user start hyprland-session.target -exec-once = bash -c "wl-paste --watch cliphist store &" # ================== # INPUT CONFIG diff --git a/core/internal/config/embedded/niri.kdl b/core/internal/config/embedded/niri.kdl index e4c711c9..45512f8d 100644 --- a/core/internal/config/embedded/niri.kdl +++ b/core/internal/config/embedded/niri.kdl @@ -109,7 +109,6 @@ overview { // which may be more convenient to use. // See the binds section below for more spawn examples. // This line starts waybar, a commonly used bar for Wayland compositors. -spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &" environment { XDG_CURRENT_DESKTOP "niri" } diff --git a/core/internal/distros/arch.go b/core/internal/distros/arch.go index 0f8613ae..dde0b9cd 100644 --- a/core/internal/distros/arch.go +++ b/core/internal/distros/arch.go @@ -139,7 +139,6 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager, "ghostty": {Name: "ghostty", Repository: RepoTypeSystem}, "kitty": {Name: "kitty", Repository: RepoTypeSystem}, "alacritty": {Name: "alacritty", Repository: RepoTypeSystem}, - "cliphist": {Name: "cliphist", Repository: RepoTypeSystem}, "wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem}, "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, "accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem}, diff --git a/core/internal/distros/base.go b/core/internal/distros/base.go index 80afdb50..bcaa1a8a 100644 --- a/core/internal/distros/base.go +++ b/core/internal/distros/base.go @@ -188,23 +188,12 @@ func (b *BaseDistribution) detectSpecificTerminal(terminal deps.Terminal) deps.D func (b *BaseDistribution) detectClipboardTools() []deps.Dependency { var dependencies []deps.Dependency - cliphist := deps.StatusMissing - if b.commandExists("cliphist") { - cliphist = deps.StatusInstalled - } - wlClipboard := deps.StatusMissing if b.commandExists("wl-copy") && b.commandExists("wl-paste") { wlClipboard = deps.StatusInstalled } dependencies = append(dependencies, - deps.Dependency{ - Name: "cliphist", - Status: cliphist, - Description: "Wayland clipboard manager", - Required: true, - }, deps.Dependency{ Name: "wl-clipboard", Status: wlClipboard, diff --git a/core/internal/distros/debian.go b/core/internal/distros/debian.go index aac9774c..7bbde1e5 100644 --- a/core/internal/distros/debian.go +++ b/core/internal/distros/debian.go @@ -111,7 +111,6 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager "quickshell": d.getQuickshellMapping(variants["quickshell"]), "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, - "cliphist": {Name: "cliphist", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, } @@ -549,7 +548,7 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua if err := d.installRust(ctx, sudoPassword, progressChan); err != nil { return fmt.Errorf("failed to install Rust: %w", err) } - case "cliphist", "dgop": + case "dgop": if err := d.installGo(ctx, sudoPassword, progressChan); err != nil { return fmt.Errorf("failed to install Go: %w", err) } diff --git a/core/internal/distros/fedora.go b/core/internal/distros/fedora.go index efdb4ccf..f73d4dad 100644 --- a/core/internal/distros/fedora.go +++ b/core/internal/distros/fedora.go @@ -124,7 +124,6 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager // COPR packages "quickshell": f.getQuickshellMapping(variants["quickshell"]), "matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, - "cliphist": {Name: "cliphist", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, "dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]), "dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, } diff --git a/core/internal/distros/gentoo.go b/core/internal/distros/gentoo.go index f5b52925..93f430ea 100644 --- a/core/internal/distros/gentoo.go +++ b/core/internal/distros/gentoo.go @@ -151,7 +151,6 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager "quickshell": g.getQuickshellMapping(variants["quickshell"]), "matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}, - "cliphist": {Name: "app-misc/cliphist", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}, "dms (DankMaterialShell)": g.getDmsMapping(variants["dms (DankMaterialShell)"]), "dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"}, } diff --git a/core/internal/distros/manual_packages.go b/core/internal/distros/manual_packages.go index 52d40382..5c032c36 100644 --- a/core/internal/distros/manual_packages.go +++ b/core/internal/distros/manual_packages.go @@ -86,10 +86,6 @@ func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, pack if err := m.installMatugen(ctx, sudoPassword, progressChan); err != nil { return fmt.Errorf("failed to install matugen: %w", err) } - case "cliphist": - if err := m.installCliphist(ctx, sudoPassword, progressChan); err != nil { - return fmt.Errorf("failed to install cliphist: %w", err) - } case "xwayland-satellite": if err := m.installXwaylandSatellite(ctx, sudoPassword, progressChan); err != nil { return fmt.Errorf("failed to install xwayland-satellite: %w", err) @@ -803,52 +799,6 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v return nil } -func (m *ManualPackageInstaller) installCliphist(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { - m.log("Installing cliphist from source...") - - progressChan <- InstallProgressMsg{ - Phase: PhaseSystemPackages, - Progress: 0.1, - Step: "Installing cliphist via go install...", - IsComplete: false, - CommandInfo: "go install go.senan.xyz/cliphist@latest", - } - - installCmd := exec.CommandContext(ctx, "go", "install", "go.senan.xyz/cliphist@latest") - if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building cliphist..."); err != nil { - return fmt.Errorf("failed to install cliphist: %w", err) - } - - homeDir := os.Getenv("HOME") - sourcePath := filepath.Join(homeDir, "go", "bin", "cliphist") - targetPath := "/usr/local/bin/cliphist" - - progressChan <- InstallProgressMsg{ - Phase: PhaseSystemPackages, - Progress: 0.7, - Step: "Installing cliphist binary to system...", - IsComplete: false, - NeedsSudo: true, - CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath), - } - - copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath) - copyCmd.Stdin = strings.NewReader(sudoPassword + "\n") - if err := copyCmd.Run(); err != nil { - return fmt.Errorf("failed to copy cliphist to /usr/local/bin: %w", err) - } - - // Make it executable - chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath) - chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n") - if err := chmodCmd.Run(); err != nil { - return fmt.Errorf("failed to make cliphist executable: %w", err) - } - - m.log("cliphist installed successfully from source") - return nil -} - func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error { m.log("Installing xwayland-satellite from source...") diff --git a/core/internal/distros/opensuse.go b/core/internal/distros/opensuse.go index 4b720724..b3d3dda6 100644 --- a/core/internal/distros/opensuse.go +++ b/core/internal/distros/opensuse.go @@ -110,7 +110,6 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag "wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem}, "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, "accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem}, - "cliphist": {Name: "cliphist", Repository: RepoTypeSystem}, // DMS packages from OBS "dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]), diff --git a/core/internal/distros/ubuntu.go b/core/internal/distros/ubuntu.go index 34148ca0..68223bbd 100644 --- a/core/internal/distros/ubuntu.go +++ b/core/internal/distros/ubuntu.go @@ -121,7 +121,6 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager "quickshell": u.getQuickshellMapping(variants["quickshell"]), "matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, "dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, - "cliphist": {Name: "cliphist", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, "ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, } @@ -539,8 +538,6 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua buildDeps["libpam0g-dev"] = true case "matugen": buildDeps["curl"] = true - case "cliphist": - // Go will be installed separately with PPA } } @@ -550,7 +547,7 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua if err := u.installRust(ctx, sudoPassword, progressChan); err != nil { return fmt.Errorf("failed to install Rust: %w", err) } - case "cliphist", "dgop": + case "dgop": if err := u.installGo(ctx, sudoPassword, progressChan); err != nil { return fmt.Errorf("failed to install Go: %w", err) } diff --git a/core/internal/proto/ext_data_control/data_control.go b/core/internal/proto/ext_data_control/data_control.go new file mode 100644 index 00000000..2200402b --- /dev/null +++ b/core/internal/proto/ext_data_control/data_control.go @@ -0,0 +1,387 @@ +package ext_data_control + +import ( + "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" + "golang.org/x/sys/unix" +) + +const ExtDataControlManagerV1InterfaceName = "ext_data_control_manager_v1" + +type ExtDataControlManagerV1 struct { + client.BaseProxy +} + +func NewExtDataControlManagerV1(ctx *client.Context) *ExtDataControlManagerV1 { + m := &ExtDataControlManagerV1{} + ctx.Register(m) + return m +} + +func (m *ExtDataControlManagerV1) CreateDataSource() (*ExtDataControlSourceV1, error) { + id := NewExtDataControlSourceV1(m.Context()) + const opcode = 0 + const reqBufLen = 8 + 4 + var reqBuf [reqBufLen]byte + l := 0 + client.PutUint32(reqBuf[l:4], m.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], uint32(reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(reqBuf[l:l+4], id.ID()) + l += 4 + err := m.Context().WriteMsg(reqBuf[:], nil) + return id, err +} + +func (m *ExtDataControlManagerV1) GetDataDevice(seat *client.Seat) (*ExtDataControlDeviceV1, error) { + id := NewExtDataControlDeviceV1(m.Context()) + const opcode = 1 + const reqBufLen = 8 + 4 + 4 + var reqBuf [reqBufLen]byte + l := 0 + client.PutUint32(reqBuf[l:4], m.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], uint32(reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(reqBuf[l:l+4], id.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], seat.ID()) + l += 4 + err := m.Context().WriteMsg(reqBuf[:], nil) + return id, err +} + +func (m *ExtDataControlManagerV1) GetDataDeviceWithProxy(device *ExtDataControlDeviceV1, seat *client.Seat) error { + const opcode = 1 + const reqBufLen = 8 + 4 + 4 + var reqBuf [reqBufLen]byte + l := 0 + client.PutUint32(reqBuf[l:4], m.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], uint32(reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutUint32(reqBuf[l:l+4], device.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], seat.ID()) + l += 4 + return m.Context().WriteMsg(reqBuf[:], nil) +} + +func (m *ExtDataControlManagerV1) Destroy() error { + defer m.MarkZombie() + const opcode = 2 + const reqBufLen = 8 + var reqBuf [reqBufLen]byte + l := 0 + client.PutUint32(reqBuf[l:4], m.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], uint32(reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + return m.Context().WriteMsg(reqBuf[:], nil) +} + +const ExtDataControlDeviceV1InterfaceName = "ext_data_control_device_v1" + +type ExtDataControlDeviceV1 struct { + client.BaseProxy + dataOfferHandler ExtDataControlDeviceV1DataOfferHandlerFunc + selectionHandler ExtDataControlDeviceV1SelectionHandlerFunc + finishedHandler ExtDataControlDeviceV1FinishedHandlerFunc + primarySelectionHandler ExtDataControlDeviceV1PrimarySelectionHandlerFunc +} + +func NewExtDataControlDeviceV1(ctx *client.Context) *ExtDataControlDeviceV1 { + d := &ExtDataControlDeviceV1{} + ctx.Register(d) + return d +} + +func (d *ExtDataControlDeviceV1) SetSelection(source *ExtDataControlSourceV1) error { + const opcode = 0 + const reqBufLen = 8 + 4 + var reqBuf [reqBufLen]byte + l := 0 + client.PutUint32(reqBuf[l:4], d.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], uint32(reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + if source == nil { + client.PutUint32(reqBuf[l:l+4], 0) + } else { + client.PutUint32(reqBuf[l:l+4], source.ID()) + } + l += 4 + return d.Context().WriteMsg(reqBuf[:], nil) +} + +func (d *ExtDataControlDeviceV1) Destroy() error { + defer d.MarkZombie() + const opcode = 1 + const reqBufLen = 8 + var reqBuf [reqBufLen]byte + l := 0 + client.PutUint32(reqBuf[l:4], d.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], uint32(reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + return d.Context().WriteMsg(reqBuf[:], nil) +} + +func (d *ExtDataControlDeviceV1) SetPrimarySelection(source *ExtDataControlSourceV1) error { + const opcode = 2 + const reqBufLen = 8 + 4 + var reqBuf [reqBufLen]byte + l := 0 + client.PutUint32(reqBuf[l:4], d.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], uint32(reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + if source == nil { + client.PutUint32(reqBuf[l:l+4], 0) + } else { + client.PutUint32(reqBuf[l:l+4], source.ID()) + } + l += 4 + return d.Context().WriteMsg(reqBuf[:], nil) +} + +type ExtDataControlDeviceV1DataOfferEvent struct { + Id *ExtDataControlOfferV1 +} +type ExtDataControlDeviceV1DataOfferHandlerFunc func(ExtDataControlDeviceV1DataOfferEvent) + +func (d *ExtDataControlDeviceV1) SetDataOfferHandler(f ExtDataControlDeviceV1DataOfferHandlerFunc) { + d.dataOfferHandler = f +} + +type ExtDataControlDeviceV1SelectionEvent struct { + Id *ExtDataControlOfferV1 + OfferId uint32 +} +type ExtDataControlDeviceV1SelectionHandlerFunc func(ExtDataControlDeviceV1SelectionEvent) + +func (d *ExtDataControlDeviceV1) SetSelectionHandler(f ExtDataControlDeviceV1SelectionHandlerFunc) { + d.selectionHandler = f +} + +type ExtDataControlDeviceV1FinishedEvent struct{} +type ExtDataControlDeviceV1FinishedHandlerFunc func(ExtDataControlDeviceV1FinishedEvent) + +func (d *ExtDataControlDeviceV1) SetFinishedHandler(f ExtDataControlDeviceV1FinishedHandlerFunc) { + d.finishedHandler = f +} + +type ExtDataControlDeviceV1PrimarySelectionEvent struct { + Id *ExtDataControlOfferV1 + OfferId uint32 +} +type ExtDataControlDeviceV1PrimarySelectionHandlerFunc func(ExtDataControlDeviceV1PrimarySelectionEvent) + +func (d *ExtDataControlDeviceV1) SetPrimarySelectionHandler(f ExtDataControlDeviceV1PrimarySelectionHandlerFunc) { + d.primarySelectionHandler = f +} + +func (d *ExtDataControlDeviceV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if d.dataOfferHandler == nil { + return + } + l := 0 + newID := client.Uint32(data[l : l+4]) + l += 4 + + ctx := d.Context() + offer := &ExtDataControlOfferV1{} + offer.SetContext(ctx) + offer.SetID(newID) + ctx.RegisterWithID(offer, newID) + + d.dataOfferHandler(ExtDataControlDeviceV1DataOfferEvent{Id: offer}) + case 1: + if d.selectionHandler == nil { + return + } + l := 0 + objID := client.Uint32(data[l : l+4]) + l += 4 + + var offer *ExtDataControlOfferV1 + if objID != 0 { + if p := d.Context().GetProxy(objID); p != nil { + offer = p.(*ExtDataControlOfferV1) + } + } + d.selectionHandler(ExtDataControlDeviceV1SelectionEvent{Id: offer, OfferId: objID}) + case 2: + if d.finishedHandler == nil { + return + } + d.finishedHandler(ExtDataControlDeviceV1FinishedEvent{}) + case 3: + if d.primarySelectionHandler == nil { + return + } + l := 0 + objID := client.Uint32(data[l : l+4]) + l += 4 + + var offer *ExtDataControlOfferV1 + if objID != 0 { + if p := d.Context().GetProxy(objID); p != nil { + offer = p.(*ExtDataControlOfferV1) + } + } + d.primarySelectionHandler(ExtDataControlDeviceV1PrimarySelectionEvent{Id: offer, OfferId: objID}) + } +} + +const ExtDataControlSourceV1InterfaceName = "ext_data_control_source_v1" + +type ExtDataControlSourceV1 struct { + client.BaseProxy + sendHandler ExtDataControlSourceV1SendHandlerFunc + cancelledHandler ExtDataControlSourceV1CancelledHandlerFunc +} + +func NewExtDataControlSourceV1(ctx *client.Context) *ExtDataControlSourceV1 { + s := &ExtDataControlSourceV1{} + ctx.Register(s) + return s +} + +func (s *ExtDataControlSourceV1) Offer(mimeType string) error { + const opcode = 0 + mimeTypeLen := client.PaddedLen(len(mimeType) + 1) + reqBufLen := 8 + (4 + mimeTypeLen) + reqBuf := make([]byte, reqBufLen) + l := 0 + client.PutUint32(reqBuf[l:4], s.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], uint32(reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutString(reqBuf[l:l+(4+mimeTypeLen)], mimeType) + l += (4 + mimeTypeLen) + return s.Context().WriteMsg(reqBuf, nil) +} + +func (s *ExtDataControlSourceV1) Destroy() error { + defer s.MarkZombie() + const opcode = 1 + const reqBufLen = 8 + var reqBuf [reqBufLen]byte + l := 0 + client.PutUint32(reqBuf[l:4], s.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], uint32(reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + return s.Context().WriteMsg(reqBuf[:], nil) +} + +type ExtDataControlSourceV1SendEvent struct { + MimeType string + Fd int +} +type ExtDataControlSourceV1SendHandlerFunc func(ExtDataControlSourceV1SendEvent) + +func (s *ExtDataControlSourceV1) SetSendHandler(f ExtDataControlSourceV1SendHandlerFunc) { + s.sendHandler = f +} + +type ExtDataControlSourceV1CancelledEvent struct{} +type ExtDataControlSourceV1CancelledHandlerFunc func(ExtDataControlSourceV1CancelledEvent) + +func (s *ExtDataControlSourceV1) SetCancelledHandler(f ExtDataControlSourceV1CancelledHandlerFunc) { + s.cancelledHandler = f +} + +func (s *ExtDataControlSourceV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if s.sendHandler == nil { + if fd != -1 { + unix.Close(fd) + } + return + } + l := 0 + mimeTypeLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + mimeType := client.String(data[l : l+mimeTypeLen]) + l += mimeTypeLen + + s.sendHandler(ExtDataControlSourceV1SendEvent{MimeType: mimeType, Fd: fd}) + case 1: + if s.cancelledHandler == nil { + return + } + s.cancelledHandler(ExtDataControlSourceV1CancelledEvent{}) + } +} + +const ExtDataControlOfferV1InterfaceName = "ext_data_control_offer_v1" + +type ExtDataControlOfferV1 struct { + client.BaseProxy + offerHandler ExtDataControlOfferV1OfferHandlerFunc +} + +func NewExtDataControlOfferV1(ctx *client.Context) *ExtDataControlOfferV1 { + o := &ExtDataControlOfferV1{} + ctx.Register(o) + return o +} + +func (o *ExtDataControlOfferV1) Receive(mimeType string, fd int) error { + const opcode = 0 + mimeTypeLen := client.PaddedLen(len(mimeType) + 1) + reqBufLen := 8 + (4 + mimeTypeLen) + reqBuf := make([]byte, reqBufLen) + l := 0 + client.PutUint32(reqBuf[l:4], o.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], uint32(reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + client.PutString(reqBuf[l:l+(4+mimeTypeLen)], mimeType) + l += (4 + mimeTypeLen) + oob := unix.UnixRights(fd) + return o.Context().WriteMsg(reqBuf, oob) +} + +func (o *ExtDataControlOfferV1) Destroy() error { + defer o.MarkZombie() + const opcode = 1 + const reqBufLen = 8 + var reqBuf [reqBufLen]byte + l := 0 + client.PutUint32(reqBuf[l:4], o.ID()) + l += 4 + client.PutUint32(reqBuf[l:l+4], uint32(reqBufLen<<16|opcode&0x0000ffff)) + l += 4 + return o.Context().WriteMsg(reqBuf[:], nil) +} + +type ExtDataControlOfferV1OfferEvent struct { + MimeType string +} +type ExtDataControlOfferV1OfferHandlerFunc func(ExtDataControlOfferV1OfferEvent) + +func (o *ExtDataControlOfferV1) SetOfferHandler(f ExtDataControlOfferV1OfferHandlerFunc) { + o.offerHandler = f +} + +func (o *ExtDataControlOfferV1) Dispatch(opcode uint32, fd int, data []byte) { + switch opcode { + case 0: + if o.offerHandler == nil { + return + } + l := 0 + mimeTypeLen := client.PaddedLen(int(client.Uint32(data[l : l+4]))) + l += 4 + mimeType := client.String(data[l : l+mimeTypeLen]) + l += mimeTypeLen + + o.offerHandler(ExtDataControlOfferV1OfferEvent{MimeType: mimeType}) + } +} diff --git a/core/internal/server/clipboard/handlers.go b/core/internal/server/clipboard/handlers.go new file mode 100644 index 00000000..4a7f4f6c --- /dev/null +++ b/core/internal/server/clipboard/handlers.go @@ -0,0 +1,215 @@ +package clipboard + +import ( + "encoding/json" + "fmt" + "net" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/models" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/params" +) + +func HandleRequest(conn net.Conn, req models.Request, m *Manager) { + switch req.Method { + case "clipboard.getState": + handleGetState(conn, req, m) + case "clipboard.getHistory": + handleGetHistory(conn, req, m) + case "clipboard.getEntry": + handleGetEntry(conn, req, m) + case "clipboard.deleteEntry": + handleDeleteEntry(conn, req, m) + case "clipboard.clearHistory": + handleClearHistory(conn, req, m) + case "clipboard.copy": + handleCopy(conn, req, m) + case "clipboard.paste": + handlePaste(conn, req, m) + case "clipboard.subscribe": + handleSubscribe(conn, req, m) + case "clipboard.search": + handleSearch(conn, req, m) + case "clipboard.getConfig": + handleGetConfig(conn, req, m) + case "clipboard.setConfig": + handleSetConfig(conn, req, m) + case "clipboard.store": + handleStore(conn, req, m) + default: + models.RespondError(conn, req.ID, "unknown method: "+req.Method) + } +} + +func handleGetState(conn net.Conn, req models.Request, m *Manager) { + models.Respond(conn, req.ID, m.GetState()) +} + +func handleGetHistory(conn net.Conn, req models.Request, m *Manager) { + history := m.GetHistory() + for i := range history { + history[i].Data = nil + } + models.Respond(conn, req.ID, history) +} + +func handleGetEntry(conn net.Conn, req models.Request, m *Manager) { + id, err := params.Int(req.Params, "id") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + entry, err := m.GetEntry(uint64(id)) + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, entry) +} + +func handleDeleteEntry(conn net.Conn, req models.Request, m *Manager) { + id, err := params.Int(req.Params, "id") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := m.DeleteEntry(uint64(id)); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "entry deleted"}) +} + +func handleClearHistory(conn net.Conn, req models.Request, m *Manager) { + m.ClearHistory() + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "history cleared"}) +} + +func handleCopy(conn net.Conn, req models.Request, m *Manager) { + text, err := params.String(req.Params, "text") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + if err := m.CopyText(text); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "copied to clipboard"}) +} + +func handlePaste(conn net.Conn, req models.Request, m *Manager) { + text, err := m.PasteText() + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, map[string]string{"text": text}) +} + +func handleSubscribe(conn net.Conn, req models.Request, m *Manager) { + clientID := fmt.Sprintf("clipboard-%d", req.ID) + + ch := m.Subscribe(clientID) + defer m.Unsubscribe(clientID) + + initialState := m.GetState() + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &initialState, + }); err != nil { + return + } + + for state := range ch { + if err := json.NewEncoder(conn).Encode(models.Response[State]{ + ID: req.ID, + Result: &state, + }); err != nil { + return + } + } +} + +func handleSearch(conn net.Conn, req models.Request, m *Manager) { + p := SearchParams{ + Query: params.StringOpt(req.Params, "query", ""), + MimeType: params.StringOpt(req.Params, "mimeType", ""), + Limit: params.IntOpt(req.Params, "limit", 50), + Offset: params.IntOpt(req.Params, "offset", 0), + } + + if img, ok := req.Params["isImage"].(bool); ok { + p.IsImage = &img + } + if b, ok := req.Params["before"].(float64); ok { + v := int64(b) + p.Before = &v + } + if a, ok := req.Params["after"].(float64); ok { + v := int64(a) + p.After = &v + } + + models.Respond(conn, req.ID, m.Search(p)) +} + +func handleGetConfig(conn net.Conn, req models.Request, m *Manager) { + models.Respond(conn, req.ID, m.GetConfig()) +} + +func handleSetConfig(conn net.Conn, req models.Request, m *Manager) { + cfg := m.GetConfig() + + if _, ok := req.Params["maxHistory"]; ok { + cfg.MaxHistory = params.IntOpt(req.Params, "maxHistory", cfg.MaxHistory) + } + if _, ok := req.Params["maxEntrySize"]; ok { + cfg.MaxEntrySize = int64(params.IntOpt(req.Params, "maxEntrySize", int(cfg.MaxEntrySize))) + } + if _, ok := req.Params["autoClearDays"]; ok { + cfg.AutoClearDays = params.IntOpt(req.Params, "autoClearDays", cfg.AutoClearDays) + } + if v, ok := req.Params["clearAtStartup"].(bool); ok { + cfg.ClearAtStartup = v + } + if v, ok := req.Params["disabled"].(bool); ok { + cfg.Disabled = v + } + if v, ok := req.Params["disableHistory"].(bool); ok { + cfg.DisableHistory = v + } + if v, ok := req.Params["disablePersist"].(bool); ok { + cfg.DisablePersist = v + } + + if err := m.SetConfig(cfg); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "config updated"}) +} + +func handleStore(conn net.Conn, req models.Request, m *Manager) { + data, err := params.String(req.Params, "data") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + mimeType := params.StringOpt(req.Params, "mimeType", "text/plain;charset=utf-8") + + if err := m.StoreData([]byte(data), mimeType); err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "stored"}) +} diff --git a/core/internal/server/clipboard/manager.go b/core/internal/server/clipboard/manager.go new file mode 100644 index 00000000..9eb6ce52 --- /dev/null +++ b/core/internal/server/clipboard/manager.go @@ -0,0 +1,1302 @@ +package clipboard + +import ( + "bytes" + "encoding/binary" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/tiff" + + bolt "go.etcd.io/bbolt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +func NewManager(wlCtx *wlcontext.SharedContext, config Config) (*Manager, error) { + if config.Disabled { + return nil, fmt.Errorf("clipboard disabled in config") + } + + display := wlCtx.Display() + dbPath, err := getDBPath() + if err != nil { + return nil, fmt.Errorf("failed to get db path: %w", err) + } + + configPath, _ := getConfigPath() + + m := &Manager{ + config: config, + configPath: configPath, + display: display, + wlCtx: wlCtx, + stopChan: make(chan struct{}), + subscribers: make(map[string]chan State), + dirty: make(chan struct{}, 1), + offerMimeTypes: make(map[any][]string), + offerRegistry: make(map[uint32]any), + dbPath: dbPath, + } + + if err := m.setupRegistry(); err != nil { + return nil, err + } + + m.notifierWg.Add(1) + go m.notifier() + + go m.watchConfig() + + if !config.DisableHistory { + db, err := openDB(dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open db: %w", err) + } + m.db = db + + if config.ClearAtStartup { + if err := m.clearHistoryInternal(); err != nil { + log.Errorf("Failed to clear history at startup: %v", err) + } + } + + if config.AutoClearDays > 0 { + if err := m.clearOldEntries(config.AutoClearDays); err != nil { + log.Errorf("Failed to clear old entries: %v", err) + } + } + } + + m.alive = true + m.updateState() + + if m.dataControlMgr != nil && m.seat != nil { + m.setupDataDeviceSync() + } + + return m, nil +} + +func getDBPath() (string, error) { + cacheDir := os.Getenv("XDG_CACHE_HOME") + if cacheDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + cacheDir = filepath.Join(homeDir, ".cache") + } + + dbDir := filepath.Join(cacheDir, "dms-clipboard") + if err := os.MkdirAll(dbDir, 0700); err != nil { + return "", err + } + + return filepath.Join(dbDir, "db"), nil +} + +func openDB(path string) (*bolt.DB, error) { + db, err := bolt.Open(path, 0644, &bolt.Options{ + Timeout: 1 * time.Second, + }) + if err != nil { + return nil, err + } + + err = db.Update(func(tx *bolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte("clipboard")) + return err + }) + if err != nil { + db.Close() + return nil, err + } + + return db, nil +} + +func (m *Manager) post(fn func()) { + m.wlCtx.Post(fn) +} + +func (m *Manager) setupRegistry() error { + ctx := m.display.Context() + + registry, err := m.display.GetRegistry() + if err != nil { + return fmt.Errorf("failed to get registry: %w", err) + } + m.registry = registry + + registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) { + switch e.Interface { + case "ext_data_control_manager_v1": + if e.Version < 1 { + return + } + dataControlMgr := ext_data_control.NewExtDataControlManagerV1(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, dataControlMgr); err != nil { + log.Errorf("Failed to bind ext_data_control_manager_v1: %v", err) + return + } + m.dataControlMgr = dataControlMgr + log.Info("Bound ext_data_control_manager_v1") + case "wl_seat": + seat := wlclient.NewSeat(ctx) + if err := registry.Bind(e.Name, e.Interface, e.Version, seat); err != nil { + log.Errorf("Failed to bind wl_seat: %v", err) + return + } + m.seat = seat + m.seatName = e.Name + log.Info("Bound wl_seat") + } + }) + + m.display.Roundtrip() + m.display.Roundtrip() + + if m.dataControlMgr == nil { + return fmt.Errorf("compositor does not support ext_data_control_manager_v1") + } + + if m.seat == nil { + return fmt.Errorf("no seat available") + } + + return nil +} + +func (m *Manager) setupDataDeviceSync() { + if m.dataControlMgr == nil || m.seat == nil { + return + } + + ctx := m.display.Context() + dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) + + dataDevice := ext_data_control.NewExtDataControlDeviceV1(ctx) + + dataDevice.SetDataOfferHandler(func(e ext_data_control.ExtDataControlDeviceV1DataOfferEvent) { + if e.Id == nil { + return + } + + m.offerMutex.Lock() + m.offerRegistry[e.Id.ID()] = e.Id + m.offerMimeTypes[e.Id] = make([]string, 0) + m.offerMutex.Unlock() + + e.Id.SetOfferHandler(func(me ext_data_control.ExtDataControlOfferV1OfferEvent) { + m.offerMutex.Lock() + m.offerMimeTypes[e.Id] = append(m.offerMimeTypes[e.Id], me.MimeType) + m.offerMutex.Unlock() + }) + }) + + dataDevice.SetSelectionHandler(func(e ext_data_control.ExtDataControlDeviceV1SelectionEvent) { + if !m.initialized { + m.initialized = true + return + } + + var offer any + if e.Id != nil { + offer = e.Id + } else if e.OfferId != 0 { + m.offerMutex.RLock() + offer = m.offerRegistry[e.OfferId] + m.offerMutex.RUnlock() + } + + m.ownerLock.Lock() + wasOwner := m.isOwner + m.ownerLock.Unlock() + + if offer == nil { + if wasOwner { + return + } + m.persistMutex.RLock() + hasData := len(m.persistData) > 0 + m.persistMutex.RUnlock() + if hasData { + log.Debug("Selection cleared, re-taking ownership") + m.post(func() { + m.takePersistOwnership() + }) + } + return + } + + if wasOwner { + return + } + + m.currentOffer = offer + + m.offerMutex.RLock() + mimes := m.offerMimeTypes[offer] + m.offerMutex.RUnlock() + + m.mimeTypes = mimes + + go m.storeCurrentClipboard() + }) + + if err := dataMgr.GetDataDeviceWithProxy(dataDevice, m.seat); err != nil { + log.Errorf("Failed to send get_data_device request: %v", err) + return + } + + m.dataDevice = dataDevice + + if err := ctx.Dispatch(); err != nil { + log.Errorf("Failed to dispatch initial events: %v", err) + return + } + + log.Info("Data device setup complete") +} + +func (m *Manager) storeCurrentClipboard() { + if m.currentOffer == nil { + return + } + + cfg := m.getConfig() + + offer := m.currentOffer.(*ext_data_control.ExtDataControlOfferV1) + + if len(m.mimeTypes) == 0 { + return + } + + allData := make(map[string][]byte) + var orderedMimes []string + + for _, mime := range m.mimeTypes { + data, err := m.receiveData(offer, mime) + if err != nil { + continue + } + if len(data) == 0 || int64(len(data)) > cfg.MaxEntrySize { + continue + } + allData[mime] = data + orderedMimes = append(orderedMimes, mime) + } + + if len(allData) == 0 { + return + } + + preferredMime := m.selectMimeType(orderedMimes) + if preferredMime == "" { + preferredMime = orderedMimes[0] + } + + data := allData[preferredMime] + if len(bytes.TrimSpace(data)) == 0 { + return + } + + if !cfg.DisableHistory && m.db != nil { + entry := Entry{ + Data: data, + MimeType: preferredMime, + Size: len(data), + Timestamp: time.Now(), + IsImage: m.isImageMimeType(preferredMime), + } + + switch { + case entry.IsImage: + entry.Preview = m.imagePreview(data, preferredMime) + default: + entry.Preview = m.textPreview(data) + } + + if err := m.storeEntry(entry); err != nil { + log.Errorf("Failed to store clipboard entry: %v", err) + } + } + + if !cfg.DisablePersist { + m.persistClipboard(orderedMimes, allData) + } + + m.updateState() + m.notifySubscribers() +} + +func (m *Manager) persistClipboard(mimeTypes []string, data map[string][]byte) { + m.persistMutex.Lock() + m.persistMimeTypes = mimeTypes + m.persistData = data + m.persistMutex.Unlock() + + m.post(func() { + m.takePersistOwnership() + }) +} + +func (m *Manager) takePersistOwnership() { + if m.dataControlMgr == nil || m.dataDevice == nil { + return + } + + if m.getConfig().DisablePersist { + return + } + + m.persistMutex.RLock() + mimeTypes := m.persistMimeTypes + m.persistMutex.RUnlock() + + if len(mimeTypes) == 0 { + return + } + + dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) + + source, err := dataMgr.CreateDataSource() + if err != nil { + log.Errorf("Failed to create persist source: %v", err) + return + } + + for _, mime := range mimeTypes { + if err := source.Offer(mime); err != nil { + log.Errorf("Failed to offer mime type %s: %v", mime, err) + } + } + + source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { + fd := e.Fd + defer syscall.Close(fd) + + m.persistMutex.RLock() + d := m.persistData[e.MimeType] + m.persistMutex.RUnlock() + + if len(d) == 0 { + return + } + + file := os.NewFile(uintptr(fd), "clipboard-pipe") + defer file.Close() + file.Write(d) + }) + + source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) { + m.ownerLock.Lock() + m.isOwner = false + m.ownerLock.Unlock() + }) + + if m.currentSource != nil { + oldSource := m.currentSource.(*ext_data_control.ExtDataControlSourceV1) + oldSource.Destroy() + } + m.currentSource = source + + device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1) + if err := device.SetSelection(source); err != nil { + log.Errorf("Failed to set persist selection: %v", err) + return + } + + m.ownerLock.Lock() + m.isOwner = true + m.ownerLock.Unlock() +} + +func (m *Manager) storeEntry(entry Entry) error { + if m.db == nil { + return fmt.Errorf("database not available") + } + return m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + + if err := m.deduplicateInTx(b, entry.Data); err != nil { + return err + } + + id, err := b.NextSequence() + if err != nil { + return err + } + + entry.ID = id + + encoded, err := encodeEntry(entry) + if err != nil { + return err + } + + if err := b.Put(itob(id), encoded); err != nil { + return err + } + + return m.trimLengthInTx(b) + }) +} + +func (m *Manager) deduplicateInTx(b *bolt.Bucket, data []byte) error { + c := b.Cursor() + for k, v := c.Last(); k != nil; k, v = c.Prev() { + entry, err := decodeEntry(v) + if err != nil { + continue + } + if bytes.Equal(entry.Data, data) { + if err := b.Delete(k); err != nil { + return err + } + } + } + return nil +} + +func (m *Manager) trimLengthInTx(b *bolt.Bucket) error { + c := b.Cursor() + var count int + for k, _ := c.Last(); k != nil; k, _ = c.Prev() { + if count < m.config.MaxHistory { + count++ + continue + } + if err := b.Delete(k); err != nil { + return err + } + } + return nil +} + +func encodeEntry(e Entry) ([]byte, error) { + buf := new(bytes.Buffer) + + binary.Write(buf, binary.BigEndian, e.ID) + binary.Write(buf, binary.BigEndian, uint32(len(e.Data))) + buf.Write(e.Data) + binary.Write(buf, binary.BigEndian, uint32(len(e.MimeType))) + buf.WriteString(e.MimeType) + binary.Write(buf, binary.BigEndian, uint32(len(e.Preview))) + buf.WriteString(e.Preview) + binary.Write(buf, binary.BigEndian, int32(e.Size)) + binary.Write(buf, binary.BigEndian, e.Timestamp.Unix()) + if e.IsImage { + buf.WriteByte(1) + } else { + buf.WriteByte(0) + } + + return buf.Bytes(), nil +} + +func decodeEntry(data []byte) (Entry, error) { + buf := bytes.NewReader(data) + var e Entry + + binary.Read(buf, binary.BigEndian, &e.ID) + + var dataLen uint32 + binary.Read(buf, binary.BigEndian, &dataLen) + e.Data = make([]byte, dataLen) + buf.Read(e.Data) + + var mimeLen uint32 + binary.Read(buf, binary.BigEndian, &mimeLen) + mimeBytes := make([]byte, mimeLen) + buf.Read(mimeBytes) + e.MimeType = string(mimeBytes) + + var prevLen uint32 + binary.Read(buf, binary.BigEndian, &prevLen) + prevBytes := make([]byte, prevLen) + buf.Read(prevBytes) + e.Preview = string(prevBytes) + + var size int32 + binary.Read(buf, binary.BigEndian, &size) + e.Size = int(size) + + var timestamp int64 + binary.Read(buf, binary.BigEndian, ×tamp) + e.Timestamp = time.Unix(timestamp, 0) + + var isImage byte + binary.Read(buf, binary.BigEndian, &isImage) + e.IsImage = isImage == 1 + + return e, nil +} + +func itob(v uint64) []byte { + b := make([]byte, 8) + binary.BigEndian.PutUint64(b, v) + return b +} + +func (m *Manager) selectMimeType(mimes []string) string { + preferredTypes := []string{ + "text/plain;charset=utf-8", + "text/plain", + "UTF8_STRING", + "STRING", + "TEXT", + "image/png", + "image/jpeg", + "image/bmp", + "image/gif", + } + + for _, pref := range preferredTypes { + for _, mime := range mimes { + if mime == pref { + return mime + } + } + } + + return "" +} + +func (m *Manager) isImageMimeType(mime string) bool { + return strings.HasPrefix(mime, "image/") +} + +func (m *Manager) receiveData(offer *ext_data_control.ExtDataControlOfferV1, mimeType string) ([]byte, error) { + r, w, err := os.Pipe() + if err != nil { + return nil, err + } + defer r.Close() + + if err := offer.Receive(mimeType, int(w.Fd())); err != nil { + w.Close() + return nil, err + } + w.Close() + + type result struct { + data []byte + err error + } + done := make(chan result, 1) + go func() { + data, err := io.ReadAll(r) + done <- result{data, err} + }() + + select { + case res := <-done: + return res.data, res.err + case <-time.After(100 * time.Millisecond): + return nil, fmt.Errorf("timeout reading clipboard data") + } +} + +func (m *Manager) textPreview(data []byte) string { + text := string(data) + text = strings.TrimSpace(text) + text = strings.Join(strings.Fields(text), " ") + + if len(text) > 100 { + return text[:100] + "…" + } + return text +} + +func (m *Manager) imagePreview(data []byte, format string) string { + config, imgFmt, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return fmt.Sprintf("[[ image %s %s ]]", sizeStr(len(data)), format) + } + return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height) +} + +func sizeStr(size int) string { + units := []string{"B", "KiB", "MiB"} + var i int + fsize := float64(size) + for fsize >= 1024 && i < len(units)-1 { + fsize /= 1024 + i++ + } + return fmt.Sprintf("%.0f %s", fsize, units[i]) +} + +func (m *Manager) updateState() { + history := m.GetHistory() + + for i := range history { + history[i].Data = nil + } + + var current *Entry + if len(history) > 0 { + c := history[0] + c.Data = nil + current = &c + } + + newState := &State{ + Enabled: m.alive, + History: history, + Current: current, + } + + m.stateMutex.Lock() + m.state = newState + m.stateMutex.Unlock() +} + +func (m *Manager) notifier() { + defer m.notifierWg.Done() + + for range m.dirty { + state := m.GetState() + + if m.lastState != nil && stateEqual(m.lastState, &state) { + continue + } + + m.lastState = &state + + m.subMutex.RLock() + subs := make([]chan State, 0, len(m.subscribers)) + for _, ch := range m.subscribers { + subs = append(subs, ch) + } + m.subMutex.RUnlock() + + for _, ch := range subs { + select { + case ch <- state: + default: + } + } + } +} + +func stateEqual(a, b *State) bool { + if a == nil || b == nil { + return false + } + if a.Enabled != b.Enabled { + return false + } + if len(a.History) != len(b.History) { + return false + } + return true +} + +func (m *Manager) GetHistory() []Entry { + if m.db == nil { + return nil + } + + cfg := m.getConfig() + var cutoff time.Time + if cfg.AutoClearDays > 0 { + cutoff = time.Now().AddDate(0, 0, -cfg.AutoClearDays) + } + + var history []Entry + var stale []uint64 + + if err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + c := b.Cursor() + + for k, v := c.Last(); k != nil; k, v = c.Prev() { + entry, err := decodeEntry(v) + if err != nil { + continue + } + if !cutoff.IsZero() && entry.Timestamp.Before(cutoff) { + stale = append(stale, entry.ID) + continue + } + history = append(history, entry) + } + return nil + }); err != nil { + log.Errorf("Failed to read clipboard history: %v", err) + } + + if len(stale) > 0 { + go m.deleteStaleEntries(stale) + } + + return history +} + +func (m *Manager) deleteStaleEntries(ids []uint64) { + if m.db == nil { + return + } + + if err := m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + for _, id := range ids { + if err := b.Delete(itob(id)); err != nil { + log.Errorf("Failed to delete stale entry %d: %v", id, err) + } + } + return nil + }); err != nil { + log.Errorf("Failed to delete stale entries: %v", err) + } +} + +func (m *Manager) GetEntry(id uint64) (*Entry, error) { + if m.db == nil { + return nil, fmt.Errorf("database not available") + } + + var entry Entry + var found bool + + err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + v := b.Get(itob(id)) + if v == nil { + return nil + } + + var err error + entry, err = decodeEntry(v) + if err != nil { + return err + } + found = true + return nil + }) + + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("entry not found") + } + + return &entry, nil +} + +func (m *Manager) DeleteEntry(id uint64) error { + if m.db == nil { + return fmt.Errorf("database not available") + } + + err := m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + return b.Delete(itob(id)) + }) + + if err == nil { + m.updateState() + m.notifySubscribers() + } + + return err +} + +func (m *Manager) ClearHistory() { + if m.db == nil { + return + } + + if err := m.db.Update(func(tx *bolt.Tx) error { + if err := tx.DeleteBucket([]byte("clipboard")); err != nil { + return err + } + _, err := tx.CreateBucket([]byte("clipboard")) + return err + }); err != nil { + log.Errorf("Failed to clear clipboard history: %v", err) + return + } + + if err := m.compactDB(); err != nil { + log.Errorf("Failed to compact database: %v", err) + } + + m.updateState() + m.notifySubscribers() +} + +func (m *Manager) compactDB() error { + m.db.Close() + + tmpPath := m.dbPath + ".compact" + defer os.Remove(tmpPath) + + srcDB, err := bolt.Open(m.dbPath, 0644, &bolt.Options{ReadOnly: true, Timeout: time.Second}) + if err != nil { + m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) + return fmt.Errorf("open source: %w", err) + } + + dstDB, err := bolt.Open(tmpPath, 0644, &bolt.Options{Timeout: time.Second}) + if err != nil { + srcDB.Close() + m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) + return fmt.Errorf("open destination: %w", err) + } + + if err := bolt.Compact(dstDB, srcDB, 0); err != nil { + srcDB.Close() + dstDB.Close() + m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) + return fmt.Errorf("compact: %w", err) + } + + srcDB.Close() + dstDB.Close() + + if err := os.Rename(tmpPath, m.dbPath); err != nil { + m.db, _ = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) + return fmt.Errorf("rename: %w", err) + } + + m.db, err = bolt.Open(m.dbPath, 0644, &bolt.Options{Timeout: time.Second}) + if err != nil { + return fmt.Errorf("reopen: %w", err) + } + + return nil +} + +func (m *Manager) SetClipboard(data []byte, mimeType string) error { + if int64(len(data)) > m.config.MaxEntrySize { + return fmt.Errorf("data too large") + } + + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + + m.post(func() { + if m.dataControlMgr == nil || m.dataDevice == nil { + log.Error("Data control manager or device not initialized") + return + } + + dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) + + source, err := dataMgr.CreateDataSource() + if err != nil { + log.Errorf("Failed to create data source: %v", err) + return + } + + if err := source.Offer(mimeType); err != nil { + log.Errorf("Failed to offer mime type: %v", err) + return + } + + source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) { + fd := e.Fd + defer syscall.Close(fd) + + file := os.NewFile(uintptr(fd), "clipboard-pipe") + defer file.Close() + + if _, err := file.Write(dataCopy); err != nil { + log.Errorf("Failed to write clipboard data: %v", err) + } + }) + + m.currentSource = source + m.sourceMutex.Lock() + m.sourceMimeTypes = []string{mimeType} + m.sourceMutex.Unlock() + + device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1) + if err := device.SetSelection(source); err != nil { + log.Errorf("Failed to set selection: %v", err) + } + }) + + return nil +} + +func (m *Manager) CopyText(text string) error { + if err := m.SetClipboard([]byte(text), "text/plain;charset=utf-8"); err != nil { + return err + } + + entry := Entry{ + Data: []byte(text), + MimeType: "text/plain;charset=utf-8", + Size: len(text), + Timestamp: time.Now(), + IsImage: false, + Preview: m.textPreview([]byte(text)), + } + + if err := m.storeEntry(entry); err != nil { + log.Errorf("Failed to store clipboard entry: %v", err) + } + + m.updateState() + m.notifySubscribers() + + return nil +} + +func (m *Manager) PasteText() (string, error) { + history := m.GetHistory() + if len(history) == 0 { + return "", fmt.Errorf("no clipboard data available") + } + + entry := history[0] + if entry.IsImage { + return "", fmt.Errorf("clipboard contains image, not text") + } + + fullEntry, err := m.GetEntry(entry.ID) + if err != nil { + return "", err + } + + return string(fullEntry.Data), nil +} + +func (m *Manager) Close() { + if !m.alive { + return + } + + m.alive = false + close(m.stopChan) + + close(m.dirty) + m.notifierWg.Wait() + + m.subMutex.Lock() + for _, ch := range m.subscribers { + close(ch) + } + m.subscribers = make(map[string]chan State) + m.subMutex.Unlock() + + if m.currentSource != nil { + source := m.currentSource.(*ext_data_control.ExtDataControlSourceV1) + source.Destroy() + } + + if m.dataDevice != nil { + device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1) + device.Destroy() + } + + if m.dataControlMgr != nil { + mgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1) + mgr.Destroy() + } + + if m.registry != nil { + m.registry.Destroy() + } + + if m.db != nil { + m.db.Close() + } +} + +func (m *Manager) clearHistoryInternal() error { + return m.db.Update(func(tx *bolt.Tx) error { + if err := tx.DeleteBucket([]byte("clipboard")); err != nil { + return err + } + _, err := tx.CreateBucket([]byte("clipboard")) + return err + }) +} + +func (m *Manager) clearOldEntries(days int) error { + cutoff := time.Now().AddDate(0, 0, -days) + + return m.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + + var toDelete [][]byte + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + entry, err := decodeEntry(v) + if err != nil { + continue + } + if entry.Timestamp.Before(cutoff) { + toDelete = append(toDelete, k) + } + } + + for _, k := range toDelete { + if err := b.Delete(k); err != nil { + return err + } + } + return nil + }) +} + +func (m *Manager) Search(params SearchParams) SearchResult { + if m.db == nil { + return SearchResult{} + } + + if params.Limit <= 0 { + params.Limit = 50 + } + if params.Limit > 500 { + params.Limit = 500 + } + + query := strings.ToLower(params.Query) + mimeFilter := strings.ToLower(params.MimeType) + + var all []Entry + if err := m.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte("clipboard")) + if b == nil { + return nil + } + + c := b.Cursor() + for k, v := c.Last(); k != nil; k, v = c.Prev() { + entry, err := decodeEntry(v) + if err != nil { + continue + } + + if params.IsImage != nil && entry.IsImage != *params.IsImage { + continue + } + + if mimeFilter != "" && !strings.Contains(strings.ToLower(entry.MimeType), mimeFilter) { + continue + } + + if params.Before != nil && entry.Timestamp.Unix() >= *params.Before { + continue + } + + if params.After != nil && entry.Timestamp.Unix() <= *params.After { + continue + } + + if query != "" && !strings.Contains(strings.ToLower(entry.Preview), query) { + continue + } + + entry.Data = nil + all = append(all, entry) + } + return nil + }); err != nil { + log.Errorf("Search failed: %v", err) + } + + total := len(all) + + start := params.Offset + if start > total { + start = total + } + end := start + params.Limit + if end > total { + end = total + } + + return SearchResult{ + Entries: all[start:end], + Total: total, + HasMore: end < total, + } +} + +func (m *Manager) GetConfig() Config { + return m.config +} + +func (m *Manager) SetConfig(cfg Config) error { + m.configMutex.Lock() + m.config = cfg + m.configMutex.Unlock() + + m.updateState() + m.notifySubscribers() + + return SaveConfig(cfg) +} + +func (m *Manager) getConfig() Config { + m.configMutex.RLock() + defer m.configMutex.RUnlock() + return m.config +} + +func (m *Manager) watchConfig() { + if m.configPath == "" { + return + } + + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Warnf("Failed to create config watcher: %v", err) + return + } + defer watcher.Close() + + configDir := filepath.Dir(m.configPath) + if err := watcher.Add(configDir); err != nil { + log.Warnf("Failed to watch config directory: %v", err) + return + } + + for { + select { + case <-m.stopChan: + return + case event, ok := <-watcher.Events: + if !ok { + return + } + if event.Name != m.configPath { + continue + } + if event.Op&(fsnotify.Write|fsnotify.Create) == 0 { + continue + } + newCfg := LoadConfig() + m.applyConfigChange(newCfg) + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Warnf("Config watcher error: %v", err) + } + } +} + +func (m *Manager) applyConfigChange(newCfg Config) { + m.configMutex.Lock() + oldCfg := m.config + m.config = newCfg + m.configMutex.Unlock() + + if newCfg.DisableHistory && !oldCfg.DisableHistory && m.db != nil { + log.Info("Clipboard history disabled, closing database") + m.db.Close() + m.db = nil + } + + if !newCfg.DisableHistory && oldCfg.DisableHistory && m.db == nil { + log.Info("Clipboard history enabled, opening database") + if db, err := openDB(m.dbPath); err == nil { + m.db = db + } else { + log.Errorf("Failed to reopen database: %v", err) + } + } + + if newCfg.DisablePersist && !oldCfg.DisablePersist { + log.Info("Clipboard persist disabled, releasing ownership") + m.releaseOwnership() + } + + log.Infof("Clipboard config reloaded: disableHistory=%v disablePersist=%v", + newCfg.DisableHistory, newCfg.DisablePersist) + + m.updateState() + m.notifySubscribers() +} + +func (m *Manager) releaseOwnership() { + m.ownerLock.Lock() + m.isOwner = false + m.ownerLock.Unlock() + + m.persistMutex.Lock() + m.persistData = nil + m.persistMimeTypes = nil + m.persistMutex.Unlock() + + if m.currentSource != nil { + source := m.currentSource.(*ext_data_control.ExtDataControlSourceV1) + source.Destroy() + m.currentSource = nil + } +} + +func (m *Manager) StoreData(data []byte, mimeType string) error { + cfg := m.getConfig() + + if cfg.DisableHistory { + return fmt.Errorf("clipboard history disabled") + } + + if m.db == nil { + return fmt.Errorf("database not available") + } + + if len(data) == 0 { + return nil + } + + if int64(len(data)) > cfg.MaxEntrySize { + return fmt.Errorf("data too large") + } + + if len(bytes.TrimSpace(data)) == 0 { + return nil + } + + entry := Entry{ + Data: data, + MimeType: mimeType, + Size: len(data), + Timestamp: time.Now(), + IsImage: m.isImageMimeType(mimeType), + } + + switch { + case entry.IsImage: + entry.Preview = m.imagePreview(data, mimeType) + default: + entry.Preview = m.textPreview(data) + } + + if err := m.storeEntry(entry); err != nil { + return err + } + + m.updateState() + m.notifySubscribers() + + return nil +} diff --git a/core/internal/server/clipboard/types.go b/core/internal/server/clipboard/types.go new file mode 100644 index 00000000..b611945d --- /dev/null +++ b/core/internal/server/clipboard/types.go @@ -0,0 +1,191 @@ +package clipboard + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" + "time" + + bolt "go.etcd.io/bbolt" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext" + wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client" +) + +type Config struct { + MaxHistory int `json:"maxHistory"` + MaxEntrySize int64 `json:"maxEntrySize"` + AutoClearDays int `json:"autoClearDays"` + ClearAtStartup bool `json:"clearAtStartup"` + + Disabled bool `json:"disabled"` + DisableHistory bool `json:"disableHistory"` + DisablePersist bool `json:"disablePersist"` +} + +func DefaultConfig() Config { + return Config{ + MaxHistory: 100, + MaxEntrySize: 5 * 1024 * 1024, + AutoClearDays: 0, + ClearAtStartup: false, + } +} + +func getConfigPath() (string, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "DankMaterialShell", "clsettings.json"), nil +} + +func LoadConfig() Config { + cfg := DefaultConfig() + + path, err := getConfigPath() + if err != nil { + return cfg + } + + data, err := os.ReadFile(path) + if err != nil { + return cfg + } + + if err := json.Unmarshal(data, &cfg); err != nil { + return DefaultConfig() + } + return cfg +} + +func SaveConfig(cfg Config) error { + path, err := getConfigPath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + return os.WriteFile(path, data, 0644) +} + +type SearchParams struct { + Query string `json:"query"` + MimeType string `json:"mimeType"` + IsImage *bool `json:"isImage"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Before *int64 `json:"before"` + After *int64 `json:"after"` +} + +type SearchResult struct { + Entries []Entry `json:"entries"` + Total int `json:"total"` + HasMore bool `json:"hasMore"` +} + +type Entry struct { + ID uint64 `json:"id"` + Data []byte `json:"data,omitempty"` + MimeType string `json:"mimeType"` + Preview string `json:"preview"` + Size int `json:"size"` + Timestamp time.Time `json:"timestamp"` + IsImage bool `json:"isImage"` +} + +type State struct { + Enabled bool `json:"enabled"` + History []Entry `json:"history"` + Current *Entry `json:"current,omitempty"` +} + +type Manager struct { + config Config + configMutex sync.RWMutex + configPath string + + display *wlclient.Display + wlCtx *wlcontext.SharedContext + + registry *wlclient.Registry + dataControlMgr any + seat *wlclient.Seat + dataDevice any + currentOffer any + currentSource any + seatName uint32 + mimeTypes []string + offerMimeTypes map[any][]string + offerMutex sync.RWMutex + offerRegistry map[uint32]any + + sourceMimeTypes []string + sourceMutex sync.RWMutex + + persistData map[string][]byte + persistMimeTypes []string + persistMutex sync.RWMutex + + isOwner bool + ownerLock sync.Mutex + initialized bool + + alive bool + stopChan chan struct{} + + db *bolt.DB + dbPath string + + state *State + stateMutex sync.RWMutex + + subscribers map[string]chan State + subMutex sync.RWMutex + dirty chan struct{} + notifierWg sync.WaitGroup + lastState *State +} + +func (m *Manager) GetState() State { + m.stateMutex.RLock() + defer m.stateMutex.RUnlock() + if m.state == nil { + return State{} + } + return *m.state +} + +func (m *Manager) Subscribe(id string) chan State { + ch := make(chan State, 64) + m.subMutex.Lock() + m.subscribers[id] = ch + m.subMutex.Unlock() + return ch +} + +func (m *Manager) Unsubscribe(id string) { + m.subMutex.Lock() + if ch, ok := m.subscribers[id]; ok { + close(ch) + delete(m.subscribers, id) + } + m.subMutex.Unlock() +} + +func (m *Manager) notifySubscribers() { + select { + case m.dirty <- struct{}{}: + default: + } +} diff --git a/core/internal/server/router.go b/core/internal/server/router.go index c17b8f84..fe5d69d1 100644 --- a/core/internal/server/router.go +++ b/core/internal/server/router.go @@ -8,6 +8,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" @@ -147,6 +148,15 @@ func RouteRequest(conn net.Conn, req models.Request) { return } + if strings.HasPrefix(req.Method, "clipboard.") { + if clipboardManager == nil { + models.RespondError(conn, req.ID, "clipboard manager not initialized") + return + } + clipboard.HandleRequest(conn, req, clipboardManager) + return + } + switch req.Method { case "ping": models.Respond(conn, req.ID, "pong") diff --git a/core/internal/server/server.go b/core/internal/server/server.go index f4e6c460..890ac994 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -18,6 +18,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" + "github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" @@ -32,7 +33,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" ) -const APIVersion = 22 +const APIVersion = 23 var CLIVersion = "dev" @@ -63,6 +64,7 @@ var extWorkspaceManager *extworkspace.Manager var brightnessManager *brightness.Manager var wlrOutputManager *wlroutput.Manager var evdevManager *evdev.Manager +var clipboardManager *clipboard.Manager var wlContext *wlcontext.SharedContext var capabilitySubscribers syncmap.Map[string, chan ServerInfo] @@ -336,6 +338,31 @@ func InitializeEvdevManager() error { return nil } +func InitializeClipboardManager() error { + log.Info("Attempting to initialize clipboard manager...") + + if wlContext == nil { + ctx, err := wlcontext.New() + if err != nil { + log.Errorf("Failed to create shared Wayland context: %v", err) + return err + } + wlContext = ctx + } + + config := clipboard.LoadConfig() + manager, err := clipboard.NewManager(wlContext, config) + if err != nil { + log.Errorf("Failed to initialize clipboard manager: %v", err) + return err + } + + clipboardManager = manager + + log.Info("Clipboard manager initialized successfully") + return nil +} + func handleConnection(conn net.Conn) { defer conn.Close() @@ -409,6 +436,10 @@ func getCapabilities() Capabilities { caps = append(caps, "evdev") } + if clipboardManager != nil { + caps = append(caps, "clipboard") + } + return Capabilities{Capabilities: caps} } @@ -463,6 +494,10 @@ func getServerInfo() ServerInfo { caps = append(caps, "evdev") } + if clipboardManager != nil { + caps = append(caps, "clipboard") + } + return ServerInfo{ APIVersion: APIVersion, CLIVersion: CLIVersion, @@ -1034,6 +1069,38 @@ func handleSubscribe(conn net.Conn, req models.Request) { }() } + if shouldSubscribe("clipboard") && clipboardManager != nil { + wg.Add(1) + clipboardChan := clipboardManager.Subscribe(clientID + "-clipboard") + go func() { + defer wg.Done() + defer clipboardManager.Unsubscribe(clientID + "-clipboard") + + initialState := clipboardManager.GetState() + select { + case eventChan <- ServiceEvent{Service: "clipboard", Data: initialState}: + case <-stopChan: + return + } + + for { + select { + case state, ok := <-clipboardChan: + if !ok { + return + } + select { + case eventChan <- ServiceEvent{Service: "clipboard", Data: state}: + case <-stopChan: + return + } + case <-stopChan: + return + } + } + }() + } + go func() { wg.Wait() close(eventChan) @@ -1096,6 +1163,9 @@ func cleanupManagers() { if evdevManager != nil { evdevManager.Close() } + if clipboardManager != nil { + clipboardManager.Close() + } if wlContext != nil { wlContext.Close() } @@ -1259,6 +1329,18 @@ func Start(printDocs bool) error { log.Info("Evdev:") log.Info(" evdev.getState - Get current evdev state (caps lock)") log.Info(" evdev.subscribe - Subscribe to evdev state changes (streaming)") + log.Info("Clipboard:") + log.Info(" clipboard.getState - Get clipboard state (enabled, history, current)") + log.Info(" clipboard.getHistory - Get clipboard history with previews") + log.Info(" clipboard.getEntry - Get full entry by ID (params: id)") + log.Info(" clipboard.deleteEntry - Delete entry by ID (params: id)") + log.Info(" clipboard.clearHistory - Clear all clipboard history") + log.Info(" clipboard.copy - Copy text to clipboard (params: text)") + log.Info(" clipboard.paste - Get current clipboard text") + log.Info(" clipboard.search - Search history (params: query?, mimeType?, isImage?, limit?, offset?, before?, after?)") + log.Info(" clipboard.getConfig - Get clipboard configuration") + log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)") + log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)") log.Info("") } log.Info("Initializing managers...") @@ -1366,10 +1448,15 @@ func Start(printDocs bool) error { } }() - if wlContext != nil { - wlContext.Start() - log.Info("Wayland event dispatcher started") - } + go func() { + if err := InitializeClipboardManager(); err != nil { + log.Warnf("Clipboard manager unavailable: %v", err) + } + if wlContext != nil { + wlContext.Start() + log.Info("Wayland event dispatcher started") + } + }() log.Info("") log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities) diff --git a/core/internal/server/wlcontext/context.go b/core/internal/server/wlcontext/context.go index 4f0781e2..27c285af 100644 --- a/core/internal/server/wlcontext/context.go +++ b/core/internal/server/wlcontext/context.go @@ -1,8 +1,11 @@ package wlcontext import ( + "errors" "fmt" + "os" "sync" + "time" "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/log" @@ -13,6 +16,7 @@ type SharedContext struct { display *wlclient.Display stopChan chan struct{} fatalError chan error + cmdQueue chan func() wg sync.WaitGroup mu sync.Mutex started bool @@ -28,6 +32,7 @@ func New() (*SharedContext, error) { display: display, stopChan: make(chan struct{}), fatalError: make(chan error, 1), + cmdQueue: make(chan func(), 256), started: false, } @@ -51,6 +56,13 @@ func (sc *SharedContext) Display() *wlclient.Display { return sc.display } +func (sc *SharedContext) Post(fn func()) { + select { + case sc.cmdQueue <- fn: + default: + } +} + func (sc *SharedContext) FatalError() <-chan error { return sc.fatalError } @@ -74,10 +86,35 @@ func (sc *SharedContext) eventDispatcher() { case <-sc.stopChan: return default: - if err := ctx.Dispatch(); err != nil { - log.Errorf("Wayland connection error: %v", err) - return - } + } + + sc.drainCmdQueue() + + if err := ctx.SetReadDeadline(time.Now().Add(50 * time.Millisecond)); err != nil { + log.Errorf("Failed to set read deadline: %v", err) + } + err := ctx.Dispatch() + if err := ctx.SetReadDeadline(time.Time{}); err != nil { + log.Errorf("Failed to clear read deadline: %v", err) + } + + switch { + case err == nil: + case errors.Is(err, os.ErrDeadlineExceeded): + default: + log.Errorf("Wayland connection error: %v", err) + return + } + } +} + +func (sc *SharedContext) drainCmdQueue() { + for { + select { + case fn := <-sc.cmdQueue: + fn() + default: + return } } } diff --git a/core/pkg/go-wayland/wayland/client/context.go b/core/pkg/go-wayland/wayland/client/context.go index d3391e1e..56bf4657 100644 --- a/core/pkg/go-wayland/wayland/client/context.go +++ b/core/pkg/go-wayland/wayland/client/context.go @@ -6,6 +6,7 @@ import ( "net" "os" "sync" + "time" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" ) @@ -28,6 +29,12 @@ func (ctx *Context) Register(p Proxy) { ctx.objects.Store(id, p) } +func (ctx *Context) RegisterWithID(p Proxy, id uint32) { + p.SetID(id) + p.SetContext(ctx) + ctx.objects.Store(id, p) +} + func (ctx *Context) Unregister(p Proxy) { ctx.objects.Delete(p.ID()) } @@ -47,6 +54,10 @@ func (ctx *Context) Close() error { return ctx.conn.Close() } +func (ctx *Context) SetReadDeadline(t time.Time) error { + return ctx.conn.SetReadDeadline(t) +} + // Dispatch reads and processes incoming messages and calls [client.Dispatcher.Dispatch] on the // respective wayland protocol. // Dispatch must be called on the same goroutine as other interactions with the Context. diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 541dd843..f91b5e4c 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -865,4 +865,32 @@ Item { target: "plugins" } + + IpcHandler { + function open(): string { + if (!PopoutService.clipboardHistoryModal) { + return "CLIPBOARD_NOT_AVAILABLE"; + } + PopoutService.clipboardHistoryModal.show(); + return "CLIPBOARD_OPEN_SUCCESS"; + } + + function close(): string { + if (!PopoutService.clipboardHistoryModal) { + return "CLIPBOARD_NOT_AVAILABLE"; + } + PopoutService.clipboardHistoryModal.hide(); + return "CLIPBOARD_CLOSE_SUCCESS"; + } + + function toggle(): string { + if (!PopoutService.clipboardHistoryModal) { + return "CLIPBOARD_NOT_AVAILABLE"; + } + PopoutService.clipboardHistoryModal.toggle(); + return "CLIPBOARD_TOGGLE_SUCCESS"; + } + + target: "clipboard" + } } diff --git a/quickshell/Modals/Clipboard/ClipboardContent.qml b/quickshell/Modals/Clipboard/ClipboardContent.qml index 835400ac..b52457ac 100644 --- a/quickshell/Modals/Clipboard/ClipboardContent.qml +++ b/quickshell/Modals/Clipboard/ClipboardContent.qml @@ -1,14 +1,12 @@ import QtQuick +import Quickshell import qs.Common -import qs.Services import qs.Widgets -import qs.Modals.Clipboard Item { id: clipboardContent required property var modal - required property var filteredModel required property var clearConfirmDialog property alias searchField: searchField @@ -22,7 +20,6 @@ Item { spacing: Theme.spacingL focus: false - // Header ClipboardHeader { id: header width: parent.width @@ -31,14 +28,13 @@ Item { onKeyboardHintsToggled: modal.showKeyboardHints = !modal.showKeyboardHints onClearAllClicked: { clearConfirmDialog.show(I18n.tr("Clear All History?"), I18n.tr("This will permanently delete all clipboard history."), function () { - modal.clearAll() - modal.hide() - }, function () {}) + modal.clearAll(); + modal.hide(); + }, function () {}); } onCloseClicked: modal.hide() } - // Search Field DankTextField { id: searchField width: parent.width @@ -49,30 +45,29 @@ Item { ignoreTabKeys: true keyForwardTargets: [modal.modalFocusScope] onTextChanged: { - modal.searchText = text - modal.updateFilteredModel() + modal.searchText = text; + modal.updateFilteredModel(); } Keys.onEscapePressed: function (event) { - modal.hide() - event.accepted = true + modal.hide(); + event.accepted = true; } Component.onCompleted: { Qt.callLater(function () { - forceActiveFocus() - }) + forceActiveFocus(); + }); } Connections { target: modal function onOpened() { Qt.callLater(function () { - searchField.forceActiveFocus() - }) + searchField.forceActiveFocus(); + }); } } } - // List Container Rectangle { width: parent.width height: parent.height - ClipboardConstants.headerHeight - 70 @@ -83,7 +78,10 @@ Item { DankListView { id: clipboardListView anchors.fill: parent - model: filteredModel + model: ScriptModel { + values: clipboardContent.modal.clipboardEntries + objectProp: "id" + } currentIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0 spacing: Theme.spacingXS @@ -97,21 +95,21 @@ Item { function ensureVisible(index) { if (index < 0 || index >= count) { - return + return; } - const itemHeight = ClipboardConstants.itemHeight + spacing - const itemY = index * itemHeight - const itemBottom = itemY + itemHeight + const itemHeight = ClipboardConstants.itemHeight + spacing; + const itemY = index * itemHeight; + const itemBottom = itemY + itemHeight; if (itemY < contentY) { - contentY = itemY + contentY = itemY; } else if (itemBottom > contentY + height) { - contentY = itemBottom - height + contentY = itemBottom - height; } } onCurrentIndexChanged: { - if (clipboardContent.modal && clipboardContent.modal.keyboardNavigationActive && currentIndex >= 0) { - ensureVisible(currentIndex) + if (clipboardContent.modal?.keyboardNavigationActive && currentIndex >= 0) { + ensureVisible(currentIndex); } } @@ -120,28 +118,27 @@ Item { anchors.centerIn: parent font.pixelSize: Theme.fontSizeMedium color: Theme.surfaceVariantText - visible: filteredModel.count === 0 + visible: clipboardContent.modal.clipboardEntries.length === 0 } delegate: ClipboardEntry { required property int index - required property var model + required property var modelData width: clipboardListView.width height: ClipboardConstants.itemHeight - entryData: model.entry + entry: modelData entryIndex: index + 1 itemIndex: index - isSelected: clipboardContent.modal && clipboardContent.modal.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex + isSelected: clipboardContent.modal?.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex modal: clipboardContent.modal listView: clipboardListView - onCopyRequested: clipboardContent.modal.copyEntry(model.entry) - onDeleteRequested: clipboardContent.modal.deleteEntry(model.entry) + onCopyRequested: clipboardContent.modal.copyEntry(modelData) + onDeleteRequested: clipboardContent.modal.deleteEntry(modelData) } } } - // Spacer for keyboard hints Item { width: parent.width height: modal.showKeyboardHints ? ClipboardConstants.keyboardHintsHeight + Theme.spacingL : 0 @@ -155,7 +152,6 @@ Item { } } - // Keyboard Hints Overlay ClipboardKeyboardHints { anchors.bottom: parent.bottom anchors.left: parent.left diff --git a/quickshell/Modals/Clipboard/ClipboardEntry.qml b/quickshell/Modals/Clipboard/ClipboardEntry.qml index 1d37eb94..bb477af5 100644 --- a/quickshell/Modals/Clipboard/ClipboardEntry.qml +++ b/quickshell/Modals/Clipboard/ClipboardEntry.qml @@ -1,14 +1,11 @@ import QtQuick -import QtQuick.Effects -import Quickshell.Io import qs.Common import qs.Widgets -import qs.Modals.Clipboard Rectangle { - id: entry + id: root - required property string entryData + required property var entry required property int entryIndex required property int itemIndex required property bool isSelected @@ -18,15 +15,15 @@ Rectangle { signal copyRequested signal deleteRequested - readonly property string entryType: modal ? modal.getEntryType(entryData) : "text" - readonly property string entryPreview: modal ? modal.getEntryPreview(entryData) : entryData + readonly property string entryType: modal ? modal.getEntryType(entry) : "text" + readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : "" radius: Theme.cornerRadius color: { if (isSelected) { - return Theme.primaryPressed + return Theme.primaryPressed; } - return mouseArea.containsMouse ? Theme.primaryHoverLight : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) + return mouseArea.containsMouse ? Theme.primaryHoverLight : Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency); } Row { @@ -35,7 +32,6 @@ Rectangle { anchors.rightMargin: Theme.spacingS spacing: Theme.spacingL - // Index indicator Rectangle { width: 24 height: 24 @@ -52,25 +48,22 @@ Rectangle { } } - // Content area Row { anchors.verticalCenter: parent.verticalCenter width: parent.width - 68 spacing: Theme.spacingM - // Thumbnail/Icon ClipboardThumbnail { width: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize height: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize anchors.verticalCenter: parent.verticalCenter - entryData: entry.entryData - entryType: entry.entryType - modal: entry.modal - listView: entry.listView - itemIndex: entry.itemIndex + entry: root.entry + entryType: root.entryType + modal: root.modal + listView: root.listView + itemIndex: root.itemIndex } - // Text content Column { anchors.verticalCenter: parent.verticalCenter width: parent.width - (entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize) - Theme.spacingM @@ -80,11 +73,11 @@ Rectangle { text: { switch (entryType) { case "image": - return I18n.tr("Image") + " • " + entryPreview + return I18n.tr("Image") + " • " + entryPreview; case "long_text": - return I18n.tr("Long Text") + return I18n.tr("Long Text"); default: - return I18n.tr("Text") + return I18n.tr("Text"); } } font.pixelSize: Theme.fontSizeSmall @@ -107,7 +100,6 @@ Rectangle { } } - // Delete button DankActionButton { anchors.right: parent.right anchors.rightMargin: Theme.spacingM @@ -118,7 +110,6 @@ Rectangle { onClicked: deleteRequested() } - // Click area MouseArea { id: mouseArea anchors.fill: parent diff --git a/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml index 73fb07a5..d8becf7a 100644 --- a/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml +++ b/quickshell/Modals/Clipboard/ClipboardHistoryModal.qml @@ -1,9 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick -import Quickshell import Quickshell.Hyprland -import Quickshell.Io import qs.Common import qs.Modals.Common import qs.Services @@ -27,33 +25,27 @@ DankModal { property Component clipboardContent property int activeImageLoads: 0 readonly property int maxConcurrentLoads: 3 + readonly property bool clipboardAvailable: DMSService.isConnected && DMSService.capabilities.includes("clipboard") function updateFilteredModel() { - filteredClipboardModel.clear(); - for (var i = 0; i < clipboardModel.count; i++) { - const entry = clipboardModel.get(i).entry; - if (searchText.trim().length === 0) { - filteredClipboardModel.append({ - "entry": entry - }); - } else { - const content = getEntryPreview(entry).toLowerCase(); - if (content.includes(searchText.toLowerCase())) { - filteredClipboardModel.append({ - "entry": entry - }); - } - } + const query = searchText.trim(); + if (query.length === 0) { + clipboardEntries = internalEntries; + } else { + const lowerQuery = query.toLowerCase(); + clipboardEntries = internalEntries.filter(entry => entry.preview.toLowerCase().includes(lowerQuery)); } - clipboardHistoryModal.totalCount = filteredClipboardModel.count; - if (filteredClipboardModel.count === 0) { + totalCount = clipboardEntries.length; + if (clipboardEntries.length === 0) { keyboardNavigationActive = false; selectedIndex = 0; - } else if (selectedIndex >= filteredClipboardModel.count) { - selectedIndex = filteredClipboardModel.count - 1; + } else if (selectedIndex >= clipboardEntries.length) { + selectedIndex = clipboardEntries.length - 1; } } + property var internalEntries: [] + function toggle() { if (shouldBeVisible) { hide(); @@ -63,15 +55,19 @@ DankModal { } function show() { + if (!clipboardAvailable) { + ToastService.showError(I18n.tr("Clipboard service not available")); + return; + } open(); - clipboardHistoryModal.searchText = ""; - clipboardHistoryModal.activeImageLoads = 0; - clipboardHistoryModal.shouldHaveFocus = true; + searchText = ""; + activeImageLoads = 0; + shouldHaveFocus = true; refreshClipboard(); keyboardController.reset(); Qt.callLater(function () { - if (contentLoader.item && contentLoader.item.searchField) { + if (contentLoader.item?.searchField) { contentLoader.item.searchField.text = ""; contentLoader.item.searchField.forceActiveFocus(); } @@ -80,60 +76,90 @@ DankModal { function hide() { close(); - clipboardHistoryModal.searchText = ""; - clipboardHistoryModal.activeImageLoads = 0; - updateFilteredModel(); + searchText = ""; + activeImageLoads = 0; + internalEntries = []; + clipboardEntries = []; keyboardController.reset(); - cleanupTempFiles(); - } - - function cleanupTempFiles() { - Quickshell.execDetached(["sh", "-c", "rm -f /tmp/clipboard_*.png"]); } function refreshClipboard() { - clipboardProcesses.refresh(); + DMSService.sendRequest("clipboard.getHistory", null, function (response) { + if (response.error) { + console.warn("ClipboardHistoryModal: Failed to get history:", response.error); + return; + } + internalEntries = response.result || []; + updateFilteredModel(); + }); } function copyEntry(entry) { - const entryId = entry.split('\t')[0]; - Quickshell.execDetached(["sh", "-c", `cliphist decode ${entryId} | wl-copy`]); - ToastService.showInfo(I18n.tr("Copied to clipboard")); - hide(); + DMSService.sendRequest("clipboard.getEntry", { + "id": entry.id + }, function (response) { + if (response.error) { + ToastService.showError(I18n.tr("Failed to copy entry")); + return; + } + const fullEntry = response.result; + if (fullEntry.isImage) { + ToastService.showInfo(I18n.tr("Image copied to clipboard")); + } else { + DMSService.sendRequest("clipboard.copy", { + "text": fullEntry.data + }, function (copyResponse) { + if (copyResponse.error) { + ToastService.showError(I18n.tr("Failed to copy")); + return; + } + ToastService.showInfo(I18n.tr("Copied to clipboard")); + }); + } + hide(); + }); } function deleteEntry(entry) { - clipboardProcesses.deleteEntry(entry); + DMSService.sendRequest("clipboard.deleteEntry", { + "id": entry.id + }, function (response) { + if (response.error) { + console.warn("ClipboardHistoryModal: Failed to delete entry:", response.error); + return; + } + internalEntries = internalEntries.filter(e => e.id !== entry.id); + updateFilteredModel(); + if (clipboardEntries.length === 0) { + keyboardNavigationActive = false; + selectedIndex = 0; + } else if (selectedIndex >= clipboardEntries.length) { + selectedIndex = clipboardEntries.length - 1; + } + }); } function clearAll() { - clipboardProcesses.clearAll(); + DMSService.sendRequest("clipboard.clearHistory", null, function (response) { + if (response.error) { + console.warn("ClipboardHistoryModal: Failed to clear history:", response.error); + return; + } + internalEntries = []; + clipboardEntries = []; + totalCount = 0; + }); } function getEntryPreview(entry) { - let content = entry.replace(/^\s*\d+\s+/, ""); - if (content.includes("image/") || content.includes("binary data") || /\.(png|jpg|jpeg|gif|bmp|webp)/i.test(content)) { - const dimensionMatch = content.match(/(\d+)x(\d+)/); - if (dimensionMatch) { - return `Image ${dimensionMatch[1]}×${dimensionMatch[2]}`; - } - const typeMatch = content.match(/\b(png|jpg|jpeg|gif|bmp|webp)\b/i); - if (typeMatch) { - return `Image (${typeMatch[1].toUpperCase()})`; - } - return "Image"; - } - if (content.length > ClipboardConstants.previewLength) { - return content.substring(0, ClipboardConstants.previewLength) + "..."; - } - return content; + return entry.preview || ""; } function getEntryType(entry) { - if (entry.includes("image/") || entry.includes("binary data") || /\.(png|jpg|jpeg|gif|bmp|webp)/i.test(entry) || /\b(png|jpg|jpeg|gif|bmp|webp)\b/i.test(entry)) { + if (entry.isImage) { return "image"; } - if (entry.length > ClipboardConstants.longTextThreshold) { + if (entry.size > ClipboardConstants.longTextThreshold) { return "long_text"; } return "text"; @@ -168,55 +194,18 @@ DankModal { } else if (clipboardHistoryModal.shouldBeVisible) { clipboardHistoryModal.shouldHaveFocus = true; clipboardHistoryModal.modalFocusScope.forceActiveFocus(); - if (clipboardHistoryModal.contentLoader.item && clipboardHistoryModal.contentLoader.item.searchField) { + if (clipboardHistoryModal.contentLoader.item?.searchField) { clipboardHistoryModal.contentLoader.item.searchField.forceActiveFocus(); } } } } - property alias filteredClipboardModel: filteredClipboardModel - property alias clipboardModel: clipboardModel property var confirmDialog: clearConfirmDialog - ListModel { - id: clipboardModel - } - - ListModel { - id: filteredClipboardModel - } - - ClipboardProcesses { - id: clipboardProcesses - modal: clipboardHistoryModal - clipboardModel: clipboardModel - filteredClipboardModel: filteredClipboardModel - } - - IpcHandler { - function open(): string { - clipboardHistoryModal.show(); - return "CLIPBOARD_OPEN_SUCCESS"; - } - - function close(): string { - clipboardHistoryModal.hide(); - return "CLIPBOARD_CLOSE_SUCCESS"; - } - - function toggle(): string { - clipboardHistoryModal.toggle(); - return "CLIPBOARD_TOGGLE_SUCCESS"; - } - - target: "clipboard" - } - clipboardContent: Component { ClipboardContent { modal: clipboardHistoryModal - filteredModel: filteredClipboardModel clearConfirmDialog: clipboardHistoryModal.confirmDialog } } diff --git a/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml b/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml index 2874eab0..8d808928 100644 --- a/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml +++ b/quickshell/Modals/Clipboard/ClipboardKeyboardController.qml @@ -1,5 +1,4 @@ import QtQuick -import qs.Common QtObject { id: keyboardController @@ -7,125 +6,133 @@ QtObject { required property var modal function reset() { - modal.selectedIndex = 0 - modal.keyboardNavigationActive = false - modal.showKeyboardHints = false + modal.selectedIndex = 0; + modal.keyboardNavigationActive = false; + modal.showKeyboardHints = false; } function selectNext() { - if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0) { - return + if (!modal.clipboardEntries || modal.clipboardEntries.length === 0) { + return; } - modal.keyboardNavigationActive = true - modal.selectedIndex = Math.min(modal.selectedIndex + 1, modal.filteredClipboardModel.count - 1) + modal.keyboardNavigationActive = true; + modal.selectedIndex = Math.min(modal.selectedIndex + 1, modal.clipboardEntries.length - 1); } function selectPrevious() { - if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0) { - return + if (!modal.clipboardEntries || modal.clipboardEntries.length === 0) { + return; } - modal.keyboardNavigationActive = true - modal.selectedIndex = Math.max(modal.selectedIndex - 1, 0) + modal.keyboardNavigationActive = true; + modal.selectedIndex = Math.max(modal.selectedIndex - 1, 0); } function copySelected() { - if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.filteredClipboardModel.count) { - return + if (!modal.clipboardEntries || modal.clipboardEntries.length === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.clipboardEntries.length) { + return; } - const selectedEntry = modal.filteredClipboardModel.get(modal.selectedIndex).entry - modal.copyEntry(selectedEntry) + const selectedEntry = modal.clipboardEntries[modal.selectedIndex]; + modal.copyEntry(selectedEntry); } function deleteSelected() { - if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.filteredClipboardModel.count) { - return + if (!modal.clipboardEntries || modal.clipboardEntries.length === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.clipboardEntries.length) { + return; } - const selectedEntry = modal.filteredClipboardModel.get(modal.selectedIndex).entry - modal.deleteEntry(selectedEntry) + const selectedEntry = modal.clipboardEntries[modal.selectedIndex]; + modal.deleteEntry(selectedEntry); } function handleKey(event) { - if (event.key === Qt.Key_Escape) { + switch (event.key) { + case Qt.Key_Escape: if (modal.keyboardNavigationActive) { - modal.keyboardNavigationActive = false - event.accepted = true + modal.keyboardNavigationActive = false; } else { - modal.hide() - event.accepted = true + modal.hide(); } - } else if (event.key === Qt.Key_Down || event.key === Qt.Key_Tab) { + event.accepted = true; + return; + case Qt.Key_Down: + case Qt.Key_Tab: if (!modal.keyboardNavigationActive) { - modal.keyboardNavigationActive = true - modal.selectedIndex = 0 - event.accepted = true + modal.keyboardNavigationActive = true; + modal.selectedIndex = 0; } else { - selectNext() - event.accepted = true + selectNext(); } - } else if (event.key === Qt.Key_Up || event.key === Qt.Key_Backtab) { + event.accepted = true; + return; + case Qt.Key_Up: + case Qt.Key_Backtab: if (!modal.keyboardNavigationActive) { - modal.keyboardNavigationActive = true - modal.selectedIndex = 0 - event.accepted = true + modal.keyboardNavigationActive = true; + modal.selectedIndex = 0; } else if (modal.selectedIndex === 0) { - modal.keyboardNavigationActive = false - event.accepted = true + modal.keyboardNavigationActive = false; } else { - selectPrevious() - event.accepted = true + selectPrevious(); } - } else if (event.key === Qt.Key_N && event.modifiers & Qt.ControlModifier) { - if (!modal.keyboardNavigationActive) { - modal.keyboardNavigationActive = true - modal.selectedIndex = 0 - } else { - selectNext() - } - event.accepted = true - } else if (event.key === Qt.Key_P && event.modifiers & Qt.ControlModifier) { - if (!modal.keyboardNavigationActive) { - modal.keyboardNavigationActive = true - modal.selectedIndex = 0 - } else if (modal.selectedIndex === 0) { - modal.keyboardNavigationActive = false - } else { - selectPrevious() - } - event.accepted = true - } else if (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) { - if (!modal.keyboardNavigationActive) { - modal.keyboardNavigationActive = true - modal.selectedIndex = 0 - } else { - selectNext() - } - event.accepted = true - } else if (event.key === Qt.Key_K && event.modifiers & Qt.ControlModifier) { - if (!modal.keyboardNavigationActive) { - modal.keyboardNavigationActive = true - modal.selectedIndex = 0 - } else if (modal.selectedIndex === 0) { - modal.keyboardNavigationActive = false - } else { - selectPrevious() - } - event.accepted = true - } else if (event.key === Qt.Key_Delete && (event.modifiers & Qt.ShiftModifier)) { - modal.clearAll() - modal.hide() - event.accepted = true - } else if (modal.keyboardNavigationActive) { - if ((event.key === Qt.Key_C && (event.modifiers & Qt.ControlModifier)) || event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - copySelected() - event.accepted = true - } else if (event.key === Qt.Key_Delete) { - deleteSelected() - event.accepted = true + event.accepted = true; + return; + case Qt.Key_F10: + modal.showKeyboardHints = !modal.showKeyboardHints; + event.accepted = true; + return; + } + + if (event.modifiers & Qt.ControlModifier) { + switch (event.key) { + case Qt.Key_N: + case Qt.Key_J: + if (!modal.keyboardNavigationActive) { + modal.keyboardNavigationActive = true; + modal.selectedIndex = 0; + } else { + selectNext(); + } + event.accepted = true; + return; + case Qt.Key_P: + case Qt.Key_K: + if (!modal.keyboardNavigationActive) { + modal.keyboardNavigationActive = true; + modal.selectedIndex = 0; + } else if (modal.selectedIndex === 0) { + modal.keyboardNavigationActive = false; + } else { + selectPrevious(); + } + event.accepted = true; + return; + case Qt.Key_C: + if (modal.keyboardNavigationActive) { + copySelected(); + event.accepted = true; + } + return; } } - if (event.key === Qt.Key_F10) { - modal.showKeyboardHints = !modal.showKeyboardHints - event.accepted = true + + if (event.modifiers & Qt.ShiftModifier && event.key === Qt.Key_Delete) { + modal.clearAll(); + modal.hide(); + event.accepted = true; + return; + } + + if (modal.keyboardNavigationActive) { + switch (event.key) { + case Qt.Key_Return: + case Qt.Key_Enter: + copySelected(); + event.accepted = true; + return; + case Qt.Key_Delete: + deleteSelected(); + event.accepted = true; + return; + } } } } diff --git a/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml b/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml index 36181575..3e61f077 100644 --- a/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml +++ b/quickshell/Modals/Clipboard/ClipboardKeyboardHints.qml @@ -1,7 +1,6 @@ import QtQuick import qs.Common import qs.Widgets -import qs.Modals.Clipboard Rectangle { id: keyboardHints diff --git a/quickshell/Modals/Clipboard/ClipboardProcesses.qml b/quickshell/Modals/Clipboard/ClipboardProcesses.qml deleted file mode 100644 index bb3c24d0..00000000 --- a/quickshell/Modals/Clipboard/ClipboardProcesses.qml +++ /dev/null @@ -1,94 +0,0 @@ -import QtQuick -import Quickshell.Io - -QtObject { - id: clipboardProcesses - - required property var modal - required property var clipboardModel - required property var filteredClipboardModel - - // Load clipboard entries - property var loadProcess: Process { - id: loadProcess - command: ["cliphist", "list"] - running: false - - stdout: StdioCollector { - onStreamFinished: { - clipboardModel.clear() - const lines = text.trim().split('\n') - for (const line of lines) { - if (line.trim().length > 0) { - clipboardModel.append({ - "entry": line - }) - } - } - modal.updateFilteredModel() - } - } - } - - // Delete single entry - property var deleteProcess: Process { - id: deleteProcess - property string deletedEntry: "" - running: false - - onExited: exitCode => { - if (exitCode === 0) { - for (var i = 0; i < clipboardModel.count; i++) { - if (clipboardModel.get(i).entry === deleteProcess.deletedEntry) { - clipboardModel.remove(i) - break - } - } - for (var j = 0; j < filteredClipboardModel.count; j++) { - if (filteredClipboardModel.get(j).entry === deleteProcess.deletedEntry) { - filteredClipboardModel.remove(j) - break - } - } - modal.totalCount = filteredClipboardModel.count - if (filteredClipboardModel.count === 0) { - modal.keyboardNavigationActive = false - modal.selectedIndex = 0 - } else if (modal.selectedIndex >= filteredClipboardModel.count) { - modal.selectedIndex = filteredClipboardModel.count - 1 - } - } else { - console.warn("Failed to delete clipboard entry") - } - } - } - - // Clear all entries - property var clearProcess: Process { - id: clearProcess - command: ["cliphist", "wipe"] - running: false - - onExited: exitCode => { - if (exitCode === 0) { - clipboardModel.clear() - filteredClipboardModel.clear() - modal.totalCount = 0 - } - } - } - - function refresh() { - loadProcess.running = true - } - - function deleteEntry(entry) { - deleteProcess.deletedEntry = entry - deleteProcess.command = ["sh", "-c", `echo '${entry.replace(/'/g, "'\\''")}' | cliphist delete`] - deleteProcess.running = true - } - - function clearAll() { - clearProcess.running = true - } -} diff --git a/quickshell/Modals/Clipboard/ClipboardThumbnail.qml b/quickshell/Modals/Clipboard/ClipboardThumbnail.qml index bbed8793..d78991ce 100644 --- a/quickshell/Modals/Clipboard/ClipboardThumbnail.qml +++ b/quickshell/Modals/Clipboard/ClipboardThumbnail.qml @@ -1,14 +1,13 @@ import QtQuick import QtQuick.Effects -import Quickshell.Io import qs.Common +import qs.Services import qs.Widgets -import qs.Modals.Clipboard Item { id: thumbnail - required property string entryData + required property var entry required property string entryType required property var modal required property var listView @@ -17,13 +16,12 @@ Item { Image { id: thumbnailImage - property string entryId: entryData.split('\t')[0] property bool isVisible: false property string cachedImageData: "" property bool loadQueued: false anchors.fill: parent - source: "" + source: cachedImageData ? `data:image/png;base64,${cachedImageData}` : "" fillMode: Image.PreserveAspectCrop smooth: true cache: false @@ -32,53 +30,66 @@ Item { sourceSize.width: 128 sourceSize.height: 128 - onCachedImageDataChanged: { - if (cachedImageData) { - source = "" - source = `data:image/png;base64,${cachedImageData}` + function tryLoadImage() { + if (loadQueued || entryType !== "image" || cachedImageData) { + return; + } + loadQueued = true; + if (modal.activeImageLoads < modal.maxConcurrentLoads) { + modal.activeImageLoads++; + loadImage(); + } else { + retryTimer.restart(); } } - function tryLoadImage() { - if (!loadQueued && entryType === "image" && !cachedImageData) { - loadQueued = true - if (modal.activeImageLoads < modal.maxConcurrentLoads) { - modal.activeImageLoads++ - imageLoader.running = true - } else { - retryTimer.restart() + function loadImage() { + DMSService.sendRequest("clipboard.getEntry", { + "id": entry.id + }, function (response) { + loadQueued = false; + if (modal.activeImageLoads > 0) { + modal.activeImageLoads--; } - } + if (response.error) { + console.warn("ClipboardThumbnail: Failed to load image:", entry.id); + return; + } + const data = response.result?.data; + if (data) { + cachedImageData = data; + } + }); } Timer { id: retryTimer interval: ClipboardConstants.retryInterval onTriggered: { - if (thumbnailImage.loadQueued && !imageLoader.running) { - if (modal.activeImageLoads < modal.maxConcurrentLoads) { - modal.activeImageLoads++ - imageLoader.running = true - } else { - retryTimer.restart() - } + if (!thumbnailImage.loadQueued) { + return; + } + if (modal.activeImageLoads < modal.maxConcurrentLoads) { + modal.activeImageLoads++; + thumbnailImage.loadImage(); + } else { + retryTimer.restart(); } } } Component.onCompleted: { if (entryType !== "image") { - return + return; } - // Check if item is visible on screen initially - const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing) - const viewTop = listView.contentY - const viewBottom = viewTop + listView.height - isVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom) + const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing); + const viewTop = listView.contentY; + const viewBottom = viewTop + listView.height; + isVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom); if (isVisible) { - tryLoadImage() + tryLoadImage(); } } @@ -86,48 +97,22 @@ Item { target: listView function onContentYChanged() { if (entryType !== "image") { - return + return; } - const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing) - const viewTop = listView.contentY - ClipboardConstants.viewportBuffer - const viewBottom = viewTop + listView.height + ClipboardConstants.extendedBuffer - const nowVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom) + const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing); + const viewTop = listView.contentY - ClipboardConstants.viewportBuffer; + const viewBottom = viewTop + listView.height + ClipboardConstants.extendedBuffer; + const nowVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom); if (nowVisible && !thumbnailImage.isVisible) { - thumbnailImage.isVisible = true - thumbnailImage.tryLoadImage() + thumbnailImage.isVisible = true; + thumbnailImage.tryLoadImage(); } } } - - Process { - id: imageLoader - running: false - command: ["sh", "-c", `cliphist decode ${thumbnailImage.entryId} | base64 -w 0`] - - stdout: StdioCollector { - onStreamFinished: { - const imageData = text.trim() - if (imageData && imageData.length > 0) { - thumbnailImage.cachedImageData = imageData - } - } - } - - onExited: exitCode => { - thumbnailImage.loadQueued = false - if (modal.activeImageLoads > 0) { - modal.activeImageLoads-- - } - if (exitCode !== 0) { - console.warn("Failed to load clipboard image:", thumbnailImage.entryId) - } - } - } } - // Rounded mask effect for images MultiEffect { anchors.fill: parent anchors.margins: 2 @@ -155,17 +140,17 @@ Item { } } - // Fallback icon DankIcon { visible: !(entryType === "image" && thumbnailImage.status === Image.Ready && thumbnailImage.source != "") name: { - if (entryType === "image") { - return "image" + switch (entryType) { + case "image": + return "image"; + case "long_text": + return "subject"; + default: + return "content_copy"; } - if (entryType === "long_text") { - return "subject" - } - return "content_copy" } size: Theme.iconSize color: Theme.primary diff --git a/quickshell/Modals/Settings/SettingsContent.qml b/quickshell/Modals/Settings/SettingsContent.qml index bc128bb0..8968dffe 100644 --- a/quickshell/Modals/Settings/SettingsContent.qml +++ b/quickshell/Modals/Settings/SettingsContent.qml @@ -399,5 +399,21 @@ FocusScope { } } } + + Loader { + id: clipboardLoader + anchors.fill: parent + active: root.currentIndex === 23 + visible: active + focus: active + + sourceComponent: ClipboardTab {} + + onActiveChanged: { + if (active && item) { + Qt.callLater(() => item.forceActiveFocus()); + } + } + } } } diff --git a/quickshell/Modals/Settings/SettingsSidebar.qml b/quickshell/Modals/Settings/SettingsSidebar.qml index bb2e96e2..694c14ca 100644 --- a/quickshell/Modals/Settings/SettingsSidebar.qml +++ b/quickshell/Modals/Settings/SettingsSidebar.qml @@ -144,12 +144,6 @@ Rectangle { "tabIndex": 2, "shortcutsOnly": true }, - { - "id": "displays", - "text": I18n.tr("Displays"), - "icon": "monitor", - "tabIndex": 6 - }, { "id": "network", "text": I18n.tr("Network"), @@ -158,11 +152,32 @@ Rectangle { "dmsOnly": true }, { - "id": "printers", - "text": I18n.tr("Printers"), - "icon": "print", - "tabIndex": 8, - "cupsOnly": true + "id": "system", + "text": I18n.tr("System"), + "icon": "computer", + "collapsedByDefault": true, + "children": [ + { + "id": "displays", + "text": I18n.tr("Displays"), + "icon": "monitor", + "tabIndex": 6 + }, + { + "id": "printers", + "text": I18n.tr("Printers"), + "icon": "print", + "tabIndex": 8, + "cupsOnly": true + }, + { + "id": "clipboard", + "text": I18n.tr("Clipboard"), + "icon": "content_paste", + "tabIndex": 23, + "clipboardOnly": true + } + ] }, { "id": "power_security", @@ -213,6 +228,8 @@ Rectangle { return false; if (item.hyprlandNiriOnly && !CompositorService.isNiri && !CompositorService.isHyprland) return false; + if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23)) + return false; return true; } diff --git a/quickshell/Modules/Settings/ClipboardTab.qml b/quickshell/Modules/Settings/ClipboardTab.qml new file mode 100644 index 00000000..b3bde370 --- /dev/null +++ b/quickshell/Modules/Settings/ClipboardTab.qml @@ -0,0 +1,273 @@ +import QtQuick +import qs.Common +import qs.Services +import qs.Widgets +import qs.Modules.Settings.Widgets + +Item { + id: root + + property var config: ({}) + property bool configLoaded: false + property bool configError: false + property bool saving: false + + readonly property var maxHistoryOptions: [ + { text: "25", value: 25 }, + { text: "50", value: 50 }, + { text: "100", value: 100 }, + { text: "200", value: 200 }, + { text: "500", value: 500 }, + { text: "1000", value: 1000 } + ] + + readonly property var maxEntrySizeOptions: [ + { text: "1 MB", value: 1048576 }, + { text: "2 MB", value: 2097152 }, + { text: "5 MB", value: 5242880 }, + { text: "10 MB", value: 10485760 }, + { text: "20 MB", value: 20971520 }, + { text: "50 MB", value: 52428800 } + ] + + readonly property var autoClearOptions: [ + { text: I18n.tr("Never"), value: 0 }, + { text: I18n.tr("1 day"), value: 1 }, + { text: I18n.tr("3 days"), value: 3 }, + { text: I18n.tr("7 days"), value: 7 }, + { text: I18n.tr("14 days"), value: 14 }, + { text: I18n.tr("30 days"), value: 30 }, + { text: I18n.tr("90 days"), value: 90 } + ] + + function getMaxHistoryText(value) { + for (let opt of maxHistoryOptions) { + if (opt.value === value) + return opt.text; + } + return String(value); + } + + function getMaxEntrySizeText(value) { + for (let opt of maxEntrySizeOptions) { + if (opt.value === value) + return opt.text; + } + const mb = Math.round(value / 1048576); + return mb + " MB"; + } + + function getAutoClearText(value) { + for (let opt of autoClearOptions) { + if (opt.value === value) + return opt.text; + } + return value + " " + I18n.tr("days"); + } + + function loadConfig() { + configLoaded = false; + configError = false; + DMSService.sendRequest("clipboard.getConfig", null, response => { + if (response.error) { + configError = true; + return; + } + config = response.result || {}; + configLoaded = true; + }); + } + + function saveConfig(key, value) { + const params = {}; + params[key] = value; + saving = true; + DMSService.sendRequest("clipboard.setConfig", params, response => { + saving = false; + if (response.error) { + ToastService.showError(I18n.tr("Failed to save clipboard setting")); + return; + } + loadConfig(); + }); + } + + Component.onCompleted: { + if (DMSService.isConnected) + loadConfig(); + } + + Connections { + target: DMSService + function onIsConnectedChanged() { + if (DMSService.isConnected) + loadConfig(); + } + } + + DankFlickable { + anchors.fill: parent + clip: true + contentHeight: mainColumn.height + Theme.spacingXL + contentWidth: width + + Column { + id: mainColumn + width: Math.min(550, parent.width - Theme.spacingL * 2) + anchors.horizontalCenter: parent.horizontalCenter + spacing: Theme.spacingXL + + Rectangle { + width: parent.width + height: warningContent.implicitHeight + Theme.spacingM * 2 + radius: Theme.cornerRadius + color: Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12) + visible: !DMSService.isConnected || configError + + Row { + id: warningContent + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingM + + DankIcon { + name: "info" + size: Theme.iconSizeSmall + color: Theme.warning + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + font.pixelSize: Theme.fontSizeSmall + text: !DMSService.isConnected + ? I18n.tr("DMS service is not connected. Clipboard settings are unavailable.") + : I18n.tr("Failed to load clipboard configuration.") + wrapMode: Text.WordWrap + width: parent.width - Theme.iconSizeSmall - Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + } + } + } + + SettingsCard { + tab: "clipboard" + tags: ["clipboard", "history", "limit"] + title: I18n.tr("History Settings") + iconName: "history" + visible: configLoaded + + SettingsDropdownRow { + tab: "clipboard" + tags: ["clipboard", "history", "max", "limit"] + settingKey: "maxHistory" + text: I18n.tr("Maximum History") + description: I18n.tr("Maximum number of clipboard entries to keep") + currentValue: root.getMaxHistoryText(root.config.maxHistory ?? 100) + options: root.maxHistoryOptions.map(opt => opt.text) + onValueChanged: value => { + for (let opt of root.maxHistoryOptions) { + if (opt.text === value) { + root.saveConfig("maxHistory", opt.value); + return; + } + } + } + } + + SettingsDropdownRow { + tab: "clipboard" + tags: ["clipboard", "entry", "size", "limit"] + settingKey: "maxEntrySize" + text: I18n.tr("Maximum Entry Size") + description: I18n.tr("Maximum size per clipboard entry") + currentValue: root.getMaxEntrySizeText(root.config.maxEntrySize ?? 5242880) + options: root.maxEntrySizeOptions.map(opt => opt.text) + onValueChanged: value => { + for (let opt of root.maxEntrySizeOptions) { + if (opt.text === value) { + root.saveConfig("maxEntrySize", opt.value); + return; + } + } + } + } + + SettingsDropdownRow { + tab: "clipboard" + tags: ["clipboard", "auto", "clear", "days"] + settingKey: "autoClearDays" + text: I18n.tr("Auto-Clear After") + description: I18n.tr("Automatically delete entries older than this") + currentValue: root.getAutoClearText(root.config.autoClearDays ?? 0) + options: root.autoClearOptions.map(opt => opt.text) + onValueChanged: value => { + for (let opt of root.autoClearOptions) { + if (opt.text === value) { + root.saveConfig("autoClearDays", opt.value); + return; + } + } + } + } + } + + SettingsCard { + tab: "clipboard" + tags: ["clipboard", "behavior"] + title: I18n.tr("Behavior") + iconName: "settings" + visible: configLoaded + + SettingsToggleRow { + tab: "clipboard" + tags: ["clipboard", "clear", "startup"] + settingKey: "clearAtStartup" + text: I18n.tr("Clear at Startup") + description: I18n.tr("Clear all history when server starts") + checked: root.config.clearAtStartup ?? false + onToggled: checked => root.saveConfig("clearAtStartup", checked) + } + } + + SettingsCard { + tab: "clipboard" + tags: ["clipboard", "advanced", "disable"] + title: I18n.tr("Advanced") + iconName: "tune" + collapsible: true + expanded: false + visible: configLoaded + + SettingsToggleRow { + tab: "clipboard" + tags: ["clipboard", "disable", "manager"] + settingKey: "disabled" + text: I18n.tr("Disable Clipboard Manager") + description: I18n.tr("Disable clipboard manager entirely (requires restart)") + checked: root.config.disabled ?? false + onToggled: checked => root.saveConfig("disabled", checked) + } + + SettingsToggleRow { + tab: "clipboard" + tags: ["clipboard", "disable", "history"] + settingKey: "disableHistory" + text: I18n.tr("Disable History Persistence") + description: I18n.tr("Clipboard works but nothing saved to disk") + checked: root.config.disableHistory ?? false + onToggled: checked => root.saveConfig("disableHistory", checked) + } + + SettingsToggleRow { + tab: "clipboard" + tags: ["clipboard", "disable", "persist", "ownership"] + settingKey: "disablePersist" + text: I18n.tr("Disable Clipboard Ownership") + description: I18n.tr("Don't preserve clipboard when apps close") + checked: root.config.disablePersist ?? false + onToggled: checked => root.saveConfig("disablePersist", checked) + } + } + } + } +} diff --git a/quickshell/PLUGINS/POPOUT_SERVICE.md b/quickshell/PLUGINS/POPOUT_SERVICE.md index cfa9ec2b..71dffba5 100644 --- a/quickshell/PLUGINS/POPOUT_SERVICE.md +++ b/quickshell/PLUGINS/POPOUT_SERVICE.md @@ -7,6 +7,7 @@ The `PopoutService` singleton provides plugins with access to all DankMaterialSh ## Automatic Injection The `popoutService` property is automatically injected into: + - Widget plugins (loaded in DankBar) - Daemon plugins (background services) - Plugin settings components @@ -23,36 +24,36 @@ property var popoutService: null ### Popouts (DankPopout-based) -| Component | Open | Close | Toggle | -|-----------|------|-------|--------| -| Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` | +| Component | Open | Close | Toggle | +| ------------------- | -------------------------- | --------------------------- | ---------------------------- | +| Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` | | Notification Center | `openNotificationCenter()` | `closeNotificationCenter()` | `toggleNotificationCenter()` | -| App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` | -| Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` | -| DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` | -| Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` | -| VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` | -| System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` | +| App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` | +| Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` | +| DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` | +| Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` | +| VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` | +| System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` | ### Modals (DankModal-based) -| Modal | Show | Hide | Notes | -|-------|------|------|-------| -| Settings | `openSettings()` | `closeSettings()` | Full settings interface | -| Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Cliphist integration | -| Spotlight | `openSpotlight()` | `closeSpotlight()` | Command launcher | -| Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` | -| Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Fullscreen version, has `toggleProcessListModal()` | -| Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection | -| Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details | -| WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network authentication | -| Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details | +| Modal | Show | Hide | Notes | +| ------------------ | ------------------------- | ------------------------- | -------------------------------------------------- | +| Settings | `openSettings()` | `closeSettings()` | Full settings interface | +| Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Clipboard integration | +| Spotlight | `openSpotlight()` | `closeSpotlight()` | Command launcher | +| Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` | +| Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Fullscreen version, has `toggleProcessListModal()` | +| Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection | +| Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details | +| WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network authentication | +| Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details | ### Slideouts -| Component | Open | Close | Toggle | -|-----------|------|-------|--------| -| Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` | +| Component | Open | Close | Toggle | +| --------- | --------------- | ---------------- | ----------------- | +| Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` | ## Usage Examples @@ -205,6 +206,7 @@ LazyLoader { The service is injected in three locations: 1. **DMSShell.qml** (daemon plugins): + ```qml Instantiator { delegate: Loader { @@ -218,6 +220,7 @@ Instantiator { ``` 2. **WidgetHost.qml** (widget plugins): + ```qml onLoaded: { if (item.popoutService !== undefined) { @@ -227,6 +230,7 @@ onLoaded: { ``` 3. **CenterSection.qml** (center widgets): + ```qml onLoaded: { if (item.popoutService !== undefined) { @@ -236,6 +240,7 @@ onLoaded: { ``` 4. **PluginsTab.qml** (settings): + ```qml onLoaded: { if (item && typeof PopoutService !== "undefined") { @@ -247,11 +252,13 @@ onLoaded: { ## Best Practices 1. **Use Optional Chaining**: Always use `?.` to handle null cases + ```qml popoutService?.toggleControlCenter() ``` 2. **Check Availability**: Some popouts may not be available + ```qml if (popoutService && popoutService.controlCenterPopout) { popoutService.toggleControlCenter() @@ -261,6 +268,7 @@ onLoaded: { 3. **Lazy Loading**: First access may activate lazy loaders - this is normal 4. **Feature Detection**: Some popouts require specific features + ```qml if (BatteryService.batteryAvailable) { popoutService?.openBattery() @@ -272,6 +280,7 @@ onLoaded: { ## Example Plugin See `PLUGINS/PopoutControlExample/` for a complete working example that demonstrates: + - Widget creation with popout controls - Menu-based popout selection - Proper service usage diff --git a/quickshell/Services/DMSService.qml b/quickshell/Services/DMSService.qml index ba4877b8..3fcc2682 100644 --- a/quickshell/Services/DMSService.qml +++ b/quickshell/Services/DMSService.qml @@ -24,6 +24,7 @@ Singleton { readonly property string socketPath: Quickshell.env("DMS_SOCKET") property var pendingRequests: ({}) + property var clipboardRequestIds: ({}) property int requestIdCounter: 0 property bool shownOutdatedError: false property string updateCommand: "dms update" @@ -179,17 +180,19 @@ Singleton { parser: SplitParser { onRead: line => { - if (!line || line.length === 0) { + if (!line || line.length === 0) return; - } - - console.log("DMSService: Request socket <<", line); try { const response = JSON.parse(line); + const isClipboard = clipboardRequestIds[response.id]; + if (isClipboard) + delete clipboardRequestIds[response.id]; + else + console.log("DMSService: Request socket <<", line); handleResponse(response); } catch (e) { - console.warn("DMSService: Failed to parse request response:", line, e); + console.warn("DMSService: Failed to parse request response"); } } } @@ -209,17 +212,16 @@ Singleton { parser: SplitParser { onRead: line => { - if (!line || line.length === 0) { + if (!line || line.length === 0) return; - } - - console.log("DMSService: Subscribe socket <<", line); try { const response = JSON.parse(line); + if (!line.includes("clipboard")) + console.log("DMSService: Subscribe socket <<", line); handleSubscriptionEvent(response); } catch (e) { - console.warn("DMSService: Failed to parse subscription event:", line, e); + console.warn("DMSService: Failed to parse subscription event"); } } } @@ -394,11 +396,14 @@ Singleton { request.params = params; } - if (callback) { + if (callback) pendingRequests[id] = callback; - } - console.log("DMSService.sendRequest: Sending request id=" + id + " method=" + method); + if (method.startsWith("clipboard")) { + clipboardRequestIds[id] = true; + } else { + console.log("DMSService.sendRequest: Sending request id=" + id + " method=" + method); + } requestSocket.send(request); }