Back to Blog
Tauri 2.0 + Vue 3 桌面應用開發實戰指南
📝 Dev Notes

Tauri 2.0 + Vue 3 桌面應用開發實戰指南

B
Blake
Dec 1, 2025 By Blake 81 min read
本文深入探討如何使用 Tauri 2.0 + Vue 3 + Rust 建立高效能的跨平台桌面應用。有別於一般入門教學,本文基於 RedForge Scanner(一個安全掃描工具)的實際開發經驗,展示從專案建立到生產打包的完整流程。 文章從 Electron 開發者常見的痛點出發:安裝檔 120 MB、記憶體占用 250 MB、啟動慢。透過遷移到 Tauri 2.0,最終達成安裝檔縮小 15 倍(8 MB)、記憶體節省 76%(60 MB)、啟動速度提升 4-6 倍的顯著成效。 文章涵蓋十大主題:(1) 為什麼選擇 Tauri(與 Electron 的詳細架構比較、實測數據);(2) 開發環境設置(macOS/Windows/Linux 各平台需求);(3) Tauri Commands 與 IPC 通信機制(invoke、events、狀態管理);(4) 前端(Vue 3)與後端(Rust)整合(Composables、類型映射);(5) 檔案系統操作與權限管理(Tauri 2.0 Capabilities 系統);(6) 資料庫整合與持久化(SQLite 插件、Migration);(7) 視窗管理與系統托盤(多視窗、背景執行);(8) 打包與跨平台發布(GitHub Actions 自動化);(9) 常見問題與解決方案(5 個常見坑和修復方法);(10) 最佳實踐檢查清單。 本文特別著重 Tauri 2.0 的新特性與陷阱避免:Capabilities/Permissions 權限系統的設定方式;IPC 參數命名必須與 Rust 函數匹配;Rust Option 映射為 T | null(不是 undefined);關閉視窗時如何只隱藏而不退出。所有程式碼範例均來自 RedForge Scanner 的生產程式碼,包含完整的錯誤處理和中文註解。 本文適合已具備 Vue 3 / TypeScript 基礎、想開發桌面應用但不想承擔 Electron 體積負擔的前端工程師,以及想學習 Rust 並將其整合到前端專案中的開發者。 核心貢獻: - ✅ Electron vs Tauri 完整架構比較與實測數據 - ✅ Tauri 2.0 Commands、Events、State 完整實作 - ✅ Capabilities/Permissions 權限系統詳解 - ✅ SQLite 資料庫整合與 Migration - ✅ 系統托盤與多視窗管理 - ✅ GitHub Actions 跨平台自動化建置 - ✅ 5 個常見問題與解決方案 - ✅ 20+ 項最佳實踐檢查清單

目錄

  1. 為什麼選擇 Tauri?

  2. 開發環境設置

  3. Tauri Commands 與 IPC 通信機制

  4. 前端(Vue 3)與後端(Rust)整合

  5. 檔案系統操作與權限管理

  6. 資料庫整合與持久化

  7. 視窗管理與系統托盤

  8. 打包與跨平台發布

  9. 常見問題與解決方案

  10. 最佳實踐檢查清單


前言:從 Electron 到 Tauri 的轉變

開發 RedForge Scanner(一個安全掃描工具)時,我原本使用 Electron。但隨著功能增加,問題逐漸浮現:

  • 安裝檔 120 MB:用戶抱怨「一個小工具為什麼這麼肥?」

  • 記憶體 250 MB:開著就吃掉一大塊 RAM

  • 啟動慢:用戶要等 2-3 秒才能看到畫面

後來換到 Tauri 2.0,結果讓我驚訝:

指標

Electron

Tauri 2.0

改善幅度

安裝檔大小

~120 MB

~8 MB

15x 更小

記憶體使用

~250 MB

~60 MB

76% 更省

啟動時間

2-3 秒

0.5 秒

4-6x 更快

CPU 閒置

5-10%

0-2%

大幅降低

這篇文章會帶你從零開始,用 Tauri 2.0 + Vue 3 + Rust 建立跨平台桌面應用,並分享開發 RedForge Scanner 過程中的實戰經驗。


1. 為什麼選擇 Tauri?(與 Electron 的詳細比較)

1.1 架構差異:誰在吃記憶體?

Electron 的架構

┌─────────────────────────────────────┐
│         Electron Application        │
├─────────────────────────────────────┤
│  Chromium (完整瀏覽器引擎)           │  ← 這就是肥胖的原因
│  ~100 MB 起跳                       │
├─────────────────────────────────────┤
│  Node.js Runtime                    │
│  ~40 MB                             │
├─────────────────────────────────────┤
│  Your Application Code              │
│  ~5-20 MB                           │
└─────────────────────────────────────┘

每個 Electron 應用都打包一整個 Chromium,即使只是顯示一個簡單的 Hello World。

Tauri 的架構

┌─────────────────────────────────────┐
│         Tauri Application           │
├─────────────────────────────────────┤
│  System WebView (系統內建)           │  ← 不需要打包
│  macOS: WKWebView                   │
│  Windows: WebView2 (Edge)           │
│  Linux: WebKit2GTK                  │
├─────────────────────────────────────┤
│  Rust Backend (原生編譯)             │
│  ~2-5 MB                            │
├─────────────────────────────────────┤
│  Your Frontend Code                 │
│  ~1-3 MB                            │
└─────────────────────────────────────┘

Tauri 使用系統內建的 WebView,不需要打包瀏覽器引擎。

1.2 安全與權限模型

這是 Tauri 2.0 最大的改進之一。

Electron 的問題

// Electron - 預設權限過於開放
const { ipcMain } = require('electron');

// 任何渲染進程都可以執行這個
ipcMain.handle('read-file', (event, path) => {
  return fs.readFileSync(path); // ⚠️ 可以讀任何檔案
});

Tauri 2.0 的解決方案

// src-tauri/capabilities/default.json
{
  "identifier": "default",
  "description": "主視窗權限",
  "windows": ["main"],
  "permissions": [
    "core:default",
    {
      "identifier": "fs:allow-read-text-file",
      "allow": [
        { "path": "$APPDATA/**" },
        { "path": "$DESKTOP/**" }
      ]
    }
  ]
}

