1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 21:42:51 -05:00

clipboard: introduce native clipboard, clip-persist, clip-storage functionality

This commit is contained in:
bbedward
2025-12-11 09:41:07 -05:00
parent 7c88865d67
commit 6d62229b5f
41 changed files with 4372 additions and 547 deletions

View File

@@ -102,7 +102,11 @@ linters:
- linters: - linters:
- ineffassign - ineffassign
path: internal/proto/ path: internal/proto/
# binary.Write to bytes.Buffer can't fail # binary.Write/Read to bytes.Buffer can't fail
- linters: - linters:
- errcheck - 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"

View File

@@ -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 <id>",
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 <id>",
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")
}

View File

@@ -513,5 +513,6 @@ func getCommonCommands() []*cobra.Command {
screenshotCmd, screenshotCmd,
notifyActionCmd, notifyActionCmd,
matugenCmd, matugenCmd,
clipboardCmd,
} }
} }

View File

@@ -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()
}

View File

@@ -15,7 +15,9 @@ require (
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.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/exp v0.0.0-20251125195548-87e1e737ad39
golang.org/x/image v0.34.0
) )
require ( require (
@@ -65,6 +67,6 @@ require (
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sys v0.38.0 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 gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@@ -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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 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 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 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 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= 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 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 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/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 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= 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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= 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 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-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

View File

@@ -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/"
}

View File

@@ -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, &timestamp)
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])
}

View File

@@ -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
}

View File

@@ -615,10 +615,11 @@ func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalComma
spawnDms := `spawn-at-startup "dms" "run"` spawnDms := `spawn-at-startup "dms" "run"`
if !strings.Contains(config, spawnDms) { if !strings.Contains(config, spawnDms) {
config = strings.Replace(config, // Insert spawn-at-startup for dms after the environment block
`spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"`, envBlockEnd := regexp.MustCompile(`environment \{[^}]*\}`)
`spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"`+"\n"+spawnDms, if loc := envBlockEnd.FindStringIndex(config); loc != nil {
1) config = config[:loc[1]] + "\n" + spawnDms + config[loc[1]:]
}
} }
return config return config

View File

@@ -12,7 +12,6 @@ monitor = , preferred,auto,auto
# ================== # ==================
exec-once = dbus-update-activation-environment --systemd --all exec-once = dbus-update-activation-environment --systemd --all
exec-once = systemctl --user start hyprland-session.target exec-once = systemctl --user start hyprland-session.target
exec-once = bash -c "wl-paste --watch cliphist store &"
# ================== # ==================
# INPUT CONFIG # INPUT CONFIG

View File

@@ -109,7 +109,6 @@ overview {
// which may be more convenient to use. // which may be more convenient to use.
// See the binds section below for more spawn examples. // See the binds section below for more spawn examples.
// This line starts waybar, a commonly used bar for Wayland compositors. // This line starts waybar, a commonly used bar for Wayland compositors.
spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"
environment { environment {
XDG_CURRENT_DESKTOP "niri" XDG_CURRENT_DESKTOP "niri"
} }

View File

@@ -139,7 +139,6 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem}, "ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
"kitty": {Name: "kitty", Repository: RepoTypeSystem}, "kitty": {Name: "kitty", Repository: RepoTypeSystem},
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem}, "alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem}, "wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem}, "accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},

View File

@@ -188,23 +188,12 @@ func (b *BaseDistribution) detectSpecificTerminal(terminal deps.Terminal) deps.D
func (b *BaseDistribution) detectClipboardTools() []deps.Dependency { func (b *BaseDistribution) detectClipboardTools() []deps.Dependency {
var dependencies []deps.Dependency var dependencies []deps.Dependency
cliphist := deps.StatusMissing
if b.commandExists("cliphist") {
cliphist = deps.StatusInstalled
}
wlClipboard := deps.StatusMissing wlClipboard := deps.StatusMissing
if b.commandExists("wl-copy") && b.commandExists("wl-paste") { if b.commandExists("wl-copy") && b.commandExists("wl-paste") {
wlClipboard = deps.StatusInstalled wlClipboard = deps.StatusInstalled
} }
dependencies = append(dependencies, dependencies = append(dependencies,
deps.Dependency{
Name: "cliphist",
Status: cliphist,
Description: "Wayland clipboard manager",
Required: true,
},
deps.Dependency{ deps.Dependency{
Name: "wl-clipboard", Name: "wl-clipboard",
Status: wlClipboard, Status: wlClipboard,

View File

@@ -111,7 +111,6 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"quickshell": d.getQuickshellMapping(variants["quickshell"]), "quickshell": d.getQuickshellMapping(variants["quickshell"]),
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
"dgop": {Name: "dgop", 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"}, "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 { if err := d.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err) return fmt.Errorf("failed to install Rust: %w", err)
} }
case "cliphist", "dgop": case "dgop":
if err := d.installGo(ctx, sudoPassword, progressChan); err != nil { if err := d.installGo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Go: %w", err) return fmt.Errorf("failed to install Go: %w", err)
} }

View File

@@ -124,7 +124,6 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
// COPR packages // COPR packages
"quickshell": f.getQuickshellMapping(variants["quickshell"]), "quickshell": f.getQuickshellMapping(variants["quickshell"]),
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"cliphist": {Name: "cliphist", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]), "dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"}, "dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
} }

View File

@@ -151,7 +151,6 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"quickshell": g.getQuickshellMapping(variants["quickshell"]), "quickshell": g.getQuickshellMapping(variants["quickshell"]),
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword}, "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)"]), "dms (DankMaterialShell)": g.getDmsMapping(variants["dms (DankMaterialShell)"]),
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"}, "dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
} }

View File

