clipboard_linux.go 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311
  1. // Copyright 2021 The golang.design Initiative Authors.
  2. // All rights reserved. Use of this source code is governed
  3. // by a MIT license that can be found in the LICENSE file.
  4. //
  5. // Written by Changkun Ou <changkun.de>
  6. //go:build linux
  7. package clipboard
  8. import (
  9. "bytes"
  10. "context"
  11. "fmt"
  12. "log/slog"
  13. "os"
  14. "os/exec"
  15. "strings"
  16. "sync"
  17. "time"
  18. )
  19. var (
  20. // Clipboard tools in order of preference
  21. clipboardTools = []struct {
  22. name string
  23. readCmd []string
  24. writeCmd []string
  25. readImg []string
  26. writeImg []string
  27. available bool
  28. }{
  29. {
  30. name: "xclip",
  31. readCmd: []string{"xclip", "-selection", "clipboard", "-o"},
  32. writeCmd: []string{"xclip", "-selection", "clipboard"},
  33. readImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png", "-o"},
  34. writeImg: []string{"xclip", "-selection", "clipboard", "-t", "image/png"},
  35. },
  36. {
  37. name: "xsel",
  38. readCmd: []string{"xsel", "--clipboard", "--output"},
  39. writeCmd: []string{"xsel", "--clipboard", "--input"},
  40. readImg: []string{"xsel", "--clipboard", "--output"},
  41. writeImg: []string{"xsel", "--clipboard", "--input"},
  42. },
  43. {
  44. name: "wl-copy",
  45. readCmd: []string{"wl-paste", "-n"},
  46. writeCmd: []string{"wl-copy"},
  47. readImg: []string{"wl-paste", "-t", "image/png", "-n"},
  48. writeImg: []string{"wl-copy", "-t", "image/png"},
  49. },
  50. }
  51. selectedTool int = -1
  52. toolMutex sync.Mutex
  53. lastChangeTime time.Time
  54. changeTimeMu sync.Mutex
  55. )
  56. func initialize() error {
  57. toolMutex.Lock()
  58. defer toolMutex.Unlock()
  59. if selectedTool >= 0 {
  60. return nil // Already initialized
  61. }
  62. order := []string{"xclip", "xsel", "wl-copy"}
  63. if os.Getenv("WAYLAND_DISPLAY") != "" {
  64. order = []string{"wl-copy", "xclip", "xsel"}
  65. }
  66. for _, name := range order {
  67. for i, tool := range clipboardTools {
  68. if tool.name == name {
  69. cmd := exec.Command("which", tool.name)
  70. if err := cmd.Run(); err == nil {
  71. clipboardTools[i].available = true
  72. if selectedTool < 0 {
  73. selectedTool = i
  74. slog.Debug("Clipboard tool found", "tool", tool.name)
  75. }
  76. }
  77. break
  78. }
  79. }
  80. }
  81. if selectedTool < 0 {
  82. slog.Warn(
  83. "No clipboard utility found on system. Copy/paste functionality will be disabled. See https://opencode.ai/docs/troubleshooting/ for more information.",
  84. )
  85. return fmt.Errorf(`%w: No clipboard utility found. Install one of the following:
  86. For X11 systems:
  87. apt install -y xclip
  88. # or
  89. apt install -y xsel
  90. For Wayland systems:
  91. apt install -y wl-clipboard
  92. If running in a headless environment, you may also need:
  93. apt install -y xvfb
  94. # and run:
  95. Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
  96. export DISPLAY=:99.0`, errUnavailable)
  97. }
  98. return nil
  99. }
  100. func read(t Format) (buf []byte, err error) {
  101. // Ensure clipboard is initialized before attempting to read
  102. if err := initialize(); err != nil {
  103. slog.Debug("Clipboard read failed: not initialized", "error", err)
  104. return nil, err
  105. }
  106. toolMutex.Lock()
  107. tool := clipboardTools[selectedTool]
  108. toolMutex.Unlock()
  109. switch t {
  110. case FmtText:
  111. return readText(tool)
  112. case FmtImage:
  113. return readImage(tool)
  114. default:
  115. return nil, errUnsupported
  116. }
  117. }
  118. func readText(tool struct {
  119. name string
  120. readCmd []string
  121. writeCmd []string
  122. readImg []string
  123. writeImg []string
  124. available bool
  125. }) ([]byte, error) {
  126. // First check if clipboard contains text
  127. cmd := exec.Command(tool.readCmd[0], tool.readCmd[1:]...)
  128. out, err := cmd.Output()
  129. if err != nil {
  130. // Check if it's because clipboard contains non-text data
  131. if tool.name == "xclip" {
  132. // xclip returns error when clipboard doesn't contain requested type
  133. checkCmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
  134. targets, _ := checkCmd.Output()
  135. if bytes.Contains(targets, []byte("image/png")) &&
  136. !bytes.Contains(targets, []byte("UTF8_STRING")) {
  137. return nil, errUnavailable
  138. }
  139. }
  140. return nil, errUnavailable
  141. }
  142. return out, nil
  143. }
  144. func readImage(tool struct {
  145. name string
  146. readCmd []string
  147. writeCmd []string
  148. readImg []string
  149. writeImg []string
  150. available bool
  151. }) ([]byte, error) {
  152. if tool.name == "xsel" {
  153. // xsel doesn't support image types well, return error
  154. return nil, errUnavailable
  155. }
  156. cmd := exec.Command(tool.readImg[0], tool.readImg[1:]...)
  157. out, err := cmd.Output()
  158. if err != nil {
  159. return nil, errUnavailable
  160. }
  161. // Verify it's PNG data
  162. if len(out) < 8 ||
  163. !bytes.Equal(out[:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) {
  164. return nil, errUnavailable
  165. }
  166. return out, nil
  167. }
  168. func write(t Format, buf []byte) (<-chan struct{}, error) {
  169. // Ensure clipboard is initialized before attempting to write
  170. if err := initialize(); err != nil {
  171. return nil, err
  172. }
  173. toolMutex.Lock()
  174. tool := clipboardTools[selectedTool]
  175. toolMutex.Unlock()
  176. var cmd *exec.Cmd
  177. switch t {
  178. case FmtText:
  179. if len(buf) == 0 {
  180. // Write empty string
  181. cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
  182. cmd.Stdin = bytes.NewReader([]byte{})
  183. } else {
  184. cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
  185. cmd.Stdin = bytes.NewReader(buf)
  186. }
  187. case FmtImage:
  188. if tool.name == "xsel" {
  189. // xsel doesn't support image types well
  190. return nil, errUnavailable
  191. }
  192. if len(buf) == 0 {
  193. // Clear clipboard
  194. cmd = exec.Command(tool.writeCmd[0], tool.writeCmd[1:]...)
  195. cmd.Stdin = bytes.NewReader([]byte{})
  196. } else {
  197. cmd = exec.Command(tool.writeImg[0], tool.writeImg[1:]...)
  198. cmd.Stdin = bytes.NewReader(buf)
  199. }
  200. default:
  201. return nil, errUnsupported
  202. }
  203. if err := cmd.Run(); err != nil {
  204. return nil, errUnavailable
  205. }
  206. // Update change time
  207. changeTimeMu.Lock()
  208. lastChangeTime = time.Now()
  209. currentTime := lastChangeTime
  210. changeTimeMu.Unlock()
  211. // Create change notification channel
  212. changed := make(chan struct{}, 1)
  213. go func() {
  214. for {
  215. time.Sleep(time.Second)
  216. changeTimeMu.Lock()
  217. if !lastChangeTime.Equal(currentTime) {
  218. changeTimeMu.Unlock()
  219. changed <- struct{}{}
  220. close(changed)
  221. return
  222. }
  223. changeTimeMu.Unlock()
  224. }
  225. }()
  226. return changed, nil
  227. }
  228. func watch(ctx context.Context, t Format) <-chan []byte {
  229. recv := make(chan []byte, 1)
  230. // Ensure clipboard is initialized before starting watch
  231. if err := initialize(); err != nil {
  232. close(recv)
  233. return recv
  234. }
  235. ti := time.NewTicker(time.Second)
  236. // Get initial clipboard content
  237. var lastContent []byte
  238. if b := Read(t); b != nil {
  239. lastContent = make([]byte, len(b))
  240. copy(lastContent, b)
  241. }
  242. go func() {
  243. defer close(recv)
  244. defer ti.Stop()
  245. for {
  246. select {
  247. case <-ctx.Done():
  248. return
  249. case <-ti.C:
  250. b := Read(t)
  251. if b == nil {
  252. continue
  253. }
  254. // Check if content changed
  255. if !bytes.Equal(lastContent, b) {
  256. recv <- b
  257. lastContent = make([]byte, len(b))
  258. copy(lastContent, b)
  259. }
  260. }
  261. }
  262. }()
  263. return recv
  264. }
  265. // Helper function to check clipboard content type for xclip
  266. func getClipboardTargets() []string {
  267. cmd := exec.Command("xclip", "-selection", "clipboard", "-t", "TARGETS", "-o")
  268. out, err := cmd.Output()
  269. if err != nil {
  270. return nil
  271. }
  272. return strings.Split(string(out), "\n")
  273. }