| 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 6d 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 | |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 7 | ## The two trees |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 7d ago | 8 | |
| 9 | ``` |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 10 | common/ .cljc portable. No dart: library, no host call except through frq.io. |
| 11 | flutter/ .cljd Flutter widgets, dart:io, dart:ffi, and the host's answers. |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 7d ago | 12 | ``` |
| 13 | |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 14 | The extension is the boundary and the compiler enforces half of it: ClojureDart |
| 15 | reads `.cljd` and `.cljc` and never `.clj`. `tools/check-common.py` enforces the |
| 16 | rest, on every push — a `dart:` library named under `common/` is a namespace |
| 17 | that compiles for one target and not the others. |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 7d ago | 18 | |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 19 | Where a shared namespace needs a different answer per target, `.cljd` wins over |
| 20 | `.cljc` in ClojureDart's own resolution, so a file here shadows a shared one |
| 21 | without either side knowing. Reader conditionals work too, with one trap from |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 7d ago | 22 | ClojureDart's FAQ: the `:clj` feature is always on under cljd, so `:clj` goes |
| 23 | **last** in a conditional, and macro code that wants the Clojure path during |
| 24 | host evaluation asks for `:cljd/clj-host`. |
| 25 | |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 26 | ## The seam |
| 27 | |
| 28 | `frq.io` is the host's job named once. `frq.io.dart` answers it on Android and |
| 29 | the Linux desktop; `frq.io.web` answers it in a browser. It carries the |
| 30 | filesystem, the environment, the config directory and the clock. |
| 31 | |
| 32 | `frq.clock` is the shape the rest follows. It used to open with four guesses at |
| 33 | the reader's zone — `TZ`, the target of `/etc/localtime`, the file itself by |
| 34 | path, then Android's `persist.sys.timezone` — and then convert days to a date |
| 35 | by printing one and taking a `subs` of the result. Both are gone: the guessing |
| 36 | is a libc question and lives behind `local-offset-seconds`, which Dart answers |
| 37 | in one call; the conversion is eleven lines of Hinnant's algorithm, checked |
| 38 | against `java.time.LocalDate` for every day from 1901 to 2052. |
| 39 | |
| 40 | Adding a host call means adding it to `common/frq/io.cljc` and to every |
| 41 | implementation. Name it for the result rather than the mechanism — the seam has |
| 42 | `write-private-file!` and not a chmod, because Dart has no chmod. |
| 43 | |
| 44 | ## The port, as it finished |
| 45 | |
| 46 | This tree began as the phone half of a client whose desktop was jolt: glimmer |
| 47 | components painted by libcosmic, with `src/` holding the half that could not |
| 48 | cross. That half is gone now, and what was a migration plan is the whole |
| 49 | program. The namespaces that made the trip: |
| 50 | |
| 51 | | namespace | note | |
| 52 | |--------------------|-------------------------------------------------------| |
| 53 | | `frq.emoji` | data; nothing to port | |
| 54 | | `frq.glyphs` | data | |
| 55 | | `frq.clock` | zone-hunting moved behind the seam | |
| 56 | | `frq.store` | `install -m 600` became `write-private-file!` | |
| 57 | | `frq.irc.parse` | the parser was always pure | |
| 58 | | `frq.irc.handshake`| SASL, driven from shared code | |
| 59 | | `frq.msgsig` | needed a crypto seam beside the io one | |
| 60 | | `frq.atproto.core` | hand-rolled HTTPS became `dart:io`, which has TLS | |
| 61 | | `frq.oauth.core` | the broker flow, as far as it is portable | |
| 62 | | `frq.rooms`, `frq.cells`, `frq.screens/*` | rewritten against `cljd.flutter` | |
| 63 | |
| 64 | What did not come: the terminal frontend (there is no terminal Flutter), the |
| 65 | libcosmic one (it is Wayland, X11 and wgpu, and does not cross to a phone), and |
| 66 | the media plane — `moq/`, `codec/`, `capture/`, `av/`, about 3,800 lines of FFI |
| 67 | against C libraries Android does not have either way. `dart:ffi` does not |
| 68 | conjure V4L2; that half wants Flutter's camera and audio plugins and is its own |
| 69 | project. The Call controls in `frq.screens.chat` are still wired to actions no |
| 70 | target installs, which is the visible edge of that. |
| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 7d ago | 71 | |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago | 72 | ## Building it |
| 73 | |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 74 | Three targets out of one tree. The ClojureDart compile is the same command for |
| 75 | all of them — `clojure -M:cljd compile` over `src/` and `../common` — and what |
| 76 | differs is only what Flutter is asked to wrap it in. |
| Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 6d ago | 77 | |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago | 78 | ```bash |
| Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 6d ago | 79 | just apk # the debug APK |
| 80 | just apk install # and onto a connected device |
| 81 | just apk run # and launched |
| 82 | just apk log # logcat |
| 83 | |
| 84 | just flutter-desktop # the debug Linux bundle |
| 85 | just flutter-desktop run # and the window |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 86 | |
| 87 | just flutter-web # the web bundle |
| 88 | just flutter-web serve # and served on :8080 |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago | 89 | ``` |
| 90 | |
| Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 6d ago | 91 | ### The desktop one |
| 92 | |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 93 | `just flutter-desktop` is this tree under Flutter's Linux target — the same |
| 94 | screens out of `common/frq/screens/` as the APK, with `frq.hiccup` emitting |
| 95 | Flutter widgets. There used to be a second desktop GUI beside it, libcosmic |
| 96 | under jolt, walking the same hiccup through a different renderer; it is gone. |
| Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 6d ago | 97 | |
| 98 | Its toolchain is `devShells.flutter-desktop`, which is the APK shell with the |
| 99 | Android half swapped out: clojure and Flutter are the same two packages at the |
| 100 | same pinned rev, and CMake, Ninja, pkg-config and GTK stand where the JDK and |
| 101 | the SDK do. Kept separate rather than merged into one shell because the halves |
| 102 | are disjoint — a desktop build has no use for a few hundred megabytes of |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 103 | Android SDK. |
| Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 6d ago | 104 | |
| 105 | Still impure, for one of the two reasons the APK is: pub.dev resolution and |
| 106 | Flutter's engine artifacts are network. What it does *not* need is the |
| 107 | writable-`ANDROID_HOME` dance, since nothing here writes into the store — so |
| 108 | there is no `flutter/.home` on this path. |
| 109 | |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 110 | nixGL off NixOS: Flutter paints through GL and the driver that can do that is |
| 111 | the host's. |
| Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 6d ago | 112 | |
| 113 | `linux/` is the Flutter template's GTK runner, renamed — `frq` rather than |
| 114 | `cljd_flutter`, and `uk.nandi.frq` rather than `com.example.cljd_flutter`, so |
| 115 | the binary, the window title and the GTK application id agree with the APK's |
| 116 | `applicationId`. |
| 117 | |
| 118 | Two things the desktop target changed in the Dart, both of them cases where |
| 119 | "the phone" had been assumed rather than asked: |
| 120 | |
| 121 | * `frq.io.dart/write-private-file!` was a plain write, on the grounds that |
| 122 | Android storage is already private to the app. On a Linux desktop it is not: |
| 123 | the file lands under the XDG data directory with the process umask, and it |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 124 | holds a broker token. The desktop branch creates the file, restricts it, then |
| 125 | writes — and Dart having no chmod is why that is a three-step process rather |
| 126 | than a mode argument. |
| Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 6d ago | 127 | * `frq.oauth.dart` handed the capture page `frq://auth` unconditionally, to |
| 128 | raise the app from behind Chrome. Nothing on a desktop claims that scheme, so |
| 129 | it is now nil there — which `core/capture-html` already documented as the |
| 130 | desktop case and already handled. |
| 131 | |
| 132 | ### The APK |
| 133 | |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago | 134 | Impure on purpose. Gradle resolves its own dependencies over the network and |
| 135 | installs build-tools and a platform into `ANDROID_HOME` as it goes, so it can |
| 136 | neither run in a sandbox nor write to the store. What nix gives is the |
| 137 | toolchain — clojure, a JDK, Flutter, and an SDK composed by androidenv — and |
| 138 | the recipe copies that SDK to `flutter/.home` for Gradle to finish off. That |
| 139 | copy and everything Gradle leaves behind are gitignored. |
| 140 | |
| Build the APK from the flake, at the rev flake.lock pins 77d4bec nandi 6d ago | 141 | All of it is the flake's, which it did not used to be. The toolchain was |
| 142 | `nix shell nixpkgs#clojure nixpkgs#jdk17 nixpkgs#flutter` and the SDK was a |
| 143 | `nix build --impure --expr` around `builtins.getFlake |
| 144 | "github:NixOS/nixpkgs/nixos-unstable"` — two references to an *unlocked* |
| 145 | nixpkgs, so the Flutter that compiled the APK and the nixpkgs under everything |
| 146 | else could drift apart without flake.lock changing a line. They are |
| 147 | `devShells.<system>.flutter` and `packages.<system>.android-sdk` now, at the |
| 148 | pinned rev, and the recipe is `nix develop .#flutter --command` over |
| 149 | `nix build .#android-sdk`. |
| 150 | |
| 151 | The SDK needs `allowUnfree` and `android_sdk.accept_license`, which cannot be |
| 152 | set on a `legacyPackages` attribute after the fact — hence `androidPkgsFor` in |
| 153 | the flake, a second `import` of the same locked input rather than a second |
| 154 | nixpkgs. The Flutter toolchain is kept out of the default dev shell: it brings |
| 155 | its own Dart and a JDK's worth of closure, and a desktop build wants none of |
| 156 | it. |
| 157 | |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago | 158 | Two things the Flutter template wanted that are deliberately not here. There is |
| 159 | no `ndkVersion` in `android/app/build.gradle.kts`: setting it makes Gradle |
| 160 | fetch that exact NDK, and there is no native code to need one — the app is |
| 161 | Dart, and path_provider is platform channels rather than JNI. And `ios/`, |
| 162 | `macos/`, `windows/`, `web/` are deleted; `android/` and `linux/` are the |
| 163 | targets. |
| 164 | |
| 165 | It is signed with `~/.android/debug.keystore`, through the template's |
| 166 | `signingConfig = signingConfigs.getByName("debug")` — which release builds also |
| 167 | use, so `flutter build apk --release` is not shippable until a real |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 168 | `signingConfigs.release` is wired up. |
| Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago | 169 | |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago | 170 | ## The screens are not rewritten |
| 171 | |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 172 | `frq.hiccup` is an interpreter, not a port. It walks the hiccup the screens |
| 173 | already produce — `[:vbox {:spacing 6} ...]` over about twenty tags, naming no |
| 174 | toolkit — and emits Flutter widgets, so `common/frq/screens/` is shared across |
| 175 | all three targets rather than forked per target. |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago | 176 | |
| 177 | This is worth being precise about, because the first read of the port said |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 178 | otherwise. The screens were data all along; what genuinely had to be written |
| 179 | was the other end — the namespaces that touch the host. Which is what `frq.io` |
| 180 | is for, and where `dart:io` paid for the whole exercise. |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago | 181 | |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 182 | What `frq.hiccup` does not do is reconciliation of its own: Flutter rebuilds |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago | 183 | from the top and diffs its own element tree, so a cell firing rebuilds the |
| 184 | screen rather than the subtree that read it. Fine at this size. |
| 185 | |
| Share the root, and with it the last of the screens 433c3c8 nandi 6d ago | 186 | ## Every screen shared, and the root that picks between them |
| Draw the conversation on the phone c0121fe nandi 6d ago | 187 | |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 188 | All of them are in `common/frq/screens/` — `connect`, `chats`, `chat`, |
| 189 | `settings` (with Discover and the tab frame) and `app`, which carries the split |
| 190 | view, the three dialogs and the decision about which screen is showing. Each |
| 191 | entry point renders `[screens/app]` and nothing else. |
| 192 | |
| 193 | Under them: the cells in `frq.cells`, the derivations in `frq.rooms`, the |
| 194 | chrome metrics in `frq.metrics`, and everything a screen cannot do itself |
| 195 | behind `frq.actions` — which each target fills in for itself, the way it fills |
| 196 | in `frq.io`. |
| Draw the conversation on the phone c0121fe nandi 6d ago | 197 | |
| 198 | What `Length::Fill` means took four goes to get right, and the rule it ended |
| 199 | at is worth stating once: a child that fills is Flutter's `Expanded`, the |
| 200 | question is recursive — a plain `:vbox` holding a `:scroll` fills too — a Row |
| 201 | holding a filling column must `stretch` and be given a height, and a pane that |
| 202 | fills a column takes the row's width as well, or it is as wide as its longest |
| 203 | line. Prose in a row is `Flexible` rather than `Expanded`, because Expanded |
| 204 | hands out equal shares and a button label then wraps down the middle of a |
| 205 | word. |
| 206 | |
| 207 | And `:width-request 0` means no request. Every number is truthy in Clojure, so |
| 208 | taking it at face value gave the message list a `SizedBox` of zero width and |
| 209 | an empty screen. |
| 210 | |
| 211 | ## The older note, kept because the lesson is general |
| Share the chats screen, and the four things under it 1f55e68 nandi 7d ago | 212 | |
| 213 | `frq.screens.connect` and `frq.screens.chats` are in `common/` now, with the |
| 214 | cells under them in `frq.cells`, the derivations in `frq.rooms`, the backend |
| 215 | metrics in `frq.metrics` and the things a screen cannot do itself behind |
| 216 | `frq.actions`. `frq.app` requires both and the desktop draws them — verified in |
| 217 | the TUI, including the conversation list with its rooms and previews. |
| 218 | |
| Take what is left: fill-height, and the chats screen on the phone ff774d1 nandi 7d ago | 219 | The phone draws both. |
| 220 | |
| 221 | `Length::Fill` is the whole of what the renderer was missing, in two |
| 222 | directions. `:fill-height` down a column and a width-less `:entry` across a |
| 223 | row are the same instruction — *take what is left* — and that is Flutter's |
| 224 | `Expanded`, not a bigger `mainAxisSize`. A band that says only `max` is handed |
| 225 | loose constraints by its parent, asks for infinity, and takes the screen with |
| 226 | it. |
| 227 | |
| 228 | So `flexed` wraps whichever children fill, with `fills-column?` for a column |
| 229 | and `fills-row?` for a row, and a Row holding one is `max` so it has width to |
| 230 | divide. A `:page` scrolls itself and a `:vbox :fill-height` takes the bounded |
| 231 | height the Scaffold gives it — which is why nothing wraps the screen any more: |
| 232 | a scroll view around the tree is exactly what takes that bound away. |
| 233 | |
| 234 | What made this expensive was looking for it as an exception. A layout error |
| 235 | happens after the build: no `try` sees it, `(catch Object ...)` sees it, the |
| 236 | red error box does not appear, and the log stays empty. `FlutterError.onError` |
| 237 | is where they go, and installing that handler in `frq.main` should have been |
| 238 | the first move rather than the tenth. |
| Share the chats screen, and the four things under it 1f55e68 nandi 7d ago | 239 | |
| One frontend where there were three, and a core that is not Clojure 438b247 nandi 3h ago | 240 | ## What is still open |
| 241 | |
| 242 | The port is finished in the sense that matters — there is no other tree left to |
| 243 | move from. What remains is work the port never covered: |
| 244 | |
| 245 | 1. **The OAuth capture on Android.** `common/frq/oauth/core.cljc` has the URL, |
| 246 | the handoff payload and the session refresh. What has no Android answer is |
| 247 | the capture itself: the flow was written for a client that binds a loopback |
| 248 | socket and serves a page the browser redirects to, and an Android app cannot |
| 249 | listen on localhost for a browser it does not own. That wants an app link or |
| 250 | a custom scheme, an intent filter, and a redirect URI the broker will accept |
| 251 | — a decision about freeq's broker, not a porting problem. The web build has |
| 252 | its own answer in `frq.oauth.web`, and `just web-local` is why it only |
| 253 | completes on localhost. |
| 254 | 2. **Calls.** The signaling is IRC and is still in the screens; the media plane |
| 255 | it drove was `libjoltmoq` — Opus, H.264, V4L2, ALSA, MoQ over QUIC — under |
| 256 | the retired jolt half, and none of it crosses. The Call controls are wired |
| 257 | to actions no target installs, so they are dead buttons today. Flutter's |
| 258 | camera and audio plugins are the way back in, and that is its own project: |
| 259 | either remove the controls or build behind them. |
| Draw frq.app's own connect screen on the phone 0bc64b5 nandi 7d ago | 260 | |
| 261 | ## What a missing tag property looks like |
| 262 | |
| 263 | Worth writing down, because it cost an evening. `frq.hiccup` ignored |
| 264 | `:width-request`, and the connect screen puts two entries side by side in an |
| 265 | `:hbox` with one. A TextField takes its width from its parent and a Row offers |
| 266 | unbounded width, so that is a hard layout error — and a layout error happens |
| 267 | after the build, so it is not an exception anything can catch, paints nothing |
| 268 | at all rather than Flutter's red box, and takes every sibling in the same |
| 269 | `children` vector down with it. The screen was blank and the log was empty. |
| 270 | |
| 271 | The way through was a harness that renders each candidate in turn with a |
| 272 | labelled marker between them, so the last label standing says where it died. |
| 273 | Not guesswork: four wrong theories went past before that — `Center` in an |
| 274 | unbounded height, `fn*` as a binding name, qualified symbols in `:watch`, a |
| 275 | `Builder` boundary — each one a three-minute deploy. |
| 276 | |
| 277 | It also found that two `:entry` nodes with no `:key` shared one |
| 278 | TextEditingController, so the host field showed the port. glimmer matches |
| 279 | children by position when there is no key; a backend holding a controller per |
| 280 | field needs a name for it. |