初识 Tauri 篇五之 App 基建

983 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第5天,点击查看活动详情

前言

本章主要讲述笔者个人在编写 Tauri 应用中遇到的认为值得记录的 App 基础知识,欢迎补充与指正,后续也会根据自己开发过程中的新收获及时更新。

Rust 相关

String 与 str 转换

可参考 Rust String &str 常用操作

// &str to String
let message:&str = "Hello, world!";
let message_String: String = String::from(message);
let message_String: String = message.to_string();
let message_String: String = message.to_owned();

// String to &str
let message_str: &str = &message_String;
let message_str: &str = message.as_str();

数组与 Vec

可参考 使用 Vector 储存列表

// 数组 [],长度固定,元素类型不一定相同,数据线性存储
let rust_arr: [u8;5] = [1, 2, 3, 4, 5]; // 指定类型为 u8,指定长度为 5
let index_of_third = rust_arr[2];       // 通过 index 下标访问值
let part_of_arr = &rust_arr[0..2];      // 数组切片,参数不可为负数

// 动态数组Vec, 长度不固定,元素类型相同,数据线性存储
let rust_vec: Vec<u8> = Vec::new();     // 指定类型为 u8
let rust_vec = vec![1, 2, 3, 4, 5];     // 带值创建 vec
let part_of_vec = &vec[2];              // 通过 index 下标访问值
let part_of_vec = rust_vec.get(2);      // 通过 index 下标访问值

rust_vec.push(1);

// vec 遍历
for (index, value) in rust_vec.iter().enumerate() {
    ...
}

// 可改动元素的遍历
for item in &mut rust_vec {
    ...
}

// 删除部分元素的遍历
rust_vec.retain(|value| -> bool {
    ...
    return false; // 删除该元素
    return true; // 保留该元素
})

结构体打印

#[derive(Debug)]
pub struct TodoList {
    pub list: HashMap<String, String>
}

println!("{:?}", TodoList);

Clone or Copy

可参考 复制值的 CloneCopy

当变量的值需要多处使用而引用不方便时可以使用 Clone

Copy 以笔者目前的浅薄见识来说并不推荐使用,见 What is the difference between Copy and Clone?Rust 当自定义结构体含 String 类型时 无法 derive Copy 应该怎么解决?

#[derive(Debug, Clone)]
pub struct TodoList {
    pub list: HashMap<String, String>
}

let todo_list: TodoList = TodoList { list: Hashmap::new() };
let todo_list_clone: TodoList = todo_list.clone();

pritnln!("{:?}", todo_list);
pritnln!("{:?}", todo_list_clone);

文件读写

可参考 Rust std::fs::OpenOptions

// 读文件,如不存在则创建
use std::fs::{ File, OpenOptions, read_to_string };

let file_path: &str = "xxxxxx";
let mut target_file: File = OpenOptions::new().read(true).write(true).create(true).open(file_path).unwrap();

// 获取文件长度
let file_size: u64 = target_file.metadata().unwrap().len();

let content: &str = "yyyyyyyyyyy";
// 覆盖式写文件
let write_size: usize = target_file.write(content).unwrap();
// 追加式写文件
let mut target_file: File = OpenOptions::new().append(true).open(file_path).unwrap();

// 读文件
let file_content: String = read_to_string(file_path).unwrap();

调用 shell

可参考 std::process::Command

// 此处以打开文件夹为例
pub fn open_folder_window(path: &str) {
  debug!("try to open {} window", path);
  if consts::OS == "linux" {
    Command::new("xdg-open")
            .arg(path)
            .spawn()
            .unwrap();
  } else if consts::OS == "macos" {
    Command::new("open")
            .arg(path)
            .spawn()
            .unwrap();
  } else if consts::OS == "windows" {
    Command::new("open")
            .arg(path)
            .spawn()
            .unwrap();
  }
}

第三方包管理

下述方法不唯一,可自行决定合适的方式, 注意,模块名称最好是 snake case 而不是驼峰

