المتطلبات الأساسية
قبل البدء في هذا الدرس، تأكد من توفر الآتي:
- Bun 1.2+ مثبت (
curl -fsSL https://bun.sh/install | bash) - معرفة متينة بـ TypeScript (الواجهات، الأنواع العامة، async/await)
- إلمام بـ React (الخطافات، الحالة، التأثيرات)
- محرر نصوص — يُوصى بـ VS Code مع إضافة Bun
- macOS 13+، أو Windows 10+، أو Ubuntu 22.04+
ما الذي ستبنيه
ستنشئ NoteFlow — تطبيق ملاحظات مارك-داون بسيط لسطح المكتب يمكنه:
- إنشاء الملاحظات وتحريرها وحفظها على نظام الملفات المحلي
- سرد الملاحظات في شريط جانبي مع تبديل بنقرة واحدة
- استخدام RPC مُدوَّن بحيث يتشارك الواجهة والخلفية تعريف نوع واحد
- التجميع في أقل من 20 ميغابايت — مقارنةً بأكثر من 200 ميغابايت لتطبيق Electron مماثل
لماذا Electrobun؟ الحجة التقنية
أحدث Electron ثورة في تطوير تطبيقات سطح المكتب، لكنه يشحن محرك Chromium الكامل مع كل تطبيق. حجم تطبيق "Hello World" في Electron قرابة 140 ميغابايت. حلّ Tauri مشكلة الحجم بكتابة الخلفية بلغة Rust — لكنك الآن بحاجة إلى لغتين.
يتبع Electrobun نهجاً مختلفاً:
| الميزة | Electron | Tauri | Electrobun |
|---|---|---|---|
| بيئة التشغيل | Node.js + Chromium | Rust + WebView نظام | Bun + WebView نظام |
| أدنى حجم حزمة | 140 ميغابايت | 3 ميغابايت | 12 ميغابايت |
| لغة الخلفية | JavaScript / TypeScript | Rust | TypeScript |
| نمط الـ IPC | سلاسل أحداث | أوامر Rust | RPC غير متزامن مُدوَّن |
| تحديثات تفاضلية | يدوية | مدمجة | مدمجة (رقعات 4 كيلوبايت) |
| منحنى التعلم | منخفض | مرتفع | منخفض |
الخطوة الأولى: تثبيت Bun
يتطلب Electrobun Bun كبيئة تشغيل. إن لم يكن مثبتاً بعد:
# macOS / Linux
curl -fsSL https://bun.sh/install | bash
# Windows (PowerShell)
powershell -c "irm bun.sh/install.ps1|iex"
# التحقق
bun --version # يجب أن يُخرج 1.2.x أو أعلىالخطوة الثانية: إنشاء هيكل المشروع
يأتي Electrobun مع مولّد مشاريع يضبط تخطيط المجلدات الصحيح:
bunx electrobun initأجب على الأسئلة:
- اسم المشروع:
noteflow - القالب:
react - مدير الحزم:
bun
ثم انتقل إلى مجلد المشروع وثبّت التبعيات:
cd noteflow
bun installهيكل المشروع:
noteflow/
├── electrobun.config.ts # هوية التطبيق وأهداف البناء وقناة التحديث
├── package.json
├── bun.lockb
├── src/
│ ├── main/ # العملية الرئيسية — تعمل في Bun، لها وصول كامل للنظام
│ │ └── index.ts
│ ├── shared/ # الأنواع المشتركة بين العملية الرئيسية والـ WebView
│ └── views/ # واجهة الـ WebView — مجلد لكل عرض
│ └── mainview/
│ ├── index.html
│ ├── index.tsx
│ └── style.css
└── public/ # الأصول الثابتة المنسوخة داخل الحزمة
الخطوة الثالثة: فهم معمارية العمليتين
يقسم Electrobun تطبيقك إلى عمليتين معزولتين:
العملية الرئيسية (src/main/) تعمل في Bun:
- تنشئ النوافذ الأصلية وتديرها (
BrowserWindow) - تقرأ نظام الملفات وتكتب إليه عبر واجهات Bun الأصلية
- تبني قوائم النظام وصينية النظام ومربعات الحوار
- تنفذ أي عملية مميزة لا يستطيع الـ WebView القيام بها
عملية WebView (src/views/) تعرض واجهتك باستخدام محرك المتصفح الأصلي للنظام:
- macOS: WebKit (المحرك نفسه المستخدم في Safari)
- Windows: Edge WebView2 (يعتمد على Chromium، يأتي مع Windows 10+)
- Linux: WebKitGTK
تتواصل العمليتان عبر RPC مُدوَّن — أبرز ميزة في Electrobun.
الخطوة الرابعة: تعريف عقد RPC المشترك
أنشئ src/shared/rpc.ts:
import { defineRpc } from "electrobun/bun";
// استدعاءات من WebView إلى العملية الرئيسية
export const mainRpc = defineRpc({
listNotes: async (): Promise<string[]> => [],
readNote: async (filename: string): Promise<string> => "",
writeNote: async (filename: string, content: string): Promise<void> => {},
deleteNote: async (filename: string): Promise<void> => {},
openFilePicker: async (): Promise<string | null> => null,
});
// استدعاءات من العملية الرئيسية إلى WebView (أحداث دفع)
export const webviewRpc = defineRpc({
onNoteChanged: async (filename: string): Promise<void> => {},
});تحلل defineRpc توقيعات الدوال في وقت البناء. حين يستدعي الـ WebView mainRpc.call.readNote("my-note.md")، تفرض TypeScript أنواع المعاملات والقيم المُعادة بدقة.
الخطوة الخامسة: تنفيذ العملية الرئيسية
استبدل محتوى src/main/index.ts بتنفيذ الخلفية الكاملة:
import { BrowserWindow, app, dialog } from "electrobun/bun";
import { mainRpc } from "../shared/rpc";
import { join } from "path";
import { homedir } from "os";
import { mkdirSync } from "fs";
const NOTES_DIR = join(homedir(), ".noteflow", "notes");
mkdirSync(NOTES_DIR, { recursive: true });
// سجّل معالجات RPC قبل فتح النافذة
mainRpc.handle({
async listNotes() {
const glob = new Bun.Glob("*.md");
const files: string[] = [];
for await (const file of glob.scan(NOTES_DIR)) {
files.push(file);
}
return files.sort();
},
async readNote(filename) {
const path = join(NOTES_DIR, filename);
const file = Bun.file(path);
if (!(await file.exists())) return "";
return file.text();
},
async writeNote(filename, content) {
await Bun.write(join(NOTES_DIR, filename), content);
},
async deleteNote(filename) {
await Bun.file(join(NOTES_DIR, filename)).delete();
},
async openFilePicker() {
const result = await dialog.showOpenDialog({
filters: [{ name: "Markdown", extensions: ["md", "txt"] }],
properties: ["openFile"],
});
return result.canceled ? null : result.filePaths[0];
},
});
app.on("ready", () => {
const win = new BrowserWindow({
title: "NoteFlow",
url: "views://mainview/index.html",
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
styleMask: ["titled", "closable", "miniaturizable", "resizable"],
});
win.show();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});الخطوة السادسة: بناء واجهة React
استبدل محتوى src/views/mainview/index.tsx:
import React, { useState, useEffect, useCallback, useRef } from "react";
import { createRoot } from "react-dom/client";
import { mainRpc } from "../../shared/rpc";
type Note = { filename: string; title: string };
function noteTitle(filename: string) {
return filename.replace(/\.md$/, "").replace(/-/g, " ");
}
function App() {
const [notes, setNotes] = useState<Note[]>([]);
const [active, setActive] = useState<string | null>(null);
const [content, setContent] = useState("");
const [dirty, setDirty] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
refresh();
}, []);
async function refresh() {
const files = await mainRpc.call.listNotes();
const noteList = files.map((f) => ({ filename: f, title: noteTitle(f) }));
setNotes(noteList);
if (noteList.length > 0 && active === null) {
await openNote(noteList[0].filename);
}
}
async function openNote(filename: string) {
if (dirty && active) await save(active, content);
const text = await mainRpc.call.readNote(filename);
setActive(filename);
setContent(text);
setDirty(false);
}
function handleChange(value: string) {
setContent(value);
setDirty(true);
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
if (active) save(active, value);
}, 1500);
}
const save = useCallback(async (filename: string, text: string) => {
await mainRpc.call.writeNote(filename, text);
setDirty(false);
}, []);
async function newNote() {
const timestamp = new Date().toISOString().split("T")[0];
const filename = `note-${timestamp}-${Date.now()}.md`;
await mainRpc.call.writeNote(filename, `# ملاحظة جديدة\n\n`);
await refresh();
await openNote(filename);
}
async function deleteActive() {
if (!active || !confirm(`حذف "${noteTitle(active)}"؟`)) return;
await mainRpc.call.deleteNote(active);
setActive(null);
setContent("");
setDirty(false);
await refresh();
}
return (
<div className="app">
<aside className="sidebar">
<div className="sidebar-header">
<span className="brand">NoteFlow</span>
<button className="icon-btn" onClick={newNote} title="ملاحظة جديدة">+</button>
</div>
<ul className="note-list">
{notes.map((n) => (
<li
key={n.filename}
className={n.filename === active ? "active" : ""}
onClick={() => openNote(n.filename)}
>
{n.title}
</li>
))}
</ul>
</aside>
<main className="editor-area">
{active ? (
<>
<div className="toolbar">
<span className="status">{dirty ? "غير محفوظ…" : "تم الحفظ"}</span>
<button onClick={() => active && save(active, content)}>حفظ</button>
<button className="danger" onClick={deleteActive}>حذف</button>
</div>
<textarea
className="editor"
value={content}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
e.preventDefault();
if (active) save(active, content);
}
}}
spellCheck={false}
dir="auto"
/>
</>
) : (
<div className="empty-state">
<p>انقر + لإنشاء أول ملاحظة.</p>
</div>
)}
</main>
</div>
);
}
const el = document.getElementById("app");
if (el) createRoot(el).render(<App />);الأنماط الرئيسية المستخدمة:
- حفظ تلقائي بعد 1.5 ثانية من انتهاء الكتابة
Cmd+S/Ctrl+Sيُشغّل حفظاً فورياً- التبديل بين الملاحظات يحفظ الملاحظة السابقة تلقائياً قبل التحميل
الخطوة السابعة: إضافة الأنماط البصرية
استبدل محتوى src/views/mainview/style.css بالأنماط الموجودة في الملف الإنجليزي (الأنماط الـ CSS متطابقة عبر اللغات).
الخطوة الثامنة: التشغيل في وضع التطوير
ابدأ خادم التطوير:
bun startيُطلق Electrobun العملية الرئيسية في Bun ويفتح نافذة أصلية تعرض الـ WebView. التغييرات على مصادر الـ WebView تُعيد تحميل الواجهة تلقائياً.
الخطوة التاسعة: إضافة قائمة أصلية
أضف قائمة تطبيق macOS/Windows مناسبة في src/main/index.ts:
import { Menu, MenuItem } from "electrobun/bun";
function buildMenu(win: BrowserWindow) {
const menu = new Menu();
const fileMenu = new MenuItem({
label: "ملف",
submenu: [
new MenuItem({
label: "ملاحظة جديدة",
accelerator: "CmdOrCtrl+N",
click: () => win.webContents.executeScript("window.__newNote?.()"),
}),
new MenuItem({ type: "separator" }),
new MenuItem({ role: "quit" }),
],
});
const editMenu = new MenuItem({
label: "تحرير",
submenu: [
new MenuItem({ role: "undo" }),
new MenuItem({ role: "redo" }),
new MenuItem({ type: "separator" }),
new MenuItem({ role: "cut" }),
new MenuItem({ role: "copy" }),
new MenuItem({ role: "paste" }),
new MenuItem({ role: "selectAll" }),
],
});
menu.append(fileMenu);
menu.append(editMenu);
Menu.setApplicationMenu(menu);
}الخطوة العاشرة: البناء للإنتاج
حين يكون تطبيقك جاهزاً للشحن:
bunx electrobun build --env=stableمواقع المخرجات:
- macOS:
dist/NoteFlow.app - Windows:
dist/NoteFlow-win-x64.exe - Linux:
dist/NoteFlow-linux-x64.AppImage
ستكون حزمة macOS بحجم 14–64 ميغابايت تقريباً حسب التبعيات — دون Chromium أو Node.js أو بيئة تشغيل Electron.
الخطوة الحادية عشرة: التحديثات التفاضلية
ينشئ نظام تحديث Electrobun فروقاً ثنائية بين الإصدارات. التحديث النموذجي لا يتجاوز 4–14 كيلوبايت بدلاً من إعادة تثبيت كاملة.
اضبط electrobun.config.ts:
import { defineConfig } from "electrobun/config";
export default defineConfig({
app: {
name: "NoteFlow",
version: "1.0.0",
identifier: "tn.noqta.noteflow",
},
updates: {
provider: "github",
owner: "your-org",
repo: "noteflow",
channel: "stable",
},
build: {
targets: ["mac-arm64", "mac-x64", "win-x64", "linux-x64"],
},
});تحقق من التحديثات من العملية الرئيسية:
import { autoUpdater } from "electrobun/bun";
autoUpdater.on("update-downloaded", () => {
autoUpdater.quitAndInstall();
});
await autoUpdater.checkForUpdates();اختبار التنفيذ
بعد تشغيل bun start، تحقق من هذه النقاط:
- تفتح النافذة بالعنوان الصحيح NoteFlow
- النقر على + ينشئ ملف
.mdجديداً داخل~/.noteflow/notes/ - الكتابة في المحرر تحفظ تلقائياً بعد 1.5 ثانية (لاحظ نص الحالة)
Cmd+S/Ctrl+Sيُشغّل حفظاً فورياً- التبديل بين الملاحظات يحفظ السابقة تلقائياً
- بعد
bunx electrobun build، الحزمة الناتجة أقل من 100 ميغابايت
استكشاف الأخطاء وإصلاحها
نافذة بيضاء فارغة عند الإطلاق
تأكد من أن src/views/mainview/index.html يحتوي على <div id="app"></div> وأن index.tsx يستورد style.css. شغّل bun run build:webview بشكل منفصل للكشف عن أخطاء التجميع.
استدعاء RPC يتوقف إلى الأبد
تأكد من استدعاء mainRpc.handle(...) قبل app.on("ready"). إذا سُجّل المعالج بعد فتح النافذة، قد يصل أول استدعاء RPC من الـ WebView قبل وجود المعالج.
Linux: WebView فارغ على Ubuntu 22.04
ثبّت تبعية WebKit: sudo apt install webkit2gtk-4.1. على Ubuntu 24.04+ هذه مثبتة افتراضياً.
حجم مخرجات البناء أكبر من المتوقع
استخدم علامة --external في electrobun.config.ts لوضع علامة على الحزم الكبيرة باعتبارها خارجية وتجميع ما تحتاجه فقط.
الخطوات التالية
بمجرد أن يعمل NoteFlow، إليك امتدادات طبيعية للاستكشاف:
- معاينة Markdown — أضف جزءاً منقسماً باستخدام مكتبة
markedللتصيير الحي بـ HTML - صينية النظام — استخدم واجهة الصينية في Electrobun لإبقاء NoteFlow متاحاً دون نافذة
- نوافذ متعددة — أنشئ
BrowserWindowثانياً لوحة إعدادات - مزامنة iCloud — أشر
NOTES_DIRإلى مجلد iCloud Drive على macOS للمزامنة الشفافة
الخلاصة
بنيت تطبيق سطح مكتب يعمل بالكامل بـ TypeScript خالصة — دون Rust، دون محرك متصفح مجمّع، وبحجم حزمة أقل من 20 ميغابايت. يربط RPC المُدوَّن في Electrobun العمليتين الرئيسية والـ WebView بشكل نظيف، ونظام التحديث التفاضلي يعني أن مستخدميك لن يُعيدوا التثبيت الكامل أبداً.
Electrobun لا يزال في طور النضج، لكن الإصدار الأول جاهز للإنتاج للأدوات الداخلية والأدوات التطويرية وتطبيقات الأعمال. إذا كنت مرتاحاً لـ TypeScript وتريد شحن برنامج سطح مكتب أصلي دون تعلم Rust، فإن Electrobun هو المسار الأكثر سلاسة المتاح في 2026.