Skip to content

How it is built

XTop is an Electron application. That means two things running together:

  • The main process — one Node.js process. This is the side with privileges on the machine.
  • The renderers — nine Chromium windows. These draw the screen, and nothing else.

Everything on this site — opening a project in WSL, running a command in a real shell, transcribing a meeting — happens because a renderer asks the main process to do it.

This page explains that split, and then maps each service onto the code that implements it.

The two sides

┌─ main process ───────────────────────────────┐
│  electron/main.js                            │
│    windows · tray · global shortcuts         │
│    power monitor · single-instance lock      │
│                                              │
│  electron/ipc.js                             │
│    ~130 channels, one per capability         │
│                                              │
│  launcher · terminal · git · notes · backup  │
│  islamic · reminders · whisper · store …     │
└───────────────────┬──────────────────────────┘
                    │  contextBridge — the only door
┌───────────────────┴──────────────────────────┐
│  preload.js  →  window.api                   │
├──────────────────────────────────────────────┤
│  9 renderer windows (Vue 3 + Vite)           │
│  orb · panel · terminal · notes · api        │
│  meetings · athkar · alert · remind          │
└──────────────────────────────────────────────┘

The main process is the only place with real privileges. It spawns processes, reads and writes files, talks to the network, registers OS-level shortcuts, and draws the tray icon.

The renderers are ordinary web pages. They run with contextIsolation: true and nodeIntegration: false — no require, no fs, no direct access to anything. A renderer cannot open your IDE; it can only ask.

The bridge

electron/preload.js is the single door between the two sides. It runs contextBridge.exposeInMainWorld('api', { … }), which puts one object — window.api — on the page, with about 180 named functions on it and nothing else. Every one of them is a thin wrapper over a named IPC channel:

js
getState: () => ipcRenderer.invoke('state:get'),
openProject: (input) => ipcRenderer.invoke('projects:open', input),

Because the surface is an explicit list, the renderer can do exactly the 180 things the list allows and no more. There is no general "run this" channel.

Event channels follow one convention — they take a handler and return their own unsubscribe function, so a component can clean up on unmount without the main process leaking listeners into a window that no longer exists:

js
onStateChanged: (handler) => {
  const listener = (_event, state) => handler(state);
  ipcRenderer.on('state:changed', listener);
  return () => ipcRenderer.off('state:changed', listener);
},

One state, nine windows

There is no store in the renderer that owns the truth. electron/store.js holds it, and every mutation goes through one wrapper in electron/ipc.js:

js
const mutate = (fn) => async (_event, ...args) => {
  const result = await fn(...args);
  broadcast();          // state:changed → every live window
  return result;
};

broadcast() walks BrowserWindow.getAllWindows() and pushes the new state to each one. That is why renaming a project in the panel updates the picker in the notes window immediately, and why switching to Arabic flips all nine windows at once instead of only the one you were looking at.

Persistence is a single JSON file written atomically by electron/json-db.js, so a crash mid-write cannot leave a half-file; a file that turns out to be corrupt is moved aside rather than discarded. See Where data lives.

Errors that a user can act on

ipcMain.handle flattens a thrown Error down to its message, so an error code is lost on the way across.

Failures the user can actually do something about — WSL missing, the Whisper model not chosen — are therefore returned as data rather than thrown:

js
{ ok: false, code: 'WHISPER_MODEL_MISSING', detail: { … } }

Everything else still throws and lands in a generic toast.

This is also why the main process contains no translations at all: it returns codes, and the renderer maps them to prose in whichever language is active.

Windows

Each of the nine windows is a Vite entry point with the same name, so the main process resolves them all the same way — dist/<name>.html in a build, or the dev server URL while developing:

js
const promise = DEV_SERVER_URL
  ? win.loadURL(`${DEV_SERVER_URL}/${page}.html`)
  : win.loadFile(pageFile(page));

