The ClojureDart half
This is the boundary, drawn before the port rather than after it, so that the
question "can this file go on the phone?" has a filesystem answer. It builds:
see "Building it" below.
The three trees
common/ .cljc both compilers. No jolt, no glimmer, no dart.
src/ .clj jolt: glimmer, jolt.ffi, the cosmic and tui backends.
flutter/ .cljd ClojureDart: Flutter widgets, dart:io, dart:ffi.
The extension is the boundary and the compilers enforce it. ClojureDart reads
.cljd and .cljc and never .clj, so a namespace that reaches for
jolt.host cannot accidentally end up in the APK — it is a .clj and the Dart
compiler cannot see it. jolt reads all three, which is why common/ works at
all: one copy of frq.clock, compiled twice.
Where both need a namespace but the answer differs, .cljd wins over .cljc
in ClojureDart's own resolution, so a file here shadows a shared one without
either side knowing. Reader conditionals work too, with one trap from
ClojureDart's FAQ: the :clj feature is always on under cljd, so :clj goes
last in a conditional, and macro code that wants the Clojure path during
host evaluation asks for :cljd/clj-host.
What has crossed
frq.io is the seam — the host's job named once, with frq.io.jolt answering
it on the desktop and frq.io.dart here. It carries the filesystem, the
environment, the config directory and the clock.
Moved to common/ and running under jolt today:
| namespace | lines | note |
|---|---|---|
frq.emoji |
1,914 | data; nothing to port |
frq.glyphs |
84 | data |
frq.av.dial |
117 | already touched neither jolt nor glimmer |
frq.clock |
93 | zone-hunting moved into the backends |
frq.store |
122 | install -m 600 became write-private-file! |
frq.clock is the shape the rest should follow. It used to open with four
guesses at the reader's zone — TZ, the target of /etc/localtime, the file
itself by path, then Android's persist.sys.timezone — and then convert days
to a date by printing one with jolt.time.local and taking a subs of the
result. Both are gone: the guessing is a libc question and lives in
frq.io.jolt, where Dart answers it in one call instead; the conversion is
eleven lines of Hinnant's algorithm, checked against java.time.LocalDate for
every day from 1901 to 2052.
What has not
Roughly 4,000 lines are portable in substance and still .clj because the seam
does not reach far enough yet. In the order worth doing them:
frq.wire,frq.msgsig— need a crypto seam beside the io one.frq.irc(433) — the parser is pure; the reader is a blocking thread in
afuture, and Dart has no threads. It becomes aStreamover
SecureSocket, which is also what makes TLS work on the phone at all.frq.atproto(209),frq.oauth(182) — hand-rolled HTTPS over
jolt.mvn-http's OpenSSL bindings, which is why sign-in is desktop-only
today.dart:iohas TLS in the runtime; this is the single biggest thing
the port buys.frq.state(1,930) — mostly portable logic, but its ratoms are
glimmer's. Needs the reactive layer decided first.frq.app(1,834) — not a port. Flutter brings its own reconciler, so
the screens are rewritten againstcljd.flutter.
Not coming: frq.tui (no terminal Flutter), frq.cosmic (libcosmic is
desktop-only), and the media plane — moq/, codec/, capture/, av/, about
3,800 lines of FFI against C libraries that do not exist on Android either way.
dart:ffi does not conjure V4L2; that half wants Flutter's camera and audio
plugins and is its own project.
Building it
Two targets out of one tree. The ClojureDart compile is the same command for
both — clojure -M:cljd compile over src/ and ../common — and what differs
is only what Flutter is asked to wrap it in.
just apk # the debug APK
just apk install # and onto a connected device
just apk run # and launched
just apk log # logcat
just flutter-desktop # the debug Linux bundle
just flutter-desktop run # and the window
The desktop one
There are two desktop GUIs now, and they are not a fallback for each other:
just cosmic run is libcosmic under jolt, and just flutter-desktop is this
tree under Flutter's Linux target. Same screens out of common/frq/screens/, two
renderers — glimmer-cosmic walks the hiccup on one side and frq.hiccup
emits Flutter widgets on the other.
Its toolchain is devShells.flutter-desktop, which is the APK shell with the
Android half swapped out: clojure and Flutter are the same two packages at the
same pinned rev, and CMake, Ninja, pkg-config and GTK stand where the JDK and
the SDK do. Kept separate rather than merged into one shell because the halves
are disjoint — a desktop build has no use for a few hundred megabytes of
Android SDK, which is the same argument that keeps Flutter out of the default
shell.
Still impure, for one of the two reasons the APK is: pub.dev resolution and
Flutter's engine artifacts are network. What it does not need is the
writable-ANDROID_HOME dance, since nothing here writes into the store — so
there is no flutter/.home on this path.
nixGL off NixOS, for the reason just cosmic run needs it and just tui does
not: Flutter paints through GL and the driver that can do that is the host's.
linux/ is the Flutter template's GTK runner, renamed — frq rather than
cljd_flutter, and uk.nandi.frq rather than com.example.cljd_flutter, so
the binary, the window title and the GTK application id agree with the APK's
applicationId.
Two things the desktop target changed in the Dart, both of them cases where
"the phone" had been assumed rather than asked:
frq.io.dart/write-private-file!was a plain write, on the grounds that
Android storage is already private to the app. On a Linux desktop it is not:
the file lands under the XDG data directory with the process umask, and it
holds a broker token. The desktop branch now does whatfrq.io.joltdoes —
create, chmod, then write — and Dart having no chmod is why that is a
process.frq.oauth.darthanded the capture pagefrq://authunconditionally, to
raise the app from behind Chrome. Nothing on a desktop claims that scheme, so
it is now nil there — whichcore/capture-htmlalready documented as the
desktop case and already handled.
The APK
Impure on purpose. Gradle resolves its own dependencies over the network and
installs build-tools and a platform into ANDROID_HOME as it goes, so it can
neither run in a sandbox nor write to the store. What nix gives is the
toolchain — clojure, a JDK, Flutter, and an SDK composed by androidenv — and
the recipe copies that SDK to flutter/.home for Gradle to finish off. That
copy and everything Gradle leaves behind are gitignored.
All of it is the flake's, which it did not used to be. The toolchain was
nix shell nixpkgs#clojure nixpkgs#jdk17 nixpkgs#flutter and the SDK was a
nix build --impure --expr around builtins.getFlake "github:NixOS/nixpkgs/nixos-unstable" — two references to an unlocked
nixpkgs, so the Flutter that compiled the APK and the nixpkgs under everything
else could drift apart without flake.lock changing a line. They are
devShells.<system>.flutter and packages.<system>.android-sdk now, at the
pinned rev, and the recipe is nix develop .#flutter --command over
nix build .#android-sdk.
The SDK needs allowUnfree and android_sdk.accept_license, which cannot be
set on a legacyPackages attribute after the fact — hence androidPkgsFor in
the flake, a second import of the same locked input rather than a second
nixpkgs. The Flutter toolchain is kept out of the default dev shell: it brings
its own Dart and a JDK's worth of closure, and a desktop build wants none of
it.
Two things the Flutter template wanted that are deliberately not here. There is
no ndkVersion in android/app/build.gradle.kts: setting it makes Gradle
fetch that exact NDK, and there is no native code to need one — the app is
Dart, and path_provider is platform channels rather than JNI. And ios/,
macos/, windows/, web/ are deleted; android/ and linux/ are the
targets.
It is signed with ~/.android/debug.keystore, through the template's
signingConfig = signingConfigs.getByName("debug") — which release builds also
use, so flutter build apk --release is not shippable until a real
signingConfigs.release is wired up. The jolt APK's key was generated inside
its own nix derivation and never written anywhere, which is why the first
install over it needed an uninstall: Android will not update a package across a
signature change.
The screens are not rewritten
frq.hiccup is a glimmer backend, the same way glimmer-cosmic and glimmer-tui
are. It walks the hiccup frq.app already produces and emits Flutter widgets,
so the screens are shared rather than forked.
This is worth being precise about, because the first read of the port said
otherwise. Measured against the source:
frq.stateis 1,930 lines and makes zero glimmer calls. Its whole
dependency on glimmer is:refer [atom]— it shadows core'satomwith a
ratom, and everything after that isswap!,reset!andderef.frq.appis 1,834 lines and makes one:r/reaction. The rest is data —
[:vbox {:spacing 6} ...]over about twenty tags, naming no toolkit.
So what a Flutter port needs is an interpreter for that data, not a rewrite of
it. What genuinely has to be ported is the other end: frq.irc, frq.atproto,
frq.oauth, frq.avatars, frq.media, frq.profile, frq.platform — the
namespaces that touch the host. Which is what frq.io is for, and where
dart:io pays for the whole exercise.
What frq.hiccup does not do is glimmer's reconciliation: Flutter rebuilds
from the top and diffs its own element tree, so a cell firing rebuilds the
screen rather than the subtree that read it. Fine at this size.
frq.main paints frq.app's own connect screen, out of
common/frq/screens/connect.cljc — the same file the desktop renders. What it
reads is frq.cells and what it calls is frq.actions, and each platform
fills those in: frq.state's reducers on the desktop, dart:io here.
Every screen shared, and the root that picks between them
All of them are in common/frq/screens/ now — connect, chats, chat,
settings (with Discover and the tab frame) and app, which carries the
split view, the three dialogs and the decision about which screen is showing.
The phone renders [screens/app] and nothing else; it was switching by hand
until that moved. With the cells under them in frq.cells, with the cells under them in frq.cells, the derivations in
frq.rooms, the backend metrics in frq.metrics and everything a screen
cannot do itself behind frq.actions. frq.app is 226 lines and was 1,744. What is left in it is the part that
cannot move: derived and the two asset lookups, which are a glimmer reaction
over a fetch-and-cache, the metrics aliases frq.tui writes, and start!.
What Length::Fill means took four goes to get right, and the rule it ended
at is worth stating once: a child that fills is Flutter's Expanded, the
question is recursive — a plain :vbox holding a :scroll fills too — a Row
holding a filling column must stretch and be given a height, and a pane that
fills a column takes the row's width as well, or it is as wide as its longest
line. Prose in a row is Flexible rather than Expanded, because Expanded
hands out equal shares and a button label then wraps down the middle of a
word.
And :width-request 0 means no request. Every number is truthy in Clojure, so
taking it at face value gave the message list a SizedBox of zero width and
an empty screen.
The older note, kept because the lesson is general
frq.screens.connect and frq.screens.chats are in common/ now, with the
cells under them in frq.cells, the derivations in frq.rooms, the backend
metrics in frq.metrics and the things a screen cannot do itself behind
frq.actions. frq.app requires both and the desktop draws them — verified in
the TUI, including the conversation list with its rooms and previews.
The phone draws both.
Length::Fill is the whole of what the renderer was missing, in two
directions. :fill-height down a column and a width-less :entry across a
row are the same instruction — take what is left — and that is Flutter's
Expanded, not a bigger mainAxisSize. A band that says only max is handed
loose constraints by its parent, asks for infinity, and takes the screen with
it.
So flexed wraps whichever children fill, with fills-column? for a column
and fills-row? for a row, and a Row holding one is max so it has width to
divide. A :page scrolls itself and a :vbox :fill-height takes the bounded
height the Scaffold gives it — which is why nothing wraps the screen any more:
a scroll view around the tree is exactly what takes that bound away.
What made this expensive was looking for it as an exception. A layout error
happens after the build: no try sees it, (catch Object ...) sees it, the
red error box does not appear, and the log stays empty. FlutterError.onError
is where they go, and installing that handler in frq.main should have been
the first move rather than the tenth.
The order to do the rest in
-
— started. The parser isfrq.irccommon/frq/irc/parse.cljcnow,
shared, withfrq.ircre-exporting it so the twenty-threeirc/tag-value
andirc/nick-ofcall sites infrq.stateandfrq.avdid not move. The
transport isfrq.net.dart:SecureSocket, aStream, no thread and no
outbox. TLS reaches irc.freeq.at:6697 from the phone — registration and
MOTD, which is the thing the jolt APK could never do. What is left of this
one is the protocol half: CAP, SASL and the idle-ping logic still live in
src/frq/irc.cljand wantfrq.msgsigandfrq.atprotounder them first. -
— done.frq.atprotocommon/frq/atproto/core.cljcis the JSON,
the base64url, the SASL payloads, and a-req/-parsepair per step of the
flow;frq.atprotoandfrq.atproto.dartsupply the middle. handle → DID
→ PDS resolves on the phone, overHttpClient.frq.oauth— half done.common/frq/oauth/core.cljchas the URL, the
handoff payload and the session refresh. What has no Android answer yet is
the capture: the desktop binds a loopback socket and serves a page the
browser redirects to, and an Android app cannot listen on localhost for a
browser it does not own. That wants an app link or a custom scheme, an
intent filter, and a redirect URI the broker will accept — a decision about
freeq's broker, not a porting problem. -
— done. The signing is shared; the four primitivesfrq.msgsig
under it arefrq.crypto, which the desktop answers with the same OpenSSL
it loads for TLS and the phone withpackage:ed25519_edwardsand
package:crypto, both pure Dart and both synchronous — a signature is
minted in the middle of sending a reaction and there is nothing to await
on. Verified on both against RFC 8032 test 1: same public key, same
signature, byte for byte.frq.wire(81) still wants the seam extended. -
frq.avatars,frq.media,frq.profile,frq.platform—
small, and mostly fetch-and-cache. -
frq.statemoves tocommon/as.cljc, withatomresolved per
platform by reader conditional. -
frq.appfollows it, and the tags it uses thatfrq.hiccupdoes not
cover yet paint as an orange?taguntil they do.
What a missing tag property looks like
Worth writing down, because it cost an evening. frq.hiccup ignored
:width-request, and the connect screen puts two entries side by side in an
:hbox with one. A TextField takes its width from its parent and a Row offers
unbounded width, so that is a hard layout error — and a layout error happens
after the build, so it is not an exception anything can catch, paints nothing
at all rather than Flutter's red box, and takes every sibling in the same
children vector down with it. The screen was blank and the log was empty.
The way through was a harness that renders each candidate in turn with a
labelled marker between them, so the last label standing says where it died.
Not guesswork: four wrong theories went past before that — Center in an
unbounded height, fn* as a binding name, qualified symbols in :watch, a
Builder boundary — each one a three-minute deploy.
It also found that two :entry nodes with no :key shared one
TextEditingController, so the host field showed the port. glimmer matches
children by position when there is no key; a backend holding a controller per
field needs a name for 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 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 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 |
|