/*
src
 | modules
      | mod.rs
      | explorer.rs
      | collector.rs
 | my_types.rs
 | main.rs
*/
// main.rs 调用 my_types.rs
// in my_types.rs
pub struct Person {
    pub name: String,
    pub age: u8,
}

// in main.rs
mod my_types;

use my_types::Person;

fn main() {
    let jack: Person = Person { name: "Jack".to_string(), age: 18 };
    println!("Hello, {}", jack.name);
}


// main.rs 调用 modules 中 explorer.rs 或 collector.rs
// modules/mod.rs
pub mod explores;
pub mod collector;

// explorer.rs
pub fn greet() {
    println!("Hello from explorer");
}

// collector.rs
pub fn greet() {
    println!("Hello from explorer");
}

// main.rs
mod modules;

use modules::explorer::greet as ex_greet;
use modules::collector::greet as co_greet;

fn main() {
    ex_greet();
    co_greet();
}


// 同级调用, explores.rs 调用 collector.rs
// explorer.rs
use super::collector::greet as co_greet;

pub fn greet() {
    co_greet();
    println!("Hello from explorer");
}

本地修改第三方库

有时候第三方库总会有些问题,fork 后修改再拉取也很麻烦,能不能直接修改它的本地版本然后直接用呢?当然是可以的,这里我们以 Windows 为例

cd C:\Users\(Username)\.cargo

// 如果你的依赖是这样引入的
[dependencies.tauri-plugin-highlander]
git = "https://github.com/ioneyed/tauri-plugin-highlander"
branch = "main"

// 那你可以在这里找到对应的本地源文件并修改
ls ./git/checkouts
> tauri-plugin-highlander-3a9ec0026e0d0fde

// 其他的引入方式根据你设置的来源找到本地源文件,比如这里有官方源和清华源等,进入对应目录修改文件即可
ls ./git/registry/src
> mirrors.ustc.edu.cn-61ef6e0cd06fb9b8
> mirrors.tuna.tsinghua.edu.cn-df7c3c540f42cdbd

日志功能

可参考 crate log4rsAdvanced logging in rust with log4rs: rotation, retention, patterns, multiple loggers

use log4rs::{
    append::{
        console::{ ConsoleAppender, Target },
        rolling_file::{
            RollingFileAppender,
            policy::compound::{
                CompoundPolicy,
                roll::fixed_window::FixedWindowRoller,
                // roll::delete::DeleteRoller,
                trigger::size::SizeTrigger,
            }
        }
    },
    config::{ Appender, Config, Root },
    encode::pattern::PatternEncoder,
    filter::threshold::ThresholdFilter,
};
use log::{ debug, LevelFilter };
use path_absolutize::*;
use std::path::Path;

/**
 * 初始化日志功能
 * 更多格式详见 https://docs.rs/log4rs/latest/log4rs/encode/pattern/
 */
