frq
A freeq client written in
ClojureDart, painted by
Flutter — one set of screens on Android, on the Linux desktop and in a browser.
See flutter/README.md.
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 the widget tags
frq.hiccup translates into Flutter, and state lives in atoms instead of an
AppState struct.
Source lives in three trees:
common/ .cljc portable: the screens, the state, the protocol — no dart:
flutter/ .cljd the Flutter half, and the host's answers — flutter/README.md
nim/ .nim the portable logic as a native library — nim/README.md
The first two are the client as it runs today; nim/ is where the logic under
the screens is moving, a module at a time, behind a C ABI the Dart side calls
through FFI. One module has made the trip so far. See nim/README.md for
what is wired up and what is not.
What common/ needs of the host it asks common/frq/io.cljc for — the seam,
named once and answered per target: frq.io.dart on Android and the desktop,
frq.io.web in a browser.
common/frq/io.cljc the seam: filesystem, environment, config dir, clock
common/frq/clock.cljc IRCv3 time tags → the reader's own zone
common/frq/store.cljc the saved sign-in, mode 600 in the config directory
common/frq/emoji.cljc the picker's catalog: every emoji and its name
common/frq/rooms.cljc the rooms this client has been in, and their order
common/frq/cells.cljc the atoms every screen reads
common/frq/screens/ connect, chats, chat, discover, settings
common/frq/irc/parse.cljc the IRC line parser, tags and all
common/frq/irc/handshake.cljc SASL, driven from shared code
common/frq/atproto/core.cljc handle → DID → PDS → session, and the SASL payloads
common/frq/oauth/core.cljc the broker flow, as far as it is portable
common/frq/msgsig.cljc message signatures
flutter/src/frq/main.cljd the entry point: installs the host, then starts
flutter/src/frq/hiccup.cljd the widget tags, as Flutter
flutter/src/frq/net/ sockets: dart:io on native, WebSocket on the web
flutter/src/frq/io/ the host's answers to the seam
There used to be a third tree, src/, and another runtime under it: jolt, with
glimmer components painted by
libcosmic in a desktop window and by libjolttui in a terminal, plus an
AV media plane over MoQ. It is gone. Flutter is the only frontend now, which is
why common/ no longer carries #?(:jolt ...) reader conditionals and why the
calls, terminal and nix run .#frq sections that used to be here are not.
Tracing
FRQ_TRACE=1 prints every IRC line sent and received to stderr, which on
Android is logcat.
Running
just flutter-desktop run # the Linux window
just apk run # onto a connected Android device
just flutter-web serve # a browser, on :8080
Every recipe lives in the justfile itself. The two that need a toolchain from
Nix re-enter nix develop and come back to the same recipe, so just flutter-desktop and nix develop .#flutter-desktop --command just flutter-desktop are one code path rather than two. just flutter-web needs no
Nix at all: tools/toolchain.sh fetches Flutter, a JDK and the Clojure CLI by
sha256, which is what lets .modal/flutter-web/ run the same script on a plain
Debian image.
All three are one clojure -M:cljd compile over flutter/src and common/,
and differ only in which Flutter target runs afterwards.
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
A browser has no TCP, so the web build wants a WebSocket URL in the Server
field — wss://irc.freeq.at/irc. And Bluesky sign-in only completes on
localhost, because that is the one origin freeq's auth broker will redirect
back to: just web-local is what serves the Modal-built bundle there.
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
Three, from one compile, and what separates them is the host half rather than
the screens.
Android and the Linux desktop are both dart:io underneath:
frq.io.dart answers the seam, frq.net.dart opens a real TCP or TLS socket.
frq.io.dart/write-private-file! is the one place that asks which of the two it
is on (Platform.isAndroid), because assuming cost a token its file mode.
The web is not: a browser has no TCP and no filesystem, so frq.net.web
carries an IRC WebSocket and frq.io.web keeps the seam's files in local
storage. Bluesky sign-in works there only on localhost — see Running.
What carries over untouched is common/ — the screens, the state, the parser,
the protocol. See the two trees at the top.
What the PoC covers
- TLS (
:6697, out ofdart:io) or plain TCP (:6667); a WebSocket on the web - 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 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 |
|