@@ -86,10 +86,6 @@ func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, pack
if err := m.installMatugen(ctx, sudoPassword, progressChan); err != nil { if err := m.installMatugen(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install matugen: %w", err) 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": case "xwayland-satellite":
if err := m.installXwaylandSatellite(ctx, sudoPassword, progressChan); err != nil { if err := m.installXwaylandSatellite(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install xwayland-satellite: %w", err) return fmt.Errorf("failed to install xwayland-satellite: %w", err)
@@ -803,52 +799,6 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
return nil 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 { func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
m.log("Installing xwayland-satellite from source...") m.log("Installing xwayland-satellite from source...")

View File

@@ -110,7 +110,6 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem}, "wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem}, "xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem}, "accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
// DMS packages from OBS // DMS packages from OBS
"dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]), "dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]),

View File

@@ -121,7 +121,6 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
"quickshell": u.getQuickshellMapping(variants["quickshell"]), "quickshell": u.getQuickshellMapping(variants["quickshell"]),
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"}, "matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
"dgop": {Name: "dgop", 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"}, "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 buildDeps["libpam0g-dev"] = true
case "matugen": case "matugen":
buildDeps["curl"] = true 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 { if err := u.installRust(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Rust: %w", err) return fmt.Errorf("failed to install Rust: %w", err)
} }
case "cliphist", "dgop": case "dgop":
if err := u.installGo(ctx, sudoPassword, progressChan); err != nil { if err := u.installGo(ctx, sudoPassword, progressChan); err != nil {
return fmt.Errorf("failed to install Go: %w", err) return fmt.Errorf("failed to install Go: %w", err)
} }

View File

@@ -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})
}
}

View File

@@ -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"})
}

File diff suppressed because it is too large Load Diff

View File

@@ -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:
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" "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/cups"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
@@ -147,6 +148,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
return 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 { switch req.Method {
case "ping": case "ping":
models.Respond(conn, req.ID, "pong") models.Respond(conn, req.ID, "pong")

View File

@@ -18,6 +18,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness" "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/cups"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
@@ -32,7 +33,7 @@ import (
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
) )
const APIVersion = 22 const APIVersion = 23
var CLIVersion = "dev" var CLIVersion = "dev"
@@ -63,6 +64,7 @@ var extWorkspaceManager *extworkspace.Manager
var brightnessManager *brightness.Manager var brightnessManager *brightness.Manager
var wlrOutputManager *wlroutput.Manager var wlrOutputManager *wlroutput.Manager
var evdevManager *evdev.Manager var evdevManager *evdev.Manager
var clipboardManager *clipboard.Manager
var wlContext *wlcontext.SharedContext var wlContext *wlcontext.SharedContext
var capabilitySubscribers syncmap.Map[string, chan ServerInfo] var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
@@ -336,6 +338,31 @@ func InitializeEvdevManager() error {
return nil 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) { func handleConnection(conn net.Conn) {
defer conn.Close() defer conn.Close()
@@ -409,6 +436,10 @@ func getCapabilities() Capabilities {
caps = append(caps, "evdev") caps = append(caps, "evdev")
} }
if clipboardManager != nil {
caps = append(caps, "clipboard")
}
return Capabilities{Capabilities: caps} return Capabilities{Capabilities: caps}
} }
@@ -463,6 +494,10 @@ func getServerInfo() ServerInfo {
caps = append(caps, "evdev") caps = append(caps, "evdev")
} }
if clipboardManager != nil {
caps = append(caps, "clipboard")
}
return ServerInfo{ return ServerInfo{
APIVersion: APIVersion, APIVersion: APIVersion,
CLIVersion: CLIVersion, 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() { go func() {
wg.Wait() wg.Wait()
close(eventChan) close(eventChan)
@@ -1096,6 +1163,9 @@ func cleanupManagers() {
if evdevManager != nil { if evdevManager != nil {
evdevManager.Close() evdevManager.Close()
} }
if clipboardManager != nil {
clipboardManager.Close()
}
if wlContext != nil { if wlContext != nil {
wlContext.Close() wlContext.Close()
} }
@@ -1259,6 +1329,18 @@ func Start(printDocs bool) error {
log.Info("Evdev:") log.Info("Evdev:")
log.Info(" evdev.getState - Get current evdev state (caps lock)") log.Info(" evdev.getState - Get current evdev state (caps lock)")
log.Info(" evdev.subscribe - Subscribe to evdev state changes (streaming)") 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("")
} }
log.Info("Initializing managers...") log.Info("Initializing managers...")
@@ -1366,10 +1448,15 @@ func Start(printDocs bool) error {
} }
}() }()
if wlContext != nil { go func() {
wlContext.Start() if err := InitializeClipboardManager(); err != nil {
log.Info("Wayland event dispatcher started") log.Warnf("Clipboard manager unavailable: %v", err)
} }
if wlContext != nil {
wlContext.Start()
log.Info("Wayland event dispatcher started")
}
}()
log.Info("") log.Info("")
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities) log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)

View File

@@ -1,8 +1,11 @@
package wlcontext package wlcontext
import ( import (
"errors"
"fmt" "fmt"
"os"
"sync" "sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
"github.com/AvengeMedia/DankMaterialShell/core/internal/log" "github.com/AvengeMedia/DankMaterialShell/core/internal/log"
@@ -13,6 +16,7 @@ type SharedContext struct {
display *wlclient.Display display *wlclient.Display
stopChan chan struct{} stopChan chan struct{}
fatalError chan error fatalError chan error
cmdQueue chan func()
wg sync.WaitGroup wg sync.WaitGroup
mu sync.Mutex mu sync.Mutex
started bool started bool
@@ -28,6 +32,7 @@ func New() (*SharedContext, error) {
display: display, display: display,
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
fatalError: make(chan error, 1), fatalError: make(chan error, 1),
cmdQueue: make(chan func(), 256),
started: false, started: false,
} }
@@ -51,6 +56,13 @@ func (sc *SharedContext) Display() *wlclient.Display {
return sc.display return sc.display
} }
func (sc *SharedContext) Post(fn func()) {
select {
case sc.cmdQueue <- fn:
default:
}
}
func (sc *SharedContext) FatalError() <-chan error { func (sc *SharedContext) FatalError() <-chan error {
return sc.fatalError return sc.fatalError
} }
@@ -74,10 +86,35 @@ func (sc *SharedContext) eventDispatcher() {
case <-sc.stopChan: case <-sc.stopChan:
return return
default: 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
} }
} }
} }

