clipboard_darwin.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  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 darwin
  7. package clipboard
  8. import (
  9. "bytes"
  10. "context"
  11. "fmt"
  12. "os"
  13. "os/exec"
  14. "strconv"
  15. "strings"
  16. "sync"
  17. "time"
  18. )
  19. var (
  20. lastChangeCount int64
  21. changeCountMu sync.Mutex
  22. )
  23. func initialize() error { return nil }
  24. func read(t Format) (buf []byte, err error) {
  25. switch t {
  26. case FmtText:
  27. return readText()
  28. case FmtImage:
  29. return readImage()
  30. default:
  31. return nil, errUnsupported
  32. }
  33. }
  34. func readText() ([]byte, error) {
  35. // Check if clipboard contains string data
  36. checkScript := `
  37. try
  38. set clipboardTypes to (clipboard info)
  39. repeat with aType in clipboardTypes
  40. if (first item of aType) is string then
  41. return "hastext"
  42. end if
  43. end repeat
  44. return "notext"
  45. on error
  46. return "error"
  47. end try
  48. `
  49. cmd := exec.Command("osascript", "-e", checkScript)
  50. checkOut, err := cmd.Output()
  51. if err != nil {
  52. return nil, errUnavailable
  53. }
  54. checkOut = bytes.TrimSpace(checkOut)
  55. if !bytes.Equal(checkOut, []byte("hastext")) {
  56. return nil, errUnavailable
  57. }
  58. // Now get the actual text
  59. cmd = exec.Command("osascript", "-e", "get the clipboard")
  60. out, err := cmd.Output()
  61. if err != nil {
  62. return nil, errUnavailable
  63. }
  64. // Remove trailing newline that osascript adds
  65. out = bytes.TrimSuffix(out, []byte("\n"))
  66. // If clipboard was set to empty string, return nil
  67. if len(out) == 0 {
  68. return nil, nil
  69. }
  70. return out, nil
  71. }
  72. func readImage() ([]byte, error) {
  73. // AppleScript to read image data from clipboard as base64
  74. script := `
  75. try
  76. set theData to the clipboard as «class PNGf»
  77. return theData
  78. on error
  79. return ""
  80. end try
  81. `
  82. cmd := exec.Command("osascript", "-e", script)
  83. out, err := cmd.Output()
  84. if err != nil {
  85. return nil, errUnavailable
  86. }
  87. // Check if we got any data
  88. out = bytes.TrimSpace(out)
  89. if len(out) == 0 {
  90. return nil, errUnavailable
  91. }
  92. // The output is in hex format (e.g., «data PNGf89504E...»)
  93. // We need to extract and convert it
  94. outStr := string(out)
  95. if !strings.HasPrefix(outStr, "«data PNGf") || !strings.HasSuffix(outStr, "»") {
  96. return nil, errUnavailable
  97. }
  98. // Extract hex data
  99. hexData := strings.TrimPrefix(outStr, "«data PNGf")
  100. hexData = strings.TrimSuffix(hexData, "»")
  101. // Convert hex to bytes
  102. buf := make([]byte, len(hexData)/2)
  103. for i := 0; i < len(hexData); i += 2 {
  104. b, err := strconv.ParseUint(hexData[i:i+2], 16, 8)
  105. if err != nil {
  106. return nil, errUnavailable
  107. }
  108. buf[i/2] = byte(b)
  109. }
  110. return buf, nil
  111. }
  112. // write writes the given data to clipboard and
  113. // returns true if success or false if failed.
  114. func write(t Format, buf []byte) (<-chan struct{}, error) {
  115. var err error
  116. switch t {
  117. case FmtText:
  118. err = writeText(buf)
  119. case FmtImage:
  120. err = writeImage(buf)
  121. default:
  122. return nil, errUnsupported
  123. }
  124. if err != nil {
  125. return nil, err
  126. }
  127. // Update change count
  128. changeCountMu.Lock()
  129. lastChangeCount++
  130. currentCount := lastChangeCount
  131. changeCountMu.Unlock()
  132. // use unbuffered channel to prevent goroutine leak
  133. changed := make(chan struct{}, 1)
  134. go func() {
  135. for {
  136. time.Sleep(time.Second)
  137. changeCountMu.Lock()
  138. if lastChangeCount != currentCount {
  139. changeCountMu.Unlock()
  140. changed <- struct{}{}
  141. close(changed)
  142. return
  143. }
  144. changeCountMu.Unlock()
  145. }
  146. }()
  147. return changed, nil
  148. }
  149. func writeText(buf []byte) error {
  150. if len(buf) == 0 {
  151. // Clear clipboard
  152. script := `set the clipboard to ""`
  153. cmd := exec.Command("osascript", "-e", script)
  154. if err := cmd.Run(); err != nil {
  155. return errUnavailable
  156. }
  157. return nil
  158. }
  159. // Escape the text for AppleScript
  160. text := string(buf)
  161. text = strings.ReplaceAll(text, "\\", "\\\\")
  162. text = strings.ReplaceAll(text, "\"", "\\\"")
  163. script := fmt.Sprintf(`set the clipboard to "%s"`, text)
  164. cmd := exec.Command("osascript", "-e", script)
  165. if err := cmd.Run(); err != nil {
  166. return errUnavailable
  167. }
  168. return nil
  169. }
  170. func writeImage(buf []byte) error {
  171. if len(buf) == 0 {
  172. // Clear clipboard
  173. script := `set the clipboard to ""`
  174. cmd := exec.Command("osascript", "-e", script)
  175. if err := cmd.Run(); err != nil {
  176. return errUnavailable
  177. }
  178. return nil
  179. }
  180. // Create a temporary file to store the PNG data
  181. tmpFile, err := os.CreateTemp("", "clipboard*.png")
  182. if err != nil {
  183. return errUnavailable
  184. }
  185. defer os.Remove(tmpFile.Name())
  186. if _, err := tmpFile.Write(buf); err != nil {
  187. tmpFile.Close()
  188. return errUnavailable
  189. }
  190. tmpFile.Close()
  191. // Use osascript to set clipboard to the image file
  192. script := fmt.Sprintf(`
  193. set theFile to POSIX file "%s"
  194. set theImage to read theFile as «class PNGf»
  195. set the clipboard to theImage
  196. `, tmpFile.Name())
  197. cmd := exec.Command("osascript", "-e", script)
  198. if err := cmd.Run(); err != nil {
  199. return errUnavailable
  200. }
  201. return nil
  202. }
  203. func watch(ctx context.Context, t Format) <-chan []byte {
  204. recv := make(chan []byte, 1)
  205. ti := time.NewTicker(time.Second)
  206. // Get initial clipboard content
  207. var lastContent []byte
  208. if b := Read(t); b != nil {
  209. lastContent = make([]byte, len(b))
  210. copy(lastContent, b)
  211. }
  212. go func() {
  213. defer close(recv)
  214. defer ti.Stop()
  215. for {
  216. select {
  217. case <-ctx.Done():
  218. return
  219. case <-ti.C:
  220. b := Read(t)
  221. if b == nil {
  222. continue
  223. }
  224. // Check if content changed
  225. if !bytes.Equal(lastContent, b) {
  226. recv <- b
  227. lastContent = make([]byte, len(b))
  228. copy(lastContent, b)
  229. }
  230. }
  231. }
  232. }()
  233. return recv
  234. }