預設全部關閉,只開放你明確允許的權限和路徑。

1.3 Tauri 2.0 vs 1.x:關鍵差異

功能

Tauri 1.x

Tauri 2.0

權限系統

allowlist 在 config

Capabilities + Permissions

IPC 機制

基本 invoke

支援二進位傳輸

插件系統

第三方

官方插件生態

行動裝置

iOS/Android 支援

API 導入

@tauri-apps/api

@tauri-apps/api/core

如果你是從 1.x 升級,最大的改變是權限系統。


2. 開發環境設置(macOS / Windows / Linux)

2.1 通用前置需求

# 1. Node.js (LTS 版本)
node --version  # 應該是 v18+ 或 v20+

# 2. Rust (透過 rustup 安裝)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustc --version  # 應該是 1.70+

# 3. Tauri CLI
cargo install tauri-cli --version "^2.0.0"

2.2 各平台特別需求

macOS

# Xcode Command Line Tools
xcode-select --install

Windows

Linux (Debian/Ubuntu)

sudo apt update
sudo apt install libgtk-3-dev \
  libayatana-appindicator3-dev \
  libwebkit2gtk-4.1-dev \
  build-essential curl wget

2.3 建立 Tauri + Vue 3 專案

# 1. 建立 Vue 3 + TypeScript 前端
npm create vite@latest redforge-scanner -- --template vue-ts
cd redforge-scanner

# 2. 安裝 Tauri 相關套件
npm install --save-dev @tauri-apps/cli
npm install @tauri-apps/api

# 3. 初始化 Tauri
npx tauri init

初始化過程中會問幾個問題:

  • App name: redforge-scanner

  • Window title: RedForge Scanner

  • Dev server URL: http://localhost:5173(Vite 預設)

  • Dev command: npm run dev

  • Build command: npm run build

完成後的專案結構:

redforge-scanner/
├── src/                    # Vue 3 前端
│   ├── App.vue
│   ├── main.ts
│   └── components/
├── src-tauri/              # Rust 後端
│   ├── src/
│   │   ├── lib.rs          # 主要入口
│   │   ├── commands/       # Tauri Commands
│   │   └── models/         # 資料模型
│   ├── capabilities/       # 權限設定 (Tauri 2.0)
│   ├── Cargo.toml
│   └── tauri.conf.json
├── package.json
└── vite.config.ts

2.4 Tauri 設定檔

src-tauri/tauri.conf.json

{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "RedForge Scanner",
  "version": "0.1.0",
  "identifier": "com.blakehung.redforge-scanner",
  "build": {
    "beforeDevCommand": "npm run dev",
    "devUrl": "http://localhost:5173",
    "beforeBuildCommand": "npm run build",
    "frontendDist": "../dist"
  },
  "app": {
    "windows": [
      {
        "title": "RedForge Scanner",
        "width": 1200,
        "height": 800,
        "resizable": true,
        "fullscreen": false
      }
    ],
    "security": {
      "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"
    }
  },
  "bundle": {
    "active": true,
    "targets": "all",
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/icon.icns",
      "icons/icon.ico"
    ]
  }
}

src-tauri/Cargo.toml(RedForge 實際使用的依賴):

[package]
name = "redforge-scanner"
version = "0.1.0"
edition = "2021"

[lib]
name = "redforge_scanner_lib"
crate-type = ["staticlib", "cdylib", "rlib"]

[dependencies]
# Tauri 核心
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }

# 序列化
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# 非同步執行
tokio = { version = "1", features = ["full"] }

# HTTP 請求(用於掃描)
reqwest = { version = "0.11", features = ["json", "rustls-tls", "cookies"] }

# 時間處理
chrono = { version = "0.4", features = ["serde"] }

# UUID 生成
uuid = { version = "1", features = ["v4", "serde"] }

# 正則表達式
regex = "1"

# 加密
base64 = "0.21"
sha2 = "0.10"

3. Tauri Commands 與 IPC 通信機制

Tauri 的核心是 IPC(Inter-Process Communication),前端透過 invoke() 呼叫 Rust 後端的 Command。

3.1 基本 Command:從前端呼叫 Rust

Rust 端:定義 Command

📄 檔案路徑: src-tauri/src/commands/scan.rs

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::State;
use tokio::sync::Mutex;
use uuid::Uuid;

