mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-01 19:18: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,
|
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{
|
var clipWatchCmd = &cobra.Command{
|
||||||
Use: "watch [command]",
|
Use: "watch [command]",
|
||||||
Short: "Watch clipboard for changes",
|
Short: "Watch clipboard for changes",
|
||||||
@@ -216,8 +225,10 @@ func init() {
|
|||||||
|
|
||||||
clipMigrateCmd.Flags().BoolVar(&clipMigrateDelete, "delete", false, "Delete cliphist db after successful migration")
|
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)
|
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) {
|
func runClipCopy(cmd *cobra.Command, args []string) {
|
||||||
@@ -314,6 +325,12 @@ func runClipPaste(cmd *cobra.Command, args []string) {
|
|||||||
os.Stdout.Write(data)
|
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) {
|
func runClipWatch(cmd *cobra.Command, args []string) {
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
"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"
|
const envServe = "_DMS_CLIPBOARD_SERVE"
|
||||||
@@ -17,6 +16,37 @@ const envMime = "_DMS_CLIPBOARD_MIME"
|
|||||||
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
|
const envPasteOnce = "_DMS_CLIPBOARD_PASTE_ONCE"
|
||||||
const envCacheFile = "_DMS_CLIPBOARD_CACHE"
|
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
|
// MaybeServeAndExit intercepts before cobra when re-exec'd as a clipboard
|
||||||
// child. Reads source data into memory, deletes any cache file, then serves.
|
// child. Reads source data into memory, deletes any cache file, then serves.
|
||||||
func MaybeServeAndExit() {
|
func MaybeServeAndExit() {
|
||||||
@@ -44,7 +74,7 @@ func MaybeServeAndExit() {
|
|||||||
os.Exit(1)
|
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)
|
fmt.Fprintf(os.Stderr, "clipboard: serve: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -55,22 +85,33 @@ func Copy(data []byte, mimeType string) error {
|
|||||||
return copyForkCached(data, mimeType, false)
|
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 {
|
func CopyOpts(data []byte, mimeType string, foreground, pasteOnce bool) error {
|
||||||
if foreground {
|
if foreground {
|
||||||
return serveClipboard(data, mimeType, pasteOnce)
|
return serveOffers(ExpandOffers(data, mimeType), pasteOnce)
|
||||||
}
|
}
|
||||||
return copyForkCached(data, mimeType, pasteOnce)
|
return copyForkCached(data, mimeType, pasteOnce)
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
|
func CopyReader(data io.Reader, mimeType string, foreground, pasteOnce bool) error {
|
||||||
if foreground {
|
if !foreground {
|
||||||
buf, err := io.ReadAll(data)
|
return copyFork(data, mimeType, pasteOnce)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("read source: %w", err)
|
|
||||||
}
|
|
||||||
return serveClipboard(buf, 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 {
|
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 {
|
func copyFork(data io.Reader, mimeType string, pasteOnce bool) error {
|
||||||
cmd := newForkCmd(mimeType, pasteOnce)
|
cmd := newForkCmd(mimeType, pasteOnce)
|
||||||
|
|
||||||
switch src := data.(type) {
|
if src, ok := data.(*os.File); ok {
|
||||||
case *os.File:
|
|
||||||
cmd.Stdin = src
|
cmd.Stdin = src
|
||||||
return waitReady(cmd)
|
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() {
|
func signalReady() {
|
||||||
@@ -194,57 +266,25 @@ func createClipboardCacheFile() (*os.File, error) {
|
|||||||
return os.CreateTemp("", "dms-clipboard-*")
|
return os.CreateTemp("", "dms-clipboard-*")
|
||||||
}
|
}
|
||||||
|
|
||||||
func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
// serveOffers owns the Wayland selection until cancelled (or first paste when
|
||||||
display, err := wlclient.Connect("")
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("wayland connect: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
defer display.Destroy()
|
defer s.Close()
|
||||||
|
|
||||||
ctx := display.Context()
|
dataControlMgr, err := s.requireDataControl()
|
||||||
registry, err := display.GetRegistry()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get registry: %w", err)
|
return 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)
|
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get data device: %w", err)
|
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)
|
return fmt.Errorf("create data source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := source.Offer(mimeType); err != nil {
|
offerData := make(map[string][]byte, len(offers))
|
||||||
return fmt.Errorf("offer mime type: %w", err)
|
for _, offer := range offers {
|
||||||
}
|
if err := source.Offer(offer.MimeType); err != nil {
|
||||||
if mimeType == "text/plain;charset=utf-8" || mimeType == "text/plain" {
|
return fmt.Errorf("offer %s: %w", offer.MimeType, err)
|
||||||
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[offer.MimeType] = offer.Data
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelled := make(chan struct{})
|
cancelled := make(chan struct{})
|
||||||
@@ -283,7 +310,11 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
_ = syscall.SetNonblock(e.Fd, false)
|
_ = syscall.SetNonblock(e.Fd, false)
|
||||||
file := os.NewFile(uintptr(e.Fd), "pipe")
|
file := os.NewFile(uintptr(e.Fd), "pipe")
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
_, _ = file.Write(data)
|
|
||||||
|
if data, ok := offerData[e.MimeType]; ok {
|
||||||
|
_, _ = file.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case pasted <- struct{}{}:
|
case pasted <- struct{}{}:
|
||||||
default:
|
default:
|
||||||
@@ -298,7 +329,7 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
return fmt.Errorf("set selection: %w", err)
|
return fmt.Errorf("set selection: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
display.Roundtrip()
|
s.display.Roundtrip()
|
||||||
signalReady()
|
signalReady()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -310,70 +341,26 @@ func serveClipboard(data []byte, mimeType string, pasteOnce bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
if err := ctx.Dispatch(); err != nil {
|
if err := s.ctx.Dispatch(); err != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func CopyText(text string) error {
|
|
||||||
return Copy([]byte(text), "text/plain;charset=utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
func Paste() ([]byte, string, error) {
|
func Paste() ([]byte, string, error) {
|
||||||
display, err := wlclient.Connect("")
|
s, err := connectSession()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("wayland connect: %w", err)
|
return nil, "", err
|
||||||
}
|
}
|
||||||
defer display.Destroy()
|
defer s.Close()
|
||||||
|
|
||||||
ctx := display.Context()
|
dataControlMgr, err := s.requireDataControl()
|
||||||
registry, err := display.GetRegistry()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("get registry: %w", err)
|
return nil, "", 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 {
|
device, err := dataControlMgr.GetDataDevice(s.seat)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("get data device: %w", err)
|
return nil, "", fmt.Errorf("get data device: %w", err)
|
||||||
}
|
}
|
||||||
@@ -399,15 +386,14 @@ func Paste() ([]byte, string, error) {
|
|||||||
gotSelection = true
|
gotSelection = true
|
||||||
})
|
})
|
||||||
|
|
||||||
display.Roundtrip()
|
s.display.Roundtrip()
|
||||||
display.Roundtrip()
|
s.display.Roundtrip()
|
||||||
|
|
||||||
if !gotSelection || selectionOffer == nil {
|
if !gotSelection || selectionOffer == nil {
|
||||||
return nil, "", fmt.Errorf("no clipboard data")
|
return nil, "", fmt.Errorf("no clipboard data")
|
||||||
}
|
}
|
||||||
|
|
||||||
mimeTypes := offerMimeTypes[selectionOffer]
|
selectedMime := selectPreferredMimeType(offerMimeTypes[selectionOffer])
|
||||||
selectedMime := selectPreferredMimeType(mimeTypes)
|
|
||||||
if selectedMime == "" {
|
if selectedMime == "" {
|
||||||
return nil, "", fmt.Errorf("no supported mime type")
|
return nil, "", fmt.Errorf("no supported mime type")
|
||||||
}
|
}
|
||||||
@@ -424,7 +410,7 @@ func Paste() ([]byte, string, error) {
|
|||||||
}
|
}
|
||||||
w.Close()
|
w.Close()
|
||||||
|
|
||||||
display.Roundtrip()
|
s.display.Roundtrip()
|
||||||
|
|
||||||
data, err := io.ReadAll(r)
|
data, err := io.ReadAll(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -470,161 +456,3 @@ func selectPreferredMimeType(mimes []string) string {
|
|||||||
func IsImageMimeType(mime string) bool {
|
func IsImageMimeType(mime string) bool {
|
||||||
return len(mime) > 6 && mime[:6] == "image/"
|
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"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
|
||||||
|
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
"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)
|
handleCopyEntry(conn, req, m)
|
||||||
case "clipboard.paste":
|
case "clipboard.paste":
|
||||||
handlePaste(conn, req, m)
|
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":
|
case "clipboard.subscribe":
|
||||||
handleSubscribe(conn, req, m)
|
handleSubscribe(conn, req, m)
|
||||||
case "clipboard.search":
|
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})
|
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) {
|
func handleSubscribe(conn net.Conn, req models.Request, m *Manager) {
|
||||||
clientID := fmt.Sprintf("clipboard-%d", req.ID)
|
clientID := fmt.Sprintf("clipboard-%d", req.ID)
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
clipboardstore "github.com/AvengeMedia/DankMaterialShell/core/internal/clipboard"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/log"
|
||||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/proto/ext_data_control"
|
"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"
|
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
||||||
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
wlclient "github.com/AvengeMedia/DankMaterialShell/core/pkg/go-wayland/wayland/client"
|
||||||
)
|
)
|
||||||
@@ -164,6 +165,8 @@ func (m *Manager) setupRegistry() error {
|
|||||||
m.seat = seat
|
m.seat = seat
|
||||||
m.seatName = e.Name
|
m.seatName = e.Name
|
||||||
log.Info("Bound wl_seat")
|
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))
|
dataCopy := make([]byte, len(data))
|
||||||
copy(dataCopy, 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() {
|
m.post(func() {
|
||||||
if m.dataControlMgr == nil || m.dataDevice == nil {
|
if m.dataControlMgr == nil || m.dataDevice == nil {
|
||||||
log.Error("Data control manager or device not initialized")
|
log.Error("Data control manager or device not initialized")
|
||||||
@@ -1068,9 +1078,13 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := source.Offer(mimeType); err != nil {
|
offerData := make(map[string][]byte, len(offers))
|
||||||
log.Errorf("Failed to offer mime type: %v", err)
|
for _, offer := range offers {
|
||||||
return
|
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) {
|
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")
|
file := os.NewFile(uintptr(fd), "clipboard-pipe")
|
||||||
defer file.Close()
|
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)
|
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.releaseCurrentSource()
|
||||||
m.currentSource = source
|
m.currentSource = source
|
||||||
m.sourceMutex.Lock()
|
|
||||||
m.sourceMimeTypes = []string{mimeType}
|
|
||||||
m.sourceMutex.Unlock()
|
|
||||||
|
|
||||||
m.ownerLock.Lock()
|
m.ownerLock.Lock()
|
||||||
m.isOwner = true
|
m.isOwner = true
|
||||||
@@ -1106,8 +1121,6 @@ func (m *Manager) SetClipboard(data []byte, mimeType string) error {
|
|||||||
log.Errorf("Failed to set selection: %v", err)
|
log.Errorf("Failed to set selection: %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) CopyText(text string) error {
|
func (m *Manager) CopyText(text string) error {
|
||||||
@@ -1779,74 +1792,16 @@ func (m *Manager) CopyFile(filePath string) error {
|
|||||||
m.updateState()
|
m.updateState()
|
||||||
m.notifySubscribers()
|
m.notifySubscribers()
|
||||||
|
|
||||||
_, imgMime, imgErr := image.DecodeConfig(bytes.NewReader(fileData))
|
offers := []clipboardstore.Offer{
|
||||||
|
{MimeType: "x-special/gnome-copied-files", Data: []byte("copy\n" + fileURI)},
|
||||||
m.post(func() {
|
{MimeType: "text/uri-list", Data: []byte(fileURI + "\r\n")},
|
||||||
if m.dataControlMgr == nil || m.dataDevice == nil {
|
{MimeType: "text/plain", Data: []byte(filePath)},
|
||||||
log.Error("Data control manager or device not initialized")
|
}
|
||||||
return
|
if _, imgMime, err := image.DecodeConfig(bytes.NewReader(fileData)); err == nil {
|
||||||
}
|
offers = append(offers, clipboardstore.Offer{MimeType: "image/" + imgMime, Data: fileData})
|
||||||
|
}
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
m.takeSelection(offers)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -531,47 +531,6 @@ func TestManager_ConcurrentOfferAccess(t *testing.T) {
|
|||||||
wg.Wait()
|
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) {
|
func TestManager_ConcurrentOwnerAccess(t *testing.T) {
|
||||||
m := &Manager{}
|
m := &Manager{}
|
||||||
|
|
||||||
|
|||||||
@@ -132,15 +132,9 @@ type Manager struct {
|
|||||||
offerMutex sync.RWMutex
|
offerMutex sync.RWMutex
|
||||||
offerRegistry map[uint32]any
|
offerRegistry map[uint32]any
|
||||||
|
|
||||||
sourceMimeTypes []string
|
isOwner bool
|
||||||
sourceMutex sync.RWMutex
|
ownerLock sync.Mutex
|
||||||
|
pasteSupported bool
|
||||||
persistData map[string][]byte
|
|
||||||
persistMimeTypes []string
|
|
||||||
persistMutex sync.RWMutex
|
|
||||||
|
|
||||||
isOwner bool
|
|
||||||
ownerLock sync.Mutex
|
|
||||||
|
|
||||||
initialized bool
|
initialized bool
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ in
|
|||||||
]
|
]
|
||||||
++ lib.optional cfg.enableDynamicTheming pkgs.matugen
|
++ lib.optional cfg.enableDynamicTheming pkgs.matugen
|
||||||
++ lib.optional cfg.enableAudioWavelength pkgs.cava
|
++ lib.optional cfg.enableAudioWavelength pkgs.cava
|
||||||
++ lib.optional cfg.enableCalendarEvents pkgs.khal
|
++ lib.optional cfg.enableCalendarEvents pkgs.khal;
|
||||||
++ lib.optional cfg.enableClipboardPaste pkgs.wtype;
|
|
||||||
|
|
||||||
plugins = lib.mapAttrs (name: plugin: {
|
plugins = lib.mapAttrs (name: plugin: {
|
||||||
source = plugin.src;
|
source = plugin.src;
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ in
|
|||||||
enableClipboardPaste = lib.mkOption {
|
enableClipboardPaste = lib.mkOption {
|
||||||
type = types.bool;
|
type = types.bool;
|
||||||
default = true;
|
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 = {
|
quickshell = {
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ install whichever features you want:
|
|||||||
| `cava` | audio visualiser widget |
|
| `cava` | audio visualiser widget |
|
||||||
| `qt6-multimedia` | system sound feedback |
|
| `qt6-multimedia` | system sound feedback |
|
||||||
| `qt6ct` | Qt app theming |
|
| `qt6ct` | Qt app theming |
|
||||||
| `wtype` | virtual keyboard input |
|
|
||||||
| `power-profiles-daemon` | power profile control |
|
| `power-profiles-daemon` | power profile control |
|
||||||
| `cups-pk-helper` | printer management |
|
| `cups-pk-helper` | printer management |
|
||||||
| `NetworkManager` | network control |
|
| `NetworkManager` | network control |
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourceComponent: ClipboardKeyboardHints {
|
sourceComponent: ClipboardKeyboardHints {
|
||||||
wtypeAvailable: modal.wtypeAvailable
|
pasteAvailable: modal.pasteAvailable
|
||||||
enterToPaste: SettingsData.clipboardEnterToPaste
|
enterToPaste: SettingsData.clipboardEnterToPaste
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ Item {
|
|||||||
implicitHeight: saveMenuPasteRow.implicitHeight + Theme.spacingS * 2
|
implicitHeight: saveMenuPasteRow.implicitHeight + Theme.spacingS * 2
|
||||||
radius: Theme.cornerRadius
|
radius: Theme.cornerRadius
|
||||||
color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : Theme.withAlpha(Theme.surfaceVariant, 0)
|
color: saveMenuPasteArea.containsMouse ? Theme.surfaceVariant : Theme.withAlpha(Theme.surfaceVariant, 0)
|
||||||
opacity: modal.wtypeAvailable ? 1 : 0.5
|
opacity: modal.pasteAvailable ? 1 : 0.5
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
id: saveMenuPasteRow
|
id: saveMenuPasteRow
|
||||||
@@ -522,7 +522,7 @@ Item {
|
|||||||
id: saveMenuPasteArea
|
id: saveMenuPasteArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
enabled: modal.wtypeAvailable
|
enabled: modal.pasteAvailable
|
||||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
onClicked: {
|
onClicked: {
|
||||||
saveMenu.close();
|
saveMenu.close();
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ FocusScope {
|
|||||||
property string activeFilter: SettingsData.clipboardRememberTypeFilter ? SettingsData.clipboardTypeFilter : "all"
|
property string activeFilter: SettingsData.clipboardRememberTypeFilter ? SettingsData.clipboardTypeFilter : "all"
|
||||||
|
|
||||||
readonly property bool clipboardAvailable: ClipboardService.clipboardAvailable
|
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 int totalCount: ClipboardService.totalCount
|
||||||
readonly property var clipboardEntries: ClipboardService.clipboardEntries
|
readonly property var clipboardEntries: ClipboardService.clipboardEntries
|
||||||
readonly property var pinnedEntries: ClipboardService.pinnedEntries
|
readonly property var pinnedEntries: ClipboardService.pinnedEntries
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import qs.Widgets
|
|||||||
Rectangle {
|
Rectangle {
|
||||||
id: keyboardHints
|
id: keyboardHints
|
||||||
|
|
||||||
property bool wtypeAvailable: false
|
property bool pasteAvailable: false
|
||||||
property bool enterToPaste: false
|
property bool enterToPaste: false
|
||||||
readonly property string hintsText: {
|
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 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");
|
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 {
|
Process {
|
||||||
id: copyProcess
|
id: copyProcess
|
||||||
running: false
|
running: false
|
||||||
@@ -161,7 +155,7 @@ Item {
|
|||||||
id: pasteTimer
|
id: pasteTimer
|
||||||
interval: 200
|
interval: 200
|
||||||
repeat: false
|
repeat: false
|
||||||
onTriggered: wtypeProcess.running = true
|
onTriggered: ClipboardService.sendPasteKeystroke()
|
||||||
}
|
}
|
||||||
|
|
||||||
function pasteSelected() {
|
function pasteSelected() {
|
||||||
@@ -179,10 +173,6 @@ Item {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!SessionService.wtypeAvailable) {
|
|
||||||
ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginId = selectedItem.pluginId;
|
const pluginId = selectedItem.pluginId;
|
||||||
if (!pluginId)
|
if (!pluginId)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ pragma ComponentBehavior: Bound
|
|||||||
|
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Wayland
|
||||||
import qs.Common
|
import qs.Common
|
||||||
import qs.Services
|
import qs.Services
|
||||||
|
|
||||||
@@ -14,7 +14,10 @@ Singleton {
|
|||||||
readonly property int longTextThreshold: 200
|
readonly property int longTextThreshold: 200
|
||||||
|
|
||||||
readonly property bool clipboardAvailable: DMSService.isConnected && (DMSService.capabilities.length === 0 || DMSService.capabilities.includes("clipboard"))
|
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 internalEntries: []
|
||||||
property var clipboardEntries: []
|
property var clipboardEntries: []
|
||||||
@@ -37,38 +40,62 @@ Singleton {
|
|||||||
signal historyCleared
|
signal historyCleared
|
||||||
signal launcherSearchReady(string query)
|
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 {
|
Timer {
|
||||||
id: pasteTimer
|
id: pasteTimer
|
||||||
interval: 200
|
interval: 200
|
||||||
repeat: false
|
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() {
|
function updateFilteredModel() {
|
||||||
let filtered = internalEntries;
|
let filtered = internalEntries;
|
||||||
|
|
||||||
if (activeFilter !== "all") {
|
if (activeFilter !== "all") {
|
||||||
filtered = filtered.filter(entry =>
|
filtered = filtered.filter(entry => getEntryType(entry) === activeFilter);
|
||||||
getEntryType(entry) === activeFilter
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = searchText.trim();
|
const query = searchText.trim();
|
||||||
|
|
||||||
if (query.length > 0) {
|
if (query.length > 0) {
|
||||||
const lowerQuery = query.toLowerCase();
|
const lowerQuery = query.toLowerCase();
|
||||||
filtered = filtered.filter(entry =>
|
filtered = filtered.filter(entry => entry.preview.toLowerCase().includes(lowerQuery));
|
||||||
entry.preview.toLowerCase().includes(lowerQuery)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
@@ -256,19 +283,17 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pasteClipboard(closeCallback) {
|
function pasteClipboard(closeCallback) {
|
||||||
if (!wtypeAvailable) {
|
|
||||||
ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (closeCallback) {
|
if (closeCallback) {
|
||||||
closeCallback();
|
closeCallback();
|
||||||
}
|
}
|
||||||
pasteTimer.start();
|
if (pasteAvailable) {
|
||||||
|
pasteTimer.start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pasteEntry(entry, closeCallback) {
|
function pasteEntry(entry, closeCallback) {
|
||||||
if (!wtypeAvailable) {
|
if (!pasteAvailable) {
|
||||||
ToastService.showError(I18n.tr("wtype not available - install wtype for paste support"));
|
copyEntry(entry, closeCallback);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
DMSService.sendRequest("clipboard.copyEntry", {
|
DMSService.sendRequest("clipboard.copyEntry", {
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ Singleton {
|
|||||||
property string nvidiaCommand: ""
|
property string nvidiaCommand: ""
|
||||||
|
|
||||||
property bool loginctlAvailable: false
|
property bool loginctlAvailable: false
|
||||||
property bool wtypeAvailable: false
|
|
||||||
property string sessionId: ""
|
property string sessionId: ""
|
||||||
property string sessionPath: ""
|
property string sessionPath: ""
|
||||||
property bool locked: false
|
property bool locked: false
|
||||||
@@ -55,7 +54,6 @@ Singleton {
|
|||||||
detectElogindProcess.running = true;
|
detectElogindProcess.running = true;
|
||||||
detectHibernateProcess.running = true;
|
detectHibernateProcess.running = true;
|
||||||
detectPrimeRunProcess.running = true;
|
detectPrimeRunProcess.running = true;
|
||||||
detectWtypeProcess.running = true;
|
|
||||||
if (!SettingsData.loginctlLockIntegration) {
|
if (!SettingsData.loginctlLockIntegration) {
|
||||||
log.debug("loginctl lock integration disabled by user");
|
log.debug("loginctl lock integration disabled by user");
|
||||||
return;
|
return;
|
||||||
@@ -120,15 +118,6 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
|
||||||
id: detectWtypeProcess
|
|
||||||
running: false
|
|
||||||
command: ["sh", "-c", "command -v wtype"]
|
|
||||||
onExited: exitCode => {
|
|
||||||
wtypeAvailable = (exitCode === 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: detectPrimeRunProcess
|
id: detectPrimeRunProcess
|
||||||
running: false
|
running: false
|
||||||
|
|||||||
+199
-187
File diff suppressed because it is too large
Load Diff
@@ -916,13 +916,6 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"term": "Active Lock Screen Monitor",
|
|
||||||
"translation": "",
|
|
||||||
"context": "",
|
|
||||||
"reference": "",
|
|
||||||
"comment": ""
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"term": "Active VPN",
|
"term": "Active VPN",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -1196,13 +1189,6 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"term": "All Monitors",
|
|
||||||
"translation": "",
|
|
||||||
"context": "",
|
|
||||||
"reference": "",
|
|
||||||
"comment": ""
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"term": "All checks passed",
|
"term": "All checks passed",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -2484,6 +2470,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Battery Alerts",
|
||||||
|
"translation": "",
|
||||||
|
"context": "",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Battery Charge Limit",
|
"term": "Battery Charge Limit",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -2506,7 +2499,7 @@
|
|||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"term": "Battery Protection & Charging",
|
"term": "Battery Protection",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
"context": "",
|
"context": "",
|
||||||
"reference": "",
|
"reference": "",
|
||||||
@@ -3367,7 +3360,21 @@
|
|||||||
"comment": ""
|
"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": "",
|
"translation": "",
|
||||||
"context": "",
|
"context": "",
|
||||||
"reference": "",
|
"reference": "",
|
||||||
@@ -3458,7 +3465,7 @@
|
|||||||
"comment": ""
|
"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": "",
|
"translation": "",
|
||||||
"context": "",
|
"context": "",
|
||||||
"reference": "",
|
"reference": "",
|
||||||
@@ -13663,6 +13670,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Paste failed: %1",
|
||||||
|
"translation": "",
|
||||||
|
"context": "",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Path copied to clipboard",
|
"term": "Path copied to clipboard",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -14266,7 +14280,7 @@
|
|||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"term": "Power Profiles Auto-Switching",
|
"term": "Power Profiles & Saving",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
"context": "",
|
"context": "",
|
||||||
"reference": "",
|
"reference": "",
|
||||||
@@ -19403,6 +19417,13 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"term": "Unknown Model",
|
||||||
|
"translation": "",
|
||||||
|
"context": "",
|
||||||
|
"reference": "",
|
||||||
|
"comment": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"term": "Unknown Monitor",
|
"term": "Unknown Monitor",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
@@ -21461,13 +21482,6 @@
|
|||||||
"reference": "",
|
"reference": "",
|
||||||
"comment": ""
|
"comment": ""
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"term": "wtype not available - install wtype for paste support",
|
|
||||||
"translation": "",
|
|
||||||
"context": "",
|
|
||||||
"reference": "",
|
|
||||||
"comment": ""
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"term": "• Install only from trusted sources",
|
"term": "• Install only from trusted sources",
|
||||||
"translation": "",
|
"translation": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user