إزاي البرنامج متبني
XTop تطبيق Electron. يعني حاجتين شغالين مع بعض:
- الـ main process — عملية Node.js واحدة. دي اللي عندها صلاحيات على الجهاز.
- الـ renderers — تسع نوافذ Chromium. دي بترسم الشاشة وبس.
أي حاجة في الموقع ده — فتح مشروع في WSL، تشغيل أمر في shell، عمل transcription لاجتماع — بتحصل لأن الـ renderer بيطلب من الـ main process يعملها.
الصفحة دي بتشرح التقسيمة دي، وبعدين بتقولك كل service متنفّذة في أنهي ملف.
الجهتين
┌─ main process ───────────────────────────────┐
│ electron/main.js │
│ windows · tray · global shortcuts │
│ powerMonitor · single-instance lock │
│ │
│ electron/ipc.js │
│ ~130 IPC channel، واحدة لكل قدرة │
│ │
│ launcher · terminal · git · notes · backup │
│ islamic · reminders · whisper · store … │
└───────────────────┬──────────────────────────┘
│ contextBridge — الباب الوحيد
┌───────────────────┴──────────────────────────┐
│ preload.js → window.api │
├──────────────────────────────────────────────┤
│ 9 renderers (Vue 3 + Vite) │
│ orb · panel · terminal · notes · api │
│ meetings · athkar · alert · remind │
└──────────────────────────────────────────────┘الـ main process هو المكان الوحيد اللي عنده صلاحيات حقيقية: بيعمل spawn لعمليات، بيقرا ويكتب files، بيكلّم الشبكة، بيسجّل global shortcuts، وبيرسم أيقونة الـ tray.
الـ renderers صفحات ويب عادية، شغالة بـ contextIsolation: true و nodeIntegration: false. يعني مفيش require ومفيش fs ومفيش وصول مباشر لحاجة. الـ renderer مش قادر يفتح الـ IDE بتاعك — هو بيطلب بس.
الـ bridge
electron/preload.js هو الباب الوحيد بين الجهتين. بينادي contextBridge.exposeInMainWorld('api', { … })، اللي بتحط object واحد اسمه window.api على الصفحة، فيه حوالي ١٨٠ function بأسماء محددة ومفيش غيرهم.
كل function فيهم مجرد wrapper رفيع على IPC channel:
getState: () => ipcRenderer.invoke('state:get'),
openProject: (input) => ipcRenderer.invoke('projects:open', input),بما إن السطح ده قائمة صريحة، الـ renderer يقدر يعمل الـ ١٨٠ حاجة اللي في القائمة وبس. مفيش channel عامة اسمها "شغّل ده".
الـ event channels كلها ماشية على اتفاق واحد: بتاخد handler وبترجّع دالة الـ unsubscribe بتاعتها. كده الـ component بينضّف نفسه في الـ unmount، والـ main process ما بيفضلش ماسك listener لنافذة اتقفلت:
onStateChanged: (handler) => {
const listener = (_event, state) => handler(state);
ipcRenderer.on('state:changed', listener);
return () => ipcRenderer.off('state:changed', listener);
},state واحدة لتسع نوافذ
مفيش store في الواجهة بيملك الحقيقة. electron/store.js هو اللي ماسكها، وأي mutation بتعدّي على wrapper واحد في electron/ipc.js:
const mutate = (fn) => async (_event, ...args) => {
const result = await fn(...args);
broadcast(); // state:changed → لكل نافذة مفتوحة
return result;
};broadcast() بتلف على BrowserWindow.getAllWindows() وبتبعت الـ state الجديدة لكل واحدة. عشان كده لما تغيّر اسم مشروع في الـ panel، الـ picker في نافذة الـ notes بيتحدّث في نفس اللحظة. وعشان كده لما تحوّل للعربي، التسع نوافذ بيتقلبوا مرة واحدة مش اللي قدامك بس.
الحفظ في ملف JSON واحد، وelectron/json-db.js بيكتبه atomically — يعني الكراش في نص الكتابة مش ممكن يسيب ملف نُص. ولو الملف طلع تالف بيتنقل جنبًا بدل ما يترمي. شوف أين تُحفظ البيانات.
الأخطاء اللي المستخدم يقدر يتصرف فيها
ipcMain.handle لما ترمي Error بتوصّل الرسالة بس — الـ code بيضيع في الطريق.
فالأخطاء اللي المستخدم فعلًا يقدر يعمل فيها حاجة (WSL ناقص، model مش مختار) ما بتترميش، بترجع كـ data:
{ ok: false, code: 'WHISPER_MODEL_MISSING', detail: { … } }وأي حاجة تانية بتفضل ترمي وتنتهي في toast عام.
ده كمان سبب إن الـ main process مافيهوش أي ترجمة خالص: هو بيرجّع codes، والـ renderer هو اللي بيحوّلها لنص باللغة الشغالة.
النوافذ
كل نافذة من التسعة هي Vite entry point بنفس الاسم، فالـ main process بيوصلها كلها بنفس السطر — dist/<name>.html في الـ build، أو الـ dev server URL وقت التطوير:
const promise = DEV_SERVER_URL
? win.loadURL(`${DEV_SERVER_URL}/${page}.html`)
: win.loadFile(pageFile(page));كلهم frameless. والـ orb والـ panel وبطاقة الـ alert وبطاقة الـ remind كمان transparent و always-on-top، ومعلّمين visible on all workspaces — عشان يفضلوا ظاهرين لما تبدّل virtual desktop أو تفتح تطبيق full-screen.
والتطبيق بياخد single-instance lock: لو شغّلت نسخة تانية، بتسلّم للنسخة الشغالة وبتخرج. عشان كده الدبل كليك على الاختصار مالوش أي ضرر.
الـ orb: نافذة ممنوع تتحرك
الـ CSS مش قادر يرسم برّه النافذة، وأذرع الـ orb الأربعة مش داخلة في الـ ٤٥ بكسل اللي الـ orb شكله واخدها. فالنافذة أكبر على طول — ١٤٥ بكسل — والعلامة عايمة في نصها والباقي transparent.
كانت بتكبر عند الـ hover، وده ما نفعش يبقى ناعم. تحريك نافذة وإعادة رسم محتواها مش عملية واحدة، فلـ frame أو اتنين كان الرسم القديم الصغير بيقعد على الـ origin الجديد المزحزح — والـ orb بيتنطط قدامك وبعدين يرجع مكانه. والنافذة اللي عمرها ما بتتحرك مش ممكن تعمل كده.
تكلفة النافذة الكبيرة الدايمة إن الهامش الفاضي بتاعها كان هيبلع الكليكات المتوجّهة لأي حاجة وراها. الحل: النافذة بتتساب click-through (setIgnoreMouseEvents(true, { forward: true }))، وما بتبقاش صلبة غير لما المؤشر يبقى فعلًا على العلامة.
والمقايضة دي ليها catch يستاهل تعرفه. لو قفلت setIgnoreMouseEvents والمؤشر أصلًا جوه النافذة، الـ OS بيصفّر الـ mouse-leave tracking بتاعه — فالـ mouseleave اللي المفروض يقفل الـ orb ساعات ما بيوصلش خالص. النتيجة: orb مفتوح وماسك الـ mouse input والمؤشر في مكان تاني تمامًا.
فطول ما الـ orb مفتوح — وساعتها بس — الـ main process بيراقب مكان المؤشر الحقيقي كـ backstop وبيقفله من ناحيته. والـ threshold بتاعه أوسع شوية عن اللي الـ renderer بيستخدمه عن قصد، فالاتنين عمرهم ما هيتخانقوا على بكسل عند الحدود.
والفتح shape morph مش transform: كل ذراع معرّف بـ path اتنين، --d-closed و --d-open، والـ CSS بيعمل animate للـ d بينهم. لو كان transform بيطوّل الذراع، كان هيمطّ الأيقونة اللي جواها معاها. مفيش حاجة في الـ orb بتتعمل لها scale، فمفيش حاجة فيه ممكن تبان كأنها بتعمل zoom.
والعلامة نفسها generated مش مرسومة مرتين. src/shared/logo-x.mjs بيعرّف الأربع أذرع على مربع ١٠٠×١٠٠، والاتنين بيتبنوا منه: الـ inline SVG اللي في الـ orb، والـ raster اللي أيقونات الـ OS بتتخبز منه (scripts/gen-logo.mjs). فالشكل اللي في الـ tray والشكل اللي على الشاشة عمرهم ما هيختلفوا.
وذراع التسجيل ماشية على channel خاصة بيها مش على الـ store، لأن "في اجتماع بيتسجّل" حالة حيّة مالهاش حاجة تتحفظ — orbRecording للسؤال، و onOrbRecording للمتابعة، و stopOrbRecording للتنفيذ.
service service
| الـ service | الـ module في الـ main process | بتشتغل إزاي فعلًا |
|---|---|---|
| فتح مشروع | launcher.js | بيعمل spawn للـ IDE أو الـ terminal أو الـ file manager أو Figma أو Postman. وpaths.js بيحوّل بين مسارات Windows و WSL، فمشروع WSL بيتفتح بـ wsl.exe -d <distro> --cd <path> بينما Git لسه بيتعامل معاه بمساره الويندوزي |
| استيراد المشاريع | scanner.js و command-detect.js | بيلف على folder يدوّر على .git وعلى package manifests، وبيقرا كل مشروع لقاه عشان يطلّع منه أوامر — شوف تحت |
| الأوامر والـ terminal | terminal.js | جلسات pty حقيقية عن طريق node-pty. بيتحمّل جوه try/catch، ولو الـ native module ما اشتغلش بيرجّع { interactive: false, reason } ويرجع لـ child_process. ده اللي بيحط الـ banner في نافذة الـ terminal بدل ما يسيبك مع كيبورد ميت |
| معلومات Git | git.js | wrappers على simple-git — الـ status والـ branches والـ checkout، ومخرجات --graph كما هي |
| الـ notes | notes.js | عمليات CRUD على ملفات Markdown عادية في الـ app data folder. مفيش database |
| الـ API tester | api-client.js و api-store.js و env-scan.js و curl.js و postman.js | الـ requests بتتبعت من الـ main process مش من الصفحة، وده اللي بيخلي "تجاهل أخطاء TLS" لكل environment والـ cancellation الحقيقي ممكنين. وenv-scan.js بيقرا ملفات .env بتاعة المشروع عشان الـ base URL. الـ requests المحفوظة files في المشروع، والـ secrets لأ |
| الاجتماعات | whisper.js و whisper-model.js و parakeet.js و summarizer.js و meetings-store.js | الـ service الوحيدة اللي محتاجة الجهتين في نفس الوقت — شوف تحت |
| الوضع الإسلامي | islamic.js و athkar-data.js | بيجيب من Aladhan API مرة في اليوم ويعمل لها cache، وبيشغّل ساعة التنبيه، وبيقفل الشاشة بـ rundll32 user32.dll,LockWorkStation. وبيسمع لـ powerMonitor بتاع Electron، وده اللي بيخلي التنبيه يقدر يستنى جهاز مقفول أو نايم ويظهر لما ترجع |
| الـ reminders | reminders.js | ticker واحد كل ٢٠ ثانية بدل timer لكل reminder. ده سبب إن الـ reminders بتصمد مع نوم الجهاز وإعادة تشغيل البرنامج بدل ما تضيع مع الـ timer بتاعها |
| الـ backup | backup.js و zip.js | zip writer و reader بسيطين بلا أي dependency، مع retention و restore متحقّق منه بالـ checksums |
| مشاريع بدء التشغيل | startup.js | بيشتغل بعد الـ boot، وبيباعد بين عمليات الفتح لأن Windows بيسقط نوافذ الـ IDE اللي بتتفتح في نفس اللحظة |
| الإعدادات والأصوات والـ tray | store.js و sounds.js و main.js | الـ tray menu بيتبني من جديد كل ما الـ state اللي بيعكسها تتغيّر |
| فحص WSL | wsl.js | بيفحص إذا كان WSL صالح للاستخدام فعلًا — ناقص، أو نسخة الـ Store الوهمية، أو مفيش distro مسجّل — وبيفشل مفتوحًا: لو الفحص مقدرش يجاوب بثقة، مفيش حاجة بتتعطّل |
اكتشاف الأوامر
electron/command-detect.js هو اللي بيخلي المشروع المستورد يوصل وnpm run dev موجودة في قائمته أصلًا. وscanner.js بينادي عليه لكل folder بيطابقه، فالـ scan الواحدة بتطلّع قائمة المشاريع وأوامرها مع بعض — والـ renderer عمره ما بيقرا ملف بنفسه.
الموديول عبارة عن مجموعة detectors صغيرة، بتتنفّذ بالترتيب وبيتعمل لها dedupe على سطر الأمر:
const DETECTORS = [fromPackageJson, fromComposer, fromMakefile,
fromPython, fromGo, fromRust, fromCompose];وفي تلات قواعد ماسكة الموديول كله:
- best-effort، وعمره ما يفشّل حاجة. كل detector شغال جوه
try/catchبتاعه، والملف اللي مش راضي يتقرا ما بيطلّعش حاجة والباقي بيكمّل عادي. الاستيراد ممنوع يفشل بسبب حاجة موجودة صدفة في فولدر المشروع. - مفيش dependencies جديدة. الـ TOML والـ YAML بيتفحصوا بـ regex، وبس للأشكال القليلة اللي فارقة فعلًا — زي
[project.scripts]، أو مجرد وجود ملف compose. جر parser كامل عشان الحتة دي كان هيبقى التكلفة الأكبر. - قراءة، مش تنفيذ. مفيش أي حاجة في المشروع بتتشغّل. حتى الـ package manager بيتستنتج من اللي على القرص، مش بسؤاله.
والاستنتاج ده بالذات هو اللي المستخدم بيحسّه. الـ runner لازم يطابق المشروع، لأن الأدوات نفسها مختلفة في الـ syntax بتاعها — npm run dev و bun run dev، بس pnpm dev و yarn dev:
const declared = String(manifest?.packageManager || '').split('@')[0].trim();
if (declared === 'pnpm') return 'pnpm';
// …
if (await exists(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';حقل packageManager هو المرجع لو موجود، لأن corepack بيفرضه. ولو مش موجود، الـ lockfile اللي على القرص هو الإشارة الوحيدة الصادقة.
والـ detector بيقترح بس. store.addCommands هي اللي بتكتب، في write واحدة للدفعة كلها، وبتتخطّى أي سطر المشروع عنده أصلًا. وعشان كده اكتشاف الأوامر ينفع تدوسه على مشروع قديم فيضيف الجديد وبس. السلوك من ناحية المستخدم في صفحة الأوامر.
الاجتماعات بالتفصيل
كل service تانية عايشة على جهة واحدة من الـ bridge. الاجتماعات متقسّمة من النص، والتقسيم ده مش عشوائي:
- الـ audio capture مكانه الـ renderer. الوصول للمايك، و loopback صوت النظام، و Web Audio API — دي كلها browser APIs. الـ main process مالوش كارت صوت.
- تشغيل الـ transcription engine مكانه الـ main process. هو اللي بيعمل spawn لـ executable أصلي، وبيدير الـ lifetime بتاعه، وبيملك الملفات على القرص.
يعني باختصار: الـ renderer بيحوّل الصوت لـ WAV segments صغيرة، والـ main process بيحوّل الـ segments لنص.
renderer main process
──────── ────────────
getUserMedia ─┐
getDisplayMedia ┴→ GainNode (mix)
↓
AudioWorklet (pcm-tap)
↓ Float32، بلوكات ١٦٠٠ عيّنة
VAD segmenter
↓ نطق واحد
resample → WAV mono 16kHz
↓
transcribeMeetingChunk ──→ queue (12 حد أقصى)
↓
whisper-server.exe
(أو parakeet CLI)
onMeetingTranscript ←── نص
↓
stitch على الـ transcript
↓
stop → saveMeetingArchive ─→ meeting.json
↓
summarizeMeeting ──→ Ollama / Groq /
OpenRouter / Anthropicالتقاط مصدرين
src/meetings/audio/mixer.js بيفتح المايك بـ getUserMedia، والسماعات بـ getDisplayMedia({ audio: true }) — حيلة الـ loopback. دي السبب إن الطرف التاني في المكالمة بيتعملّه transcription، مش إنت بس.
بيطلب أصغر video track ممكن ويوقّفه فورًا، لأن Chromium مش بيسلّم صوت النظام من غير واحد.
ولو الـ loopback مش متاح، بيطلّع AUDIO_LOOPBACK_UNSUPPORTED ويكمّل بالمايك لوحده بدل ما يفشّل التسجيل.
المسارين بيتخلطوا في GainNode واحد. والـ graph كمان فيه مسار صامت للـ destination، لأن الـ worklet graph في Chromium شغال pull-based: من غير حاجة بتسحب، الـ processor ما بيتنادىش ومفيش صوت بيوصل.
والـ context بيتطلب على ١٦ كيلوهرتز مباشرة. بعض الـ drivers بتتجاهل ده وبترجّع ٤٨ كيلوهرتز برضه، فالكود بيفحص context.sampleRate بعد كده بدل ما يثق في الطلب، وبيعمل resample من خلال OfflineAudioContext لما يضطر.
التقطيع لنُطق
audio/pcm-tap.js هو AudioWorkletProcessor — بيشتغل على الـ audio thread، بيعمل soft-clip لكل sample، وبيبعت بلوكات ١٦٠٠ عيّنة كـ transferable buffers فمفيش أي نسخ.
audio/segmenter.js هو VAD (voice activity detector) مش timer. بيشتغل على frames ٢٠ مللي ثانية وبيقارن الـ RMS بتاع كل frame بـ rolling noise floor — المئين العاشر لآخر تلات ثواني. كده بيتأقلم مع مروحة أو تكييف أو أوضة هادية بدل threshold ثابت.
- تلات frames عالية ورا بعض بيفتحوا segment، وبيتضاف pre-roll ٣٠٠ مللي ثانية قبلها فأول كلمة ما تتقصّش.
- ٦٠٠ مللي ثانية سكوت بتقفل الـ segment.
- الـ segment بيتبعت بالعافية عند ١٢ ثانية، فالمونولوج برضه بيتبث.
- وأي segment فيه أقل من ٧٠٠ مللي ثانية كلام فعلي بيتحذف بدل ما يتبعت.
التقطيع على السكوت مش على الساعة هو اللي بيخلي الكلمات كاملة — القطع على فترة ثابتة كان هيقطع نص كلمة كذا مرة في الدقيقة.
الـ engine
electron/whisper.js هو اللي بيملك الـ local server:
- الـ start-up بيعمل spawn لـ
whisper-server.exeعلى localhost port فاضي، بالـ model المختار وعدد الـ threads، وبعدين بيعمل polling على health endpoint لحد ما ترد. - adopt أو kill. الـ pid والـ port محفوظين. لو البرنامج كراش وساب server شغّال، التشغيل اللي بعده يا إما يتبنّاه — من غير ما يدفع تكلفة start-up تانية — يا إما يقتله لو مش صالح.
- queue بحد أقصى ١٢ segment. الـ transcription أبطأ من الكلام على model كبير، فالـ segments بتستنى في الـ queue بدل ما تتكوّم بلا حدود. وعمق الـ queue بيتبعت مع كل تغيير، وده سطر "متأخر بـ N segment" في النافذة. والـ queue الممتلئة بترجّع
WHISPER_BUSYبدل ما ترمي صوت في صمت. - typed failures.
WHISPER_BINARY_MISSINGوWHISPER_MODEL_MISSINGوWHISPER_PORT_BUSYوWHISPER_ARCH_MISMATCHوWHISPER_CRASHEDوWHISPER_MODEL_PATH_UNSUPPORTED— بترجع كـ codes وبتتترجم في الـ renderer، وده اللي بيخلي النافذة تقدر تقول لك تعمل إيه في كل واحدة.
parakeet.js هو الـ engine البديل: بدل server مستمر، بيشغّل CLI لكل segment. فبيدفع تكلفة بدء عملية كل مرة، لكنه أسرع في الـ segment الواحد. نفس الـ queue، نفس الـ interface.
والنص الراجع بيتعمله stitch على الـ transcript بمقارنة آخر اللي موجود بأول اللي وصل، فالتداخل بتاع الـ pre-roll ما ينتجش كلمات مكررة.
إزاي الـ settings بتوصل للـ engine
مفيش حاجة في الـ engine hardcoded. كل اختيار في الـ model dialog هو key تحت settings.meetings في نفس ملف الـ JSON بتاع كل حاجة تانية. وwhisper.js بيقرا الـ object ده مع كل call بدل ما يمسك نسخة خاصة بيه:
| الـ setting key | بيحدد إيه |
|---|---|
engine | whisper (local server مستمر) ولا parakeet (CLI لكل segment) |
model | أنهي entry من الـ catalogue. الـ path بيتبني من الـ key ده، وعشان كده الـ key لازم يفضل هو نفسه جذر اسم الملف بالظبط |
whisperModelDir | مكان الـ models. الافتراضي C:\ProgramData\XTop\models |
whisperModelPath | model اخترته بإيدك، بيتخطّى الـ catalogue |
whisperBinaryPath | server executable وجّهت البرنامج عليه، بيتخطّى المرفق |
parakeetModelPath | نفس الحاجة، لـ engine الـ parakeet |
whisperServer | { pid, port } لـ server شغّال، محفوظ عشان التشغيل اللي بعد الكراش يعمله adopt أو kill |
modelMirror | مصدر التنزيل، الافتراضي huggingface.co |
language | بتتبعت للـ engine كـ -l؛ وبترجع للغة البرنامج لو فاضية |
summaryProvider و ollamaUrl و ollamaModel و summaryModel | خاصة بالـ summariser، مش بالـ transcription |
تغيير أي setting بيلغي الـ probe المخزّنة. الـ capability detection مكلّفة — بتعمل stat لملفات وبتشغّل الـ executable بـ --help — فبتتعمل لها memoise لطول عمر الـ process.
ده كان معناه إن اختيار model جديد ما يغيّرش حاجة ظاهرة لحد ما تعيد التشغيل. فـ settings:update بيرمي الـ cache صراحةً لما الـ keys المهمة تتغيّر:
if ('meetings' in patch || 'language' in patch) whisper.invalidate();وعشان كده افحص تاني في الإعدادات، وسطر الحالة في الـ model dialog، بيقولوا الحقيقة فورًا بعد ما تغيّر حاجة.
الـ capability probe
meetingsCapability هي الـ channel اللي ورا جملة "Whisper المحلي جاهز". بتحدد الـ binary، وبتحدد الـ model، وبتصنّف النتيجة لواحد من الـ codes المحددة:
const context = {
model: modelPath(settings),
language: settings.language,
engine: isParakeet(settings) ? 'parakeet' : 'whisper',
};
const exe = binaryCandidates(settings).find(isFile);
if (!exe) return classify({ ...context, exe: null }); // WHISPER_BINARY_MISSING
const result = await runHelp(exe); // هل هو أصلًا بيشتغل هنا؟تشغيل الـ executable بـ --help هو اللي بيفرّق بين ناقص وموجود بس مش صالح. الـ architecture الغلط، أو ملفات ggml الناقصة جنبه — الاتنين بيظهروا هنا كـ WHISPER_ARCH_MISMATCH بدل ما يبقوا فشل غامض في نص أول اجتماع ليك.
ونفس الـ call بيكتشف إذا كان الـ build ده بيدعم --prompt، فالميزة بتتستخدم في المكان اللي بتشتغل فيه بس.
الـ model catalogue والتنزيل
whisper-model.js ماسك الـ catalogue كـ data، واسم الملف derived مش مكتوب مرتين:
const fileName = (model) => model.file || `ggml-${model.key}.bin`;
const repoPath = (model) => `${model.repo || 'ggerganov/whisper.cpp'}/resolve/main/${fileName(model)}`;في حاجتين في الـ catalogue تستاهلوا تعرفهم، لأنهم قرارات مش defaults:
- entries الـ
.enإنجليزية بس.tiny.enوbase.enمش قادرين يعملوا transcription للعربي خالص — الـ multilingual entries هي اللي بتخلي العربي يشتغل. ده الفرق الحقيقي بين الأحجام، مش مجرد الدقة. large-v3-turbo-q8_0بدّلmedium. لأن medium ضعف التنزيل، وبيخسر قدام turbo في العربي، ونسخة whisper.cpp دي أصلًا مش قادرة تعمله load.
التنزيل بيعمل stream للقرص مع progress و cancel token، وبيرجع من Hugging Face لـ hf-mirror.com. الـ fallback موجود لأن الـ host الأساسي مش موصول من كل مكان بشكل موثوق، ومش هو الافتراضي لأنه بيتأخّر في التحديث.
ليه الـ models في ProgramData
whisper.cpp بيفتح ملفات الـ model من خلال الـ ANSI code page بتاعة Windows، فالـ path اللي فيه حروف غير لاتينية ببساطة مش بيتفتح. والمستخدم اللي اسم حسابه في Windows بالعربي كان هيقع في ده مع أي مكان per-user، فالافتراضي بقى machine-wide في C:\ProgramData.
الكلام مع الـ local service
الـ server ده HTTP عادي على 127.0.0.1، وده بيخلي الـ contract بسيط:
start()بتختار localhost port فاضي وبتعمل spawn للـ executable بالـ model والـ language والـ host والـ port، وعدد threads يساويclamp(cpuCount - 2, 2, 8)— فالجهاز يفضل صالح للاستخدام وهو شغّال.- بتفضل تعمل polling لحد ما حاجة ترد على الـ port، وبعدين بتعلّم نفسها ready وبتطلّع state event النافذة بتعرضه.
- كل segment بيتبعت POST كملف WAV؛ والرد نص.
stop()بتقتل الـ child process. أو — للـ server اللي عمله adopt مش spawn، واللي مالوش child handle — بتقتله بالـ pid المحفوظ.
ولأنه مجرد local HTTP server، توجيه البرنامج على build بتاعك مسار مدعوم مش حيلة: حدّد whisperBinaryPath من تحديد السيرفر في الـ model dialog، وكل حاجة تانية هتشتغل زي ما هي.
الـ summary provider
meetingSummaryConfig بتبني محتوى الـ dialog وقت الفتح بدل ما يكون hardcoded. قائمة الـ providers بتيجي من summarizer.PROVIDERS، وكل entry شايل: هو مجاني ولا لأ، local ولا لأ، محتاج key ولا لأ، ومن فين تجيبه. وكمان إذا كان في key محفوظ بالفعل — من غير ما الـ key نفسه يتبعت للـ renderer أبدًا.
ولما Ollama يكون مختار، بتجيب كمان /api/tags من نسخة Ollama عندك، عشان خانة الـ model تبقى قائمة اختيار من الـ models اللي إنت فعلًا عملتلها pull بدل اسم لازم تكتبه بالظبط. ولو Ollama مش شغّال، الـ fetch بيتبلع والقائمة بتفضل فاضية؛ والخطأ بيتبلّغ صح بعدين، في اللحظة اللي بتطلب فيها الـ summary فعلًا.
الـ keys بتتكتب في apiSecrets تحت meetings:<provider> — نفس الـ store اللي فيه secrets الـ API tester، ولنفس السبب: هو المكان الوحيد اللي عمره ما بيوصل لمجلد مشروع.
بعد التسجيل
الـ stop بيكتب meeting.json فورًا — الـ transcript الأول، والـ summary بعده. كده الـ model اللي بيفشل ما يقدرش يكلّفك التسجيل.
وبعدين summarizer.js بيطلب من LLM ردّ JSON صارم ({ summary, decisions, actionItems }) وبيعمل validate للشكل قبل التخزين. Ollama هو الافتراضي لأنه بيخلي الميزة كلها local؛ و Groq و OpenRouter و Anthropic اختيارية ومحتاجة key، متخزّن في الـ app data folder مش في أي مشروع.
ولما الـ transcript عربي، بيطلب إجابة عربية مع الاحتفاظ بمفاتيح الـ JSON إنجليزية، فالـ parsing يفضل مستقل عن اللغة.
وpowerSaveBlocker بيتمسك طول التسجيل، ونافذة الاجتماعات بتتعمل بـ backgroundThrottling: false — لأن Chromium بيخنق الـ timers في النوافذ الخلفية، وده كان هيوقّف الـ audio pipeline أول ما تبدّل للتطبيق اللي إنت مجتمع عليه أصلًا.
جهة الـ renderer
تسع تطبيقات Vue 3، واحد لكل نافذة، و Vite بيبنيهم كتسع entry points منفصلة. وبيشتركوا في src/shared/: جدول الـ i18n، والـ theme tokens، ومجموعة صغيرة من الـ UI primitives.
اللغة والـ theme بيتطبّقوا قبل ما أي حاجة تعمل mount — كل entry point بينتظر نفس خطوة الـ boot — فمفيش نافذة بتومض بالـ palette الغلط أو بالاتجاه الغلط وهي داخلة:
applyLanguage().then(() => createApp(App).mount('#app'));الـ packaging
electron-builder بيطلّع NSIS installer ونسخة portable. وملفات Whisper التنفيذية بتتشحن كـ extraResources مش متضمّنة جوه الـ asar، لأنها لازم تكون ملفات قابلة للتنفيذ على القرص. وnode-pty بيتفك من الـ asar لنفس السبب.