/// 掃描任務狀態
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanTask {
    pub id: String,
    pub target_url: String,
    pub scan_type: String,
    pub status: ScanStatus,
    pub started_at: Option<String>,
    pub completed_at: Option<String>,
    pub created_at: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ScanStatus {
    Pending,
    Running,
    Completed,
    Failed,
}

/// 應用程式共享狀態
pub struct ScanState {
    pub current_tasks: Arc<Mutex<Vec<ScanTask>>>,
    pub scan_results: Arc<Mutex<HashMap<String, ScanReport>>>,
}

/// 啟動掃描的 Command
#[tauri::command]
pub async fn start_scan(
    url: String,
    scan_type: String,
    state: State<'_, ScanState>,
) -> Result<String, String> {
    // 驗證 URL 格式
    if !url.starts_with("http://") && !url.starts_with("https://") {
        return Err("無效的 URL 格式,請使用 http:// 或 https://".to_string());
    }

    // 生成唯一任務 ID
    let task_id = Uuid::new_v4().to_string();
    let now = chrono::Utc::now().to_rfc3339();

    // 建立掃描任務
    let task = ScanTask {
        id: task_id.clone(),
        target_url: url.clone(),
        scan_type: scan_type.clone(),
        status: ScanStatus::Pending,
        started_at: None,
        completed_at: None,
        created_at: now,
    };

    // 存入共享狀態
    {
        let mut tasks = state.current_tasks.lock().await;
        tasks.push(task.clone());
    }

    // 在背景執行掃描
    let state_clone = ScanState {
        current_tasks: Arc::clone(&state.current_tasks),
        scan_results: Arc::clone(&state.scan_results),
    };

    let task_id_clone = task_id.clone();
    tokio::spawn(async move {
        execute_scan(task_id_clone, url, scan_type, state_clone).await;
    });

    Ok(task_id)
}

/// 取得掃描狀態
#[tauri::command]
pub async fn get_scan_status(
    task_id: String,
    state: State<'_, ScanState>,
) -> Result<ScanTask, String> {
    let tasks = state.current_tasks.lock().await;

    tasks
        .iter()
        .find(|t| t.id == task_id)
        .cloned()
        .ok_or_else(|| "找不到該任務".to_string())
}

/// 取得掃描報告
#[tauri::command]
pub async fn get_scan_report(
    task_id: String,
    state: State<'_, ScanState>,
) -> Result<ScanReport, String> {
    let results = state.scan_results.lock().await;

    results
        .get(&task_id)
        .cloned()
        .ok_or_else(|| "找不到掃描報告".to_string())
}

/// 列出所有掃描
#[tauri::command]
pub async fn list_scans(state: State<'_, ScanState>) -> Result<Vec<ScanTask>, String> {
    let tasks = state.current_tasks.lock().await;
    Ok(tasks.clone())
}

Rust 端:註冊 Commands

📄 檔案路徑: src-tauri/src/lib.rs

mod commands;
mod models;
mod database;

use commands::scan::*;
use commands::collaboration::*;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Mutex;

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        // 註冊共享狀態
        .manage(ScanState {
            current_tasks: Arc::new(Mutex::new(Vec::new())),
            scan_results: Arc::new(Mutex::new(HashMap::new())),
        })
        // 註冊插件
        .plugin(tauri_plugin_opener::init())
        .plugin(tauri_plugin_dialog::init())
        .plugin(tauri_plugin_fs::init())
        .plugin(
            tauri_plugin_sql::Builder::default()
                .add_migrations("sqlite:redforge.db", database::get_migrations())
                .build(),
        )
        // 註冊 Commands
        .invoke_handler(tauri::generate_handler![
            start_scan,
            get_scan_status,
            list_scans,
            get_scan_report,
            export_scan_data,
            import_scan_data,
            deduplicate_import_data,
        ])
        .run(tauri::generate_context!())
        .expect("啟動 Tauri 應用程式時發生錯誤");
}

前端(Vue 3 + TypeScript):呼叫 Command

📄 檔案路徑: src/composables/useScan.ts

import { ref } from 'vue';
import { invoke } from '@tauri-apps/api/core';

// 類型定義(映射 Rust 結構)
export interface ScanTask {
  id: string;
  target_url: string;
  scan_type: string;
  status: 'pending' | 'running' | 'completed' | 'failed';
  started_at?: string;
  completed_at?: string;
  created_at: string;
}

export interface ScanConfig {
  url: string;
  scanType: string;
}

export function useScan() {
  const loading = ref(false);
  const currentTask = ref<ScanTask | null>(null);
  const error = ref<string | null>(null);

  /**
   * 啟動掃描
   */
  const startScan = async (config: ScanConfig): Promise<string> => {
    loading.value = true;
    error.value = null;

    try {
      // invoke<返回類型>('command名稱', { 參數 })
      const taskId = await invoke<string>('start_scan', {
        url: config.url,
        scanType: config.scanType,
      });

      console.log('🚀 掃描已啟動:', taskId);
      return taskId;
    } catch (e) {
      error.value = e instanceof Error ? e.message : String(e);
      throw e;
    } finally {
      loading.value = false;
    }
  };

  /**
   * 輪詢掃描狀態
   */
  const pollStatus = async (taskId: string): Promise<ScanTask> => {
    const task = await invoke<ScanTask>('get_scan_status', { taskId });
    currentTask.value = task;
    return task;
  };

  /**
   * 取得掃描報告
   */
  const getReport = async (taskId: string) => {
    return await invoke<ScanReport>('get_scan_report', { taskId });
  };

  return {
    loading,
    currentTask,
    error,
    startScan,
    pollStatus,
    getReport,
  };
}

3.2 Events:從 Rust 主動推送到前端

有時候後端需要主動通知前端(例如:進度更新)。

Rust 端:發送事件

use tauri::{AppHandle, Manager};

#[tauri::command]
pub async fn start_long_task(app: AppHandle) -> Result<(), String> {
    for progress in 0..=100 {
        // 發送進度事件到所有視窗
        app.emit("scan-progress", progress)
            .map_err(|e| e.to_string())?;

        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
    }

    // 發送完成事件
    app.emit("scan-completed", "掃描完成!")
        .map_err(|e| e.to_string())?;

    Ok(())
}

Vue 端:監聽事件

import { ref, onMounted, onUnmounted } from 'vue';
import { listen, UnlistenFn } from '@tauri-apps/api/event';

export function useScanProgress() {
  const progress = ref(0);
  const isCompleted = ref(false);

  let unlistenProgress: UnlistenFn | null = null;
  let unlistenCompleted: UnlistenFn | null = null;

  onMounted(async () => {
    // 監聽進度事件
    unlistenProgress = await listen<number>('scan-progress', (event) => {
      progress.value = event.payload;
      console.log(`進度: ${event.payload}%`);
    });

    // 監聽完成事件
    unlistenCompleted = await listen<string>('scan-completed', (event) => {
      isCompleted.value = true;
      console.log(event.payload);
    });
  });

  onUnmounted(() => {
    // 清理監聽器
    if (unlistenProgress) unlistenProgress();
    if (unlistenCompleted) unlistenCompleted();
  });

  return { progress, isCompleted };
}

3.3 IPC 類型安全:確保 Rust ↔ TypeScript 同步

這是最容易出錯的地方。我在 RedForge 中建立了類型映射檔案:

📄 檔案路徑: src/types/tauri.ts

/**
 * Rust ↔ TypeScript 類型映射
 *
 * 重要提醒:
 * - Rust Option<T> → TypeScript T | null(不是 undefined)
 * - Rust enum → TypeScript union type
 * - Rust DateTime<Utc> → TypeScript string(ISO 8601)
 */

// 對應 Rust: pub struct ScanTask
export interface ScanTask {
  id: string;
  target_url: string;
  scan_type: ScanType;
  status: ScanStatus;
  started_at: string | null;  // Option<DateTime> → string | null
  completed_at: string | null;
  created_at: string;
}

// 對應 Rust: pub enum ScanStatus
export type ScanStatus = 'pending' | 'running' | 'completed' | 'failed';

// 對應 Rust: pub enum ScanType
export type ScanType = 'full' | 'quick' | 'vulnerability' | 'port' | 'ssl' | 'headers';

// 對應 Rust: pub struct ScanReport
export interface ScanReport {
  task: ScanTask;
  headers: SecurityHeader[];
  ssl_analysis: SslAnalysis | null;
  technologies: DetectedTechnology[];
  vulnerabilities: ScanResult[];
}

// 對應 Rust: pub struct ScanResult
export interface ScanResult {
  id: string;
  task_id: string;
  result_type: ResultType;
  severity: Severity | null;
  title: string;
  description: string | null;
  raw_data: string | null;
  created_at: string;
}

// 對應 Rust: pub enum Severity
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info';

4. 前端(Vue 3)與後端(Rust)整合

4.1 Vue 3 元件實作範例

📄 檔案路徑: src/components/Scanner.vue

<script setup lang="ts">
import { ref } from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { saveScanToDatabase } from '@/composables/useScanPersistence';
import type { ScanTask } from '@/types/tauri';

// 響應式狀態
const url = ref('https://example.com');
const scanType = ref('full');
const isScanning = ref(false);
const currentTask = ref<ScanTask | null>(null);

// 掃描類型選項
const scanTypes = [
  { id: 'quick', label: '快速掃描', desc: '基本安全檢查' },
  { id: 'full', label: '完整掃描', desc: 'Headers + SSL + 漏洞' },
  { id: 'vulnerability', label: '漏洞掃描', desc: 'OWASP Top 10' },
];

/**
 * 啟動掃描
 */
const startScan = async () => {
  if (!url.value) {
    alert('請輸入目標 URL');
    return;
  }

  isScanning.value = true;

  try {
    // 呼叫 Rust Command
    const taskId = await invoke<string>('start_scan', {
      url: url.value,
      scanType: scanType.value,
    });

    console.log('🚀 掃描已啟動:', taskId);

    // 輪詢掃描狀態
    const pollInterval = setInterval(async () => {
      try {
        const task = await invoke<ScanTask>('get_scan_status', { taskId });
        currentTask.value = task;

        // 檢查是否完成
        if (task.status === 'completed' || task.status === 'failed') {
          clearInterval(pollInterval);
          isScanning.value = false;

          if (task.status === 'completed') {
            console.log('✅ 掃描完成,開始保存到資料庫...');

            try {
              await saveScanToDatabase(taskId);
              console.log('✅ 掃描結果已保存到資料庫');
            } catch (dbError) {
              console.error('⚠️ 保存到資料庫失敗:', dbError);
            }
          }
        }
      } catch (err) {
        console.error('輪詢狀態失敗:', err);
      }
    }, 1000);
  } catch (error) {
    console.error('啟動掃描失敗:', error);
    isScanning.value = false;
    alert('掃描啟動失敗: ' + error);
  }
};

/**
 * 根據狀態返回顏色
 */
const getStatusColor = (status: string): string => {
  switch (status) {
    case 'running': return 'text-info-500';
    case 'completed': return 'text-success-500';
    case 'failed': return 'text-danger-500';
    default: return 'text-warning-500';
  }
};
</script>

<template>
  <div class="space-y-6">
    <!-- 掃描設定卡片 -->
    <div class="bg-dark-800 rounded-lg border border-dark-700 p-6">
      <h2 class="text-2xl font-bold text-white mb-6 flex items-center">
        <span class="mr-2">🔍</span>
        啟動掃描
      </h2>

      <div class="space-y-4">
        <!-- URL 輸入 -->
        <div>
          <label class="block text-sm font-medium text-dark-300 mb-2">
            目標 URL
          </label>
          <input
            v-model="url"
            type="url"
            placeholder="https://example.com"
            class="w-full px-4 py-3 bg-dark-700 border border-dark-600 rounded-lg
                   text-white placeholder-dark-400
                   focus:outline-none focus:ring-2 focus:ring-danger-500"
          />
        </div>

        <!-- 掃描類型選擇 -->
        <div>
          <label class="block text-sm font-medium text-dark-300 mb-2">
            掃描類型
          </label>
          <div class="grid grid-cols-3 gap-3">
            <button
              v-for="type in scanTypes"
              :key="type.id"
              @click="scanType = type.id"
              :class="[
                'p-4 rounded-lg border-2 text-left transition-all',
                scanType === type.id
                  ? 'border-danger-500 bg-danger-900/20'
                  : 'border-dark-600 bg-dark-700 hover:border-dark-500'
              ]"
            >
              <div class="font-semibold text-white">{{ type.label }}</div>
              <div class="text-xs text-dark-400 mt-1">{{ type.desc }}</div>
            </button>
          </div>
        </div>

        <!-- 開始按鈕 -->
        <button
          @click="startScan"
          :disabled="isScanning"
          :class="[
            'w-full py-3 rounded-lg font-semibold transition-all',
            'flex items-center justify-center',
            isScanning
              ? 'bg-dark-600 text-dark-400 cursor-not-allowed'
              : 'bg-danger-600 hover:bg-danger-700 text-white'
          ]"
        >
          <span v-if="isScanning" class="animate-spin mr-2">⟳</span>
          {{ isScanning ? '掃描中...' : '開始掃描' }}
        </button>
      </div>
    </div>

    <!-- 目前任務狀態 -->
    <div v-if="currentTask" class="bg-dark-800 rounded-lg border border-dark-700 p-6">
      <h3 class="text-lg font-semibold text-white mb-4">掃描狀態</h3>

      <div class="space-y-2">
        <div class="flex justify-between text-sm">
          <span class="text-dark-400">任務 ID:</span>
          <span class="text-white font-mono">
            {{ currentTask.id.slice(0, 8) }}...
          </span>
        </div>
        <div class="flex justify-between text-sm">
          <span class="text-dark-400">目標:</span>
          <span class="text-white">{{ currentTask.target_url }}</span>
        </div>
        <div class="flex justify-between text-sm">
          <span class="text-dark-400">狀態:</span>
          <span :class="['font-semibold uppercase', getStatusColor(currentTask.status)]">
            {{ currentTask.status }}
          </span>
        </div>
      </div>

      <!-- 進度條 -->
      <div v-if="currentTask.status === 'running'" class="mt-4">
        <div class="h-2 bg-dark-700 rounded-full overflow-hidden">
          <div
            class="h-full bg-gradient-to-r from-danger-600 to-danger-400 animate-pulse"
            style="width: 60%"
          ></div>
        </div>
      </div>
    </div>

    <!-- 警告提示 -->
    <div class="bg-warning-900/20 border border-warning-700 rounded-lg p-4">
      <div class="flex items-start">
        <span class="text-warning-500 mr-3">⚠️</span>
        <div class="text-sm text-warning-200">
          <p class="font-semibold mb-1">重要提醒</p>
          <p>請確保您有權限掃描目標網站。未經授權的安全測試可能違反法律。</p>
        </div>
      </div>
    </div>
  </div>
</template>

4.2 Composables:封裝業務邏輯

📄 檔案路徑: src/composables/useScanPersistence.ts

/**
 * 掃描持久化 Composable
 *
 * 處理掃描結果的自動儲存和載入
 */

import { invoke } from '@tauri-apps/api/core';
import {
  insertScanTask,
  insertScanResults,
  getAllScanTasks,
  type DbScanTask,
  type DbScanResult,
} from '@/services/database';
import type { ScanTask, ScanReport } from '@/types/tauri';

/**
 * 掃描完成時自動保存到資料庫
 */
export async function saveScanToDatabase(taskId: string): Promise<void> {
  try {
    console.log(`💾 開始保存掃描結果到資料庫: ${taskId}`);

    // 從 Rust 後端取得掃描任務和報告
    const task = await invoke<ScanTask>('get_scan_status', { taskId });
    const report = await invoke<ScanReport>('get_scan_report', { taskId });

    // 保存掃描任務
    await insertScanTask({
      id: task.id,
      target_url: task.target_url,
      scan_type: task.scan_type,
      status: task.status,
      created_at: task.created_at,
      started_at: task.started_at,
      completed_at: task.completed_at,
    });

    // 保存掃描結果(漏洞)
    if (report.vulnerabilities && report.vulnerabilities.length > 0) {
      const results: DbScanResult[] = report.vulnerabilities.map((vuln) => ({
        id: vuln.id,
        task_id: task.id,
        result_type: vuln.result_type || 'vulnerability',
        severity: vuln.severity,
        title: vuln.title,
        description: vuln.description,
        raw_data: vuln.raw_data,
        created_at: vuln.created_at,
      }));

      await insertScanResults(results);
    }

    console.log(`✅ 成功保存掃描結果: ${taskId} (${report.vulnerabilities.length} 個漏洞)`);
  } catch (error) {
    console.error(`❌ 保存掃描結果失敗: ${taskId}`, error);
    throw error;
  }
}

/**
 * 從資料庫載入所有掃描歷史
 */
export async function loadScanHistory(): Promise<DbScanTask[]> {
  try {
    console.log('📂 從資料庫載入掃描歷史...');
    const scans = await getAllScanTasks();
    console.log(`✅ 成功載入 ${scans.length} 筆掃描記錄`);
    return scans;
  } catch (error) {
    console.error('❌ 載入掃描歷史失敗:', error);
    throw error;
  }
}

/**
 * 輪詢掃描狀態並在完成時自動保存
 */
export async function pollScanAndSave(
  taskId: string,
  onUpdate?: (task: ScanTask) => void
): Promise<void> {
  const pollInterval = 1000; // 每秒檢查一次
  const maxAttempts = 300;   // 最多等待 5 分鐘
  let attempts = 0;

  return new Promise((resolve, reject) => {
    const intervalId = setInterval(async () => {
      attempts++;

      try {
        const task = await invoke<ScanTask>('get_scan_status', { taskId });

        // 通知外部狀態更新
        if (onUpdate) {
          onUpdate(task);
        }

        // 檢查是否完成
        if (task.status === 'completed' || task.status === 'failed') {
          clearInterval(intervalId);

          // 自動保存到資料庫
          if (task.status === 'completed') {
            try {
              await saveScanToDatabase(taskId);
            } catch (error) {
              console.error('保存到資料庫失敗,但掃描已完成:', error);
            }
          }

          resolve();
        }

        // 檢查是否超時
        if (attempts >= maxAttempts) {
          clearInterval(intervalId);
          reject(new Error('掃描超時'));
        }
      } catch (error) {
        clearInterval(intervalId);
        reject(error);
      }
    }, pollInterval);
  });
}

5. 檔案系統操作與權限管理(Tauri 2.0 新重點)

Tauri 2.0 的權限系統是最大的改變。預設全部關閉,你必須明確聲明需要的權限。

5.1 啟用檔案系統插件

📄 檔案路徑: src-tauri/Cargo.toml

[dependencies]
tauri-plugin-fs = "2"

📄 檔案路徑: src-tauri/src/lib.rs

pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        // ...其他設定
        .run(tauri::generate_context!())
        .expect("error");
}

5.2 設定 Capabilities:允許讀寫特定路徑

📄 檔案路徑: src-tauri/capabilities/default.json

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "主視窗的預設權限",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "opener:default",

    "dialog:default",
    "dialog:allow-save",
    "dialog:allow-open",

    "fs:default",
    {
      "identifier": "fs:allow-read-text-file",
      "allow": [
        { "path": "$APPDATA/**" },
        { "path": "$DESKTOP/**" },
        { "path": "$DOCUMENT/**" }
      ]
    },
    {
      "identifier": "fs:allow-write-text-file",
      "allow": [
        { "path": "$APPDATA/**" },
        { "path": "$DESKTOP/**" },
        { "path": "$DOCUMENT/**" }
      ]
    },

    "sql:default",
    "sql:allow-load",
    "sql:allow-execute",
    "sql:allow-select"
  ]
}

常用路徑變數

  • $APPDATA:應用程式資料目錄

  • $DESKTOP:桌面

  • $DOCUMENT:文件資料夾

  • $HOME:使用者家目錄

  • $TEMP:暫存目錄

5.3 前端使用檔案系統 API

📄 檔案路徑: src/services/fileService.ts

import { save, open } from '@tauri-apps/plugin-dialog';
import { readTextFile, writeTextFile, BaseDirectory } from '@tauri-apps/plugin-fs';

/**
 * 儲存設定檔
 */
export async function saveConfig(content: string): Promise<void> {
  await writeTextFile('config.json', content, {
    baseDir: BaseDirectory.AppData,
  });
  console.log('✅ 設定已儲存');
}

/**
 * 載入設定檔
 */
export async function loadConfig(): Promise<string | null> {
  try {
    const content = await readTextFile('config.json', {
      baseDir: BaseDirectory.AppData,
    });
    return content;
  } catch {
    console.log('⚠️ 設定檔不存在,使用預設值');
    return null;
  }
}

/**
 * 匯出報告(讓使用者選擇儲存位置)
 */
export async function exportReport(
  content: string,
  defaultName: string
): Promise<string | null> {
  // 開啟儲存對話框
  const filePath = await save({
    defaultPath: defaultName,
    filters: [
      { name: 'Markdown', extensions: ['md'] },
      { name: 'JSON', extensions: ['json'] },
    ],
  });

  if (!filePath) {
    console.log('使用者取消儲存');
    return null;
  }

  // 寫入檔案
  await writeTextFile(filePath, content);
  console.log(`✅ 報告已匯出至: ${filePath}`);

  return filePath;
}

/**
 * 匯入檔案
 */
export async function importFile(): Promise<string | null> {
  // 開啟選擇對話框
  const selected = await open({
    multiple: false,
    filters: [
      { name: 'Markdown', extensions: ['md'] },
      { name: 'JSON', extensions: ['json'] },
    ],
  });

  if (!selected) {
    console.log('使用者取消選擇');
    return null;
  }

  // 讀取檔案內容
  const content = await readTextFile(selected as string);
  return content;
}

5.4 常見錯誤:PermissionDenied

如果你遇到這個錯誤:

Error: fs plugin: path not allowed by scope

代表你嘗試存取的路徑沒有在 capabilities 中設定。

解決方案

  1. 確認 capabilities 中有設定對應路徑

  2. 使用 BaseDirectory 列舉而不是絕對路徑

  3. 確認路徑模式正確(** 代表所有子目錄)


6. 資料庫整合與持久化

Tauri 2.0 提供官方的 SQLite 插件。

6.1 設定 SQLite 插件

📄 檔案路徑: src-tauri/Cargo.toml

[dependencies]
tauri-plugin-sql = { version = "2", features = ["sqlite"] }

📄 檔案路徑: src-tauri/src/lib.rs

use tauri_plugin_sql::{Migration, MigrationKind};

fn get_migrations() -> Vec<Migration> {
    vec![
        Migration {
            version: 1,
            description: "create_initial_tables",
            sql: include_str!("database/migrations/001_create_initial_tables.sql"),
            kind: MigrationKind::Up,
        },
    ]
}

pub fn run() {
    tauri::Builder::default()
        .plugin(
            tauri_plugin_sql::Builder::default()
                .add_migrations("sqlite:redforge.db", get_migrations())
                .build(),
        )
        // ...
        .run(tauri::generate_context!())
        .expect("error");
}

📄 檔案路徑: src-tauri/src/database/migrations/001_create_initial_tables.sql

-- 掃描任務表
CREATE TABLE IF NOT EXISTS scan_tasks (
    id TEXT PRIMARY KEY NOT NULL,
    target_url TEXT NOT NULL,
    scan_type TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'pending',
    started_at TEXT,
    completed_at TEXT,
    created_at TEXT NOT NULL,
    created_by TEXT DEFAULT 'local'
);

-- 掃描結果表
CREATE TABLE IF NOT EXISTS scan_results (
    id TEXT PRIMARY KEY NOT NULL,
    task_id TEXT NOT NULL,
    result_type TEXT NOT NULL,
    severity TEXT,
    title TEXT NOT NULL,
    description TEXT,
    raw_data TEXT,
    created_at TEXT NOT NULL,
    FOREIGN KEY (task_id) REFERENCES scan_tasks(id)
);

-- 建立索引
CREATE INDEX IF NOT EXISTS idx_scan_tasks_created_at ON scan_tasks(created_at);
CREATE INDEX IF NOT EXISTS idx_scan_results_task_id ON scan_results(task_id);
CREATE INDEX IF NOT EXISTS idx_scan_results_severity ON scan_results(severity);

6.2 前端資料庫操作

📄 檔案路徑: src/services/database.ts

import Database from '@tauri-apps/plugin-sql';

let db: Database | null = null;

/**
 * 初始化資料庫連線
 */
export async function initDatabase(): Promise<void> {
  try {
    db = await Database.load('sqlite:redforge.db');
    console.log('✅ 資料庫初始化成功');
  } catch (error) {
    console.error('❌ 資料庫初始化失敗:', error);
    throw error;
  }
}

/**
 * 取得資料庫實例
 */
function getDb(): Database {
  if (!db) {
    throw new Error('資料庫尚未初始化,請先呼叫 initDatabase()');
  }
  return db;
}

// ========== 類型定義 ==========

export interface DbScanTask {
  id: string;
  target_url: string;
  scan_type: string;
  status: string;
  started_at: string | null;
  completed_at: string | null;
  created_at: string;
  created_by: string;
}

export interface DbScanResult {
  id: string;
  task_id: string;
  result_type: string;
  severity?: string;
  title: string;
  description?: string;
  raw_data?: string;
  created_at: string;
}

// ========== CRUD 操作 ==========

/**
 * 新增掃描任務
 */
export async function insertScanTask(task: {
  id: string;
  target_url: string;
  scan_type: string;
  status: string;
  created_at: string;
  started_at?: string;
  completed_at?: string;
}): Promise<void> {
  const database = getDb();

  await database.execute(
    `INSERT INTO scan_tasks (id, target_url, scan_type, status, started_at, completed_at, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7)`,
    [
      task.id,
      task.target_url,
      task.scan_type,
      task.status,
      task.started_at || null,
      task.completed_at || null,
      task.created_at,
    ]
  );

  console.log(`✅ 已新增掃描任務: ${task.id}`);
}

/**
 * 取得所有掃描任務
 */
export async function getAllScanTasks(): Promise<DbScanTask[]> {
  const database = getDb();

  const result = await database.select<DbScanTask[]>(
    'SELECT * FROM scan_tasks ORDER BY created_at DESC'
  );

  return result;
}

/**
 * 新增掃描結果
 */
export async function insertScanResult(result: DbScanResult): Promise<void> {
  const database = getDb();

  await database.execute(
    `INSERT INTO scan_results (id, task_id, result_type, severity, title, description, raw_data, created_at)
     VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
    [
      result.id,
      result.task_id,
      result.result_type,
      result.severity || null,
      result.title,
      result.description || null,
      result.raw_data || null,
      result.created_at,
    ]
  );
}

/**
 * 批次新增掃描結果
 */
export async function insertScanResults(results: DbScanResult[]): Promise<void> {
  for (const result of results) {
    await insertScanResult(result);
  }
  console.log(`✅ 已新增 ${results.length} 筆掃描結果`);
}

/**
 * 根據任務 ID 取得掃描結果
 */
export async function getScanResultsByTaskId(taskId: string): Promise<DbScanResult[]> {
  const database = getDb();

  const result = await database.select<DbScanResult[]>(
    'SELECT * FROM scan_results WHERE task_id = $1 ORDER BY created_at DESC',
    [taskId]
  );

  return result;
}

/**
 * 刪除掃描任務及其結果
 */
export async function deleteScanTask(taskId: string): Promise<void> {
  const database = getDb();

  // 先刪除結果
  await database.execute(
    'DELETE FROM scan_results WHERE task_id = $1',
    [taskId]
  );

  // 再刪除任務
  await database.execute(
    'DELETE FROM scan_tasks WHERE id = $1',
    [taskId]
  );

  console.log(`✅ 已刪除掃描任務: ${taskId}`);
}

6.3 在 Vue 中使用

📄 檔案路徑: src/main.ts

import { createApp } from 'vue';
import App from './App.vue';
import { initDatabase } from './services/database';

async function bootstrap() {
  // 初始化資料庫
  try {
    await initDatabase();
  } catch (error) {
    console.error('資料庫初始化失敗:', error);
  }

  // 建立 Vue 應用
  createApp(App).mount('#app');
}

bootstrap();

7. 視窗管理與系統托盤

7.1 視窗設定

📄 檔案路徑: src-tauri/tauri.conf.json

{
  "app": {
    "windows": [
      {
        "label": "main",
        "title": "RedForge Scanner",
        "width": 1200,
        "height": 800,
        "minWidth": 800,
        "minHeight": 600,
        "resizable": true,
        "fullscreen": false,
        "center": true,
        "decorations": true
      }
    ]
  }
}

7.2 動態建立新視窗

📄 檔案路徑: src-tauri/src/commands/window.rs

use tauri::{Manager, WebviewWindowBuilder, WebviewUrl};

#[tauri::command]
pub fn open_report_window(
    app: tauri::AppHandle,
    task_id: String,
) -> Result<(), String> {
    // 建立新視窗
    WebviewWindowBuilder::new(
        &app,
        format!("report-{}", task_id),
        WebviewUrl::App(format!("index.html#/report/{}", task_id).into())
    )
    .title(format!("掃描報告 - {}", &task_id[..8]))
    .inner_size(900.0, 700.0)
    .center()
    .build()
    .map_err(|e| e.to_string())?;

    Ok(())
}

#[tauri::command]
pub fn close_window(app: tauri::AppHandle, label: String) -> Result<(), String> {
    if let Some(window) = app.get_webview_window(&label) {
        window.close().map_err(|e| e.to_string())?;
    }
    Ok(())
}

7.3 系統托盤(背景執行)

📄 檔案路徑: src-tauri/src/tray.rs

use tauri::{
    AppHandle,
    Manager,
    tray::{TrayIcon, TrayIconBuilder, TrayIconEvent},
    menu::{Menu, MenuItem},
};

pub fn create_tray(app: &AppHandle) -> Result<TrayIcon, tauri::Error> {
    // 建立選單
    let show_item = MenuItem::with_id(app, "show", "顯示視窗", true, None::<&str>)?;
    let quit_item = MenuItem::with_id(app, "quit", "結束程式", true, None::<&str>)?;

    let menu = Menu::with_items(app, &[&show_item, &quit_item])?;

    // 建立托盤圖示
    let tray = TrayIconBuilder::new()
        .icon(app.default_window_icon().unwrap().clone())
        .menu(&menu)
        .tooltip("RedForge Scanner")
        .on_menu_event(move |app, event| {
            match event.id.as_ref() {
                "show" => {
                    if let Some(window) = app.get_webview_window("main") {
                        let _ = window.show();
                        let _ = window.set_focus();
                    }
                }
                "quit" => {
                    app.exit(0);
                }
                _ => {}
            }
        })
        .on_tray_icon_event(|tray, event| {
            // 點擊托盤圖示時顯示視窗
            if let TrayIconEvent::Click { .. } = event {
                let app = tray.app_handle();
                if let Some(window) = app.get_webview_window("main") {
                    let _ = window.show();
                    let _ = window.set_focus();
                }
            }
        })
        .build(app)?;

    Ok(tray)
}

📄 檔案路徑: src-tauri/src/lib.rs(整合托盤)

mod tray;

pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            // 建立系統托盤
            tray::create_tray(app.handle())?;
            Ok(())
        })
        .on_window_event(|window, event| {
            // 關閉視窗時隱藏而不是退出
            if let tauri::WindowEvent::CloseRequested { api, .. } = event {
                let _ = window.hide();
                api.prevent_close();
            }
        })
        // ...其他設定
        .run(tauri::generate_context!())
        .expect("error");
}

8. 打包與跨平台發布

8.1 開發模式

# 啟動開發環境(前端 + Tauri)
npm run tauri dev

# 或分開執行
npm run dev          # 終端 1:Vite 開發伺服器
npm run tauri dev    # 終端 2:Tauri 開發模式

8.2 生產打包

# 打包當前平台
npm run tauri build

# 打包特定目標(macOS)
npm run tauri build -- --target universal-apple-darwin

# 打包特定目標(Windows)
npm run tauri build -- --target x86_64-pc-windows-msvc

# 打包特定目標(Linux)
npm run tauri build -- --target x86_64-unknown-linux-gnu

8.3 輸出檔案

打包完成後,檔案會在 src-tauri/target/release/bundle/

src-tauri/target/release/bundle/
├── macos/
│   ├── RedForge Scanner.app
│   └── RedForge Scanner.app.tar.gz
├── dmg/
│   └── RedForge Scanner_0.1.0_x64.dmg
├── msi/
│   └── RedForge Scanner_0.1.0_x64_en-US.msi
├── nsis/
│   └── RedForge Scanner_0.1.0_x64-setup.exe
├── deb/
│   └── redforge-scanner_0.1.0_amd64.deb
└── appimage/
    └── redforge-scanner_0.1.0_amd64.AppImage

8.4 package.json 腳本設定

{
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc --noEmit && vite build",
    "preview": "vite preview",
    "tauri": "tauri",
    "tauri:dev": "tauri dev",
    "tauri:build": "tauri build",
    "tauri:build:mac": "tauri build --target universal-apple-darwin",
    "tauri:build:win": "tauri build --target x86_64-pc-windows-msvc",
    "tauri:build:linux": "tauri build --target x86_64-unknown-linux-gnu"
  }
}

8.5 GitHub Actions 自動化建置

📄 檔案路徑: .github/workflows/build.yml

name: Build

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    strategy:
      matrix:
        platform: [macos-latest, ubuntu-22.04, windows-latest]

    runs-on: ${{ matrix.platform }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install Rust
        uses: dtolnay/rust-action@stable

      - name: Install dependencies (Ubuntu)
        if: matrix.platform == 'ubuntu-22.04'
        run: |
          sudo apt-get update
          sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev

      - name: Install frontend dependencies
        run: npm ci

      - name: Build Tauri
        uses: tauri-apps/tauri-action@v0
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          tagName: v__VERSION__
          releaseName: 'RedForge Scanner v__VERSION__'
          releaseBody: 'See the assets to download this version.'
          releaseDraft: true
          prerelease: false

9. 常見問題與解決方案

9.1 IPC 呼叫失敗:Unknown command

可能原因

  1. Command 沒有加上 #[tauri::command]

  2. 忘記放進 invoke_handler![]

  3. 前端 invoke('command_name') 名稱與 Rust 函數不一致

檢查清單

// 1. 確認有 attribute
#[tauri::command]
pub async fn my_command() -> Result<String, String> { ... }

// 2. 確認有註冊
tauri::Builder::default()
    .invoke_handler(tauri::generate_handler![
        my_command,  // ← 要加在這裡
    ])

9.2 檔案系統存取被拒:PermissionDenied

原因:Tauri 2.0 預設禁止所有檔案存取

解決方案

// src-tauri/capabilities/default.json
{
  "permissions": [
    {
      "identifier": "fs:allow-read-text-file",
      "allow": [
        { "path": "$APPDATA/**" }
      ]
    }
  ]
}

9.3 關閉視窗就整個退出

需求:關閉視窗時只隱藏,改由托盤控制

解決方案

tauri::Builder::default()
    .on_window_event(|window, event| {
        if let tauri::WindowEvent::CloseRequested { api, .. } = event {
            let _ = window.hide();
            api.prevent_close();
        }
    })

9.4 Rust Option 映射錯誤

常見錯誤:用 undefined 而不是 null

// ❌ 錯誤
interface Task {
  started_at?: string;  // 這會是 undefined
}

// ✅ 正確
interface Task {
  started_at: string | null;  // Rust Option 序列化為 null
}

9.5 Tauri 1.x → 2.0 遷移問題

主要變更

  1. allowlistcapabilities

  2. @tauri-apps/api@tauri-apps/api/core

  3. 插件需要更新到 v2

// Tauri 1.x
import { invoke } from '@tauri-apps/api';

// Tauri 2.0
import { invoke } from '@tauri-apps/api/core';

10. 最佳實踐檢查清單

開發階段

  • 使用 TypeScript,為所有 Rust 結構建立對應的 interface

  • Rust Option 映射為 T | null(不是 undefined)

  • 所有 IPC 呼叫都有錯誤處理

  • 使用 Composables 封裝業務邏輯

  • 長時間操作使用 tokio::spawn 在背景執行

安全性

  • 只開放必要的 capabilities 權限

  • 檔案系統存取限制在特定目錄

  • 設定 CSP(Content Security Policy)

  • 驗證所有使用者輸入

效能

  • 大型資料使用串流或分批處理

  • 避免在主執行緒執行 CPU 密集操作

  • 使用 SQLite 而不是 JSON 檔案儲存大量資料

  • 圖片和大型資源使用懶載入

打包發布

  • 設定適當的應用程式圖示(所有尺寸)

  • 更新 tauri.conf.json 中的版本號

  • 測試所有目標平台

  • 設定 GitHub Actions 自動化建置

使用者體驗

  • 實作系統托盤(背景執行)

  • 顯示載入狀態和進度

  • 適當的錯誤訊息和使用者回饋

  • 支援鍵盤快捷鍵


結語:什麼時候選擇 Tauri + Vue 3?

如果你符合以下條件,Tauri 2.0 + Vue 3 是目前最優的選擇之一:

✅ 已熟悉 Vue 3 / Vite / TypeScript
✅ 需要桌面應用,但不想承擔 Electron 的體積
✅ 重視安全與權限控制
✅ 希望用 Rust 實作高效能後端邏輯
✅ 需要跨平台(macOS / Windows / Linux)

透過本文,你應該能夠:

  • 完成開發環境設置

  • 理解 Tauri 2.0 IPC、Commands、Events

  • 正確使用檔案系統與權限

  • 整合 SQLite 資料庫

  • 實作多視窗與系統托盤

  • 打包並跨平台發布


參考資源

Enjoyed this article? Show some love!

0
Clap

Enjoyed this article?

Subscribe for engineering notes and AI development insights

We respect your privacy. No spam, unsubscribe anytime.

Share this article

Comments