Rust+Tauri2+React+TS剪切板管理桌面端应用开发示例
itomcoil 2025-05-14 14:05 11 浏览
随着Tauri2.0的发布,Tauri越来越值得关注,当然与名气更大的Electron相比仍有差距,但因其有Rust加持,仍表现出很大潜力,如果想开发【小而美】的桌面端App,Tauri是个不错的选择。
日常使用电脑的时候我们可能有这样的困扰:复制过的文字找不到,要是有个应用能存放复制过的内容并在需要时可以还原回剪切板就好了。本文以这个需求为例,使用Tauri2开发一个桌面端剪切板管理工具。
开发环境
- win10及以上(含WebView2)
- requires rustc 1.77.2 or newer
# 本机版本
C:\Users\changfeng>rustc --version
rustc 1.83.0 (90b35a623 2024-11-26)
- node.js:Long Term Support (LTS) version
# 本机版本
C:\Users\changfeng>node -v
v18.20.3
- @tauri-apps/cli:全局安装、局部安装可选
# 全局: npm list -g @tauri-apps/cli
# 局部(项目):yarn add -D @tauri-apps/cli@latest
项目创建及配置
工程创建
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
后端依赖
- 找到src-tauri\Cargo.toml,增加如下配置
arboard = "3.4.1" # 添加该依赖项
命令行工具集成
- 按需选择局部安装还是全局安装
npm list -g @tauri-apps/cli
yarn add -D @tauri-apps/cli@latest
镜像源配置
- 找到src-tauri\Cargo.toml,增加如下配置
[source.crates.io]
replace = "https://mirros.tuna.tsinghua.edu.cn/git/crates.io-index"
缓存目录设计
- 找到src目录新建cache文件夹
- 避免在src-tauri创建,因为每次写入都会重启应用,非所需
Rust后端代码
- src-tauri\src\lib.rs
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");
}
React+TS前端代码
React代码
- src\App.tsx
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
/* 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;
}
调试及打包
- 调试:yarn tauri dev
- 打包:yarn tauri build。打包后在src-tauri\target\release可以找到.exe文件
效果展示
整个开发过程还是非常顺畅的,感兴趣的小伙伴不妨试试。有任何问题欢迎交流~
相关推荐
- MariaDB开窗函数(开窗函数max)
-
在使用GROUPBY子句时,总是需要将筛选的所有数据进行分组操作,它的分组作用域是整张表。分组以后,为每个组只返回一行。而使用基于窗口的操作,类似于分组,但却可以对这些"组"(即窗口...
- 你还不知道什么是MySQL窗口函数?(mysql5.7窗口函数)
-
MySQL中的窗口函数是一类用来在某一部分查询结果上进行计算的函数,这些函数的用法与普通的聚合函数如SUM、AVG、COUNT类似,但是与聚合函数不同的是,窗口函数不会讲多行数据合并成一行结果,而是...
- 精通88道题包你面试通过BAT-精简版-不得不收藏!
-
J2SE基础1.九种基本数据类型的大小,以及他们的封装类。2.Switch能否用string做参数?3.equals与==的区别。4.Object有哪些公用方法?5.Java的四种引用,强弱...
- Transact-SQL学习笔记21——排名窗口函数
-
将OVER()子句和排名函数连用,就是排名窗口函数,它们只能用在SELECT子句或ORDERBY子句之后。如果放在SELECT之后,它运行的逻顺序在DISTINCT之前。逻辑处理顺序如下:SE...
- MySQL8 窗口函数是真的省事!(mysql中的窗口函数)
-
@[toc]MySQL9已经出来了,MySQL8相信也慢慢走进各位小伙伴的工作中了。MySQL8还是有很多重量级变化的,一些底层优化大家在使用中有时候不易察觉,但是有一些用法,还是带给我们耳目一...
- Lodash 这 20 个方法,既高级又超级实用!
-
一、安全操作篇1._.get:防御性取值2._.set:智能路径赋值3._.invoke:安全方法调用二、集合处理篇4._.keyBy:快速对象映射5._.orderBy:多条件排序6._...
- Oracle有哪些常见的函数?(oracle常用函数有哪些)
-
恢复删除的数据insertinto'表名'select*from'表名'asofTIMESTAMPTO_TIMESTAMP("当前时间...
- excel的高级用法——宏,原来如此实用
-
使用excel时,直接手动计算或者输入公式,你会感到很苦恼或者操作很繁琐,如果使用vba直接输出结果,虽然效率很高,但是不够直观。excel宏最方便的用法是作为公式里的函数使用,打开宏编辑器,编写一个...
- 7 RDD常用算子(2)(rd算法)
-
filter()deffilter(f:T=>Boolean):RDD[T]函数说明将数据根据指定的规则进行筛选过滤,符合规则的数据保留,不符合规则的数据丢弃。当数据进行筛选过滤后,分...
- 从零开始学SQL进阶,数据分析师必备SQL取数技巧,建议收藏
-
上一节给大家讲到SQL取数的一些基本内容,包含SQL简单查询与高级查询,需要复习相关知识的同学可以跳转至上一节,本节给大家讲解SQL的进阶应用,在实际过程中用途比较多的子查询与窗口函数,下面一起学习。...
- SQL窗口函数知多少?(sql窗口怎么执行)
-
我们在日常工作中是否经常会遇到需要排名的情况,比如:每个部门按业绩来排名,每人按绩效排名,对部门销售业绩前N名的进行奖励等。面对这类需求,我们就需要使用sql的高级功能——窗口函数。一、什么是窗口函数...
- SQL开窗函数讲解,让查询统计更简单
-
用了这么多关系型数据库产品,开源的商业的,如:Oracle、MySql(注意5.7以上版本才可以使用)、SqlServer、postgreSQL。如果从应用角度来看,谁都逃离不了增删改查;而查又是难点...
- mysql窗口函数(mysql窗口函数rank)
-
MySQL窗口函数是一种高级的SQL函数,它可以进行一些比较复杂的数据分析和处理。与传统的聚合函数不同,窗口函数不会合并行,而是根据特定的条件为每行分配一个值。MySQL窗口函数可以用来计算每...
- 一文讲懂SQL窗口函数 大厂必考知识点
-
大家好,我是宁一。今天是我们的第24课:窗口函数。窗口函数,也叫OLAP(OnlineAnallyticalProcessing,联机分析处理),可以对数据库数据进行实时分析处理。窗口函数是数据分...
- C++20 四大特性之一:Module 特性详解
-
C++20最大的特性是什么?最大的特性是迄今为止没有哪一款编译器完全实现了所有特性。文章来源:网易云信有人认为C++20是C++11以来最大的一次改动,甚至比C++11还要大。本文仅介绍...
- 一周热门
- 最近发表
- 标签列表
-
- ps图案在哪里 (33)
- super().__init__ (33)
- python 获取日期 (34)
- 0xa (36)
- super().__init__()详解 (33)
- python安装包在哪里找 (33)
- linux查看python版本信息 (35)
- python怎么改成中文 (35)
- php文件怎么在浏览器运行 (33)
- eval在python中的意思 (33)
- python安装opencv库 (35)
- python div (34)
- sticky css (33)
- python中random.randint()函数 (34)
- python去掉字符串中的指定字符 (33)
- python入门经典100题 (34)
- anaconda安装路径 (34)
- yield和return的区别 (33)
- 1到10的阶乘之和是多少 (35)
- python安装sklearn库 (33)
- dom和bom区别 (33)
- js 替换指定位置的字符 (33)
- python判断元素是否存在 (33)
- sorted key (33)
- shutil.copy() (33)