使用 Tauri 开发一个基于 Web 和 Rust 技术栈的跨平台桌面应用(Minecraft Server Player UUID Modifier)

2023-03-06 18:44:47 浏览数 (2)

使用 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 插件:

代码语言:javascript复制
{
  "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[]) 方法,并以如下算法计算的:

代码语言:javascript复制
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 函数:

代码语言:javascript复制
/*
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> 传入以解决问题:

代码语言:javascript复制
Array.from<number>(name)

最后,后端的主要代码大致如下:

代码语言:javascript复制
#![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)。

0 人点赞