Rust+Tauri2+React+TS剪切板管理桌面端应用开发示例

随着Tauri2.0的发布,Tauri越来越值得关注,当然与名气更大的Electron相比仍有差距,但因其有Rust加持,仍表现出很大潜力,如果想开发【小而美】的桌面端App,Tauri是个不错的选择。

日常使用电脑的时候我们可能有这样的困扰:复制过的文字找不到,要是有个应用能存放复制过的内容并在需要时可以还原回剪切板就好了。本文以这个需求为例,使用Tauri2开发一个桌面端剪切板管理工具。

【1】🎈开发环境

  • win10及以上(含WebView2

  • requires rustc 1.77.2 or newer

    yaml 复制代码
     # 本机版本
     C:\Users\changfeng>rustc --version
     rustc 1.83.0 (90b35a623 2024-11-26)
  • node.js:Long Term Support (LTS) version

    bash 复制代码
     # 本机版本
     C:\Users\changfeng>node -v
     v18.20.3
  • @tauri-apps/cli:全局安装、局部安装可选

    • 全局:npm list -g @tauri-apps/cli
    • 局部(项目):yarn add -D @tauri-apps/cli@latest

【2】🎈项目创建及配置

🔑工程创建

bash 复制代码
 cd tauri_projects
 yarn create tauri-app
 ✔ Project name · clipy
 ✔ Identifier · com.clipy.app
 ✔ Choose which language to use for your frontend · TypeScript / JavaScript - (pnpm, yarn, npm, deno, bun)
 ✔ Choose your package manager · yarn
 ✔ Choose your UI template · React - (https://react.dev/)
 ✔ Choose your UI flavor · TypeScript

🔑后端依赖

ini 复制代码
 # 找到src-tauri\Cargo.toml,增加如下配置
 arboard = "3.4.1" # 添加该依赖项

🔑命令行工具集成

perl 复制代码
 # 按需选择局部安装还是全局安装
 npm list -g @tauri-apps/cli
 yarn add -D @tauri-apps/cli@latest

🔑镜像源配置

ini 复制代码
 # 找到src-tauri\Cargo.toml,增加如下配置
 [source.crates.io]
 replace = "https://mirros.tuna.tsinghua.edu.cn/git/crates.io-index"

🔑缓存目录设计

bash 复制代码
 # 找到src目录新建cache文件夹
 # 避免在src-tauri创建,因为每次写入都会重启应用,非所需

【3】🎈Rust后端代码

  • src-tauri\src\lib.rs
rust 复制代码
use std::{
    fs::{remove_file, File, OpenOptions},
    io::{BufReader, BufWriter},
    thread::{sleep, spawn},
    time::Duration,
};

use arboard::Clipboard;
use serde::{Deserialize, Serialize};
use tauri::ipc::Channel;

// 定义缓存数据结构体
#[derive(Serialize, Deserialize)]
struct ClipboardHistory {
    items: Vec<String>,
}

// 定义缓存文件信息(绝对路径)
const CACHE_PATH: &str = "D://tauri_projects//clipy//src//cache//clipboard_history.json";

// 清空操作
#[tauri::command]
fn wipe_all() {
    match remove_file(CACHE_PATH) {
        Ok(_) => println!("CACHE_PATH successfully removed."),
        Err(e) => eprintln!("Failed to remove CACHE_PATH: {}", e),
    }
}

// 复制操作
#[tauri::command]
fn copy(data: String) {
    let mut clipboard = Clipboard::new().unwrap();
    clipboard.set_text(&data).unwrap();
    println!("Copied text: \"{}\"", data);
}

// 加载缓存文件
fn load_history() -> Result<ClipboardHistory, std::io::Error> {
    let file: File = File::open(CACHE_PATH)?;
    let reader = BufReader::new(file);
    let history = serde_json::from_reader(reader)?;
    Ok(history)
}

// 保存历史记录
fn save_history(history: &ClipboardHistory) -> Result<(), std::io::Error> {
    // 打开一个文件用于写入
    let file = OpenOptions::new()
        .create(true) // 如果文件不存在则创建新文件
        .write(true) // 以写入模式打开文件
        .truncate(true) // 如果文件存在则清空文件内容
        .open(CACHE_PATH)?; // 打开指定路径的文件,如果失败则返回错误

    // 创建一个带缓冲的写入器
    let writer = BufWriter::new(file);

    // 将剪贴板历史记录序列化为 JSON 格式并写入文件
    serde_json::to_writer_pretty(writer, history)?;

    // 返回 Ok 表示操作成功
    Ok(())
}

// 提取history最近N个对象
#[tauri::command]
fn load_last_n_entries(n: usize) -> Vec<String> {
    if let Ok(history) = load_history() {
        history.items.into_iter().rev().take(n).collect()
    } else {
        vec![]
    }
}

// 线程循环监听剪切板
#[tauri::command]
fn init(on_event: Channel<String>) {
    spawn(move || {
        let mut clipboard = Clipboard::new().unwrap();
        loop {
            if let Ok(data) = clipboard.get_text() {
                let mut history =
                    load_history().unwrap_or_else(|_| ClipboardHistory { items: vec![] });
                if history
                    .items
                    .last()
                    .map(|last| last != &data)
                    .unwrap_or(true)
                {
                    history.items.push(data.clone());
                    save_history(&history).unwrap();
                    on_event.send(data).unwrap();
                }
            }
            sleep(Duration::from_secs(2));
        }
    });
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_opener::init())
        .invoke_handler(tauri::generate_handler![
            wipe_all,
            copy,
            load_last_n_entries,
            init,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

【4】🎈React+TS前端代码

🔑React代码

  • src\App.tsx
react 复制代码
import React,{ useEffect, useState } from "react";

import { Channel, invoke } from "@tauri-apps/api/core";

import './App.css'

const App:React.FC = () => {
  const [clipboardItems,setClipboardItems] = useState<string[]>([])
  const [filter,setFilter] = useState<number>(5)
  const [status,setStatus] = useState<string>("Loading")


  // 获取缓存数据
  const fetchClipboardHistory = async (n:number) => {
    try {
      const items:string[] = await invoke<string[]>("load_last_n_entries",{n})
      items && setClipboardItems(items)
      setStatus(items.length > 0 ? "Items update success" :"No clipboard items found!")
    } catch (error) {
      console.log("Error fetch clipboard history:",error)
      setStatus("Failed fetch clipboard history")
    }
  }

  // 将内容置入剪切板
  const copyToClipboard = async (data:string) => {
    try {
      await invoke("copy",{data})
    } catch (error) {
      console.log("Error copy to clipboard:",error)

    }
  }

  // 清空缓存历史记录
  const wipeAllClipboardHistory = async () => {
    console.log("点击了wipeAllClipboardHistory")
    try {
      await invoke("wipe_all")
      setClipboardItems([])
      setStatus("Clipboard history wiped")
    } catch (error) {
      console.log("Error wipe all clipboard history:",error)
    }
    console.log("执行完了wipeAllClipboardHistory")
  }


  useEffect(()=>{
    const initializeClipboard = async () => {
      try {
        const onEvent = new Channel<string>()
        onEvent.onmessage = (message:string)=>{
          console.log('on mounted current filter is:',filter)
          setClipboardItems((prevItems)=>
            [message, ...(prevItems.length >= filter?prevItems.slice(0,filter -1) :prevItems) ]
          )
        }

        await invoke("init",{onEvent})
        
      } catch (error) {
        console.error("Error initialize clipboard:",error)
      }
    }
    initializeClipboard()
    fetchClipboardHistory(filter)
  },[filter]); 

  return (
    <div className="app">
      <header className="app-header">
        <h1>Clipy</h1>
        <p>Manager your clipboard history with ease</p>
      </header>
      <main className="app-main">
        <div className="controls">
          <div className="filter-container">
            <label htmlFor={"filter"}>Show last:</label>
            <select name="" id="filter" value={filter} 
              onChange={(e)=> setFilter(Number(e.target.value))}
            >
              <option value={5}>5</option>
              <option value={10}>10</option>
              <option value={20}>20</option>
              <option value={50}>50</option>
            </select>
          </div>
          <button className="wipe-button" onClick={wipeAllClipboardHistory}>
            Wipe all
          </button>
        </div>
        {status && <p className="status">{status}</p>}
        <ul className="clipboard-list">
          {
            clipboardItems.map((item,index) => (
              <li className="clipboard-item" key={index}>
                <span className="item-text">{item}</span>
                <button className="copy-button" onClick={() => copyToClipboard(item)}>
                  Copy
                </button>
              </li>
            ))
          }
        </ul>
      </main>
    </div>
  )

}

export default App;

🔑App.css代码

  • src\App.css
css 复制代码
/* General Styles */
body {
  margin: 0;
  font-family: 'Arial', sans-serif;
  background-color: #f9f9f9;
  color: #333;
}

.app {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

.app-header {
  text-align: center;
  padding: 20px;
  background: #ffffff;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  border-bottom: 1px solid #e0e0e0;
}

.app-header h1 {
  font-size: 2.5rem;
  margin: 0;
  color: #007bff;
}

.app-header p {
  font-size: 1.2rem;
  margin: 10px 0 0;
  color: #666;
}

.app-main {
  flex: 1;
  padding: 20px;
  max-width: 800px;
  margin: 0 auto;
}

.controls {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.filter-container {
  display: flex;
  align-items: center;
}

.filter-container label {
  margin-right: 10px;
  font-size: 1rem;
  color: #555;
}

.filter-container select {
  padding: 5px 10px;
  font-size: 1rem;
  background-color: #ffffff;
  color: #333;
  border: 1px solid #ccc;
  border-radius: 5px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: border-color 0.3s ease;
}

.filter-container select:focus {
  border-color: #007bff;
  outline: none;
}

.wipe-button {
  padding: 10px 20px;
  font-size: 1rem;
  background-color: #ff4d4d;
  color: #ffffff;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: background-color 0.3s ease, box-shadow 0.3s ease;
}

.wipe-button:hover {
  background-color: #cc0000;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}

.clipboard-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.clipboard-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  background-color: #ffffff;
  padding: 15px 20px;
  margin-bottom: 10px;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: transform 0.2s ease, box-shadow 0.3s ease;
}

.clipboard-item:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}

.item-text {
  flex: 1;
  margin-right: 10px;
  font-size: 1rem;
  color: #333;
  word-break: break-word;
}

.copy-button {
  padding: 8px 15px;
  font-size: 1rem;
  background-color: #007bff;
  color: #ffffff;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
  transition: background-color 0.3s ease, box-shadow 0.3s ease;
}

.copy-button:hover {
  background-color: #0056b3;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}

.status {
  text-align: center;
  font-size: 1.2rem;
  color: #666;
  margin-bottom: 20px;
}

【5】🎈调试及打包

  • 调试:yarn tauri dev

  • 打包:yarn tauri build

    • src-tauri\target\release可以找到.exe文件

【6】🎈效果展示

整个开发过程还是非常顺畅的,感兴趣的小伙伴不妨试试。有任何问题欢迎交流~

相关推荐
牛奔4 小时前
Go 如何避免频繁抢占?
开发语言·后端·golang
想用offer打牌9 小时前
MCP (Model Context Protocol) 技术理解 - 第二篇
后端·aigc·mcp
崔庆才丨静觅9 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606110 小时前
完成前端时间处理的另一块版图
前端·github·web components
KYGALYX10 小时前
服务异步通信
开发语言·后端·微服务·ruby
掘了10 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅10 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅11 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
爬山算法11 小时前
Hibernate(90)如何在故障注入测试中使用Hibernate?
java·后端·hibernate
崔庆才丨静觅11 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端