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】🎈效果展示

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

相关推荐
晓风伴月2 分钟前
Css:overflow: hidden截断条件‌及如何避免截断
前端·css·overflow截断条件
最新资讯动态5 分钟前
使用“一次开发,多端部署”,实现Pura X阔折叠的全新设计
前端
风象南19 分钟前
Spring Boot 实现文件秒传功能
java·spring boot·后端
橘猫云计算机设计19 分钟前
基于django优秀少儿图书推荐网(源码+lw+部署文档+讲解),源码可白嫖!
java·spring boot·后端·python·小程序·django·毕业设计
爱泡脚的鸡腿20 分钟前
HTML CSS 第二次笔记
前端·css
黑猫Teng23 分钟前
Spring Boot拦截器(Interceptor)与过滤器(Filter)深度解析:区别、实现与实战指南
java·spring boot·后端
小智疯狂敲代码25 分钟前
Java架构师成长之路-框架源码系列-整体认识Spring体系结构(1)
后端
星河浪人29 分钟前
Spring Boot启动流程及源码实现深度解析
java·spring boot·后端
灯火不休ᝰ36 分钟前
前端处理pdf文件流,展示pdf
前端·pdf
智践行37 分钟前
Trae开发实战之转盘小程序
前端·trae