自分用のクリップボード管理ツールをつくった（Windows, Mac, Linux対応）

メインのマシンはUbuntuなのですが、仕事上Windowsを使うこともあります。家ではMacbookAirを使うときも。仕事でLinux操作中にWindowsの端末とクリップボードを同期したいな、という思いがありました。

先に書いておくと、つくったソフトは公開しません。簡単につくれるので、皆が遊びでつくってみたいな、と思ったときの手引きとしてこの記事を書いています。

完成したものは以下。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679704/ce4c50a9-e55b-e301-f2c4-7fcaf7a7147b.png)

## どういう技術スタックでつくるのか

tauri + Reactで作成しました。electronは、どうしてもファイルサイズが嵩むのが嫌でした。あと仕事でelectronは使っているので、tauriで遊ぼうという気持ちでつくっています。ReactかSvelteかは悩みましたが、自分が慣れ親しんでいるReactにしました。ElectronならSvelteでつくったかもしれません（単純にSvelteを学び直しながらtauriも勉強するのが面倒くさかった）。

## どういう仕組みでクリップボードを同期するか

1秒ごとに現在のクリップボードを確認し、前回と異なっていた場合にDBへ突っ込みます。今回、DBはsupabaseを使用しました。理由はフロントエンドから非常に簡単にアクセスできるからです。また、1秒ごとに監視する部分はReact内でsetIntervalを使用することによって書かれています。

もともと、OSごとにNativeなAPIにアクセスしてクリップボードが変更されたイベントを検知しようか、と考えていました。しかし、OSごとの処理を考えるのが面倒くさいのでやめました。次にRust側でクリップボードを1秒ごとに監視しようかと思いましたが、そもそも、そこまで重い処理ではないだろうし、結局取得したデータをsupabaseに投げる部分がReact側になるので、最終的にはReact側でクリップボードを監視することにしました。ここでちょっと重いようならRust側で書こうと考えていましたが、いまのところCPU使用率もメモリ使用量も少ないです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679704/fc8ac0f2-c1a6-62b8-89ef-6e1836fba83f.png)


## 機能について

Ctrl(Macはcommand)+Shift+Vでソフトがマウスの位置に表示されます。そこで選択したテキストが現在のクリップボードに入ります。あとはtemplateという、いわゆる定型文の機能もあります。一般的なクリップボード拡張ツールですが、インストールしている全端末でクリップボードが共有できるのが特徴です。おそらく探せば世の中にはあると思いますが、自前のDBに保存されているので非常にセキュアだと言えます。あとはデザインでしょうか。自分好みのデザインでつくれるので、そこが気に入ってます。

## 作り方

それでは実際に作り方を簡単に説明していきます。

### Polling
クリップボードを監視して、変更があるかどうかを検知します。

```tsx:Polling.tsx
import { useInterval } from "@chakra-ui/react";
import { readText } from "@tauri-apps/api/clipboard";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { supabase } from "../../utils/supabase";
import { clipboardListAtom } from "../Clipboard/stores/clipboardStore";

export const Polling = () => {
	const [prev, setPrev] = useState("");
	const clipboardList = useAtomValue(clipboardListAtom);
	useInterval(() => {
		const polling = async () => {
			const clipboard = await readText();
			const clipboardListTextList = clipboardList.map((c) => c.text);
			if (
				clipboard !== prev &&
				clipboard !== null &&
				clipboardListTextList.includes(clipboard) === false
			) {
				await supabase.from("clipboard_histroy").insert({
					text: clipboard,
					user_id: "test-user-id",
				});
				setPrev(clipboard);
			}
		};
		polling();
	}, 1000);

	useInterval(
		() => {
			// 10分に1回、clipboardの件数が100件を超えたら古いデータを削除する
			const del = async () => {
				const { data, error } = await supabase
					.from("clipboard_histroy")
					.select("id")
					.order("created_at", { ascending: true })
					.range(100, 1000);
				if (error) {
					console.error(error);
					return;
				}
				if (data) {
					const ids = data.map((d) => d.id);
					await supabase.from("clipboard_histroy").delete().in("id", ids);
				}
			};
			del();
		},
		1000 * 60 * 10,
	);

	return null;
};
```

useIntervalで1秒毎にtauriのclipboard, readText関数を呼び出しています。そして前回と異なる場合にsupabaseへ入れています。もう少し複雑なプログラムになりそうであればTanstack Queryなどを使用したかと思いますが、どうせクリップボードと定型文だけなのでjotaiで管理することにしました。

### GlobalShortcut
 次はGlobalShortcut、つまりどのキーを押したらソフトがマウスの位置に表示されるのか、という部分です。

```tsx:GlobalShortcut.tsx
import { invoke } from "@tauri-apps/api";
import { isRegistered, register } from "@tauri-apps/api/globalShortcut";
import { LogicalPosition, appWindow } from "@tauri-apps/api/window";
import { useSetAtom } from "jotai";
import { useEffect } from "react";
import { supabase } from "../../utils/supabase";
import { clipboardListAtom } from "../Clipboard/stores/clipboardStore";
import { templateAtom } from "../Template/stores/templateStore";

export const GlobalShortcut = () => {
	const setClipboard = useSetAtom(clipboardListAtom);
	const setTemplate = useSetAtom(templateAtom);
	useEffect(() => {
		const init = async () => {
			const { data } = await supabase
				.from("clipboard_histroy")
				.select("*")
				.order("created_at", { ascending: false })
				.limit(30);
			if (data) {
				setClipboard(data);
			}
			const { data: data2 } = await supabase
				.from("template")
				.select("*")
				.order("created_at", { ascending: false })
				.limit(30);
			if (data2) {
				setTemplate(data2);
			}
		};
		const asfn = async () => {
			const isRegist = await isRegistered("CommandOrControl+Shift+v");
			if (isRegist) return;
			await register("CommandOrControl+Shift+v", async () => {
				const cursorPosition = (await invoke("get_cursor_position")) as {
					x: number;
					y: number;
				};
				await appWindow.show();
				await appWindow.setFocus();
				await appWindow.setAlwaysOnTop(true);
				await appWindow.setPosition(
					new LogicalPosition(cursorPosition.x, cursorPosition.y),
				);
				const { data } = await supabase
					.from("clipboard_histroy")
					.select("*")
					.order("created_at", { ascending: false })
					.limit(30);
				if (data) {
					setClipboard(data);
				}

				const { data: data2 } = await supabase
					.from("template")
					.select("*")
					.order("created_at", { ascending: false })
					.limit(30);
				if (data2) {
					setTemplate(data2);
				}
			});
		};
		asfn();
		init();
	}, [setClipboard, setTemplate]);
	return null;
};
```

仕事で使うソフトではないので、data2とか変数名横着していますが……。簡単に解説すると、tauriのregisterという関数で、あるキーの組み合わせが押されたときに処理が開始されます。そこで現在のカーソル位置を取得し、隠されていたappWindowが表示されるという処理になっています。ついでにその瞬間にsupabaseから最新のクリップボードを取得します。Tanstack Queryなどを使用するときは、focus時にrefetcとしてあげたら楽だと思います。invokeで呼んでいるrust側のプログラムは以下。

```rust:main.rs
use mouse_position::mouse_position::Mouse;
use serde::{Deserialize, Serialize};

#[derive(Serialize, Deserialize)]
struct CursorPosition {
    x: i32,
    y: i32,
}

#[tauri::command]
fn get_cursor_position() -> CursorPosition {
    let m = Mouse::get_mouse_position();
    match m {
        Mouse::Position { x, y } => CursorPosition { x, y },
        Mouse::Error => CursorPosition { x: 0, y: 0 },
    }
}
```

mouse_positionというcrateがあったので、それをそのまま使っています。今回のソフトでrustを書いたのはこの箇所だけです。globalShortcutやwindowを使用するときはtauri.conf.jsonを編集する必要があるところは注意です。

### クリップボードに入れる処理
さっきのreadTextと同じようにwriteTextという関数があるので、button click時に呼んであげるだけです。

```tsx
const onClickHandler = async (text: string) => {
	await writeText(text);
	await appWindow.hide();
};
```

## クロスプラットフォームビルド

全OSのマシーンを持っているので各端末でビルドしてもいいのですが、便利なGithubActionがあるのでそれを使います。

```yml:action.yml
name: "publish"

on:
  push:
    branches:
      - release

# This workflow will trigger on each push to the `release` branch to create or update a GitHub release, build your app, and upload the artifacts to the release.

jobs:
  publish-tauri:
    permissions:
      contents: write
    strategy:
      fail-fast: false
      matrix:
        include:
          - platform: "macos-latest" # for Arm based macs (M1 and above).
            args: "--target aarch64-apple-darwin"
          - platform: "macos-latest" # for Intel based macs.
            args: "--target x86_64-apple-darwin"
          - platform: "ubuntu-22.04" # for Tauri v1 you could replace this with ubuntu-20.04.
            args: ""
          - platform: "windows-latest"
            args: ""

    runs-on: ${{ matrix.platform }}
    steps:
      - uses: actions/checkout@v4

      - name: setup node
        uses: actions/setup-node@v4
        with:
          node-version: lts/*

      - name: install Rust stable
        uses: dtolnay/rust-toolchain@stable
        with:
          # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds.
          targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}

      - name: install dependencies (ubuntu only)
        if: matrix.platform == 'ubuntu-22.04' # This must match the platform value defined above.
        run: |
          sudo apt-get update
          sudo apt-get install -y libwebkit2gtk-4.0-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
        # webkitgtk 4.0 is for Tauri v1 - webkitgtk 4.1 is for Tauri v2.
        # You can remove the one that doesn't apply to your app to speed up the workflow a bit.

      - name: install pnpm
        run: npm install -g pnpm

      - name: install frontend dependencies
        run: pnpm install # change this to npm, pnpm or bun depending on which one you use.

      - uses: tauri-apps/tauri-action@v0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tagName: app-v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version.
          releaseName: "App v__VERSION__"
          releaseBody: "See the assets to download this version and install."
          releaseDraft: true
          prerelease: false
          args: ${{ matrix.args }}

```

これでreleaseブランチにpushする度、自動的にWindows, Linux, Macでアプリをビルドしてくれます。便利。

作成にかかった時間は、tauriのdocumentを全部ざっと読むのを含めて、およそ5時間くらいでしょうか。これくらいの規模のソフトなら、週末の趣味のプログラミングとしては最適だと思います。新人研修とかにも良いかも。