pub fn initialize_logger(log_file_path: &str) {
    // 定义不同日志等级
    let global_log_level = LevelFilter::Debug;
    let stdout_log_level = LevelFilter::Debug;
    let logfile_log_level = LevelFilter::Info;
    let strong_level_log_level = LevelFilter::Warn;

    // 定义日志输出格式
    let log_pattern = "{d(%Y-%m-%d %H:%M:%S)} | {({l}):5.5} | {m}{n}";

    // 定义分日志文件触发体积,目前设定为主日志达到 6Mb 后分离为独立文件
    let trigger_size = byte_unit::n_mb_bytes!(6) as u64;
    let trigger = SizeTrigger::new(trigger_size);

    // 滚动式更新日志文件
    let roller_pattern = "logs/log - {}.log"; // 指定滚动生成的日志文件路径及名称 1.log 2.log ...
    let roller_count = 3; // 一共将存在的日志文件数量, 超过将会把最新的日志信息覆盖式写入到最早的日志文件中,如 3.log 到了 6 Mb,接下来的日志将输出到 1.log 中
    let roller_base = 1;  // 滚动生成日志文件的 index 即上述 roller_pattern 中 {} 值,递增,默认为 0
    let roller = FixedWindowRoller::builder()
                .base(roller_base)
                .build(roller_pattern, roller_count).unwrap();

    let strong_level_roller = FixedWindowRoller::builder()
                .base(roller_base)
                .build(roller_pattern, roller_count).unwrap();

    // 达到 trigger_size 后直接删除旧日志文件,并创建空的新日志文件写入
    // let delete_roller = DeleteRoller::new();

    // 定义不同输出目标的文件更新策略
    let log_file_compound_policy = CompoundPolicy::new(Box::new(trigger), Box::new(roller));
    let strong_level_compound_policy = CompoundPolicy::new(Box::new(trigger), Box::new(strong_level_roller));
    // let compound_policy = CompoundPolicy::new(Box::new(trigger), Box::new(delete_roller));

    // 输出日志到日志文件,若指定日志文件路径不存在则创建
    let log_file = RollingFileAppender::builder()
                    .encoder(Box::new(PatternEncoder::new(log_pattern)))
                    .build(log_file_path, Box::new(log_file_compound_policy))
                    .unwrap();

    // 输出 warn 和 error 等级到新文件
    let absolute_path = Path::new(log_file_path).absolutize().unwrap();
    let parent_path = absolute_path.parent().unwrap();
    let strong_level_path = parent_path.join("strong_level.log").into_os_string().into_string().unwrap();
    let strong_level_log_file = RollingFileAppender::builder()
                    .encoder(Box::new(PatternEncoder::new(log_pattern)))
                    .build(strong_level_path, Box::new(strong_level_compound_policy))
                    .unwrap();

    // 输出日志到 stdout 即终端
    let stdout = ConsoleAppender::builder()
                    .encoder(Box::new(PatternEncoder::new(log_pattern)))
                    .target(Target::Stdout)
                    .build();
    
    // 设置 log4rs
    let config = Config::builder()
                    .appender(
                        Appender::builder()
                            .filter(Box::new(ThresholdFilter::new(logfile_log_level)))
                            .build("log_file", Box::new(log_file)))
                    .appender(
                        Appender::builder()
                        .filter(Box::new(ThresholdFilter::new(strong_level_log_level)))
                            .build("strong_level", Box::new(strong_level_log_file))
                    )
                    .appender(
                        Appender::builder()
                            .filter(Box::new(ThresholdFilter::new(stdout_log_level)))
                            .build("stdout", Box::new(stdout)),
                    )
                    .build(
                        Root::builder()
                            .appender("stdout")
                            .appender("log_file")
                            .appender("strong_level")
                            .build(global_log_level))
                    .unwrap();

    let _log_handler = log4rs::init_config(config).unwrap();

    debug!("Programe logger initialize success");  
}

时间

可参考 Crate: chrono

// cargo.toml
[dependencies]
...
chrono = "0.4"

// 获取当前时间
use chrono::{Utc, Local, DateTime};

let current_utc_time = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
let current_local_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();

// 加减时间, 以天为单位
let tommorrow_date = NaiveDate::parse_from_str(start_date, "%Y-%m-%d").unwrap() + Duration::days(1);

// 获取一个月天数
fn get_days_from_month(year: i32, month: u32) -> i64 {
    NaiveDate::from_ymd(
        match month {
            12 => year + 1,
            _ => year,
        },
        match month {
            12 => 1,
            _ => month + 1,
        },
        1
    )
    .signed_duration_since(NaiveDate::from_ymd(year, month, 1))
    .num_days()
}

Tauri 相关

系统托盘

可参考 Tauri: System Tray

// tauri.config.json
{
  "tauri": {
    "systemTray": {
      "iconPath": "icons/icon.png",
      "iconAsTemplate": true
    }
  }
}