They are all frameless. The orb, panel, alert and reminder windows are also transparent and always-on-top, and marked visible on all workspaces so they survive a virtual-desktop switch and a full-screen app.

The app takes a single-instance lock: launching a second copy hands off to the running one and exits, which is what makes clicking the desktop shortcut twice harmless.

The orb: a window that must not move

CSS cannot paint outside a window, and the orb's four launcher blades do not fit in the 45px the orb appears to occupy. So the window is permanently larger — 145px — with the mark floating in the middle of it and the rest transparent.

It used to grow on hover, and that could not be made smooth: moving a window and repainting its contents are not one operation, so for a frame or two the old, small rendering sat at the new, shifted origin and the orb visibly teleported before snapping back. A window that never moves cannot do that.

The cost of a permanently large window is that its empty margin would swallow clicks meant for whatever is behind it. So the window is left click-through (setIgnoreMouseEvents(true, { forward: true })) and only becomes solid once the pointer is actually on the mark.

That trade has its own catch, and the fix is worth knowing about. Turning setIgnoreMouseEvents off while the cursor is already inside the window resets the OS's mouse-leave tracking, so the mouseleave that should close the orb sometimes never arrives. The result: an orb left open, holding mouse input, while the pointer is somewhere else entirely.

While the orb is open — and only then — the main process watches the real cursor position as a backstop and closes it from that side. Its threshold is deliberately a little wider than the one the renderer uses, so the two never fight over a pixel at the boundary.

Opening is a shape morph, not a transform: each blade is defined by two outlines, --d-closed and --d-open, and CSS animates d between them. A transform that lengthened a blade would stretch the icon inside it along with it. Nothing about the orb is scaled, so nothing about it can appear to zoom.

The mark itself is generated, not drawn twice: src/shared/logo-x.mjs defines the four arms on a 100×100 box, and both the inline SVG in the orb and the raster the OS icons are baked from (scripts/gen-logo.mjs) are built from it. The shape in the tray and the shape on screen therefore cannot drift apart.

The record arm is driven by its own channel rather than by the store, because "a meeting is recording" is live state with nothing to persist — orbRecording to ask, onOrbRecording to follow, stopOrbRecording to act.

Service by service

ServiceMain-process moduleHow it actually works
Opening a projectlauncher.jsSpawns the IDE, terminal, file manager, Figma or Postman. paths.js converts between Windows and WSL paths, so a WSL project opens with wsl.exe -d <distro> --cd <path> while Git still addresses it by its Windows path
Importing projectsscanner.js, command-detect.jsWalks a folder looking for .git directories and package manifests, and reads each match for runnable commands — see below
Quick commands & terminalterminal.jsReal pty sessions through node-pty. It is loaded in a try/catch and, if the native module will not load, the module reports { interactive: false, reason } and falls back to child_process — which is what puts the banner in the terminal window instead of leaving you with a dead keyboard
Git infogit.jssimple-git wrappers — status, branches, checkout, and the raw --graph log rendered as-is
Notesnotes.jsPlain Markdown file CRUD under the app data folder. No database
API testerapi-client.js, api-store.js, env-scan.js, curl.js, postman.jsRequests are sent from the main process, not the page, which is what makes per-environment "ignore TLS errors" and real cancellation possible. env-scan.js reads the project's .env files for the base URL; saved requests are files in the project, secrets are not
Meetingswhisper.js, whisper-model.js, parakeet.js, summarizer.js, meetings-store.jsThe one service that genuinely needs both sides at once — see below
Islamic modeislamic.js, athkar-data.jsFetches from the Aladhan API once a day and caches it, runs the alert clock, and locks the desktop through rundll32 user32.dll,LockWorkStation. It listens to Electron's power monitor, which is how an alert can wait out a locked or sleeping machine and be raised when you come back
Remindersreminders.jsA single 20-second ticker rather than one timer per reminder — the reason reminders survive sleep and app restarts instead of being lost with their timer
Backup & restorebackup.js, zip.jsA minimal zip writer and reader with no runtime dependency, plus retention and checksum-verified restore
Startup projectsstartup.jsRuns after boot, spacing launches out because Windows drops IDE windows opened in the same instant
Settings, sounds, traystore.js, sounds.js, main.jsThe tray menu is rebuilt whenever the state it reflects changes
WSL detectionwsl.jsProbes whether WSL is genuinely usable — missing, Store stub, no distro registered — and fails open: if the probe cannot answer confidently, nothing is disabled

