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:
@@ -102,7 +102,11 @@ linters:
|
||||
- linters:
|
||||
- ineffassign
|
||||
path: internal/proto/
|
||||
# binary.Write to bytes.Buffer can't fail
|
||||
# binary.Write/Read to bytes.Buffer can't fail
|
||||
- linters:
|
||||
- errcheck
|
||||
text: "Error return value of `binary\\.Write` is not checked"
|
||||
text: "Error return value of `binary\\.(Write|Read)` is not checked"
|
||||
# bytes.Reader.Read can't fail (reads from memory)
|
||||
- linters:
|
||||
- errcheck
|
||||
text: "Error return value of `buf\\.Read` is not checked"
|
||||
|
||||
597
core/cmd/dms/commands_clipboard.go
Normal file
597
core/cmd/dms/commands_clipboard.go
Normal 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")
|
||||
}
|
||||
@@ -513,5 +513,6 @@ func getCommonCommands() []*cobra.Command {
|
||||
screenshotCmd,
|
||||
notifyActionCmd,
|
||||
matugenCmd,
|
||||
clipboardCmd,
|
||||
}
|
||||
}
|
||||
|
||||
79
core/cmd/dms/server_client.go
Normal file
79
core/cmd/dms/server_client.go
Normal 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()
|
||||
}
|
||||
@@ -15,7 +15,9 @@ require (
|
||||
github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.etcd.io/bbolt v1.4.3
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
|
||||
golang.org/x/image v0.34.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -65,6 +67,6 @@ require (
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/text v0.31.0
|
||||
golang.org/x/text v0.32.0
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
10
core/go.sum
10
core/go.sum
@@ -131,20 +131,26 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
332
core/internal/clipboard/clipboard.go
Normal file
332
core/internal/clipboard/clipboard.go
Normal 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/"
|
||||
}
|
||||
253
core/internal/clipboard/store.go
Normal file
253
core/internal/clipboard/store.go
Normal 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, ×tamp)
|
||||
e.Timestamp = time.Unix(timestamp, 0)
|
||||
|
||||
var isImage byte
|
||||
binary.Read(buf, binary.BigEndian, &isImage)
|
||||
e.IsImage = isImage == 1
|
||||
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func itob(v uint64) []byte {
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(b, v)
|
||||
return b
|
||||
}
|
||||
|
||||
func textPreview(data []byte) string {
|
||||
text := string(data)
|
||||
text = strings.TrimSpace(text)
|
||||
text = strings.Join(strings.Fields(text), " ")
|
||||
|
||||
if len(text) > 100 {
|
||||
return text[:100] + "…"
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func imagePreview(data []byte, format string) string {
|
||||
config, imgFmt, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return fmt.Sprintf("[[ image %s %s ]]", sizeStr(len(data)), format)
|
||||
}
|
||||
return fmt.Sprintf("[[ image %s %s %dx%d ]]", sizeStr(len(data)), imgFmt, config.Width, config.Height)
|
||||
}
|
||||
|
||||
func sizeStr(size int) string {
|
||||
units := []string{"B", "KiB", "MiB"}
|
||||
var i int
|
||||
fsize := float64(size)
|
||||
for fsize >= 1024 && i < len(units)-1 {
|
||||
fsize /= 1024
|
||||
i++
|
||||
}
|
||||
return fmt.Sprintf("%.0f %s", fsize, units[i])
|
||||
}
|
||||
160
core/internal/clipboard/watch.go
Normal file
160
core/internal/clipboard/watch.go
Normal 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
|
||||
}
|
||||
@@ -615,10 +615,11 @@ func (cd *ConfigDeployer) transformNiriConfigForNonSystemd(config, terminalComma
|
||||
|
||||
spawnDms := `spawn-at-startup "dms" "run"`
|
||||
if !strings.Contains(config, spawnDms) {
|
||||
config = strings.Replace(config,
|
||||
`spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"`,
|
||||
`spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"`+"\n"+spawnDms,
|
||||
1)
|
||||
// Insert spawn-at-startup for dms after the environment block
|
||||
envBlockEnd := regexp.MustCompile(`environment \{[^}]*\}`)
|
||||
if loc := envBlockEnd.FindStringIndex(config); loc != nil {
|
||||
config = config[:loc[1]] + "\n" + spawnDms + config[loc[1]:]
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
|
||||
@@ -12,7 +12,6 @@ monitor = , preferred,auto,auto
|
||||
# ==================
|
||||
exec-once = dbus-update-activation-environment --systemd --all
|
||||
exec-once = systemctl --user start hyprland-session.target
|
||||
exec-once = bash -c "wl-paste --watch cliphist store &"
|
||||
|
||||
# ==================
|
||||
# INPUT CONFIG
|
||||
|
||||
@@ -109,7 +109,6 @@ overview {
|
||||
// which may be more convenient to use.
|
||||
// See the binds section below for more spawn examples.
|
||||
// This line starts waybar, a commonly used bar for Wayland compositors.
|
||||
spawn-at-startup "bash" "-c" "wl-paste --watch cliphist store &"
|
||||
environment {
|
||||
XDG_CURRENT_DESKTOP "niri"
|
||||
}
|
||||
|
||||
@@ -139,7 +139,6 @@ func (a *ArchDistribution) GetPackageMappingWithVariants(wm deps.WindowManager,
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeSystem},
|
||||
"kitty": {Name: "kitty", Repository: RepoTypeSystem},
|
||||
"alacritty": {Name: "alacritty", Repository: RepoTypeSystem},
|
||||
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
|
||||
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
|
||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||
|
||||
@@ -188,23 +188,12 @@ func (b *BaseDistribution) detectSpecificTerminal(terminal deps.Terminal) deps.D
|
||||
func (b *BaseDistribution) detectClipboardTools() []deps.Dependency {
|
||||
var dependencies []deps.Dependency
|
||||
|
||||
cliphist := deps.StatusMissing
|
||||
if b.commandExists("cliphist") {
|
||||
cliphist = deps.StatusInstalled
|
||||
}
|
||||
|
||||
wlClipboard := deps.StatusMissing
|
||||
if b.commandExists("wl-copy") && b.commandExists("wl-paste") {
|
||||
wlClipboard = deps.StatusInstalled
|
||||
}
|
||||
|
||||
dependencies = append(dependencies,
|
||||
deps.Dependency{
|
||||
Name: "cliphist",
|
||||
Status: cliphist,
|
||||
Description: "Wayland clipboard manager",
|
||||
Required: true,
|
||||
},
|
||||
deps.Dependency{
|
||||
Name: "wl-clipboard",
|
||||
Status: wlClipboard,
|
||||
|
||||
@@ -111,7 +111,6 @@ func (d *DebianDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"quickshell": d.getQuickshellMapping(variants["quickshell"]),
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"cliphist": {Name: "cliphist", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypeOBS, RepoURL: "home:AvengeMedia:danklinux"},
|
||||
}
|
||||
|
||||
@@ -549,7 +548,7 @@ func (d *DebianDistribution) installBuildDependencies(ctx context.Context, manua
|
||||
if err := d.installRust(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install Rust: %w", err)
|
||||
}
|
||||
case "cliphist", "dgop":
|
||||
case "dgop":
|
||||
if err := d.installGo(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install Go: %w", err)
|
||||
}
|
||||
|
||||
@@ -124,7 +124,6 @@ func (f *FedoraDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
// COPR packages
|
||||
"quickshell": f.getQuickshellMapping(variants["quickshell"]),
|
||||
"matugen": {Name: "matugen", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"cliphist": {Name: "cliphist", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
"dms (DankMaterialShell)": f.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeCOPR, RepoURL: "avengemedia/danklinux"},
|
||||
}
|
||||
|
||||
@@ -151,7 +151,6 @@ func (g *GentooDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
|
||||
"quickshell": g.getQuickshellMapping(variants["quickshell"]),
|
||||
"matugen": {Name: "x11-misc/matugen", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
"cliphist": {Name: "app-misc/cliphist", Repository: RepoTypeGURU, AcceptKeywords: archKeyword},
|
||||
"dms (DankMaterialShell)": g.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
"dgop": {Name: "dgop", Repository: RepoTypeManual, BuildFunc: "installDgop"},
|
||||
}
|
||||
|
||||
@@ -86,10 +86,6 @@ func (m *ManualPackageInstaller) InstallManualPackages(ctx context.Context, pack
|
||||
if err := m.installMatugen(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install matugen: %w", err)
|
||||
}
|
||||
case "cliphist":
|
||||
if err := m.installCliphist(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install cliphist: %w", err)
|
||||
}
|
||||
case "xwayland-satellite":
|
||||
if err := m.installXwaylandSatellite(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install xwayland-satellite: %w", err)
|
||||
@@ -803,52 +799,6 @@ func (m *ManualPackageInstaller) installDankMaterialShell(ctx context.Context, v
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ManualPackageInstaller) installCliphist(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
m.log("Installing cliphist from source...")
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.1,
|
||||
Step: "Installing cliphist via go install...",
|
||||
IsComplete: false,
|
||||
CommandInfo: "go install go.senan.xyz/cliphist@latest",
|
||||
}
|
||||
|
||||
installCmd := exec.CommandContext(ctx, "go", "install", "go.senan.xyz/cliphist@latest")
|
||||
if err := m.runWithProgressStep(installCmd, progressChan, PhaseSystemPackages, 0.1, 0.7, "Building cliphist..."); err != nil {
|
||||
return fmt.Errorf("failed to install cliphist: %w", err)
|
||||
}
|
||||
|
||||
homeDir := os.Getenv("HOME")
|
||||
sourcePath := filepath.Join(homeDir, "go", "bin", "cliphist")
|
||||
targetPath := "/usr/local/bin/cliphist"
|
||||
|
||||
progressChan <- InstallProgressMsg{
|
||||
Phase: PhaseSystemPackages,
|
||||
Progress: 0.7,
|
||||
Step: "Installing cliphist binary to system...",
|
||||
IsComplete: false,
|
||||
NeedsSudo: true,
|
||||
CommandInfo: fmt.Sprintf("sudo cp %s %s", sourcePath, targetPath),
|
||||
}
|
||||
|
||||
copyCmd := exec.CommandContext(ctx, "sudo", "-S", "cp", sourcePath, targetPath)
|
||||
copyCmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
||||
if err := copyCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to copy cliphist to /usr/local/bin: %w", err)
|
||||
}
|
||||
|
||||
// Make it executable
|
||||
chmodCmd := exec.CommandContext(ctx, "sudo", "-S", "chmod", "+x", targetPath)
|
||||
chmodCmd.Stdin = strings.NewReader(sudoPassword + "\n")
|
||||
if err := chmodCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to make cliphist executable: %w", err)
|
||||
}
|
||||
|
||||
m.log("cliphist installed successfully from source")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *ManualPackageInstaller) installXwaylandSatellite(ctx context.Context, sudoPassword string, progressChan chan<- InstallProgressMsg) error {
|
||||
m.log("Installing xwayland-satellite from source...")
|
||||
|
||||
|
||||
@@ -110,7 +110,6 @@ func (o *OpenSUSEDistribution) GetPackageMappingWithVariants(wm deps.WindowManag
|
||||
"wl-clipboard": {Name: "wl-clipboard", Repository: RepoTypeSystem},
|
||||
"xdg-desktop-portal-gtk": {Name: "xdg-desktop-portal-gtk", Repository: RepoTypeSystem},
|
||||
"accountsservice": {Name: "accountsservice", Repository: RepoTypeSystem},
|
||||
"cliphist": {Name: "cliphist", Repository: RepoTypeSystem},
|
||||
|
||||
// DMS packages from OBS
|
||||
"dms (DankMaterialShell)": o.getDmsMapping(variants["dms (DankMaterialShell)"]),
|
||||
|
||||
@@ -121,7 +121,6 @@ func (u *UbuntuDistribution) GetPackageMappingWithVariants(wm deps.WindowManager
|
||||
"quickshell": u.getQuickshellMapping(variants["quickshell"]),
|
||||
"matugen": {Name: "matugen", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"dgop": {Name: "dgop", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"cliphist": {Name: "cliphist", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
"ghostty": {Name: "ghostty", Repository: RepoTypePPA, RepoURL: "ppa:avengemedia/danklinux"},
|
||||
}
|
||||
|
||||
@@ -539,8 +538,6 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
|
||||
buildDeps["libpam0g-dev"] = true
|
||||
case "matugen":
|
||||
buildDeps["curl"] = true
|
||||
case "cliphist":
|
||||
// Go will be installed separately with PPA
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,7 +547,7 @@ func (u *UbuntuDistribution) installBuildDependencies(ctx context.Context, manua
|
||||
if err := u.installRust(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install Rust: %w", err)
|
||||
}
|
||||
case "cliphist", "dgop":
|
||||
case "dgop":
|
||||
if err := u.installGo(ctx, sudoPassword, progressChan); err != nil {
|
||||
return fmt.Errorf("failed to install Go: %w", err)
|
||||
}
|
||||
|
||||
387
core/internal/proto/ext_data_control/data_control.go
Normal file
387
core/internal/proto/ext_data_control/data_control.go
Normal 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})
|
||||
}
|
||||
}
|
||||
215
core/internal/server/clipboard/handlers.go
Normal file
215
core/internal/server/clipboard/handlers.go
Normal 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"})
|
||||
}
|
||||
1302
core/internal/server/clipboard/manager.go
Normal file
1302
core/internal/server/clipboard/manager.go
Normal file
File diff suppressed because it is too large
Load Diff
191
core/internal/server/clipboard/types.go
Normal file
191
core/internal/server/clipboard/types.go
Normal 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:
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
||||
@@ -147,6 +148,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "clipboard.") {
|
||||
if clipboardManager == nil {
|
||||
models.RespondError(conn, req.ID, "clipboard manager not initialized")
|
||||
return
|
||||
}
|
||||
clipboard.HandleRequest(conn, req, clipboardManager)
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "ping":
|
||||
models.Respond(conn, req.ID, "pong")
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/apppicker"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/bluez"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/brightness"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/cups"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/dwl"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/evdev"
|
||||
@@ -32,7 +33,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
)
|
||||
|
||||
const APIVersion = 22
|
||||
const APIVersion = 23
|
||||
|
||||
var CLIVersion = "dev"
|
||||
|
||||
@@ -63,6 +64,7 @@ var extWorkspaceManager *extworkspace.Manager
|
||||
var brightnessManager *brightness.Manager
|
||||
var wlrOutputManager *wlroutput.Manager
|
||||
var evdevManager *evdev.Manager
|
||||
var clipboardManager *clipboard.Manager
|
||||
var wlContext *wlcontext.SharedContext
|
||||
|
||||
var capabilitySubscribers syncmap.Map[string, chan ServerInfo]
|
||||
@@ -336,6 +338,31 @@ func InitializeEvdevManager() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializeClipboardManager() error {
|
||||
log.Info("Attempting to initialize clipboard manager...")
|
||||
|
||||
if wlContext == nil {
|
||||
ctx, err := wlcontext.New()
|
||||
if err != nil {
|
||||
log.Errorf("Failed to create shared Wayland context: %v", err)
|
||||
return err
|
||||
}
|
||||
wlContext = ctx
|
||||
}
|
||||
|
||||
config := clipboard.LoadConfig()
|
||||
manager, err := clipboard.NewManager(wlContext, config)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to initialize clipboard manager: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
clipboardManager = manager
|
||||
|
||||
log.Info("Clipboard manager initialized successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleConnection(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
@@ -409,6 +436,10 @@ func getCapabilities() Capabilities {
|
||||
caps = append(caps, "evdev")
|
||||
}
|
||||
|
||||
if clipboardManager != nil {
|
||||
caps = append(caps, "clipboard")
|
||||
}
|
||||
|
||||
return Capabilities{Capabilities: caps}
|
||||
}
|
||||
|
||||
@@ -463,6 +494,10 @@ func getServerInfo() ServerInfo {
|
||||
caps = append(caps, "evdev")
|
||||
}
|
||||
|
||||
if clipboardManager != nil {
|
||||
caps = append(caps, "clipboard")
|
||||
}
|
||||
|
||||
return ServerInfo{
|
||||
APIVersion: APIVersion,
|
||||
CLIVersion: CLIVersion,
|
||||
@@ -1034,6 +1069,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
||||
}()
|
||||
}
|
||||
|
||||
if shouldSubscribe("clipboard") && clipboardManager != nil {
|
||||
wg.Add(1)
|
||||
clipboardChan := clipboardManager.Subscribe(clientID + "-clipboard")
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer clipboardManager.Unsubscribe(clientID + "-clipboard")
|
||||
|
||||
initialState := clipboardManager.GetState()
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "clipboard", Data: initialState}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case state, ok := <-clipboardChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "clipboard", Data: state}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(eventChan)
|
||||
@@ -1096,6 +1163,9 @@ func cleanupManagers() {
|
||||
if evdevManager != nil {
|
||||
evdevManager.Close()
|
||||
}
|
||||
if clipboardManager != nil {
|
||||
clipboardManager.Close()
|
||||
}
|
||||
if wlContext != nil {
|
||||
wlContext.Close()
|
||||
}
|
||||
@@ -1259,6 +1329,18 @@ func Start(printDocs bool) error {
|
||||
log.Info("Evdev:")
|
||||
log.Info(" evdev.getState - Get current evdev state (caps lock)")
|
||||
log.Info(" evdev.subscribe - Subscribe to evdev state changes (streaming)")
|
||||
log.Info("Clipboard:")
|
||||
log.Info(" clipboard.getState - Get clipboard state (enabled, history, current)")
|
||||
log.Info(" clipboard.getHistory - Get clipboard history with previews")
|
||||
log.Info(" clipboard.getEntry - Get full entry by ID (params: id)")
|
||||
log.Info(" clipboard.deleteEntry - Delete entry by ID (params: id)")
|
||||
log.Info(" clipboard.clearHistory - Clear all clipboard history")
|
||||
log.Info(" clipboard.copy - Copy text to clipboard (params: text)")
|
||||
log.Info(" clipboard.paste - Get current clipboard text")
|
||||
log.Info(" clipboard.search - Search history (params: query?, mimeType?, isImage?, limit?, offset?, before?, after?)")
|
||||
log.Info(" clipboard.getConfig - Get clipboard configuration")
|
||||
log.Info(" clipboard.setConfig - Set configuration (params: maxHistory?, maxEntrySize?, autoClearDays?, clearAtStartup?)")
|
||||
log.Info(" clipboard.subscribe - Subscribe to clipboard state changes (streaming)")
|
||||
log.Info("")
|
||||
}
|
||||
log.Info("Initializing managers...")
|
||||
@@ -1366,10 +1448,15 @@ func Start(printDocs bool) error {
|
||||
}
|
||||
}()
|
||||
|
||||
if wlContext != nil {
|
||||
wlContext.Start()
|
||||
log.Info("Wayland event dispatcher started")
|
||||
}
|
||||
go func() {
|
||||
if err := InitializeClipboardManager(); err != nil {
|
||||
log.Warnf("Clipboard manager unavailable: %v", err)
|
||||
}
|
||||
if wlContext != nil {
|
||||
wlContext.Start()
|
||||
log.Info("Wayland event dispatcher started")
|
||||
}
|
||||
}()
|
||||
|
||||
log.Info("")
|
||||
log.Infof("Ready! Capabilities: %v", getCapabilities().Capabilities)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package wlcontext
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
@@ -13,6 +16,7 @@ type SharedContext struct {
|
||||
display *wlclient.Display
|
||||
stopChan chan struct{}
|
||||
fatalError chan error
|
||||
cmdQueue chan func()
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
started bool
|
||||
@@ -28,6 +32,7 @@ func New() (*SharedContext, error) {
|
||||
display: display,
|
||||
stopChan: make(chan struct{}),
|
||||
fatalError: make(chan error, 1),
|
||||
cmdQueue: make(chan func(), 256),
|
||||
started: false,
|
||||
}
|
||||
|
||||
@@ -51,6 +56,13 @@ func (sc *SharedContext) Display() *wlclient.Display {
|
||||
return sc.display
|
||||
}
|
||||
|
||||
func (sc *SharedContext) Post(fn func()) {
|
||||
select {
|
||||
case sc.cmdQueue <- fn:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *SharedContext) FatalError() <-chan error {
|
||||
return sc.fatalError
|
||||
}
|
||||
@@ -74,10 +86,35 @@ func (sc *SharedContext) eventDispatcher() {
|
||||
case <-sc.stopChan:
|
||||
return
|
||||
default:
|
||||
if err := ctx.Dispatch(); err != nil {
|
||||
log.Errorf("Wayland connection error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sc.drainCmdQueue()
|
||||
|
||||
if err := ctx.SetReadDeadline(time.Now().Add(50 * time.Millisecond)); err != nil {
|
||||
log.Errorf("Failed to set read deadline: %v", err)
|
||||
}
|
||||
err := ctx.Dispatch()
|
||||
if err := ctx.SetReadDeadline(time.Time{}); err != nil {
|
||||
log.Errorf("Failed to clear read deadline: %v", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.Is(err, os.ErrDeadlineExceeded):
|
||||
default:
|
||||
log.Errorf("Wayland connection error: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sc *SharedContext) drainCmdQueue() {
|
||||
for {
|
||||
select {
|
||||
case fn := <-sc.cmdQueue:
|
||||
fn()
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
)
|
||||
@@ -28,6 +29,12 @@ func (ctx *Context) Register(p Proxy) {
|
||||
ctx.objects.Store(id, p)
|
||||
}
|
||||
|
||||
func (ctx *Context) RegisterWithID(p Proxy, id uint32) {
|
||||
p.SetID(id)
|
||||
p.SetContext(ctx)
|
||||
ctx.objects.Store(id, p)
|
||||
}
|
||||
|
||||
func (ctx *Context) Unregister(p Proxy) {
|
||||
ctx.objects.Delete(p.ID())
|
||||
}
|
||||
@@ -47,6 +54,10 @@ func (ctx *Context) Close() error {
|
||||
return ctx.conn.Close()
|
||||
}
|
||||
|
||||
func (ctx *Context) SetReadDeadline(t time.Time) error {
|
||||
return ctx.conn.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
// Dispatch reads and processes incoming messages and calls [client.Dispatcher.Dispatch] on the
|
||||
// respective wayland protocol.
|
||||
// Dispatch must be called on the same goroutine as other interactions with the Context.
|
||||
|
||||
Reference in New Issue
Block a user