1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-01-24 13:32:50 -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

@@ -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"`
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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/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")

View File

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

View File

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