frq
A freeq client written in
jolt, as
glimmer components painted by
Vidya/egui.
It is a proof of concept port of sleek, which is the same client in
Rust against egui directly. The screens are sleek's — connect, chats, chat,
discover, settings, under a tab bar — but each is hiccup over glimmer's widget
tags rather than immediate-mode drawing code, and state lives in ratoms instead
of an AppState struct.
src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads
src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session
src/frq/store.jolt the saved sign-in, mode 600 in the config directory
src/frq/avatars.jolt profile pictures, by DID or handle
src/frq/profile.jolt who someone is: the Bluesky profile behind a nick
src/frq/media.jolt image links: spot them, fetch them once, cache on disk
src/frq/upload.jolt a pasted picture to freeq's media endpoint, as multipart
src/frq/av.jolt calls: the signaling, and a handle on the media plane
src/frq/clock.jolt the reader's own zone, twelve-hour times, day headings
src/frq/emoji.jolt the picker's catalog: every drawable emoji and its name
src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
src/frq/state.jolt the ratoms every screen reads, and `apply-msg!`
src/frq/app.jolt the screens
Tracing
FRQ_TRACE=1 prints every IRC line sent and received to stderr, which on
Android is logcat.
Running
Both native libraries, then the app:
just lib
just run
just lib fetches both shared objects this client loads —
jolt-native's libvidya, the
retained-tree ABI glimmer paints through, and libjoltmoq, the AV media plane
— out of that project's release, by the digests in scripts/*.dotslash, and
links them into build/lib. Nothing is compiled: no Rust toolchain, and no
jolt-native checkout beside this one. It needs patchelf, and only to name
libasound in libjoltmoq.so, which v0.1.3 does not — see the comment in
scripts/lib.bb; that goes away with the release that links it. just bump moves every pin to the
latest release at once, and just bump v0.1.2 to a named one.
just run is jolt -M:frq with LD_LIBRARY_PATH pointed at build/lib. It
connects to irc.freeq.at:6697 over TLS and joins #test. Untick TLS on the
connect screen (or point it at 127.0.0.1) for a local server's
plain listener:
cargo run --release --bin freeq-server # in the freeq checkout
In a terminal
The screens are hiccup over glimmer's reconciler, and the reconciler does not
know what is under it — so the same tree paints into a terminal through
jolt-native's libjolttui, which exports libvidya's retained-tree ABI over a
grid of cells instead of a GPU window.
It is the client, not a preview of it. frq.app/start! is what a launch does —
the saved settings, the rooms this client has been in, the sign-in that
connects itself — and src/frq/tui.jolt hands it the terminal's timers instead
of the window's. Nothing in frq.app changed.
nix run .#tui # or: just tui
just tui --headless --cols=90 --rows=60 # one screenshot on stdout
just tui --headless --demo # a buffer of its own, no server
just tui --headless --wait=9000 # long enough to have connected
The headless one is tui_headless — the same layout and the same painting with
the writer taken off the end — which is what a screenshot in a bug report or a
CI check should be. It paints once and prints, so --wait= is how long the
client is given first: the default is a picture of the connect screen, because
that is where a client is a moment after launch, and --demo fills a #tui
buffer for a screenshot that is not waiting on a server at all.
Logs go to stderr, which in a terminal session is the screen frq is painting.
Send them somewhere: nix run .#tui 2>/tmp/frq.log.
What a terminal has not got, frq does without: pictures, avatars and the
lightbox draw nothing, and calls are off — the media plane paints frames into
a texture, and there is no texture here. And frq's spacing is written in
points, for a window — the backend is handed :points-per-cell 8 so those
numbers land in cells, but below-messages in src/frq/app.jolt is point
arithmetic rather than a point length, and a scale cannot fix it: it
reserves about thirteen rows more than the compose bar needs, so the bottom of
the backlog is pushed out of the list.
Two things are unpinned, because the terminal backend is not in a jolt-native
release yet: libjolttui.so comes out of a jolt-native checkout's target
directory (JOLT_NATIVE=…, or beside this tree) for just tui, and the flake
carries a second jolt-native-tui input at the rev that has it — its own input
rather than a bump, so the window half stays on the release the rest of the
tree names. Both become one pin when it ships.
Signing in
Three modes on the connect screen.
Bluesky (OAuth, the default way in) follows sleek's flow: frq binds a
loopback port, puts it in return_to, and opens
auth.freeq.at/auth/login?handle=…. The broker runs the OAuth dance with the
PDS and redirects back to that port with the handoff in the URL fragment, so
it never reaches a server as a query string. The page frq serves there has one
job: POST the fragment back to itself. What comes back is a single-use SASL
web-token and a durable broker_token; later connections mint a fresh token
from the durable one at /session and skip the browser.
The durable token is saved to $XDG_CONFIG_HOME/frq/session.edn (mode 600) so
a restart resumes without one, along with the handle and nick it belongs to —
and it connects on its own at launch when one is there.
The web-token beside it is single-use and deliberately not saved. A token the
broker no longer honours is dropped — from disk and memory — and the browser
flow runs once more, rather than failing the same way on every Connect.
App password signs in without a browser, straight to the user's own PDS:
resolveHandle → DID → PDS from the DID document → createSession. The
password goes to that PDS and nowhere else, is never written to disk, and is
dropped once the session exists.
Either way freeq sees only a token. The SASL mechanism is
ATPROTO-CHALLENGE in both cases — method: "web-token", which the server
resolves through its own token store, or method: "pds-session" with the
server's nonce echoed back so the token cannot be replayed elsewhere.
A refused sign-in is reported and the connection carries on as a guest.
Android
An APK whose native halves come from jolt-native's release and whose Jolt half
is frq's: libvidya.so (the Rust/egui C ABI, which owns the event loop as the
NativeActivity's own library), libjoltmoq.so (the media plane) and
libjoltapp.so (frq compiled to a Chez boot image, linked against both).
./android/build-apk.bb run # build, install, launch on a connected device
./android/build-apk.bb log # logcat, filtered
Needs an SDK and a cross-built Chez in ~/.cache/vidya-chez-android; the NDK
comes down through scripts/android-ndk.dotslash. just apk builds the same
APK as a buck2 graph, which is the incremental way in.
TLS does not work there: jolt reaches OpenSSL through the dynamic loader, and
Android has no public libssl to load. The connect screen falls back to the
plain :6667 listener on its own, which is why the plain transport is the raw
socket/connect/send/recv calls rather than jolt's java.net.Socket
surface — that surface does not work on Android either, while the syscalls do.
What the PoC covers
- TLS (
:6697, via jolt.mvn-http's OpenSSL bindings) or plain TCP (:6667) - Guest connect (
NICK/USER),001welcome,PING/PONGkeepalive - Auto-joins
#testonirc.freeq.at - Join channels, channel buffers with unread counts, send and receive
PRIVMSG - Backlog on join, and
CHATHISTORYfor the channels freeq restores instead - Twelve-hour timestamps from the server's own clock, with a heading wherever
the day changes - A chip above a reply quoting what it answers, and a click that goes there;
↩ beside a sender to answer them, with+draft/replyon the way out - Emoji reactions: colour pills under a message, ☺ beside the sender to open a
picker over every emoji Vidya can draw (popular first, then Unicode's own
groups, searchable by name), and a second click on a pill to take yours off
— sent asTAGMSG, and restored from the server's own tally when the
backlog comes back - Inline previews for PNG links, fetched once and cached under
$XDG_CACHE_HOME/frq/media; click one to see it full size - Ctrl+V in the draft attaches the picture on the clipboard: it is previewed
under the box and uploaded to freeq's media endpoint while you write the line
it goes with, and only on the way out does it become the link — which is the
whole of what sending an image over IRC means. The draft itself is never
written into. Text pastes as text, as it always did: the picture path is the
keystroke the field had no text to answer with - Join/part notices, DMs bucketed under the sender's nick
- Discover list, search over buffers, disconnect
- The rooms you have opened, remembered across runs and listed in the order
you last used them ($XDG_CONFIG_HOME/frq/channels.edn) - Conversations listed most recently opened first
- Bluesky avatars beside the sender, resolved from the DID freeq tags each
message with - Calls: a Call button opens one in a channel, a banner offers Join where
somebody already has, and in one there is mute, deafen, video and leave. Mute
and deafen are separate — a deafened microphone still carries your voice.
Whoever turns a camera on appears as a tile; the self-view is labelled You
and sits last, where it cannot push a face you are talking to off the row
Calls
Signaling is IRC and lives here: +freeq.at/av-start, av-join and av-leave
go out as TAGMSGs and the server broadcasts +freeq.at/av-state back, which is
what actually moves this client's state — a press is optimistic, and the server
settles it. Losing a race to open a call (start-collision) is answered by
joining the call that won rather than by reporting an error, since the person
asked to be in a call in that room and there is one.
Media is not IRC and is not here. Audio and video ride MoQ — Media over QUIC —
through freeq's SFU, and that is libjoltmoq: Opus, H.264, capture and
transport, lifted out of sleek rather than written a second time in jolt.
src/frq/av.jolt is the whole of what frq says to it, and two of its rules
shape this side:
- Nothing calls back. Status and video are polled, drained by a timer that
glimmer runs on the loop thread — the only thread allowed to touch a node. - A frame is borrowed. The decoder's own buffer is handed to Vidya as a
pointer and painted by an:imagewith a:feed. The pixels never become a
jolt value and are never copied on this side, which is the only way thirty
frames a second is affordable here.
The SFU is dialled once the server has minted a token, not when we ask to join:
a remote SFU refuses a connection without one, and the MoQ client then retries
in a loop that looks exactly like a hang.
Limits
- TLS and plain TCP only — no WebSocket, no iroh. On Android, plain only.
- No
did:keysigning, no credential gates, no E2EE. Sign-in of either
kind needs TLS, so it is desktop-only — the Android build connects as a
guest. - Only the broker token is persisted, and only for OAuth. An app-password
sign-in is not remembered. - Previews are PNG only — the tree backend's decoder reads no other
format, and a fetch needs TLS, so the phone shows links. The link is left in
place either way. - Nothing evicts the media cache.
- Calls are desktop-only.
libjoltmoqis not built for Android here, and
the camera and microphone paths that are would still need the runtime
permissions the APK does not ask for. - One call at a time, which is the media plane's rule and the microphone's.
- No call is offered in a DM — freeq's AV signaling is a channel's.
- Pasting a picture needs a sign-in and a desktop. The upload is filed
under the DID of a live session, so a guest cannot make one; and it is read
off the clipboard through the ABI'svidya_clipboard_image_png, which
arboard backs on desktop and nothing backs on Android. It also shares
nothing to your PDS and posts nothing to Bluesky — those fields are opt-in
and this client does not send them. - No scrollback trimming or threads.
- A sent line waits up to 200ms for the reader thread to flush it.
- Message lists are keyed vboxes; glimmer-vidya has no
:listboxyet.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 |
|