技術メモ

技術メモ

ラフなメモ

Go製のマルチプラットフォームなクリップボードライブラリを作った

はじめに

Go でマルチプラットフォームで動く CLI を作っていて、その過程でクリップボードにコピーしたり、ペーストできるようなライブラリがほしくなった。

マルチプラットフォームで動く Go のクリップボードライブラリを探してみると、ほとんど出てこなくて、たぶん以下のみ

https://github.com/atotto/clipboard

これも Windows は WindowsAPI を使って外部パッケージに依存せずに動作できるようになっているが、LinuxmacOS については xclip や pbcopy などの外部パッケージのコマンドを叩くだけのラッパーになっている。

WSL の ubuntu で動かしてみたのだが動作せずに exit status 1 などと cmd.Exec の外部コマンドを実行したときのエラーのみが返ってきて、困っていた。LinuxmacOSクリップボードについても、外部パッケージを使うのではなく、Go のみで実装できると安定するな、と思い、作成方法を模索し始めた。

できたもの(失敗作)

現状は Linux/Unix 環境用で動かすことが実現できていない。

https://github.com/d-tsuji/clipboard

Windows

Windows には Clipboard を操作する API が用意されている。

https://docs.microsoft.com/ja-jp/windows/win32/dataxchg/using-the-clipboard

全体の流れはこれがわかりやすいように思える。

http://wisdom.sakura.ne.jp/system/winapi/win32/win90.html

atotto/clipboard ではどうなっていたかというと以下のように各種のプロシージャを取得している。

   user32           = syscall.MustLoadDLL("user32")
    openClipboard    = user32.MustFindProc("OpenClipboard")
    closeClipboard   = user32.MustFindProc("CloseClipboard")
    emptyClipboard   = user32.MustFindProc("EmptyClipboard")
    getClipboardData = user32.MustFindProc("GetClipboardData")
    setClipboardData = user32.MustFindProc("SetClipboardData")

openClipboard を使ってクリップボードを開いている。雰囲気は分かるが細部はわからず。。Windowsデベロッパーサイトには C++ の例は記載されている。

HANDLE GetClipboardData(
  UINT uFormat
);

しかし Go の例は記載されていないので、自前でシステムコールを扱う必要があるのだが、どのように調べるのだろうか。わからず。

atotto/clipboard での実装は以下のような感じ

func waitOpenClipboard() error {
    started := time.Now()
    limit := started.Add(time.Second)
    var r uintptr
    var err error
    for time.Now().Before(limit) {
        r, _, err = openClipboard.Call(0)
        if r != 0 {
            return nil
        }
        time.Sleep(time.Millisecond)
    }
    return err
}
func readAll() (string, error) {
    err := waitOpenClipboard()
    if err != nil {
        return "", err
    }
    defer closeClipboard.Call()

    h, _, err := getClipboardData.Call(cfUnicodetext)
    if h == 0 {
        return "", err
    }

    l, _, err := globalLock.Call(h)
    if l == 0 {
        return "", err
    }

    text := syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(l))[:])

    r, _, err := globalUnlock.Call(h)
    if r == 0 {
        return "", err
    }

    return text, nil
}

Windows API のラッパーに関してはいろいろリポジトリが揃っているようだったので、それのうちの 1 つを用いることにした。

例えば以下

今回は上の lxn/walk を使っている。これのおかげで直接はシステムコールを扱わずともクリップボードを扱える

func get() (string, error) {
    c := walk.Clipboard()
    return c.Text()
}

OSX

Windows と同様に Apple が提供しているクリップボード用の API が存在する

https://developer.apple.com/documentation/appkit/nspasteboard?language=objc

Swift か Objective-C 用のライブラリでクリップボードに対して Write, Read はできる。が当然だが Go には存在しない。少し調べたが、Go でのラッパーもほとんど見つからなかった。WindowsAPI のようにシステムコールを発行して、Pure Go で実装できるのであろうか。わからない。

Go の実験的なリポジトリとして一部の cocoa の実装があったが NSPasteboard の仕組みは提供されていなかった。

https://github.com/golang/exp/blob/master/shiny/driver/gldriver/cocoa.go

が、以下の不思議なサイト1Objective-C を cgo でラップしている呼び出している面白い実装があった。

https://git.wow.st/gmp/clip

Go のランタイムの仕組みには cgo という仕組みが合って、C のコードを Go で呼び出すことができる。が、ビルド時に C コンパイラが必要になるため、ライブラリとして提供するのは筋が悪いように思えた。

gmp/clip では以下のようにして Objective-C のコードを Go で呼び出すことに成功している。

package ns

/*
#cgo CFLAGS: -x objective-c -fno-objc-arc
#cgo LDFLAGS: -framework AppKit -framework Foundation
#pragma clang diagnostic ignored "-Wformat-security"

#import <Foundation/Foundation.h>
#import <AppKit/NSPasteboard.h>

void
NSObject_inst_Release(void* o) {
        @autoreleasepool {
                [(NSObject*)o release];
        }
}

// ...

void* _Nullable
NSPasteboard_inst_GetString(void* o) {
        NSString* _Nullable ret;
        @autoreleasepool {
                ret = [(NSPasteboard*)o stringForType:NSPasteboardTypeString];
                if (ret != nil && ret != o) { [ret retain]; }
        }
        return ret;

}

*/
import "C"

