| Split the tree three ways, and let the phone be Flutter's 7070931 nandi 7d ago | 1 | # The ClojureDart half |
| 2 | |
| 3 | Nothing here builds yet. This is the boundary, drawn before the port rather |
| 4 | than after it, so that the question "can this file go on the phone?" has a |
| 5 | filesystem answer. |
| 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 | |
| 79 | ```bash |
| 80 | just apk # the debug APK |
| 81 | just apk install # and onto a connected device |
| 82 | just apk run # and launched |
| 83 | just apk log # logcat |
| 84 | ``` |
| 85 | |
| 86 | Impure on purpose. Gradle resolves its own dependencies over the network and |
| 87 | installs build-tools and a platform into `ANDROID_HOME` as it goes, so it can |
| 88 | neither run in a sandbox nor write to the store. What nix gives is the |
| 89 | toolchain — clojure, a JDK, Flutter, and an SDK composed by androidenv — and |
| 90 | the recipe copies that SDK to `flutter/.home` for Gradle to finish off. That |
| 91 | copy and everything Gradle leaves behind are gitignored. |
| 92 | |
| 93 | Two things the Flutter template wanted that are deliberately not here. There is |
| 94 | no `ndkVersion` in `android/app/build.gradle.kts`: setting it makes Gradle |
| 95 | fetch that exact NDK, and there is no native code to need one — the app is |
| 96 | Dart, and path_provider is platform channels rather than JNI. And `ios/`, |
| 97 | `macos/`, `windows/`, `web/` are deleted; `android/` and `linux/` are the |
| 98 | targets. |
| 99 | |
| 100 | It is signed with `~/.android/debug.keystore`, through the template's |
| 101 | `signingConfig = signingConfigs.getByName("debug")` — which release builds also |
| 102 | use, so `flutter build apk --release` is not shippable until a real |
| 103 | `signingConfigs.release` is wired up. The jolt APK's key was generated inside |
| 104 | its own nix derivation and never written anywhere, which is why the first |
| 105 | install over it needed an uninstall: Android will not update a package across a |
| 106 | signature change. |
| 107 | |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago | 108 | ## The screens are not rewritten |
| 109 | |
| 110 | `frq.hiccup` is a glimmer backend, the same way glimmer-cosmic and glimmer-tui |
| 111 | are. It walks the hiccup `frq.app` already produces and emits Flutter widgets, |
| 112 | so the screens are shared rather than forked. |
| 113 | |
| 114 | This is worth being precise about, because the first read of the port said |
| 115 | otherwise. Measured against the source: |
| 116 | |
| 117 | * `frq.state` is 1,930 lines and makes **zero** glimmer calls. Its whole |
| 118 | dependency on glimmer is `:refer [atom]` — it shadows core's `atom` with a |
| 119 | ratom, and everything after that is `swap!`, `reset!` and `deref`. |
| 120 | * `frq.app` is 1,834 lines and makes **one**: `r/reaction`. The rest is data — |
| 121 | `[:vbox {:spacing 6} ...]` over about twenty tags, naming no toolkit. |
| 122 | |
| 123 | So what a Flutter port needs is an interpreter for that data, not a rewrite of |
| 124 | it. What genuinely has to be ported is the other end: `frq.irc`, `frq.atproto`, |
| 125 | `frq.oauth`, `frq.avatars`, `frq.media`, `frq.profile`, `frq.platform` — the |
| 126 | namespaces that touch the host. Which is what `frq.io` is for, and where |
| 127 | `dart:io` pays for the whole exercise. |
| 128 | |
| 129 | What `frq.hiccup` does not do is glimmer's reconciliation: Flutter rebuilds |
| 130 | from the top and diffs its own element tree, so a cell firing rebuilds the |
| 131 | screen rather than the subtree that read it. Fine at this size. |
| 132 | |
| Draw frq.app's own connect screen on the phone 0bc64b5 nandi 7d ago | 133 | `frq.main` paints `frq.app`'s own connect screen, out of |
| 134 | `common/frq/screens/connect.cljc` — the same file the desktop renders. What it |
| 135 | reads is `frq.cells` and what it calls is `frq.actions`, and each platform |
| 136 | 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 | 137 | |
| Share the chats screen, and the four things under it 1f55e68 nandi 7d ago | 138 | ## Two screens shared, and where the renderer stops |
| 139 | |
| 140 | `frq.screens.connect` and `frq.screens.chats` are in `common/` now, with the |
| 141 | cells under them in `frq.cells`, the derivations in `frq.rooms`, the backend |
| 142 | metrics in `frq.metrics` and the things a screen cannot do itself behind |
| 143 | `frq.actions`. `frq.app` requires both and the desktop draws them — verified in |
| 144 | the TUI, including the conversation list with its rooms and previews. |
| 145 | |
| 146 | The phone draws the connect screen. It does not draw the chats one yet, and |
| 147 | the gap is in `frq.hiccup` rather than in the screen: `:fill-height` is iced's |
| 148 | `Length::Fill`, the space left over, which is Flutter's `Expanded` — not a |
| 149 | taller `mainAxisSize`. Getting that onto the right children without breaking |
| 150 | the bands around them is unfinished; the attempt blanked the screen and was |
| 151 | reverted rather than shipped half-working. |
| 152 | |
| 153 | `:entry` is the same lesson twice. An entry with no `:width-request` fills its |
| 154 | row, which in a Row is `Expanded`; the connect screen's server boxes carry |
| 155 | widths and came out fine, and the chats screen's join box carries none and did |
| 156 | not. |
| 157 | |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago | 158 | ## The order to do the rest in |
| 159 | |
| Reach irc.freeq.at over TLS from the phone e80514e nandi 7d ago | 160 | 1. ~~**`frq.irc`**~~ — started. The parser is `common/frq/irc/parse.cljc` now, |
| 161 | shared, with `frq.irc` re-exporting it so the twenty-three `irc/tag-value` |
| 162 | and `irc/nick-of` call sites in `frq.state` and `frq.av` did not move. The |
| 163 | transport is `frq.net.dart`: `SecureSocket`, a `Stream`, no thread and no |
| 164 | outbox. **TLS reaches irc.freeq.at:6697 from the phone** — registration and |
| 165 | MOTD, which is the thing the jolt APK could never do. What is left of this |
| 166 | one is the protocol half: CAP, SASL and the idle-ping logic still live in |
| 167 | `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 | 168 | 2. ~~**`frq.atproto`**~~ — done. `common/frq/atproto/core.cljc` is the JSON, |
| 169 | the base64url, the SASL payloads, and a `-req`/`-parse` pair per step of the |
| 170 | flow; `frq.atproto` and `frq.atproto.dart` supply the middle. **handle → DID |
| 171 | → PDS resolves on the phone**, over `HttpClient`. |
| 172 | |
| 173 | **`frq.oauth`** — half done. `common/frq/oauth/core.cljc` has the URL, the |
| 174 | handoff payload and the session refresh. What has no Android answer yet is |
| 175 | the capture: the desktop binds a loopback socket and serves a page the |
| 176 | browser redirects to, and an Android app cannot listen on localhost for a |
| 177 | browser it does not own. That wants an app link or a custom scheme, an |
| 178 | intent filter, and a redirect URI the broker will accept — a decision about |
| 179 | freeq's broker, not a porting problem. |
| Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago | 180 | 3. **`frq.msgsig`** (268), **`frq.wire`** (81) — need a crypto seam beside the |
| 181 | io one. |
| 182 | 4. **`frq.avatars`**, **`frq.media`**, **`frq.profile`**, **`frq.platform`** — |
| 183 | small, and mostly fetch-and-cache. |
| 184 | 5. **`frq.state`** moves to `common/` as `.cljc`, with `atom` resolved per |
| 185 | platform by reader conditional. |
| 186 | 6. **`frq.app`** follows it, and the tags it uses that `frq.hiccup` does not |
| 187 | cover yet paint as an orange `?tag` until they do. |
| Draw frq.app's own connect screen on the phone 0bc64b5 nandi 7d ago | 188 | |
| 189 | ## What a missing tag property looks like |
| 190 | |
| 191 | Worth writing down, because it cost an evening. `frq.hiccup` ignored |
| 192 | `:width-request`, and the connect screen puts two entries side by side in an |
| 193 | `:hbox` with one. A TextField takes its width from its parent and a Row offers |
| 194 | unbounded width, so that is a hard layout error — and a layout error happens |
| 195 | after the build, so it is not an exception anything can catch, paints nothing |
| 196 | at all rather than Flutter's red box, and takes every sibling in the same |
| 197 | `children` vector down with it. The screen was blank and the log was empty. |
| 198 | |
| 199 | The way through was a harness that renders each candidate in turn with a |
| 200 | labelled marker between them, so the last label standing says where it died. |
| 201 | Not guesswork: four wrong theories went past before that — `Center` in an |
| 202 | unbounded height, `fn*` as a binding name, qualified symbols in `:watch`, a |
| 203 | `Builder` boundary — each one a three-minute deploy. |
| 204 | |
| 205 | It also found that two `:entry` nodes with no `:key` shared one |
| 206 | TextEditingController, so the host field showed the port. glimmer matches |
| 207 | children by position when there is no key; a backend holding a controller per |
| 208 | field needs a name for it. |