Tauri 2 + React: بناء تطبيق سطح مكتب متعدد المنصات باستخدام Rust

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

المقدمة

لطالما كان Electron الخيار الافتراضي لبناء تطبيقات سطح المكتب بتقنيات الويب، لكنه يحمل متصفح Chromium كاملاً مع كل تطبيق — مما ينتج ملفات بحجم 150 ميغابايت أو أكثر واستهلاك عالٍ للذاكرة. Tauri 2 يغير قواعد اللعبة باستخدام عرض الويب الأصلي لنظام التشغيل، مما ينتج تطبيقات أصغر بـ 10-100 مرة وأسرع بشكل ملحوظ.

مع Tauri 2، تكتب واجهة المستخدم بـ React (أو أي إطار عمل أمامي) ومنطق الخلفية بـ Rust. النتيجة هي تطبيق سطح مكتب آمن وعالي الأداء يعمل على Windows وmacOS وLinux — وحتى iOS وAndroid.

في هذا الدليل، ستبني تطبيق ملاحظات لسطح المكتب — تطبيق بسيط لكنه عملي لتدوين الملاحظات مع إمكانية الوصول إلى نظام الملفات وأيقونة شريط النظام والتعبئة متعددة المنصات.

المتطلبات المسبقة

قبل البدء، تأكد من توفر:

  • Node.js 18+ مثبت
  • Rust مثبت عبر rustup
  • معرفة أساسية بـ React وTypeScript
  • محرر أكواد (يُنصح بـ VS Code مع إضافة rust-analyzer)

متطلبات خاصة بالمنصة

macOS:

xcode-select --install

Windows:

  • أدوات بناء Microsoft Visual Studio C++
  • WebView2 (مثبت مسبقاً على Windows 10/11)

Linux (Ubuntu/Debian):

sudo apt update
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev

ما ستبنيه

تطبيق ملاحظات لسطح المكتب بالميزات التالية:

  • إنشاء وتعديل وحذف الملاحظات
  • حفظ الملاحظات في نظام الملفات المحلي
  • تكامل شريط النظام مع إجراءات سريعة
  • تعبئة متعددة المنصات (Windows وmacOS وLinux)
  • عناصر تحكم النافذة والقوائم الأصلية

الخطوة 1: إنشاء مشروع Tauri

استخدم أداة create-tauri-app الرسمية لبدء مشروعك:

npm create tauri-app@latest notes-app -- --template react-ts

عند السؤال، اختر:

  • مدير الحزم: npm
  • قالب الواجهة: React
  • نكهة الواجهة: TypeScript

انتقل إلى مشروعك وثبّت التبعيات:

cd notes-app
npm install

هيكل المشروع

مشروعك يتكون من جزأين رئيسيين:

notes-app/
├── src/              # واجهة React الأمامية
│   ├── App.tsx
│   ├── main.tsx
│   └── styles.css
├── src-tauri/        # خلفية Rust
│   ├── src/
│   │   └── lib.rs
│   ├── Cargo.toml
│   ├── tauri.conf.json
│   └── capabilities/
└── package.json

مجلد src/ يحتوي على تطبيق React، بينما src-tauri/ يحتوي على خلفية Rust وإعدادات Tauri.

الخطوة 2: إعداد Tauri

افتح src-tauri/tauri.conf.json وحدّث إعدادات التطبيق:

{
  "$schema": "https://schema.tauri.app/config/2",
  "productName": "Notes App",
  "version": "1.0.0",
  "identifier": "com.notes.app",
  "build": {
    "frontendDist": "../dist",
    "devUrl": "http://localhost:1420",
    "beforeDevCommand": "npm run dev",
    "beforeBuildCommand": "npm run build"
  },
  "app": {
    "title": "Notes App",
    "windows": [
      {
        "title": "Notes App",
        "width": 900,
        "height": 680,
        "resizable": true,
        "fullscreen": false
      }
    ],
    "security": {
      "csp": null
    }
  },
  "bundle": {
    "active": true,
    "targets": "all",
    "icon": [
      "icons/32x32.png",
      "icons/128x128.png",
      "icons/128x128@2x.png",
      "icons/icon.icns",
      "icons/icon.ico"
    ]
  }
}

