| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 1 | # frq |
| 2 | |
| The last of the Clojure 284b59c nandi 18h ago | 3 | A **[freeq](https://github.com/codegod100/freeq)** client written in **Nim**, |
| 4 | painted by Flutter. Nim owns the state, the screens, the IRC connection and |
| 5 | the message signing; Flutter is a renderer over the widget tree it emits. |
| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 6 | |
| 7 | It is a proof of concept port of [sleek](../sleek), which is the same client in |
| 8 | Rust against egui directly. The screens are sleek's — connect, chats, chat, |
| The last of the Clojure 284b59c nandi 18h ago | 9 | discover, settings, under a tab bar. |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 8d ago | 10 | |
| 11 | ``` |
| The last of the Clojure 284b59c nandi 18h ago | 12 | nim/ the program: state, screens, IRC, signing |
| 13 | dart/frq_core the FFI binding — plain Dart, not a Flutter package |
| 14 | flutter/lib the renderer, and the app's entry point |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 8d ago | 15 | ``` |
| 16 | |
| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 17 | ``` |
| The last of the Clojure 284b59c nandi 18h ago | 18 | nim/src/frq/ui.nim the widget tree, in the screens' own tag vocabulary |
| 19 | nim/src/frq/cells.nim every piece of state the screens read |
| 20 | nim/src/frq/reducer.nim every event they can send, and what it does |
| 21 | nim/src/frq/conn.nim the socket, the TLS, the line framing |
| 22 | nim/src/frq/ircparse.nim the IRC wire format |
| 23 | nim/src/frq/handshake.nim CAP and the SASL exchange inside it |
| 24 | nim/src/frq/atproto.nim handle → DID → PDS → session |
| 25 | nim/src/frq/crypto.nim OpenSSL, bound: SHA-256 and Ed25519 |
| 26 | nim/src/frq/msgsig.nim signing a mutation so freeq will accept it |
| 27 | nim/src/frq/screens/ connect, chats, chat, discover, settings |
| 28 | flutter/lib/nim_renderer.dart the tags, as Flutter widgets |
| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 29 | ``` |
| 30 | |
| The last of the Clojure 284b59c nandi 18h ago | 31 | There were two other clients here. `src/` was jolt with a libcosmic window and |
| 32 | a terminal; `common/` and `flutter/src/` were ClojureDart, compiled for |
| 33 | Android, Linux and the web. Both are gone. The APK and the web target went with |
| 34 | the second: a browser has no `dart:ffi`, so the web needs the core compiled to |
| 35 | wasm rather than ClojureDart restored. |
| Stop mistaking a quiet connection for a closed one a225fb1 nandi 20d ago | 36 | |
| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 37 | ## Running |
| 38 | |
| 39 | ```bash |
| The last of the Clojure 284b59c nandi 18h ago | 40 | just run desktop # build and open the window |
| 41 | just test # nim, dart and the layout suite |
| 42 | just test live # the whole stack against a real freeq |
| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 43 | ``` |
| 44 | |
| The last of the Clojure 284b59c nandi 18h ago | 45 | `tools/toolchain.sh` fetches Flutter and Nim by sha256; there is no nix and no |
| 46 | JVM. The host brings a C compiler, OpenSSL, GTK and the usual |
| 47 | CMake/Ninja/pkg-config. |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi yesterday | 48 | |
| The last of the Clojure 284b59c nandi 18h ago | 49 | Two switches, because a Wayland window cannot be clicked from a script: |
| 50 | |
| 51 | ```bash |
| 52 | FRQ_TRACE=1 just run desktop # every line in and out, both languages |
| 53 | FRQ_AUTOCONNECT=1 just run desktop # press Connect at startup |
| 54 | ``` |
| Build the desktop libraries rather than fetching a release of them 0b81161 nandi 16d ago | 55 | |
| 56 | frq connects to `irc.freeq.at:6697` over TLS and joins `#test`. Untick TLS on |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi yesterday | 57 | the connect screen (or point it at `127.0.0.1`) for a local server's plain |
| 58 | listener: |
| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 59 | |
| 60 | ```bash |
| 61 | cargo run --release --bin freeq-server # in the freeq checkout |
| 62 | ``` |
| 63 | |
| Sign in with a Bluesky identity 5192124 nandi 21d ago | 64 | ## Signing in |
| 65 | |
| Sign in with Bluesky OAuth, through freeq's broker 2d4a377 nandi 21d ago | 66 | Three modes on the connect screen. |
| Sign in with a Bluesky identity 5192124 nandi 21d ago | 67 | |
| Sign in with Bluesky OAuth, through freeq's broker 2d4a377 nandi 21d ago | 68 | **Bluesky** (OAuth, the default way in) follows sleek's flow: frq binds a |
| 69 | loopback port, puts it in `return_to`, and opens |
| 70 | `auth.freeq.at/auth/login?handle=…`. The broker runs the OAuth dance with the |
| 71 | PDS and redirects back to that port with the handoff in the URL *fragment*, so |
| 72 | it never reaches a server as a query string. The page frq serves there has one |
| 73 | job: POST the fragment back to itself. What comes back is a single-use SASL |
| 74 | `web-token` and a durable `broker_token`; later connections mint a fresh token |
| 75 | from the durable one at `/session` and skip the browser. |
| Sign in with a Bluesky identity 5192124 nandi 21d ago | 76 | |
| Remember an OAuth sign-in across restarts 4aa71e7 nandi 20d ago | 77 | The durable token is saved to `$XDG_CONFIG_HOME/frq/session.edn` (mode 600) so |
| Connect on launch when an account is remembered 9237cf4 nandi 20d ago | 78 | a restart resumes without one, along with the handle and nick it belongs to — |
| 79 | and it connects on its own at launch when one is there. |
| Remember an OAuth sign-in across restarts 4aa71e7 nandi 20d ago | 80 | The web-token beside it is single-use and deliberately not saved. A token the |
| 81 | broker no longer honours is dropped — from disk and memory — and the browser |
| 82 | flow runs once more, rather than failing the same way on every Connect. |
| 83 | |
| Sign in with Bluesky OAuth, through freeq's broker 2d4a377 nandi 21d ago | 84 | **App password** signs in without a browser, straight to the user's own PDS: |
| 85 | `resolveHandle` → DID → PDS from the DID document → `createSession`. The |
| 86 | password goes to that PDS and nowhere else, is never written to disk, and is |
| 87 | dropped once the session exists. |
| Sign in with a Bluesky identity 5192124 nandi 21d ago | 88 | |
| Sign in with Bluesky OAuth, through freeq's broker 2d4a377 nandi 21d ago | 89 | Either way freeq sees only a token. The SASL mechanism is |
| 90 | `ATPROTO-CHALLENGE` in both cases — `method: "web-token"`, which the server |
| 91 | resolves through its own token store, or `method: "pds-session"` with the |
| 92 | server's nonce echoed back so the token cannot be replayed elsewhere. |
| 93 | |
| 94 | A refused sign-in is reported and the connection carries on as a guest. |
| Sign in with a Bluesky identity 5192124 nandi 21d ago | 95 | |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi yesterday | 96 | ## Targets |
| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 97 | |
| The last of the Clojure 284b59c nandi 18h ago | 98 | One: the Linux desktop. |
| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 99 | |
| The last of the Clojure 284b59c nandi 18h ago | 100 | There were three, and losing two is the cost of the port rather than an |
| 101 | accident. **The web** needs the core compiled to wasm — a browser has no |
| 102 | `dart:ffi`, so there is no way for Dart to call a native library there, and |
| 103 | that was true of this design from the first day. **Android** needs |
| 104 | `libfrqcore.so` cross-compiled for its ABIs; `dart:ffi` works there, so this |
| 105 | is a build problem rather than a design one. |
| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 106 | |
| 107 | ## What the PoC covers |
| 108 | |
| The last of the Clojure 284b59c nandi 18h ago | 109 | * TLS (`:6697`) or plain TCP (`:6667`), out of Nim's `std/net` |
| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 110 | * Guest connect (`NICK`/`USER`), `001` welcome, `PING`/`PONG` keepalive |
| 111 | * Auto-joins `#test` on `irc.freeq.at` |
| 112 | * Join channels, channel buffers with unread counts, send and receive `PRIVMSG` |
| Ask for the backlog freeq restores channels without 94e59a2 nandi 20d ago | 113 | * Backlog on join, and `CHATHISTORY` for the channels freeq restores instead |
| Say when each thing was said dbf3b86 nandi 20d ago | 114 | * Twelve-hour timestamps from the server's own clock, with a heading wherever |
| 115 | the day changes |
| Answer a message, not just read that one was answered 4876db8 nandi 20d ago | 116 | * A chip above a reply quoting what it answers, and a click that goes there; |
| 117 | ↩ beside a sender to answer them, with `+draft/reply` on the way out |
| Pick any emoji, in colour 18c5ccd nandi 20d ago | 118 | * Emoji reactions: colour pills under a message, ☺ beside the sender to open a |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 8d ago | 119 | picker over every emoji the backend can draw (popular first, then Unicode's own |
| Pick any emoji, in colour 18c5ccd nandi 20d ago | 120 | groups, searchable by name), and a second click on a pill to take yours off |
| 121 | — sent as `TAGMSG`, and restored from the server's own tally when the |
| 122 | backlog comes back |
| Show the pictures people paste 56cdac3 nandi 20d ago | 123 | * Inline previews for PNG links, fetched once and cached under |
| Click a picture to see it full size 563ada3 nandi 20d ago | 124 | `$XDG_CACHE_HOME/frq/media`; click one to see it full size |
| Send a picture by pasting it 8a491ba nandi 20d ago | 125 | * Ctrl+V in the draft attaches the picture on the clipboard: it is previewed |
| 126 | under the box and uploaded to freeq's media endpoint while you write the line |
| 127 | it goes with, and only on the way out does it become the link — which is the |
| 128 | whole of what sending an image over IRC means. The draft itself is never |
| 129 | written into. Text pastes as text, as it always did: the picture path is the |
| 130 | keystroke the field had no text to answer with |
| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 131 | * Join/part notices, DMs bucketed under the sender's nick |
| 132 | * Discover list, search over buffers, disconnect |
| Remember which rooms this client has been in 1d062fd nandi 20d ago | 133 | * The rooms you have opened, remembered across runs and listed in the order |
| 134 | you last used them (`$XDG_CONFIG_HOME/frq/channels.edn`) |
| Order the chat list by what you were last in 75e557e nandi 20d ago | 135 | * Conversations listed most recently opened first |
| Show who is talking, with their Bluesky picture f5548bd nandi 20d ago | 136 | * Bluesky avatars beside the sender, resolved from the DID freeq tags each |
| 137 | message with |
| A freeq client in jolt, as glimmer components on Vidya 4719d6f nandi 21d ago | 138 | |
| 139 | ## Limits |
| 140 | |
| 141 | * **TLS and plain TCP only** — no WebSocket, no iroh. On Android, plain only. |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi yesterday | 142 | * **No `did:key` signing, no credential gates, no E2EE.** |
| Remember an OAuth sign-in across restarts 4aa71e7 nandi 20d ago | 143 | * **Only the broker token is persisted**, and only for OAuth. An app-password |
| 144 | sign-in is not remembered. |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi yesterday | 145 | * **Previews are PNG only.** The link is left in place either way. |
| Show the pictures people paste 56cdac3 nandi 20d ago | 146 | * **Nothing evicts the media cache.** |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi yesterday | 147 | * **No calls.** The AV signaling is still in the screens, but the media plane |
| 148 | it drove was `libjoltmoq` under the retired jolt half — Opus, H.264, V4L2 and |
| 149 | ALSA, none of which crosses to Flutter. The Call controls are wired to |
| 150 | actions no target installs. Flutter's camera and audio plugins are the way |
| 151 | back in, and that is its own project. |
| 152 | * **Attaching a picture needs a sign-in.** The upload is filed under the DID |
| 153 | of a live session, so a guest cannot make one. It also shares nothing to your |
| 154 | PDS and posts nothing to Bluesky — those fields are opt-in and this client |
| 155 | does not send them. |
| Calls f31ad3d nandi 20d ago | 156 | * **No scrollback trimming or threads.** |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi yesterday | 157 | * A sent line waits up to 200ms for the reader to flush it. |