commit 5dd95c9434138f38330ee16581cae8d49864e003 Author: Trevor Slocum Date: Fri Oct 11 19:54:04 2024 -0700 Fork https://github.com/golang-design/clipboard diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cd07a88 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# Copyright 2021 The golang.design Initiative Authors. +# All rights reserved. Use of this source code is governed +# by a MIT license that can be found in the LICENSE file. +# +# Written by Changkun Ou + +FROM golang:1.17 +RUN apt-get update && apt-get install -y \ + xvfb libx11-dev libegl1-mesa-dev libgles2-mesa-dev \ + && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* +WORKDIR /app +COPY . . +CMD [ "sh", "-c", "./tests/test-docker.sh" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7f2bbe2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Changkun Ou + +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 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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6f38a6f --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# clipboard [![PkgGoDev](https://pkg.go.dev/badge/golang.design/x/clipboard)](https://pkg.go.dev/golang.design/x/clipboard) ![](https://changkun.de/urlstat?mode=github&repo=golang-design/clipboard) ![clipboard](https://github.com/golang-design/clipboard/workflows/clipboard/badge.svg?branch=main) + +Cross platform (macOS/Linux/Windows/Android/iOS) clipboard package in Go + +```go +import "golang.design/x/clipboard" +``` + +## Features + +- Cross platform supports: **macOS, Linux (X11), Windows, iOS, and Android** +- Copy/paste UTF-8 text +- Copy/paste PNG encoded images (Desktop-only) +- Command `gclip` as a demo application +- Mobile app `gclip-gui` as a demo application + +## API Usage + +Package clipboard provides cross platform clipboard access and supports +macOS/Linux/Windows/Android/iOS platform. Before interacting with the +clipboard, one must call Init to assert if it is possible to use this +package: + +```go +// Init returns an error if the package is not ready for use. +err := clipboard.Init() +if err != nil { + panic(err) +} +``` + +The most common operations are `Read` and `Write`. To use them: + +```go +// write/read text format data of the clipboard, and +// the byte buffer regarding the text are UTF8 encoded. +clipboard.Write(clipboard.FmtText, []byte("text data")) +clipboard.Read(clipboard.FmtText) + +// write/read image format data of the clipboard, and +// the byte buffer regarding the image are PNG encoded. +clipboard.Write(clipboard.FmtImage, []byte("image data")) +clipboard.Read(clipboard.FmtImage) +``` + +Note that read/write regarding image format assumes that the bytes are +PNG encoded since it serves the alpha blending purpose that might be +used in other graphical software. + +In addition, `clipboard.Write` returns a channel that can receive an +empty struct as a signal, which indicates the corresponding write call +to the clipboard is outdated, meaning the clipboard has been overwritten +by others and the previously written data is lost. For instance: + +```go +changed := clipboard.Write(clipboard.FmtText, []byte("text data")) + +select { +case <-changed: + println(`"text data" is no longer available from clipboard.`) +} +``` + +You can ignore the returning channel if you don't need this type of +notification. Furthermore, when you need more than just knowing whether +clipboard data is changed, use the watcher API: + +```go +ch := clipboard.Watch(context.TODO(), clipboard.FmtText) +for data := range ch { + // print out clipboard data whenever it is changed + println(string(data)) +} +``` + +## Demos + +- A command line tool `gclip` for command line clipboard accesses, see document [here](./cmd/gclip/README.md). +- A GUI application `gclip-gui` for functionality verifications on mobile systems, see a document [here](./cmd/gclip-gui/README.md). + + +## Command Usage + +`gclip` command offers the ability to interact with the system clipboard +from the shell. To install: + +```bash +$ go install golang.design/x/clipboard/cmd/gclip@latest +``` + +```bash +$ gclip +gclip is a command that provides clipboard interaction. + +usage: gclip [-copy|-paste] [-f ] + +options: + -copy + copy data to clipboard + -f string + source or destination to a given file path + -paste + paste data from clipboard + +examples: +gclip -paste paste from clipboard and prints the content +gclip -paste -f x.txt paste from clipboard and save as text to x.txt +gclip -paste -f x.png paste from clipboard and save as image to x.png + +cat x.txt | gclip -copy copy content from x.txt to clipboard +gclip -copy -f x.txt copy content from x.txt to clipboard +gclip -copy -f x.png copy x.png as image data to clipboard +``` + +If `-copy` is used, the command will exit when the data is no longer +available from the clipboard. You can always send the command to the +background using a shell `&` operator, for example: + +```bash +$ cat x.txt | gclip -copy & +``` + +## Platform Specific Details + +This package spent efforts to provide cross platform abstraction regarding +accessing system clipboards, but here are a few details you might need to know. + +### Dependency + +- macOS: require Cgo, no dependency +- Linux: require X11 dev package. For instance, install `libx11-dev` or `xorg-dev` or `libX11-devel` to access X window system. +- Windows: no Cgo, no dependency +- iOS/Android: collaborate with [`gomobile`](https://golang.org/x/mobile) + +### Screenshot + +In general, when you need test your implementation regarding images, +There are system level shortcuts to put screenshot image into your system clipboard: + +- On macOS, use `Ctrl+Shift+Cmd+4` +- On Linux/Ubuntu, use `Ctrl+Shift+PrintScreen` +- On Windows, use `Shift+Win+s` + +As described in the API documentation, the package supports read/write +UTF8 encoded plain text or PNG encoded image data. Thus, +the other types of data are not supported yet, i.e. undefined behavior. + +## Who is using this package? + +The main purpose of building this package is to support the +[midgard](https://changkun.de/s/midgard) project, which offers +clipboard-based features like universal clipboard service that syncs +clipboard content across multiple systems, allocating public accessible +for clipboard content, etc. + +To know more projects, check our [wiki](https://github.com/golang-design/clipboard/wiki) page. + +## License + +MIT | © 2021 The golang.design Initiative Authors, written by [Changkun Ou](https://changkun.de). \ No newline at end of file diff --git a/clipboard.go b/clipboard.go new file mode 100644 index 0000000..b00f415 --- /dev/null +++ b/clipboard.go @@ -0,0 +1,154 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +/* +Package clipboard provides cross platform clipboard access and supports +macOS/Linux/Windows/Android/iOS platform. Before interacting with the +clipboard, one must call Init to assert if it is possible to use this +package: + + err := clipboard.Init() + if err != nil { + panic(err) + } + +The most common operations are `Read` and `Write`. To use them: + + // write/read text format data of the clipboard, and + // the byte buffer regarding the text are UTF8 encoded. + clipboard.Write(clipboard.FmtText, []byte("text data")) + clipboard.Read(clipboard.FmtText) + + // write/read image format data of the clipboard, and + // the byte buffer regarding the image are PNG encoded. + clipboard.Write(clipboard.FmtImage, []byte("image data")) + clipboard.Read(clipboard.FmtImage) + +Note that read/write regarding image format assumes that the bytes are +PNG encoded since it serves the alpha blending purpose that might be +used in other graphical software. + +In addition, `clipboard.Write` returns a channel that can receive an +empty struct as a signal, which indicates the corresponding write call +to the clipboard is outdated, meaning the clipboard has been overwritten +by others and the previously written data is lost. For instance: + + changed := clipboard.Write(clipboard.FmtText, []byte("text data")) + + select { + case <-changed: + println(`"text data" is no longer available from clipboard.`) + } + +You can ignore the returning channel if you don't need this type of +notification. Furthermore, when you need more than just knowing whether +clipboard data is changed, use the watcher API: + + ch := clipboard.Watch(context.TODO(), clipboard.FmtText) + for data := range ch { + // print out clipboard data whenever it is changed + println(string(data)) + } +*/ +package clipboard // import "golang.design/x/clipboard" + +import ( + "context" + "errors" + "fmt" + "os" + "sync" +) + +var ( + // activate only for running tests. + debug = false + errUnavailable = errors.New("clipboard unavailable") + errUnsupported = errors.New("unsupported format") +) + +// Format represents the format of clipboard data. +type Format int + +// All sorts of supported clipboard data +const ( + // FmtText indicates plain text clipboard format + FmtText Format = iota + // FmtImage indicates image/png clipboard format + FmtImage +) + +var ( + // Due to the limitation on operating systems (such as darwin), + // concurrent read can even cause panic, use a global lock to + // guarantee one read at a time. + lock = sync.Mutex{} + initOnce sync.Once + initError error +) + +// Init initializes the clipboard package. It returns an error +// if the clipboard is not available to use. This may happen if the +// target system lacks required dependency, such as libx11-dev in X11 +// environment. For example, +// +// err := clipboard.Init() +// if err != nil { +// panic(err) +// } +// +// If Init returns an error, any subsequent Read/Write/Watch call +// may result in an unrecoverable panic. +func Init() error { + initOnce.Do(func() { + initError = initialize() + }) + return initError +} + +// Read returns a chunk of bytes of the clipboard data if it presents +// in the desired format t presents. Otherwise, it returns nil. +func Read(t Format) []byte { + lock.Lock() + defer lock.Unlock() + + buf, err := read(t) + if err != nil { + if debug { + fmt.Fprintf(os.Stderr, "read clipboard err: %v\n", err) + } + return nil + } + return buf +} + +// Write writes a given buffer to the clipboard in a specified format. +// Write returned a receive-only channel can receive an empty struct +// as a signal, which indicates the clipboard has been overwritten from +// this write. +// If format t indicates an image, then the given buf assumes +// the image data is PNG encoded. +func Write(t Format, buf []byte) <-chan struct{} { + lock.Lock() + defer lock.Unlock() + + changed, err := write(t, buf) + if err != nil { + if debug { + fmt.Fprintf(os.Stderr, "write to clipboard err: %v\n", err) + } + return nil + } + return changed +} + +// Watch returns a receive-only channel that received the clipboard data +// whenever any change of clipboard data in the desired format happens. +// +// The returned channel will be closed if the given context is canceled. +func Watch(ctx context.Context, t Format) <-chan []byte { + return watch(ctx, t) +} diff --git a/clipboard_android.c b/clipboard_android.c new file mode 100644 index 0000000..9dc34f6 --- /dev/null +++ b/clipboard_android.c @@ -0,0 +1,80 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +//go:build android + +#include +#include +#include +#include + +#define LOG_FATAL(...) __android_log_print(ANDROID_LOG_FATAL, \ + "GOLANG.DESIGN/X/CLIPBOARD", __VA_ARGS__) + +static jmethodID find_method(JNIEnv *env, jclass clazz, const char *name, const char *sig) { + jmethodID m = (*env)->GetMethodID(env, clazz, name, sig); + if (m == 0) { + (*env)->ExceptionClear(env); + LOG_FATAL("cannot find method %s %s", name, sig); + return 0; + } + return m; +} + +jobject get_clipboard(uintptr_t jni_env, uintptr_t ctx) { + JNIEnv *env = (JNIEnv*)jni_env; + jclass ctxClass = (*env)->GetObjectClass(env, (jobject)ctx); + jmethodID getSystemService = find_method(env, ctxClass, "getSystemService", "(Ljava/lang/String;)Ljava/lang/Object;"); + + jstring service = (*env)->NewStringUTF(env, "clipboard"); + jobject ret = (jobject)(*env)->CallObjectMethod(env, (jobject)ctx, getSystemService, service); + jthrowable err = (*env)->ExceptionOccurred(env); + + if (err != NULL) { + LOG_FATAL("cannot find clipboard"); + (*env)->ExceptionClear(env); + return NULL; + } + return ret; +} + +char *clipboard_read_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx) { + JNIEnv *env = (JNIEnv*)jni_env; + jobject mgr = get_clipboard(jni_env, ctx); + if (mgr == NULL) { + return NULL; + } + + jclass mgrClass = (*env)->GetObjectClass(env, mgr); + jmethodID getText = find_method(env, mgrClass, "getText", "()Ljava/lang/CharSequence;"); + + jobject content = (jstring)(*env)->CallObjectMethod(env, mgr, getText); + if (content == NULL) { + return NULL; + } + + jclass clzCharSequence = (*env)->GetObjectClass(env, content); + jmethodID toString = (*env)->GetMethodID(env, clzCharSequence, "toString", "()Ljava/lang/String;"); + jobject s = (*env)->CallObjectMethod(env, content, toString); + + const char *chars = (*env)->GetStringUTFChars(env, s, NULL); + char *copy = strdup(chars); + (*env)->ReleaseStringUTFChars(env, s, chars); + return copy; +} + +void clipboard_write_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx, char *str) { + JNIEnv *env = (JNIEnv*)jni_env; + jobject mgr = get_clipboard(jni_env, ctx); + if (mgr == NULL) { + return; + } + + jclass mgrClass = (*env)->GetObjectClass(env, mgr); + jmethodID setText = find_method(env, mgrClass, "setText", "(Ljava/lang/CharSequence;)V"); + + (*env)->CallVoidMethod(env, mgr, setText, (*env)->NewStringUTF(env, str)); +} diff --git a/clipboard_android.go b/clipboard_android.go new file mode 100644 index 0000000..c9ce78f --- /dev/null +++ b/clipboard_android.go @@ -0,0 +1,102 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +//go:build android + +package clipboard + +/* +#cgo LDFLAGS: -landroid -llog + +#include +char *clipboard_read_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx); +void clipboard_write_string(uintptr_t java_vm, uintptr_t jni_env, uintptr_t ctx, char *str); + +*/ +import "C" +import ( + "bytes" + "context" + "time" + "unsafe" + + "golang.org/x/mobile/app" +) + +func initialize() error { return nil } + +func read(t Format) (buf []byte, err error) { + switch t { + case FmtText: + s := "" + if err := app.RunOnJVM(func(vm, env, ctx uintptr) error { + cs := C.clipboard_read_string(C.uintptr_t(vm), C.uintptr_t(env), C.uintptr_t(ctx)) + if cs == nil { + return nil + } + + s = C.GoString(cs) + C.free(unsafe.Pointer(cs)) + return nil + }); err != nil { + return nil, err + } + return []byte(s), nil + case FmtImage: + return nil, errUnsupported + default: + return nil, errUnsupported + } +} + +// write writes the given data to clipboard and +// returns true if success or false if failed. +func write(t Format, buf []byte) (<-chan struct{}, error) { + done := make(chan struct{}, 1) + switch t { + case FmtText: + cs := C.CString(string(buf)) + defer C.free(unsafe.Pointer(cs)) + + if err := app.RunOnJVM(func(vm, env, ctx uintptr) error { + C.clipboard_write_string(C.uintptr_t(vm), C.uintptr_t(env), C.uintptr_t(ctx), cs) + done <- struct{}{} + return nil + }); err != nil { + return nil, err + } + return done, nil + case FmtImage: + return nil, errUnsupported + default: + return nil, errUnsupported + } +} + +func watch(ctx context.Context, t Format) <-chan []byte { + recv := make(chan []byte, 1) + ti := time.NewTicker(time.Second) + last := Read(t) + go func() { + for { + select { + case <-ctx.Done(): + close(recv) + return + case <-ti.C: + b := Read(t) + if b == nil { + continue + } + if bytes.Compare(last, b) != 0 { + recv <- b + last = b + } + } + } + }() + return recv +} diff --git a/clipboard_darwin.go b/clipboard_darwin.go new file mode 100644 index 0000000..bcda127 --- /dev/null +++ b/clipboard_darwin.go @@ -0,0 +1,122 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +//go:build darwin && !ios + +package clipboard + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework Cocoa +#import +#import + +unsigned int clipboard_read_string(void **out); +unsigned int clipboard_read_image(void **out); +int clipboard_write_string(const void *bytes, NSInteger n); +int clipboard_write_image(const void *bytes, NSInteger n); +NSInteger clipboard_change_count(); +*/ +import "C" +import ( + "context" + "time" + "unsafe" +) + +func initialize() error { return nil } + +func read(t Format) (buf []byte, err error) { + var ( + data unsafe.Pointer + n C.uint + ) + switch t { + case FmtText: + n = C.clipboard_read_string(&data) + case FmtImage: + n = C.clipboard_read_image(&data) + } + if data == nil { + return nil, errUnavailable + } + defer C.free(unsafe.Pointer(data)) + if n == 0 { + return nil, nil + } + return C.GoBytes(data, C.int(n)), nil +} + +// write writes the given data to clipboard and +// returns true if success or false if failed. +func write(t Format, buf []byte) (<-chan struct{}, error) { + var ok C.int + switch t { + case FmtText: + if len(buf) == 0 { + ok = C.clipboard_write_string(unsafe.Pointer(nil), 0) + } else { + ok = C.clipboard_write_string(unsafe.Pointer(&buf[0]), + C.NSInteger(len(buf))) + } + case FmtImage: + if len(buf) == 0 { + ok = C.clipboard_write_image(unsafe.Pointer(nil), 0) + } else { + ok = C.clipboard_write_image(unsafe.Pointer(&buf[0]), + C.NSInteger(len(buf))) + } + default: + return nil, errUnsupported + } + if ok != 0 { + return nil, errUnavailable + } + + // use unbuffered data to prevent goroutine leak + changed := make(chan struct{}, 1) + cnt := C.long(C.clipboard_change_count()) + go func() { + for { + // not sure if we are too slow or the user too fast :) + time.Sleep(time.Second) + cur := C.long(C.clipboard_change_count()) + if cnt != cur { + changed <- struct{}{} + close(changed) + return + } + } + }() + return changed, nil +} + +func watch(ctx context.Context, t Format) <-chan []byte { + recv := make(chan []byte, 1) + // not sure if we are too slow or the user too fast :) + ti := time.NewTicker(time.Second) + lastCount := C.long(C.clipboard_change_count()) + go func() { + for { + select { + case <-ctx.Done(): + close(recv) + return + case <-ti.C: + this := C.long(C.clipboard_change_count()) + if lastCount != this { + b := Read(t) + if b == nil { + continue + } + recv <- b + lastCount = this + } + } + } + }() + return recv +} diff --git a/clipboard_darwin.m b/clipboard_darwin.m new file mode 100644 index 0000000..177e771 --- /dev/null +++ b/clipboard_darwin.m @@ -0,0 +1,62 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +//go:build darwin && !ios + +// Interact with NSPasteboard using Objective-C +// https://developer.apple.com/documentation/appkit/nspasteboard?language=objc + +#import +#import + +unsigned int clipboard_read_string(void **out) { + NSPasteboard * pasteboard = [NSPasteboard generalPasteboard]; + NSData *data = [pasteboard dataForType:NSPasteboardTypeString]; + if (data == nil) { + return 0; + } + NSUInteger siz = [data length]; + *out = malloc(siz); + [data getBytes: *out length: siz]; + return siz; +} + +unsigned int clipboard_read_image(void **out) { + NSPasteboard * pasteboard = [NSPasteboard generalPasteboard]; + NSData *data = [pasteboard dataForType:NSPasteboardTypePNG]; + if (data == nil) { + return 0; + } + NSUInteger siz = [data length]; + *out = malloc(siz); + [data getBytes: *out length: siz]; + return siz; +} + +int clipboard_write_string(const void *bytes, NSInteger n) { + NSPasteboard *pasteboard = [NSPasteboard generalPasteboard]; + NSData *data = [NSData dataWithBytes: bytes length: n]; + [pasteboard clearContents]; + BOOL ok = [pasteboard setData: data forType:NSPasteboardTypeString]; + if (!ok) { + return -1; + } + return 0; +} +int clipboard_write_image(const void *bytes, NSInteger n) { + NSPasteboard *pasteboard = [NSPasteboard generalPasteboard]; + NSData *data = [NSData dataWithBytes: bytes length: n]; + [pasteboard clearContents]; + BOOL ok = [pasteboard setData: data forType:NSPasteboardTypePNG]; + if (!ok) { + return -1; + } + return 0; +} + +NSInteger clipboard_change_count() { + return [[NSPasteboard generalPasteboard] changeCount]; +} diff --git a/clipboard_ios.go b/clipboard_ios.go new file mode 100644 index 0000000..e027b8c --- /dev/null +++ b/clipboard_ios.go @@ -0,0 +1,80 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +//go:build ios + +package clipboard + +/* +#cgo CFLAGS: -x objective-c +#cgo LDFLAGS: -framework Foundation -framework UIKit -framework MobileCoreServices + +#import +void clipboard_write_string(char *s); +char *clipboard_read_string(); +*/ +import "C" +import ( + "bytes" + "context" + "time" + "unsafe" +) + +func initialize() error { return nil } + +func read(t Format) (buf []byte, err error) { + switch t { + case FmtText: + return []byte(C.GoString(C.clipboard_read_string())), nil + case FmtImage: + return nil, errUnsupported + default: + return nil, errUnsupported + } +} + +// SetContent sets the clipboard content for iOS +func write(t Format, buf []byte) (<-chan struct{}, error) { + done := make(chan struct{}, 1) + switch t { + case FmtText: + cs := C.CString(string(buf)) + defer C.free(unsafe.Pointer(cs)) + + C.clipboard_write_string(cs) + return done, nil + case FmtImage: + return nil, errUnsupported + default: + return nil, errUnsupported + } +} + +func watch(ctx context.Context, t Format) <-chan []byte { + recv := make(chan []byte, 1) + ti := time.NewTicker(time.Second) + last := Read(t) + go func() { + for { + select { + case <-ctx.Done(): + close(recv) + return + case <-ti.C: + b := Read(t) + if b == nil { + continue + } + if bytes.Compare(last, b) != 0 { + recv <- b + last = b + } + } + } + }() + return recv +} diff --git a/clipboard_ios.m b/clipboard_ios.m new file mode 100644 index 0000000..15eb122 --- /dev/null +++ b/clipboard_ios.m @@ -0,0 +1,20 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +//go:build ios + +#import +#import + +void clipboard_write_string(char *s) { + NSString *value = [NSString stringWithUTF8String:s]; + [[UIPasteboard generalPasteboard] setString:value]; +} + +char *clipboard_read_string() { + NSString *str = [[UIPasteboard generalPasteboard] string]; + return (char *)[str UTF8String]; +} diff --git a/clipboard_linux.c b/clipboard_linux.c new file mode 100644 index 0000000..45ac607 --- /dev/null +++ b/clipboard_linux.c @@ -0,0 +1,263 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +//go:build linux && !android + +#include +#include +#include +#include +#include +#include +#include + +// syncStatus is a function from the Go side. +extern void syncStatus(uintptr_t handle, int status); + +void *libX11; + +Display* (*P_XOpenDisplay)(int); +void (*P_XCloseDisplay)(Display*); +Window (*P_XDefaultRootWindow)(Display*); +Window (*P_XCreateSimpleWindow)(Display*, Window, int, int, int, int, int, int, int); +Atom (*P_XInternAtom)(Display*, char*, int); +void (*P_XSetSelectionOwner)(Display*, Atom, Window, unsigned long); +Window (*P_XGetSelectionOwner)(Display*, Atom); +void (*P_XNextEvent)(Display*, XEvent*); +int (*P_XChangeProperty)(Display*, Window, Atom, Atom, int, int, unsigned char*, int); +void (*P_XSendEvent)(Display*, Window, int, long , XEvent*); +int (*P_XGetWindowProperty) (Display*, Window, Atom, long, long, Bool, Atom, Atom*, int*, unsigned long *, unsigned long *, unsigned char **); +void (*P_XFree) (void*); +void (*P_XDeleteProperty) (Display*, Window, Atom); +void (*P_XConvertSelection)(Display*, Atom, Atom, Atom, Window, Time); + +int initX11() { + if (libX11) { + return 1; + } + libX11 = dlopen("libX11.so", RTLD_LAZY); + if (!libX11) { + return 0; + } + P_XOpenDisplay = (Display* (*)(int)) dlsym(libX11, "XOpenDisplay"); + P_XCloseDisplay = (void (*)(Display*)) dlsym(libX11, "XCloseDisplay"); + P_XDefaultRootWindow = (Window (*)(Display*)) dlsym(libX11, "XDefaultRootWindow"); + P_XCreateSimpleWindow = (Window (*)(Display*, Window, int, int, int, int, int, int, int)) dlsym(libX11, "XCreateSimpleWindow"); + P_XInternAtom = (Atom (*)(Display*, char*, int)) dlsym(libX11, "XInternAtom"); + P_XSetSelectionOwner = (void (*)(Display*, Atom, Window, unsigned long)) dlsym(libX11, "XSetSelectionOwner"); + P_XGetSelectionOwner = (Window (*)(Display*, Atom)) dlsym(libX11, "XGetSelectionOwner"); + P_XNextEvent = (void (*)(Display*, XEvent*)) dlsym(libX11, "XNextEvent"); + P_XChangeProperty = (int (*)(Display*, Window, Atom, Atom, int, int, unsigned char*, int)) dlsym(libX11, "XChangeProperty"); + P_XSendEvent = (void (*)(Display*, Window, int, long , XEvent*)) dlsym(libX11, "XSendEvent"); + P_XGetWindowProperty = (int (*)(Display*, Window, Atom, long, long, Bool, Atom, Atom*, int*, unsigned long *, unsigned long *, unsigned char **)) dlsym(libX11, "XGetWindowProperty"); + P_XFree = (void (*)(void*)) dlsym(libX11, "XFree"); + P_XDeleteProperty = (void (*)(Display*, Window, Atom)) dlsym(libX11, "XDeleteProperty"); + P_XConvertSelection = (void (*)(Display*, Atom, Atom, Atom, Window, Time)) dlsym(libX11, "XConvertSelection"); + return 1; +} + +int clipboard_test() { + if (!initX11()) { + return -1; + } + + Display* d = NULL; + for (int i = 0; i < 42; i++) { + d = (*P_XOpenDisplay)(0); + if (d == NULL) { + continue; + } + break; + } + if (d == NULL) { + return -1; + } + (*P_XCloseDisplay)(d); + return 0; +} + +// clipboard_write writes the given buf of size n as type typ. +// if start is provided, the value of start will be changed to 1 to indicate +// if the write is availiable for reading. +int clipboard_write(char *typ, unsigned char *buf, size_t n, uintptr_t handle) { + if (!initX11()) { + return -1; + } + + Display* d = NULL; + for (int i = 0; i < 42; i++) { + d = (*P_XOpenDisplay)(0); + if (d == NULL) { + continue; + } + break; + } + if (d == NULL) { + syncStatus(handle, -1); + return -1; + } + Window w = (*P_XCreateSimpleWindow)(d, (*P_XDefaultRootWindow)(d), 0, 0, 1, 1, 0, 0, 0); + + // Use False because these may not available for the first time. + Atom sel = (*P_XInternAtom)(d, "CLIPBOARD", 0); + Atom atomString = (*P_XInternAtom)(d, "UTF8_STRING", 0); + Atom atomImage = (*P_XInternAtom)(d, "image/png", 0); + Atom targetsAtom = (*P_XInternAtom)(d, "TARGETS", 0); + + // Use True to makesure the requested type is a valid type. + Atom target = (*P_XInternAtom)(d, typ, 1); + if (target == None) { + (*P_XCloseDisplay)(d); + syncStatus(handle, -2); + return -2; + } + + (*P_XSetSelectionOwner)(d, sel, w, CurrentTime); + if ((*P_XGetSelectionOwner)(d, sel) != w) { + (*P_XCloseDisplay)(d); + syncStatus(handle, -3); + return -3; + } + + XEvent event; + XSelectionRequestEvent* xsr; + int notified = 0; + for (;;) { + if (notified == 0) { + syncStatus(handle, 1); // notify Go side + notified = 1; + } + + (*P_XNextEvent)(d, &event); + switch (event.type) { + case SelectionClear: + // For debugging: + // printf("x11write: lost ownership of clipboard selection.\n"); + // fflush(stdout); + (*P_XCloseDisplay)(d); + return 0; + case SelectionNotify: + // For debugging: + // printf("x11write: notify.\n"); + // fflush(stdout); + break; + case SelectionRequest: + if (event.xselectionrequest.selection != sel) { + break; + } + + XSelectionRequestEvent * xsr = &event.xselectionrequest; + XSelectionEvent ev = {0}; + int R = 0; + + ev.type = SelectionNotify; + ev.display = xsr->display; + ev.requestor = xsr->requestor; + ev.selection = xsr->selection; + ev.time = xsr->time; + ev.target = xsr->target; + ev.property = xsr->property; + + if (ev.target == atomString && ev.target == target) { + R = (*P_XChangeProperty)(ev.display, ev.requestor, ev.property, + atomString, 8, PropModeReplace, buf, n); + } else if (ev.target == atomImage && ev.target == target) { + R = (*P_XChangeProperty)(ev.display, ev.requestor, ev.property, + atomImage, 8, PropModeReplace, buf, n); + } else if (ev.target == targetsAtom) { + // Reply atoms for supported targets, other clients should + // request the clipboard again and obtain the data if their + // implementation is correct. + Atom targets[] = { atomString, atomImage }; + R = (*P_XChangeProperty)(ev.display, ev.requestor, ev.property, + XA_ATOM, 32, PropModeReplace, + (unsigned char *)&targets, sizeof(targets)/sizeof(Atom)); + } else { + ev.property = None; + } + + if ((R & 2) == 0) (*P_XSendEvent)(d, ev.requestor, 0, 0, (XEvent *)&ev); + break; + } + } +} + +// read_data reads the property of a selection if the target atom matches +// the actual atom. +unsigned long read_data(XSelectionEvent *sev, Atom sel, Atom prop, Atom target, char **buf) { + if (!initX11()) { + return -1; + } + + unsigned char *data; + Atom actual; + int format; + unsigned long n = 0; + unsigned long size = 0; + if (sev->property == None || sev->selection != sel || sev->property != prop) { + return 0; + } + + int ret = (*P_XGetWindowProperty)(sev->display, sev->requestor, sev->property, + 0L, (~0L), 0, AnyPropertyType, &actual, &format, &size, &n, &data); + if (ret != Success) { + return 0; + } + + if (actual == target && buf != NULL) { + *buf = (char *)malloc(size * sizeof(char)); + memcpy(*buf, data, size*sizeof(char)); + } + (*P_XFree)(data); + (*P_XDeleteProperty)(sev->display, sev->requestor, sev->property); + return size * sizeof(char); +} + +// clipboard_read reads the clipboard selection in given format typ. +// the readed bytes is written into buf and returns the size of the buffer. +// +// The caller of this function should responsible for the free of the buf. +unsigned long clipboard_read(char* typ, char **buf) { + if (!initX11()) { + return -1; + } + + Display* d = NULL; + for (int i = 0; i < 42; i++) { + d = (*P_XOpenDisplay)(0); + if (d == NULL) { + continue; + } + break; + } + if (d == NULL) { + return -1; + } + + Window w = (*P_XCreateSimpleWindow)(d, (*P_XDefaultRootWindow)(d), 0, 0, 1, 1, 0, 0, 0); + + // Use False because these may not available for the first time. + Atom sel = (*P_XInternAtom)(d, "CLIPBOARD", False); + Atom prop = (*P_XInternAtom)(d, "GOLANG_DESIGN_DATA", False); + + // Use True to makesure the requested type is a valid type. + Atom target = (*P_XInternAtom)(d, typ, True); + if (target == None) { + (*P_XCloseDisplay)(d); + return -2; + } + + (*P_XConvertSelection)(d, sel, target, prop, w, CurrentTime); + XEvent event; + for (;;) { + (*P_XNextEvent)(d, &event); + if (event.type != SelectionNotify) continue; + break; + } + unsigned long n = read_data((XSelectionEvent *)&event.xselection, sel, prop, target, buf); + (*P_XCloseDisplay)(d); + return n; +} diff --git a/clipboard_linux.go b/clipboard_linux.go new file mode 100644 index 0000000..14ebe95 --- /dev/null +++ b/clipboard_linux.go @@ -0,0 +1,166 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +//go:build linux && !android + +package clipboard + +/* +#cgo LDFLAGS: -ldl +#include +#include +#include +#include + +int clipboard_test(); +int clipboard_write( + char* typ, + unsigned char* buf, + size_t n, + uintptr_t handle +); +unsigned long clipboard_read(char* typ, char **out); +*/ +import "C" +import ( + "bytes" + "context" + "fmt" + "os" + "runtime" + "runtime/cgo" + "time" + "unsafe" +) + +var helpmsg = `%w: Failed to initialize the X11 display, and the clipboard package +will not work properly. Install the following dependency may help: + + apt install -y libx11-dev + +If the clipboard package is in an environment without a frame buffer, +such as a cloud server, it may also be necessary to install xvfb: + + apt install -y xvfb + +and initialize a virtual frame buffer: + + Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + export DISPLAY=:99.0 + +Then this package should be ready to use. +` + +func initialize() error { + ok := C.clipboard_test() + if ok != 0 { + return fmt.Errorf(helpmsg, errUnavailable) + } + return nil +} + +func read(t Format) (buf []byte, err error) { + switch t { + case FmtText: + return readc("UTF8_STRING") + case FmtImage: + return readc("image/png") + } + return nil, errUnsupported +} + +func readc(t string) ([]byte, error) { + ct := C.CString(t) + defer C.free(unsafe.Pointer(ct)) + + var data *C.char + n := C.clipboard_read(ct, &data) + if data == nil { + return nil, errUnavailable + } + defer C.free(unsafe.Pointer(data)) + switch { + case n == 0: + return nil, nil + default: + return C.GoBytes(unsafe.Pointer(data), C.int(n)), nil + } +} + +// write writes the given data to clipboard and +// returns true if success or false if failed. +func write(t Format, buf []byte) (<-chan struct{}, error) { + var s string + switch t { + case FmtText: + s = "UTF8_STRING" + case FmtImage: + s = "image/png" + } + + start := make(chan int) + done := make(chan struct{}, 1) + + go func() { // serve as a daemon until the ownership is terminated. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + cs := C.CString(s) + defer C.free(unsafe.Pointer(cs)) + + h := cgo.NewHandle(start) + var ok C.int + if len(buf) == 0 { + ok = C.clipboard_write(cs, nil, 0, C.uintptr_t(h)) + } else { + ok = C.clipboard_write(cs, (*C.uchar)(unsafe.Pointer(&(buf[0]))), C.size_t(len(buf)), C.uintptr_t(h)) + } + if ok != C.int(0) { + fmt.Fprintf(os.Stderr, "write failed with status: %d\n", int(ok)) + } + done <- struct{}{} + close(done) + }() + + status := <-start + if status < 0 { + return nil, errUnavailable + } + // wait until enter event loop + return done, nil +} + +func watch(ctx context.Context, t Format) <-chan []byte { + recv := make(chan []byte, 1) + ti := time.NewTicker(time.Second) + last := Read(t) + go func() { + for { + select { + case <-ctx.Done(): + close(recv) + return + case <-ti.C: + b := Read(t) + if b == nil { + continue + } + if !bytes.Equal(last, b) { + recv <- b + last = b + } + } + } + }() + return recv +} + +//export syncStatus +func syncStatus(h uintptr, val int) { + v := cgo.Handle(h).Value().(chan int) + v <- val + cgo.Handle(h).Delete() +} diff --git a/clipboard_nocgo.go b/clipboard_nocgo.go new file mode 100644 index 0000000..77826cf --- /dev/null +++ b/clipboard_nocgo.go @@ -0,0 +1,25 @@ +//go:build !windows && !cgo + +package clipboard + +import "context" + +func initialize() error { + panic("clipboard: cannot use when CGO_ENABLED=0") +} + +func read(t Format) (buf []byte, err error) { + panic("clipboard: cannot use when CGO_ENABLED=0") +} + +func readc(t string) ([]byte, error) { + panic("clipboard: cannot use when CGO_ENABLED=0") +} + +func write(t Format, buf []byte) (<-chan struct{}, error) { + panic("clipboard: cannot use when CGO_ENABLED=0") +} + +func watch(ctx context.Context, t Format) <-chan []byte { + panic("clipboard: cannot use when CGO_ENABLED=0") +} diff --git a/clipboard_test.go b/clipboard_test.go new file mode 100644 index 0000000..8d47568 --- /dev/null +++ b/clipboard_test.go @@ -0,0 +1,341 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +package clipboard_test + +import ( + "bytes" + "context" + "errors" + "image/color" + "image/png" + "os" + "reflect" + "runtime" + "testing" + "time" + + "golang.design/x/clipboard" +) + +func init() { + clipboard.Debug = true +} + +func TestClipboardInit(t *testing.T) { + t.Run("no-cgo", func(t *testing.T) { + if val, ok := os.LookupEnv("CGO_ENABLED"); !ok || val != "0" { + t.Skip("CGO_ENABLED is set to 1") + } + if runtime.GOOS == "windows" { + t.Skip("Windows does not need to check for cgo") + } + + defer func() { + if r := recover(); r != nil { + return + } + t.Fatalf("expect to fail when CGO_ENABLED=0") + }() + + clipboard.Init() + }) + t.Run("with-cgo", func(t *testing.T) { + if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" { + t.Skip("CGO_ENABLED is set to 0") + } + if runtime.GOOS != "linux" { + t.Skip("Only Linux may return error at the moment.") + } + + if err := clipboard.Init(); err != nil && !errors.Is(err, clipboard.ErrUnavailable) { + t.Fatalf("expect ErrUnavailable, but got: %v", err) + } + }) +} + +func TestClipboard(t *testing.T) { + if runtime.GOOS != "windows" { + if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" { + t.Skip("CGO_ENABLED is set to 0") + } + } + + t.Run("image", func(t *testing.T) { + data, err := os.ReadFile("tests/testdata/clipboard.png") + if err != nil { + t.Fatalf("failed to read gold file: %v", err) + } + clipboard.Write(clipboard.FmtImage, data) + + b := clipboard.Read(clipboard.FmtText) + if b != nil { + t.Fatalf("read clipboard that stores image data as text should fail, but got len: %d", len(b)) + } + + b = clipboard.Read(clipboard.FmtImage) + if b == nil { + t.Fatalf("read clipboard that stores image data as image should success, but got: nil") + } + + img1, err := png.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("write image is not png encoded: %v", err) + } + img2, err := png.Decode(bytes.NewReader(b)) + if err != nil { + t.Fatalf("read image is not png encoded: %v", err) + } + + w := img2.Bounds().Dx() + h := img2.Bounds().Dy() + + incorrect := 0 + for i := 0; i < w; i++ { + for j := 0; j < h; j++ { + wr, wg, wb, wa := img1.At(i, j).RGBA() + gr, gg, gb, ga := img2.At(i, j).RGBA() + want := color.RGBA{ + R: uint8(wr), + G: uint8(wg), + B: uint8(wb), + A: uint8(wa), + } + got := color.RGBA{ + R: uint8(gr), + G: uint8(gg), + B: uint8(gb), + A: uint8(ga), + } + + if !reflect.DeepEqual(want, got) { + t.Logf("read data from clipbaord is inconsistent with previous written data, pix: (%d,%d), got: %+v, want: %+v", i, j, got, want) + incorrect++ + } + } + } + + if incorrect > 0 { + t.Fatalf("read data from clipboard contains too much inconsistent pixels to the previous written data, number of incorrect pixels: %v", incorrect) + } + }) + + t.Run("text", func(t *testing.T) { + data := []byte("golang.design/x/clipboard") + clipboard.Write(clipboard.FmtText, data) + + b := clipboard.Read(clipboard.FmtImage) + if b != nil { + t.Fatalf("read clipboard that stores text data as image should fail, but got len: %d", len(b)) + } + b = clipboard.Read(clipboard.FmtText) + if b == nil { + t.Fatal("read clipboard taht stores text data as text should success, but got: nil") + } + + if !reflect.DeepEqual(data, b) { + t.Fatalf("read data from clipbaord is inconsistent with previous written data, got: %d, want: %d", len(b), len(data)) + } + }) +} + +func TestClipboardMultipleWrites(t *testing.T) { + if runtime.GOOS != "windows" { + if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" { + t.Skip("CGO_ENABLED is set to 0") + } + } + + data, err := os.ReadFile("tests/testdata/clipboard.png") + if err != nil { + t.Fatalf("failed to read gold file: %v", err) + } + chg := clipboard.Write(clipboard.FmtImage, data) + + data = []byte("golang.design/x/clipboard") + clipboard.Write(clipboard.FmtText, data) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) + defer cancel() + + select { + case <-ctx.Done(): + t.Fatalf("failed to receive clipboard change notification") + case _, ok := <-chg: + if !ok { + t.Fatalf("change channel is closed before receiving the changed clipboard data") + } + } + _, ok := <-chg + if ok { + t.Fatalf("changed channel should be closed after receiving the notification") + } + + b := clipboard.Read(clipboard.FmtImage) + if b != nil { + t.Fatalf("read clipboard that should store text data as image should fail, but got: %d", len(b)) + } + + b = clipboard.Read(clipboard.FmtText) + if b == nil { + t.Fatalf("read clipboard that should store text data as text should success, got: nil") + } + + if !reflect.DeepEqual(data, b) { + t.Fatalf("read data from clipbaord is inconsistent with previous write, want %s, got: %s", string(data), string(b)) + } +} + +func TestClipboardConcurrentRead(t *testing.T) { + if runtime.GOOS != "windows" { + if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" { + t.Skip("CGO_ENABLED is set to 0") + } + } + + // This test check that concurrent read/write to the clipboard does + // not cause crashes on some specific platform, such as macOS. + done := make(chan bool, 2) + go func() { + defer func() { + done <- true + }() + clipboard.Read(clipboard.FmtText) + }() + go func() { + defer func() { + done <- true + }() + clipboard.Read(clipboard.FmtImage) + }() + <-done + <-done +} + +func TestClipboardWriteEmpty(t *testing.T) { + if runtime.GOOS != "windows" { + if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" { + t.Skip("CGO_ENABLED is set to 0") + } + } + + chg1 := clipboard.Write(clipboard.FmtText, nil) + if got := clipboard.Read(clipboard.FmtText); got != nil { + t.Fatalf("write nil to clipboard should read nil, got: %v", string(got)) + } + clipboard.Write(clipboard.FmtText, []byte("")) + <-chg1 + + if got := clipboard.Read(clipboard.FmtText); string(got) != "" { + t.Fatalf("write empty string to clipboard should read empty string, got: `%v`", string(got)) + } +} + +func TestClipboardWatch(t *testing.T) { + if runtime.GOOS != "windows" { + if val, ok := os.LookupEnv("CGO_ENABLED"); ok && val == "0" { + t.Skip("CGO_ENABLED is set to 0") + } + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) + defer cancel() + + // clear clipboard + clipboard.Write(clipboard.FmtText, []byte("")) + lastRead := clipboard.Read(clipboard.FmtText) + + changed := clipboard.Watch(ctx, clipboard.FmtText) + + want := []byte("golang.design/x/clipboard") + go func(ctx context.Context) { + t := time.NewTicker(time.Millisecond * 500) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + clipboard.Write(clipboard.FmtText, want) + } + } + }(ctx) + for { + select { + case <-ctx.Done(): + if string(lastRead) == "" { + t.Fatalf("clipboard watch never receives a notification") + } + t.Log(string(lastRead)) + return + case data, ok := <-changed: + if !ok { + if string(lastRead) == "" { + t.Fatalf("clipboard watch never receives a notification") + } + return + } + if !bytes.Equal(data, want) { + t.Fatalf("received data from watch mismatch, want: %v, got %v", string(want), string(data)) + } + lastRead = data + } + } +} + +func BenchmarkClipboard(b *testing.B) { + b.Run("text", func(b *testing.B) { + data := []byte("golang.design/x/clipboard") + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + clipboard.Write(clipboard.FmtText, data) + _ = clipboard.Read(clipboard.FmtText) + } + }) +} + +func TestClipboardNoCgo(t *testing.T) { + if val, ok := os.LookupEnv("CGO_ENABLED"); !ok || val != "0" { + t.Skip("CGO_ENABLED is set to 1") + } + if runtime.GOOS == "windows" { + t.Skip("Windows should always be tested") + } + + t.Run("Read", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + return + } + t.Fatalf("expect to fail when CGO_ENABLED=0") + }() + + clipboard.Read(clipboard.FmtText) + }) + + t.Run("Write", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + return + } + t.Fatalf("expect to fail when CGO_ENABLED=0") + }() + + clipboard.Write(clipboard.FmtText, []byte("dummy")) + }) + + t.Run("Watch", func(t *testing.T) { + defer func() { + if r := recover(); r != nil { + return + } + t.Fatalf("expect to fail when CGO_ENABLED=0") + }() + + clipboard.Watch(context.TODO(), clipboard.FmtText) + }) +} diff --git a/clipboard_windows.go b/clipboard_windows.go new file mode 100644 index 0000000..bd042cd --- /dev/null +++ b/clipboard_windows.go @@ -0,0 +1,551 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +//go:build windows + +package clipboard + +// Interacting with Clipboard on Windows: +// https://docs.microsoft.com/zh-cn/windows/win32/dataxchg/using-the-clipboard + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "image" + "image/color" + "image/png" + "reflect" + "runtime" + "syscall" + "time" + "unicode/utf16" + "unsafe" + + "golang.org/x/image/bmp" +) + +func initialize() error { return nil } + +// readText reads the clipboard and returns the text data if presents. +// The caller is responsible for opening/closing the clipboard before +// calling this function. +func readText() (buf []byte, err error) { + hMem, _, err := getClipboardData.Call(cFmtUnicodeText) + if hMem == 0 { + return nil, err + } + p, _, err := gLock.Call(hMem) + if p == 0 { + return nil, err + } + defer gUnlock.Call(hMem) + + // Find NUL terminator + n := 0 + for ptr := unsafe.Pointer(p); *(*uint16)(ptr) != 0; n++ { + ptr = unsafe.Pointer(uintptr(ptr) + + unsafe.Sizeof(*((*uint16)(unsafe.Pointer(p))))) + } + + var s []uint16 + h := (*reflect.SliceHeader)(unsafe.Pointer(&s)) + h.Data = p + h.Len = n + h.Cap = n + return []byte(string(utf16.Decode(s))), nil +} + +// writeText writes given data to the clipboard. It is the caller's +// responsibility for opening/closing the clipboard before calling +// this function. +func writeText(buf []byte) error { + r, _, err := emptyClipboard.Call() + if r == 0 { + return fmt.Errorf("failed to clear clipboard: %w", err) + } + + // empty text, we are done here. + if len(buf) == 0 { + return nil + } + + s, err := syscall.UTF16FromString(string(buf)) + if err != nil { + return fmt.Errorf("failed to convert given string: %w", err) + } + + hMem, _, err := gAlloc.Call(gmemMoveable, uintptr(len(s)*int(unsafe.Sizeof(s[0])))) + if hMem == 0 { + return fmt.Errorf("failed to alloc global memory: %w", err) + } + + p, _, err := gLock.Call(hMem) + if p == 0 { + return fmt.Errorf("failed to lock global memory: %w", err) + } + defer gUnlock.Call(hMem) + + // no return value + memMove.Call(p, uintptr(unsafe.Pointer(&s[0])), + uintptr(len(s)*int(unsafe.Sizeof(s[0])))) + + v, _, err := setClipboardData.Call(cFmtUnicodeText, hMem) + if v == 0 { + gFree.Call(hMem) + return fmt.Errorf("failed to set text to clipboard: %w", err) + } + + return nil +} + +// readImage reads the clipboard and returns PNG encoded image data +// if presents. The caller is responsible for opening/closing the +// clipboard before calling this function. +func readImage() ([]byte, error) { + hMem, _, err := getClipboardData.Call(cFmtDIBV5) + if hMem == 0 { + // second chance to try FmtDIB + return readImageDib() + } + p, _, err := gLock.Call(hMem) + if p == 0 { + return nil, err + } + defer gUnlock.Call(hMem) + + // inspect header information + info := (*bitmapV5Header)(unsafe.Pointer(p)) + + // maybe deal with other formats? + if info.BitCount != 32 { + return nil, errUnsupported + } + + var data []byte + sh := (*reflect.SliceHeader)(unsafe.Pointer(&data)) + sh.Data = uintptr(p) + sh.Cap = int(info.Size + 4*uint32(info.Width)*uint32(info.Height)) + sh.Len = int(info.Size + 4*uint32(info.Width)*uint32(info.Height)) + img := image.NewRGBA(image.Rect(0, 0, int(info.Width), int(info.Height))) + offset := int(info.Size) + stride := int(info.Width) + for y := 0; y < int(info.Height); y++ { + for x := 0; x < int(info.Width); x++ { + idx := offset + 4*(y*stride+x) + xhat := (x + int(info.Width)) % int(info.Width) + yhat := int(info.Height) - 1 - y + r := data[idx+2] + g := data[idx+1] + b := data[idx+0] + a := data[idx+3] + img.SetRGBA(xhat, yhat, color.RGBA{r, g, b, a}) + } + } + // always use PNG encoding. + var buf bytes.Buffer + png.Encode(&buf, img) + return buf.Bytes(), nil +} + +func readImageDib() ([]byte, error) { + const ( + fileHeaderLen = 14 + infoHeaderLen = 40 + cFmtDIB = 8 + ) + + hClipDat, _, err := getClipboardData.Call(cFmtDIB) + if err != nil { + return nil, errors.New("not dib format data: " + err.Error()) + } + pMemBlk, _, err := gLock.Call(hClipDat) + if pMemBlk == 0 { + return nil, errors.New("failed to call global lock: " + err.Error()) + } + defer gUnlock.Call(hClipDat) + + bmpHeader := (*bitmapHeader)(unsafe.Pointer(pMemBlk)) + dataSize := bmpHeader.SizeImage + fileHeaderLen + infoHeaderLen + + if bmpHeader.SizeImage == 0 && bmpHeader.Compression == 0 { + iSizeImage := bmpHeader.Height * ((bmpHeader.Width*uint32(bmpHeader.BitCount)/8 + 3) &^ 3) + dataSize += iSizeImage + } + buf := new(bytes.Buffer) + binary.Write(buf, binary.LittleEndian, uint16('B')|(uint16('M')<<8)) + binary.Write(buf, binary.LittleEndian, uint32(dataSize)) + binary.Write(buf, binary.LittleEndian, uint32(0)) + const sizeof_colorbar = 0 + binary.Write(buf, binary.LittleEndian, uint32(fileHeaderLen+infoHeaderLen+sizeof_colorbar)) + j := 0 + for i := fileHeaderLen; i < int(dataSize); i++ { + binary.Write(buf, binary.BigEndian, *(*byte)(unsafe.Pointer(pMemBlk + uintptr(j)))) + j++ + } + return bmpToPng(buf) +} + +func bmpToPng(bmpBuf *bytes.Buffer) (buf []byte, err error) { + var f bytes.Buffer + original_image, err := bmp.Decode(bmpBuf) + if err != nil { + return nil, err + } + err = png.Encode(&f, original_image) + if err != nil { + return nil, err + } + return f.Bytes(), nil +} + +func writeImage(buf []byte) error { + r, _, err := emptyClipboard.Call() + if r == 0 { + return fmt.Errorf("failed to clear clipboard: %w", err) + } + + // empty text, we are done here. + if len(buf) == 0 { + return nil + } + + img, err := png.Decode(bytes.NewReader(buf)) + if err != nil { + return fmt.Errorf("input bytes is not PNG encoded: %w", err) + } + + offset := unsafe.Sizeof(bitmapV5Header{}) + width := img.Bounds().Dx() + height := img.Bounds().Dy() + imageSize := 4 * width * height + + data := make([]byte, int(offset)+imageSize) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + idx := int(offset) + 4*(y*width+x) + r, g, b, a := img.At(x, height-1-y).RGBA() + data[idx+2] = uint8(r) + data[idx+1] = uint8(g) + data[idx+0] = uint8(b) + data[idx+3] = uint8(a) + } + } + + info := bitmapV5Header{} + info.Size = uint32(offset) + info.Width = int32(width) + info.Height = int32(height) + info.Planes = 1 + info.Compression = 0 // BI_RGB + info.SizeImage = uint32(4 * info.Width * info.Height) + info.RedMask = 0xff0000 // default mask + info.GreenMask = 0xff00 + info.BlueMask = 0xff + info.AlphaMask = 0xff000000 + info.BitCount = 32 // we only deal with 32 bpp at the moment. + // Use calibrated RGB values as Go's image/png assumes linear color space. + // Other options: + // - LCS_CALIBRATED_RGB = 0x00000000 + // - LCS_sRGB = 0x73524742 + // - LCS_WINDOWS_COLOR_SPACE = 0x57696E20 + // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/eb4bbd50-b3ce-4917-895c-be31f214797f + info.CSType = 0x73524742 + // Use GL_IMAGES for GamutMappingIntent + // Other options: + // - LCS_GM_ABS_COLORIMETRIC = 0x00000008 + // - LCS_GM_BUSINESS = 0x00000001 + // - LCS_GM_GRAPHICS = 0x00000002 + // - LCS_GM_IMAGES = 0x00000004 + // https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-wmf/9fec0834-607d-427d-abd5-ab240fb0db38 + info.Intent = 4 // LCS_GM_IMAGES + + infob := make([]byte, int(unsafe.Sizeof(info))) + for i, v := range *(*[unsafe.Sizeof(info)]byte)(unsafe.Pointer(&info)) { + infob[i] = v + } + copy(data[:], infob[:]) + + hMem, _, err := gAlloc.Call(gmemMoveable, + uintptr(len(data)*int(unsafe.Sizeof(data[0])))) + if hMem == 0 { + return fmt.Errorf("failed to alloc global memory: %w", err) + } + + p, _, err := gLock.Call(hMem) + if p == 0 { + return fmt.Errorf("failed to lock global memory: %w", err) + } + defer gUnlock.Call(hMem) + + memMove.Call(p, uintptr(unsafe.Pointer(&data[0])), + uintptr(len(data)*int(unsafe.Sizeof(data[0])))) + + v, _, err := setClipboardData.Call(cFmtDIBV5, hMem) + if v == 0 { + gFree.Call(hMem) + return fmt.Errorf("failed to set text to clipboard: %w", err) + } + + return nil +} + +func read(t Format) (buf []byte, err error) { + // On Windows, OpenClipboard and CloseClipboard must be executed on + // the same thread. Thus, lock the OS thread for further execution. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + var format uintptr + switch t { + case FmtImage: + format = cFmtDIBV5 + case FmtText: + fallthrough + default: + format = cFmtUnicodeText + } + + // check if clipboard is avaliable for the requested format + r, _, err := isClipboardFormatAvailable.Call(format) + if r == 0 { + return nil, errUnavailable + } + + // try again until open clipboard successed + for { + r, _, _ = openClipboard.Call() + if r == 0 { + continue + } + break + } + defer closeClipboard.Call() + + switch format { + case cFmtDIBV5: + return readImage() + case cFmtUnicodeText: + fallthrough + default: + return readText() + } +} + +// write writes the given data to clipboard and +// returns true if success or false if failed. +func write(t Format, buf []byte) (<-chan struct{}, error) { + errch := make(chan error) + changed := make(chan struct{}, 1) + go func() { + // make sure GetClipboardSequenceNumber happens with + // OpenClipboard on the same thread. + runtime.LockOSThread() + defer runtime.UnlockOSThread() + for { + r, _, _ := openClipboard.Call(0) + if r == 0 { + continue + } + break + } + + // var param uintptr + switch t { + case FmtImage: + err := writeImage(buf) + if err != nil { + errch <- err + closeClipboard.Call() + return + } + case FmtText: + fallthrough + default: + // param = cFmtUnicodeText + err := writeText(buf) + if err != nil { + errch <- err + closeClipboard.Call() + return + } + } + // Close the clipboard otherwise other applications cannot + // paste the data. + closeClipboard.Call() + + cnt, _, _ := getClipboardSequenceNumber.Call() + errch <- nil + for { + time.Sleep(time.Second) + cur, _, _ := getClipboardSequenceNumber.Call() + if cur != cnt { + changed <- struct{}{} + close(changed) + return + } + } + }() + err := <-errch + if err != nil { + return nil, err + } + return changed, nil +} + +func watch(ctx context.Context, t Format) <-chan []byte { + recv := make(chan []byte, 1) + ready := make(chan struct{}) + go func() { + // not sure if we are too slow or the user too fast :) + ti := time.NewTicker(time.Second) + cnt, _, _ := getClipboardSequenceNumber.Call() + ready <- struct{}{} + for { + select { + case <-ctx.Done(): + close(recv) + return + case <-ti.C: + cur, _, _ := getClipboardSequenceNumber.Call() + if cnt != cur { + b := Read(t) + if b == nil { + continue + } + recv <- b + cnt = cur + } + } + } + }() + <-ready + return recv +} + +const ( + cFmtBitmap = 2 // Win+PrintScreen + cFmtUnicodeText = 13 + cFmtDIBV5 = 17 + // Screenshot taken from special shortcut is in different format (why??), see: + // https://jpsoft.com/forums/threads/detecting-clipboard-format.5225/ + cFmtDataObject = 49161 // Shift+Win+s, returned from enumClipboardFormats + gmemMoveable = 0x0002 +) + +// BITMAPV5Header structure, see: +// https://docs.microsoft.com/en-us/windows/win32/api/wingdi/ns-wingdi-bitmapv5header +type bitmapV5Header struct { + Size uint32 + Width int32 + Height int32 + Planes uint16 + BitCount uint16 + Compression uint32 + SizeImage uint32 + XPelsPerMeter int32 + YPelsPerMeter int32 + ClrUsed uint32 + ClrImportant uint32 + RedMask uint32 + GreenMask uint32 + BlueMask uint32 + AlphaMask uint32 + CSType uint32 + Endpoints struct { + CiexyzRed, CiexyzGreen, CiexyzBlue struct { + CiexyzX, CiexyzY, CiexyzZ int32 // FXPT2DOT30 + } + } + GammaRed uint32 + GammaGreen uint32 + GammaBlue uint32 + Intent uint32 + ProfileData uint32 + ProfileSize uint32 + Reserved uint32 +} + +type bitmapHeader struct { + Size uint32 + Width uint32 + Height uint32 + PLanes uint16 + BitCount uint16 + Compression uint32 + SizeImage uint32 + XPelsPerMeter uint32 + YPelsPerMeter uint32 + ClrUsed uint32 + ClrImportant uint32 +} + +// Calling a Windows DLL, see: +// https://github.com/golang/go/wiki/WindowsDLLs +var ( + user32 = syscall.MustLoadDLL("user32") + // Opens the clipboard for examination and prevents other + // applications from modifying the clipboard content. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-openclipboard + openClipboard = user32.MustFindProc("OpenClipboard") + // Closes the clipboard. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-closeclipboard + closeClipboard = user32.MustFindProc("CloseClipboard") + // Empties the clipboard and frees handles to data in the clipboard. + // The function then assigns ownership of the clipboard to the + // window that currently has the clipboard open. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-emptyclipboard + emptyClipboard = user32.MustFindProc("EmptyClipboard") + // Retrieves data from the clipboard in a specified format. + // The clipboard must have been opened previously. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboarddata + getClipboardData = user32.MustFindProc("GetClipboardData") + // Places data on the clipboard in a specified clipboard format. + // The window must be the current clipboard owner, and the + // application must have called the OpenClipboard function. (When + // responding to the WM_RENDERFORMAT message, the clipboard owner + // must not call OpenClipboard before calling SetClipboardData.) + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setclipboarddata + setClipboardData = user32.MustFindProc("SetClipboardData") + // Determines whether the clipboard contains data in the specified format. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable + isClipboardFormatAvailable = user32.MustFindProc("IsClipboardFormatAvailable") + // Clipboard data formats are stored in an ordered list. To perform + // an enumeration of clipboard data formats, you make a series of + // calls to the EnumClipboardFormats function. For each call, the + // format parameter specifies an available clipboard format, and the + // function returns the next available clipboard format. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-isclipboardformatavailable + enumClipboardFormats = user32.MustFindProc("EnumClipboardFormats") + // Retrieves the clipboard sequence number for the current window station. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getclipboardsequencenumber + getClipboardSequenceNumber = user32.MustFindProc("GetClipboardSequenceNumber") + // Registers a new clipboard format. This format can then be used as + // a valid clipboard format. + // https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-registerclipboardformata + registerClipboardFormatA = user32.MustFindProc("RegisterClipboardFormatA") + + kernel32 = syscall.NewLazyDLL("kernel32") + + // Locks a global memory object and returns a pointer to the first + // byte of the object's memory block. + // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globallock + gLock = kernel32.NewProc("GlobalLock") + // Decrements the lock count associated with a memory object that was + // allocated with GMEM_MOVEABLE. This function has no effect on memory + // objects allocated with GMEM_FIXED. + // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalunlock + gUnlock = kernel32.NewProc("GlobalUnlock") + // Allocates the specified number of bytes from the heap. + // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalalloc + gAlloc = kernel32.NewProc("GlobalAlloc") + // Frees the specified global memory object and invalidates its handle. + // https://docs.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-globalfree + gFree = kernel32.NewProc("GlobalFree") + memMove = kernel32.NewProc("RtlMoveMemory") +) diff --git a/cmd/gclip-gui/AndroidManifest.xml b/cmd/gclip-gui/AndroidManifest.xml new file mode 100644 index 0000000..63b0cba --- /dev/null +++ b/cmd/gclip-gui/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + diff --git a/cmd/gclip-gui/README.md b/cmd/gclip-gui/README.md new file mode 100644 index 0000000..571604e --- /dev/null +++ b/cmd/gclip-gui/README.md @@ -0,0 +1,31 @@ +# gclip-gui + +This is a very basic example for verification purpose that demonstrates +how the [golang.design/x/clipboard](https://golang.design/x/clipboard) +can interact with macOS/Linux/Windows/Android/iOS system clipboard. + +The gclip GUI application writes a string to the system clipboard +periodically then reads it back and renders it if possible. + +Because of the system limitation, on mobile devices, only string data is +supported at the moment. Hence, one must use clipboard.FmtText. Other supplied +formats result in a panic. + +This example is intentded as cross platform application. To build it, one +must use [gomobile](https://golang.org/x/mobile). You may follow the instructions +provided in the [GoMobile wiki](https://github.com/golang/go/wiki/Mobile) page. + + +- For desktop: `go build -o gclip-gui` +- For Android: `gomobile build -v -target=android -o gclip-gui.apk` +- For iOS: `gomobile build -v -target=ios -bundleid design.golang.gclip-gui.app` + +## Screenshots + +| macOS | iOS | Windows | Android | Linux | +|:-----:|:---:|:-------:|:-------:|:-----:| +|![](../../tests/testdata/darwin.png)|![](../../tests/testdata/ios.png)|![](../../tests/testdata/windows.png)|![](../../tests/testdata/android.png)|![](../../tests/testdata/linux.png)| + +## License + +MIT | © 2021 The golang.design Initiative Authors, written by [Changkun Ou](https://changkun.de). \ No newline at end of file diff --git a/cmd/gclip-gui/main.go b/cmd/gclip-gui/main.go new file mode 100644 index 0000000..1bf4627 --- /dev/null +++ b/cmd/gclip-gui/main.go @@ -0,0 +1,236 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +//go:build android || ios || linux || darwin || windows + +// This is a very basic example for verification purpose that +// demonstrates how the golang.design/x/clipboard can interact +// with macOS/Linux/Windows/Android/iOS system clipboard. +// +// The gclip GUI application writes a string to the system clipboard +// periodically then reads it back and renders it if possible. +// +// Because of the system limitation, on mobile devices, only string +// data is supported at the moment. Hence, one must use clipboard.FmtText. +// Other supplied formats result in a panic. +// +// This example is intentded as cross platform application. +// To build it, one must use gomobile (https://golang.org/x/mobile). +// You may follow the instructions provided in the GoMobile's wiki page: +// https://github.com/golang/go/wiki/Mobile. +// +// - For desktop: +// +// go build -o gclip-gui +// +// - For Android: +// +// gomobile build -v -target=android -o gclip-gui.apk +// +// - For iOS: +// +// gomobile build -v -target=ios -bundleid design.golang.gclip-gui.app +// +package main + +import ( + "fmt" + "image" + "image/color" + "log" + "os" + "sync" + "time" + + "golang.design/x/clipboard" + + "golang.org/x/image/font" + "golang.org/x/image/font/basicfont" + "golang.org/x/image/math/fixed" + "golang.org/x/mobile/app" + "golang.org/x/mobile/event/lifecycle" + "golang.org/x/mobile/event/paint" + "golang.org/x/mobile/event/size" + "golang.org/x/mobile/exp/gl/glutil" + "golang.org/x/mobile/geom" + "golang.org/x/mobile/gl" +) + +type Label struct { + sz size.Event + images *glutil.Images + m *glutil.Image + drawer *font.Drawer + + mu sync.Mutex + data string +} + +func NewLabel(images *glutil.Images) *Label { + return &Label{ + images: images, + data: "Hello! Gclip.", + drawer: nil, + } +} + +func (l *Label) SetLabel(s string) { + l.mu.Lock() + defer l.mu.Unlock() + + l.data = s +} + +const ( + lineWidth = 100 + lineHeight = 120 +) + +func (l *Label) Draw(sz size.Event) { + l.mu.Lock() + s := l.data + l.mu.Unlock() + imgW, imgH := lineWidth*basicfont.Face7x13.Width, lineHeight*basicfont.Face7x13.Height + if sz.WidthPx == 0 && sz.HeightPx == 0 { + return + } + if imgW > sz.WidthPx { + imgW = sz.WidthPx + } + + if l.sz != sz { + l.sz = sz + if l.m != nil { + l.m.Release() + } + l.m = l.images.NewImage(imgW, imgH) + } + // Clear the drawing image. + for i := 0; i < len(l.m.RGBA.Pix); i++ { + l.m.RGBA.Pix[i] = 0 + } + + l.drawer = &font.Drawer{ + Dst: l.m.RGBA, + Src: image.NewUniform(color.RGBA{0, 100, 125, 255}), + Face: basicfont.Face7x13, + Dot: fixed.P(5, 10), + } + l.drawer.DrawString(s) + l.m.Upload() + l.m.Draw( + sz, + geom.Point{X: 0, Y: 50}, + geom.Point{X: geom.Pt(imgW), Y: 50}, + geom.Point{X: 0, Y: geom.Pt(imgH)}, + l.m.RGBA.Bounds(), + ) +} + +func (l *Label) Release() { + if l.m != nil { + l.m.Release() + l.m = nil + l.images = nil + } +} + +// GclipApp is the application instance. +type GclipApp struct { + app app.App + + ctx gl.Context + siz size.Event + + images *glutil.Images + l *Label + + counter int +} + +// WatchClipboard watches the system clipboard every seconds. +func (g *GclipApp) WatchClipboard() { + go func() { + tk := time.NewTicker(time.Second) + for range tk.C { + // Write something to the clipboard + w := fmt.Sprintf("(gclip: %d)", g.counter) + clipboard.Write(clipboard.FmtText, []byte(w)) + g.counter++ + log.Println(w) + + // Read it back and render it, if possible. + data := clipboard.Read(clipboard.FmtText) + if len(data) == 0 { + continue + } + + // Set the current clipboard data as label content and render on the screen. + r := fmt.Sprintf("clipboard: %s", string(data)) + g.l.SetLabel(r) + g.app.Send(paint.Event{}) + } + }() +} + +func (g *GclipApp) OnStart(e lifecycle.Event) { + g.ctx, _ = e.DrawContext.(gl.Context) + g.images = glutil.NewImages(g.ctx) + g.l = NewLabel(g.images) + g.app.Send(paint.Event{}) +} + +func (g *GclipApp) OnStop() { + g.l.Release() + g.images.Release() + g.ctx = nil +} + +func (g *GclipApp) OnSize(size size.Event) { + g.siz = size +} + +func (g *GclipApp) OnDraw() { + if g.ctx == nil { + return + } + defer g.app.Send(paint.Event{}) + defer g.app.Publish() + g.ctx.ClearColor(0, 0, 0, 1) + g.ctx.Clear(gl.COLOR_BUFFER_BIT) + g.l.Draw(g.siz) +} + +func init() { + err := clipboard.Init() + if err != nil { + panic(err) + } +} + +func main() { + app.Main(func(a app.App) { + gclip := GclipApp{app: a} + gclip.app.Send(size.Event{WidthPx: 800, HeightPx: 500}) + gclip.WatchClipboard() + for e := range gclip.app.Events() { + switch e := gclip.app.Filter(e).(type) { + case lifecycle.Event: + switch e.Crosses(lifecycle.StageVisible) { + case lifecycle.CrossOn: + gclip.OnStart(e) + case lifecycle.CrossOff: + gclip.OnStop() + os.Exit(0) + } + case size.Event: + gclip.OnSize(e) + case paint.Event: + gclip.OnDraw() + } + } + }) +} diff --git a/cmd/gclip/README.md b/cmd/gclip/README.md new file mode 100644 index 0000000..d4ea0a6 --- /dev/null +++ b/cmd/gclip/README.md @@ -0,0 +1,40 @@ +# gclip + +`gclip` command offers the ability to interact with the system clipboard +from the shell. To install: + +```bash +$ go install golang.design/x/clipboard/cmd/gclip@latest +``` + +```bash +$ gclip +gclip is a command that provides clipboard interaction. +usage: gclip [-copy|-paste] [-f ] +options: + -copy + copy data to clipboard + -f string + source or destination to a given file path + -paste + paste data from clipboard +examples: +gclip -paste paste from clipboard and prints the content +gclip -paste -f x.txt paste from clipboard and save as text to x.txt +gclip -paste -f x.png paste from clipboard and save as image to x.png +cat x.txt | gclip -copy copy content from x.txt to clipboard +gclip -copy -f x.txt copy content from x.txt to clipboard +gclip -copy -f x.png copy x.png as image data to clipboard +``` + +If `-copy` is used, the command will exit when the data is no longer +available from the clipboard. You can always send the command to the +background using a shell `&` operator, for example: + +```bash +$ cat x.txt | gclip -copy & +``` + +## License + +MIT | © 2021 The golang.design Initiative Authors, written by [Changkun Ou](https://changkun.de). \ No newline at end of file diff --git a/cmd/gclip/main.go b/cmd/gclip/main.go new file mode 100644 index 0000000..30d5714 --- /dev/null +++ b/cmd/gclip/main.go @@ -0,0 +1,131 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +package main // go install golang.design/x/clipboard/cmd/gclip@latest + +import ( + "flag" + "fmt" + "io" + "os" + "path/filepath" + + "golang.design/x/clipboard" +) + +func usage() { + fmt.Fprintf(os.Stderr, `gclip is a command that provides clipboard interaction. + +usage: gclip [-copy|-paste] [-f ] + +options: +`) + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, ` +examples: +gclip -paste paste from clipboard and prints the content +gclip -paste -f x.txt paste from clipboard and save as text to x.txt +gclip -paste -f x.png paste from clipboard and save as image to x.png + +cat x.txt | gclip -copy copy content from x.txt to clipboard +gclip -copy -f x.txt copy content from x.txt to clipboard +gclip -copy -f x.png copy x.png as image data to clipboard +`) + os.Exit(2) +} + +var ( + in = flag.Bool("copy", false, "copy data to clipboard") + out = flag.Bool("paste", false, "paste data from clipboard") + file = flag.String("f", "", "source or destination to a given file path") +) + +func init() { + err := clipboard.Init() + if err != nil { + panic(err) + } +} + +func main() { + flag.Usage = usage + flag.Parse() + if *out { + if err := pst(); err != nil { + usage() + } + return + } + if *in { + if err := cpy(); err != nil { + usage() + } + return + } + usage() +} + +func cpy() error { + t := clipboard.FmtText + ext := filepath.Ext(*file) + + switch ext { + case ".png": + t = clipboard.FmtImage + case ".txt": + fallthrough + default: + t = clipboard.FmtText + } + + var ( + b []byte + err error + ) + if *file != "" { + b, err = os.ReadFile(*file) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to read given file: %v", err) + return err + } + } else { + b, err = io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to read from stdin: %v", err) + return err + } + } + + // Wait until clipboard content has been changed. + <-clipboard.Write(t, b) + return nil +} + +func pst() (err error) { + var b []byte + + b = clipboard.Read(clipboard.FmtText) + if b == nil { + b = clipboard.Read(clipboard.FmtImage) + } + + if *file != "" && b != nil { + err = os.WriteFile(*file, b, os.ModePerm) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to write data to file %s: %v", *file, err) + } + return err + } + + for len(b) > 0 { + n, err := os.Stdout.Write(b) + if err != nil { + return err + } + b = b[n:] + } + return nil +} diff --git a/example_test.go b/example_test.go new file mode 100644 index 0000000..72d613a --- /dev/null +++ b/example_test.go @@ -0,0 +1,56 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +//go:build cgo + +package clipboard_test + +import ( + "context" + "fmt" + "time" + + "golang.design/x/clipboard" +) + +func ExampleWrite() { + err := clipboard.Init() + if err != nil { + panic(err) + } + + clipboard.Write(clipboard.FmtText, []byte("Hello, 世界")) + // Output: +} + +func ExampleRead() { + err := clipboard.Init() + if err != nil { + panic(err) + } + + fmt.Println(string(clipboard.Read(clipboard.FmtText))) + // Output: + // Hello, 世界 +} + +func ExampleWatch() { + err := clipboard.Init() + if err != nil { + panic(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) + defer cancel() + + changed := clipboard.Watch(context.Background(), clipboard.FmtText) + go func(ctx context.Context) { + clipboard.Write(clipboard.FmtText, []byte("你好,world")) + }(ctx) + fmt.Println(string(<-changed)) + // Output: + // 你好,world +} diff --git a/export_test.go b/export_test.go new file mode 100644 index 0000000..1a0c90d --- /dev/null +++ b/export_test.go @@ -0,0 +1,13 @@ +// Copyright 2021 The golang.design Initiative Authors. +// All rights reserved. Use of this source code is governed +// by a MIT license that can be found in the LICENSE file. +// +// Written by Changkun Ou + +package clipboard + +// for debugging errors +var ( + Debug = debug + ErrUnavailable = errUnavailable +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7b2d6b5 --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module golang.design/x/clipboard + +go 1.22.0 + +toolchain go1.23.1 + +require ( + golang.org/x/image v0.21.0 + golang.org/x/mobile v0.0.0-20241004191011-08a83c5af9f8 +) + +require ( + golang.org/x/exp/shiny v0.0.0-20241009180824-f66d83c29e7c // indirect + golang.org/x/sys v0.26.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..29648d9 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +golang.org/x/exp/shiny v0.0.0-20241009180824-f66d83c29e7c h1:jTMrjjZRcSH/BDxWhXCP6OWsfVgmnwI7J+F4/nyVXaU= +golang.org/x/exp/shiny v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:3F+MieQB7dRYLTmnncoFbb1crS5lfQoTfDgQy6K4N0o= +golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= +golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= +golang.org/x/mobile v0.0.0-20241004191011-08a83c5af9f8 h1:W8YtRRiKAvbIbqAh+xpWKecxEO8w1oaD6DrZhMyotF8= +golang.org/x/mobile v0.0.0-20241004191011-08a83c5af9f8/go.mod h1:snk1Mn2ZpdKCt90JPEsDh4sL3ReK520U2t0d7RHBnSU= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/tests/Makefile b/tests/Makefile new file mode 100644 index 0000000..809e2ea --- /dev/null +++ b/tests/Makefile @@ -0,0 +1,15 @@ +# Copyright 2021 The golang.design Initiative Authors. +# All rights reserved. Use of this source code is governed +# by a MIT license that can be found in the LICENSE file. +# +# Written by Changkun Ou + +all: test + +test: + go test -v -count=1 -covermode=atomic .. + +test-docker: + docker build -t golang-design/x/clipboard .. + docker run --rm --name cb golang-design/x/clipboard + docker rmi golang-design/x/clipboard \ No newline at end of file diff --git a/tests/test-docker.sh b/tests/test-docker.sh new file mode 100755 index 0000000..a17a6af --- /dev/null +++ b/tests/test-docker.sh @@ -0,0 +1,11 @@ +# Copyright 2021 The golang.design Initiative Authors. +# All rights reserved. Use of this source code is governed +# by a MIT license that can be found in the LICENSE file. +# +# Written by Changkun Ou + +# require apt-get install xvfb +Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & +export DISPLAY=:99.0 + +go test -v -covermode=atomic ./... \ No newline at end of file diff --git a/tests/testdata/android.png b/tests/testdata/android.png new file mode 100644 index 0000000..b309ce4 Binary files /dev/null and b/tests/testdata/android.png differ diff --git a/tests/testdata/clipboard.png b/tests/testdata/clipboard.png new file mode 100644 index 0000000..cd3af8c Binary files /dev/null and b/tests/testdata/clipboard.png differ diff --git a/tests/testdata/darwin.png b/tests/testdata/darwin.png new file mode 100644 index 0000000..b9ed2bf Binary files /dev/null and b/tests/testdata/darwin.png differ diff --git a/tests/testdata/ios.png b/tests/testdata/ios.png new file mode 100644 index 0000000..ddc7a9d Binary files /dev/null and b/tests/testdata/ios.png differ diff --git a/tests/testdata/linux.png b/tests/testdata/linux.png new file mode 100644 index 0000000..e156273 Binary files /dev/null and b/tests/testdata/linux.png differ diff --git a/tests/testdata/windows.png b/tests/testdata/windows.png new file mode 100644 index 0000000..dd25f31 Binary files /dev/null and b/tests/testdata/windows.png differ