Detecting commands

electron/command-detect.js is what makes an imported project arrive with npm run dev already in its menu. scanner.js calls it for every folder it matches, so one scan produces both the project list and its commands — the renderer never reads a file itself.

It is a list of small detectors, run in order and deduped by command line:

js
const DETECTORS = [fromPackageJson, fromComposer, fromMakefile,
                   fromPython, fromGo, fromRust, fromCompose];

Three rules shape the whole module:

  • Best-effort, never fatal. Each detector runs inside its own try/catch, and a file that will not parse contributes nothing while the rest still run. Import must never fail because of what a project folder happens to contain.
  • No new dependencies. TOML and YAML are probed with regexes, and only for the handful of shapes that matter — [project.scripts], a compose file's existence. Pulling a parser in for that would be the larger cost.
  • Read, never execute. Nothing in the project is run. Even the package manager is inferred from what is on disk rather than by asking it.

That last inference is the part users notice. The runner has to match the project, because the tools disagree on their own syntax — npm run dev and bun run dev, but pnpm dev and yarn dev:

js
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 is authoritative when present, since corepack enforces it. Otherwise the lockfile on disk is the only honest signal.

The detector only proposes. store.addCommands is what writes, in one commit for the whole batch, and it skips any line the project already has — which is why Detect commands can be pressed on an old project and adds only what is new. The user-facing behaviour is on the commands page.

Meetings in detail

Every other service lives on one side of the bridge. Meetings is split down the middle, and the split is not arbitrary:

  • Audio capture belongs in the renderer. Microphone access, system-audio loopback and the Web Audio API are browser APIs. Electron's main process has no sound card.
  • Running the transcription engine belongs in the main process. It spawns a native executable, manages its lifetime, and owns files on disk.

So the renderer turns sound into small WAV segments, and the main process turns segments into text.

renderer                                    main process
────────                                    ────────────
getUserMedia   ─┐
getDisplayMedia ┴→ GainNode (mix)

                 AudioWorklet (pcm-tap)
                     ↓  Float32, 1600-sample blocks
                 VAD segmenter
                     ↓  one utterance
                 resample → 16 kHz mono WAV

                 transcribeMeetingChunk ──→  queue (max 12)

                                         whisper-server.exe
                                          (or parakeet CLI)
                     onMeetingTranscript ←──  text

                 stitch onto transcript

                 stop → saveMeetingArchive ─→ meeting.json

                        summarizeMeeting ──→  Ollama / Groq /
                                              OpenRouter / Anthropic

Capturing two sources

src/meetings/audio/mixer.js opens the microphone with getUserMedia, and the speakers with getDisplayMedia({ audio: true }) — the loopback trick, which is why the far side of a call is transcribed and not just you.

It asks for the smallest possible video track and immediately stops it, because Chromium will not hand over system audio without one.

If loopback is unavailable, it emits AUDIO_LOOPBACK_UNSUPPORTED and carries on with the microphone alone rather than failing the recording.

Both streams are mixed into one GainNode. The graph also has a silent path to the destination, because Chromium's worklet graph is pull-based: without something pulling, the processor is never called and no audio arrives.

The context is requested at 16 kHz directly. Some drivers ignore that and give back 48 kHz anyway, so the code checks context.sampleRate afterwards rather than trusting the request, and resamples through an OfflineAudioContext when it has to.

Cutting it into utterances

