4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-09-21

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

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

完成したものは以下。

image.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

機能について

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

作り方

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

Polling

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

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、つまりどのキーを押したらソフトがマウスの位置に表示されるのか、という部分です。

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側のプログラムは以下。

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時に呼んであげるだけです。

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

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

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

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時間くらいでしょうか。これくらいの規模のソフトなら、週末の趣味のプログラミングとしては最適だと思います。新人研修とかにも良いかも。

4
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?