nandi/frqpublic Fork 0
8c38fb8bbe192d09a2f585404b1a38d8f309d0ae
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

README.md · 318 lines · 16.9 KBmarkdown Blame HistoryRaw
Split the tree three ways, and let the phone be Flutter's 7070931 nandi 7d ago1# The ClojureDart half
2
Build the APK from the flake, at the rev flake.lock pins 77d4bec nandi 7d ago3This is the boundary, drawn before the port rather than after it, so that the
4question "can this file go on the phone?" has a filesystem answer. It builds:
5see "Building it" below.
Split the tree three ways, and let the phone be Flutter's 7070931 nandi 7d ago6
7## The three trees
8
9```
10common/ .cljc both compilers. No jolt, no glimmer, no dart.
11src/ .clj jolt: glimmer, jolt.ffi, the cosmic and tui backends.
12flutter/ .cljd ClojureDart: Flutter widgets, dart:io, dart:ffi.
13```
14
15The 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
18compiler cannot see it. jolt reads all three, which is why `common/` works at
19all: one copy of `frq.clock`, compiled twice.
20
21Where both need a namespace but the answer differs, `.cljd` wins over `.cljc`
22in ClojureDart's own resolution, so a file here shadows a shared one without
23either side knowing. Reader conditionals work too, with one trap from
24ClojureDart'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
26host 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
31it on the desktop and `frq.io.dart` here. It carries the filesystem, the
32environment, the config directory and the clock.
33
34Moved 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
45guesses at the reader's zone — `TZ`, the target of `/etc/localtime`, the file
46itself by path, then Android's `persist.sys.timezone` — and then convert days
47to a date by printing one with `jolt.time.local` and taking a `subs` of the
48result. 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
50eleven lines of Hinnant's algorithm, checked against `java.time.LocalDate` for
51every day from 1901 to 2052.
52
53## What has not
54
55Roughly 4,000 lines are portable in substance and still `.clj` because the seam
56does not reach far enough yet. In the order worth doing them:
57
581. **`frq.wire`, `frq.msgsig`** — need a crypto seam beside the io one.
592. **`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.
623. **`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.
664. **`frq.state`** (1,930) — mostly portable logic, but its ratoms are
67 glimmer's. Needs the reactive layer decided first.
685. **`frq.app`** (1,834) — not a port. Flutter brings its own reconciler, so
69 the screens are rewritten against `cljd.flutter`.
70
71Not coming: `frq.tui` (no terminal Flutter), `frq.cosmic` (libcosmic is
72desktop-only), and the media plane — `moq/`, `codec/`, `capture/`, `av/`, about
733,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
75plugins and is its own project.
76
Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago77## Building it
78
Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 7d ago79Two targets out of one tree. The ClojureDart compile is the same command for
80both — `clojure -M:cljd compile` over `src/` and `../common` — and what differs
81is only what Flutter is asked to wrap it in.
82
Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago83```bash
Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 7d ago84just apk # the debug APK
85just apk install # and onto a connected device
86just apk run # and launched
87just apk log # logcat
88
89just flutter-desktop # the debug Linux bundle
90just flutter-desktop run # and the window
Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago91```
92
Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 7d ago93### The desktop one
94
95There are two desktop GUIs now, and they are not a fallback for each other:
Name the libcosmic recipe for its backend: `just cosmic run` 5ba9382 nandi 7d ago96`just cosmic run` is libcosmic under jolt, and `just flutter-desktop` is this
97tree under Flutter's Linux target. Same screens out of `common/frq/screens/`, two
Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 7d ago98renderers — `glimmer-cosmic` walks the hiccup on one side and `frq.hiccup`
99emits Flutter widgets on the other.
100
101Its toolchain is `devShells.flutter-desktop`, which is the APK shell with the
102Android half swapped out: clojure and Flutter are the same two packages at the
103same pinned rev, and CMake, Ninja, pkg-config and GTK stand where the JDK and
104the SDK do. Kept separate rather than merged into one shell because the halves
105are disjoint — a desktop build has no use for a few hundred megabytes of
106Android SDK, which is the same argument that keeps Flutter out of the default
107shell.
108
109Still impure, for one of the two reasons the APK is: pub.dev resolution and
110Flutter's engine artifacts are network. What it does *not* need is the
111writable-`ANDROID_HOME` dance, since nothing here writes into the store — so
112there is no `flutter/.home` on this path.
113
Name the libcosmic recipe for its backend: `just cosmic run` 5ba9382 nandi 7d ago114nixGL off NixOS, for the reason `just cosmic run` needs it and `just tui` does
115not: Flutter paints through GL and the driver that can do that is the host's.
Paint the desktop with Flutter too, beside libcosmic fc5f63c nandi 7d ago116
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
119the binary, the window title and the GTK application id agree with the APK's
120`applicationId`.
121
122Two 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 ago138Impure on purpose. Gradle resolves its own dependencies over the network and
139installs build-tools and a platform into `ANDROID_HOME` as it goes, so it can
140neither run in a sandbox nor write to the store. What nix gives is the
141toolchain — clojure, a JDK, Flutter, and an SDK composed by androidenv — and
142the recipe copies that SDK to `flutter/.home` for Gradle to finish off. That
143copy and everything Gradle leaves behind are gitignored.
144
Build the APK from the flake, at the rev flake.lock pins 77d4bec nandi 7d ago145All 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*
149nixpkgs, so the Flutter that compiled the APK and the nixpkgs under everything
150else could drift apart without flake.lock changing a line. They are
151`devShells.<system>.flutter` and `packages.<system>.android-sdk` now, at the
152pinned rev, and the recipe is `nix develop .#flutter --command` over
153`nix build .#android-sdk`.
154
155The SDK needs `allowUnfree` and `android_sdk.accept_license`, which cannot be
156set on a `legacyPackages` attribute after the fact — hence `androidPkgsFor` in
157the flake, a second `import` of the same locked input rather than a second
158nixpkgs. The Flutter toolchain is kept out of the default dev shell: it brings
159its own Dart and a JDK's worth of closure, and a desktop build wants none of
160it.
161
Build the APK, out of ClojureDart and Flutter e2b0e6b nandi 7d ago162Two things the Flutter template wanted that are deliberately not here. There is
163no `ndkVersion` in `android/app/build.gradle.kts`: setting it makes Gradle
164fetch that exact NDK, and there is no native code to need one — the app is
165Dart, and path_provider is platform channels rather than JNI. And `ios/`,
166`macos/`, `windows/`, `web/` are deleted; `android/` and `linux/` are the
167targets.
168
169It is signed with `~/.android/debug.keystore`, through the template's
170`signingConfig = signingConfigs.getByName("debug")` — which release builds also
171use, 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
173its own nix derivation and never written anywhere, which is why the first
174install over it needed an uninstall: Android will not update a package across a
175signature change.
176
Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago177## The screens are not rewritten
178
179`frq.hiccup` is a glimmer backend, the same way glimmer-cosmic and glimmer-tui
180are. It walks the hiccup `frq.app` already produces and emits Flutter widgets,
181so the screens are shared rather than forked.
182
183This is worth being precise about, because the first read of the port said
184otherwise. 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
192So what a Flutter port needs is an interpreter for that data, not a rewrite of
193it. 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
195namespaces that touch the host. Which is what `frq.io` is for, and where
196`dart:io` pays for the whole exercise.
197
198What `frq.hiccup` does not do is glimmer's reconciliation: Flutter rebuilds
199from the top and diffs its own element tree, so a cell firing rebuilds the
200screen 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 ago202`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
204reads is `frq.cells` and what it calls is `frq.actions`, and each platform
205fills 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 ago206
Share the root, and with it the last of the screens 433c3c8 nandi 7d ago207## Every screen shared, and the root that picks between them
Draw the conversation on the phone c0121fe nandi 7d ago208
Share the root, and with it the last of the screens 433c3c8 nandi 7d ago209All of them are in `common/frq/screens/` now — `connect`, `chats`, `chat`,
210`settings` (with Discover and the tab frame) and `app`, which carries the
211split view, the three dialogs and the decision about which screen is showing.
212The phone renders `[screens/app]` and nothing else; it was switching by hand
213until 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 ago214`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 ago215cannot do itself behind `frq.actions`. `frq.app` is 226 lines and was 1,744. What is left in it is the part that
216cannot move: `derived` and the two asset lookups, which are a glimmer reaction
217over a fetch-and-cache, the metrics aliases `frq.tui` writes, and `start!`.
Draw the conversation on the phone c0121fe nandi 7d ago218
219What `Length::Fill` means took four goes to get right, and the rule it ended
220at is worth stating once: a child that fills is Flutter's `Expanded`, the
221question is recursive — a plain `:vbox` holding a `:scroll` fills too — a Row
222holding a filling column must `stretch` and be given a height, and a pane that
223fills a column takes the row's width as well, or it is as wide as its longest
224line. Prose in a row is `Flexible` rather than `Expanded`, because Expanded
225hands out equal shares and a button label then wraps down the middle of a
226word.
227
228And `:width-request 0` means no request. Every number is truthy in Clojure, so
229taking it at face value gave the message list a `SizedBox` of zero width and
230an 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 ago233
234`frq.screens.connect` and `frq.screens.chats` are in `common/` now, with the
235cells under them in `frq.cells`, the derivations in `frq.rooms`, the backend
236metrics 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
238the 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 ago240The phone draws both.
241
242`Length::Fill` is the whole of what the renderer was missing, in two
243directions. `:fill-height` down a column and a width-less `:entry` across a
244row 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
246loose constraints by its parent, asks for infinity, and takes the screen with
247it.
248
249So `flexed` wraps whichever children fill, with `fills-column?` for a column
250and `fills-row?` for a row, and a Row holding one is `max` so it has width to
251divide. A `:page` scrolls itself and a `:vbox :fill-height` takes the bounded
252height the Scaffold gives it — which is why nothing wraps the screen any more:
253a scroll view around the tree is exactly what takes that bound away.
254
255What made this expensive was looking for it as an exception. A layout error
256happens after the build: no `try` sees it, `(catch Object ...)` sees it, the
257red error box does not appear, and the log stays empty. `FlutterError.onError`
258is where they go, and installing that handler in `frq.main` should have been
259the first move rather than the tenth.
Share the chats screen, and the four things under it 1f55e68 nandi 7d ago260
Paint glimmer's hiccup with Flutter, rather than porting the screens bc877e5 nandi 7d ago261## The order to do the rest in
262
Reach irc.freeq.at over TLS from the phone e80514e nandi 7d ago2631. ~~**`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 ago2712. ~~**`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 ago2833. ~~**`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 ago2924. **`frq.avatars`**, **`frq.media`**, **`frq.profile`**, **`frq.platform`** —
293 small, and mostly fetch-and-cache.
2945. **`frq.state`** moves to `common/` as `.cljc`, with `atom` resolved per
295 platform by reader conditional.
2966. **`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 ago298
299## What a missing tag property looks like
300
301Worth 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
304unbounded width, so that is a hard layout error — and a layout error happens
305after the build, so it is not an exception anything can catch, paints nothing
306at 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
309The way through was a harness that renders each candidate in turn with a
310labelled marker between them, so the last label standing says where it died.
311Not guesswork: four wrong theories went past before that — `Center` in an
312unbounded height, `fn*` as a binding name, qualified symbols in `:watch`, a
313`Builder` boundary — each one a three-minute deploy.
314
315It also found that two `:entry` nodes with no `:key` shared one
316TextEditingController, so the host field showed the port. glimmer matches
317children by position when there is no key; a backend holding a controller per
318field needs a name for it.