// 创建系统托盘
pub fn create_system_tray() -> SystemTray {
    let quit = CustomMenuItem::new("quit".to_string(), "Quit Program"); // 创建 quit 选项,quit 为对应项目 id,Quit Program 为实际显示文本
    let hide = CustomMenuItem::new("hide".to_string(), "Close to tray");// 创建 hide 选项,hide 为对应项目 id,Close to tray 为实际显示文本
    let tray_menu = SystemTrayMenu::new()
      .add_item(hide)
      .add_native_item(SystemTrayMenuItem::Separator) // 添加项目间分隔线
      .add_item(quit);
    SystemTray::new().with_menu(tray_menu)
}

// 处理托盘事件
pub fn handle_system_tray_event(app: &AppHandle<Wry>, event: SystemTrayEvent) {
    match event {
        // 匹配项目双击事件
        SystemTrayEvent::DoubleClick { position: _ , size: _, .. } => {
            let window = app.get_window("main").unwrap();
            window.unminimize().unwrap();
            window.show().unwrap(); 
            window.set_focus().unwrap();
        },
        // 匹配项目单击事件,根据 id 区分不同行为
        SystemTrayEvent::MenuItemClick { id, ..} => {
            match id.as_str() {
              "quit" => {
                app.get_window("main").unwrap().hide().unwrap();
                app.emit_all("close", {}).unwrap();
                debug!("System tray try to close");
              }
              "hide" => {
                app.get_window("main").unwrap().hide().unwrap();
                app.emit_all("hide", {}).unwrap();
                debug!("System tray try to hide/show");
              }
              _ => {}
            }
        }
        _ => {}
    }
}

// 通过 command 国际化托盘文本
#[command]
pub fn change_system_tray_lang(lang: &str, app_handle: tauri::AppHandle) -> bool {
  let window = app_handle.get_window("main").unwrap();
  
  let hide_item = app_handle.tray_handle().get_item("hide");
  let quit_item = app_handle.tray_handle().get_item("quit");

  if &lang == &"zh-CN" {
    let hide_title = if !window_visiable {"最小化至托盘"} else {"显示主界面"};
    let quit_title = r"退出程序";
    
    hide_item.set_title(format!("{}", hide_title)).unwrap();
    quit_item.set_title(format!("{}", quit_title)).unwrap();
  } else if &lang == &"en-US" {
    let hide_title = if !window_visiable {"Close to tray"} else {"Show MainPage"};
    let quit_title = r"Quit Program";

    hide_item.set_title(format!("{}", hide_title)).unwrap();
    quit_item.set_title(format!("{}", quit_title)).unwrap();  
  }

  debug!("Change sysyem tray to lang {}", lang);
    
  true
}

fn main() {
    tauri::Builder::default()
        .system_tray(create_system_tray())
    	.on_system_tray_event(handle_system_tray_event)
        .run(tauri::generate_context!())
        .expect("error while running tauri application");    
}

去除顶部状态栏

可参考 Tauri: Window Customization

// tauri.config.json
{
  "tauri": {
    "windows": {
      ...
      "decorations": false
    }
  }
}
// TitleBar.vue
<template>
  <div data-tauri-drag-region class="titlebar">
    <div class="titlebar-left" id="titlebar-left">
        <div class="titlebar-icon" id="titlebar-icon">
            <el-icon class="app-icon">
                <AppIcon />
            </el-icon>
        </div>
        <div class="titlebar-title" id="titlebar-title">
            {{t('general.AppTitle')}}
        </div>        
    </div>
    <div class="titlebar-right" id="titlebar-right">
        <div class="titlebar-button" id="titlebar-minimize">
            <el-icon><SemiSelect /></el-icon>
        </div>
        <div class="titlebar-button" id="titlebar-maximize">
            <el-icon><BorderOutlined /></el-icon>
        </div>
        <div class="titlebar-button" id="titlebar-close">
            <el-icon><CloseBold /></el-icon>
        </div>        
    </div>
  </div>
</template>

<script lang="ts" setup>
import { onMounted } from 'vue';
import { appWindow } from '@tauri-apps/api/window';
import { AppstoreOutlined, BorderOutlined } from '@vicons/antd';
import { SemiSelect, FullScreen, CloseBold } from '@element-plus/icons-vue';
import AppIcon from '~icons/icons/favicon';