الخطوة 3: إضافة إضافات Tauri

يستخدم Tauri 2 نظام إضافات للوصول إلى واجهات النظام. ثبّت إضافات نظام الملفات والحوار:

npm run tauri add fs
npm run tauri add dialog

هذا يحدّث كلاً من package.json (ربط JavaScript) وCargo.toml (تبعيات Rust).

إعداد الصلاحيات

يتطلب Tauri 2 صلاحيات صريحة للوصول إلى الإضافات. حدّث src-tauri/capabilities/default.json:

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "fs:default",
    "fs:allow-read-text-file",
    "fs:allow-write-text-file",
    "fs:allow-exists",
    "fs:allow-mkdir",
    "fs:allow-read-dir",
    "fs:allow-remove",
    "fs:scope-app-data",
    "dialog:allow-open",
    "dialog:allow-save",
    "dialog:allow-message"
  ]
}

الخطوة 4: إنشاء أوامر Rust للخلفية

أوامر Tauri هي دوال Rust يمكن لواجهتك الأمامية استدعاؤها. افتح src-tauri/src/lib.rs وأضف منطق إدارة الملاحظات:

use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::Manager;
 
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Note {
    pub id: String,
    pub title: String,
    pub content: String,
    pub created_at: String,
    pub updated_at: String,
}
 
fn get_notes_dir(app: &tauri::AppHandle) -> PathBuf {
    let data_dir = app.path().app_data_dir().expect("Failed to get app data dir");
    let notes_dir = data_dir.join("notes");
    if !notes_dir.exists() {
        fs::create_dir_all(&notes_dir).expect("Failed to create notes directory");
    }
    notes_dir
}
 
#[tauri::command]
fn list_notes(app: tauri::AppHandle) -> Result<Vec<Note>, String> {
    let notes_dir = get_notes_dir(&app);
    let mut notes: Vec<Note> = Vec::new();
 
    let entries = fs::read_dir(&notes_dir).map_err(|e| e.to_string())?;
 
    for entry in entries {
        let entry = entry.map_err(|e| e.to_string())?;
        let path = entry.path();
        if path.extension().map_or(false, |ext| ext == "json") {
            let content = fs::read_to_string(&path).map_err(|e| e.to_string())?;
            let note: Note = serde_json::from_str(&content).map_err(|e| e.to_string())?;
            notes.push(note);
        }
    }
 
    notes.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
    Ok(notes)
}
 
#[tauri::command]
fn save_note(app: tauri::AppHandle, note: Note) -> Result<Note, String> {
    let notes_dir = get_notes_dir(&app);
    let file_path = notes_dir.join(format!("{}.json", note.id));
    let json = serde_json::to_string_pretty(&note).map_err(|e| e.to_string())?;
    fs::write(&file_path, json).map_err(|e| e.to_string())?;
    Ok(note)
}
 
#[tauri::command]
fn delete_note(app: tauri::AppHandle, id: String) -> Result<(), String> {
    let notes_dir = get_notes_dir(&app);
    let file_path = notes_dir.join(format!("{}.json", id));
    if file_path.exists() {
        fs::remove_file(&file_path).map_err(|e| e.to_string())?;
    }
    Ok(())
}
 
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_dialog::init())
        .invoke_handler(tauri::generate_handler![list_notes, save_note, delete_note])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

أضف تبعية serde_json إلى src-tauri/Cargo.toml:

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri = { version = "2", features = [] }
tauri-plugin-fs = "2"
tauri-plugin-dialog = "2"

الخطوة 5: بناء واجهة React الأمامية

الآن لننشئ واجهة المستخدم. أولاً، ثبّت بعض الحزم المساعدة:

npm install uuid
npm install -D @types/uuid

تعريف نوع الملاحظة

أنشئ src/types.ts:

export interface Note {
  id: string;
  title: string;
  content: string;
  created_at: string;
  updated_at: string;
}

مكون قائمة الملاحظات