audio/pcm-tap.js is an AudioWorkletProcessor — it runs on the audio thread, soft-clips each sample, and posts 1600-sample blocks across as transferable buffers so nothing is copied.

audio/segmenter.js is a voice-activity detector, not a timer. It works on 20 ms frames and compares each frame's RMS against a rolling noise floor — the 10th percentile of the last three seconds — so it adapts to a fan, an air conditioner, or a quiet room instead of using a fixed threshold.

  • Three consecutive loud frames open a segment, and 300 ms of pre-roll is prepended so the first word is not clipped.
  • 600 ms of quiet closes it.
  • A segment is forced out at 12 seconds, so a monologue still streams.
  • Anything with under 700 ms of actual speech is dropped rather than sent.

Cutting on silence rather than on a clock is what keeps words whole — a fixed-interval cut would slice through the middle of a word several times a minute.

The engine

electron/whisper.js owns the local server:

  • Start-up spawns whisper-server.exe on a free localhost port with the chosen model and thread count, then polls a health endpoint until it answers.
  • Adopt or kill. The server's pid and port are persisted. If the app crashed and left one running, the next launch either adopts it — no second start-up cost — or kills it if it is unusable.
  • A queue, capped at 12 segments. Transcription is slower than speech on a large model, so segments wait rather than pile up without bound. Depth is emitted on every change, which is the "N segments behind" line in the window. A full queue returns WHISPER_BUSY rather than dropping audio silently.
  • Typed failures. WHISPER_BINARY_MISSING, WHISPER_MODEL_MISSING, WHISPER_PORT_BUSY, WHISPER_ARCH_MISMATCH, WHISPER_CRASHED, WHISPER_MODEL_PATH_UNSUPPORTED — returned as codes and translated in the renderer, which is how the window can tell you what to do about each one.

parakeet.js is the alternative engine: instead of a long-lived server it runs a CLI once per segment, so it pays process start-up each time but is faster per segment. Same queue, same interface.

Returned text is stitched onto the transcript by comparing the tail of what is already there with the head of what just arrived, so the pre-roll overlap does not produce duplicated words.

How the settings reach the engine

Nothing about the engine is hardcoded. Every choice in the model dialog is a key under settings.meetings in the same JSON store as everything else, and whisper.js reads that object on each call rather than holding its own copy:

Setting keyWhat it decides
enginewhisper (a long-lived local server) or parakeet (a CLI per segment)
modelWhich catalogue entry to use. The path is rebuilt from this key, which is why a key has to stay the exact filename stem
whisperModelDirWhere models live. Defaults to C:\ProgramData\XTop\models
whisperModelPathA model you browsed for by hand, overriding the catalogue
whisperBinaryPathA server executable you pointed at, overriding the bundled one
parakeetModelPathThe same, for the parakeet engine
whisperServer{ pid, port } of a running server, persisted so a crashed app can adopt or kill it next launch
modelMirrorWhere downloads come from, default huggingface.co
languagePassed to the engine as -l; falls back to the app language
summaryProvider, ollamaUrl, ollamaModel, summaryModelThe summariser, not the transcriber

Changing a setting invalidates the probe. Capability detection is expensive — it stats files and runs the executable with --help — so it is memoised for the process lifetime.

That would have meant picking a new model changes nothing visible until you restart. So settings:update explicitly drops the cache when the relevant keys move:

js
if ('meetings' in patch || 'language' in patch) whisper.invalidate();

This is why Check again in Settings and the status line in the model dialog tell the truth immediately after you change something.

The capability probe

meetingsCapability is the channel behind "Local Whisper is ready". It resolves the binary, resolves the model, and classifies the result into one of the typed codes:

js
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);                       // does it even run here?

Running the executable with --help is what separates missing from present but unusable. The wrong architecture, or missing ggml DLLs next to it, both surface here as WHISPER_ARCH_MISMATCH rather than as a mysterious failure halfway through your first meeting.

The same call detects whether this build supports --prompt, so the feature is used only where it exists.

