frq
A freeq client written in Nim,
painted by Flutter. Nim owns the state, the screens, the IRC connection and
the message signing; Flutter is a renderer over the widget tree it emits.
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.
nim/ the program: state, screens, IRC, signing
dart/frq_core the FFI binding — plain Dart, not a Flutter package
flutter/lib the renderer, and the app's entry point
nim/src/frq/ui.nim the widget tree, in the screens' own tag vocabulary
nim/src/frq/cells.nim every piece of state the screens read
nim/src/frq/reducer.nim every event they can send, and what it does
nim/src/frq/conn.nim the socket, the TLS, the line framing
nim/src/frq/ircparse.nim the IRC wire format
nim/src/frq/handshake.nim CAP and the SASL exchange inside it
nim/src/frq/atproto.nim handle → DID → PDS → session
nim/src/frq/crypto.nim OpenSSL, bound: SHA-256 and Ed25519
nim/src/frq/msgsig.nim signing a mutation so freeq will accept it
nim/src/frq/screens/ connect, chats, chat, discover, settings
flutter/lib/nim_renderer.dart the tags, as Flutter widgets
There were two other clients here. src/ was jolt with a libcosmic window and
a terminal; common/ and flutter/src/ were ClojureDart, compiled for
Android, Linux and the web. Both are gone. The APK and the web target went with
the second: a browser has no dart:ffi, so the web needs the core compiled to
wasm rather than ClojureDart restored.
Running
just run desktop # build and open the window
just test # nim, dart and the layout suite
just test live # the whole stack against a real freeq
tools/toolchain.sh fetches Flutter and Nim by sha256; there is no nix and no
JVM. The host brings a C compiler, OpenSSL, GTK and the usual
CMake/Ninja/pkg-config.
Two switches, because a Wayland window cannot be clicked from a script:
FRQ_TRACE=1 just run desktop # every line in and out, both languages
FRQ_AUTOCONNECT=1 just run desktop # press Connect at startup
frq 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
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.
Targets
One: the Linux desktop.
There were three, and losing two is the cost of the port rather than an
accident. The web needs the core compiled to wasm — a browser has no
dart:ffi, so there is no way for Dart to call a native library there, and
that was true of this design from the first day. Android needs
libfrqcore.so cross-compiled for its ABIs; dart:ffi works there, so this
is a build problem rather than a design one.
What the PoC covers
- TLS (
:6697) or plain TCP (:6667), out of Nim'sstd/net - 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 the backend 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
Limits
- TLS and plain TCP only — no WebSocket, no iroh. On Android, plain only.
- No
did:keysigning, no credential gates, no E2EE. - Only the broker token is persisted, and only for OAuth. An app-password
sign-in is not remembered. - Previews are PNG only. The link is left in place either way.
- Nothing evicts the media cache.
- No calls. The AV signaling is still in the screens, but the media plane
it drove waslibjoltmoqunder the retired jolt half — Opus, H.264, V4L2 and
ALSA, none of which crosses to Flutter. The Call controls are wired to
actions no target installs. Flutter's camera and audio plugins are the way
back in, and that is its own project. - Attaching a picture needs a sign-in. The upload is filed under the DID
of a live session, so a guest cannot make one. 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 to flush it.
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 |
|