The desktop half of our live chat desk is a macOS app, but almost none of its logic knows that. Everything that decides what the app believes — which visitors exist, what they said, what’s unread, whether the server says you’re online — lives in a small C++17 core with no AppKit, no Objective-C and no third-party packages. The window is a thin shell that asks the core what happened and redraws.
That split is the single decision that made the rest of the project pleasant, so this post is about the core: the JSON it parses, the one function every frame flows through, and the unread rules that turned out to need two definitions.
Why a “core” at all
A chat client is a state machine fed by a network. If the state machine is tangled into view controllers, every bug is a UI bug and every test needs a window. Pulled out into plain C++, it becomes a function you can call with a string and assert on:
- It builds anywhere. The same test binary compiles with clang++ on a Mac and g++ on a Linux CI runner, with warnings as errors.
- The UI never parses JSON, and the model never touches the UI. The contract between them is a small
Eventstruct. - Hostile input is contained. Every byte from the network hits the core first, where “malformed” has exactly one outcome: ignored.
A JSON parser that degrades instead of throwing
We didn’t want a package manager for one data format, so the core carries its own JSON: about 300 lines for a recursive-descent parser, a serializer, and a Value type. Two design choices in Value do most of the work.
Objects are two parallel vectors, not a map. A std::map<std::string, Value> inside Value is a recursive type that some standard libraries refuse to instantiate, and std::pair<std::string, Value> needs Value to be complete. Parallel keys_ and items_ vectors sidestep both, keep insertion order (so serialized frames are byte-stable in tests), and are faster than a map for objects with five keys:
class Value {
public:
// Wrong-typed reads return an empty/null sentinel instead of throwing:
// a malformed frame should degrade to "ignored", never crash the app.
const std::string& asString() const;
const Value& operator[](const std::string& key) const; // missing → shared null
std::string str(const std::string& key, const std::string& fallback = "") const;
// ...
private:
Type type_ = Type::Null;
std::string str_;
Array items_; // array items, or object values
std::vector<std::string> keys_; // object keys, parallel to items_
};Reads never throw. frame["message"]["body"] on a frame that has no message returns a shared null, and .str("body") on that null returns "". Only the parser throws, once, at the boundary — and the one caller that catches it turns the whole frame into Ignored. That makes the model code read like the happy path while still being safe against anything a server (or an attacker relaying through it) might send.
The parser has the other guardrails you’d expect: a nesting cap so [[[[… can’t blow the stack, a checked number conversion, and escapes decoded to UTF-8.
The one escape that bit us
JavaScript strings are UTF-16 and are allowed to contain unpaired surrogates. JSON.stringify preserves them faithfully as "\ud800". Our first parser followed the JSON spec’s spirit and rejected a lone surrogate — which meant one odd character in one visitor’s message made the owner app silently drop that entire frame, notification and all. The fix was to do what browsers do and substitute U+FFFD (lightly simplified):
unsigned cp = hex4();
if (cp >= 0xD800 && cp <= 0xDBFF) { // high surrogate: need a pair
size_t mark = i_;
unsigned lo = 0;
if (next_is("\\u")) lo = hex4();
if (lo >= 0xDC00 && lo <= 0xDFFF) cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
else { i_ = mark; cp = 0xFFFD; } // not a pair: keep reading after it
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
cp = 0xFFFD; // lone low surrogate
}The server now normalizes the same way before it stores anything, so the bad character never makes it to disk either. Defense on both sides of a wire is cheap.
One function: Inbox::apply
Every frame the app receives goes through a single method:
Event Inbox::apply(const std::string& frame);It parses, mutates the model, and returns what happened. The shell’s entire networking logic is a switch over Event::Kind — Hello, NewMessage, VisitorUpdated, Status, Photo, Pong, Error, or Ignored. Here’s the heart of the message branch:
if (type == "message") {
Message m;
const std::string id = v.str("visitorId");
if (id.empty() || !readMessage(v["message"], m)) return ev; // Ignored
Thread& t = threads_[id];
mergeMeta(t, v); // email / page, never overwritten with ""
if (hasMessage(t, m.id)) return ev; // echo + snapshot overlap: drop the dup
t.messages.push_back(m);
if (m.from == Sender::Visitor) ++t.unread;
else t.unread = 0; // your own reply means you read it
ev.kind = Event::Kind::NewMessage;
ev.visitorId = id;
ev.message = m;
return ev;
}Three small rules there prevent three classes of bugs: metadata that’s absent is never treated as metadata that’s empty, the same message id can never be counted twice, and replying clears the badge without the UI having to remember to.
Unread, defined twice

“Unread” sounds like one number. It’s two, because the app is in two different situations when a snapshot arrives:
- On launch there is no “since.” The app has never shown you anything, so marking every message in the last month as unread would be noise. The honest number is what’s waiting on a reply: the visitor messages after your last answer in each thread.
- On reconnect the app has been showing you the conversation all along. The only news is what arrived while the socket was down — so the snapshot is merged, and unread goes up by exactly the message ids it hadn’t seen.
The model knows which situation it’s in (it remembers whether it has ever seen a hello), so the shell doesn’t have to. That’s also why the dock badge after a laptop wakes up says “1” rather than “37.”
Tests without a test framework
The core’s tests are one file, a short CHECK macro and a main() that returns non-zero on failure:
#define CHECK(cond) \
do { \
++checks; \
if (!(cond)) { \
++failures; \
std::fprintf(stderr, "FAIL %s:%d %s\n", __FILE__, __LINE__, #cond); \
} \
} while (0)The fixtures are written in the exact frame shapes the server sends — the same shapes the server’s own tests assert — so the two sides can’t drift apart silently. They cover the JSON edge cases (surrogates, depth, trailing garbage), the unread rules above, de-duplication, email arriving after the fact, and the sign-in helpers covered later in the series. The whole suite runs in milliseconds, and CI runs it on Linux — a quiet proof that “portable” is still true.
What the core deliberately doesn’t do
It doesn’t open sockets, touch the Keychain, draw pixels or know what a notification is. It also doesn’t retry, time out, or decide when to reconnect — those are policies of the platform layer, and they’re the subject of the next post: WebSockets from C++ on macOS that survive sleep, Wi-Fi drops and server restarts.
The payoff of keeping the core this narrow showed up later, when the app grew GitHub sign-in, a Slack-style room and owner photos. Each feature added a few lines to the core (a URL helper here, an Event field there) and a lot of lines to the shell — and none of it required re-testing the model by hand. If your team is carrying a desktop or embedded client where the logic has fused with the UI, this is the kind of untangling we help with.