The model catalogue and downloads

whisper-model.js holds the catalogue as data, and the file name is derived rather than stored twice:

js
const fileName = (model) => model.file || `ggml-${model.key}.bin`;
const repoPath = (model) => `${model.repo || 'ggerganov/whisper.cpp'}/resolve/main/${fileName(model)}`;

Two things in the catalogue are worth knowing, because they are decisions rather than defaults:

  • .en entries are English-only. tiny.en and base.en cannot transcribe Arabic at all — the multilingual entries are what make Arabic work. That is the real difference between the sizes, not just accuracy.
  • large-v3-turbo-q8_0 replaced medium. Medium is twice the download, loses to turbo on Arabic, and this whisper.cpp build cannot even load it.

Downloads stream to disk with progress and a cancel token, and fall back from Hugging Face to hf-mirror.com — the fallback exists because the primary host is not reliably reachable everywhere, and the mirror is not the default because it lags.

Why models live in ProgramData

whisper.cpp opens model files through the Windows ANSI code page, so a path containing non-Latin characters simply fails to open. A user whose Windows account name is Arabic would hit that with any per-user location, so the default is machine-wide C:\ProgramData instead.

Talking to the local service

The server is plain HTTP on 127.0.0.1, which keeps the contract simple:

  1. start() picks a free localhost port and spawns the executable with the model, language, host, port and a thread count of clamp(cpuCount - 2, 2, 8) — leaving the machine usable while it works.
  2. It polls until something answers on that port, then marks itself ready and emits a state event the window renders.
  3. Each segment is POSTed as a WAV; the reply is text.
  4. stop() kills the child, or — for a server it adopted rather than spawned, where there is no child handle — kills it by the persisted pid.

Because it is just a local HTTP server, pointing the app at your own build is a supported path, not a hack: set whisperBinaryPath through locate server in the model dialog and everything else works unchanged.

The summary provider

meetingSummaryConfig builds the dialog's contents at open time rather than hardcoding them. The provider list comes from summarizer.PROVIDERS, and each entry carries whether it is free, whether it is local, whether it needs a key, and where to get one — plus whether a key is already stored, without ever sending the key itself to the renderer.

When Ollama is selected it also fetches /api/tags from your Ollama instance, so the model field becomes a picker of models you have actually pulled instead of a name you have to type exactly. If Ollama is not running the fetch is swallowed and the list is simply empty; the error is reported properly later, at the point where you actually ask for a summary.

Keys are written to apiSecrets under meetings:<provider> — the same store that holds API-tester secrets, and the same reason: it is the one place that never ends up in a project folder.

After the recording

Stopping writes meeting.json immediately — transcript first, summarising second, so a failing model can never cost you the recording.

summarizer.js then asks a language model for strict JSON ({ summary, decisions, actionItems }) and validates the shape before storing it. Ollama is the default because it keeps the whole feature local; Groq, OpenRouter and Anthropic are opt-in and need a key, stored in the app data folder rather than in any project.

When the transcript is Arabic it asks for an Arabic answer while keeping the JSON keys English, so parsing stays language-independent.

A powerSaveBlocker is held for the whole recording, and the meetings window is created with backgroundThrottling: false — Chromium throttles timers in background windows, which would otherwise stall the audio pipeline the moment you switched to the app you were actually meeting about.

The renderer side

Nine Vue 3 applications, one per window, built by Vite as nine separate entry points. They share src/shared/: the i18n table, the theme tokens, and a small set of UI primitives.

Language and theme are applied before anything mounts — every entry point awaits the same boot step — so no window ever flashes the wrong palette or the wrong direction on the way in:

js
applyLanguage().then(() => createApp(App).mount('#app'));

Packaging

electron-builder produces an NSIS installer and a portable exe. The Whisper binaries ship as extraResources rather than being bundled into the asar, since they have to be executable files on disk, and node-pty is unpacked from the asar for the same reason.