onMounted(() => {
    (document.getElementById('titlebar-minimize') as HTMLElement).addEventListener('click', () => appWindow.minimize());
    (document.getElementById('titlebar-maximize') as HTMLElement).addEventListener('click', () => appWindow.toggleMaximize());
    (document.getElementById('titlebar-close') as HTMLElement).addEventListener('click', () => appWindow.close());
})
</script>

<style lang="less" scoped>
@import "../assets/style/theme/default-vars.less";
.titlebar {
  height: 30px;
  color: var(--el-text-color-regular);
  background-color: var(--el-bg-color);
  user-select: none;
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 999;

  .titlebar-left {
    display: flex;
    flex-direction: row;

    .titlebar-icon {
        width: 30px;
        height: 30px;
        padding-top: 5px;
        padding-left: 5px;

        .app-icon {
            font-size: 20px;
        }
    }

    .titlebar-title {
        padding-top: 6px;     
    }
  }

  .titlebar-right {
    display: flex;
    flex-direction: row;
    
    .titlebar-button {
    display: inline-flex;
    justify-content: center;
    align-items: center;
    width: 30px;
    height: 30px;
    }
    .titlebar-button:hover {
        background: var(--el-border-color);
    }    

    #titlebar-close:hover {
        background: red;
    }
  }
}
</style>

添加窗口阴影

可参考 window-shadows,注意:暂不支持 Linux

use window_shadows::set_shadow;

pub fn initialize_window_shadow(window: &TauriWindow, is_shadow_enable: bool) {
  if let Err(e) = set_shadow(window, is_shadow_enable) {
    error!("Failed to add native window shadow, errMsg: {:?}", e);
  }
}

