mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
clipboard: implement virtual-keyboard-unstable-v1 to replace wtype for
pasting entries
This commit is contained in:
@@ -63,6 +63,15 @@ var clipPasteCmd = &cobra.Command{
|
||||
Run: runClipPaste,
|
||||
}
|
||||
|
||||
var clipSendPasteCmd = &cobra.Command{
|
||||
Use: "send-paste",
|
||||
Short: "Send a paste keystroke to the focused window",
|
||||
Long: "Emulate ctrl+v (or ctrl+shift+v with --shift) via a virtual keyboard. Works without server.",
|
||||
Run: runClipSendPaste,
|
||||
}
|
||||
|
||||
var clipSendPasteShift bool
|
||||
|
||||
var clipWatchCmd = &cobra.Command{
|
||||
Use: "watch [command]",
|
||||
Short: "Watch clipboard for changes",
|
||||
@@ -216,8 +225,10 @@ func init() {
|
||||
|
||||
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
|
||||
|
||||
clipSendPasteCmd.Flags().BoolVarP(&clipSendPasteShift, "shift", "s", false, "Send ctrl+shift+v (terminal paste)")
|
||||
|
||||
clipConfigCmd.AddCommand(clipConfigGetCmd, clipConfigSetCmd)
|
||||
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
|
||||
clipboardCmd.AddCommand(clipCopyCmd, clipPasteCmd, clipSendPasteCmd, clipWatchCmd, clipHistoryCmd, clipGetCmd, clipDeleteCmd, clipClearCmd, clipSearchCmd, clipConfigCmd, clipExportCmd, clipImportCmd, clipMigrateCmd)
|
||||
}
|
||||
|
||||
func runClipCopy(cmd *cobra.Command, args []string) {
|
||||
@@ -314,6 +325,12 @@ func runClipPaste(cmd *cobra.Command, args []string) {
|
||||
os.Stdout.Write(data)
|
||||
}
|
||||
|
||||
func runClipSendPaste(cmd *cobra.Command, args []string) {
|
||||
if err := clipboard.SendPasteKeystroke(clipSendPasteShift); err != nil {
|
||||
log.Fatalf("send-paste: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runClipWatch(cmd *cobra.Command, args []string) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
const envServe = "_DMS_CLIPBOARD_SERVE"
|
||||
@@ -17,6 +16,37 @@ const envMime = "_DMS_CLIPBOARD_MIME"
|
||||
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
|
||||
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
|
||||
|
||||
type Offer struct {
|
||||
MimeType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// textMimeAliases are offered alongside plain-text content so legacy X11
|
||||
// clients bridged through XWayland find a target they can convert.
|
||||
var textMimeAliases = []string{
|
||||
"text/plain",
|
||||
"text/plain;charset=utf-8",
|
||||
"UTF8_STRING",
|
||||
"STRING",
|
||||
"TEXT",
|
||||
}
|
||||
|
||||
// ExpandOffers turns raw clipboard data into the full offer list to serve,
|
||||
// adding the standard alias set for text content.
|
||||
func ExpandOffers(data []byte, mimeType string) []Offer {
|
||||
offers := []Offer{{MimeType: mimeType, Data: data}}
|
||||
if mimeType != "text/plain" && mimeType != "text/plain;charset=utf-8" {
|
||||
return offers
|
||||
}
|
||||
for _, alias := range textMimeAliases {
|
||||
if alias == mimeType {
|
||||
continue
|
||||
}
|
||||
offers = append(offers, Offer{MimeType: alias, Data: data})
|
||||
}
|
||||
return offers
|
||||
}
|
||||
|
||||
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
|
||||
// child. Reads source data into memory, deletes any cache file, then serves.
|
||||
func MaybeServeAndExit() {
|
||||
@@ -44,7 +74,7 @@ func MaybeServeAndExit() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := serveClipboard(data, mimeType, pasteOnce); err != nil {
|
||||
if err := serveOffers(ExpandOffers(data, mimeType), pasteOnce); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -55,22 +85,33 @@ func Copy(data []byte, mimeType string) error {
|
||||
return copyForkCached(data, mimeType, false)
|
||||
}
|
||||
|
||||
func CopyText(text string) error {
|
||||
return Copy([]byte(text), "text/plain;charset=utf-8")
|
||||
}
|
||||
|
||||
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
return serveClipboard(data, mimeType, pasteOnce)
|
||||
return serveOffers(ExpandOffers(data, mimeType), pasteOnce)
|
||||
}
|
||||
return copyForkCached(data, mimeType, pasteOnce)
|
||||
}
|
||||
|
||||
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
buf, err := io.ReadAll(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
return serveClipboard(buf, mimeType, pasteOnce)
|
||||
if !foreground {
|
||||
return copyFork(data, mimeType, pasteOnce)
|
||||
}
|
||||
return copyFork(data, mimeType, pasteOnce)
|
||||
buf, err := io.ReadAll(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source: %w", err)
|
||||
}
|
||||
return serveOffers(ExpandOffers(buf, mimeType), pasteOnce)
|
||||
}
|
||||
|
||||
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
|
||||
if foreground {
|
||||
return serveOffers(offers, pasteOnce)
|
||||
}
|
||||
return copyMultiFork(offers, pasteOnce)
|
||||
}
|
||||
|
||||
func newForkCmd(mimeType string, pasteOnce bool, extra ...string) *exec.Cmd {
|
||||
@@ -132,39 +173,70 @@ func copyForkCached(data []byte, mimeType string, pasteOnce bool) error {
|
||||
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||
cmd := newForkCmd(mimeType, pasteOnce)
|
||||
|
||||
switch src := data.(type) {
|
||||
case *os.File:
|
||||
if src, ok := data.(*os.File); ok {
|
||||
cmd.Stdin = src
|
||||
return waitReady(cmd)
|
||||
|
||||
default:
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(stdin, data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
if err := stdin.Close(); err != nil {
|
||||
return fmt.Errorf("close stdin: %w", err)
|
||||
}
|
||||
|
||||
var buf [1]byte
|
||||
if _, err := stdout.Read(buf[:]); err != nil {
|
||||
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdin pipe: %w", err)
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("start: %w", err)
|
||||
}
|
||||
if _, err := io.Copy(stdin, data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write stdin: %w", err)
|
||||
}
|
||||
if err := stdin.Close(); err != nil {
|
||||
return fmt.Errorf("close stdin: %w", err)
|
||||
}
|
||||
|
||||
var buf [1]byte
|
||||
if _, err := stdout.Read(buf[:]); err != nil {
|
||||
return fmt.Errorf("waiting for clipboard ready: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMultiFork(offers []Offer, pasteOnce bool) error {
|
||||
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
|
||||
if pasteOnce {
|
||||
args = append(args, "--paste-once")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
for _, offer := range offers {
|
||||
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
|
||||
if _, err := stdin.Write(offer.Data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write offer data: %w", err)
|
||||
}
|
||||
}
|
||||
stdin.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func signalReady() {
|
||||
@@ -194,57 +266,25 @@ func createClipboardCacheFile() (*os.File, error) {
|
||||
return os.CreateTemp("", "dms-clipboard-*")
|
||||
}
|
||||
|
||||
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||
display, err := wlclient.Connect("")
|
||||
// serveOffers owns the Wayland selection until cancelled (or first paste when
|
||||
// pasteOnce is set), answering every offered mime type with its data.
|
||||
func serveOffers(offers []Offer, pasteOnce bool) error {
|
||||
if len(offers) == 0 {
|
||||
return fmt.Errorf("no offers to serve")
|
||||
}
|
||||
|
||||
s, err := connectSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("wayland connect: %w", err)
|
||||
return err
|
||||
}
|
||||
defer display.Destroy()
|
||||
defer s.Close()
|
||||
|
||||
ctx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
dataControlMgr, err := s.requireDataControl()
|
||||
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")
|
||||
return err
|
||||
}
|
||||
|
||||
device, err := dataControlMgr.GetDataDevice(seat)
|
||||
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
@@ -255,25 +295,12 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||
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)
|
||||
offerData := make(map[string][]byte, len(offers))
|
||||
for _, offer := range offers {
|
||||
if err := source.Offer(offer.MimeType); err != nil {
|
||||
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
|
||||
}
|
||||
offerData[offer.MimeType] = offer.Data
|
||||
}
|
||||
|
||||
cancelled := make(chan struct{})
|
||||
@@ -283,7 +310,11 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||
_ = syscall.SetNonblock(e.Fd, false)
|
||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||
defer file.Close()
|
||||
_, _ = file.Write(data)
|
||||
|
||||
if data, ok := offerData[e.MimeType]; ok {
|
||||
_, _ = file.Write(data)
|
||||
}
|
||||
|
||||
select {
|
||||
case pasted <- struct{}{}:
|
||||
default:
|
||||
@@ -298,7 +329,7 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||
return fmt.Errorf("set selection: %w", err)
|
||||
}
|
||||
|
||||
display.Roundtrip()
|
||||
s.display.Roundtrip()
|
||||
signalReady()
|
||||
|
||||
for {
|
||||
@@ -310,70 +341,26 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
if err := ctx.Dispatch(); err != nil {
|
||||
if err := s.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("")
|
||||
s, err := connectSession()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("wayland connect: %w", err)
|
||||
return nil, "", err
|
||||
}
|
||||
defer display.Destroy()
|
||||
defer s.Close()
|
||||
|
||||
ctx := display.Context()
|
||||
registry, err := display.GetRegistry()
|
||||
dataControlMgr, err := s.requireDataControl()
|
||||
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)
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
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)
|
||||
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("get data device: %w", err)
|
||||
}
|
||||
@@ -399,15 +386,14 @@ func Paste() ([]byte, string, error) {
|
||||
gotSelection = true
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
s.display.Roundtrip()
|
||||
s.display.Roundtrip()
|
||||
|
||||
if !gotSelection || selectionOffer == nil {
|
||||
return nil, "", fmt.Errorf("no clipboard data")
|
||||
}
|
||||
|
||||
mimeTypes := offerMimeTypes[selectionOffer]
|
||||
selectedMime := selectPreferredMimeType(mimeTypes)
|
||||
selectedMime := selectPreferredMimeType(offerMimeTypes[selectionOffer])
|
||||
if selectedMime == "" {
|
||||
return nil, "", fmt.Errorf("no supported mime type")
|
||||
}
|
||||
@@ -424,7 +410,7 @@ func Paste() ([]byte, string, error) {
|
||||
}
|
||||
w.Close()
|
||||
|
||||
display.Roundtrip()
|
||||
s.display.Roundtrip()
|
||||
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
@@ -470,161 +456,3 @@ func selectPreferredMimeType(mimes []string) string {
|
||||
func IsImageMimeType(mime string) bool {
|
||||
return len(mime) > 6 && mime[:6] == "image/"
|
||||
}
|
||||
|
||||
type Offer struct {
|
||||
MimeType string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func CopyMulti(offers []Offer, foreground, pasteOnce bool) error {
|
||||
if !foreground {
|
||||
return copyMultiFork(offers, pasteOnce)
|
||||
}
|
||||
return copyMultiServe(offers, pasteOnce)
|
||||
}
|
||||
|
||||
func copyMultiFork(offers []Offer, pasteOnce bool) error {
|
||||
args := []string{os.Args[0], "cl", "copy", "--foreground", "--type", "__multi__"}
|
||||
if pasteOnce {
|
||||
args = append(args, "--paste-once")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
for _, offer := range offers {
|
||||
fmt.Fprintf(stdin, "%s\x00%d\x00", offer.MimeType, len(offer.Data))
|
||||
if _, err := stdin.Write(offer.Data); err != nil {
|
||||
stdin.Close()
|
||||
return fmt.Errorf("write offer data: %w", err)
|
||||
}
|
||||
}
|
||||
stdin.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyMultiServe(offers []Offer, 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)
|
||||
}
|
||||
|
||||
offerMap := make(map[string][]byte)
|
||||
for _, offer := range offers {
|
||||
if err := source.Offer(offer.MimeType); err != nil {
|
||||
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
|
||||
}
|
||||
offerMap[offer.MimeType] = offer.Data
|
||||
}
|
||||
|
||||
cancelled := make(chan struct{})
|
||||
pasted := make(chan struct{}, 1)
|
||||
|
||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||
_ = syscall.SetNonblock(e.Fd, false)
|
||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||
defer file.Close()
|
||||
|
||||
if data, ok := offerMap[e.MimeType]; ok {
|
||||
_, _ = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const (
|
||||
xkbKeymapFormatV1 = 1
|
||||
|
||||
keyStateReleased = 0
|
||||
keyStatePressed = 1
|
||||
|
||||
// xkb real modifier bit positions are fixed: Shift=0, Lock=1, Control=2
|
||||
shiftModMask = 1 << 0
|
||||
ctrlModMask = 1 << 2
|
||||
|
||||
// evdev fallbacks for a standard pc105 map
|
||||
fallbackCtrlKey = 29 // KEY_LEFTCTRL
|
||||
fallbackShiftKey = 42 // KEY_LEFTSHIFT
|
||||
fallbackVKey = 47 // KEY_V
|
||||
)
|
||||
|
||||
// SendPasteKeystroke emulates a paste shortcut via zwp_virtual_keyboard_v1
|
||||
// using the seat's own keymap, so keycodes stay valid for XWayland clients
|
||||
// (a synthetic wtype-style keymap breaks X11 apps like Steam). withShift
|
||||
// selects ctrl+shift+v for terminal targets.
|
||||
func SendPasteKeystroke(withShift bool) error {
|
||||
s, err := connectSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if s.virtualKeyboardMgr == nil {
|
||||
return fmt.Errorf("compositor does not support zwp_virtual_keyboard_manager_v1")
|
||||
}
|
||||
if s.seat == nil {
|
||||
return fmt.Errorf("no seat available")
|
||||
}
|
||||
|
||||
keyboard, err := s.seat.GetKeyboard()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get keyboard: %w", err)
|
||||
}
|
||||
defer keyboard.Release()
|
||||
|
||||
var keymap *wlclient.KeyboardKeymapEvent
|
||||
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
||||
if keymap == nil {
|
||||
keymap = &e
|
||||
}
|
||||
})
|
||||
|
||||
s.display.Roundtrip()
|
||||
|
||||
if keymap == nil || keymap.Format != xkbKeymapFormatV1 {
|
||||
return fmt.Errorf("no xkb keymap from seat")
|
||||
}
|
||||
defer unix.Close(keymap.Fd)
|
||||
|
||||
keymapText, err := readKeymap(keymap.Fd, keymap.Size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read keymap: %w", err)
|
||||
}
|
||||
keys := resolveKeycodes(keymapText)
|
||||
|
||||
vk, err := s.virtualKeyboardMgr.CreateVirtualKeyboard(s.seat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create virtual keyboard: %w", err)
|
||||
}
|
||||
defer vk.Destroy()
|
||||
|
||||
if err := vk.Keymap(xkbKeymapFormatV1, keymap.Fd, keymap.Size); err != nil {
|
||||
return fmt.Errorf("set keymap: %w", err)
|
||||
}
|
||||
|
||||
mods := uint32(ctrlModMask)
|
||||
held := []uint32{keys.ctrl}
|
||||
if withShift {
|
||||
mods |= shiftModMask
|
||||
held = append(held, keys.shift)
|
||||
}
|
||||
|
||||
t := uint32(0)
|
||||
press := func(key, state uint32) error {
|
||||
t++
|
||||
return vk.Key(t, key, state)
|
||||
}
|
||||
|
||||
for _, key := range held {
|
||||
if err := press(key, keyStatePressed); err != nil {
|
||||
return fmt.Errorf("key press: %w", err)
|
||||
}
|
||||
}
|
||||
if err := vk.Modifiers(mods, 0, 0, 0); err != nil {
|
||||
return fmt.Errorf("set modifiers: %w", err)
|
||||
}
|
||||
if err := press(keys.v, keyStatePressed); err != nil {
|
||||
return fmt.Errorf("key press: %w", err)
|
||||
}
|
||||
if err := press(keys.v, keyStateReleased); err != nil {
|
||||
return fmt.Errorf("key release: %w", err)
|
||||
}
|
||||
for i := len(held) - 1; i >= 0; i-- {
|
||||
if err := press(held[i], keyStateReleased); err != nil {
|
||||
return fmt.Errorf("key release: %w", err)
|
||||
}
|
||||
}
|
||||
if err := vk.Modifiers(0, 0, 0, 0); err != nil {
|
||||
return fmt.Errorf("clear modifiers: %w", err)
|
||||
}
|
||||
|
||||
s.display.Roundtrip()
|
||||
return nil
|
||||
}
|
||||
|
||||
func readKeymap(fd int, size uint32) (string, error) {
|
||||
data, err := unix.Mmap(fd, 0, int(size), unix.PROT_READ, unix.MAP_PRIVATE)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := strings.TrimRight(string(data), "\x00")
|
||||
return text, unix.Munmap(data)
|
||||
}
|
||||
|
||||
type pasteKeycodes struct {
|
||||
ctrl uint32
|
||||
shift uint32
|
||||
v uint32
|
||||
}
|
||||
|
||||
var (
|
||||
keycodeDefRe = regexp.MustCompile(`<([A-Za-z0-9+_-]+)>\s*=\s*(\d+)`)
|
||||
keySymbolsRe = regexp.MustCompile(`key\s*<([A-Za-z0-9+_-]+)>\s*\{([^}]*)\}`)
|
||||
groupIndexRe = regexp.MustCompile(`\w+\[\d+\]\s*=`)
|
||||
symbolListRe = regexp.MustCompile(`\[([^\]]*)\]`)
|
||||
)
|
||||
|
||||
// xkbcommon may serialize keysyms as hex escapes instead of names
|
||||
// (e.g. "0x76" for v, "0xffe3" for Control_L).
|
||||
var keysymNames = map[uint32]string{
|
||||
0x76: "v",
|
||||
0xffe3: "Control_L",
|
||||
0xffe1: "Shift_L",
|
||||
}
|
||||
|
||||
func canonicalKeysym(sym string) string {
|
||||
if !strings.HasPrefix(sym, "0x") && !strings.HasPrefix(sym, "0X") {
|
||||
return sym
|
||||
}
|
||||
value, err := strconv.ParseUint(sym[2:], 16, 32)
|
||||
if err != nil {
|
||||
return sym
|
||||
}
|
||||
if name, ok := keysymNames[uint32(value)]; ok {
|
||||
return name
|
||||
}
|
||||
return sym
|
||||
}
|
||||
|
||||
// resolveKeycodes finds the evdev keycodes producing the keysyms we need in
|
||||
// the seat keymap's first group, falling back to pc105 positions.
|
||||
func resolveKeycodes(keymap string) pasteKeycodes {
|
||||
codes := map[string]uint32{}
|
||||
for _, m := range keycodeDefRe.FindAllStringSubmatch(keymap, -1) {
|
||||
if code, err := strconv.Atoi(m[2]); err == nil {
|
||||
codes[m[1]] = uint32(code)
|
||||
}
|
||||
}
|
||||
|
||||
keys := pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}
|
||||
want := map[string]*uint32{
|
||||
"Control_L": &keys.ctrl,
|
||||
"Shift_L": &keys.shift,
|
||||
"v": &keys.v,
|
||||
}
|
||||
|
||||
for _, m := range keySymbolsRe.FindAllStringSubmatch(keymap, -1) {
|
||||
group := symbolListRe.FindStringSubmatch(groupIndexRe.ReplaceAllString(m[2], ""))
|
||||
if group == nil {
|
||||
continue
|
||||
}
|
||||
xkbCode, ok := codes[m[1]]
|
||||
if !ok || xkbCode < 8 {
|
||||
continue
|
||||
}
|
||||
level1 := canonicalKeysym(strings.TrimSpace(strings.Split(group[1], ",")[0]))
|
||||
target, wanted := want[level1]
|
||||
if !wanted {
|
||||
continue
|
||||
}
|
||||
*target = xkbCode - 8
|
||||
delete(want, level1)
|
||||
if len(want) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func TestLiveSeatKeymapResolution(t *testing.T) {
|
||||
if os.Getenv("DMS_LIVE_TEST") == "" {
|
||||
t.Skip("set DMS_LIVE_TEST=1 to run against the live compositor")
|
||||
}
|
||||
|
||||
s, err := connectSession()
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
if s.virtualKeyboardMgr == nil {
|
||||
t.Fatal("compositor does not advertise zwp_virtual_keyboard_manager_v1")
|
||||
}
|
||||
if s.seat == nil {
|
||||
t.Fatal("no seat")
|
||||
}
|
||||
|
||||
keyboard, err := s.seat.GetKeyboard()
|
||||
if err != nil {
|
||||
t.Fatalf("get keyboard: %v", err)
|
||||
}
|
||||
defer keyboard.Release()
|
||||
|
||||
var keymap *wlclient.KeyboardKeymapEvent
|
||||
keyboard.SetKeymapHandler(func(e wlclient.KeyboardKeymapEvent) {
|
||||
if keymap == nil {
|
||||
keymap = &e
|
||||
}
|
||||
})
|
||||
s.display.Roundtrip()
|
||||
|
||||
if keymap == nil {
|
||||
t.Fatal("no keymap event")
|
||||
}
|
||||
defer unix.Close(keymap.Fd)
|
||||
|
||||
text, err := readKeymap(keymap.Fd, keymap.Size)
|
||||
if err != nil {
|
||||
t.Fatalf("read keymap: %v", err)
|
||||
}
|
||||
|
||||
if dump := os.Getenv("DMS_LIVE_DUMP"); dump != "" {
|
||||
if err := os.WriteFile(dump, []byte(text), 0o644); err != nil {
|
||||
t.Fatalf("dump keymap: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
keys := resolveKeycodes(text)
|
||||
t.Logf("keymap size=%d resolved ctrl=%d shift=%d v=%d", keymap.Size, keys.ctrl, keys.shift, keys.v)
|
||||
|
||||
if keys.ctrl == fallbackCtrlKey && keys.shift == fallbackShiftKey && keys.v == fallbackVKey {
|
||||
t.Log("all keycodes are fallbacks - parsing may not have matched the live keymap")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package clipboard
|
||||
|
||||
import "testing"
|
||||
|
||||
const qwertyKeymap = `xkb_keymap {
|
||||
xkb_keycodes "(unnamed)" {
|
||||
minimum = 8;
|
||||
maximum = 708;
|
||||
<ESC> = 9;
|
||||
<AB04> = 55;
|
||||
<LCTL> = 37;
|
||||
<LFSH> = 50;
|
||||
alias <AL01> = <AC01>;
|
||||
indicator 1 = "Caps Lock";
|
||||
};
|
||||
xkb_types "(unnamed)" {
|
||||
type "ALPHABETIC" {
|
||||
modifiers = Shift+Lock;
|
||||
map[Shift] = Level2;
|
||||
level_name[Level1] = "Base";
|
||||
};
|
||||
};
|
||||
xkb_symbols "(unnamed)" {
|
||||
key <ESC> { [ Escape ] };
|
||||
key <AB04> { type= "ALPHABETIC", [ v, V ] };
|
||||
key <LCTL> { [ Control_L ] };
|
||||
key <LFSH> { [ Shift_L ] };
|
||||
};
|
||||
};`
|
||||
|
||||
const hexKeymap = `xkb_keymap {
|
||||
xkb_keycodes "(unnamed)" {
|
||||
<AB04> = 56;
|
||||
<LCTL> = 38;
|
||||
<LFSH> = 51;
|
||||
};
|
||||
xkb_symbols "(unnamed)" {
|
||||
key <LCTL> { [ 0xffe3 ] };
|
||||
key <LFSH> {
|
||||
type= "PC_ALT_LEVEL2",
|
||||
symbols[1]= [ 0xffe1, 0xfe08 ]
|
||||
};
|
||||
key <AB04> { [ 0x76, 0x56 ] };
|
||||
};
|
||||
};`
|
||||
|
||||
const dvorakKeymap = `xkb_keymap {
|
||||
xkb_keycodes "(unnamed)" {
|
||||
<AB09> = 60;
|
||||
<LCTL> = 37;
|
||||
<LFSH> = 50;
|
||||
};
|
||||
xkb_symbols "(unnamed)" {
|
||||
key <AB09> { [ v, V ] };
|
||||
key <LCTL> { [ Control_L ] };
|
||||
key <LFSH> { [ Shift_L ] };
|
||||
};
|
||||
};`
|
||||
|
||||
func TestResolveKeycodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keymap string
|
||||
want pasteKeycodes
|
||||
}{
|
||||
{"qwerty", qwertyKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 47}},
|
||||
{"dvorak", dvorakKeymap, pasteKeycodes{ctrl: 29, shift: 42, v: 52}},
|
||||
{"hex keysyms", hexKeymap, pasteKeycodes{ctrl: 30, shift: 43, v: 48}},
|
||||
{"empty falls back", "", pasteKeycodes{ctrl: fallbackCtrlKey, shift: fallbackShiftKey, v: fallbackVKey}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := resolveKeycodes(tt.keymap)
|
||||
if got != tt.want {
|
||||
t.Errorf("resolveKeycodes() = %+v, want %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandOffers(t *testing.T) {
|
||||
text := ExpandOffers([]byte("hi"), "text/plain;charset=utf-8")
|
||||
if len(text) != 5 {
|
||||
t.Fatalf("expected 5 text offers, got %d", len(text))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, o := range text {
|
||||
if string(o.Data) != "hi" {
|
||||
t.Errorf("offer %s has wrong data", o.MimeType)
|
||||
}
|
||||
if seen[o.MimeType] {
|
||||
t.Errorf("duplicate offer %s", o.MimeType)
|
||||
}
|
||||
seen[o.MimeType] = true
|
||||
}
|
||||
if !seen["UTF8_STRING"] || !seen["STRING"] || !seen["TEXT"] || !seen["text/plain"] {
|
||||
t.Errorf("missing X11 alias offers: %v", seen)
|
||||
}
|
||||
|
||||
img := ExpandOffers([]byte{1}, "image/png")
|
||||
if len(img) != 1 || img[0].MimeType != "image/png" {
|
||||
t.Errorf("non-text mime should not expand, got %+v", img)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package clipboard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
|
||||
type session struct {
|
||||
display *wlclient.Display
|
||||
ctx *wlclient.Context
|
||||
registry *wlclient.Registry
|
||||
seat *wlclient.Seat
|
||||
dataControlMgr *ext_data_control.ExtDataControlManagerV1
|
||||
virtualKeyboardMgr *virtual_keyboard.ZwpVirtualKeyboardManagerV1
|
||||
}
|
||||
|
||||
// connectSession opens a short-lived Wayland connection and binds the seat
|
||||
// plus whichever clipboard-related globals the compositor advertises.
|
||||
func connectSession() (*session, error) {
|
||||
display, err := wlclient.Connect("")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wayland connect: %w", err)
|
||||
}
|
||||
|
||||
s := &session{display: display, ctx: display.Context()}
|
||||
|
||||
registry, err := display.GetRegistry()
|
||||
if err != nil {
|
||||
display.Destroy()
|
||||
return nil, fmt.Errorf("get registry: %w", err)
|
||||
}
|
||||
s.registry = registry
|
||||
|
||||
var bindErr error
|
||||
bind := func(name uint32, iface string, version uint32, proxy wlclient.Proxy) {
|
||||
if err := registry.Bind(name, iface, version, proxy); err != nil {
|
||||
bindErr = fmt.Errorf("bind %s: %w", iface, err)
|
||||
}
|
||||
}
|
||||
|
||||
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
||||
switch e.Interface {
|
||||
case ext_data_control.ExtDataControlManagerV1InterfaceName:
|
||||
mgr := ext_data_control.NewExtDataControlManagerV1(s.ctx)
|
||||
bind(e.Name, e.Interface, e.Version, mgr)
|
||||
s.dataControlMgr = mgr
|
||||
case virtual_keyboard.ZwpVirtualKeyboardManagerV1InterfaceName:
|
||||
mgr := virtual_keyboard.NewZwpVirtualKeyboardManagerV1(s.ctx)
|
||||
bind(e.Name, e.Interface, e.Version, mgr)
|
||||
s.virtualKeyboardMgr = mgr
|
||||
case "wl_seat":
|
||||
if s.seat != nil {
|
||||
return
|
||||
}
|
||||
seat := wlclient.NewSeat(s.ctx)
|
||||
bind(e.Name, e.Interface, e.Version, seat)
|
||||
s.seat = seat
|
||||
}
|
||||
})
|
||||
|
||||
display.Roundtrip()
|
||||
display.Roundtrip()
|
||||
|
||||
if bindErr != nil {
|
||||
s.Close()
|
||||
return nil, bindErr
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *session) requireDataControl() (*ext_data_control.ExtDataControlManagerV1, error) {
|
||||
switch {
|
||||
case s.dataControlMgr == nil:
|
||||
return nil, fmt.Errorf("compositor does not support ext_data_control_manager_v1")
|
||||
case s.seat == nil:
|
||||
return nil, fmt.Errorf("no seat available")
|
||||
default:
|
||||
return s.dataControlMgr, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) Close() {
|
||||
if s.dataControlMgr != nil {
|
||||
s.dataControlMgr.Destroy()
|
||||
}
|
||||
if s.registry != nil {
|
||||
s.registry.Destroy()
|
||||
}
|
||||
s.display.Destroy()
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// Generated by go-wayland-scanner
|
||||
// https://github.com/yaslama/go-wayland/cmd/go-wayland-scanner
|
||||
// XML file : internal/proto/xml/virtual-keyboard-unstable-v1.xml
|
||||
//
|
||||
// virtual_keyboard_unstable_v1 Protocol Copyright:
|
||||
//
|
||||
// Copyright © 2008-2011 Kristian Høgsberg
|
||||
// Copyright © 2010-2013 Intel Corporation
|
||||
// Copyright © 2012-2013 Collabora, Ltd.
|
||||
// Copyright © 2018 Purism SPC
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a
|
||||
// copy of this software and associated documentation files (the "Software"),
|
||||
// to deal in the Software without restriction, including without limitation
|
||||
// the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
// and/or sell copies of the Software, and to permit persons to whom the
|
||||
// Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice (including the next
|
||||
// paragraph) shall be included in all copies or substantial portions of the
|
||||
// Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
package virtual_keyboard
|
||||
|
||||
import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ZwpVirtualKeyboardV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
||||
const ZwpVirtualKeyboardV1InterfaceName = "zwp_virtual_keyboard_v1"
|
||||
|
||||
// ZwpVirtualKeyboardV1 : virtual keyboard
|
||||
//
|
||||
// The virtual keyboard provides an application with requests which emulate
|
||||
// the behaviour of a physical keyboard.
|
||||
//
|
||||
// This interface can be used by clients on its own to provide raw input
|
||||
// events, or it can accompany the input method protocol.
|
||||
type ZwpVirtualKeyboardV1 struct {
|
||||
client.BaseProxy
|
||||
}
|
||||
|
||||
// NewZwpVirtualKeyboardV1 : virtual keyboard
|
||||
//
|
||||
// The virtual keyboard provides an application with requests which emulate
|
||||
// the behaviour of a physical keyboard.
|
||||
//
|
||||
// This interface can be used by clients on its own to provide raw input
|
||||
// events, or it can accompany the input method protocol.
|
||||
func NewZwpVirtualKeyboardV1(ctx *client.Context) *ZwpVirtualKeyboardV1 {
|
||||
zwpVirtualKeyboardV1 := &ZwpVirtualKeyboardV1{}
|
||||
ctx.Register(zwpVirtualKeyboardV1)
|
||||
return zwpVirtualKeyboardV1
|
||||
}
|
||||
|
||||
// Keymap : keyboard mapping
|
||||
//
|
||||
// Provide a file descriptor to the compositor which can be
|
||||
// memory-mapped to provide a keyboard mapping description.
|
||||
//
|
||||
// Format carries a value from the keymap_format enumeration.
|
||||
//
|
||||
// format: keymap format
|
||||
// fd: keymap file descriptor
|
||||
// size: keymap size, in bytes
|
||||
func (i *ZwpVirtualKeyboardV1) Keymap(format uint32, fd int, size uint32) error {
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(format))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(size))
|
||||
l += 4
|
||||
oob := unix.UnixRights(int(fd))
|
||||
err := i.Context().WriteMsg(_reqBuf[:], oob)
|
||||
return err
|
||||
}
|
||||
|
||||
// Key : key event
|
||||
//
|
||||
// A key was pressed or released.
|
||||
// The time argument is a timestamp with millisecond granularity, with an
|
||||
// undefined base. All requests regarding a single object must share the
|
||||
// same clock.
|
||||
//
|
||||
// Keymap must be set before issuing this request.
|
||||
//
|
||||
// State carries a value from the key_state enumeration.
|
||||
//
|
||||
// time: timestamp with millisecond granularity
|
||||
// key: key that produced the event
|
||||
// state: physical state of the key
|
||||
func (i *ZwpVirtualKeyboardV1) Key(time, key, state uint32) error {
|
||||
const opcode = 1
|
||||
const _reqBufLen = 8 + 4 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(time))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(key))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(state))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Modifiers : modifier and group state
|
||||
//
|
||||
// Notifies the compositor that the modifier and/or group state has
|
||||
// changed, and it should update state.
|
||||
//
|
||||
// The client should use wl_keyboard.modifiers event to synchronize its
|
||||
// internal state with seat state.
|
||||
//
|
||||
// Keymap must be set before issuing this request.
|
||||
//
|
||||
// modsDepressed: depressed modifiers
|
||||
// modsLatched: latched modifiers
|
||||
// modsLocked: locked modifiers
|
||||
// group: keyboard layout
|
||||
func (i *ZwpVirtualKeyboardV1) Modifiers(modsDepressed, modsLatched, modsLocked, group uint32) error {
|
||||
const opcode = 2
|
||||
const _reqBufLen = 8 + 4 + 4 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(modsDepressed))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(modsLatched))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(modsLocked))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(group))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// Destroy : destroy the virtual keyboard keyboard object
|
||||
func (i *ZwpVirtualKeyboardV1) Destroy() error {
|
||||
defer i.Context().Unregister(i)
|
||||
const opcode = 3
|
||||
const _reqBufLen = 8
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return err
|
||||
}
|
||||
|
||||
type ZwpVirtualKeyboardV1Error uint32
|
||||
|
||||
// ZwpVirtualKeyboardV1Error :
|
||||
const (
|
||||
// ZwpVirtualKeyboardV1ErrorNoKeymap : No keymap was set
|
||||
ZwpVirtualKeyboardV1ErrorNoKeymap ZwpVirtualKeyboardV1Error = 0
|
||||
)
|
||||
|
||||
func (e ZwpVirtualKeyboardV1Error) Name() string {
|
||||
switch e {
|
||||
case ZwpVirtualKeyboardV1ErrorNoKeymap:
|
||||
return "no_keymap"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ZwpVirtualKeyboardV1Error) Value() string {
|
||||
switch e {
|
||||
case ZwpVirtualKeyboardV1ErrorNoKeymap:
|
||||
return "0"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ZwpVirtualKeyboardV1Error) String() string {
|
||||
return e.Name() + "=" + e.Value()
|
||||
}
|
||||
|
||||
// ZwpVirtualKeyboardManagerV1InterfaceName is the name of the interface as it appears in the [client.Registry].
|
||||
// It can be used to match the [client.RegistryGlobalEvent.Interface] in the
|
||||
// [Registry.SetGlobalHandler] and can be used in [Registry.Bind] if this applies.
|
||||
const ZwpVirtualKeyboardManagerV1InterfaceName = "zwp_virtual_keyboard_manager_v1"
|
||||
|
||||
// ZwpVirtualKeyboardManagerV1 : virtual keyboard manager
|
||||
//
|
||||
// A virtual keyboard manager allows an application to provide keyboard
|
||||
// input events as if they came from a physical keyboard.
|
||||
type ZwpVirtualKeyboardManagerV1 struct {
|
||||
client.BaseProxy
|
||||
}
|
||||
|
||||
// NewZwpVirtualKeyboardManagerV1 : virtual keyboard manager
|
||||
//
|
||||
// A virtual keyboard manager allows an application to provide keyboard
|
||||
// input events as if they came from a physical keyboard.
|
||||
func NewZwpVirtualKeyboardManagerV1(ctx *client.Context) *ZwpVirtualKeyboardManagerV1 {
|
||||
zwpVirtualKeyboardManagerV1 := &ZwpVirtualKeyboardManagerV1{}
|
||||
ctx.Register(zwpVirtualKeyboardManagerV1)
|
||||
return zwpVirtualKeyboardManagerV1
|
||||
}
|
||||
|
||||
// CreateVirtualKeyboard : Create a new virtual keyboard
|
||||
//
|
||||
// Creates a new virtual keyboard associated to a seat.
|
||||
//
|
||||
// If the compositor enables a keyboard to perform arbitrary actions, it
|
||||
// should present an error when an untrusted client requests a new
|
||||
// keyboard.
|
||||
func (i *ZwpVirtualKeyboardManagerV1) CreateVirtualKeyboard(seat *client.Seat) (*ZwpVirtualKeyboardV1, error) {
|
||||
id := NewZwpVirtualKeyboardV1(i.Context())
|
||||
const opcode = 0
|
||||
const _reqBufLen = 8 + 4 + 4
|
||||
var _reqBuf [_reqBufLen]byte
|
||||
l := 0
|
||||
client.PutUint32(_reqBuf[l:4], i.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], uint32(_reqBufLen<<16|opcode&0x0000ffff))
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], seat.ID())
|
||||
l += 4
|
||||
client.PutUint32(_reqBuf[l:l+4], id.ID())
|
||||
l += 4
|
||||
err := i.Context().WriteMsg(_reqBuf[:], nil)
|
||||
return id, err
|
||||
}
|
||||
|
||||
func (i *ZwpVirtualKeyboardManagerV1) Destroy() error {
|
||||
i.Context().Unregister(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
type ZwpVirtualKeyboardManagerV1Error uint32
|
||||
|
||||
// ZwpVirtualKeyboardManagerV1Error :
|
||||
const (
|
||||
// ZwpVirtualKeyboardManagerV1ErrorUnauthorized : client not authorized to use the interface
|
||||
ZwpVirtualKeyboardManagerV1ErrorUnauthorized ZwpVirtualKeyboardManagerV1Error = 0
|
||||
)
|
||||
|
||||
func (e ZwpVirtualKeyboardManagerV1Error) Name() string {
|
||||
switch e {
|
||||
case ZwpVirtualKeyboardManagerV1ErrorUnauthorized:
|
||||
return "unauthorized"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ZwpVirtualKeyboardManagerV1Error) Value() string {
|
||||
switch e {
|
||||
case ZwpVirtualKeyboardManagerV1ErrorUnauthorized:
|
||||
return "0"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (e ZwpVirtualKeyboardManagerV1Error) String() string {
|
||||
return e.Name() + "=" + e.Value()
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="virtual_keyboard_unstable_v1">
|
||||
<copyright>
|
||||
Copyright © 2008-2011 Kristian Høgsberg
|
||||
Copyright © 2010-2013 Intel Corporation
|
||||
Copyright © 2012-2013 Collabora, Ltd.
|
||||
Copyright © 2018 Purism SPC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<interface name="zwp_virtual_keyboard_v1" version="1">
|
||||
<description summary="virtual keyboard">
|
||||
The virtual keyboard provides an application with requests which emulate
|
||||
the behaviour of a physical keyboard.
|
||||
|
||||
This interface can be used by clients on its own to provide raw input
|
||||
events, or it can accompany the input method protocol.
|
||||
</description>
|
||||
|
||||
<request name="keymap">
|
||||
<description summary="keyboard mapping">
|
||||
Provide a file descriptor to the compositor which can be
|
||||
memory-mapped to provide a keyboard mapping description.
|
||||
|
||||
Format carries a value from the keymap_format enumeration.
|
||||
</description>
|
||||
<arg name="format" type="uint" summary="keymap format"/>
|
||||
<arg name="fd" type="fd" summary="keymap file descriptor"/>
|
||||
<arg name="size" type="uint" summary="keymap size, in bytes"/>
|
||||
</request>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="no_keymap" value="0" summary="No keymap was set"/>
|
||||
</enum>
|
||||
|
||||
<request name="key">
|
||||
<description summary="key event">
|
||||
A key was pressed or released.
|
||||
The time argument is a timestamp with millisecond granularity, with an
|
||||
undefined base. All requests regarding a single object must share the
|
||||
same clock.
|
||||
|
||||
Keymap must be set before issuing this request.
|
||||
|
||||
State carries a value from the key_state enumeration.
|
||||
</description>
|
||||
<arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
|
||||
<arg name="key" type="uint" summary="key that produced the event"/>
|
||||
<arg name="state" type="uint" summary="physical state of the key"/>
|
||||
</request>
|
||||
|
||||
<request name="modifiers">
|
||||
<description summary="modifier and group state">
|
||||
Notifies the compositor that the modifier and/or group state has
|
||||
changed, and it should update state.
|
||||
|
||||
The client should use wl_keyboard.modifiers event to synchronize its
|
||||
internal state with seat state.
|
||||
|
||||
Keymap must be set before issuing this request.
|
||||
</description>
|
||||
<arg name="mods_depressed" type="uint" summary="depressed modifiers"/>
|
||||
<arg name="mods_latched" type="uint" summary="latched modifiers"/>
|
||||
<arg name="mods_locked" type="uint" summary="locked modifiers"/>
|
||||
<arg name="group" type="uint" summary="keyboard layout"/>
|
||||
</request>
|
||||
|
||||
<request name="destroy" type="destructor" since="1">
|
||||
<description summary="destroy the virtual keyboard keyboard object"/>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="zwp_virtual_keyboard_manager_v1" version="1">
|
||||
<description summary="virtual keyboard manager">
|
||||
A virtual keyboard manager allows an application to provide keyboard
|
||||
input events as if they came from a physical keyboard.
|
||||
</description>
|
||||
|
||||
<enum name="error">
|
||||
<entry name="unauthorized" value="0" summary="client not authorized to use the interface"/>
|
||||
</enum>
|
||||
|
||||
<request name="create_virtual_keyboard">
|
||||
<description summary="Create a new virtual keyboard">
|
||||
Creates a new virtual keyboard associated to a seat.
|
||||
|
||||
If the compositor enables a keyboard to perform arbitrary actions, it
|
||||
should present an error when an untrusted client requests a new
|
||||
keyboard.
|
||||
</description>
|
||||
<arg name="seat" type="object" interface="wl_seat"/>
|
||||
<arg name="id" type="new_id" interface="zwp_virtual_keyboard_v1"/>
|
||||
</request>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
||||
)
|
||||
@@ -28,6 +29,10 @@ func HandleRequest(conn net.Conn, req models.Request, m *Manager) {
|
||||
handleCopyEntry(conn, req, m)
|
||||
case "clipboard.paste":
|
||||
handlePaste(conn, req, m)
|
||||
case "clipboard.sendPaste":
|
||||
handleSendPaste(conn, req)
|
||||
case "clipboard.pasteSupported":
|
||||
models.Respond(conn, req.ID, map[string]bool{"supported": m.pasteSupported})
|
||||
case "clipboard.subscribe":
|
||||
handleSubscribe(conn, req, m)
|
||||
case "clipboard.search":
|
||||
@@ -176,6 +181,17 @@ func handlePaste(conn net.Conn, req models.Request, m *Manager) {
|
||||
models.Respond(conn, req.ID, map[string]string{"text": text})
|
||||
}
|
||||
|
||||
func handleSendPaste(conn net.Conn, req models.Request) {
|
||||
shift, _ := models.Get[bool](req, "shift")
|
||||
|
||||
if err := clipboardstore.SendPasteKeystroke(shift); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "paste sent"})
|
||||
}
|
||||
|
||||
func handleSubscribe(conn net.Conn, req models.Request, m *Manager) {
|
||||
clientID := fmt.Sprintf("clipboard-%d", req.ID)
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/virtual_keyboard"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||
)
|
||||
@@ -164,6 +165,8 @@ func (m *Manager) setupRegistry() error {
|
||||
m.seat = seat
|
||||
m.seatName = e.Name
|
||||
log.Info("Bound wl_seat")
|
||||
case virtual_keyboard.ZwpVirtualKeyboardManagerV1InterfaceName:
|
||||
m.pasteSupported = true
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1054,6 +1057,13 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||
dataCopy := make([]byte, len(data))
|
||||
copy(dataCopy, data)
|
||||
|
||||
m.takeSelection(clipboardstore.ExpandOffers(dataCopy, mimeType))
|
||||
return nil
|
||||
}
|
||||
|
||||
// takeSelection makes the daemon the selection owner, serving the given
|
||||
// offers until another client claims the clipboard.
|
||||
func (m *Manager) takeSelection(offers []clipboardstore.Offer) {
|
||||
m.post(func() {
|
||||
if m.dataControlMgr == nil || m.dataDevice == nil {
|
||||
log.Error("Data control manager or device not initialized")
|
||||
@@ -1068,9 +1078,13 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||
return
|
||||
}
|
||||
|
||||
if err := source.Offer(mimeType); err != nil {
|
||||
log.Errorf("Failed to offer mime type: %v", err)
|
||||
return
|
||||
offerData := make(map[string][]byte, len(offers))
|
||||
for _, offer := range offers {
|
||||
if err := source.Offer(offer.MimeType); err != nil {
|
||||
log.Errorf("Failed to offer %s: %v", offer.MimeType, err)
|
||||
return
|
||||
}
|
||||
offerData[offer.MimeType] = offer.Data
|
||||
}
|
||||
|
||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||
@@ -1080,7 +1094,11 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||
file := os.NewFile(uintptr(fd), "clipboard-pipe")
|
||||
defer file.Close()
|
||||
|
||||
if _, err := file.Write(dataCopy); err != nil {
|
||||
data, ok := offerData[e.MimeType]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := file.Write(data); err != nil {
|
||||
log.Errorf("Failed to write clipboard data: %v", err)
|
||||
}
|
||||
})
|
||||
@@ -1093,9 +1111,6 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||
|
||||
m.releaseCurrentSource()
|
||||
m.currentSource = source
|
||||
m.sourceMutex.Lock()
|
||||
m.sourceMimeTypes = []string{mimeType}
|
||||
m.sourceMutex.Unlock()
|
||||
|
||||
m.ownerLock.Lock()
|
||||
m.isOwner = true
|
||||
@@ -1106,8 +1121,6 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
||||
log.Errorf("Failed to set selection: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) CopyText(text string) error {
|
||||
@@ -1779,74 +1792,16 @@ func (m *Manager) CopyFile(filePath string) error {
|
||||
m.updateState()
|
||||
m.notifySubscribers()
|
||||
|
||||
_, imgMime, imgErr := image.DecodeConfig(bytes.NewReader(fileData))
|
||||
|
||||
m.post(func() {
|
||||
if m.dataControlMgr == nil || m.dataDevice == nil {
|
||||
log.Error("Data control manager or device not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
dataMgr := m.dataControlMgr.(*ext_data_control.ExtDataControlManagerV1)
|
||||
source, err := dataMgr.CreateDataSource()
|
||||
if err != nil {
|
||||
log.Errorf("Failed to create data source: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
type offer struct {
|
||||
mime string
|
||||
data []byte
|
||||
}
|
||||
offers := []offer{
|
||||
{"x-special/gnome-copied-files", []byte("copy\n" + fileURI)},
|
||||
{"text/uri-list", []byte(fileURI + "\r\n")},
|
||||
{"text/plain", []byte(filePath)},
|
||||
}
|
||||
|
||||
if imgErr == nil {
|
||||
imgMimeType := "image/" + imgMime
|
||||
offers = append(offers, offer{imgMimeType, fileData})
|
||||
}
|
||||
|
||||
offerData := make(map[string][]byte)
|
||||
for _, o := range offers {
|
||||
if err := source.Offer(o.mime); err != nil {
|
||||
log.Errorf("Failed to offer %s: %v", o.mime, err)
|
||||
return
|
||||
}
|
||||
offerData[o.mime] = o.data
|
||||
}
|
||||
|
||||
source.SetSendHandler(func(e ext_data_control.ExtDataControlSourceV1SendEvent) {
|
||||
fd := e.Fd
|
||||
defer syscall.Close(fd)
|
||||
file := os.NewFile(uintptr(fd), "clipboard-pipe")
|
||||
defer file.Close()
|
||||
if data, ok := offerData[e.MimeType]; ok {
|
||||
file.Write(data)
|
||||
}
|
||||
})
|
||||
|
||||
source.SetCancelledHandler(func(e ext_data_control.ExtDataControlSourceV1CancelledEvent) {
|
||||
m.ownerLock.Lock()
|
||||
m.isOwner = false
|
||||
m.ownerLock.Unlock()
|
||||
})
|
||||
|
||||
m.releaseCurrentSource()
|
||||
m.currentSource = source
|
||||
|
||||
m.ownerLock.Lock()
|
||||
m.isOwner = true
|
||||
m.ownerLock.Unlock()
|
||||
|
||||
device := m.dataDevice.(*ext_data_control.ExtDataControlDeviceV1)
|
||||
if err := device.SetSelection(source); err != nil {
|
||||
log.Errorf("Failed to set selection: %v", err)
|
||||
}
|
||||
})
|
||||
offers := []clipboardstore.Offer{
|
||||
{MimeType: "x-special/gnome-copied-files", Data: []byte("copy\n" + fileURI)},
|
||||
{MimeType: "text/uri-list", Data: []byte(fileURI + "\r\n")},
|
||||
{MimeType: "text/plain", Data: []byte(filePath)},
|
||||
}
|
||||
if _, imgMime, err := image.DecodeConfig(bytes.NewReader(fileData)); err == nil {
|
||||
offers = append(offers, clipboardstore.Offer{MimeType: "image/" + imgMime, Data: fileData})
|
||||
}
|
||||
|
||||
m.takeSelection(offers)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -531,47 +531,6 @@ func TestManager_ConcurrentOfferAccess(t *testing.T) {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentPersistAccess(t *testing.T) {
|
||||
m := &Manager{
|
||||
persistData: make(map[string][]byte),
|
||||
persistMimeTypes: []string{},
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const goroutines = 20
|
||||
const iterations = 50
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.persistMutex.RLock()
|
||||
_ = m.persistData
|
||||
_ = m.persistMimeTypes
|
||||
m.persistMutex.RUnlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < goroutines/2; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < iterations; j++ {
|
||||
m.persistMutex.Lock()
|
||||
m.persistMimeTypes = []string{"text/plain", "text/html"}
|
||||
m.persistData = map[string][]byte{
|
||||
"text/plain": []byte("test"),
|
||||
}
|
||||
m.persistMutex.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestManager_ConcurrentOwnerAccess(t *testing.T) {
|
||||
m := &Manager{}
|
||||
|
||||
|
||||
@@ -132,15 +132,9 @@ type Manager struct {
|
||||
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
|
||||
isOwner bool
|
||||
ownerLock sync.Mutex
|
||||
pasteSupported bool
|
||||
|
||||
initialized bool
|
||||
|
||||
|
||||
@@ -18,8 +18,7 @@ in
|
||||
]
|
||||
++ lib.optional cfg.enableDynamicTheming pkgs.matugen
|
||||
++ lib.optional cfg.enableAudioWavelength pkgs.cava
|
||||
++ lib.optional cfg.enableCalendarEvents pkgs.khal
|
||||
++ lib.optional cfg.enableClipboardPaste pkgs.wtype;
|
||||
++ lib.optional cfg.enableCalendarEvents pkgs.khal;
|
||||
|
||||
plugins = lib.mapAttrs (name: plugin: {
|
||||
source = plugin.src;
|
||||
|
||||
@@ -76,7 +76,7 @@ in
|
||||
enableClipboardPaste = lib.mkOption {
|
||||
type = types.bool;
|
||||
default = true;
|
||||
description = "Adds needed dependencies for directly pasting items from the clipboard history.";
|
||||
description = "Deprecated: paste is built into dms; no extra dependencies needed. Kept as a no-op for compatibility.";
|
||||
};
|
||||
|
||||
quickshell = {
|
||||
|
||||
@@ -61,7 +61,6 @@ install whichever features you want:
|
||||
| `cava` | audio visualiser widget |
|
||||
| `qt6-multimedia` | system sound feedback |
|
||||
| `qt6ct` | Qt app theming |
|
||||
| `wtype` | virtual keyboard input |
|
||||
| `power-profiles-daemon` | power profile control |
|
||||
| `cups-pk-helper` | printer management |
|
||||
| `NetworkManager` | network control |
|
||||
|
||||
@@ -396,7 +396,7 @@ Item {
|
||||
}
|
||||
|
||||
sourceComponent: ClipboardKeyboardHints {
|
||||
wtypeAvailable: modal.wtypeAvailable
|
||||
pasteAvailable: modal.pasteAvailable
|
||||
enterToPaste: SettingsData.clipboardEnterToPaste
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,7 +496,7 @@ Item {
|
||||
implicitHeight: saveMenuPasteRow.implicitHeight + Theme.spacingS * 2
|
||||
radius: Theme.cornerRadius
|
||||
color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : Theme.withAlpha(Theme.surfaceVariant, 0)
|
||||
opacity: modal.wtypeAvailable ? 1 : 0.5
|
||||
opacity: modal.pasteAvailable ? 1 : 0.5
|
||||
|
||||
Row {
|
||||
id: saveMenuPasteRow
|
||||
@@ -522,7 +522,7 @@ Item {
|
||||
id: saveMenuPasteArea
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: modal.wtypeAvailable
|
||||
enabled: modal.pasteAvailable
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
saveMenu.close();
|
||||
|
||||
@@ -21,7 +21,7 @@ FocusScope {
|
||||
property string activeFilter: SettingsData.clipboardRememberTypeFilter ? SettingsData.clipboardTypeFilter : "all"
|
||||
|
||||
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
|
||||
readonly property bool wtypeAvailable: ClipboardService.wtypeAvailable
|
||||
readonly property bool pasteAvailable: ClipboardService.pasteAvailable
|
||||
readonly property int totalCount: ClipboardService.totalCount
|
||||
readonly property var clipboardEntries: ClipboardService.clipboardEntries
|
||||
readonly property var pinnedEntries: ClipboardService.pinnedEntries
|
||||
|
||||
@@ -5,10 +5,10 @@ import qs.Widgets
|
||||
Rectangle {
|
||||
id: keyboardHints
|
||||
|
||||
property bool wtypeAvailable: false
|
||||
property bool pasteAvailable: false
|
||||
property bool enterToPaste: false
|
||||
readonly property string hintsText: {
|
||||
if (!wtypeAvailable)
|
||||
if (!pasteAvailable)
|
||||
return I18n.tr("Ctrl+Tab: Switch Tab • Ctrl+S: Pin/Unpin • Shift+Del: Clear All • Esc: Close");
|
||||
return enterToPaste ? I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Copy • Shift+Del: Clear All • F10: Help • Esc: Close", "Keyboard hints when enter-to-paste is enabled") : I18n.tr("Ctrl+Tab: Switch Tabs • Ctrl+S: Pin/Unpin • Shift+Enter: Paste • Shift+Del: Clear All • F10: Help • Esc: Close");
|
||||
}
|
||||
|
||||
@@ -145,12 +145,6 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: wtypeProcess
|
||||
command: ["wtype", "-M", "ctrl", "-P", "v", "-p", "v", "-m", "ctrl"]
|
||||
running: false
|
||||
}
|
||||
|
||||
Process {
|
||||
id: copyProcess
|
||||
running: false
|
||||
@@ -161,7 +155,7 @@ Item {
|
||||
id: pasteTimer
|
||||
interval: 200
|
||||
repeat: false
|
||||
onTriggered: wtypeProcess.running = true
|
||||
onTriggered: ClipboardService.sendPasteKeystroke()
|
||||
}
|
||||
|
||||
function pasteSelected() {
|
||||
@@ -179,10 +173,6 @@ Item {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!SessionService.wtypeAvailable) {
|
||||
ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginId = selectedItem.pluginId;
|
||||
if (!pluginId)
|
||||
|
||||
@@ -3,7 +3,7 @@ pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import qs.Common
|
||||
import qs.Services
|
||||
|
||||
@@ -14,7 +14,10 @@ Singleton {
|
||||
readonly property int longTextThreshold: 200
|
||||
|
||||
readonly property bool clipboardAvailable: DMSService.isConnected && (DMSService.capabilities.length === 0 || DMSService.capabilities.includes("clipboard"))
|
||||
readonly property bool wtypeAvailable: SessionService.wtypeAvailable
|
||||
property bool pasteSupported: false
|
||||
readonly property bool pasteAvailable: clipboardAvailable && pasteSupported
|
||||
|
||||
readonly property var terminalAppIds: ["kitty", "foot", "footclient", "alacritty", "st", "org.wezfurlong.wezterm", "com.mitchellh.ghostty", "ghostty", "org.kde.konsole", "konsole", "org.gnome.terminal", "gnome-terminal-server", "org.gnome.console", "kgx", "com.gexperts.tilix", "tilix", "terminator", "xfce4-terminal", "lxterminal", "deepin-terminal", "io.elementary.terminal", "rio", "contour", "wayst", "urxvt", "rxvt"]
|
||||
|
||||
property var internalEntries: []
|
||||
property var clipboardEntries: []
|
||||
@@ -37,38 +40,62 @@ Singleton {
|
||||
signal historyCleared
|
||||
signal launcherSearchReady(string query)
|
||||
|
||||
Process {
|
||||
id: wtypeProcess
|
||||
// TODO: This is only a paste shortcut fallback. It assumes the target
|
||||
// application accepts Ctrl+V, which is false for many terminals.
|
||||
// Replace with a more reliable target-aware paste strategy.
|
||||
command: ["wtype", "-M", "ctrl", "-P", "v", "-p", "v", "-m", "ctrl"]
|
||||
running: false
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: pasteTimer
|
||||
interval: 200
|
||||
repeat: false
|
||||
onTriggered: wtypeProcess.running = true
|
||||
onTriggered: root.sendPasteKeystroke()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: DMSService
|
||||
function onIsConnectedChanged() {
|
||||
root.refreshPasteSupport();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: refreshPasteSupport()
|
||||
|
||||
function refreshPasteSupport() {
|
||||
if (!DMSService.isConnected) {
|
||||
pasteSupported = false;
|
||||
return;
|
||||
}
|
||||
DMSService.sendRequest("clipboard.pasteSupported", null, function (response) {
|
||||
root.pasteSupported = !response.error && response.result && response.result.supported === true;
|
||||
});
|
||||
}
|
||||
|
||||
function isTerminalFocused() {
|
||||
const appId = (ToplevelManager.activeToplevel?.appId ?? "").toLowerCase();
|
||||
if (!appId) {
|
||||
return false;
|
||||
}
|
||||
return terminalAppIds.includes(appId) || appId.endsWith("term") || appId.includes("terminal");
|
||||
}
|
||||
|
||||
function sendPasteKeystroke() {
|
||||
DMSService.sendRequest("clipboard.sendPaste", {
|
||||
"shift": isTerminalFocused()
|
||||
}, function (response) {
|
||||
if (response.error) {
|
||||
ToastService.showError(I18n.tr("Paste failed: %1").arg(response.error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateFilteredModel() {
|
||||
let filtered = internalEntries;
|
||||
|
||||
if (activeFilter !== "all") {
|
||||
filtered = filtered.filter(entry =>
|
||||
getEntryType(entry) === activeFilter
|
||||
);
|
||||
filtered = filtered.filter(entry => getEntryType(entry) === activeFilter);
|
||||
}
|
||||
|
||||
const query = searchText.trim();
|
||||
|
||||
if (query.length > 0) {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
filtered = filtered.filter(entry =>
|
||||
entry.preview.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
filtered = filtered.filter(entry => entry.preview.toLowerCase().includes(lowerQuery));
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
@@ -256,19 +283,17 @@ Singleton {
|
||||
}
|
||||
|
||||
function pasteClipboard(closeCallback) {
|
||||
if (!wtypeAvailable) {
|
||||
ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
|
||||
return;
|
||||
}
|
||||
if (closeCallback) {
|
||||
closeCallback();
|
||||
}
|
||||
pasteTimer.start();
|
||||
if (pasteAvailable) {
|
||||
pasteTimer.start();
|
||||
}
|
||||
}
|
||||
|
||||
function pasteEntry(entry, closeCallback) {
|
||||
if (!wtypeAvailable) {
|
||||
ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
|
||||
if (!pasteAvailable) {
|
||||
copyEntry(entry, closeCallback);
|
||||
return;
|
||||
}
|
||||
DMSService.sendRequest("clipboard.copyEntry", {
|
||||
|
||||
@@ -21,7 +21,6 @@ Singleton {
|
||||
property string nvidiaCommand: ""
|
||||
|
||||
property bool loginctlAvailable: false
|
||||
property bool wtypeAvailable: false
|
||||
property string sessionId: ""
|
||||
property string sessionPath: ""
|
||||
property bool locked: false
|
||||
@@ -55,7 +54,6 @@ Singleton {
|
||||
detectElogindProcess.running = true;
|
||||
detectHibernateProcess.running = true;
|
||||
detectPrimeRunProcess.running = true;
|
||||
detectWtypeProcess.running = true;
|
||||
if (!SettingsData.loginctlLockIntegration) {
|
||||
log.debug("loginctl lock integration disabled by user");
|
||||
return;
|
||||
@@ -120,15 +118,6 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: detectWtypeProcess
|
||||
running: false
|
||||
command: ["sh", "-c", "command -v wtype"]
|
||||
onExited: exitCode => {
|
||||
wtypeAvailable = (exitCode === 0);
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: detectPrimeRunProcess
|
||||
running: false
|
||||
|
||||
+199
-187
File diff suppressed because it is too large
Load Diff
@@ -916,13 +916,6 @@
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Active Lock Screen Monitor",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Active VPN",
|
||||
"translation": "",
|
||||
@@ -1196,13 +1189,6 @@
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "All Monitors",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "All checks passed",
|
||||
"translation": "",
|
||||
@@ -2484,6 +2470,13 @@
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Battery Alerts",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Battery Charge Limit",
|
||||
"translation": "",
|
||||
@@ -2506,7 +2499,7 @@
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Battery Protection & Charging",
|
||||
"term": "Battery Protection",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
@@ -3367,7 +3360,21 @@
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Choose how to be notified about battery alerts.",
|
||||
"term": "Choose how to be notified about critical battery alerts.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Choose how to be notified about low battery alerts.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Choose how to be notified when charge limit is reached.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
@@ -3458,7 +3465,7 @@
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Choose which monitor shows the lock screen interface. Other monitors will display a solid color for OLED burn-in protection.",
|
||||
"term": "Choose which monitors show the lock screen interface. Other monitors will display a solid color for OLED burn-in protection.",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
@@ -13663,6 +13670,13 @@
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Paste failed: %1",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Path copied to clipboard",
|
||||
"translation": "",
|
||||
@@ -14266,7 +14280,7 @@
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Power Profiles Auto-Switching",
|
||||
"term": "Power Profiles & Saving",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
@@ -19403,6 +19417,13 @@
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Unknown Model",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "Unknown Monitor",
|
||||
"translation": "",
|
||||
@@ -21461,13 +21482,6 @@
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "wtype not available - install wtype for paste support",
|
||||
"translation": "",
|
||||
"context": "",
|
||||
"reference": "",
|
||||
"comment": ""
|
||||
},
|
||||
{
|
||||
"term": "• Install only from trusted sources",
|
||||
"translation": "",
|
||||
|
||||
Reference in New Issue
Block a user