A production-grade messaging client for Windows, macOS and Linux — threads, reactions, presence, search and a command palette, wrapped in an interface that gets out of the way.
Every screenshot in this README is a real render of the real app. They are captured by
npm run screenshots, which boots the actual renderer against an in-memory workspace and photographs it at 2×. No mockups, no Figma exports, no drift.
- Why Pulse
- Features
- The interface
- Architecture
- Getting started
- Project layout
- Scripts
- The realtime protocol
- Design system
- Security
- Keyboard shortcuts
- Building installers
- Roadmap
Most chat demos stop at "messages appear in a list". Pulse is built as the thing you would actually ship:
- A real desktop application. Frameless window with custom chrome, a system tray, native notifications, an unread badge, a global summon hotkey and a hardened preload bridge — not a web page in a wrapper.
- A typed contract, not string soup. Every event that crosses the wire is declared once
in
@pulse/sharedand consumed by both the client and the gateway, so a protocol change breaks the build instead of production. - A transport you can swap. The React tree talks to a
Transportinterface. One implementation speaks Socket.IO; the other is an in-memory workspace. That is how this README stays honest and how the UI can be developed with no backend running. - No native dependencies.
npm installworks on a clean machine on all three platforms with no build tools, no Python, no compiler.
|
|
|
Replies live in a side panel with the root message pinned above them, so a deep discussion never buries the rest of the conversation.
Ctrl/⌘+K searches channels, people, every message in the workspace, and the commands
themselves — with subsequence matching, so dsy finds design-system.
Every colour is a semantic token, so the light theme is a genuine second design rather than
an inverted afterthought. Toggle it with Ctrl/⌘+Shift+L.
|
Direct messages
|
Sign in
|
flowchart LR
subgraph Desktop["Electron client"]
direction TB
Main["Main process<br/><small>window · tray · menu · notifications</small>"]
Preload["Preload bridge<br/><small>contextIsolation, no nodeIntegration</small>"]
Renderer["React renderer<br/><small>zustand · Tailwind · framer-motion</small>"]
Main <--> Preload
Preload <--> Renderer
end
subgraph Shared["@pulse/shared"]
Contract["Domain model<br/>Event contract<br/>Constants"]
end
subgraph Gateway["Realtime gateway"]
direction TB
HTTP["Express<br/><small>auth · uploads · health</small>"]
Socket["Socket.IO<br/><small>rooms · acks · presence</small>"]
Store["JSON store<br/><small>atomic, debounced writes</small>"]
HTTP --> Store
Socket --> Store
end
Renderer -- "Transport interface" --> Socket
Renderer -- "REST" --> HTTP
Shared -.-> Renderer
Shared -.-> Socket
The renderer never imports socket.io-client directly. It depends on a Transport
interface with two implementations:
| Implementation | Used by | Backed by |
|---|---|---|
SocketTransport |
The app in normal use | A live Socket.IO gateway |
DemoTransport |
Showcase mode and README screenshots | An in-memory, scripted workspace |
Because both satisfy the same contract, the entire UI can be exercised — including typing indicators, incoming messages and unread badges — with nothing running but the renderer.
git clone https://github.com/msdev290/Pulse.git
cd Pulse
npm run bootstrap # install + build the shared contract
cp apps/server/.env.example apps/server/.env
npm run dev # gateway on :4000, Electron opens automaticallyRequires Node 20.11+. Nothing compiles natively, so a clean Windows, macOS or Linux machine needs no additional tooling.
On first boot the gateway seeds a complete demo workspace — eight members, nine channels and a conversation with threads, reactions and attachments — and prints the credentials:
demo login msdev290@pulse.chat / pulse1234
The sign-in screen offers that account as a one-click button whenever seeding is enabled.
npm run showcase # http://localhost:5174Showcase mode runs the real renderer in a browser against DemoTransport. No gateway, no
database, no account — teammates even type and post on a loop.
Pulse/
├── apps/
│ ├── desktop/ Electron client
│ │ ├── src/main/ window, tray, menu, IPC, settings
│ │ ├── src/preload/ the only bridge into the renderer
│ │ └── src/renderer/ React app
│ │ ├── components/ UI, grouped by surface
│ │ ├── lib/transport/ SocketTransport · DemoTransport
│ │ └── store/ zustand: app state and UI state
│ └── server/ Express + Socket.IO gateway
│ ├── src/http/ auth, uploads, health
│ ├── src/realtime/ the Socket.IO gateway
│ └── src/store.ts JSON persistence
├── packages/shared/ domain model + event contract
├── scripts/ icons, demo assets, screenshots, smoke test
└── docs/images/ generated — never hand-edited
| Command | What it does |
|---|---|
npm run bootstrap |
Install dependencies and build @pulse/shared |
npm run dev |
Gateway and Electron client together, both hot-reloading |
npm run showcase |
The renderer in a browser, backed by the demo workspace |
npm run typecheck |
Strict TypeScript across every workspace |
npm run smoke |
End-to-end check against a running gateway |
npm run assets |
Regenerate icons and the demo attachments |
npm run screenshots |
Recapture every README image at 2× |
npm run dist:win |
Build a Windows installer (dist:mac, dist:linux also available) |
Every client → server event is acknowledged with a discriminated union, so failures are values rather than silent no-ops:
export type Ack<T> = { ok: true; data: T } | { ok: false; error: string };The full surface lives in packages/shared/src/index.ts:
| Direction | Events |
|---|---|
| client → server | message:send · message:edit · message:delete · message:react · message:history · channel:create · channel:join · channel:openDirect · channel:markRead · typing:start · typing:stop · presence:set · search:messages · profile:update |
| server → client | session:bootstrap · message:new · message:updated · message:deleted · channel:created · channel:updated · channel:read · presence:updated · user:joined · user:updated · typing:updated · server:notice |
Rooms. Every socket joins channel:<id> for each visible channel and user:<id> for
its own account. Fan-out goes to the channel room; per-user state such as unread counts
goes to the user room, so a member signed in on two machines stays consistent.
Delivery. Sending renders an optimistic bubble keyed by a client nonce. The server echoes the canonical message carrying the same nonce, and the client reconciles in place — no duplicate, no flicker. If the ack never arrives the bubble is marked undelivered with a retry button.
Reconnects. Backoff is exponential from 500 ms to a 30 s ceiling with 50 % jitter applied from the first attempt, so a gateway restart does not summon a thundering herd.
Colour never appears as a literal in a component. index.css declares semantic tokens that
Tailwind consumes through @theme inline:
:root,
[data-theme='dark'] {
--canvas: #07070c;
--surface: #0c0c14;
--ink: #ececf4;
--ink-2: #a3a3bb;
--border: rgb(255 255 255 / 0.07);
}
[data-theme='light'] {
--canvas: #f4f4f9;
--surface: #ffffff;
--ink: #14141f;
/* … */
}Switching data-theme or data-accent on <html> re-skins the entire application, which
is why the theme toggle is instant and why adding an accent is a five-line change.
The surface treatment is deliberately restrained: a slow-drifting aurora field behind everything, glass panels with real backdrop blur, one accent gradient used sparingly for emphasis, and motion that is always spring-based and always interruptible.
The renderer is treated as untrusted:
contextIsolation: true,nodeIntegration: false,webviewTag: false- A narrow, explicitly enumerated preload API — no generic
invoke(channel, …)escape hatch - A restrictive
Content-Security-Policymeta tag on the renderer document - External links and window-open requests are intercepted and handed to the OS browser
- Passwords are bcrypt-hashed; sessions are signed JWTs; the gateway refuses to boot in production with the development signing key
- Auth endpoints are rate-limited per IP; uploads are size-capped and filename-sanitised
| Shortcut | Action |
|---|---|
Ctrl/⌘ + K |
Command palette |
Ctrl/⌘ + F |
Search every message |
Ctrl/⌘ + Alt + ↑ / ↓ |
Previous / next conversation |
Ctrl/⌘ + Shift + N |
Create a channel |
Ctrl/⌘ + Shift + D |
Start a direct message |
Ctrl/⌘ + Shift + L |
Toggle light / dark theme |
Ctrl/⌘ + Shift + C |
Toggle compact density |
Ctrl/⌘ + , |
Preferences |
Ctrl/⌘ + / |
Shortcut sheet |
Ctrl/⌘ + Shift + Space |
Summon Pulse from anywhere |
Enter / Shift+Enter |
Send / newline |
Esc |
Close the topmost surface |
npm run dist:win # NSIS installer + portable .exe
npm run dist:mac # .dmg for arm64 and x64
npm run dist:linux # AppImage + .debArtifacts land in apps/desktop/release/<version>/. The package job in
.github/workflows/ci.yml builds all three from a manual
workflow dispatch.
- Voice notes and inline audio playback
- Message pinning and per-channel bookmarks
- Offline queue that drains on reconnect
- Postgres adapter behind the existing store interface
- End-to-end encryption for direct messages
- Auto-update via
electron-updater
Bug reports and pull requests are welcome — see CONTRIBUTING.md for the development setup and the expectations for a change.