import (
    "unsafe"
    "runtime"
)

// ...

実際 GitHub Actions で macOS の OS 上でのテストが通ったので、上記のコードは正常に動くだろう。手元に masOS のパソコンがないため実際には試しせていない。

しかし当然ながら cgo を用いているため、ライブラリとして組み込んだ場合に C コンパイラが要求される。README には記載しているがライブラリとしては筋が悪い気がする。macOS に詳しくないので何も言及できないが、 WindowsAPI のときと同様にシステムコールを自前で発行すれば、cgo を用いてなくても実装できるのではないか?

Linux

WindowsmacOS の仕組みとは異なり、Linuxカーネルそのものにはクリップボードの仕組みは提供されておらず、X11 に依存する。また Wayland (私は初めて知ったが)というディスプレイサーバプロトコルクリップボードを提供できるよう。

Linux だと xclipxsel というツールがよく使われるようで、実際 atotto/clipboard でも xclip, xsel のコマンドを叩くようになっている。

いずれもは C 言語で書かれていて、コマンドラインクリップボードのコピーとペーストを提供している。コードは 1000 行くらい。コードの読みやすさとしては xsel のほうが読みやすかった。

X11 では selection という概念があって、Selection を使ってウィンドウとデータを交換することができる。

クリップボードでは PRIMARY と CLIPBOARD と SECONDARY という selection を使うことができる

領域 説明
PRIMARY 引数を 1 つだけとるすべてのコマンドに対して使用され、セレクションのメカニズムを用いるクライアント間のコミュニケーションの主要な手段。PRIMARY でマウスのクリックでペーストできるような仕組みも実現している。
SECONDARY PRIMARY セレクションが存在して、ユーザがそれを乱したくないときに、データを獲得する手段として用いられる。
CLIPBOARD アンドペーストやコピーアンドペーストするデータを保持するのに用いる。最も馴染みがある領域。

というもの

いずれにしても X11 サーバに対してプロトコル通信する必要がある。cgo を使って既存の X クライアントの実装を移植するのも一つの手だが、大変である。既存の実装は 1000 行くらいでそんなに多くはないが、cgo 自体が大変そうで、メモリ管理などを C 同様に明示的に実装する必要があったり、その他いろいろありそう。最初の取っ掛かりとしては重すぎる。

調べていたら X クライアントの Go 実装が存在した。もともと Google のコードだったが、メンテされていないから移植したらしい。

https://github.com/BurntSushi/xgb

で、このライブラリを使った X のクリップボードの実装があった。リポジトリ自体は Go 製のテキストエディタを実装することを目的としている。

https://github.com/aarzilli/nucular

aarzilli/nucular の以下の実装を多少修正すればライブラリとしても使えるので、これを使うことにした。

未解決の問題

未解決の問題があって、X に値をセットしたときに、セットしたプログラムが終了してしまうと、セットした値はなくなってしまう。xclip や xsel ではどうしているかというと、プロセスを fork() して、fork した子プロセスから値をセットするようになっている。Wayland 用のクリップボードライブラリである wl-clipboard の実装もみてみたが、やはり fork() するようになっている。また、子プロセスは、selection へ他のプロセスから割り込まれた(Ctrl+C など)ときに SelectionClear のイベントを受け取って、プロセスが終了するようになっている。

  • デーモン化

Go では基本的に fork() の仕組みは提供されていないが os.Args[0] や裏技的にシステムコールを発行することはできる。

Goでデーモンを実装する

今回は sevlyar/go-daemon を用いてプロセスを生成した。単一のアプリケーションとして動作させる場合はうまく動作した。しかしライブラリとして組み込まれる場合は機能しなかった。これは os.Args[0] で外部プロセスを起動する仕組みであるために、他のリポジトリに組み込まれることは想定しておらず、組み込まれた場合は機能しない。VividCortex/godaemonsyscall.RawSyscall(syscall.SYS_FORK, 0, 0, 0) を実行する方法も試したが、結局思った動作は得られなかった。

  • イベントでプロセス終了

SelectionClear のイベントを受け取ったタイミングでプロセスが終了する実装は簡単である。channel と goroutine を組み合わせて、SelectionClear のイベントを受け取ったタイミングで doneCh <- struct{}{} などとし、待っている側のゴルーチンは <- doneCh としておけばよい。

まとめ

気軽にやってみよう、と取り組んでみたが xlib や Windows API など普段の業務よりも低レベルなプログラミングが要求されて、力不足を感じた。ないものは作る精神なので、低レベルなアプリも実装できる技術力を身につけていきたい。


  1. Go 製の GitHub 風のバージョン管理システムを用いている: