目錄
前言:從 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 |
|---|---|---|
權限系統 |
| Capabilities + Permissions |
IPC 機制 | 基本 invoke | 支援二進位傳輸 |
插件系統 | 第三方 | 官方插件生態 |
行動裝置 | 無 | iOS/Android 支援 |
API 導入 |
|
|
如果你是從 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
安裝 Visual Studio Build Tools(含 C++ 工具鏈)
WebView2 Runtime(Windows 10/11 通常已內建)
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 中設定。
解決方案:
確認 capabilities 中有設定對應路徑
使用
BaseDirectory列舉而不是絕對路徑確認路徑模式正確(
**代表所有子目錄)
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
可能原因:
Command 沒有加上
#[tauri::command]忘記放進
invoke_handler![]前端
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 遷移問題
主要變更:
allowlist→capabilities@tauri-apps/api→@tauri-apps/api/core插件需要更新到 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 資料庫
實作多視窗與系統托盤
打包並跨平台發布