fn main() {
    tauri::Builder::default()
        .setup(|app| {
              let main_window = app.get_window("main").unwrap();
              initialize_window_shadow(&main_window, true);

              Ok(())
    	})
        .invoke_handler(tauri::generate_handler![
            add_todo,
            delete_todo,
            start_heart_rate
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");    
}

限制多实例运行(暂不可用)

可参考 Tauri Plugin HighlanderSingle Instance

⚠️ tauri-plugin-highlander 目前无法通过 Github Actions 下 Ubuntu 和 MacOS 环境的编译,已提交相关 pr 修复,此处使用修复后的分支

❌ single_instance 目前在纯 rust 环境下表现良好,在 tauri 环境下观察到所有实例均为 single,暂未查明原因

// tauri-plugin-highlander
// cargo.toml
[dependencies.tauri-plugin-highlander]
git = "https://github.com/Hellager/tauri-plugin-highlander"
branch = "fix-platform-build-error"

// main.rs
use tauri_plugin_highlander::*;
use tauri::Wry;

pub fn initialize_plugin_highlander(event_name: &str) -> Highlander<Wry> {
    HighlanderBuilder::default().event(event_name.to_string()).build()
}

fn main() {
    let plugin_highlander = initialize_plugin_highlander("another_instance");
    
    tauri::Builder::default()
        .plugin(plugin_highlander)
        .run(tauri::generate_context!())
        .expect("error while running tauri application");    
}


// single-instance
// cargo.toml
single-instance = "0.3"

// xxx.rs
pub fn check_instance(name: &str) {
  let instance = SingleInstance::new(name).unwrap();
  debug!("Check instance {} whether single {}", name, instance.is_single());
  if !instance.is_single() {
    debug!("{} is not single", name);
    std::process::exit(0);
  } else {
      debug!("{} is single", name);
  }
}

拦截 app 请求

pub fn handle_app_event(_app_handle: &AppHandle<Wry>, event: RunEvent) {
  match event {
      RunEvent::Exit => {}
      RunEvent::ExitRequested { .. } => {}
      RunEvent::WindowEvent { label, event, .. } => {
          match event {
              WindowEvent::CloseRequested { api, .. } => {
                  // 拦截 main 窗口发起的关闭 event, 并发送 close 事件至前端,交给前端处理
                  if label == "main" {
                      let _ = _app_handle.get_window("main").unwrap().hide();  
                      _app_handle.emit_all("close", {}).unwrap();
                      debug!("Rust try to close");
                      api.prevent_close()
                  }
              }
              _ => {}
          }
      }
      RunEvent::Ready => {}
      RunEvent::Resumed => {}
      RunEvent::MainEventsCleared => {}
      _ => {}
  }
}

fn main() { 
    let _app = tauri::Builder::default()
        .build(tauri::generate_context!())
        .expect("error while running tauri application");
    
    _app.run(handle_app_event)
}

加载页

可参考 Tauri: Splashscreen

// tauri.config.json
{
    "tauri": {
        "windows": [
            {...} // main window configuration
            {
                "width": 500,
                "height": 300,
                "center": true,
                "decorations": false,
                "url": "splashscreen.html",
                "label": "splashscreen",
                "title": "Loading",
                "transparent": true
            }
        ]
    }
}

// splashscreen.html 示例
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        body { 
          background-color:transparent;
        }
        
        canvas {
            display: block;
            left: 50%;
            margin: -125px 0 0 -125px;
            position: absolute;
            top: 50%;
        }        

        .container {
          display: flex;
          align-items: center;
          justify-content: center;
          width: 100%;
          height: 100%;        
        }

        #content {
          margin-top: 280px;
          font-size: 2vw;
          font-weight: bold;
          color: #FFFFFF;
        }

    </style>
</head>
<body>
    <div class="container">
      <canvas id="canvas" width="250" height="250"></canvas> 
      <span id="content">Loading...</span>      
    </div>

    <script>
        // refer Organic Circle from https://uxplanet.org/using-loading-animation-on-websites-and-apps-examples-and-snippets-to-use-cab0097be9f1
        var canvasLoader = function(){
        
        var self = this;
        window.requestAnimFrame=function(){return window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame||function(a){window.setTimeout(a,1E3/60)}}();
        
        self.init = function(){ 
          self.canvas = document.getElementById('canvas');        
          self.ctx = self.canvas.getContext('2d');
          self.ctx.lineWidth = .5;
          self.ctx.strokeStyle = 'rgba(255,255,255,.75)';
          self.count = 75;
          self.rotation = 270*(Math.PI/180);    
          self.speed = 6;
          self.canvasLoop();
        };  
        
        self.updateLoader = function(){
          self.rotation += self.speed/100;                  
        };
        
        self.renderLoader = function(){             
          self.ctx.save();
          self.ctx.globalCompositeOperation = 'source-over';
          self.ctx.translate(125, 125);
          self.ctx.rotate(self.rotation); 
          var i = self.count;
          while(i--){               
            self.ctx.beginPath();
            self.ctx.arc(0, 0, i+(Math.random()*35), Math.random(), Math.PI/3+(Math.random()/12), false);               
            self.ctx.stroke();
          } 
          self.ctx.restore();                     
        };          
        
        self.canvasLoop = function(){
          requestAnimFrame(self.canvasLoop, self.canvas);     
          self.ctx.globalCompositeOperation = 'destination-out';
          self.ctx.fillStyle = 'rgba(255,255,255,.03)';
          self.ctx.fillRect(0,0,250,250);
          self.updateLoader();
          self.renderLoader();
        };
        
      };
      
      var loader = new canvasLoader();
      loader.init();
    </script>   
</body>
</html>

手动退出程序

⚠️ 两种退出方式都不会触发 app 的 close 事件,而是直接退出

use std::process;

process::exit(0)
<script lang="ts" setup>
import { appWindow } from '@tauri-apps/api/window';

appWindow.close();
</scriptscript>

博客版:

初识 Tauri 篇五之 App 基建

Medium 版:

New Acquaintance With Tauri Chapter 5: Infrastructure of App