Files
moho/ARCHITECTURE.md

286 lines
20 KiB
Markdown

# moho architecture
How the two halves fit together, what the daemon speaks, and where it puts things. The
[README](README.md) is the short version; this is everything else.
## Why two parts
- **`nobilis`** (`nobilis/`, Rust) speaks each protocol directly and translates it into a small
unified JSON model (accounts, buffers, messages) over a Unix socket. What every service has in
common - sending, editing, reacting, backlog, search - is one set of calls and events. What
only one service has - a Matrix room's power levels, a Discord poll, a Kick prediction - is a
call of its own, so a client never has to pretend the services are the same where they are not.
- **The Electron client** (`src/`) spawns and supervises `nobilis` and renders whatever it reports.
Keeping the protocol work in a separate long-lived daemon means connections, Tor circuits, and
Matrix sync survive the UI being closed, restarted, or reloaded during development.
```
nobilis/src/backend/ one module per protocol (irc, discord, sneedchat, matrix, kick)
nobilis/src/rpc/ the Unix-socket JSON-RPC server
nobilis/src/store.rs SQLite-backed scrollback persistence
nobilis/src/runtime.rs protocol-agnostic connection/buffer/message state
src/main/ Electron main: daemon supervision, RPC socket, tray, notifications
src/preload/ the contextBridge surface exposed to the renderer
src/renderer/ React UI: buffer list, message log, member list, accounts, settings
resources/icons/ bundled artwork (service marks, IRC network logos, tray icons)
```
### One daemon, many clients
Every packaged form bundles `nobilis`, the Sneedchat smilies and the icons under `resources/`,
and the app resolves them from there rather than from the source tree - so the app and the
daemon in one download always match.
nobilis holds an `flock`-based singleton lock on its data directory, so launching a second client
does not start a second daemon — it attaches to the running one. Quitting the app sends the
daemon a clean shutdown (real QUITs to IRC, rather than dropping the connections), so your nick
isn't left ghosted.
## The client
Three panes: an account-grouped buffer sidebar, the message log, and a member list, with the
sidebar and member list independently foldable. Closing the window hides it to the tray; nobilis
keeps running, so connections and unread tracking survive.
- **Message rendering** - an IRC-style log by default ("Classic"), with nick colouring, BBCode and
markdown, spoilers, code and quote blocks, inline image/video embeds, custom emoji, emotes and
stickers (Discord's animated Lottie ones included), reactions, replies, inline edit, and
Sneedchat's bundled smilies. "Comfy" groups consecutive messages under one avatar, and "Bubbles"
draws them as chat bubbles. A link is described by the daemon for every service, not only the
ones that do it themselves: a YouTube link becomes a titled card, a Tenor or Giphy link is drawn
as the GIF, and a GIF picker sends one from the composer. What somebody did on a Kick stream -
KICKs sent, subscriptions gifted and to whom, a raid, a redeemed reward - is drawn as a card.
- **Settings** - a searchable rail of categories: Accounts and Downloads; General, Appearance,
Notifications and Keybinds; a page for each network (IRC, Sneedchat, Tor, Discord, Matrix, Kick);
and About. Message-kind filters (joins, parts, mode changes) are applied client-side, so toggling
one takes effect immediately without a reconnect.
- **Moving around** - Ctrl+K opens a quick switcher over every conversation on every account.
A conversation opens at its first unread message when the unread run is short, and at its end,
with a bar to go back by, when it is long - as Discord does it. Escape marks the conversation
read and Shift+Escape the whole server; a muted conversation stays quiet but still counts the
times your name was used in it.
- **Presence and the tray** - the status menu offers only the statuses each service has (Discord
all four, IRC away and back, Matrix online and offline) and sends nothing to a service that
has no such thing. Do not disturb silences desktop notifications everywhere and is Discord's
real do-not-disturb on Discord. When nothing has been used for a while an account is put on
Idle and back, from the system's idle time where it will say and moho's own windows where it
will not. The tray draws a number counted by the window by the rule the server rail uses.
- **Per-protocol join pages** - each service's mechanism is genuinely different (IRC joins by
channel name, Discord offers the friends list and sends joining, adding a friend and making
a server to discord.com - see below - Matrix takes a room address
or searches every homeserver's directory at once, Sneedchat lists the rooms the site itself
publishes, Kick takes a streamer's handle), so each gets its own page.
- **Matrix security** - device verification by emoji or QR, cross-signing, server-side key
backup, key export and import, and signing other sessions out, all under the account's own
row in the Accounts pane. A session verified here is verified in Element too.
- **Service interruptions** - a conversation that is not receiving is drawn in italics, with a
spinner while something is trying, and says why in a banner when opened: its account is
reconnecting, an IRC channel refused the join, a Kick channel is still being looked up. A
Sneedchat room that cannot connect is diagnosed - Tor, the forum, or only the chat - by
checking the route, then the forum's front page, then concluding it is the chat.
- **Calls and streams** - a call stage like Discord's, which can sit in the corner or pop out
into a window of its own, with tiles per person, volume per person and for the whole call on
their right-click menu, and the camera and screen buttons in one row. A Kick stream is drawn
in the same surface, resizable in the corner or popped out.
- **Tagging** - typing "@" lists the people here, the roles the service lets anybody ping, and
its whole-room words, matched fuzzily. What is sent is what the service understands: an id on
Discord, `m.mentions` on Matrix, "nick: " on IRC.
- **Profiles** - right-click somebody in the member list or on a message. Each service answers
what it keeps: an idle time and shared channels on IRC, an account age and a join date on
Discord, a power level and a last-seen on Matrix.
### Security notes
Message bodies are fully attacker-controlled. The renderer runs with `contextIsolation` and no
Node integration, and its preload surface is a fixed list of calls - there is no `ipcRenderer`
passthrough and no filesystem access. Formatted message HTML is never injected directly: it is
parsed into an inert document and rebuilt through a tag and attribute whitelist, so unknown tags
degrade to text and `javascript:`/`data:` URLs, inline event handlers and `<script>` cannot
survive. Local media nobilis has fetched is served through a dedicated scheme that resolves only
inside the known cache directories, rather than by disabling `webSecurity`.
#### No plugins, and no scripting
moho does not have a plugin API or a scripting language, and will not grow one. This is a
decision rather than a gap, and it is the same decision as everything above: the client does not
execute code.
It is the one place moho deliberately falls short of the clients it is otherwise measured
against — HexChat has Perl and Python, WeeChat has Lua, Python, Perl and Ruby, mIRC has its own
language — so a comparison that counts scripting will always find moho missing it, and that is
the intended answer rather than a backlog item.
The reasoning is that a scripting host is an execution surface pointed at exactly the data that
is fully attacker-controlled. Every mitigation above — the fixed preload surface, the tag and
attribute whitelist, the media scheme — exists to keep message content from ever becoming
something that runs. A plugin API hands that back in one step, and does it with more privilege
than the renderer has: scripts want the filesystem, the network and the message stream, which is
the whole of what an attacker would ask for. The daemon/client split makes this cheaper to hold
to than it would otherwise be, since anything that genuinely needs to automate moho can speak the
wire protocol in `Wire protocol` below as a separate process, with its own permissions and its
own blast radius.
### Global hotkey on Wayland
The show/hide hotkey (`Control+Shift+M` by default, configurable) uses Electron's
`globalShortcut`, which only works under X11 — Wayland compositors do not let an ordinary client
grab keys globally. Under Wayland the registration is refused, logged, and otherwise ignored;
bind the compositor to focus the window instead.
## Wire protocol
Newline-delimited JSON over a Unix socket at `$XDG_RUNTIME_DIR/nobilis/nobilis.sock`:
```
request: {"id": 1, "method": "sendMessage", "params": {...}}
response: {"id": 1, "result": {...}} or {"id": 1, "error": "..."}
push: {"event": "message", "data": {...}}
```
Core methods: `listAccounts`, `listBuffers`, `listProtocols`, `addAccount`, `removeAccount`,
`setAccountConnected`, `joinBuffer`, `partBuffer`, `sendMessage`, `editMessage`, `deleteMessage`,
`subscribe`/`unsubscribe`, `getBacklog` (SQLite-backed, persists across `nobilis` restarts),
`searchMessages`, `requestProfile`, `markBufferRead`. Each protocol also has its own
account-creation method (`addAccount` for IRC, `addDiscordAccount`, `addSneedChatAccount`,
`addMatrixAccount`, `addKickAccount`) and its own verbs beyond the shared ones - the full list is
the match in `nobilis/src/rpc/methods.rs`, which is the only place it cannot go stale.
Requests are answered concurrently, so a slow one - a room-directory search across several
homeservers, a Tor round trip - does not hold up anything else on the same connection. Responses
carry the id of the request they answer and may arrive in any order.
Push events: `message`, `messageUpdated`, `messageDeleted`, `reactionsChanged`, `presenceChange`,
`bufferListChange`, `connectionState`, `notification`, `profile`, `readReceipts`, plus
per-protocol login-flow events
(`discordLoginQr`/`discordLoginScanned`/`discordLoginResult`, `sneedChatLoginStatus`/
`sneedChatLoginResult`, `matrixLoginStatus`/`matrixLoginResult`). `message`, `presenceChange`,
`messageUpdated`, `messageDeleted` and `reactionsChanged` are only delivered to clients that
called `subscribe` for that buffer.
## Protocol backends
- **IRC** - TLS with SASL (PLAIN, EXTERNAL, SCRAM-SHA-256), STS, NickServ auto-identify and
GHOST reclaim, autojoin, optional SOCKS5 or Tor, DCC send and receive with resume, and the
network's own colour codes. Twenty-seven IRCv3 capabilities are negotiated, six of them drafts,
alongside SASL and WHOX - which a network advertises in ISUPPORT rather than as a capability. Among them:
`echo-message` and `labeled-response`, so a sent line is the one the server actually delivered
rather than this client's guess at it; `chathistory` in both spellings for server-side
backfill; `draft/multiline`, so a long message is continued rather than cut at 512 bytes;
`draft/message-redaction`, so a deleted message leaves the screen; `userhost-in-names` and
`account-tag`, so a join says who somebody is rather than only what they are called. Typing
over `+typing`, a notify list over MONITOR with `extended-monitor`, avatars over METADATA,
WHOIS, away, and channel modes passed through raw. Images are uploaded to catbox, postimg,
ibb.co or imgur and sent as a link, since IRC itself carries only text.
The list negotiated is `WANTED_CAPS` in `nobilis/src/backend/irc/connect.rs`, filtered against
what the server offered in CAP LS - one REQ line with only the advertised ones, because
twenty-odd REQ lines trip flood protection on a real network.
- **Discord** - official cross-device QR login (the same one discord.com/app offers), Discord's
own sign-in page in a window moho opens, or a token; a real-time gateway client over the user
gateway. Messages with edit/delete/reactions/replies/forwarding, threads and forum posts, slash
commands with buttons, menus and modal forms, polls that can be voted in, stickers sent and
drawn, pinned messages, Discord's own search, invites, scheduled events, AutoMod's verdicts,
and server and channel mutes - read from the account itself, and set on it when a channel is muted
here, so a phone is quiet too.
Voice and video are moho's own connections rather than a library's: voice, the camera and Go
Live streams - hosted and watched, with sound - over Discord's RTP with DAVE end-to-end
encryption, VP8 encoded and decoded in the window. Echo cancellation and noise suppression
(WebRTC's audio processing, in Rust), per-person and call volume, the soundboard both ways,
and stage channels.
Joining a server, adding a friend and making a server are deliberately not done here. Discord
puts each behind an hCaptcha when it comes from anything but its own client, and a third-party
client answering one is what gets an account flagged for spam - so moho opens discord.com for
them, and says why beside the button. A captcha that turns up anywhere else is refused the same
way rather than answered.
- **Sneedchat (SneedChat)** - the chat built into Kiwi Farms. On the open internet by default,
or per account over an embedded Tor client (or an external SOCKS5 proxy) at the onion address.
Solves the site's own proof-of-work anti-bot gate *and* the Tartarus captcha on its login form,
with two-factor, and connects to every configured room simultaneously (one persistent
websocket per room, sharing a single login). Message edit/delete, whispers, attachments
(uploaded to postimg, since the chat itself is text-only), and avatars fetched through the
same route and cached locally.
- **Matrix** - Client-Server API with full end-to-end encryption (vodozemac-backed Olm/Megolm via
`matrix-sdk-crypto`), SAS device verification, cross-signing, server-side key backup and key
import/export, encrypted attachments. Threads, read receipts, spaces (created and filled),
polls, stickers, forwarding (the original content sent again, as Element does), knocking,
reporting, room moderation, widgets, ignore lists, sliding sync, and a room directory search
that asks every homeserver this account knows at once. Calls both ways: one-to-one
signalling, and the group calls Element holds on a LiveKit media server, with the media keys
the room passes round. Camera and screen share in all three shapes of call - a single
connection, a mesh, or a media server - and either can be turned on part-way through, which
is a renegotiation rather than a new call.
- **Kick** - the streaming site's chat, over its Pusher socket. Joins by streamer handle, imports
the account's follows, and carries the three emote tiers with subscriber gating (and 7TV and
BTTV beside them), KICKs sent to a streamer, redemptions, subscriptions, gifted subs with who
received them, raids, moderation, chat modes, and polls and predictions that can be answered
rather than only watched. The stream itself
plays in the window over HLS, with its VODs and clips.
- **Twitch** - a streamer's chat over Twitch's IRC service (read with no account, sent through the
official API), with emotes (Twitch's own, animated ones, and the channel's whole set in the picker with the ones the
account cannot use greyed out; BetterTTV, FrankerFaceZ and 7TV are browser extensions Twitch does not carry, so
they are not drawn), badges as pictures, cheers, replies and threads, subscriptions, gifts and
raids, raids out, polls and predictions as they happen, moderation, whispers, shoutouts and blocking.
One sign-in on twitch.tv gives the account its API token and the website session its player takes, so
the stream plays in the window as the person (their Turbo or subscriptions count) with Twitch's ad
breaks said and never skipped.
- **Telegram** - MTProto through grammers (a pure-Rust client, pinned), with each account carrying its own
`api_id` and `api_hash` from my.telegram.org (moho embeds none, so no install shares a key) and one saved
session per account in a private file that is never re-created on a hiccup. Sign-in by phone, code and cloud
password. Chats are read from the dialog list and become buffers under Direct Messages, Groups and Channels
(a channel the account cannot write in shows the lock message instead of the message box); history, live
messages, edits and deletions, formatting both ways, replies and forwards, service messages as cards, media
fetched on demand into a cache, reactions, pins, polls, search in a chat or everywhere, members and profiles,
threads and a channel's comments, join by @name or link, administrator actions, and a mute that follows the
account. Not supported: secret chats, calls, stories, payments; sign-in by QR code, folders, forum topics,
drafts and scheduled messages are not done yet. The code is `nobilis/src/backend/telegram/`.
### Notifications are cards
Anything a service *says* rather than somebody *saying* - a join or part, a topic or mode change, a kick,
a ban, a timeout, a room's slow mode, a removed message, a raid, a gift, a pinned message, a warning, a
notice from the server itself - is drawn in the window as a **card**: the same plate Kick's and Twitch's
events use (`KickEventCard.tsx`), with a mark for what it was, its subject in bold, and what happened.
This holds for every protocol, and **for every protocol added later**; it is a rule of the design and not
a styling choice of any one backend.
How a backend takes part, which is the whole of its job:
- It sends the line with a kind that is not speech (`system`, `join`, `part`, `topic`, `mode`, `kick`,
`moderation`, and so on - `isChatKind` in `lib/util.ts` is the test). The window then draws a
`NotificationCard` for it on its own: a mark chosen by kind (`SYSTEM_MARKS` in `MessageRow.tsx`), the
line's own words, a warning in red. Nothing else is needed, which is why a new protocol's notifications
are cards from its first connection.
- It may say more by attaching an embed to the line (`kind: "moderation"`, `"raid"`, `"subscribe"`,
`"gift-sub"`, `"stream"`, ...) with the subject in `author`, what happened in `title`, who did it in
`description` where that is known, and a Material Symbols glyph in `icon`. A penalty (`gavel`,
`timer_off`, `delete`, `block`) draws in the danger colour, anything else in the window's accent.
- It says each thing **once**. What the service already tells the whole room (a timeout, a mode change) is
not also written by the client that caused it; only what happens to this account alone is written
locally, as a card.
- A line that is not speech is **never** left as bare grey text, and a card is never used for a person
speaking: the divide is whether somebody said it.
A new protocol is not finished until its joins, parts, topic changes, moderation, mode changes and
service notices come out of it as cards. `scripts/shot-notification-cards.mjs` draws every kind of line
beside ordinary chat and a moderation card, to check that they are the same sort of thing.
## On-disk state
| Path | Contents |
|---|---|
| `~/.config/nobilis/accounts.toml` | account config and credentials |
| `~/.config/nobilis/scrollback.db` | SQLite scrollback |
| `~/.config/nobilis/matrix-crypto/` | Matrix E2EE device keys and Olm sessions |
| `~/.config/nobilis/voice.toml` | microphone, speakers, volumes and voice processing |
| `~/.config/nobilis/highlights.toml`, `ignores.toml` | the words that count as a mention, and who is ignored |
| `~/.config/nobilis/dcc.toml`, `irc-sts.toml` | DCC settings, and the IRC networks that have pinned TLS (STS) |
| `~/.config/nobilis/tor-state/`, `tor-cache/` | the embedded Tor client's state |
| `~/.cache/nobilis/` | re-derivable media caches (Matrix media, Discord thumbnails, stickers and sounds, Kick emotes, Sneedchat avatars and attachments), each held to a size and to thirty days, and fetched again when a missing file is looked at - see `nobilis/src/media_cache.rs` |
| `~/.cache/moho/moho.log` | the app's log, the daemon's included |
| `$XDG_RUNTIME_DIR/nobilis/nobilis.sock` | the daemon's control socket |