أنشئ src/components/NotesList.tsx:

import { Note } from "../types";
 
interface NotesListProps {
  notes: Note[];
  selectedId: string | null;
  onSelect: (note: Note) => void;
  onDelete: (id: string) => void;
  onCreate: () => void;
}
 
export function NotesList({ notes, selectedId, onSelect, onDelete, onCreate }: NotesListProps) {
  return (
    <aside className="notes-sidebar">
      <div className="sidebar-header">
        <h2>الملاحظات</h2>
        <button onClick={onCreate} className="btn-new" title="ملاحظة جديدة">
          +
        </button>
      </div>
      <ul className="notes-list">
        {notes.map((note) => (
          <li
            key={note.id}
            className={`note-item ${selectedId === note.id ? "active" : ""}`}
            onClick={() => onSelect(note)}
          >
            <div className="note-item-title">
              {note.title || "بدون عنوان"}
            </div>
            <div className="note-item-date">
              {new Date(note.updated_at).toLocaleDateString("ar")}
            </div>
            <button
              className="btn-delete"
              onClick={(e) => {
                e.stopPropagation();
                onDelete(note.id);
              }}
              title="حذف"
            >
              ×
            </button>
          </li>
        ))}
      </ul>
    </aside>
  );
}

مكون محرر الملاحظات

أنشئ src/components/NoteEditor.tsx:

import { useState, useEffect } from "react";
import { Note } from "../types";
 
interface NoteEditorProps {
  note: Note | null;
  onSave: (note: Note) => void;
}
 
export function NoteEditor({ note, onSave }: NoteEditorProps) {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
 
  useEffect(() => {
    if (note) {
      setTitle(note.title);
      setContent(note.content);
    }
  }, [note?.id]);
 
  if (!note) {
    return (
      <main className="editor-empty">
        <p>اختر ملاحظة أو أنشئ واحدة جديدة</p>
      </main>
    );
  }
 
  const handleSave = () => {
    onSave({
      ...note,
      title,
      content,
      updated_at: new Date().toISOString(),
    });
  };
 
  return (
    <main className="editor">
      <input
        type="text"
        className="editor-title"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        onBlur={handleSave}
        placeholder="عنوان الملاحظة..."
      />
      <textarea
        className="editor-content"
        value={content}
        onChange={(e) => setContent(e.target.value)}
        onBlur={handleSave}
        placeholder="ابدأ الكتابة..."
      />
    </main>
  );
}

مكون التطبيق الرئيسي

استبدل src/App.tsx:

import { useState, useEffect } from "react";
import { invoke } from "@tauri-apps/api/core";
import { v4 as uuidv4 } from "uuid";
import { NotesList } from "./components/NotesList";
import { NoteEditor } from "./components/NoteEditor";
import { Note } from "./types";
import "./App.css";
 
function App() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [selectedNote, setSelectedNote] = useState<Note | null>(null);
 
  useEffect(() => {
    loadNotes();
  }, []);
 
  async function loadNotes() {
    try {
      const result = await invoke<Note[]>("list_notes");
      setNotes(result);
    } catch (error) {
      console.error("Failed to load notes:", error);
    }
  }
 
  async function createNote() {
    const newNote: Note = {
      id: uuidv4(),
      title: "",
      content: "",
      created_at: new Date().toISOString(),
      updated_at: new Date().toISOString(),
    };
 
    try {
      await invoke("save_note", { note: newNote });
      setNotes((prev) => [newNote, ...prev]);
      setSelectedNote(newNote);
    } catch (error) {
      console.error("Failed to create note:", error);
    }
  }
 
  async function saveNote(note: Note) {
    try {
      await invoke("save_note", { note });
      setNotes((prev) =>
        prev.map((n) => (n.id === note.id ? note : n))
      );
      setSelectedNote(note);
    } catch (error) {
      console.error("Failed to save note:", error);
    }
  }
 
  async function deleteNote(id: string) {
    try {
      await invoke("delete_note", { id });
      setNotes((prev) => prev.filter((n) => n.id !== id));
      if (selectedNote?.id === id) {
        setSelectedNote(null);
      }
    } catch (error) {
      console.error("Failed to delete note:", error);
    }
  }
 
  return (
    <div className="app">
      <NotesList
        notes={notes}
        selectedId={selectedNote?.id ?? null}
        onSelect={setSelectedNote}
        onDelete={deleteNote}
        onCreate={createNote}
      />
      <NoteEditor note={selectedNote} onSave={saveNote} />
    </div>
  );
}
 
