Moho 1.0. Five protocols in one window - IRC, Discord, Matrix, Kick and Sneedchat - over a daemon that keeps them connected; this is everything since the first release candidate: the rebuilt interface and settings, GIFs and link cards on every service, idle detection, the tray, Kick events as cards, conversations that open where you stopped reading, and every Rust test read.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Fifth release candidate: GIFs on every service with a picker, idle detection, YouTube and GIF cards on every service including over Tor, the tray's number counted by the window, mentions-only counts that survive a mute, and Discord mutes set on the account.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Fourth release candidate: a rebuilt tray, statuses by service, Do not disturb that silences notifications, and fixes to switching servers, Matrix rate limits and mutes, and Discord's connection.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Third release candidate: Discord forums, a redesigned interface and settings, the one-box composer, Discord's notices and read-only channels, and window recovery after a crash.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
scripts/ui-shots.mjs photographs a fixed list of scenes from an isolated copy of the app (scratch home, its own daemon, a fake IRC server with several days of history) and records motion as mp4. Discord-shaped screens come from a test-only state hook that exists only when main is started with MOHO_UI_SHOTS=1. The launch and fake-server code is shared with the layout check in scripts/lib/harness.mjs.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Second release candidate: YouTube cards for every kind of video, the formatting strip and right-click menu in the message box, typing anywhere, one # per channel row (#237, #265, #266, #267).
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Vitest, with 70 tests across the pure lib modules - formatting (IRC codes,
BBCode, spoilers, emoji and emotes, media and YouTube detection), media URL
resolution including the file-version fragment, the rail and its folders,
mentions, search filters, nick completion, Kick watching, emote and
interruption state - and the media scheme's boundary in the main process,
with Electron stubbed.
scripts/ui-check.mjs runs the built app against a scratch home and a fake
IRC server, opens the emoji picker, the channel, message and member menus
(right-clicked in the window's far corner) and the mentions inbox at two
window sizes, and fails if any of them leaves the window at 80 ms, 400 ms,
1.5 s or 3.5 s. Checked that it fails: with the context menu's clamping
removed it reports every corner-opened menu outside the window.
npm test and the layout check (under xvfb) run in the Linux release job.
#212
Co-Authored-By: Claude Opus 5.5 <[email protected]>
One picker with Stickers and Emoji tabs where the service has stickers,
opened on whichever tab its button is for. A search box, then a category
rail down the left - recent, each server or pack by its icon, smilies,
standard emoji - that scrolls on its own with the wheel and lights the
section the list is in. Along the bottom, what the pointer is over and
where it is from. Stickers get a Frequently Used row of their own, kept
per account, and larger cells.
Lottie stickers are played with lottie-web's light player (no expressions,
which would need eval) from the copy the daemon keeps, only once on screen:
in chat, moving if media autoplay is on and on hover otherwise; in the
picker, on hover; in the rail, still. moho-media is allowed for fetch.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Electron 33 is eleven majors and two years behind, and out of support -
the last three majors get fixes, so every Chromium flaw published since
has stood open in the process that renders text, images and attachments
from four networks. Against that, sandboxing the renderer is the
smaller half of the job. Chromium goes 130 to 152.
One thing broke, and it is a real break rather than a deprecation:
Electron 44 replaced the synchronous clipboard with the W3C shape.
`readImage()`, `writeImage()`, `availableFormats()` are gone; `read()`
returns ClipboardItems addressed by MIME type, asynchronously. Pasting
an image into the composer went through readImage(), so it is ported
here - and it now keeps the format it was given instead of re-encoding
everything to PNG, which the old call could not do.
Nothing else moved. The whole tree typechecks, and on a packaged build:
the window renders, the daemon answers with 309 buffers, the bridge
still has its 28 methods, moho-media images decode, WebCodecs offers
VP8, MediaStreamTrackProcessor and AudioEncoder are present,
RTCPeerConnection negotiates, getDisplayMedia and screenSources answer,
and the renderer is still at Seccomp 2.
What none of that covers is a call. The screen share leans on
WebCodecs, RTP timing and a live peer, and eleven majors of WebRTC have
gone past underneath it - so a real Discord stream and a real Matrix
call want testing before this reaches master.
moho's renderer has been running with no OS sandbox at all. `sandbox:
false` is not merely "no Node in the page" - it opts the renderer out
of Chromium's process isolation entirely, which is a strange place for
the process that draws untrusted text, images and attachments from four
networks to be standing. Measured on a build from before this commit:
Seccomp 0, NoNewPrivs 0, the host's own user namespace. After it:
Seccomp 2, NoNewPrivs 1, a namespace of its own.
Nothing had to move to get it. The preload imports `electron` and
nothing else, which is exactly what a sandboxed preload may do, and
reads its popout flag from process.argv, which stays readable - 28
bridge methods, 309 buffers and 43 of 43 moho-media images, same as
before.
The sandbox needs unprivileged user namespaces, and an AppImage cannot
supply them any other way: its mount is nosuid, so the setuid helper is
unreachable, and an AppArmor profile needs an install step an AppImage
by definition does not have. Hence the deb, which has one: it carries a
profile granting `userns` to /opt/moho/moho and a postinst that loads
it, and on a host old enough to lack the AppArmor version for that,
falls back to the setuid helper instead.
Where neither is possible - Ubuntu 24.04 and after, where the launcher
starts the app with --no-sandbox - it runs anyway and says so, once in
the log and once in the window. Refusing to start would leave somebody
with a chat client that will not open and a dialog pointing at a
different download.
The detection is the command-line switch deliberately. `process
.sandboxed` looks like the right answer and is not: it describes a
renderer's own context and reports true under --no-sandbox just the
same, so it would claim a sandbox that is not there. Checked, rather
than assumed.
electron-builder 26 is what makes all of this available, and it fixes
something older than any of it. The AppImage runtime in 25 is dated
June 2019 and dlopens libfuse.so.2, which neither Ubuntu 22.04 nor
Debian 13 installs by default - the reason Electron AppImages have
wanted `apt install libfuse2` for years. The runtime in 26 is static,
has libfuse3 compiled in, and needs only a fusermount binary, which
fuse3 provides and both distributions ship.
Both targets were checked against the two distributions' real package
indexes rather than assumed: every dependency resolves on Ubuntu 22.04
natively, and on Debian 13 through the t64 packages' Provides.
Also here, because 26 changed or exposed them:
- `desktop` takes its entry under `entry` now; the flat form is ignored
and the launcher would have lost its name and comment.
- `desktopName` plus `syncDesktopName` gives StartupWMClass=moho in
moho.desktop, so a running window is linked to its launcher entry.
- The package points at github.com/Moho-Chat/moho rather than Gitea,
which is the copy somebody holding a deb can actually reach.
- `pack` still builds only the AppImage, so `deploy` stays quick;
`pack:linux` builds both and is what CI calls.
The artifacts were called moho-0.1.0 - a version nobody has bumped since the
first commit and nobody intends to, which made every build ever produced
indistinguishable from every other one by name. Two people comparing "the
0.1.0 AppImage" were not necessarily talking about the same file.
They are named after the commit now, with `-dirty` where the tree was not
clean - which is the honest answer for a build made from uncommitted work,
and the one thing a hash alone cannot tell you.
The install script already globbed for *.AppImage, so nothing downstream
cared what the file was called.
Co-Authored-By: Claude Opus 5 <[email protected]>
One stream up and everybody else's back down, rather than a connection
per person - which is what Element's calls are and, past a handful of
people, the only shape that works.
Deliberately not a second implementation of the call: it hands back the
same people-and-streams the mesh does, so the grid, the speaking ring and
the corner view do not know which kind they are drawing, and mute and
screen share reach whichever is up.
Two things learned by hitting them. The renderer's own CSP refused the
websocket - a signalling connection is neither http nor https - so `wss:`
is allowed now, and plaintext only to this machine, since a server across
a network should not be carrying signalling in the clear. And the stage
now goes up before the joining rather than after it: connecting to a
media server takes a moment, and setting the phase afterwards overwrote
the "connected" the call had already reported.
Co-Authored-By: Claude Opus 5 <[email protected]>
A Kick channel is a stream as much as a chat, and the video was in a
browser tab beside the window that had the conversation in it.
Not a player of its own. The frame a call draws into is now its own
thing - VideoStage - and a call and a stream are two sources feeding it:
the same pane, the same corner it retreats to, the same minimise that
puts the picture down without ending it, the same sizing. What differs
is the pipe. A call is real-time media that arrives as a MediaStream on
srcObject; a stream is one-to-many HLS off a CDN, which needs Media
Source Extensions and so needs hls.js, and that is the only piece here
a call has no use for.
Receiving only, and one at a time: nothing broadcasts, nobody else is in
it, and starting a stream during a call is refused rather than fought
over - the picture has one place to go.
Offered where the channel is, by right-clicking it, and only while there
is something to watch: an offline channel's playlist is a signed URL to
nothing, and an entry that fails a few seconds after being pressed is
worse than one that isn't there. The same menu is how it stops.
Capped below 1080p60. That rendition is 5.8 Mbps of 60fps decode beside
a chat client, and the ladder underneath it still adapts. A refused
segment is read as an expired token rather than a dead stream, and
answered by loading whichever playback URL the daemon has since sent.
Co-Authored-By: Claude Opus 5 <[email protected]>
Issue #12: the networks now carry their own marks, taken from each
network's own site and bundled rather than fetched - an IRC network is
not a CDN, a rail of tiles should not be a rail of requests, and half of
these sites are plain HTTP. Libera, OFTC, Rizon, QuakeNet, Undernet,
hackint, DALnet and CoreIRC have one; EFnet, Snoonet and Abjects publish
none and keep their initials, which is a better answer than a
wrong-looking copy of somebody's artwork. The marks belong to the
networks that own them and identify those networks here and nothing else.
CoreIRC and Abjects are in the list now - the two this client's own users
are on. A network being small is exactly why nobody else's client offers
it ready-made, and typing "irc.coreirc.net" correctly is the step the
list exists to remove.
The form itself: the network is picked from those marks rather than from
a dropdown of names, and the server and port fields appear only for
"Other" - a chosen network has its address already, and a field showing
it invites editing something that is right. Sign-in is asked here now,
as SASL or NickServ, because a registered nick that never identifies is
a nick lost to the ghost of the last session, and that happens on the
first connection rather than after anybody visits a settings pane.
Autojoin left the form entirely. Which channels an account rejoins is a
property of the channels, so it is set on one: right-click a channel,
"Join automatically". It was a comma-separated field typed once and never
seen again.
And Sneedchat's rooms moved out of the account pane into the join panel,
now that the list comes from the site. That page is for joining things; a
room being ticked is exactly that. The account pane is for what an
account is.
npm run deploy stopped building the daemon, with "rustup could not
choose a version of cargo to run" - which reads like rust is missing and
is not.
rustup installs a shim, and the shim finds the real toolchain through
CARGO_HOME and RUSTUP_HOME. On the XDG layout those point under
~/.local/share rather than ~/.cargo, so they are named by environment
variables a login shell sets and an npm script never sees. The shim then
has nowhere to look.
The distribution's own /usr/bin/cargo does run, which makes it look like
the answer, and for a native build it is. It is also the wrong one: it
carries no cross-compilation targets, so a Windows build through it
fails with "can't find crate for `core`" - which in turn reads like a
broken checkout. Each wrong answer points at the other, which is why
this has been worked around by hand all week instead of fixed.
So the XDG locations are filled in when nothing else has, rustup's shim
is preferred because it is the toolchain carrying the targets, and every
candidate is asked `--version` rather than assumed - that question being
the only thing that tells a working toolchain from a shim that cannot
find one. The Linux build goes through the same script as the Windows
one now, since it had the same problem and no reason to solve it
differently.
Both were verified through npm rather than from a shell that already had
the right environment, which is the only way this bug is visible at all.
Co-Authored-By: Claude Opus 5 <[email protected]>
pack:win bundled nobilis/target/release/nobilis.exe, and nothing on a
Linux machine ever writes there - a cross-build goes to
target/x86_64-pc-windows-gnu/release/. So the packager took whichever
.exe happened to be at the first path, which on a machine that has been
cross-building for days was three days old. Building a Windows package
today would have shipped a daemon from before XDCC, whispers and
deeplinks existed, and it would only have shown up as "unknown method"
on somebody else's computer.
npm run daemon built the Linux binary regardless of the target being
packaged, so pack:win never refreshed the exe it was about to ship.
There is a daemon:win now that does, and pack:win runs it.
Its own script rather than a line in package.json because two things
have to be right. rustup's toolchain has to be on PATH, not merely named
- cargo runs whichever rustc it finds, and pointing at rustup's cargo
while a distribution's rustc is first still fails with "can't find crate
for `core`", which reads like a broken checkout. And the copy is
conditional on the executable actually existing, since a build that
fails after having produced one once leaves the old one in place, which
is precisely how the stale copy got there.
Caught by the guard on its first run: the cross-build failed and the
Aug 28 file was left untouched rather than copied forward.
Co-Authored-By: Claude Opus 5 <[email protected]>
Four things assumed Linux, and two of them would have failed silently
rather than loudly, which is the worse kind.
The socket path is the contract's other half. Node needs no help with the
difference beyond the name - net.createConnection speaks Unix sockets or
named pipes according to what it is handed - so only the string changes,
but it has to match nobilis's ipc::default_endpoint exactly. A
disagreement here is a client that never finds a daemon that is running
perfectly well.
The media allowlist computed XDG paths unconditionally. This is the
predicate deciding which local files the renderer may load, and it has to
agree with Rust's `dirs` crate, which is what actually wrote the files -
not with Electron's own getPath, which answers for this application and
points somewhere nobilis never writes. On Windows the XDG guess resolves
to a .cache directory under the profile that nothing writes to, so the
check would have failed closed and blanked every avatar and every
attachment the daemon fetched, with nothing in the UI to say why.
Two path bugs went with it. A Windows absolute path starts at a drive
letter rather than a slash, so a bare C:\... arrived looking like a
remote URL and was handed to the renderer unresolved - which under
contextIsolation shows nothing at all. And file:///C:/x leaves a leading
slash that Windows reads as a path relative to the current drive. Both
mean the same file; both are accepted now.
Path comparison in the allowlist is case-insensitive where the filesystem
is. C:\Users and c:\users name the same file while startsWith calls them
different - failing closed, so it leaks nothing, but a media allowlist
that silently blanks half the avatars is still broken.
Quitting now asks over the socket on every platform instead of
signalling. A signal is not a portable way to say "shut down cleanly":
Windows has no SIGTERM and Node maps it onto TerminateProcess, which
would skip the QUITs whose absence leaves a ghost session holding the
nick until the network times it out. The shutdown RPC means the same
thing everywhere, and is a request rather than an end that cannot be
refused.
Packaging gains a Windows target - NSIS, not one-click, installing per
user - and the daemon binary is named per platform in both the packaged
and the development lookup.
Submodule moves to the matching daemon.
The AppImage went to ~/.local/bin, which is already on PATH here, with
a launcher entry and the icon at every size a panel or window switcher
might ask for - scaling one 512px icon down at display time is blurrier
than shipping the sizes.
Paired with packaging as `npm run deploy` rather than left as a manual
step. Building and installing are different acts, but the gap between
them is where a stale copy lives: a fresh AppImage sitting in dist/
while an older one runs from ~/.local/bin is exactly the confusion that
already cost an evening once.
Co-Authored-By: Claude Opus 5 <[email protected]>
Packaging copies nobilis/target/release/nobilis into the app, and
nothing ever built it. The binary sitting there was three days old, so
every AppImage built since had been shipping a daemon from before the
work it was meant to contain - rebuilding changed the frontend and left
the daemon exactly where it was, which is a confusing way to fail: the
new UI appears, presses a button, and the daemon answers that it has
never heard of it.
An unknown method now says that is what happened, rather than reading
like a bug in the feature. The two halves are separately built binaries
and the daemon can also be one left running from before an upgrade, so
this will be met again.
Co-Authored-By: Claude Opus 5 <[email protected]>
The daemon moves to its own repository as nobilis, so both frontends - this
one and dms-chat - depend on it as a peer instead of one of them vendoring it.
The vendored chatd tree and the Rust workspace are gone; `npm run daemon`
builds the submodule, and packaging pulls the binary out of it.
The Sneedchat smiley images go with it, next to the table that names them.
Previously nobilis held the filenames while this repository held the files,
so the two could drift apart with nothing to catch it.
Fixes found while testing against live accounts:
- The sidebar did not truncate. A flex item's automatic minimum size is its
content's min-content width, so prose-titled Matrix rooms made the buffer
list wider than its pane and painted over the message list.
- Discord's QR never rendered: the event field is `qrCodePath`, not `path`.
The Sneedchat and Matrix login progress text was blank for the same reason -
all four status events carry `detail`, not `status`.
- Every Matrix message rendered as a grey system line with no author or
avatar. IRC, Discord and Sneedchat tag an ordinary message "chat" but Matrix
tags it "message"; only the first was treated as something a person said.
- Context menus and the emoji picker swallowed their own clicks. Both
dismissed on a capture-phase mousedown, which unmounts the item before the
click can land on it - so pin, mute, hide and emoji selection all did
nothing. Presses inside the popup are now ignored.