| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 7d ago | 1 | # The ClojureDart half |
| 2 | |
| Build the APK from the flake, at the rev flake.lock pins 77d4bec nandi 7d ago | 3 | This is the boundary, drawn before the port rather than after it, so that the |
| 4 | question "can this file go on the phone?" has a filesystem answer. It builds: |
| 5 | see "Building it" below. |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 7d ago | 6 | |
| 7 | ## The three trees |
| 8 | |
| 9 | ``` |
| 10 | common/ .cljc both compilers. No jolt, no glimmer, no dart. |
| 11 | src/ .clj jolt: glimmer, jolt.ffi, the cosmic and tui backends. |
| 12 | flutter/ .cljd ClojureDart: Flutter widgets, dart:io, dart:ffi. |
| 13 | ``` |
| 14 | |
| 15 | The extension is the boundary and the compilers enforce it. ClojureDart reads |
| 16 | `.cljd` and `.cljc` and never `.clj`, so a namespace that reaches for |
| 17 | `jolt.host` cannot accidentally end up in the APK — it is a `.clj` and the Dart |
| 18 | compiler cannot see it. jolt reads all three, which is why `common/` works at |
| 19 | all: one copy of `frq.clock`, compiled twice. |
| 20 | |
| 21 | Where both need a namespace but the answer differs, `.cljd` wins over `.cljc` |
| 22 | in ClojureDart's own resolution, so a file here shadows a shared one without |
| 23 | either side knowing. Reader conditionals work too, with one trap from |
| 24 | ClojureDart's FAQ: the `:clj` feature is always on under cljd, so `:clj` goes |
| 25 | **last** in a conditional, and macro code that wants the Clojure path during |
| 26 | host evaluation asks for `:cljd/clj-host`. |
| 27 | |
| 28 | ## What has crossed |
| 29 | |
| 30 | `frq.io` is the seam — the host's job named once, with `frq.io.jolt` answering |
| 31 | it on the desktop and `frq.io.dart` here. It carries the filesystem, the |
| 32 | environment, the config directory and the clock. |
| 33 | |
| 34 | Moved to `common/` and running under jolt today: |
| 35 | |
| 36 | | namespace | lines | note | |
| 37 | |---------------|-------|-------------------------------------------------| |
| 38 | | `frq.emoji` | 1,914 | data; nothing to port | |
| 39 | | `frq.glyphs` | 84 | data | |
| 40 | | `frq.av.dial` | 117 | already touched neither jolt nor glimmer | |
| 41 | | `frq.clock` | 93 | zone-hunting moved into the backends | |
| 42 | | `frq.store` | 122 | `install -m 600` became `write-private-file!` | |
| 43 | |
| 44 | `frq.clock` is the shape the rest should follow. It used to open with four |
| 45 | guesses at the reader's zone — `TZ`, the target of `/etc/localtime`, the file |
| 46 | itself by path, then Android's `persist.sys.timezone` — and then convert days |
| 47 | to a date by printing one with `jolt.time.local` and taking a `subs` of the |
| 48 | result. Both are gone: the guessing is a libc question and lives in |
| 49 | `frq.io.jolt`, where Dart answers it in one call instead; the conversion is |
| 50 | eleven lines of Hinnant's algorithm, checked against `java.time.LocalDate` for |
| 51 | every day from 1901 to 2052. |
| 52 | |
| 53 | ## What has not |
| 54 | |
| 55 | Roughly 4,000 lines are portable in substance and still `.clj` because the seam |
| 56 | does not reach far enough yet. In the order worth doing them: |
| 57 | |
| 58 | 1. **`frq.wire`, `frq.msgsig`** — need a crypto seam beside the io one. |
| 59 | 2. **`frq.irc`** (433) — the parser is pure; the reader is a blocking thread in |
| 60 | a `future`, and Dart has no threads. It becomes a `Stream` over |
| 61 | `SecureSocket`, which is also what makes TLS work on the phone at all. |
| 62 | 3. **`frq.atproto`** (209), **`frq.oauth`** (182) — hand-rolled HTTPS over |
| 63 | `jolt.mvn-http`'s OpenSSL bindings, which is why sign-in is desktop-only |
| 64 | today. `dart:io` has TLS in the runtime; this is the single biggest thing |
| 65 | the port buys. |
| 66 | 4. **`frq.state`** (1,930) — mostly portable logic, but its ratoms are |
| 67 | glimmer's. Needs the reactive layer decided first. |
| 68 | 5. **`frq.app`** (1,834) — not a port. Flutter brings its own reconciler, so |
| 69 | the screens are rewritten against `cljd.flutter`. |
| 70 | |
| 71 | Not coming: `frq.tui` (no terminal Flutter), `frq.cosmic` (libcosmic is |
| 72 | desktop-only), and the media plane — `moq/`, `codec/`, `capture/`, `av/`, about |
| 73 | 3,800 lines of FFI against C libraries that do not exist on Android either way. |
| 74 | `dart:ffi` does not conjure V4L2; that half wants Flutter's camera and audio |
| 75 | plugins and is its own project. |
| 76 | |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago | 77 | ## Building it |
| 78 | |
| Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 6d ago | 79 | Two targets out of one tree. The ClojureDart compile is the same command for |
| 80 | both — `clojure -M:cljd compile` over `src/` and `../common` — and what differs |
| 81 | is only what Flutter is asked to wrap it in. |
| 82 | |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago | 83 | ```bash |
| Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 6d ago | 84 | just apk # the debug APK |
| 85 | just apk install # and onto a connected device |
| 86 | just apk run # and launched |
| 87 | just apk log # logcat |
| 88 | |
| 89 | just flutter-desktop # the debug Linux bundle |
| 90 | just flutter-desktop run # and the window |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago | 91 | ``` |
| 92 | |
| Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 6d ago | 93 | ### The desktop one |
| 94 | |
| 95 | There are two desktop GUIs now, and they are not a fallback for each other: |
| 96 | `just run` is libcosmic under jolt, and `just flutter-desktop` is this tree |
| 97 | under Flutter's Linux target. Same screens out of `common/frq/screens/`, two |
| 98 | renderers — `glimmer-cosmic` walks the hiccup on one side and `frq.hiccup` |
| 99 | emits Flutter widgets on the other. |
| 100 | |
| 101 | Its toolchain is `devShells.flutter-desktop`, which is the APK shell with the |
| 102 | Android half swapped out: clojure and Flutter are the same two packages at the |
| 103 | same pinned rev, and CMake, Ninja, pkg-config and GTK stand where the JDK and |
| 104 | the SDK do. Kept separate rather than merged into one shell because the halves |
| 105 | are disjoint — a desktop build has no use for a few hundred megabytes of |
| 106 | Android SDK, which is the same argument that keeps Flutter out of the default |
| 107 | shell. |
| 108 | |
| 109 | Still impure, for one of the two reasons the APK is: pub.dev resolution and |
| 110 | Flutter's engine artifacts are network. What it does *not* need is the |
| 111 | writable-`ANDROID_HOME` dance, since nothing here writes into the store — so |
| 112 | there is no `flutter/.home` on this path. |
| 113 | |
| 114 | nixGL off NixOS, for the reason `just run` needs it and `just tui` does not: |
| 115 | Flutter paints through GL and the driver that can do that is the host's. |
| 116 | |
| 117 | `linux/` is the Flutter template's GTK runner, renamed — `frq` rather than |
| 118 | `cljd_flutter`, and `uk.nandi.frq` rather than `com.example.cljd_flutter`, so |
| 119 | the binary, the window title and the GTK application id agree with the APK's |
| 120 | `applicationId`. |
| 121 | |
| 122 | Two things the desktop target changed in the Dart, both of them cases where |
| 123 | "the phone" had been assumed rather than asked: |
| 124 | |
| 125 | * `frq.io.dart/write-private-file!` was a plain write, on the grounds that |
| 126 | Android storage is already private to the app. On a Linux desktop it is not: |
| 127 | the file lands under the XDG data directory with the process umask, and it |
| 128 | holds a broker token. The desktop branch now does what `frq.io.jolt` does — |
| 129 | create, chmod, then write — and Dart having no chmod is why that is a |
| 130 | process. |
| 131 | * `frq.oauth.dart` handed the capture page `frq://auth` unconditionally, to |
| 132 | raise the app from behind Chrome. Nothing on a desktop claims that scheme, so |
| 133 | it is now nil there — which `core/capture-html` already documented as the |
| 134 | desktop case and already handled. |
| 135 | |
| 136 | ### The APK |
| 137 | |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago | 138 | Impure on purpose. Gradle resolves its own dependencies over the network and |
| 139 | installs build-tools and a platform into `ANDROID_HOME` as it goes, so it can |
| 140 | neither run in a sandbox nor write to the store. What nix gives is the |
| 141 | toolchain — clojure, a JDK, Flutter, and an SDK composed by androidenv — and |
| 142 | the recipe copies that SDK to `flutter/.home` for Gradle to finish off. That |
| 143 | copy and everything Gradle leaves behind are gitignored. |
| 144 | |
| Build the APK from the flake, at the rev flake.lock pins 77d4bec nandi 7d ago | 145 | All of it is the flake's, which it did not used to be. The toolchain was |
| 146 | `nix shell nixpkgs#clojure nixpkgs#jdk17 nixpkgs#flutter` and the SDK was a |
| 147 | `nix build --impure --expr` around `builtins.getFlake |
| 148 | "github:NixOS/nixpkgs/nixos-unstable"` — two references to an *unlocked* |
| 149 | nixpkgs, so the Flutter that compiled the APK and the nixpkgs under everything |
| 150 | else could drift apart without flake.lock changing a line. They are |
| 151 | `devShells.<system>.flutter` and `packages.<system>.android-sdk` now, at the |
| 152 | pinned rev, and the recipe is `nix develop .#flutter --command` over |
| 153 | `nix build .#android-sdk`. |
| 154 | |
| 155 | The SDK needs `allowUnfree` and `android_sdk.accept_license`, which cannot be |
| 156 | set on a `legacyPackages` attribute after the fact — hence `androidPkgsFor` in |
| 157 | the flake, a second `import` of the same locked input rather than a second |
| 158 | nixpkgs. The Flutter toolchain is kept out of the default dev shell: it brings |
| 159 | its own Dart and a JDK's worth of closure, and a desktop build wants none of |
| 160 | it. |
| 161 | |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago | 162 | Two things the Flutter template wanted that are deliberately not here. There is |
| 163 | no `ndkVersion` in `android/app/build.gradle.kts`: setting it makes Gradle |
| 164 | fetch that exact NDK, and there is no native code to need one — the app is |
| 165 | Dart, and path_provider is platform channels rather than JNI. And `ios/`, |
| 166 | `macos/`, `windows/`, `web/` are deleted; `android/` and `linux/` are the |
| 167 | targets. |
| 168 | |
| 169 | It is signed with `~/.android/debug.keystore`, through the template's |
| 170 | `signingConfig = signingConfigs.getByName("debug")` — which release builds also |
| 171 | use, so `flutter build apk --release` is not shippable until a real |
| 172 | `signingConfigs.release` is wired up. The jolt APK's key was generated inside |
| 173 | its own nix derivation and never written anywhere, which is why the first |
| 174 | install over it needed an uninstall: Android will not update a package across a |
| 175 | signature change. |
| 176 | |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago | 177 | ## The screens are not rewritten |
| 178 | |
| 179 | `frq.hiccup` is a glimmer backend, the same way glimmer-cosmic and glimmer-tui |
| 180 | are. It walks the hiccup `frq.app` already produces and emits Flutter widgets, |
| 181 | so the screens are shared rather than forked. |
| 182 | |
| 183 | This is worth being precise about, because the first read of the port said |
| 184 | otherwise. Measured against the source: |
| 185 | |
| 186 | * `frq.state` is 1,930 lines and makes **zero** glimmer calls. Its whole |
| 187 | dependency on glimmer is `:refer [atom]` — it shadows core's `atom` with a |
| 188 | ratom, and everything after that is `swap!`, `reset!` and `deref`. |
| 189 | * `frq.app` is 1,834 lines and makes **one**: `r/reaction`. The rest is data — |
| 190 | `[:vbox {:spacing 6} ...]` over about twenty tags, naming no toolkit. |
| 191 | |
| 192 | So what a Flutter port needs is an interpreter for that data, not a rewrite of |
| 193 | it. What genuinely has to be ported is the other end: `frq.irc`, `frq.atproto`, |
| 194 | `frq.oauth`, `frq.avatars`, `frq.media`, `frq.profile`, `frq.platform` — the |
| 195 | namespaces that touch the host. Which is what `frq.io` is for, and where |
| 196 | `dart:io` pays for the whole exercise. |
| 197 | |
| 198 | What `frq.hiccup` does not do is glimmer's reconciliation: Flutter rebuilds |
| 199 | from the top and diffs its own element tree, so a cell firing rebuilds the |
| 200 | screen rather than the subtree that read it. Fine at this size. |
| 201 | |
| Draw frq.app's own connect screen on the phone 0bc64b5 nandi 7d ago | 202 | `frq.main` paints `frq.app`'s own connect screen, out of |
| 203 | `common/frq/screens/connect.cljc` — the same file the desktop renders. What it |
| 204 | reads is `frq.cells` and what it calls is `frq.actions`, and each platform |
| 205 | fills those in: `frq.state`'s reducers on the desktop, dart:io here. |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago | 206 | |
| Share the root, and with it the last of the screens 433c3c8 nandi 7d ago | 207 | ## Every screen shared, and the root that picks between them |
| Draw the conversation on the phone c0121fe nandi 7d ago | 208 | |
| Share the root, and with it the last of the screens 433c3c8 nandi 7d ago | 209 | All of them are in `common/frq/screens/` now — `connect`, `chats`, `chat`, |
| 210 | `settings` (with Discover and the tab frame) and `app`, which carries the |
| 211 | split view, the three dialogs and the decision about which screen is showing. |
| 212 | The phone renders `[screens/app]` and nothing else; it was switching by hand |
| 213 | until that moved. With the cells under them in `frq.cells`, with the cells under them in `frq.cells`, the derivations in |
| Draw the conversation on the phone c0121fe nandi 7d ago | 214 | `frq.rooms`, the backend metrics in `frq.metrics` and everything a screen |
| Share the root, and with it the last of the screens 433c3c8 nandi 7d ago | 215 | cannot do itself behind `frq.actions`. `frq.app` is 226 lines and was 1,744. What is left in it is the part that |
| 216 | cannot move: `derived` and the two asset lookups, which are a glimmer reaction |
| 217 | over a fetch-and-cache, the metrics aliases `frq.tui` writes, and `start!`. |
| Draw the conversation on the phone c0121fe nandi 7d ago | 218 | |
| 219 | What `Length::Fill` means took four goes to get right, and the rule it ended |
| 220 | at is worth stating once: a child that fills is Flutter's `Expanded`, the |
| 221 | question is recursive — a plain `:vbox` holding a `:scroll` fills too — a Row |
| 222 | holding a filling column must `stretch` and be given a height, and a pane that |
| 223 | fills a column takes the row's width as well, or it is as wide as its longest |
| 224 | line. Prose in a row is `Flexible` rather than `Expanded`, because Expanded |
| 225 | hands out equal shares and a button label then wraps down the middle of a |
| 226 | word. |
| 227 | |
| 228 | And `:width-request 0` means no request. Every number is truthy in Clojure, so |
| 229 | taking it at face value gave the message list a `SizedBox` of zero width and |
| 230 | an empty screen. |
| 231 | |
| 232 | ## The older note, kept because the lesson is general |
| Share the chats screen, and the four things under it 1f55e68 nandi 7d ago | 233 | |
| 234 | `frq.screens.connect` and `frq.screens.chats` are in `common/` now, with the |
| 235 | cells under them in `frq.cells`, the derivations in `frq.rooms`, the backend |
| 236 | metrics in `frq.metrics` and the things a screen cannot do itself behind |
| 237 | `frq.actions`. `frq.app` requires both and the desktop draws them — verified in |
| 238 | the TUI, including the conversation list with its rooms and previews. |
| 239 | |
| Take what is left: fill-height, and the chats screen on the phone ff774d1 nandi 7d ago | 240 | The phone draws both. |
| 241 | |
| 242 | `Length::Fill` is the whole of what the renderer was missing, in two |
| 243 | directions. `:fill-height` down a column and a width-less `:entry` across a |
| 244 | row are the same instruction — *take what is left* — and that is Flutter's |
| 245 | `Expanded`, not a bigger `mainAxisSize`. A band that says only `max` is handed |
| 246 | loose constraints by its parent, asks for infinity, and takes the screen with |
| 247 | it. |
| 248 | |
| 249 | So `flexed` wraps whichever children fill, with `fills-column?` for a column |
| 250 | and `fills-row?` for a row, and a Row holding one is `max` so it has width to |
| 251 | divide. A `:page` scrolls itself and a `:vbox :fill-height` takes the bounded |
| 252 | height the Scaffold gives it — which is why nothing wraps the screen any more: |
| 253 | a scroll view around the tree is exactly what takes that bound away. |
| 254 | |
| 255 | What made this expensive was looking for it as an exception. A layout error |
| 256 | happens after the build: no `try` sees it, `(catch Object ...)` sees it, the |
| 257 | red error box does not appear, and the log stays empty. `FlutterError.onError` |
| 258 | is where they go, and installing that handler in `frq.main` should have been |
| 259 | the first move rather than the tenth. |
| Share the chats screen, and the four things under it 1f55e68 nandi 7d ago | 260 | |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago | 261 | ## The order to do the rest in |
| 262 | |
| Reach irc.freeq.at over TLS from the phone e80514e nandi 7d ago | 263 | 1. ~~**`frq.irc`**~~ — started. The parser is `common/frq/irc/parse.cljc` now, |
| 264 | shared, with `frq.irc` re-exporting it so the twenty-three `irc/tag-value` |
| 265 | and `irc/nick-of` call sites in `frq.state` and `frq.av` did not move. The |
| 266 | transport is `frq.net.dart`: `SecureSocket`, a `Stream`, no thread and no |
| 267 | outbox. **TLS reaches irc.freeq.at:6697 from the phone** — registration and |
| 268 | MOTD, which is the thing the jolt APK could never do. What is left of this |
| 269 | one is the protocol half: CAP, SASL and the idle-ping logic still live in |
| 270 | `src/frq/irc.clj` and want `frq.msgsig` and `frq.atproto` under them first. |
| Resolve an identity from the phone, over the shared AT Protocol core f735240 nandi 7d ago | 271 | 2. ~~**`frq.atproto`**~~ — done. `common/frq/atproto/core.cljc` is the JSON, |
| 272 | the base64url, the SASL payloads, and a `-req`/`-parse` pair per step of the |
| 273 | flow; `frq.atproto` and `frq.atproto.dart` supply the middle. **handle → DID |
| 274 | → PDS resolves on the phone**, over `HttpClient`. |
| 275 | |
| 276 | **`frq.oauth`** — half done. `common/frq/oauth/core.cljc` has the URL, the |
| 277 | handoff payload and the session refresh. What has no Android answer yet is |
| 278 | the capture: the desktop binds a loopback socket and serves a page the |
| 279 | browser redirects to, and an Android app cannot listen on localhost for a |
| 280 | browser it does not own. That wants an app link or a custom scheme, an |
| 281 | intent filter, and a redirect URI the broker will accept — a decision about |
| 282 | freeq's broker, not a porting problem. |
| Sign on the phone: msgsig shared, crypto seamed d8fadee nandi 7d ago | 283 | 3. ~~**`frq.msgsig`**~~ — done. The signing is shared; the four primitives |
| 284 | under it are `frq.crypto`, which the desktop answers with the same OpenSSL |
| 285 | it loads for TLS and the phone with `package:ed25519_edwards` and |
| 286 | `package:crypto`, both pure Dart and both synchronous — a signature is |
| 287 | minted in the middle of sending a reaction and there is nothing to await |
| 288 | on. Verified on both against RFC 8032 test 1: same public key, same |
| 289 | signature, byte for byte. |
| 290 | |
| 291 | **`frq.wire`** (81) still wants the seam extended. |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago | 292 | 4. **`frq.avatars`**, **`frq.media`**, **`frq.profile`**, **`frq.platform`** — |
| 293 | small, and mostly fetch-and-cache. |
| 294 | 5. **`frq.state`** moves to `common/` as `.cljc`, with `atom` resolved per |
| 295 | platform by reader conditional. |
| 296 | 6. **`frq.app`** follows it, and the tags it uses that `frq.hiccup` does not |
| 297 | cover yet paint as an orange `?tag` until they do. |
| Draw frq.app's own connect screen on the phone 0bc64b5 nandi 7d ago | 298 | |
| 299 | ## What a missing tag property looks like |
| 300 | |
| 301 | Worth writing down, because it cost an evening. `frq.hiccup` ignored |
| 302 | `:width-request`, and the connect screen puts two entries side by side in an |
| 303 | `:hbox` with one. A TextField takes its width from its parent and a Row offers |
| 304 | unbounded width, so that is a hard layout error — and a layout error happens |
| 305 | after the build, so it is not an exception anything can catch, paints nothing |
| 306 | at all rather than Flutter's red box, and takes every sibling in the same |
| 307 | `children` vector down with it. The screen was blank and the log was empty. |
| 308 | |
| 309 | The way through was a harness that renders each candidate in turn with a |
| 310 | labelled marker between them, so the last label standing says where it died. |
| 311 | Not guesswork: four wrong theories went past before that — `Center` in an |
| 312 | unbounded height, `fn*` as a binding name, qualified symbols in `:watch`, a |
| 313 | `Builder` boundary — each one a three-minute deploy. |
| 314 | |
| 315 | It also found that two `:entry` nodes with no `:key` shared one |
| 316 | TextEditingController, so the host field showed the port. glimmer matches |
| 317 | children by position when there is no key; a backend holding a controller per |
| 318 | field needs a name for it. |