View File

@@ -6,6 +6,7 @@ import (
"net" "net"
"os" "os"
"sync" "sync"
"time"
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
) )
@@ -28,6 +29,12 @@ func (ctx *Context) Register(p Proxy) {
ctx.objects.Store(id, p) 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) { func (ctx *Context) Unregister(p Proxy) {
ctx.objects.Delete(p.ID()) ctx.objects.Delete(p.ID())
} }
@@ -47,6 +54,10 @@ func (ctx *Context) Close() error {
return ctx.conn.Close() 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 // Dispatch reads and processes incoming messages and calls [client.Dispatcher.Dispatch] on the
// respective wayland protocol. // respective wayland protocol.
// Dispatch must be called on the same goroutine as other interactions with the Context. // Dispatch must be called on the same goroutine as other interactions with the Context.

View File

@@ -865,4 +865,32 @@ Item {
target: "plugins" 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"
}
} }

View File

@@ -1,14 +1,12 @@
import QtQuick import QtQuick
import Quickshell
import qs.Common import qs.Common
import qs.Services
import qs.Widgets import qs.Widgets
import qs.Modals.Clipboard
Item { Item {
id: clipboardContent id: clipboardContent
required property var modal required property var modal
required property var filteredModel
required property var clearConfirmDialog required property var clearConfirmDialog
property alias searchField: searchField property alias searchField: searchField
@@ -22,7 +20,6 @@ Item {
spacing: Theme.spacingL spacing: Theme.spacingL
focus: false focus: false
// Header
ClipboardHeader { ClipboardHeader {
id: header id: header
width: parent.width width: parent.width
@@ -31,14 +28,13 @@ Item {
onKeyboardHintsToggled: modal.showKeyboardHints = !modal.showKeyboardHints onKeyboardHintsToggled: modal.showKeyboardHints = !modal.showKeyboardHints
onClearAllClicked: { onClearAllClicked: {
clearConfirmDialog.show(I18n.tr("Clear All History?"), I18n.tr("This will permanently delete all clipboard history."), function () { clearConfirmDialog.show(I18n.tr("Clear All History?"), I18n.tr("This will permanently delete all clipboard history."), function () {
modal.clearAll() modal.clearAll();
modal.hide() modal.hide();
}, function () {}) }, function () {});
} }
onCloseClicked: modal.hide() onCloseClicked: modal.hide()
} }
// Search Field
DankTextField { DankTextField {
id: searchField id: searchField
width: parent.width width: parent.width
@@ -49,30 +45,29 @@ Item {
ignoreTabKeys: true ignoreTabKeys: true
keyForwardTargets: [modal.modalFocusScope] keyForwardTargets: [modal.modalFocusScope]
onTextChanged: { onTextChanged: {
modal.searchText = text modal.searchText = text;
modal.updateFilteredModel() modal.updateFilteredModel();
} }
Keys.onEscapePressed: function (event) { Keys.onEscapePressed: function (event) {
modal.hide() modal.hide();
event.accepted = true event.accepted = true;
} }
Component.onCompleted: { Component.onCompleted: {
Qt.callLater(function () { Qt.callLater(function () {
forceActiveFocus() forceActiveFocus();
}) });
} }
Connections { Connections {
target: modal target: modal
function onOpened() { function onOpened() {
Qt.callLater(function () { Qt.callLater(function () {
searchField.forceActiveFocus() searchField.forceActiveFocus();
}) });
} }
} }
} }
// List Container
Rectangle { Rectangle {
width: parent.width width: parent.width
height: parent.height - ClipboardConstants.headerHeight - 70 height: parent.height - ClipboardConstants.headerHeight - 70
@@ -83,7 +78,10 @@ Item {
DankListView { DankListView {
id: clipboardListView id: clipboardListView
anchors.fill: parent anchors.fill: parent
model: filteredModel model: ScriptModel {
values: clipboardContent.modal.clipboardEntries
objectProp: "id"
}
currentIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0 currentIndex: clipboardContent.modal ? clipboardContent.modal.selectedIndex : 0
spacing: Theme.spacingXS spacing: Theme.spacingXS
@@ -97,21 +95,21 @@ Item {
function ensureVisible(index) { function ensureVisible(index) {
if (index < 0 || index >= count) { if (index < 0 || index >= count) {
return return;
} }
const itemHeight = ClipboardConstants.itemHeight + spacing const itemHeight = ClipboardConstants.itemHeight + spacing;
const itemY = index * itemHeight const itemY = index * itemHeight;
const itemBottom = itemY + itemHeight const itemBottom = itemY + itemHeight;
if (itemY < contentY) { if (itemY < contentY) {
contentY = itemY contentY = itemY;
} else if (itemBottom > contentY + height) { } else if (itemBottom > contentY + height) {
contentY = itemBottom - height contentY = itemBottom - height;
} }
} }
onCurrentIndexChanged: { onCurrentIndexChanged: {
if (clipboardContent.modal && clipboardContent.modal.keyboardNavigationActive && currentIndex >= 0) { if (clipboardContent.modal?.keyboardNavigationActive && currentIndex >= 0) {
ensureVisible(currentIndex) ensureVisible(currentIndex);
} }
} }
@@ -120,28 +118,27 @@ Item {
anchors.centerIn: parent anchors.centerIn: parent
font.pixelSize: Theme.fontSizeMedium font.pixelSize: Theme.fontSizeMedium
color: Theme.surfaceVariantText color: Theme.surfaceVariantText
visible: filteredModel.count === 0 visible: clipboardContent.modal.clipboardEntries.length === 0
} }
delegate: ClipboardEntry { delegate: ClipboardEntry {
required property int index required property int index
required property var model required property var modelData
width: clipboardListView.width width: clipboardListView.width
height: ClipboardConstants.itemHeight height: ClipboardConstants.itemHeight
entryData: model.entry entry: modelData
entryIndex: index + 1 entryIndex: index + 1
itemIndex: index itemIndex: index
isSelected: clipboardContent.modal && clipboardContent.modal.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex isSelected: clipboardContent.modal?.keyboardNavigationActive && index === clipboardContent.modal.selectedIndex
modal: clipboardContent.modal modal: clipboardContent.modal
listView: clipboardListView listView: clipboardListView
onCopyRequested: clipboardContent.modal.copyEntry(model.entry) onCopyRequested: clipboardContent.modal.copyEntry(modelData)
onDeleteRequested: clipboardContent.modal.deleteEntry(model.entry) onDeleteRequested: clipboardContent.modal.deleteEntry(modelData)
} }
} }
} }
// Spacer for keyboard hints
Item { Item {
width: parent.width width: parent.width
height: modal.showKeyboardHints ? ClipboardConstants.keyboardHintsHeight + Theme.spacingL : 0 height: modal.showKeyboardHints ? ClipboardConstants.keyboardHintsHeight + Theme.spacingL : 0
@@ -155,7 +152,6 @@ Item {
} }
} }
// Keyboard Hints Overlay
ClipboardKeyboardHints { ClipboardKeyboardHints {
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.left: parent.left anchors.left: parent.left

View File

@@ -1,14 +1,11 @@
import QtQuick import QtQuick
import QtQuick.Effects
import Quickshell.Io
import qs.Common import qs.Common
import qs.Widgets import qs.Widgets
import qs.Modals.Clipboard
Rectangle { Rectangle {
id: entry id: root
required property string entryData required property var entry
required property int entryIndex required property int entryIndex
required property int itemIndex required property int itemIndex
required property bool isSelected required property bool isSelected
@@ -18,15 +15,15 @@ Rectangle {
signal copyRequested signal copyRequested
signal deleteRequested signal deleteRequested
readonly property string entryType: modal ? modal.getEntryType(entryData) : "text" readonly property string entryType: modal ? modal.getEntryType(entry) : "text"
readonly property string entryPreview: modal ? modal.getEntryPreview(entryData) : entryData readonly property string entryPreview: modal ? modal.getEntryPreview(entry) : ""
radius: Theme.cornerRadius radius: Theme.cornerRadius
color: { color: {
if (isSelected) { 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 { Row {
@@ -35,7 +32,6 @@ Rectangle {
anchors.rightMargin: Theme.spacingS anchors.rightMargin: Theme.spacingS
spacing: Theme.spacingL spacing: Theme.spacingL
// Index indicator
Rectangle { Rectangle {
width: 24 width: 24
height: 24 height: 24
@@ -52,25 +48,22 @@ Rectangle {
} }
} }
// Content area
Row { Row {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: parent.width - 68 width: parent.width - 68
spacing: Theme.spacingM spacing: Theme.spacingM
// Thumbnail/Icon
ClipboardThumbnail { ClipboardThumbnail {
width: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize width: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize
height: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize height: entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
entryData: entry.entryData entry: root.entry
entryType: entry.entryType entryType: root.entryType
modal: entry.modal modal: root.modal
listView: entry.listView listView: root.listView
itemIndex: entry.itemIndex itemIndex: root.itemIndex
} }
// Text content
Column { Column {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: parent.width - (entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize) - Theme.spacingM width: parent.width - (entryType === "image" ? ClipboardConstants.thumbnailSize : Theme.iconSize) - Theme.spacingM
@@ -80,11 +73,11 @@ Rectangle {
text: { text: {
switch (entryType) { switch (entryType) {
case "image": case "image":
return I18n.tr("Image") + " • " + entryPreview return I18n.tr("Image") + " • " + entryPreview;
case "long_text": case "long_text":
return I18n.tr("Long Text") return I18n.tr("Long Text");
default: default:
return I18n.tr("Text") return I18n.tr("Text");
} }
} }
font.pixelSize: Theme.fontSizeSmall font.pixelSize: Theme.fontSizeSmall
@@ -107,7 +100,6 @@ Rectangle {
} }
} }
// Delete button
DankActionButton { DankActionButton {
anchors.right: parent.right anchors.right: parent.right
anchors.rightMargin: Theme.spacingM anchors.rightMargin: Theme.spacingM
@@ -118,7 +110,6 @@ Rectangle {
onClicked: deleteRequested() onClicked: deleteRequested()
} }
// Click area
MouseArea { MouseArea {
id: mouseArea id: mouseArea
anchors.fill: parent anchors.fill: parent

View File

@@ -1,9 +1,7 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import QtQuick import QtQuick
import Quickshell
import Quickshell.Hyprland import Quickshell.Hyprland
import Quickshell.Io
import qs.Common import qs.Common
import qs.Modals.Common import qs.Modals.Common
import qs.Services import qs.Services
@@ -27,33 +25,27 @@ DankModal {
property Component clipboardContent property Component clipboardContent
property int activeImageLoads: 0 property int activeImageLoads: 0
readonly property int maxConcurrentLoads: 3 readonly property int maxConcurrentLoads: 3
readonly property bool clipboardAvailable: DMSService.isConnected && DMSService.capabilities.includes("clipboard")
function updateFilteredModel() { function updateFilteredModel() {
filteredClipboardModel.clear(); const query = searchText.trim();
for (var i = 0; i < clipboardModel.count; i++) { if (query.length === 0) {
const entry = clipboardModel.get(i).entry; clipboardEntries = internalEntries;
if (searchText.trim().length === 0) { } else {
filteredClipboardModel.append({ const lowerQuery = query.toLowerCase();
"entry": entry clipboardEntries = internalEntries.filter(entry => entry.preview.toLowerCase().includes(lowerQuery));
});
} else {
const content = getEntryPreview(entry).toLowerCase();
if (content.includes(searchText.toLowerCase())) {
filteredClipboardModel.append({
"entry": entry
});
}
}
} }
clipboardHistoryModal.totalCount = filteredClipboardModel.count; totalCount = clipboardEntries.length;
if (filteredClipboardModel.count === 0) { if (clipboardEntries.length === 0) {
keyboardNavigationActive = false; keyboardNavigationActive = false;
selectedIndex = 0; selectedIndex = 0;
} else if (selectedIndex >= filteredClipboardModel.count) { } else if (selectedIndex >= clipboardEntries.length) {
selectedIndex = filteredClipboardModel.count - 1; selectedIndex = clipboardEntries.length - 1;
} }
} }
property var internalEntries: []
function toggle() { function toggle() {
if (shouldBeVisible) { if (shouldBeVisible) {
hide(); hide();
@@ -63,15 +55,19 @@ DankModal {
} }
function show() { function show() {
if (!clipboardAvailable) {
ToastService.showError(I18n.tr("Clipboard service not available"));
return;
}
open(); open();
clipboardHistoryModal.searchText = ""; searchText = "";
clipboardHistoryModal.activeImageLoads = 0; activeImageLoads = 0;
clipboardHistoryModal.shouldHaveFocus = true; shouldHaveFocus = true;
refreshClipboard(); refreshClipboard();
keyboardController.reset(); keyboardController.reset();
Qt.callLater(function () { Qt.callLater(function () {
if (contentLoader.item && contentLoader.item.searchField) { if (contentLoader.item?.searchField) {
contentLoader.item.searchField.text = ""; contentLoader.item.searchField.text = "";
contentLoader.item.searchField.forceActiveFocus(); contentLoader.item.searchField.forceActiveFocus();
} }
@@ -80,60 +76,90 @@ DankModal {
function hide() { function hide() {
close(); close();
clipboardHistoryModal.searchText = ""; searchText = "";
clipboardHistoryModal.activeImageLoads = 0; activeImageLoads = 0;
updateFilteredModel(); internalEntries = [];
clipboardEntries = [];
keyboardController.reset(); keyboardController.reset();
cleanupTempFiles();
}
function cleanupTempFiles() {
Quickshell.execDetached(["sh", "-c", "rm -f /tmp/clipboard_*.png"]);
} }
function refreshClipboard() { 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) { function copyEntry(entry) {
const entryId = entry.split('\t')[0]; DMSService.sendRequest("clipboard.getEntry", {
Quickshell.execDetached(["sh", "-c", `cliphist decode ${entryId} | wl-copy`]); "id": entry.id
ToastService.showInfo(I18n.tr("Copied to clipboard")); }, function (response) {
hide(); 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) { 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() { 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) { function getEntryPreview(entry) {
let content = entry.replace(/^\s*\d+\s+/, ""); return entry.preview || "";
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;
} }
function getEntryType(entry) { 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"; return "image";
} }
if (entry.length > ClipboardConstants.longTextThreshold) { if (entry.size > ClipboardConstants.longTextThreshold) {
return "long_text"; return "long_text";
} }
return "text"; return "text";
@@ -168,55 +194,18 @@ DankModal {
} else if (clipboardHistoryModal.shouldBeVisible) { } else if (clipboardHistoryModal.shouldBeVisible) {
clipboardHistoryModal.shouldHaveFocus = true; clipboardHistoryModal.shouldHaveFocus = true;
clipboardHistoryModal.modalFocusScope.forceActiveFocus(); clipboardHistoryModal.modalFocusScope.forceActiveFocus();
if (clipboardHistoryModal.contentLoader.item && clipboardHistoryModal.contentLoader.item.searchField) { if (clipboardHistoryModal.contentLoader.item?.searchField) {
clipboardHistoryModal.contentLoader.item.searchField.forceActiveFocus(); clipboardHistoryModal.contentLoader.item.searchField.forceActiveFocus();
} }
} }
} }
} }
property alias filteredClipboardModel: filteredClipboardModel
property alias clipboardModel: clipboardModel
property var confirmDialog: clearConfirmDialog 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: Component {
ClipboardContent { ClipboardContent {
modal: clipboardHistoryModal modal: clipboardHistoryModal
filteredModel: filteredClipboardModel
clearConfirmDialog: clipboardHistoryModal.confirmDialog clearConfirmDialog: clipboardHistoryModal.confirmDialog
} }
} }

View File

@@ -1,5 +1,4 @@
import QtQuick import QtQuick
import qs.Common
QtObject { QtObject {
id: keyboardController id: keyboardController
@@ -7,125 +6,133 @@ QtObject {
required property var modal required property var modal
function reset() { function reset() {
modal.selectedIndex = 0 modal.selectedIndex = 0;
modal.keyboardNavigationActive = false modal.keyboardNavigationActive = false;
modal.showKeyboardHints = false modal.showKeyboardHints = false;
} }
function selectNext() { function selectNext() {
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0) { if (!modal.clipboardEntries || modal.clipboardEntries.length === 0) {
return return;
} }
modal.keyboardNavigationActive = true modal.keyboardNavigationActive = true;
modal.selectedIndex = Math.min(modal.selectedIndex + 1, modal.filteredClipboardModel.count - 1) modal.selectedIndex = Math.min(modal.selectedIndex + 1, modal.clipboardEntries.length - 1);
} }
function selectPrevious() { function selectPrevious() {
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0) { if (!modal.clipboardEntries || modal.clipboardEntries.length === 0) {
return return;
} }
modal.keyboardNavigationActive = true modal.keyboardNavigationActive = true;
modal.selectedIndex = Math.max(modal.selectedIndex - 1, 0) modal.selectedIndex = Math.max(modal.selectedIndex - 1, 0);
} }
function copySelected() { function copySelected() {
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.filteredClipboardModel.count) { if (!modal.clipboardEntries || modal.clipboardEntries.length === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.clipboardEntries.length) {
return return;
} }
const selectedEntry = modal.filteredClipboardModel.get(modal.selectedIndex).entry const selectedEntry = modal.clipboardEntries[modal.selectedIndex];
modal.copyEntry(selectedEntry) modal.copyEntry(selectedEntry);
} }
function deleteSelected() { function deleteSelected() {
if (!modal.filteredClipboardModel || modal.filteredClipboardModel.count === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.filteredClipboardModel.count) { if (!modal.clipboardEntries || modal.clipboardEntries.length === 0 || modal.selectedIndex < 0 || modal.selectedIndex >= modal.clipboardEntries.length) {
return return;
} }
const selectedEntry = modal.filteredClipboardModel.get(modal.selectedIndex).entry const selectedEntry = modal.clipboardEntries[modal.selectedIndex];
modal.deleteEntry(selectedEntry) modal.deleteEntry(selectedEntry);
} }
function handleKey(event) { function handleKey(event) {
if (event.key === Qt.Key_Escape) { switch (event.key) {
case Qt.Key_Escape:
if (modal.keyboardNavigationActive) { if (modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = false modal.keyboardNavigationActive = false;
event.accepted = true
} else { } else {
modal.hide() modal.hide();
event.accepted = true
} }
} 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) { if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = true modal.keyboardNavigationActive = true;
modal.selectedIndex = 0 modal.selectedIndex = 0;
event.accepted = true
} else { } else {
selectNext() selectNext();
event.accepted = true
} }
} 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) { if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = true modal.keyboardNavigationActive = true;
modal.selectedIndex = 0 modal.selectedIndex = 0;
event.accepted = true
} else if (modal.selectedIndex === 0) { } else if (modal.selectedIndex === 0) {
modal.keyboardNavigationActive = false modal.keyboardNavigationActive = false;
event.accepted = true
} else { } else {
selectPrevious() selectPrevious();
event.accepted = true
} }
} else if (event.key === Qt.Key_N && event.modifiers & Qt.ControlModifier) { event.accepted = true;
if (!modal.keyboardNavigationActive) { return;
modal.keyboardNavigationActive = true case Qt.Key_F10:
modal.selectedIndex = 0 modal.showKeyboardHints = !modal.showKeyboardHints;
} else { event.accepted = true;
selectNext() return;
} }
event.accepted = true
} else if (event.key === Qt.Key_P && event.modifiers & Qt.ControlModifier) { if (event.modifiers & Qt.ControlModifier) {
if (!modal.keyboardNavigationActive) { switch (event.key) {
modal.keyboardNavigationActive = true case Qt.Key_N:
modal.selectedIndex = 0 case Qt.Key_J:
} else if (modal.selectedIndex === 0) { if (!modal.keyboardNavigationActive) {
modal.keyboardNavigationActive = false modal.keyboardNavigationActive = true;
} else { modal.selectedIndex = 0;
selectPrevious() } else {
} selectNext();
event.accepted = true }
} else if (event.key === Qt.Key_J && event.modifiers & Qt.ControlModifier) { event.accepted = true;
if (!modal.keyboardNavigationActive) { return;
modal.keyboardNavigationActive = true case Qt.Key_P:
modal.selectedIndex = 0 case Qt.Key_K:
} else { if (!modal.keyboardNavigationActive) {
selectNext() modal.keyboardNavigationActive = true;
} modal.selectedIndex = 0;
event.accepted = true } else if (modal.selectedIndex === 0) {
} else if (event.key === Qt.Key_K && event.modifiers & Qt.ControlModifier) { modal.keyboardNavigationActive = false;
if (!modal.keyboardNavigationActive) { } else {
modal.keyboardNavigationActive = true selectPrevious();
modal.selectedIndex = 0 }
} else if (modal.selectedIndex === 0) { event.accepted = true;
modal.keyboardNavigationActive = false return;
} else { case Qt.Key_C:
selectPrevious() if (modal.keyboardNavigationActive) {
} copySelected();
event.accepted = true event.accepted = true;
} else if (event.key === Qt.Key_Delete && (event.modifiers & Qt.ShiftModifier)) { }
modal.clearAll() return;
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
} }
} }
if (event.key === Qt.Key_F10) {
modal.showKeyboardHints = !modal.showKeyboardHints if (event.modifiers & Qt.ShiftModifier && event.key === Qt.Key_Delete) {
event.accepted = true 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;
}
} }
} }
} }

View File

@@ -1,7 +1,6 @@
import QtQuick import QtQuick
import qs.Common import qs.Common
import qs.Widgets import qs.Widgets
import qs.Modals.Clipboard
Rectangle { Rectangle {
id: keyboardHints id: keyboardHints

View File

@@ -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
}
}

View File

@@ -1,14 +1,13 @@
import QtQuick import QtQuick
import QtQuick.Effects import QtQuick.Effects
import Quickshell.Io
import qs.Common import qs.Common
import qs.Services
import qs.Widgets import qs.Widgets
import qs.Modals.Clipboard
Item { Item {
id: thumbnail id: thumbnail
required property string entryData required property var entry
required property string entryType required property string entryType
required property var modal required property var modal
required property var listView required property var listView
@@ -17,13 +16,12 @@ Item {
Image { Image {
id: thumbnailImage id: thumbnailImage
property string entryId: entryData.split('\t')[0]
property bool isVisible: false property bool isVisible: false
property string cachedImageData: "" property string cachedImageData: ""
property bool loadQueued: false property bool loadQueued: false
anchors.fill: parent anchors.fill: parent
source: "" source: cachedImageData ? `data:image/png;base64,${cachedImageData}` : ""
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
smooth: true smooth: true
cache: false cache: false
@@ -32,53 +30,66 @@ Item {
sourceSize.width: 128 sourceSize.width: 128
sourceSize.height: 128 sourceSize.height: 128
onCachedImageDataChanged: { function tryLoadImage() {
if (cachedImageData) { if (loadQueued || entryType !== "image" || cachedImageData) {
source = "" return;
source = `data:image/png;base64,${cachedImageData}` }
loadQueued = true;
if (modal.activeImageLoads < modal.maxConcurrentLoads) {
modal.activeImageLoads++;
loadImage();
} else {
retryTimer.restart();
} }
} }
function tryLoadImage() { function loadImage() {
if (!loadQueued && entryType === "image" && !cachedImageData) { DMSService.sendRequest("clipboard.getEntry", {
loadQueued = true "id": entry.id
if (modal.activeImageLoads < modal.maxConcurrentLoads) { }, function (response) {
modal.activeImageLoads++ loadQueued = false;
imageLoader.running = true if (modal.activeImageLoads > 0) {
} else { modal.activeImageLoads--;
retryTimer.restart()
} }
} if (response.error) {
console.warn("ClipboardThumbnail: Failed to load image:", entry.id);
return;
}
const data = response.result?.data;
if (data) {
cachedImageData = data;
}
});
} }
Timer { Timer {
id: retryTimer id: retryTimer
interval: ClipboardConstants.retryInterval interval: ClipboardConstants.retryInterval
onTriggered: { onTriggered: {
if (thumbnailImage.loadQueued && !imageLoader.running) { if (!thumbnailImage.loadQueued) {
if (modal.activeImageLoads < modal.maxConcurrentLoads) { return;
modal.activeImageLoads++ }
imageLoader.running = true if (modal.activeImageLoads < modal.maxConcurrentLoads) {
} else { modal.activeImageLoads++;
retryTimer.restart() thumbnailImage.loadImage();
} } else {
retryTimer.restart();
} }
} }
} }
Component.onCompleted: { Component.onCompleted: {
if (entryType !== "image") { if (entryType !== "image") {
return return;
} }
// Check if item is visible on screen initially const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing);
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing) const viewTop = listView.contentY;
const viewTop = listView.contentY const viewBottom = viewTop + listView.height;
const viewBottom = viewTop + listView.height isVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom);
isVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom)
if (isVisible) { if (isVisible) {
tryLoadImage() tryLoadImage();
} }
} }
@@ -86,48 +97,22 @@ Item {
target: listView target: listView
function onContentYChanged() { function onContentYChanged() {
if (entryType !== "image") { if (entryType !== "image") {
return return;
} }
const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing) const itemY = itemIndex * (ClipboardConstants.itemHeight + listView.spacing);
const viewTop = listView.contentY - ClipboardConstants.viewportBuffer const viewTop = listView.contentY - ClipboardConstants.viewportBuffer;
const viewBottom = viewTop + listView.height + ClipboardConstants.extendedBuffer const viewBottom = viewTop + listView.height + ClipboardConstants.extendedBuffer;
const nowVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom) const nowVisible = (itemY + ClipboardConstants.itemHeight >= viewTop && itemY <= viewBottom);
if (nowVisible && !thumbnailImage.isVisible) { if (nowVisible && !thumbnailImage.isVisible) {
thumbnailImage.isVisible = true thumbnailImage.isVisible = true;
thumbnailImage.tryLoadImage() 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 { MultiEffect {
anchors.fill: parent anchors.fill: parent
anchors.margins: 2 anchors.margins: 2
@@ -155,17 +140,17 @@ Item {
} }
} }
// Fallback icon
DankIcon { DankIcon {
visible: !(entryType === "image" && thumbnailImage.status === Image.Ready && thumbnailImage.source != "") visible: !(entryType === "image" && thumbnailImage.status === Image.Ready && thumbnailImage.source != "")
name: { name: {
if (entryType === "image") { switch (entryType) {
return "image" 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 size: Theme.iconSize
color: Theme.primary color: Theme.primary

View File

@@ -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());
}
}
}
} }
} }

View File

@@ -144,12 +144,6 @@ Rectangle {
"tabIndex": 2, "tabIndex": 2,
"shortcutsOnly": true "shortcutsOnly": true
}, },
{
"id": "displays",
"text": I18n.tr("Displays"),
"icon": "monitor",
"tabIndex": 6
},
{ {
"id": "network", "id": "network",
"text": I18n.tr("Network"), "text": I18n.tr("Network"),
@@ -158,11 +152,32 @@ Rectangle {
"dmsOnly": true "dmsOnly": true
}, },
{ {
"id": "printers", "id": "system",
"text": I18n.tr("Printers"), "text": I18n.tr("System"),
"icon": "print", "icon": "computer",
"tabIndex": 8, "collapsedByDefault": true,
"cupsOnly": 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", "id": "power_security",
@@ -213,6 +228,8 @@ Rectangle {
return false; return false;
if (item.hyprlandNiriOnly && !CompositorService.isNiri && !CompositorService.isHyprland) if (item.hyprlandNiriOnly && !CompositorService.isNiri && !CompositorService.isHyprland)
return false; return false;
if (item.clipboardOnly && (!DMSService.isConnected || DMSService.apiVersion < 23))
return false;
return true; return true;
} }

View File

@@ -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)
}
}
}
}
}

View File

@@ -7,6 +7,7 @@ The `PopoutService` singleton provides plugins with access to all DankMaterialSh
## Automatic Injection ## Automatic Injection
The `popoutService` property is automatically injected into: The `popoutService` property is automatically injected into:
- Widget plugins (loaded in DankBar) - Widget plugins (loaded in DankBar)
- Daemon plugins (background services) - Daemon plugins (background services)
- Plugin settings components - Plugin settings components
@@ -23,36 +24,36 @@ property var popoutService: null
### Popouts (DankPopout-based) ### Popouts (DankPopout-based)
| Component | Open | Close | Toggle | | Component | Open | Close | Toggle |
|-----------|------|-------|--------| | ------------------- | -------------------------- | --------------------------- | ---------------------------- |
| Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` | | Control Center | `openControlCenter()` | `closeControlCenter()` | `toggleControlCenter()` |
| Notification Center | `openNotificationCenter()` | `closeNotificationCenter()` | `toggleNotificationCenter()` | | Notification Center | `openNotificationCenter()` | `closeNotificationCenter()` | `toggleNotificationCenter()` |
| App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` | | App Drawer | `openAppDrawer()` | `closeAppDrawer()` | `toggleAppDrawer()` |
| Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` | | Process List | `openProcessList()` | `closeProcessList()` | `toggleProcessList()` |
| DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` | | DankDash | `openDankDash(tab)` | `closeDankDash()` | `toggleDankDash(tab)` |
| Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` | | Battery | `openBattery()` | `closeBattery()` | `toggleBattery()` |
| VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` | | VPN | `openVpn()` | `closeVpn()` | `toggleVpn()` |
| System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` | | System Update | `openSystemUpdate()` | `closeSystemUpdate()` | `toggleSystemUpdate()` |
### Modals (DankModal-based) ### Modals (DankModal-based)
| Modal | Show | Hide | Notes | | Modal | Show | Hide | Notes |
|-------|------|------|-------| | ------------------ | ------------------------- | ------------------------- | -------------------------------------------------- |
| Settings | `openSettings()` | `closeSettings()` | Full settings interface | | Settings | `openSettings()` | `closeSettings()` | Full settings interface |
| Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Cliphist integration | | Clipboard History | `openClipboardHistory()` | `closeClipboardHistory()` | Clipboard integration |
| Spotlight | `openSpotlight()` | `closeSpotlight()` | Command launcher | | Spotlight | `openSpotlight()` | `closeSpotlight()` | Command launcher |
| Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` | | Power Menu | `openPowerMenu()` | `closePowerMenu()` | Also has `togglePowerMenu()` |
| Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Fullscreen version, has `toggleProcessListModal()` | | Process List Modal | `showProcessListModal()` | `hideProcessListModal()` | Fullscreen version, has `toggleProcessListModal()` |
| Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection | | Color Picker | `showColorPicker()` | `hideColorPicker()` | Theme color selection |
| Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details | | Notification | `showNotificationModal()` | `hideNotificationModal()` | Notification details |
| WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network authentication | | WiFi Password | `showWifiPasswordModal()` | `hideWifiPasswordModal()` | Network authentication |
| Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details | | Network Info | `showNetworkInfoModal()` | `hideNetworkInfoModal()` | Network details |
### Slideouts ### Slideouts
| Component | Open | Close | Toggle | | Component | Open | Close | Toggle |
|-----------|------|-------|--------| | --------- | --------------- | ---------------- | ----------------- |
| Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` | | Notepad | `openNotepad()` | `closeNotepad()` | `toggleNotepad()` |
## Usage Examples ## Usage Examples
@@ -205,6 +206,7 @@ LazyLoader {
The service is injected in three locations: The service is injected in three locations:
1. **DMSShell.qml** (daemon plugins): 1. **DMSShell.qml** (daemon plugins):
```qml ```qml
Instantiator { Instantiator {
delegate: Loader { delegate: Loader {
@@ -218,6 +220,7 @@ Instantiator {
``` ```
2. **WidgetHost.qml** (widget plugins): 2. **WidgetHost.qml** (widget plugins):
```qml ```qml
onLoaded: { onLoaded: {
if (item.popoutService !== undefined) { if (item.popoutService !== undefined) {
@@ -227,6 +230,7 @@ onLoaded: {
``` ```
3. **CenterSection.qml** (center widgets): 3. **CenterSection.qml** (center widgets):
```qml ```qml
onLoaded: { onLoaded: {
if (item.popoutService !== undefined) { if (item.popoutService !== undefined) {
@@ -236,6 +240,7 @@ onLoaded: {
``` ```
4. **PluginsTab.qml** (settings): 4. **PluginsTab.qml** (settings):
```qml ```qml
onLoaded: { onLoaded: {
if (item && typeof PopoutService !== "undefined") { if (item && typeof PopoutService !== "undefined") {
@@ -247,11 +252,13 @@ onLoaded: {
## Best Practices ## Best Practices
1. **Use Optional Chaining**: Always use `?.` to handle null cases 1. **Use Optional Chaining**: Always use `?.` to handle null cases
```qml ```qml
popoutService?.toggleControlCenter() popoutService?.toggleControlCenter()
``` ```
2. **Check Availability**: Some popouts may not be available 2. **Check Availability**: Some popouts may not be available
```qml ```qml
if (popoutService && popoutService.controlCenterPopout) { if (popoutService && popoutService.controlCenterPopout) {
popoutService.toggleControlCenter() popoutService.toggleControlCenter()
@@ -261,6 +268,7 @@ onLoaded: {
3. **Lazy Loading**: First access may activate lazy loaders - this is normal 3. **Lazy Loading**: First access may activate lazy loaders - this is normal
4. **Feature Detection**: Some popouts require specific features 4. **Feature Detection**: Some popouts require specific features
```qml ```qml
if (BatteryService.batteryAvailable) { if (BatteryService.batteryAvailable) {
popoutService?.openBattery() popoutService?.openBattery()
@@ -272,6 +280,7 @@ onLoaded: {
## Example Plugin ## Example Plugin
See `PLUGINS/PopoutControlExample/` for a complete working example that demonstrates: See `PLUGINS/PopoutControlExample/` for a complete working example that demonstrates:
- Widget creation with popout controls - Widget creation with popout controls
- Menu-based popout selection - Menu-based popout selection
- Proper service usage - Proper service usage

View File

@@ -24,6 +24,7 @@ Singleton {
readonly property string socketPath: Quickshell.env("DMS_SOCKET") readonly property string socketPath: Quickshell.env("DMS_SOCKET")
property var pendingRequests: ({}) property var pendingRequests: ({})
property var clipboardRequestIds: ({})
property int requestIdCounter: 0 property int requestIdCounter: 0
property bool shownOutdatedError: false property bool shownOutdatedError: false
property string updateCommand: "dms update" property string updateCommand: "dms update"
@@ -179,17 +180,19 @@ Singleton {
parser: SplitParser { parser: SplitParser {
onRead: line => { onRead: line => {
if (!line || line.length === 0) { if (!line || line.length === 0)
return; return;
}
console.log("DMSService: Request socket <<", line);
try { try {
const response = JSON.parse(line); 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); handleResponse(response);
} catch (e) { } 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 { parser: SplitParser {
onRead: line => { onRead: line => {
if (!line || line.length === 0) { if (!line || line.length === 0)
return; return;
}
console.log("DMSService: Subscribe socket <<", line);
try { try {
const response = JSON.parse(line); const response = JSON.parse(line);
if (!line.includes("clipboard"))
console.log("DMSService: Subscribe socket <<", line);
handleSubscriptionEvent(response); handleSubscriptionEvent(response);
} catch (e) { } 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; request.params = params;
} }
if (callback) { if (callback)
pendingRequests[id] = 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); requestSocket.send(request);
} }