使用 Tauri 开发一个基于 Web 和 Rust 技术栈的跨平台桌面应用(Minecraft Server Player UUID Modifier)
前言
前些天在某 IDC 售后群里潜水,看到很多 MC 服主都在为正盗版 UUID 转换发愁(如果您不理解的话,Minecraft 服务器可以被设置为正版和盗版两种验证模式,而在此两种模式下运行的服务器实例为玩家生成的唯一标识符,也即 UUID 是完全不同的,前者从 Minecraft 正版验证服务直接获取,后者由服务端以玩家 ID 直接生成 UUID v3),遂打算开发一款能够快速转换玩家 UUID 的桌面应用。
于是,在选择桌面应用技术栈时犯了难:我个人 WPF 开发不够熟练;Compose 性能又不太好,而且还有很多问题;Electron 又太臃肿...... 最后,一个叫做 Tauri 的跨平台桌面应用开发框架吸引了我 —— 其前端可以使用传统的前端三件套进行开发,后端则是使用 Rust 编写;在完全支持前端包管理器(npm/Yarn/pnpm)的同时也支持 Rust 的 Cargo;最令我惊叹的地方是,其二进制文件不需要打包一个臃肿的 CEF 框架,而是调用各操作系统的本地 WebView 框架(Windows 上是 Edge WebView 2 框架,MacOS 和 Linux 上是 Webkit 框架)显示 UI。
考虑到正好前几天学习了 Rust 开发,正好可以拿来练练手,于是决定使用 Tauri(前端 Vue,后端 Rust)开发这款 Minecraft Server Player UUID Modifier(MCSPUM)。
开始使用 Tauri 进行开发
要开始 Tauri 开发,必须进行一些前置准备工作,在 Tauri 的文档Prerequisites | Tauri Apps 中展示了如何部署前置框架。对于 Windows 来说,需要使用 Build Tools for Visual Studio 2022 部署指定 C++ 生成工具,安装 WebView 2 框架(如果操作系统未内置),然后安装 Rust;对于 MacOS 和 Linux,则需要安装各自的框架和 Rust。
随后,便可以使用喜欢的包管理器(亦或者不使用任何包管理器)快速部署 Tauri 模板程序,如Cargo(此部署方式不支持使用前端包管理器),npm/Yarn/pnpm(此部署方式同时支持对应前端包管理器和 Cargo 包管理器),Bash/PowerShell(此部署方式可以选择使用的包管理器)。
同时,Tauri 还可以兼容 Next.js,SvelteKit,Vite 等构建工具。
部署完成后,可以使用 npm run tauri dev
进入开发模式(热更新)或使用 bpm run tauri build
构建应用程序。
Tauri 使用一种很巧妙的方式令前端与后端交互,并支持错误处理和异步调用,前后端同时可以进行数据交换,只要该数据实现了 serde::Serialize
和/或 serde::Deserialize
特征。可以在 Calling Rust from the frontend | Tauri Apps 查看详细信息。
值得一提的是,Tauri 不支持交叉编译,但是,其提供了多种 GitHub Actions 配置文件来帮助你快速的在 GitHub Actions 构建可用于生成三个平台应用程序包的 CI。
除此之外,Tauri 还支持许多客制化功能,具体可在 Features | Tauri Apps 查看。
对于使用的 IDE 来讲,本来是想用 IDEA 进行开发的(可以同时支持前端和 Rust 开发),但是后来发现 IDEA 开发这种跨语言应用的体验实在不太行,遂改用 VSCode 开发。对于 VSCode,Tauri 也贴心的生成了 extensions.json
为你推荐所需的 VSCode 插件:
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}
回到正题:MCSPUM 是如何工作的
MCSPUM 在设计上就是前后端分离的 —— 前端仅用于显示 UI,所有逻辑计算均由 Rust 后端反馈(前端其实也做不了太多逻辑,因为前端并不包含 Node 环境)。前端看起来大概长这个样子:
你可以选择 UUID 转换模式,随后选择服务端根目录位置。MCSPUM 会读取服务端根目录的 usercache.json
文件以获得服务器内的玩家 ID 信息,然后通过调用后端接口获得离线/正版验证 UUID 显示给前端;然后,前端可以选择使用的转换选项,这决定了 UUID 转换的范围,包括世界文件,插件文件,数据库文件等;最后,点击开始转换后,前端会调用后端相关接口,并传入转换选项和待转换的 UUID,完成转换。
MCSPUM 开发过程中遇到了两个大坑,在这里简单说一下:
UUID v3 和 UUID#nameUUIDFromBytes(byte[])
Minecraft 离线玩家的 UUID 是调用 Java 的 UUID#nameUUIDFromBytes(byte[])
方法,并以如下算法计算的:
String playerName = ...;
String uuid = UUID.nameUUIDFromBytes("OfflinePlayer:"+playerName);
uuid
是一个 UUID v3 格式的 UUID,代表玩家的唯一标识符。而 UUID v3 可通过字符串等形式生成一个唯一 UUID。本以为生成 UUID 应该是很简单的,使用 uuid
库就可以了,结果我发现,所有 UUID 生成库都要求提供一个 namespace
,用以区分 UUID 使用范围,并在计算时带入,避免和其他数据发生碰撞。于是我开始寻找 Java 生成 UUID 使用的 namespace
,结果我发现...
java - What namespace does the JDK use to generate a UUID with nameUUIDFromBytes? - Stack Overflow
The
UUID.nameUUIDFromBytes()
method doesn't use a namespace to generate UUIDs v3. It just hashes the 'name' using MD5.
这可难为我了。不过还好最后,我仿照 Java 的生成算法自己实现了 name_uuid_from_bytes
函数:
/*
public static UUID nameUUIDFromBytes(byte[] name) {
MessageDigest md;
try {
md = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException nsae) {
throw new InternalError("MD5 not supported", nsae);
}
byte[] md5Bytes = md.digest(name);
md5Bytes[6] &= 0x0f;
md5Bytes[6] |= 0x30;
md5Bytes[8] &= 0x3f;
md5Bytes[8] |= 0x80;
return new UUID(md5Bytes);
}
*/
#[tauri::command]
pub fn name_uuid_from_bytes(name: Vec<u8>) -> String{
let mut md5_bytes = md5::compute(name).0;
md5_bytes[6] &= 0x0f;
md5_bytes[6] |= 0x30;
md5_bytes[8] &= 0x3f;
md5_bytes[8] |= 0x80;
let uuid = md5_bytes.into_iter()
.map(|x| format!("{:02x}", x))
.collect::<Vec<String>>().join("");
String::from("") + &uuid[..8] + "-" + &uuid[8..12] + "-" + &uuid[12..16] + "-" + &uuid[16..20] + "-" + &uuid[20..]
}
Vec<T>
, &[T]
和 Uint8Array
Tauri 使用 Serde 提供的序列化系统在前端和后端之间转换数据,正因如此,当前端使用 invoke
函数调用 rust 函数时,rust 可以正确接收函数参数并转换返回值给后端。
这里的坑是,Serde 无法正确将 JavaScript 数组转换为 &[T]
(T 类型切片),也无法将 TypeScript 的 Uint8Array
(无符号 Byte 数组)转换为 Vec<u8>
。
而前者的解决方案是,使用 Vec<T>
代替 &[T]
,Rust 可以正确将 JavaScript 数组转换为 Vec<T>
,而因为 Vec<T>
实现了 Deref<Vec<T>>
,因此可以被隐式转换为 &[T]
;
对于后者,可以将 UInt8Array
转换为 Array<number>
传入以解决问题:
Array.from<number>(name)
最后,后端的主要代码大致如下:
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
use std::{
collections::HashMap,
fs::{self},
io,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
mod calc;
mod file;
mod net;
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
convert,
file::open_dir_dialog,
file::read_usercache,
calc::name_uuid_from_bytes,
net::fetch,
net::fetch_post
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[derive(Serialize, Deserialize, Debug)]
struct Config {
#[serde(rename = "rootDir")]
root_dir: String,
#[serde(rename = "convertOptions")]
convert_options: Vec<String>,
uuids: HashMap<String, String>,
}
#[tauri::command]
fn convert(config: Config) -> Result<Vec<PathBuf>, String> {
let mut result = Vec::new();
for it in config.convert_options.iter() {
match it.as_str() {
"world" => {
convert_worlds(&config.root_dir, &config.uuids)
.into_iter()
.for_each(|it| {
result.extend(it);
});
}
"plugin_text" => {
convert_plugins(&config.root_dir, &config.uuids)
.into_iter()
.for_each(|it| {
result.extend(it);
});
}
_ => return Err(format!("Unknown convert option: {}", it)),
}
}
Ok(result)
}
fn convert_worlds(
root_dir: &str,
entries: &HashMap<String, String>,
) -> Result<Vec<PathBuf>, String> {
let worlds = scan_worlds(root_dir).map_err(|it| it.to_string())?;
let result = worlds
.iter()
.map(|it| rename_all_files_in_dir(it, entries))
.filter(|it| it.is_ok())
.flat_map(|it| it.unwrap())
.collect();
Ok(result)
}
fn convert_plugins(
root_dir: &str,
entries: &HashMap<String, String>,
) -> Result<Vec<PathBuf>, String> {
println!("{:?}", scan_plugins(root_dir));
let plugins = scan_plugins(root_dir).map_err(|it| it.to_string())?;
let mut result = Vec::new();
for it in plugins.iter() {
let file = rename_all_files_in_dir(it, entries).map_err(|it| it.to_string())?;
let dir = rename_all_dir(it, entries).map_err(|it| it.to_string())?;
let text = rename_all_text(it, entries).map_err(|it| it.to_string())?;
result.extend(file);
result.extend(dir);
result.extend(text);
}
Ok(result)
}
fn scan_worlds<P: AsRef<Path>>(path: P) -> io::Result<Vec<PathBuf>> {
let collect = fs::read_dir(path)?
.filter_map(|it| {
if let Ok(path) = it {
path.path().join("level.dat").exists().then(|| path.path())
} else {
None
}
})
.collect();
Ok(collect)
}
fn scan_plugins<P: AsRef<Path>>(path: P) -> io::Result<Vec<PathBuf>> {
let collect = fs::read_dir(path.as_ref().join("plugins"))?
.filter_map(|it| {
if let Ok(path) = it {
path.path().is_dir().then(|| path.path())
} else {
None
}
})
.collect();
Ok(collect)
}
fn rename_all_files_in_dir<P: AsRef<Path>>(
dir: P,
entries: &HashMap<String, String>,
) -> io::Result<Vec<PathBuf>> {
let mut result = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let rst = rename_all_files_in_dir(&path, entries)?;
result.extend(rst);
continue;
}
if let Some(name) = path.file_stem() {
if let Some(from) = entries.keys().find(|k| name.to_str().unwrap_or("") == *k) {
let new_path = path.with_file_name(
entries[from].clone()
+ &path.extension().map_or(String::from(""), |it| {
String::from(".") + it.to_str().unwrap()
}),
);
fs::rename(path, &new_path)?;
result.push(new_path);
} else {
continue;
}
}
}
Ok(result)
}
fn rename_all_dir<P: AsRef<Path>>(
dir: P,
entries: &HashMap<String, String>,
) -> io::Result<Vec<PathBuf>> {
let mut result = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
if let Some(name) = path.file_name() {
if let Some(from) = entries.keys().find(|k| name.to_str().unwrap_or("") == *k) {
let new_path = path.with_file_name(entries[from].clone());
fs::rename(path, &new_path)?;
result.push(new_path.clone());
let rst = rename_all_dir(new_path, entries)?;
result.extend(rst);
} else {
let rst = rename_all_dir(&path, entries)?;
result.extend(rst);
continue;
}
}
}
Ok(result)
}
fn rename_all_text<P: AsRef<Path>>(
dir: P,
entries: &HashMap<String, String>,
) -> io::Result<Vec<PathBuf>> {
let mut result = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let rst = rename_all_text(&path, entries)?;
result.extend(rst);
continue;
}
println!("Scanning text file: {:?}", path.clone());
let read = fs::read_to_string(path.clone());
// Slient ignore read_to_string error to skip binary files read
if read.is_err() {
continue;
}
let mut read = read.unwrap();
if entries.keys().filter(|k| read.contains(*k)).count() == 0 {
continue;
}
entries
.clone()
.iter()
.for_each(|(k, v)| read = read.replace(k, &v));
fs::write(path.clone(), read)?;
result.push(path);
}
Ok(result)
}
不得不说 Rust 的模式识别和错误处理还是非常强大的(这里 diss 一下 Go)。