export default App;

التنسيق

استبدل src/App.css بتصميم نظيف يشبه التطبيقات الأصلية:

:root {
  --sidebar-width: 280px;
  --bg-primary: #1e1e2e;
  --bg-secondary: #181825;
  --bg-surface: #313244;
  --text-primary: #cdd6f4;
  --text-secondary: #a6adc8;
  --accent: #89b4fa;
  --danger: #f38ba8;
  --border: #45475a;
}
 
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
 
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: var(--bg-primary);
  color: var(--text-primary);
}
 
.app {
  display: flex;
  height: 100vh;
}
 
.notes-sidebar {
  width: var(--sidebar-width);
  background: var(--bg-secondary);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
}
 
.sidebar-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px;
  border-bottom: 1px solid var(--border);
}
 
.sidebar-header h2 {
  font-size: 18px;
  font-weight: 600;
}
 
.btn-new {
  width: 32px;
  height: 32px;
  border-radius: 8px;
  border: none;
  background: var(--accent);
  color: var(--bg-primary);
  font-size: 20px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
 
.notes-list {
  list-style: none;
  overflow-y: auto;
  flex: 1;
}
 
.note-item {
  padding: 12px 16px;
  border-bottom: 1px solid var(--border);
  cursor: pointer;
  position: relative;
  transition: background 0.15s;
}
 
.note-item:hover {
  background: var(--bg-surface);
}
 
.note-item.active {
  background: var(--bg-surface);
  border-left: 3px solid var(--accent);
}
 
.note-item-title {
  font-size: 14px;
  font-weight: 500;
  margin-bottom: 4px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
 
.note-item-date {
  font-size: 11px;
  color: var(--text-secondary);
}
 
.btn-delete {
  position: absolute;
  top: 8px;
  right: 8px;
  width: 24px;
  height: 24px;
  border-radius: 4px;
  border: none;
  background: transparent;
  color: var(--text-secondary);
  font-size: 16px;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s;
}
 
.note-item:hover .btn-delete {
  opacity: 1;
}
 
.btn-delete:hover {
  background: var(--danger);
  color: white;
}
 
.editor {
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: 24px;
}
 
.editor-empty {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-secondary);
}
 
.editor-title {
  font-size: 28px;
  font-weight: 700;
  border: none;
  background: transparent;
  color: var(--text-primary);
  outline: none;
  margin-bottom: 16px;
  padding: 4px 0;
}
 
.editor-content {
  flex: 1;
  border: none;
  background: transparent;
  color: var(--text-primary);
  outline: none;
  font-size: 15px;
  line-height: 1.7;
  resize: none;
  font-family: inherit;
}

الخطوة 6: تشغيل خادم التطوير

شغّل خادم تطوير Tauri:

npm run tauri dev

هذا يشغّل كلاً من خادم Vite (لإعادة تحميل React الفوري) ونافذة Tauri. سترى تطبيق الملاحظات مع شريط جانبي ومحرر. يُعاد تجميع Rust تلقائياً عند تغيير ملفات src-tauri/.

الخطوة 7: إضافة شريط النظام

يدعم Tauri 2 تكامل شريط النظام بشكل أصلي. حدّث src-tauri/src/lib.rs لإضافة أيقونة الشريط:

use tauri::{
    menu::{Menu, MenuItem},
    tray::TrayIconBuilder,
    Manager,
};
 
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_fs::init())
        .plugin(tauri_plugin_dialog::init())
        .setup(|app| {
            let quit = MenuItem::with_id(app, "quit", "خروج", true, None::<&str>)?;
            let show = MenuItem::with_id(app, "show", "إظهار النافذة", true, None::<&str>)?;
            let menu = Menu::with_items(app, &[&show, &quit])?;
 
            TrayIconBuilder::new()
                .menu(&menu)
                .tooltip("تطبيق الملاحظات")
                .on_menu_event(|app, event| match event.id.as_ref() {
                    "quit" => app.exit(0),
                    "show" => {
                        if let Some(window) = app.get_webview_window("main") {
                            let _ = window.show();
                            let _ = window.set_focus();
                        }
                    }
                    _ => {}
                })
                .build(app)?;
 
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![list_notes, save_note, delete_note])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

أضف صورة أيقونة الشريط في src-tauri/icons/tray.png (PNG بحجم 32×32 مع شفافية).

الخطوة 8: البناء والتعبئة

عندما يكون تطبيقك جاهزاً، ابنِه للتوزيع:

npm run tauri build

هذا يجمّع خلفية Rust بوضع الإصدار ويُعبّئ تطبيقك:

المنصةالناتجالحجم
macOS.dmg, .app~8 م.ب
Windows.msi, .exe~5 م.ب
Linux.deb, .AppImage~7 م.ب

الحزم تُخرج إلى src-tauri/target/release/bundle/.

قارن ذلك بتطبيق Electron مكافئ بحجم 150-200 م.ب — تطبيقات Tauri أصغر بشكل كبير.

الخطوة 9: التحديث التلقائي (اختياري)

يتضمن Tauri 2 محدّثاً مدمجاً. أضفه لمشروعك:

npm run tauri add updater

إعداد نقطة نهاية التحديث في tauri.conf.json:

{
  "plugins": {
    "updater": {
      "endpoints": [
        "https://releases.myapp.com/{{target}}/{{arch}}/{{current_version}}"
      ],
      "pubkey": "YOUR_PUBLIC_KEY"
    }
  }
}

أنشئ مفاتيح التوقيع بـ:

npm run tauri signer generate -- -w ~/.tauri/myapp.key

استكشاف الأخطاء

أخطاء تجميع Rust

تأكد من تحديث سلسلة أدوات Rust:

rustup update

نافذة فارغة على Linux

ثبّت الإصدار الصحيح من WebKitGTK:

sudo apt install libwebkit2gtk-4.1-dev

أخطاء رفض الصلاحية

تحقق من أن capabilities/default.json يتضمن الصلاحيات المطلوبة لكل إضافة تستخدمها. Tauri 2 صارم بشأن الصلاحيات حسب التصميم.

بطء البناء الأول

التجميع الأول لـ Rust يحمّل ويجمّع كل التبعيات. الإنشاءات اللاحقة أسرع بكثير بفضل التخزين المؤقت. استخدم cargo clean فقط عند الضرورة.

الخطوات التالية

الآن بعد أن أصبح لديك تطبيق Tauri 2 يعمل، يمكنك:

  • إضافة اختصارات لوحة المفاتيح مع إضافة Tauri للاختصارات العامة
  • تنفيذ عرض Markdown في المحرر
  • إضافة وظيفة البحث عبر الملاحظات
  • إعداد CI/CD مع GitHub Actions للبناء الآلي
  • إضافة تخزين قاعدة بيانات مع SQLite عبر إضافة tauri-plugin-sql
  • البناء للهواتف (iOS/Android) باستخدام دعم Tauri 2 للهواتف

الخلاصة

لقد بنيت تطبيق سطح مكتب كامل باستخدام Tauri 2 وReact. مقارنةً بـ Electron، تطبيقك أصغر وأسرع وأكثر أماناً — مستفيداً من أداء Rust ونموذج أمان Tauri القائم على الصلاحيات. نظام الإضافات يجعل الوصول إلى واجهات النظام الأصلية سهلاً مع الحفاظ على حجم صغير. يمثل Tauri 2 مستقبل تطوير تطبيقات سطح المكتب متعددة المنصات لمطوري الويب.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على مركز دروس AI SDK: دليلك الشامل لبناء تطبيقات الذكاء الاصطناعي.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة