nandi/frqpublic Fork 0
7070931
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.

Split the tree three ways, and let the phone be Flutter's

The APK had no painter left. nix/android.nix built frq.app against
glimmer-vidya, deps.edn had moved to glimmer-jvui, and both of those were
experiments that are over; libcosmic, which is what paints the window now,
is Wayland, X11 and wgpu and does not cross to a phone at all. `nix build
.#apk` has been failing at frq-jolt-boot for that reason and not for a
reason worth fixing — so the jolt APK goes, with nix/android.nix, the
android/ manifest and Java glue, the .#apk outputs, `just apk`, and the
three inputs that existed only to serve them.

What replaces it is ClojureDart. The phone gets Flutter over dart:io, which
is most of what the APK never had: TLS lives in the Dart runtime, so sign-in
stops being desktop-only for want of a libssl to dlopen, and the camera and
audio the media plane cannot find in V4L2 and ALSA are plugins there.

So the tree is three now, and the extension is the boundary rather than a
convention:

  common/   .cljc   both compilers
  src/      .clj    jolt: glimmer, jolt.ffi, cosmic and tui
  flutter/  .cljd   ClojureDart: Flutter, dart:io

ClojureDart reads .cljd and .cljc and never .clj, and jolt reads all three.
A namespace that requires jolt.host is therefore a .clj that the Dart
compiler physically cannot see, and one that both need is a .cljc compiled
twice. Nothing enforces this by hand.

common/frq/io.cljc is the seam: the host's job named once, with frq.io.jolt
answering on the desktop and frq.io.dart on the phone. Named for results
rather than mechanisms — `write-private-file!` and not a chmod, because Dart
has no chmod; `local-offset-seconds` and not a zone name, because finding
the zone is four guesses on Linux and one runtime call in Dart.

frq.clock and frq.store cross on the strength of it, with frq.emoji,
frq.glyphs and frq.av.dial, which never touched either side. frq.clock is
the shape the rest should follow: the zone ladder moved into the backends,
and converting days to a date stopped going through jolt.time.local — it
printed a datetime only to subs it apart. Eleven lines of Hinnant's
algorithm instead, checked against java.time.LocalDate for every day from
1901 to 2052.

jvui is gone from the code with it. It was still load-bearing in three
namespaces, and frq.platform was a diff against it: every key fell back to a
gui/ call, so a backend named only what it did differently. There is no
default backend now, so the fallbacks are no-ops — which is what the
terminal's picture chooser and libcosmic's video texture always were, only
slower. frq.app loses its -main and is the screens and nothing else; there
is one entry point per backend, and `just run` is the cosmic window.

The APK does not build. Neither does anything else Android — that is the
state, not an oversight, and flutter/README.md says what it would take.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T17:29:06-07:00 Browse files
7070931 parent: 9dd480b
modified CLAUDE.md +30 -3
@@ -9,11 +9,38 @@ nix commands directly — do not wrap them in `distrobox enter`:
99 nix build .#frq
1010 ```
1111
12-A remote builder (`eu.nixbuild.net`) is already configured here; the Android
13-outputs want `--store ssh-ng://eu.nixbuild.net --eval-store auto`, for the
14-reason nix/android.nix gives at the top.
12+A remote builder (`eu.nixbuild.net`) is already configured here. Large builds
13+want `--store ssh-ng://eu.nixbuild.net --eval-store auto` rather than a
14+`builders` entry, so the whole graph stays there and only .drv files go up —
15+libjoltcosmic's dependency tree is the one that makes this worth remembering.
1516
1617 One thing this container is *not* representative of: `/etc/localtime` is a
1718 regular file here rather than a symlink, so anything that reads the zone out
1819 of its path sees nothing. That is a real deployment shape, not an artefact —
1920 frq.clock handles it.
21+
22+## The three source trees
23+
24+```
25+common/ .cljc jolt AND ClojureDart
26+src/ .clj jolt only
27+flutter/ .cljd ClojureDart only
28+```
29+
30+The extension is the boundary, not a convention: ClojureDart reads `.cljd` and
31+`.cljc` and never `.clj`, jolt reads all three. So the rule for anything under
32+`common/` is that it may not require `jolt.*`, `glimmer*` or a `dart:` library
33+— if it needs the host, it asks `frq.io`, and the backend that installed itself
34+answers. `frq.io.jolt` is required for its side effect by `frq.app`;
35+`frq.io.dart` is installed by `flutter/src/frq/main.cljd`, which has to await
36+the storage directory first.
37+
38+Adding a host call means adding it to the seam in `common/frq/io.cljc` and to
39+both implementations. Name it for the result rather than the mechanism — the
40+seam has `write-private-file!` and not a chmod, because Dart has no chmod.
41+
42+`flutter/` does not build; there is no cljd toolchain or Flutter SDK in the
43+flake. See flutter/README.md. It is the only APK there is — the jolt APK,
44+`nix/android.nix`, `android/` and the `.#apk` outputs are gone, because every
45+backend that APK could paint with is retired. jvui and Vidya were experiments;
46+libcosmic is the desktop window and does not cross to a phone.
@@ -9,11 +9,38 @@ nix commands directly — do not wrap them in `distrobox enter`:
9 nix build .#frq9 nix build .#frq
10 ```10 ```
11 11
12-A remote builder (`eu.nixbuild.net`) is already configured here; the Android12+A remote builder (`eu.nixbuild.net`) is already configured here. Large builds
13-outputs want `--store ssh-ng://eu.nixbuild.net --eval-store auto`, for the13+want `--store ssh-ng://eu.nixbuild.net --eval-store auto` rather than a
14-reason nix/android.nix gives at the top.14+`builders` entry, so the whole graph stays there and only .drv files go up —
15+libjoltcosmic's dependency tree is the one that makes this worth remembering.
15 16
16 One thing this container is *not* representative of: `/etc/localtime` is a17 One thing this container is *not* representative of: `/etc/localtime` is a
17 regular file here rather than a symlink, so anything that reads the zone out18 regular file here rather than a symlink, so anything that reads the zone out
18 of its path sees nothing. That is a real deployment shape, not an artefact —19 of its path sees nothing. That is a real deployment shape, not an artefact —
19 frq.clock handles it.20 frq.clock handles it.
21+
22+## The three source trees
23+
24+```
25+common/ .cljc jolt AND ClojureDart
26+src/ .clj jolt only
27+flutter/ .cljd ClojureDart only
28+```
29+
30+The extension is the boundary, not a convention: ClojureDart reads `.cljd` and
31+`.cljc` and never `.clj`, jolt reads all three. So the rule for anything under
32+`common/` is that it may not require `jolt.*`, `glimmer*` or a `dart:` library
33+— if it needs the host, it asks `frq.io`, and the backend that installed itself
34+answers. `frq.io.jolt` is required for its side effect by `frq.app`;
35+`frq.io.dart` is installed by `flutter/src/frq/main.cljd`, which has to await
36+the storage directory first.
37+
38+Adding a host call means adding it to the seam in `common/frq/io.cljc` and to
39+both implementations. Name it for the result rather than the mechanism — the
40+seam has `write-private-file!` and not a chmod, because Dart has no chmod.
41+
42+`flutter/` does not build; there is no cljd toolchain or Flutter SDK in the
43+flake. See flutter/README.md. It is the only APK there is — the jolt APK,
44+`nix/android.nix`, `android/` and the `.#apk` outputs are gone, because every
45+backend that APK could paint with is retired. jvui and Vidya were experiments;
46+libcosmic is the desktop window and does not cross to a phone.
modified README.md +72 -59
@@ -3,7 +3,9 @@
33 A **[freeq](https://github.com/codegod100/freeq)** client written in
44 **[jolt](https://github.com/jolt-lang/jolt)**, as
55 [glimmer](https://github.com/jolt-lang/glimmer) components painted by
6-**[Vidya](https://tangled.org/nandi.uk/vidya)**/egui.
6+**libcosmic** on the desktop — and, on the phone, by Flutter through
7+[ClojureDart](https://github.com/tensegritics/ClojureDart) over the same
8+shared namespaces. See [flutter/README.md](flutter/README.md).
79
810 It is a proof of concept port of [sleek](../sleek), which is the same client in
911 Rust against egui directly. The screens are sleek's — connect, chats, chat,
@@ -11,20 +13,35 @@ discover, settings, under a tab bar — but each is hiccup over glimmer's widget
1113 tags rather than immediate-mode drawing code, and state lives in ratoms instead
1214 of an `AppState` struct.
1315
16+Source lives in three trees, and the file extension is the boundary:
17+
18+```
19+common/ .cljc compiled by both jolt and ClojureDart — no jolt, no glimmer
20+src/ .clj the jolt half: glimmer, jolt.ffi, cosmic + tui backends
21+flutter/ .cljd the ClojureDart half: Flutter, dart:io — see flutter/README.md
22+```
23+
24+ClojureDart reads `.cljd` and `.cljc` and never `.clj`, so a namespace that
25+reaches for `jolt.host` cannot end up in a Flutter build by accident. What the
26+two halves share, they ask of `common/frq/io.cljc` — the host's job named once,
27+answered by `frq.io.jolt` on one side and `frq.io.dart` on the other.
28+
1429 ```
15-src/frq/atproto.clj handle → DID → PDS → session, and the SASL payloads
16-src/frq/oauth.clj the broker flow: login URL, loopback capture, /session
17-src/frq/store.clj the saved sign-in, mode 600 in the config directory
18-src/frq/avatars.clj profile pictures, by DID or handle
19-src/frq/profile.clj who someone is: the Bluesky profile behind a nick
20-src/frq/media.clj image links: spot them, fetch them once, cache on disk
21-src/frq/upload.clj a pasted picture to freeq's media endpoint, as multipart
22-src/frq/av.clj calls: the signaling, and a handle on the media plane
23-src/frq/clock.clj the reader's own zone, twelve-hour times, day headings
24-src/frq/emoji.clj the picker's catalog: every drawable emoji and its name
25-src/frq/irc.clj IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
26-src/frq/state.clj the ratoms every screen reads, and `apply-msg!`
27-src/frq/app.clj the screens
30+common/frq/io.cljc the seam: filesystem, environment, config dir, clock
31+common/frq/clock.cljc IRCv3 time tags → the reader's own zone
32+common/frq/store.cljc the saved sign-in, mode 600 in the config directory
33+common/frq/emoji.cljc the picker's catalog: every drawable emoji and its name
34+src/frq/io/jolt.clj the desktop's answers to the seam
35+src/frq/atproto.clj handle → DID → PDS → session, and the SASL payloads
36+src/frq/oauth.clj the broker flow: login URL, loopback capture, /session
37+src/frq/avatars.clj profile pictures, by DID or handle
38+src/frq/profile.clj who someone is: the Bluesky profile behind a nick
39+src/frq/media.clj image links: spot them, fetch them once, cache on disk
40+src/frq/upload.clj a pasted picture to freeq's media endpoint, as multipart
41+src/frq/av.clj calls: the signaling, and a handle on the media plane
42+src/frq/irc.clj IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
43+src/frq/state.clj the ratoms every screen reads, and `apply-msg!`
44+src/frq/app.clj the screens
2845 ```
2946
3047 ## Tracing
@@ -45,20 +62,18 @@ Every recipe lives in the `justfile` itself. Each one that runs frq re-enters
4562 `nix develop --command just run` are one code path rather than two. Nothing has
4663 to be installed for that but Nix.
4764
48-`just run` is `jolt -M:frq` inside `nix develop`, with `LD_LIBRARY_PATH`
49-pointed at the shell's `JOLT_NATIVE_LIB` — the flake's build of
50-[jolt-native](https://gitlab.com/nandithebull/jolt-native), which is both
51-shared objects this client loads: `libvidya`, the retained-tree ABI glimmer
52-paints through, and `libjoltmoq`, the AV media plane. The source frq runs is
53-the working tree; everything under it is built rather than fetched, at the revs
54-`flake.lock` names. Nothing has to be installed but Nix, and no jolt-native
55-checkout beside this one.
56-
57-The APK is the other half of that: it takes the two libraries out of
58-jolt-native's *release* instead, by the digests in `nix/android.nix`, because a
59-derivation's inputs have to be fetchurl. `just bump` moves those pins — and
60-deps.edn's glimmer-vidya sha — to the latest release at once, and
61-`just bump v0.1.2` to a named one.
65+`just run` is `jolt -m frq.cosmic` inside `nix develop`, with
66+`LD_LIBRARY_PATH` pointed at the shell's `JOLT_NATIVE_LIB` — the flake's build
67+of [jolt-native](https://gitlab.com/nandithebull/jolt-native), which carries
68+the shared objects this client loads: `libjoltcosmic`, the retained-tree ABI
69+glimmer paints the window through, and `libjolttui`, the same ABI over a grid
70+of cells. The source frq runs is the working tree; everything under it is built
71+rather than fetched, at the revs `flake.lock` names. Nothing has to be
72+installed but Nix, and no jolt-native checkout beside this one.
73+
74+There is no jvui and no Vidya any more. Both were experiments: the window is
75+libcosmic and the terminal is libjolttui, and those are the two backends there
76+are.
6277
6378 `jolt` on its own does not work in this tree: `deps.edn` names both libraries
6479 under `:jolt/native`, so every invocation loads them before it reads a line and
@@ -77,7 +92,7 @@ cargo run --release --bin freeq-server # in the freeq checkout
7792
7893 The screens are hiccup over glimmer's reconciler, and the reconciler does not
7994 know what is under it — so the same tree paints into a terminal through
80-jolt-native's `libjolttui`, which exports libvidya's retained-tree ABI over a
95+jolt-native's `libjolttui`, which exports the same retained-tree ABI over a
8196 grid of cells instead of a GPU window.
8297
8398 It is the client, not a preview of it. `frq.app/start!` is what a launch does —
@@ -120,7 +135,7 @@ that is said, and `frq.tui` sets it.
120135
121136 `just tui` is `just run`'s two halves with the other backend under them: this
122137 tree's source on the flake's everything-else, in the dev shell. jolt-native
123-carries both native libraries and both Jolt sides — glimmer-vidya for the
138+carries both native libraries and both Jolt sides — glimmer-cosmic for the
124139 window, glimmer-tui for the terminal — so one input answers for either, and
125140 nothing here needs a checkout beside the tree.
126141
@@ -158,29 +173,26 @@ A refused sign-in is reported and the connection carries on as a guest.
158173
159174 ## Android
160175
161-An APK whose native halves come from jolt-native's release and whose Jolt half
162-is frq's: `libvidya.so` (the Rust/egui C ABI, which owns the event loop as the
163-NativeActivity's own library), `libjoltmoq.so` (the media plane) and
164-`libjoltapp.so` (frq compiled to a Chez boot image, linked against both).
176+The APK is **ClojureDart and Flutter**, not jolt — see
177+[flutter/README.md](flutter/README.md). Nothing here builds it yet.
165178
166-```bash
167-just apk run # build, install, launch on a connected device
168-just apk log # logcat, filtered
169-```
179+There was a jolt APK: `libvidya.so` painting through a NativeActivity, with frq
180+compiled to an arm64 Chez boot image beside it. It is gone, and so are
181+`nix/android.nix`, the `.#apk` outputs and the `just apk` recipe. The reason is
182+not the build, which worked — it is that every backend it could paint with is
183+retired. Vidya and jvui were experiments, and libcosmic is Wayland, X11 and
184+wgpu, so it does not cross to a phone at all.
170185
171-Needs nothing on the machine but Nix and an `adb`: the build is
172-[`nix/android.nix`](nix/android.nix), and the SDK, the NDK, the arm64 Chez
173-cross target and the OpenSSL the app carries are all built or fetched there.
174-`nix build .#apk` is the same thing without adb; on a machine with a remote
175-builder, hand it the store rather than a `builders` entry —
176-`FRQ_NIX_STORE=ssh-ng://eu.nixbuild.net just apk`, and see the header of
177-`nix/android.nix` for why.
186+What the phone gains by the move is most of what it never had. TLS was the
187+worst of it: jolt reaches OpenSSL through the dynamic loader and Android has no
188+public `libssl`, so sign-in was desktop-only and the connect screen fell back to
189+the plain `:6667` listener on its own. `dart:io` carries TLS in the runtime.
190+The same goes for the media plane — V4L2 and ALSA are not there either, and
191+Flutter has camera and audio plugins that are.
178192
179-TLS does not work there: jolt reaches OpenSSL through the dynamic loader, and
180-Android has no public `libssl` to load. The connect screen falls back to the
181-plain `:6667` listener on its own, which is why the plain transport is the raw
182-`socket`/`connect`/`send`/`recv` calls rather than jolt's `java.net.Socket`
183-surface — that surface does not work on Android either, while the syscalls do.
193+What carries over untouched is `common/` — see the three trees at the top.
194+`frq.clock`, `frq.store` and the rest are compiled by both jolt and
195+ClojureDart, and what they need from the host they ask `frq.io` for.
184196
185197 ## What the PoC covers
186198
@@ -194,7 +206,7 @@ surface — that surface does not work on Android either, while the syscalls do.
194206 * A chip above a reply quoting what it answers, and a click that goes there;
195207 ↩ beside a sender to answer them, with `+draft/reply` on the way out
196208 * Emoji reactions: colour pills under a message, ☺ beside the sender to open a
197- picker over every emoji Vidya can draw (popular first, then Unicode's own
209+ picker over every emoji the backend can draw (popular first, then Unicode's own
198210 groups, searchable by name), and a second click on a pill to take yours off
199211 — sent as `TAGMSG`, and restored from the server's own tally when the
200212 backlog comes back
@@ -236,8 +248,8 @@ shape this side:
236248
237249 * **Nothing calls back.** Status and video are polled, drained by a timer that
238250 glimmer runs on the loop thread — the only thread allowed to touch a node.
239-* **A frame is borrowed.** The decoder's own buffer is handed to Vidya as a
240- pointer and painted by an `:image` with a `:feed`. The pixels never become a
251+* **A frame is borrowed.** The decoder's own buffer is handed to the backend as
252+ a pointer and painted by an `:image` with a `:feed`. The pixels never become a
241253 jolt value and are never copied on this side, which is the only way thirty
242254 frames a second is affordable here.
243255
@@ -257,17 +269,18 @@ in a loop that looks exactly like a hang.
257269 format, and a fetch needs TLS, so the phone shows links. The link is left in
258270 place either way.
259271 * **Nothing evicts the media cache.**
260-* **Calls are desktop-only.** `libjoltmoq` is not built for Android here, and
261- the camera and microphone paths that are would still need the runtime
262- permissions the APK does not ask for.
272+* **Calls are desktop-only.** The media plane is V4L2 and ALSA, which Android
273+ does not have — and libcosmic, which paints the frames, does not run there
274+ either. Flutter's camera and audio plugins are the way in on the phone, and
275+ that is its own project.
263276 * **One call at a time**, which is the media plane's rule and the microphone's.
264277 * **No call is offered in a DM** — freeq's AV signaling is a channel's.
265278 * **Pasting a picture needs a sign-in and a desktop.** The upload is filed
266279 under the DID of a live session, so a guest cannot make one; and it is read
267- off the clipboard through the ABI's `vidya_clipboard_image_png`, which
268- arboard backs on desktop and nothing backs on Android. It also shares
280+ off the clipboard through the backend's `clipboard-image-png!`, which
281+ libcosmic backs on the desktop and nothing backs in a terminal. It also shares
269282 nothing to your PDS and posts nothing to Bluesky — those fields are opt-in
270283 and this client does not send them.
271284 * **No scrollback trimming or threads.**
272285 * A sent line waits up to 200ms for the reader thread to flush it.
273-* Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.
286+* Message lists are keyed vboxes; glimmer-cosmic has no `:listbox` yet.
@@ -3,7 +3,9 @@
3 A **[freeq](https://github.com/codegod100/freeq)** client written in3 A **[freeq](https://github.com/codegod100/freeq)** client written in
4 **[jolt](https://github.com/jolt-lang/jolt)**, as4 **[jolt](https://github.com/jolt-lang/jolt)**, as
5 [glimmer](https://github.com/jolt-lang/glimmer) components painted by5 [glimmer](https://github.com/jolt-lang/glimmer) components painted by
6-**[Vidya](https://tangled.org/nandi.uk/vidya)**/egui.6+**libcosmic** on the desktop — and, on the phone, by Flutter through
7+[ClojureDart](https://github.com/tensegritics/ClojureDart) over the same
8+shared namespaces. See [flutter/README.md](flutter/README.md).
7 9
8 It is a proof of concept port of [sleek](../sleek), which is the same client in10 It is a proof of concept port of [sleek](../sleek), which is the same client in
9 Rust against egui directly. The screens are sleek's — connect, chats, chat,11 Rust against egui directly. The screens are sleek's — connect, chats, chat,
@@ -11,20 +13,35 @@ discover, settings, under a tab bar — but each is hiccup over glimmer's widget
11 tags rather than immediate-mode drawing code, and state lives in ratoms instead13 tags rather than immediate-mode drawing code, and state lives in ratoms instead
12 of an `AppState` struct.14 of an `AppState` struct.
13 15
16+Source lives in three trees, and the file extension is the boundary:
17+
18+```
19+common/ .cljc compiled by both jolt and ClojureDart — no jolt, no glimmer
20+src/ .clj the jolt half: glimmer, jolt.ffi, cosmic + tui backends
21+flutter/ .cljd the ClojureDart half: Flutter, dart:io — see flutter/README.md
22+```
23+
24+ClojureDart reads `.cljd` and `.cljc` and never `.clj`, so a namespace that
25+reaches for `jolt.host` cannot end up in a Flutter build by accident. What the
26+two halves share, they ask of `common/frq/io.cljc` — the host's job named once,
27+answered by `frq.io.jolt` on one side and `frq.io.dart` on the other.
28+
14 ```29 ```
15-src/frq/atproto.clj handle → DID → PDS → session, and the SASL payloads30+common/frq/io.cljc the seam: filesystem, environment, config dir, clock
16-src/frq/oauth.clj the broker flow: login URL, loopback capture, /session31+common/frq/clock.cljc IRCv3 time tags → the reader's own zone
17-src/frq/store.clj the saved sign-in, mode 600 in the config directory32+common/frq/store.cljc the saved sign-in, mode 600 in the config directory
18-src/frq/avatars.clj profile pictures, by DID or handle33+common/frq/emoji.cljc the picker's catalog: every drawable emoji and its name
19-src/frq/profile.clj who someone is: the Bluesky profile behind a nick34+src/frq/io/jolt.clj the desktop's answers to the seam
20-src/frq/media.clj image links: spot them, fetch them once, cache on disk35+src/frq/atproto.clj handle → DID → PDS → session, and the SASL payloads
21-src/frq/upload.clj a pasted picture to freeq's media endpoint, as multipart36+src/frq/oauth.clj the broker flow: login URL, loopback capture, /session
22-src/frq/av.clj calls: the signaling, and a handle on the media plane37+src/frq/avatars.clj profile pictures, by DID or handle
23-src/frq/clock.clj the reader's own zone, twelve-hour times, day headings38+src/frq/profile.clj who someone is: the Bluesky profile behind a nick
24-src/frq/emoji.clj the picker's catalog: every drawable emoji and its name39+src/frq/media.clj image links: spot them, fetch them once, cache on disk
25-src/frq/irc.clj IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG40+src/frq/upload.clj a pasted picture to freeq's media endpoint, as multipart
26-src/frq/state.clj the ratoms every screen reads, and `apply-msg!`41+src/frq/av.clj calls: the signaling, and a handle on the media plane
27-src/frq/app.clj the screens42+src/frq/irc.clj IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
43+src/frq/state.clj the ratoms every screen reads, and `apply-msg!`
44+src/frq/app.clj the screens
28 ```45 ```
29 46
30 ## Tracing47 ## Tracing
@@ -45,20 +62,18 @@ Every recipe lives in the `justfile` itself. Each one that runs frq re-enters
45 `nix develop --command just run` are one code path rather than two. Nothing has62 `nix develop --command just run` are one code path rather than two. Nothing has
46 to be installed for that but Nix.63 to be installed for that but Nix.
47 64
48-`just run` is `jolt -M:frq` inside `nix develop`, with `LD_LIBRARY_PATH`65+`just run` is `jolt -m frq.cosmic` inside `nix develop`, with
49-pointed at the shell's `JOLT_NATIVE_LIB` — the flake's build of66+`LD_LIBRARY_PATH` pointed at the shell's `JOLT_NATIVE_LIB` — the flake's build
50-[jolt-native](https://gitlab.com/nandithebull/jolt-native), which is both67+of [jolt-native](https://gitlab.com/nandithebull/jolt-native), which carries
51-shared objects this client loads: `libvidya`, the retained-tree ABI glimmer68+the shared objects this client loads: `libjoltcosmic`, the retained-tree ABI
52-paints through, and `libjoltmoq`, the AV media plane. The source frq runs is69+glimmer paints the window through, and `libjolttui`, the same ABI over a grid
53-the working tree; everything under it is built rather than fetched, at the revs70+of cells. The source frq runs is the working tree; everything under it is built
54-`flake.lock` names. Nothing has to be installed but Nix, and no jolt-native71+rather than fetched, at the revs `flake.lock` names. Nothing has to be
55-checkout beside this one.72+installed but Nix, and no jolt-native checkout beside this one.
56-73+
57-The APK is the other half of that: it takes the two libraries out of74+There is no jvui and no Vidya any more. Both were experiments: the window is
58-jolt-native's *release* instead, by the digests in `nix/android.nix`, because a75+libcosmic and the terminal is libjolttui, and those are the two backends there
59-derivation's inputs have to be fetchurl. `just bump` moves those pins — and76+are.
60-deps.edn's glimmer-vidya sha — to the latest release at once, and
61-`just bump v0.1.2` to a named one.
62 77
63 `jolt` on its own does not work in this tree: `deps.edn` names both libraries78 `jolt` on its own does not work in this tree: `deps.edn` names both libraries
64 under `:jolt/native`, so every invocation loads them before it reads a line and79 under `:jolt/native`, so every invocation loads them before it reads a line and
@@ -77,7 +92,7 @@ cargo run --release --bin freeq-server # in the freeq checkout
77 92
78 The screens are hiccup over glimmer's reconciler, and the reconciler does not93 The screens are hiccup over glimmer's reconciler, and the reconciler does not
79 know what is under it — so the same tree paints into a terminal through94 know what is under it — so the same tree paints into a terminal through
80-jolt-native's `libjolttui`, which exports libvidya's retained-tree ABI over a95+jolt-native's `libjolttui`, which exports the same retained-tree ABI over a
81 grid of cells instead of a GPU window.96 grid of cells instead of a GPU window.
82 97
83 It is the client, not a preview of it. `frq.app/start!` is what a launch does —98 It is the client, not a preview of it. `frq.app/start!` is what a launch does —
@@ -120,7 +135,7 @@ that is said, and `frq.tui` sets it.
120 135
121 `just tui` is `just run`'s two halves with the other backend under them: this136 `just tui` is `just run`'s two halves with the other backend under them: this
122 tree's source on the flake's everything-else, in the dev shell. jolt-native137 tree's source on the flake's everything-else, in the dev shell. jolt-native
123-carries both native libraries and both Jolt sides — glimmer-vidya for the138+carries both native libraries and both Jolt sides — glimmer-cosmic for the
124 window, glimmer-tui for the terminal — so one input answers for either, and139 window, glimmer-tui for the terminal — so one input answers for either, and
125 nothing here needs a checkout beside the tree.140 nothing here needs a checkout beside the tree.
126 141
@@ -158,29 +173,26 @@ A refused sign-in is reported and the connection carries on as a guest.
158 173
159 ## Android174 ## Android
160 175
161-An APK whose native halves come from jolt-native's release and whose Jolt half176+The APK is **ClojureDart and Flutter**, not jolt — see
162-is frq's: `libvidya.so` (the Rust/egui C ABI, which owns the event loop as the177+[flutter/README.md](flutter/README.md). Nothing here builds it yet.
163-NativeActivity's own library), `libjoltmoq.so` (the media plane) and
164-`libjoltapp.so` (frq compiled to a Chez boot image, linked against both).
165 178
166-```bash179+There was a jolt APK: `libvidya.so` painting through a NativeActivity, with frq
167-just apk run # build, install, launch on a connected device180+compiled to an arm64 Chez boot image beside it. It is gone, and so are
168-just apk log # logcat, filtered181+`nix/android.nix`, the `.#apk` outputs and the `just apk` recipe. The reason is
169-```182+not the build, which worked — it is that every backend it could paint with is
183+retired. Vidya and jvui were experiments, and libcosmic is Wayland, X11 and
184+wgpu, so it does not cross to a phone at all.
170 185
171-Needs nothing on the machine but Nix and an `adb`: the build is186+What the phone gains by the move is most of what it never had. TLS was the
172-[`nix/android.nix`](nix/android.nix), and the SDK, the NDK, the arm64 Chez187+worst of it: jolt reaches OpenSSL through the dynamic loader and Android has no
173-cross target and the OpenSSL the app carries are all built or fetched there.188+public `libssl`, so sign-in was desktop-only and the connect screen fell back to
174-`nix build .#apk` is the same thing without adb; on a machine with a remote189+the plain `:6667` listener on its own. `dart:io` carries TLS in the runtime.
175-builder, hand it the store rather than a `builders` entry —190+The same goes for the media plane — V4L2 and ALSA are not there either, and
176-`FRQ_NIX_STORE=ssh-ng://eu.nixbuild.net just apk`, and see the header of191+Flutter has camera and audio plugins that are.
177-`nix/android.nix` for why.
178 192
179-TLS does not work there: jolt reaches OpenSSL through the dynamic loader, and193+What carries over untouched is `common/` — see the three trees at the top.
180-Android has no public `libssl` to load. The connect screen falls back to the194+`frq.clock`, `frq.store` and the rest are compiled by both jolt and
181-plain `:6667` listener on its own, which is why the plain transport is the raw195+ClojureDart, and what they need from the host they ask `frq.io` for.
182-`socket`/`connect`/`send`/`recv` calls rather than jolt's `java.net.Socket`
183-surface — that surface does not work on Android either, while the syscalls do.
184 196
185 ## What the PoC covers197 ## What the PoC covers
186 198
@@ -194,7 +206,7 @@ surface — that surface does not work on Android either, while the syscalls do.
194 * A chip above a reply quoting what it answers, and a click that goes there;206 * A chip above a reply quoting what it answers, and a click that goes there;
195 ↩ beside a sender to answer them, with `+draft/reply` on the way out207 ↩ beside a sender to answer them, with `+draft/reply` on the way out
196 * Emoji reactions: colour pills under a message, ☺ beside the sender to open a208 * Emoji reactions: colour pills under a message, ☺ beside the sender to open a
197- picker over every emoji Vidya can draw (popular first, then Unicode's own209+ picker over every emoji the backend can draw (popular first, then Unicode's own
198 groups, searchable by name), and a second click on a pill to take yours off210 groups, searchable by name), and a second click on a pill to take yours off
199 — sent as `TAGMSG`, and restored from the server's own tally when the211 — sent as `TAGMSG`, and restored from the server's own tally when the
200 backlog comes back212 backlog comes back
@@ -236,8 +248,8 @@ shape this side:
236 248
237 * **Nothing calls back.** Status and video are polled, drained by a timer that249 * **Nothing calls back.** Status and video are polled, drained by a timer that
238 glimmer runs on the loop thread — the only thread allowed to touch a node.250 glimmer runs on the loop thread — the only thread allowed to touch a node.
239-* **A frame is borrowed.** The decoder's own buffer is handed to Vidya as a251+* **A frame is borrowed.** The decoder's own buffer is handed to the backend as
240- pointer and painted by an `:image` with a `:feed`. The pixels never become a252+ a pointer and painted by an `:image` with a `:feed`. The pixels never become a
241 jolt value and are never copied on this side, which is the only way thirty253 jolt value and are never copied on this side, which is the only way thirty
242 frames a second is affordable here.254 frames a second is affordable here.
243 255
@@ -257,17 +269,18 @@ in a loop that looks exactly like a hang.
257 format, and a fetch needs TLS, so the phone shows links. The link is left in269 format, and a fetch needs TLS, so the phone shows links. The link is left in
258 place either way.270 place either way.
259 * **Nothing evicts the media cache.**271 * **Nothing evicts the media cache.**
260-* **Calls are desktop-only.** `libjoltmoq` is not built for Android here, and272+* **Calls are desktop-only.** The media plane is V4L2 and ALSA, which Android
261- the camera and microphone paths that are would still need the runtime273+ does not have — and libcosmic, which paints the frames, does not run there
262- permissions the APK does not ask for.274+ either. Flutter's camera and audio plugins are the way in on the phone, and
275+ that is its own project.
263 * **One call at a time**, which is the media plane's rule and the microphone's.276 * **One call at a time**, which is the media plane's rule and the microphone's.
264 * **No call is offered in a DM** — freeq's AV signaling is a channel's.277 * **No call is offered in a DM** — freeq's AV signaling is a channel's.
265 * **Pasting a picture needs a sign-in and a desktop.** The upload is filed278 * **Pasting a picture needs a sign-in and a desktop.** The upload is filed
266 under the DID of a live session, so a guest cannot make one; and it is read279 under the DID of a live session, so a guest cannot make one; and it is read
267- off the clipboard through the ABI's `vidya_clipboard_image_png`, which280+ off the clipboard through the backend's `clipboard-image-png!`, which
268- arboard backs on desktop and nothing backs on Android. It also shares281+ libcosmic backs on the desktop and nothing backs in a terminal. It also shares
269 nothing to your PDS and posts nothing to Bluesky — those fields are opt-in282 nothing to your PDS and posts nothing to Bluesky — those fields are opt-in
270 and this client does not send them.283 and this client does not send them.
271 * **No scrollback trimming or threads.**284 * **No scrollback trimming or threads.**
272 * A sent line waits up to 200ms for the reader thread to flush it.285 * A sent line waits up to 200ms for the reader thread to flush it.
273-* Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.286+* Message lists are keyed vboxes; glimmer-cosmic has no `:listbox` yet.
deleted android/AndroidManifest.xml +0 -95
deleted file mode 100644
@@ -1,95 +0,0 @@
1-<?xml version="1.0" encoding="utf-8"?>
2-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3- package="uk.nandi.frq">
4- <uses-permission android:name="android.permission.INTERNET" />
5- <!--
6- Reading the pictures the picker offers. Both, because the permission that
7- covers them was split at API 33: before that a single storage read, after
8- it one per medium. Neither is granted by being asked for here — they are
9- runtime permissions, and this activity has no Java to raise the dialog
10- with, so they are granted from Settings (or `adb shell pm grant`) until
11- something asks. Without them the picker sees directory names and no
12- files, which is what it says.
13- -->
14- <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
15- <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
16- android:maxSdkVersion="32" />
17- <!--
18- The call. Both are runtime permissions and neither is granted by being
19- asked for here; CameraCapture checks CAMERA before it opens anything and
20- reports "permission denied" back to the media plane rather than throwing.
21- Until this activity has Java to raise a dialog with, they are granted from
22- Settings (or `adb shell pm grant`), the same as the storage reads above.
23- -->
24- <uses-permission android:name="android.permission.CAMERA" />
25- <uses-permission android:name="android.permission.RECORD_AUDIO" />
26-
27- <uses-feature android:glEsVersion="0x00030000" android:required="true" />
28- <!--
29- Not required: a call without a camera is audio-only and still a call, and
30- required="true" would hide the app from tablets that have no camera.
31- -->
32- <uses-feature android:name="android.hardware.camera" android:required="false" />
33-
34- <!--
35- Not the Fullscreen theme below: under it the window owns the whole
36- display, content_rect is the whole display too, and there is nothing for
37- vidya to inset by — the status bar clock sits on top of the app and the
38- compose bar sits under the keyboard.
39- -->
40- <!--
41- debuggable: `adb run-as` reads the app's own data directory under it,
42- which is how a session file or a cache is looked at on the device. It
43- costs nothing at runtime and this APK is signed with the debug key
44- anyway; drop it for anything that ships.
45- -->
46- <application
47- android:allowBackup="false"
48- android:debuggable="true"
49- android:label="frq"
50- android:theme="@android:style/Theme.Material.NoActionBar">
51- <!--
52- adjustResize: without it the activity's content_rect never shrinks for
53- the soft keyboard, so vidya has no IME inset to reserve and the compose
54- bar sits under the keys.
55- -->
56- <!--
57- singleTask: the sign-in deep link below has to bring the *running*
58- app forward, with its loopback handoff already in hand, rather than
59- start a second copy of it beside the first.
60- -->
61- <activity
62- android:name=".FrqActivity"
63- android:configChanges="orientation|keyboardHidden|screenSize"
64- android:launchMode="singleTask"
65- android:windowSoftInputMode="adjustResize"
66- android:exported="true"
67- android:screenOrientation="portrait">
68- <!--
69- The activity's library is libvidya.so, not the Jolt one: whoever
70- holds android-activity's glue owns the event loop, and winit
71- cannot build one without the AndroidApp handle that glue receives.
72- It dlopens libjoltapp.so in turn. See ffi/src/android.rs.
73- -->
74- <meta-data
75- android:name="android.app.lib_name"
76- android:value="vidya" />
77- <intent-filter>
78- <action android:name="android.intent.action.MAIN" />
79- <category android:name="android.intent.category.LAUNCHER" />
80- </intent-filter>
81- <!--
82- Coming back from the browser. The handoff itself travels over
83- loopback and is already captured by the time this arrives; the
84- link carries nothing, and exists only to put the app back in
85- front of the browser that covered it.
86- -->
87- <intent-filter>
88- <action android:name="android.intent.action.VIEW" />
89- <category android:name="android.intent.category.DEFAULT" />
90- <category android:name="android.intent.category.BROWSABLE" />
91- <data android:scheme="frq" android:host="auth" />
92- </intent-filter>
93- </activity>
94- </application>
95-</manifest>
deleted file mode 100644
@@ -1,95 +0,0 @@
1-<?xml version="1.0" encoding="utf-8"?>
2-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3- package="uk.nandi.frq">
4- <uses-permission android:name="android.permission.INTERNET" />
5- <!--
6- Reading the pictures the picker offers. Both, because the permission that
7- covers them was split at API 33: before that a single storage read, after
8- it one per medium. Neither is granted by being asked for here — they are
9- runtime permissions, and this activity has no Java to raise the dialog
10- with, so they are granted from Settings (or `adb shell pm grant`) until
11- something asks. Without them the picker sees directory names and no
12- files, which is what it says.
13- -->
14- <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
15- <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
16- android:maxSdkVersion="32" />
17- <!--
18- The call. Both are runtime permissions and neither is granted by being
19- asked for here; CameraCapture checks CAMERA before it opens anything and
20- reports "permission denied" back to the media plane rather than throwing.
21- Until this activity has Java to raise a dialog with, they are granted from
22- Settings (or `adb shell pm grant`), the same as the storage reads above.
23- -->
24- <uses-permission android:name="android.permission.CAMERA" />
25- <uses-permission android:name="android.permission.RECORD_AUDIO" />
26-
27- <uses-feature android:glEsVersion="0x00030000" android:required="true" />
28- <!--
29- Not required: a call without a camera is audio-only and still a call, and
30- required="true" would hide the app from tablets that have no camera.
31- -->
32- <uses-feature android:name="android.hardware.camera" android:required="false" />
33-
34- <!--
35- Not the Fullscreen theme below: under it the window owns the whole
36- display, content_rect is the whole display too, and there is nothing for
37- vidya to inset by — the status bar clock sits on top of the app and the
38- compose bar sits under the keyboard.
39- -->
40- <!--
41- debuggable: `adb run-as` reads the app's own data directory under it,
42- which is how a session file or a cache is looked at on the device. It
43- costs nothing at runtime and this APK is signed with the debug key
44- anyway; drop it for anything that ships.
45- -->
46- <application
47- android:allowBackup="false"
48- android:debuggable="true"
49- android:label="frq"
50- android:theme="@android:style/Theme.Material.NoActionBar">
51- <!--
52- adjustResize: without it the activity's content_rect never shrinks for
53- the soft keyboard, so vidya has no IME inset to reserve and the compose
54- bar sits under the keys.
55- -->
56- <!--
57- singleTask: the sign-in deep link below has to bring the *running*
58- app forward, with its loopback handoff already in hand, rather than
59- start a second copy of it beside the first.
60- -->
61- <activity
62- android:name=".FrqActivity"
63- android:configChanges="orientation|keyboardHidden|screenSize"
64- android:launchMode="singleTask"
65- android:windowSoftInputMode="adjustResize"
66- android:exported="true"
67- android:screenOrientation="portrait">
68- <!--
69- The activity's library is libvidya.so, not the Jolt one: whoever
70- holds android-activity's glue owns the event loop, and winit
71- cannot build one without the AndroidApp handle that glue receives.
72- It dlopens libjoltapp.so in turn. See ffi/src/android.rs.
73- -->
74- <meta-data
75- android:name="android.app.lib_name"
76- android:value="vidya" />
77- <intent-filter>
78- <action android:name="android.intent.action.MAIN" />
79- <category android:name="android.intent.category.LAUNCHER" />
80- </intent-filter>
81- <!--
82- Coming back from the browser. The handoff itself travels over
83- loopback and is already captured by the time this arrives; the
84- link carries nothing, and exists only to put the app back in
85- front of the browser that covered it.
86- -->
87- <intent-filter>
88- <action android:name="android.intent.action.VIEW" />
89- <category android:name="android.intent.category.DEFAULT" />
90- <category android:name="android.intent.category.BROWSABLE" />
91- <data android:scheme="frq" android:host="auth" />
92- </intent-filter>
93- </activity>
94- </application>
95-</manifest>
deleted android/java/uk/nandi/frq/CameraCapture.java +0 -642
deleted file mode 100644
@@ -1,642 +0,0 @@
1-package uk.nandi.frq;
2-
3-import android.app.Activity;
4-import android.content.Context;
5-import android.content.pm.PackageManager;
6-import android.graphics.ImageFormat;
7-import android.hardware.camera2.CameraAccessException;
8-import android.hardware.camera2.CameraCaptureSession;
9-import android.hardware.camera2.CameraCharacteristics;
10-import android.hardware.camera2.CameraDevice;
11-import android.hardware.camera2.CameraManager;
12-import android.hardware.camera2.CaptureRequest;
13-import android.media.Image;
14-import android.media.ImageReader;
15-import android.os.Build;
16-import android.os.Handler;
17-import android.os.HandlerThread;
18-import android.util.Log;
19-import android.util.Size;
20-import android.view.Display;
21-import android.view.Surface;
22-
23-import java.nio.ByteBuffer;
24-import java.util.ArrayList;
25-import java.util.Collections;
26-import java.util.List;
27-
28-/**
29- * Camera2 capture for NativeActivity AV calls.
30- *
31- * <p>Opens a camera (front preferred), streams {@link ImageFormat#YUV_420_888}
32- * into an {@link ImageReader}, and pushes NV12 planes into Rust via JNI so
33- * iroh-live can publish H.264 on the MoQ media plane.
34- *
35- * <p>ImageReader buffers are in <b>sensor</b> coordinates. Most phone sensors
36- * sit at 90°/270°, so without a CW rotate by {@link
37- * CameraCharacteristics#SENSOR_ORIENTATION} ± display rotation the published
38- * video (and local preview) looks sideways. Rotation degrees are passed to
39- * Rust, which rotates the NV12 planes before encode/preview.
40- *
41- * <p>Compiled into the APK's {@code classes.dex}. Rust must resolve this class
42- * via {@code Activity.getClassLoader().loadClass} — {@code FindClass} from a
43- * native worker thread uses the system loader and cannot see APK classes.
44- *
45- * <p>The native half is {@code crates/jolt-moq/src/android_camera.rs} in
46- * jolt-native, and it needs the JavaVM and the Activity handed to it first:
47- * {@code libjoltapp.so}'s glue calls {@code joltmoq_android_init} before Jolt
48- * starts. Without that, {@link #start} is never reached at all.
49- */
50-public final class CameraCapture {
51- private static final String TAG = "FrqCamera";
52- /** Target capture size — encoder preset is 360p; 640x480 is widely supported. */
53- private static final int TARGET_WIDTH = 640;
54- private static final int TARGET_HEIGHT = 480;
55-
56- private static final Object LOCK = new Object();
57- private static CameraDevice cameraDevice;
58- private static CameraCaptureSession captureSession;
59- private static ImageReader imageReader;
60- private static HandlerThread backgroundThread;
61- private static Handler backgroundHandler;
62- private static volatile boolean running;
63- private static long frameCount;
64- /** Host activity — used to read live display rotation while capturing. */
65- private static volatile Activity hostActivity;
66-
67- /**
68- * Whether the dialog has been raised in this process. Once, because the
69- * ask after a refusal is one the system does not show at all — it answers
70- * denied straight away, which here would be an invisible loop between the
71- * check below and the retry it schedules. After a refusal the grant comes
72- * from Settings, and the next start finds it.
73- */
74- private static volatile boolean permissionAsked;
75- private static int sensorOrientationDeg = 90;
76- private static boolean frontFacing = true;
77-
78- static {
79- // Required, not belt-and-braces. The runtime resolves a `native` method
80- // against the libraries this class loader was told about through
81- // System.loadLibrary — and libjoltmoq.so is not one of them: nothing
82- // Java loads it. It arrives as a DT_NEEDED of libjoltapp.so, which
83- // libvidya dlopens, and a library that reached the process that way is
84- // invisible to JNI method binding. So it is named here, where the class
85- // that declares the natives is initialised. Already-mapped is fine;
86- // loadLibrary finds it and registers it rather than mapping it twice.
87- try {
88- System.loadLibrary("joltmoq");
89- } catch (UnsatisfiedLinkError e) {
90- // Nothing below can work after this: every frame and every state
91- // change goes out through a native method.
92- Log.e(TAG, "libjoltmoq.so not loadable; camera capture is dead", e);
93- }
94- }
95-
96- private CameraCapture() {}
97-
98- /**
99- * Native: push one NV12 frame into the MoQ publish pipeline.
100- *
101- * @param rotationDegrees CW degrees (0/90/180/270) to apply so the frame
102- * is upright for the current display — see {@link #computeRotationDegrees()}.
103- */
104- private static native void onNv12Frame(
105- byte[] yData,
106- byte[] uvData,
107- int width,
108- int height,
109- int yStride,
110- int uvStride,
111- int rotationDegrees);
112-
113- /** Native: camera opened successfully (or failed). */
114- private static native void onCameraState(boolean opened, String detail);
115-
116- /**
117- * List available cameras as {@code "id\\tlabel"} lines (id then tab then label).
118- * Facing preference: front first, then back, then external/other.
119- */
120- public static String[] listCameras(Context context) {
121- try {
122- CameraManager manager =
123- (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
124- if (manager == null) {
125- return new String[0];
126- }
127- String[] ids = manager.getCameraIdList();
128- List<String> front = new ArrayList<>();
129- List<String> back = new ArrayList<>();
130- List<String> other = new ArrayList<>();
131- for (String id : ids) {
132- CameraCharacteristics chars = manager.getCameraCharacteristics(id);
133- Integer facing = chars.get(CameraCharacteristics.LENS_FACING);
134- String label;
135- if (facing != null && facing == CameraCharacteristics.LENS_FACING_FRONT) {
136- label = "Front camera · " + id;
137- front.add(id + "\t" + label);
138- } else if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) {
139- label = "Back camera · " + id;
140- back.add(id + "\t" + label);
141- } else {
142- label = "Camera · " + id;
143- other.add(id + "\t" + label);
144- }
145- }
146- List<String> out = new ArrayList<>(front.size() + back.size() + other.size());
147- out.addAll(front);
148- out.addAll(back);
149- out.addAll(other);
150- return out.toArray(new String[0]);
151- } catch (Throwable t) {
152- Log.w(TAG, "listCameras failed", t);
153- return new String[0];
154- }
155- }
156-
157- /**
158- * Start capture. {@code cameraId} may be null/empty (prefer front), a device
159- * id, or the tokens {@code "front"} / {@code "back"}.
160- */
161- public static void start(final Activity activity, final String cameraId) {
162- if (activity == null) {
163- onCameraState(false, "null activity");
164- return;
165- }
166- activity.runOnUiThread(
167- new Runnable() {
168- @Override
169- public void run() {
170- startOnThread(activity, cameraId);
171- }
172- });
173- }
174-
175- /** Stop capture and release camera resources. Safe to call repeatedly. */
176- public static void stop(final Activity activity) {
177- Runnable r =
178- new Runnable() {
179- @Override
180- public void run() {
181- stopLocked();
182- }
183- };
184- if (activity != null) {
185- activity.runOnUiThread(r);
186- } else {
187- r.run();
188- }
189- }
190-
191- private static void startOnThread(Activity activity, String cameraId) {
192- synchronized (LOCK) {
193- stopLocked();
194- if (activity.checkSelfPermission(android.Manifest.permission.CAMERA)
195- != PackageManager.PERMISSION_GRANTED) {
196- // Ask, once, and come back here when the reader has answered.
197- // The retry re-checks rather than trusting the dialog, so a
198- // refusal lands on this same branch a second time — by then
199- // permissionAsked is set and it reports denied instead of
200- // asking again.
201- if (activity instanceof FrqActivity && !permissionAsked) {
202- permissionAsked = true;
203- final Activity host = activity;
204- final String requested = cameraId;
205- ((FrqActivity) activity)
206- .ensurePermissions(
207- new String[] {
208- android.Manifest.permission.CAMERA,
209- android.Manifest.permission.RECORD_AUDIO,
210- },
211- new Runnable() {
212- @Override
213- public void run() {
214- startOnThread(host, requested);
215- }
216- });
217- return;
218- }
219- Log.e(TAG, "CAMERA permission not granted");
220- onCameraState(false, "permission denied");
221- return;
222- }
223- CameraManager manager =
224- (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE);
225- if (manager == null) {
226- onCameraState(false, "no CameraManager");
227- return;
228- }
229- String id;
230- try {
231- id = resolveCameraId(manager, cameraId);
232- } catch (CameraAccessException e) {
233- Log.e(TAG, "resolveCameraId", e);
234- onCameraState(false, e.getMessage());
235- return;
236- }
237- if (id == null) {
238- onCameraState(false, "no camera");
239- return;
240- }
241-
242- hostActivity = activity;
243- try {
244- CameraCharacteristics chars = manager.getCameraCharacteristics(id);
245- Integer so = chars.get(CameraCharacteristics.SENSOR_ORIENTATION);
246- sensorOrientationDeg = so != null ? so : 90;
247- Integer facing = chars.get(CameraCharacteristics.LENS_FACING);
248- frontFacing =
249- facing != null && facing == CameraCharacteristics.LENS_FACING_FRONT;
250- } catch (CameraAccessException e) {
251- Log.w(TAG, "characteristics; using default orientation", e);
252- sensorOrientationDeg = 90;
253- frontFacing = true;
254- }
255-
256- Size size = chooseSize(manager, id);
257- int width = size.getWidth();
258- int height = size.getHeight();
259- int rot = computeRotationDegrees();
260- Log.i(
261- TAG,
262- "opening camera id="
263- + id
264- + " size="
265- + width
266- + "x"
267- + height
268- + " sensorOrient="
269- + sensorOrientationDeg
270- + " front="
271- + frontFacing
272- + " rot="
273- + rot);
274-
275- startBackgroundThread();
276- frameCount = 0;
277- imageReader = ImageReader.newInstance(width, height, ImageFormat.YUV_420_888, 2);
278- imageReader.setOnImageAvailableListener(
279- new ImageReader.OnImageAvailableListener() {
280- @Override
281- public void onImageAvailable(ImageReader reader) {
282- Image image = null;
283- try {
284- image = reader.acquireLatestImage();
285- if (image == null) {
286- return;
287- }
288- pushNv12(image);
289- } catch (Throwable t) {
290- Log.w(TAG, "onImageAvailable", t);
291- } finally {
292- if (image != null) {
293- image.close();
294- }
295- }
296- }
297- },
298- backgroundHandler);
299-
300- final String openId = id;
301- try {
302- manager.openCamera(
303- openId,
304- new CameraDevice.StateCallback() {
305- @Override
306- public void onOpened(CameraDevice camera) {
307- synchronized (LOCK) {
308- cameraDevice = camera;
309- createSessionLocked(camera);
310- }
311- }
312-
313- @Override
314- public void onDisconnected(CameraDevice camera) {
315- Log.w(TAG, "camera disconnected");
316- synchronized (LOCK) {
317- camera.close();
318- if (cameraDevice == camera) {
319- cameraDevice = null;
320- }
321- running = false;
322- }
323- onCameraState(false, "disconnected");
324- }
325-
326- @Override
327- public void onError(CameraDevice camera, int error) {
328- Log.e(TAG, "camera error " + error);
329- synchronized (LOCK) {
330- camera.close();
331- if (cameraDevice == camera) {
332- cameraDevice = null;
333- }
334- running = false;
335- }
336- onCameraState(false, "error " + error);
337- }
338- },
339- backgroundHandler);
340- } catch (SecurityException e) {
341- Log.e(TAG, "openCamera security", e);
342- stopLocked();
343- onCameraState(false, "permission denied");
344- } catch (CameraAccessException e) {
345- Log.e(TAG, "openCamera", e);
346- stopLocked();
347- onCameraState(false, e.getMessage());
348- }
349- }
350- }
351-
352- private static void createSessionLocked(CameraDevice camera) {
353- final ImageReader reader = imageReader;
354- if (reader == null) {
355- onCameraState(false, "no ImageReader");
356- return;
357- }
358- final Surface surface = reader.getSurface();
359- try {
360- camera.createCaptureSession(
361- Collections.singletonList(surface),
362- new CameraCaptureSession.StateCallback() {
363- @Override
364- public void onConfigured(CameraCaptureSession session) {
365- synchronized (LOCK) {
366- if (cameraDevice == null) {
367- return;
368- }
369- captureSession = session;
370- try {
371- CaptureRequest.Builder builder =
372- cameraDevice.createCaptureRequest(
373- CameraDevice.TEMPLATE_PREVIEW);
374- builder.addTarget(surface);
375- builder.set(
376- CaptureRequest.CONTROL_AF_MODE,
377- CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO);
378- session.setRepeatingRequest(
379- builder.build(), null, backgroundHandler);
380- running = true;
381- onCameraState(true, "live");
382- Log.i(TAG, "capture session live");
383- } catch (CameraAccessException e) {
384- Log.e(TAG, "setRepeatingRequest", e);
385- onCameraState(false, e.getMessage());
386- }
387- }
388- }
389-
390- @Override
391- public void onConfigureFailed(CameraCaptureSession session) {
392- Log.e(TAG, "capture session configure failed");
393- onCameraState(false, "configure failed");
394- }
395- },
396- backgroundHandler);
397- } catch (CameraAccessException e) {
398- Log.e(TAG, "createCaptureSession", e);
399- onCameraState(false, e.getMessage());
400- }
401- }
402-
403- private static void pushNv12(Image image) {
404- if (image.getFormat() != ImageFormat.YUV_420_888) {
405- return;
406- }
407- Image.Plane[] planes = image.getPlanes();
408- if (planes.length < 3) {
409- return;
410- }
411- int width = image.getWidth();
412- int height = image.getHeight();
413- Image.Plane yPlane = planes[0];
414- Image.Plane uPlane = planes[1];
415- Image.Plane vPlane = planes[2];
416- int yStride = yPlane.getRowStride();
417- int uvStride = uPlane.getRowStride();
418- int uvPixelStride = uPlane.getPixelStride();
419- int uvHeight = height / 2;
420-
421- ByteBuffer yBuf = yPlane.getBuffer();
422- int ySize = yStride * height;
423- byte[] yData = new byte[ySize];
424- yBuf.position(0);
425- int yCopy = Math.min(ySize, yBuf.remaining());
426- yBuf.get(yData, 0, yCopy);
427-
428- byte[] uvData;
429- int outUvStride;
430- if (uvPixelStride == 2) {
431- // Semi-planar: U plane buffer is UVUV… (NV12-style on most devices).
432- ByteBuffer uvBuf = uPlane.getBuffer();
433- int uvSize = uvStride * uvHeight;
434- uvData = new byte[uvSize];
435- uvBuf.position(0);
436- int uvCopy = Math.min(uvSize, uvBuf.remaining());
437- uvBuf.get(uvData, 0, uvCopy);
438- outUvStride = uvStride;
439- } else {
440- // Planar I420 → interleave U+V into NV12.
441- ByteBuffer uBuf = uPlane.getBuffer();
442- ByteBuffer vBuf = vPlane.getBuffer();
443- int uvWidth = width / 2;
444- outUvStride = width;
445- uvData = new byte[outUvStride * uvHeight];
446- for (int row = 0; row < uvHeight; row++) {
447- for (int col = 0; col < uvWidth; col++) {
448- int srcIdx = row * uPlane.getRowStride() + col * uvPixelStride;
449- int dstIdx = row * outUvStride + col * 2;
450- uvData[dstIdx] = uBuf.get(srcIdx);
451- uvData[dstIdx + 1] = vBuf.get(srcIdx);
452- }
453- }
454- }
455-
456- int rotationDegrees = computeRotationDegrees();
457- onNv12Frame(yData, uvData, width, height, yStride, outUvStride, rotationDegrees);
458- long n = ++frameCount;
459- if (n == 1L || n == 30L || n == 300L) {
460- Log.i(
461- TAG,
462- "nv12 frame #"
463- + n
464- + " "
465- + width
466- + "x"
467- + height
468- + " yStride="
469- + yStride
470- + " uvStride="
471- + outUvStride
472- + " uvPixelStride="
473- + uvPixelStride
474- + " rot="
475- + rotationDegrees);
476- }
477- }
478-
479- /**
480- * CW degrees to rotate sensor buffers so content is upright for the
481- * current display orientation (Camera2 JPEG_ORIENTATION formula).
482- */
483- private static int computeRotationDegrees() {
484- int device = displayRotationDegrees(hostActivity);
485- if (frontFacing) {
486- return (sensorOrientationDeg + device) % 360;
487- }
488- return (sensorOrientationDeg - device + 360) % 360;
489- }
490-
491- private static int displayRotationDegrees(Activity activity) {
492- if (activity == null) {
493- return 0;
494- }
495- try {
496- int rotation;
497- if (Build.VERSION.SDK_INT >= 30) {
498- Display display = activity.getDisplay();
499- rotation = display != null ? display.getRotation() : Surface.ROTATION_0;
500- } else {
501- @SuppressWarnings("deprecation")
502- Display display = activity.getWindowManager().getDefaultDisplay();
503- rotation = display.getRotation();
504- }
505- switch (rotation) {
506- case Surface.ROTATION_90:
507- return 90;
508- case Surface.ROTATION_180:
509- return 180;
510- case Surface.ROTATION_270:
511- return 270;
512- case Surface.ROTATION_0:
513- default:
514- return 0;
515- }
516- } catch (Throwable t) {
517- Log.w(TAG, "displayRotationDegrees", t);
518- return 0;
519- }
520- }
521-
522- private static String resolveCameraId(CameraManager manager, String requested)
523- throws CameraAccessException {
524- String[] ids = manager.getCameraIdList();
525- if (ids.length == 0) {
526- return null;
527- }
528- String want = requested == null ? "" : requested.trim();
529- if (!want.isEmpty()) {
530- for (String id : ids) {
531- if (id.equals(want)) {
532- return id;
533- }
534- }
535- Integer facingWant = null;
536- if ("front".equalsIgnoreCase(want)) {
537- facingWant = CameraCharacteristics.LENS_FACING_FRONT;
538- } else if ("back".equalsIgnoreCase(want)) {
539- facingWant = CameraCharacteristics.LENS_FACING_BACK;
540- }
541- if (facingWant != null) {
542- for (String id : ids) {
543- Integer facing =
544- manager.getCameraCharacteristics(id)
545- .get(CameraCharacteristics.LENS_FACING);
546- if (facingWant.equals(facing)) {
547- return id;
548- }
549- }
550- }
551- }
552- // Prefer front for self-view, then first listed.
553- for (String id : ids) {
554- Integer facing =
555- manager.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING);
556- if (facing != null && facing == CameraCharacteristics.LENS_FACING_FRONT) {
557- return id;
558- }
559- }
560- return ids[0];
561- }
562-
563- private static Size chooseSize(CameraManager manager, String cameraId) {
564- try {
565- CameraCharacteristics chars = manager.getCameraCharacteristics(cameraId);
566- android.hardware.camera2.params.StreamConfigurationMap map =
567- chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
568- if (map == null) {
569- return new Size(TARGET_WIDTH, TARGET_HEIGHT);
570- }
571- Size[] sizes = map.getOutputSizes(ImageFormat.YUV_420_888);
572- if (sizes == null || sizes.length == 0) {
573- return new Size(TARGET_WIDTH, TARGET_HEIGHT);
574- }
575- Size best = sizes[0];
576- long bestScore = Long.MAX_VALUE;
577- for (Size s : sizes) {
578- long dw = (long) s.getWidth() - TARGET_WIDTH;
579- long dh = (long) s.getHeight() - TARGET_HEIGHT;
580- long score = dw * dw + dh * dh;
581- // Prefer sizes at or above target when scores are close.
582- if (s.getWidth() < TARGET_WIDTH / 2 || s.getHeight() < TARGET_HEIGHT / 2) {
583- score += 10_000_000L;
584- }
585- if (score < bestScore) {
586- bestScore = score;
587- best = s;
588- }
589- }
590- return best;
591- } catch (Throwable t) {
592- Log.w(TAG, "chooseSize fallback", t);
593- return new Size(TARGET_WIDTH, TARGET_HEIGHT);
594- }
595- }
596-
597- private static void stopLocked() {
598- running = false;
599- hostActivity = null;
600- if (captureSession != null) {
601- try {
602- captureSession.close();
603- } catch (Throwable ignored) {
604- }
605- captureSession = null;
606- }
607- if (cameraDevice != null) {
608- try {
609- cameraDevice.close();
610- } catch (Throwable ignored) {
611- }
612- cameraDevice = null;
613- }
614- if (imageReader != null) {
615- try {
616- imageReader.close();
617- } catch (Throwable ignored) {
618- }
619- imageReader = null;
620- }
621- stopBackgroundThread();
622- }
623-
624- private static void startBackgroundThread() {
625- stopBackgroundThread();
626- backgroundThread = new HandlerThread("frq-camera");
627- backgroundThread.start();
628- backgroundHandler = new Handler(backgroundThread.getLooper());
629- }
630-
631- private static void stopBackgroundThread() {
632- if (backgroundThread != null) {
633- backgroundThread.quitSafely();
634- try {
635- backgroundThread.join(1000);
636- } catch (InterruptedException ignored) {
637- }
638- backgroundThread = null;
639- backgroundHandler = null;
640- }
641- }
642-}
deleted file mode 100644
@@ -1,642 +0,0 @@
1-package uk.nandi.frq;
2-
3-import android.app.Activity;
4-import android.content.Context;
5-import android.content.pm.PackageManager;
6-import android.graphics.ImageFormat;
7-import android.hardware.camera2.CameraAccessException;
8-import android.hardware.camera2.CameraCaptureSession;
9-import android.hardware.camera2.CameraCharacteristics;
10-import android.hardware.camera2.CameraDevice;
11-import android.hardware.camera2.CameraManager;
12-import android.hardware.camera2.CaptureRequest;
13-import android.media.Image;
14-import android.media.ImageReader;
15-import android.os.Build;
16-import android.os.Handler;
17-import android.os.HandlerThread;
18-import android.util.Log;
19-import android.util.Size;
20-import android.view.Display;
21-import android.view.Surface;
22-
23-import java.nio.ByteBuffer;
24-import java.util.ArrayList;
25-import java.util.Collections;
26-import java.util.List;
27-
28-/**
29- * Camera2 capture for NativeActivity AV calls.
30- *
31- * <p>Opens a camera (front preferred), streams {@link ImageFormat#YUV_420_888}
32- * into an {@link ImageReader}, and pushes NV12 planes into Rust via JNI so
33- * iroh-live can publish H.264 on the MoQ media plane.
34- *
35- * <p>ImageReader buffers are in <b>sensor</b> coordinates. Most phone sensors
36- * sit at 90°/270°, so without a CW rotate by {@link
37- * CameraCharacteristics#SENSOR_ORIENTATION} ± display rotation the published
38- * video (and local preview) looks sideways. Rotation degrees are passed to
39- * Rust, which rotates the NV12 planes before encode/preview.
40- *
41- * <p>Compiled into the APK's {@code classes.dex}. Rust must resolve this class
42- * via {@code Activity.getClassLoader().loadClass} — {@code FindClass} from a
43- * native worker thread uses the system loader and cannot see APK classes.
44- *
45- * <p>The native half is {@code crates/jolt-moq/src/android_camera.rs} in
46- * jolt-native, and it needs the JavaVM and the Activity handed to it first:
47- * {@code libjoltapp.so}'s glue calls {@code joltmoq_android_init} before Jolt
48- * starts. Without that, {@link #start} is never reached at all.
49- */
50-public final class CameraCapture {
51- private static final String TAG = "FrqCamera";
52- /** Target capture size — encoder preset is 360p; 640x480 is widely supported. */
53- private static final int TARGET_WIDTH = 640;
54- private static final int TARGET_HEIGHT = 480;
55-
56- private static final Object LOCK = new Object();
57- private static CameraDevice cameraDevice;
58- private static CameraCaptureSession captureSession;
59- private static ImageReader imageReader;
60- private static HandlerThread backgroundThread;
61- private static Handler backgroundHandler;
62- private static volatile boolean running;
63- private static long frameCount;
64- /** Host activity — used to read live display rotation while capturing. */
65- private static volatile Activity hostActivity;
66-
67- /**
68- * Whether the dialog has been raised in this process. Once, because the
69- * ask after a refusal is one the system does not show at all — it answers
70- * denied straight away, which here would be an invisible loop between the
71- * check below and the retry it schedules. After a refusal the grant comes
72- * from Settings, and the next start finds it.
73- */
74- private static volatile boolean permissionAsked;
75- private static int sensorOrientationDeg = 90;
76- private static boolean frontFacing = true;
77-
78- static {
79- // Required, not belt-and-braces. The runtime resolves a `native` method
80- // against the libraries this class loader was told about through
81- // System.loadLibrary — and libjoltmoq.so is not one of them: nothing
82- // Java loads it. It arrives as a DT_NEEDED of libjoltapp.so, which
83- // libvidya dlopens, and a library that reached the process that way is
84- // invisible to JNI method binding. So it is named here, where the class
85- // that declares the natives is initialised. Already-mapped is fine;
86- // loadLibrary finds it and registers it rather than mapping it twice.
87- try {
88- System.loadLibrary("joltmoq");
89- } catch (UnsatisfiedLinkError e) {
90- // Nothing below can work after this: every frame and every state
91- // change goes out through a native method.
92- Log.e(TAG, "libjoltmoq.so not loadable; camera capture is dead", e);
93- }
94- }
95-
96- private CameraCapture() {}
97-
98- /**
99- * Native: push one NV12 frame into the MoQ publish pipeline.
100- *
101- * @param rotationDegrees CW degrees (0/90/180/270) to apply so the frame
102- * is upright for the current display — see {@link #computeRotationDegrees()}.
103- */
104- private static native void onNv12Frame(
105- byte[] yData,
106- byte[] uvData,
107- int width,
108- int height,
109- int yStride,
110- int uvStride,
111- int rotationDegrees);
112-
113- /** Native: camera opened successfully (or failed). */
114- private static native void onCameraState(boolean opened, String detail);
115-
116- /**
117- * List available cameras as {@code "id\\tlabel"} lines (id then tab then label).
118- * Facing preference: front first, then back, then external/other.
119- */
120- public static String[] listCameras(Context context) {
121- try {
122- CameraManager manager =
123- (CameraManager) context.getSystemService(Context.CAMERA_SERVICE);
124- if (manager == null) {
125- return new String[0];
126- }
127- String[] ids = manager.getCameraIdList();
128- List<String> front = new ArrayList<>();
129- List<String> back = new ArrayList<>();
130- List<String> other = new ArrayList<>();
131- for (String id : ids) {
132- CameraCharacteristics chars = manager.getCameraCharacteristics(id);
133- Integer facing = chars.get(CameraCharacteristics.LENS_FACING);
134- String label;
135- if (facing != null && facing == CameraCharacteristics.LENS_FACING_FRONT) {
136- label = "Front camera · " + id;
137- front.add(id + "\t" + label);
138- } else if (facing != null && facing == CameraCharacteristics.LENS_FACING_BACK) {
139- label = "Back camera · " + id;
140- back.add(id + "\t" + label);
141- } else {
142- label = "Camera · " + id;
143- other.add(id + "\t" + label);
144- }
145- }
146- List<String> out = new ArrayList<>(front.size() + back.size() + other.size());
147- out.addAll(front);
148- out.addAll(back);
149- out.addAll(other);
150- return out.toArray(new String[0]);
151- } catch (Throwable t) {
152- Log.w(TAG, "listCameras failed", t);
153- return new String[0];
154- }
155- }
156-
157- /**
158- * Start capture. {@code cameraId} may be null/empty (prefer front), a device
159- * id, or the tokens {@code "front"} / {@code "back"}.
160- */
161- public static void start(final Activity activity, final String cameraId) {
162- if (activity == null) {
163- onCameraState(false, "null activity");
164- return;
165- }
166- activity.runOnUiThread(
167- new Runnable() {
168- @Override
169- public void run() {
170- startOnThread(activity, cameraId);
171- }
172- });
173- }
174-
175- /** Stop capture and release camera resources. Safe to call repeatedly. */
176- public static void stop(final Activity activity) {
177- Runnable r =
178- new Runnable() {
179- @Override
180- public void run() {
181- stopLocked();
182- }
183- };
184- if (activity != null) {
185- activity.runOnUiThread(r);
186- } else {
187- r.run();
188- }
189- }
190-
191- private static void startOnThread(Activity activity, String cameraId) {
192- synchronized (LOCK) {
193- stopLocked();
194- if (activity.checkSelfPermission(android.Manifest.permission.CAMERA)
195- != PackageManager.PERMISSION_GRANTED) {
196- // Ask, once, and come back here when the reader has answered.
197- // The retry re-checks rather than trusting the dialog, so a
198- // refusal lands on this same branch a second time — by then
199- // permissionAsked is set and it reports denied instead of
200- // asking again.
201- if (activity instanceof FrqActivity && !permissionAsked) {
202- permissionAsked = true;
203- final Activity host = activity;
204- final String requested = cameraId;
205- ((FrqActivity) activity)
206- .ensurePermissions(
207- new String[] {
208- android.Manifest.permission.CAMERA,
209- android.Manifest.permission.RECORD_AUDIO,
210- },
211- new Runnable() {
212- @Override
213- public void run() {
214- startOnThread(host, requested);
215- }
216- });
217- return;
218- }
219- Log.e(TAG, "CAMERA permission not granted");
220- onCameraState(false, "permission denied");
221- return;
222- }
223- CameraManager manager =
224- (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE);
225- if (manager == null) {
226- onCameraState(false, "no CameraManager");
227- return;
228- }
229- String id;
230- try {
231- id = resolveCameraId(manager, cameraId);
232- } catch (CameraAccessException e) {
233- Log.e(TAG, "resolveCameraId", e);
234- onCameraState(false, e.getMessage());
235- return;
236- }
237- if (id == null) {
238- onCameraState(false, "no camera");
239- return;
240- }
241-
242- hostActivity = activity;
243- try {
244- CameraCharacteristics chars = manager.getCameraCharacteristics(id);
245- Integer so = chars.get(CameraCharacteristics.SENSOR_ORIENTATION);
246- sensorOrientationDeg = so != null ? so : 90;
247- Integer facing = chars.get(CameraCharacteristics.LENS_FACING);
248- frontFacing =
249- facing != null && facing == CameraCharacteristics.LENS_FACING_FRONT;
250- } catch (CameraAccessException e) {
251- Log.w(TAG, "characteristics; using default orientation", e);
252- sensorOrientationDeg = 90;
253- frontFacing = true;
254- }
255-
256- Size size = chooseSize(manager, id);
257- int width = size.getWidth();
258- int height = size.getHeight();
259- int rot = computeRotationDegrees();
260- Log.i(
261- TAG,
262- "opening camera id="
263- + id
264- + " size="
265- + width
266- + "x"
267- + height
268- + " sensorOrient="
269- + sensorOrientationDeg
270- + " front="
271- + frontFacing
272- + " rot="
273- + rot);
274-
275- startBackgroundThread();
276- frameCount = 0;
277- imageReader = ImageReader.newInstance(width, height, ImageFormat.YUV_420_888, 2);
278- imageReader.setOnImageAvailableListener(
279- new ImageReader.OnImageAvailableListener() {
280- @Override
281- public void onImageAvailable(ImageReader reader) {
282- Image image = null;
283- try {
284- image = reader.acquireLatestImage();
285- if (image == null) {
286- return;
287- }
288- pushNv12(image);
289- } catch (Throwable t) {
290- Log.w(TAG, "onImageAvailable", t);
291- } finally {
292- if (image != null) {
293- image.close();
294- }
295- }
296- }
297- },
298- backgroundHandler);
299-
300- final String openId = id;
301- try {
302- manager.openCamera(
303- openId,
304- new CameraDevice.StateCallback() {
305- @Override
306- public void onOpened(CameraDevice camera) {
307- synchronized (LOCK) {
308- cameraDevice = camera;
309- createSessionLocked(camera);
310- }
311- }
312-
313- @Override
314- public void onDisconnected(CameraDevice camera) {
315- Log.w(TAG, "camera disconnected");
316- synchronized (LOCK) {
317- camera.close();
318- if (cameraDevice == camera) {
319- cameraDevice = null;
320- }
321- running = false;
322- }
323- onCameraState(false, "disconnected");
324- }
325-
326- @Override
327- public void onError(CameraDevice camera, int error) {
328- Log.e(TAG, "camera error " + error);
329- synchronized (LOCK) {
330- camera.close();
331- if (cameraDevice == camera) {
332- cameraDevice = null;
333- }
334- running = false;
335- }
336- onCameraState(false, "error " + error);
337- }
338- },
339- backgroundHandler);
340- } catch (SecurityException e) {
341- Log.e(TAG, "openCamera security", e);
342- stopLocked();
343- onCameraState(false, "permission denied");
344- } catch (CameraAccessException e) {
345- Log.e(TAG, "openCamera", e);
346- stopLocked();
347- onCameraState(false, e.getMessage());
348- }
349- }
350- }
351-
352- private static void createSessionLocked(CameraDevice camera) {
353- final ImageReader reader = imageReader;
354- if (reader == null) {
355- onCameraState(false, "no ImageReader");
356- return;
357- }
358- final Surface surface = reader.getSurface();
359- try {
360- camera.createCaptureSession(
361- Collections.singletonList(surface),
362- new CameraCaptureSession.StateCallback() {
363- @Override
364- public void onConfigured(CameraCaptureSession session) {
365- synchronized (LOCK) {
366- if (cameraDevice == null) {
367- return;
368- }
369- captureSession = session;
370- try {
371- CaptureRequest.Builder builder =
372- cameraDevice.createCaptureRequest(
373- CameraDevice.TEMPLATE_PREVIEW);
374- builder.addTarget(surface);
375- builder.set(
376- CaptureRequest.CONTROL_AF_MODE,
377- CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_VIDEO);
378- session.setRepeatingRequest(
379- builder.build(), null, backgroundHandler);
380- running = true;
381- onCameraState(true, "live");
382- Log.i(TAG, "capture session live");
383- } catch (CameraAccessException e) {
384- Log.e(TAG, "setRepeatingRequest", e);
385- onCameraState(false, e.getMessage());
386- }
387- }
388- }
389-
390- @Override
391- public void onConfigureFailed(CameraCaptureSession session) {
392- Log.e(TAG, "capture session configure failed");
393- onCameraState(false, "configure failed");
394- }
395- },
396- backgroundHandler);
397- } catch (CameraAccessException e) {
398- Log.e(TAG, "createCaptureSession", e);
399- onCameraState(false, e.getMessage());
400- }
401- }
402-
403- private static void pushNv12(Image image) {
404- if (image.getFormat() != ImageFormat.YUV_420_888) {
405- return;
406- }
407- Image.Plane[] planes = image.getPlanes();
408- if (planes.length < 3) {
409- return;
410- }
411- int width = image.getWidth();
412- int height = image.getHeight();
413- Image.Plane yPlane = planes[0];
414- Image.Plane uPlane = planes[1];
415- Image.Plane vPlane = planes[2];
416- int yStride = yPlane.getRowStride();
417- int uvStride = uPlane.getRowStride();
418- int uvPixelStride = uPlane.getPixelStride();
419- int uvHeight = height / 2;
420-
421- ByteBuffer yBuf = yPlane.getBuffer();
422- int ySize = yStride * height;
423- byte[] yData = new byte[ySize];
424- yBuf.position(0);
425- int yCopy = Math.min(ySize, yBuf.remaining());
426- yBuf.get(yData, 0, yCopy);
427-
428- byte[] uvData;
429- int outUvStride;
430- if (uvPixelStride == 2) {
431- // Semi-planar: U plane buffer is UVUV… (NV12-style on most devices).
432- ByteBuffer uvBuf = uPlane.getBuffer();
433- int uvSize = uvStride * uvHeight;
434- uvData = new byte[uvSize];
435- uvBuf.position(0);
436- int uvCopy = Math.min(uvSize, uvBuf.remaining());
437- uvBuf.get(uvData, 0, uvCopy);
438- outUvStride = uvStride;
439- } else {
440- // Planar I420 → interleave U+V into NV12.
441- ByteBuffer uBuf = uPlane.getBuffer();
442- ByteBuffer vBuf = vPlane.getBuffer();
443- int uvWidth = width / 2;
444- outUvStride = width;
445- uvData = new byte[outUvStride * uvHeight];
446- for (int row = 0; row < uvHeight; row++) {
447- for (int col = 0; col < uvWidth; col++) {
448- int srcIdx = row * uPlane.getRowStride() + col * uvPixelStride;
449- int dstIdx = row * outUvStride + col * 2;
450- uvData[dstIdx] = uBuf.get(srcIdx);
451- uvData[dstIdx + 1] = vBuf.get(srcIdx);
452- }
453- }
454- }
455-
456- int rotationDegrees = computeRotationDegrees();
457- onNv12Frame(yData, uvData, width, height, yStride, outUvStride, rotationDegrees);
458- long n = ++frameCount;
459- if (n == 1L || n == 30L || n == 300L) {
460- Log.i(
461- TAG,
462- "nv12 frame #"
463- + n
464- + " "
465- + width
466- + "x"
467- + height
468- + " yStride="
469- + yStride
470- + " uvStride="
471- + outUvStride
472- + " uvPixelStride="
473- + uvPixelStride
474- + " rot="
475- + rotationDegrees);
476- }
477- }
478-
479- /**
480- * CW degrees to rotate sensor buffers so content is upright for the
481- * current display orientation (Camera2 JPEG_ORIENTATION formula).
482- */
483- private static int computeRotationDegrees() {
484- int device = displayRotationDegrees(hostActivity);
485- if (frontFacing) {
486- return (sensorOrientationDeg + device) % 360;
487- }
488- return (sensorOrientationDeg - device + 360) % 360;
489- }
490-
491- private static int displayRotationDegrees(Activity activity) {
492- if (activity == null) {
493- return 0;
494- }
495- try {
496- int rotation;
497- if (Build.VERSION.SDK_INT >= 30) {
498- Display display = activity.getDisplay();
499- rotation = display != null ? display.getRotation() : Surface.ROTATION_0;
500- } else {
501- @SuppressWarnings("deprecation")
502- Display display = activity.getWindowManager().getDefaultDisplay();
503- rotation = display.getRotation();
504- }
505- switch (rotation) {
506- case Surface.ROTATION_90:
507- return 90;
508- case Surface.ROTATION_180:
509- return 180;
510- case Surface.ROTATION_270:
511- return 270;
512- case Surface.ROTATION_0:
513- default:
514- return 0;
515- }
516- } catch (Throwable t) {
517- Log.w(TAG, "displayRotationDegrees", t);
518- return 0;
519- }
520- }
521-
522- private static String resolveCameraId(CameraManager manager, String requested)
523- throws CameraAccessException {
524- String[] ids = manager.getCameraIdList();
525- if (ids.length == 0) {
526- return null;
527- }
528- String want = requested == null ? "" : requested.trim();
529- if (!want.isEmpty()) {
530- for (String id : ids) {
531- if (id.equals(want)) {
532- return id;
533- }
534- }
535- Integer facingWant = null;
536- if ("front".equalsIgnoreCase(want)) {
537- facingWant = CameraCharacteristics.LENS_FACING_FRONT;
538- } else if ("back".equalsIgnoreCase(want)) {
539- facingWant = CameraCharacteristics.LENS_FACING_BACK;
540- }
541- if (facingWant != null) {
542- for (String id : ids) {
543- Integer facing =
544- manager.getCameraCharacteristics(id)
545- .get(CameraCharacteristics.LENS_FACING);
546- if (facingWant.equals(facing)) {
547- return id;
548- }
549- }
550- }
551- }
552- // Prefer front for self-view, then first listed.
553- for (String id : ids) {
554- Integer facing =
555- manager.getCameraCharacteristics(id).get(CameraCharacteristics.LENS_FACING);
556- if (facing != null && facing == CameraCharacteristics.LENS_FACING_FRONT) {
557- return id;
558- }
559- }
560- return ids[0];
561- }
562-
563- private static Size chooseSize(CameraManager manager, String cameraId) {
564- try {
565- CameraCharacteristics chars = manager.getCameraCharacteristics(cameraId);
566- android.hardware.camera2.params.StreamConfigurationMap map =
567- chars.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
568- if (map == null) {
569- return new Size(TARGET_WIDTH, TARGET_HEIGHT);
570- }
571- Size[] sizes = map.getOutputSizes(ImageFormat.YUV_420_888);
572- if (sizes == null || sizes.length == 0) {
573- return new Size(TARGET_WIDTH, TARGET_HEIGHT);
574- }
575- Size best = sizes[0];
576- long bestScore = Long.MAX_VALUE;
577- for (Size s : sizes) {
578- long dw = (long) s.getWidth() - TARGET_WIDTH;
579- long dh = (long) s.getHeight() - TARGET_HEIGHT;
580- long score = dw * dw + dh * dh;
581- // Prefer sizes at or above target when scores are close.
582- if (s.getWidth() < TARGET_WIDTH / 2 || s.getHeight() < TARGET_HEIGHT / 2) {
583- score += 10_000_000L;
584- }
585- if (score < bestScore) {
586- bestScore = score;
587- best = s;
588- }
589- }
590- return best;
591- } catch (Throwable t) {
592- Log.w(TAG, "chooseSize fallback", t);
593- return new Size(TARGET_WIDTH, TARGET_HEIGHT);
594- }
595- }
596-
597- private static void stopLocked() {
598- running = false;
599- hostActivity = null;
600- if (captureSession != null) {
601- try {
602- captureSession.close();
603- } catch (Throwable ignored) {
604- }
605- captureSession = null;
606- }
607- if (cameraDevice != null) {
608- try {
609- cameraDevice.close();
610- } catch (Throwable ignored) {
611- }
612- cameraDevice = null;
613- }
614- if (imageReader != null) {
615- try {
616- imageReader.close();
617- } catch (Throwable ignored) {
618- }
619- imageReader = null;
620- }
621- stopBackgroundThread();
622- }
623-
624- private static void startBackgroundThread() {
625- stopBackgroundThread();
626- backgroundThread = new HandlerThread("frq-camera");
627- backgroundThread.start();
628- backgroundHandler = new Handler(backgroundThread.getLooper());
629- }
630-
631- private static void stopBackgroundThread() {
632- if (backgroundThread != null) {
633- backgroundThread.quitSafely();
634- try {
635- backgroundThread.join(1000);
636- } catch (InterruptedException ignored) {
637- }
638- backgroundThread = null;
639- backgroundHandler = null;
640- }
641- }
642-}
deleted android/java/uk/nandi/frq/FrqActivity.java +0 -217
deleted file mode 100644
@@ -1,217 +0,0 @@
1-package uk.nandi.frq;
2-
3-import android.app.NativeActivity;
4-import android.content.ContentResolver;
5-import android.content.Intent;
6-import android.content.pm.PackageManager;
7-import android.net.Uri;
8-import android.os.Bundle;
9-import android.os.Build;
10-import android.provider.MediaStore;
11-import android.util.Log;
12-
13-import java.io.File;
14-import java.io.FileOutputStream;
15-import java.io.InputStream;
16-import java.io.OutputStream;
17-import java.util.ArrayList;
18-import java.util.List;
19-
20-/**
21- * The only Java in the app, and it exists for one reason: a picture chooser
22- * answers through {@code onActivityResult}, and a plain NativeActivity has
23- * nowhere to deliver that. Everything else the app does — the window, the
24- * event loop, the UI — is native, and this class does not touch any of it.
25- *
26- * The native side reaches the methods below over JNI, by name, on the
27- * activity handle the glue already holds. See vidya's `vidya_pick_image` and
28- * `vidya_picked_image`.
29- *
30- * The other reason it exists is the runtime permission dialog: the manifest
31- * can only declare CAMERA and RECORD_AUDIO, and asking for them is an
32- * Activity call. {@link CameraCapture} asks through here rather than failing
33- * at a permission it could have had for the asking.
34- */
35-public class FrqActivity extends NativeActivity {
36- private static final String TAG = "VidyaJolt";
37- private static final int PICK_IMAGE = 0x1CE;
38- private static final int PERMISSIONS = 0x1CF;
39-
40- /**
41- * What a call needs and cannot be given by the manifest: both are runtime
42- * permissions, so the manifest only makes them askable and this activity
43- * is what asks. The picture chooser is deliberately not here — the picker
44- * hands back a grant for the one file that was chosen, so it needs no
45- * standing permission at all.
46- */
47- private static final String[] CALL_PERMISSIONS = {
48- android.Manifest.permission.CAMERA,
49- android.Manifest.permission.RECORD_AUDIO,
50- };
51-
52- /** Where the last pick was written, until the native side takes it. */
53- private volatile String picked;
54-
55- /** What to run when the dialog is answered, whichever way it is answered. */
56- private volatile Runnable afterPermissions;
57-
58- /**
59- * Asked for at startup rather than at the first call, because the call is
60- * not where they can be asked from: the microphone is opened by cpal
61- * inside the media plane, which is Rust with no Activity to raise a dialog
62- * on, and by the time it fails the call is already up. A denied mic there
63- * is not a prompt but a dead pipeline — the audio stream never builds, and
64- * a second later the pipeline gives up reading a source that was never
65- * there.
66- *
67- * So both are asked for once, here, before anything can want them. The
68- * answer is not waited for: the dialog is its own window and the activity
69- * carries on behind it, exactly as it would if the permissions were
70- * already held.
71- */
72- @Override
73- protected void onCreate(Bundle state) {
74- super.onCreate(state);
75- ensurePermissions(CALL_PERMISSIONS, null);
76- }
77-
78- /**
79- * Open the system photo picker. Called from the UI thread or off it, so it
80- * hops to the right one itself.
81- *
82- * ACTION_PICK_IMAGES where there is one (API 33+): it shows the reader's
83- * own photos without this app holding any storage permission at all, since
84- * what comes back is a grant for the one picture they chose. Below that,
85- * the document picker does the same job through the same result.
86- *
87- * PNG only, because PNG is what the tree backend paints and what the
88- * upload sends — a chooser offering pictures the app then refuses would be
89- * a worse answer than one that never offered them.
90- */
91- public void pickImage() {
92- runOnUiThread(new Runnable() {
93- @Override
94- public void run() {
95- Intent intent;
96- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
97- intent = new Intent(MediaStore.ACTION_PICK_IMAGES);
98- } else {
99- intent = new Intent(Intent.ACTION_GET_CONTENT);
100- intent.addCategory(Intent.CATEGORY_OPENABLE);
101- }
102- intent.setType("image/png");
103- try {
104- startActivityForResult(intent, PICK_IMAGE);
105- } catch (Exception e) {
106- Log.e(TAG, "no picture chooser on this device", e);
107- }
108- }
109- });
110- }
111-
112- /**
113- * The picture chosen since the last call, as a path, or null.
114- *
115- * Handed over once: a caller polling for it must not attach the same
116- * picture twice, and the file is the native side's to move from here.
117- */
118- public String takePickedImage() {
119- String path = picked;
120- picked = null;
121- return path;
122- }
123-
124- /**
125- * Ask for the camera and the microphone, and say nothing about the answer.
126- *
127- * Here for the native side, which reaches it over JNI by name the same way
128- * it reaches {@link #pickImage} — a call that is about to open the mic can
129- * raise the dialog before it does. Granting is not waited for: the dialog
130- * is a separate window and the caller carries on without it.
131- */
132- public void requestCallPermissions() {
133- ensurePermissions(CALL_PERMISSIONS, null);
134- }
135-
136- /**
137- * Ask for whichever of {@code perms} is not held yet, then run {@code after}
138- * on the UI thread — after the reader answers, or immediately if there was
139- * nothing to ask. {@code after} is told nothing: what it does next is a
140- * fresh {@code checkSelfPermission}, because a dialog can be dismissed and
141- * a permission can be revoked from Settings while the app is running.
142- *
143- * One dialog at a time. A second ask while the first is still up would
144- * lose the first one's callback, so it is refused and answers late — when
145- * the outstanding one comes back.
146- */
147- public void ensurePermissions(final String[] perms, final Runnable after) {
148- runOnUiThread(new Runnable() {
149- @Override
150- public void run() {
151- List<String> missing = new ArrayList<>();
152- for (String p : perms) {
153- if (checkSelfPermission(p) != PackageManager.PERMISSION_GRANTED) {
154- missing.add(p);
155- }
156- }
157- if (missing.isEmpty() || afterPermissions != null) {
158- if (!missing.isEmpty()) {
159- Log.w(TAG, "a permission dialog is already up; not asking again");
160- }
161- if (after != null) {
162- after.run();
163- }
164- return;
165- }
166- afterPermissions = after;
167- requestPermissions(missing.toArray(new String[0]), PERMISSIONS);
168- }
169- });
170- }
171-
172- @Override
173- public void onRequestPermissionsResult(int request, String[] perms, int[] results) {
174- super.onRequestPermissionsResult(request, perms, results);
175- if (request != PERMISSIONS) {
176- return;
177- }
178- Runnable after = afterPermissions;
179- afterPermissions = null;
180- if (after != null) {
181- after.run();
182- }
183- }
184-
185- @Override
186- protected void onActivityResult(int request, int result, Intent data) {
187- super.onActivityResult(request, result, data);
188- if (request != PICK_IMAGE) {
189- return;
190- }
191- Uri uri = result == RESULT_OK && data != null ? data.getData() : null;
192- if (uri == null) {
193- // Cancelled, or a chooser that answered with nothing. Not an
194- // error: the picker screen is still there to try again from.
195- return;
196- }
197- // Copied out now, while the grant on that URI is still live — it is
198- // this activity's for the length of the result and no longer.
199- File out = new File(getCacheDir(), "picked-" + System.nanoTime() + ".png");
200- ContentResolver resolver = getContentResolver();
201- try (InputStream in = resolver.openInputStream(uri);
202- OutputStream os = new FileOutputStream(out)) {
203- if (in == null) {
204- throw new java.io.IOException("nothing to read at " + uri);
205- }
206- byte[] buf = new byte[1 << 16];
207- for (int n = in.read(buf); n > 0; n = in.read(buf)) {
208- os.write(buf, 0, n);
209- }
210- } catch (Exception e) {
211- Log.e(TAG, "could not read the chosen picture", e);
212- out.delete();
213- return;
214- }
215- picked = out.getAbsolutePath();
216- }
217-}
deleted file mode 100644
@@ -1,217 +0,0 @@
1-package uk.nandi.frq;
2-
3-import android.app.NativeActivity;
4-import android.content.ContentResolver;
5-import android.content.Intent;
6-import android.content.pm.PackageManager;
7-import android.net.Uri;
8-import android.os.Bundle;
9-import android.os.Build;
10-import android.provider.MediaStore;
11-import android.util.Log;
12-
13-import java.io.File;
14-import java.io.FileOutputStream;
15-import java.io.InputStream;
16-import java.io.OutputStream;
17-import java.util.ArrayList;
18-import java.util.List;
19-
20-/**
21- * The only Java in the app, and it exists for one reason: a picture chooser
22- * answers through {@code onActivityResult}, and a plain NativeActivity has
23- * nowhere to deliver that. Everything else the app does — the window, the
24- * event loop, the UI — is native, and this class does not touch any of it.
25- *
26- * The native side reaches the methods below over JNI, by name, on the
27- * activity handle the glue already holds. See vidya's `vidya_pick_image` and
28- * `vidya_picked_image`.
29- *
30- * The other reason it exists is the runtime permission dialog: the manifest
31- * can only declare CAMERA and RECORD_AUDIO, and asking for them is an
32- * Activity call. {@link CameraCapture} asks through here rather than failing
33- * at a permission it could have had for the asking.
34- */
35-public class FrqActivity extends NativeActivity {
36- private static final String TAG = "VidyaJolt";
37- private static final int PICK_IMAGE = 0x1CE;
38- private static final int PERMISSIONS = 0x1CF;
39-
40- /**
41- * What a call needs and cannot be given by the manifest: both are runtime
42- * permissions, so the manifest only makes them askable and this activity
43- * is what asks. The picture chooser is deliberately not here — the picker
44- * hands back a grant for the one file that was chosen, so it needs no
45- * standing permission at all.
46- */
47- private static final String[] CALL_PERMISSIONS = {
48- android.Manifest.permission.CAMERA,
49- android.Manifest.permission.RECORD_AUDIO,
50- };
51-
52- /** Where the last pick was written, until the native side takes it. */
53- private volatile String picked;
54-
55- /** What to run when the dialog is answered, whichever way it is answered. */
56- private volatile Runnable afterPermissions;
57-
58- /**
59- * Asked for at startup rather than at the first call, because the call is
60- * not where they can be asked from: the microphone is opened by cpal
61- * inside the media plane, which is Rust with no Activity to raise a dialog
62- * on, and by the time it fails the call is already up. A denied mic there
63- * is not a prompt but a dead pipeline — the audio stream never builds, and
64- * a second later the pipeline gives up reading a source that was never
65- * there.
66- *
67- * So both are asked for once, here, before anything can want them. The
68- * answer is not waited for: the dialog is its own window and the activity
69- * carries on behind it, exactly as it would if the permissions were
70- * already held.
71- */
72- @Override
73- protected void onCreate(Bundle state) {
74- super.onCreate(state);
75- ensurePermissions(CALL_PERMISSIONS, null);
76- }
77-
78- /**
79- * Open the system photo picker. Called from the UI thread or off it, so it
80- * hops to the right one itself.
81- *
82- * ACTION_PICK_IMAGES where there is one (API 33+): it shows the reader's
83- * own photos without this app holding any storage permission at all, since
84- * what comes back is a grant for the one picture they chose. Below that,
85- * the document picker does the same job through the same result.
86- *
87- * PNG only, because PNG is what the tree backend paints and what the
88- * upload sends — a chooser offering pictures the app then refuses would be
89- * a worse answer than one that never offered them.
90- */
91- public void pickImage() {
92- runOnUiThread(new Runnable() {
93- @Override
94- public void run() {
95- Intent intent;
96- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
97- intent = new Intent(MediaStore.ACTION_PICK_IMAGES);
98- } else {
99- intent = new Intent(Intent.ACTION_GET_CONTENT);
100- intent.addCategory(Intent.CATEGORY_OPENABLE);
101- }
102- intent.setType("image/png");
103- try {
104- startActivityForResult(intent, PICK_IMAGE);
105- } catch (Exception e) {
106- Log.e(TAG, "no picture chooser on this device", e);
107- }
108- }
109- });
110- }
111-
112- /**
113- * The picture chosen since the last call, as a path, or null.
114- *
115- * Handed over once: a caller polling for it must not attach the same
116- * picture twice, and the file is the native side's to move from here.
117- */
118- public String takePickedImage() {
119- String path = picked;
120- picked = null;
121- return path;
122- }
123-
124- /**
125- * Ask for the camera and the microphone, and say nothing about the answer.
126- *
127- * Here for the native side, which reaches it over JNI by name the same way
128- * it reaches {@link #pickImage} — a call that is about to open the mic can
129- * raise the dialog before it does. Granting is not waited for: the dialog
130- * is a separate window and the caller carries on without it.
131- */
132- public void requestCallPermissions() {
133- ensurePermissions(CALL_PERMISSIONS, null);
134- }
135-
136- /**
137- * Ask for whichever of {@code perms} is not held yet, then run {@code after}
138- * on the UI thread — after the reader answers, or immediately if there was
139- * nothing to ask. {@code after} is told nothing: what it does next is a
140- * fresh {@code checkSelfPermission}, because a dialog can be dismissed and
141- * a permission can be revoked from Settings while the app is running.
142- *
143- * One dialog at a time. A second ask while the first is still up would
144- * lose the first one's callback, so it is refused and answers late — when
145- * the outstanding one comes back.
146- */
147- public void ensurePermissions(final String[] perms, final Runnable after) {
148- runOnUiThread(new Runnable() {
149- @Override
150- public void run() {
151- List<String> missing = new ArrayList<>();
152- for (String p : perms) {
153- if (checkSelfPermission(p) != PackageManager.PERMISSION_GRANTED) {
154- missing.add(p);
155- }
156- }
157- if (missing.isEmpty() || afterPermissions != null) {
158- if (!missing.isEmpty()) {
159- Log.w(TAG, "a permission dialog is already up; not asking again");
160- }
161- if (after != null) {
162- after.run();
163- }
164- return;
165- }
166- afterPermissions = after;
167- requestPermissions(missing.toArray(new String[0]), PERMISSIONS);
168- }
169- });
170- }
171-
172- @Override
173- public void onRequestPermissionsResult(int request, String[] perms, int[] results) {
174- super.onRequestPermissionsResult(request, perms, results);
175- if (request != PERMISSIONS) {
176- return;
177- }
178- Runnable after = afterPermissions;
179- afterPermissions = null;
180- if (after != null) {
181- after.run();
182- }
183- }
184-
185- @Override
186- protected void onActivityResult(int request, int result, Intent data) {
187- super.onActivityResult(request, result, data);
188- if (request != PICK_IMAGE) {
189- return;
190- }
191- Uri uri = result == RESULT_OK && data != null ? data.getData() : null;
192- if (uri == null) {
193- // Cancelled, or a chooser that answered with nothing. Not an
194- // error: the picker screen is still there to try again from.
195- return;
196- }
197- // Copied out now, while the grant on that URI is still live — it is
198- // this activity's for the length of the result and no longer.
199- File out = new File(getCacheDir(), "picked-" + System.nanoTime() + ".png");
200- ContentResolver resolver = getContentResolver();
201- try (InputStream in = resolver.openInputStream(uri);
202- OutputStream os = new FileOutputStream(out)) {
203- if (in == null) {
204- throw new java.io.IOException("nothing to read at " + uri);
205- }
206- byte[] buf = new byte[1 << 16];
207- for (int n = in.read(buf); n > 0; n = in.read(buf)) {
208- os.write(buf, 0, n);
209- }
210- } catch (Exception e) {
211- Log.e(TAG, "could not read the chosen picture", e);
212- out.delete();
213- return;
214- }
215- picked = out.getAbsolutePath();
216- }
217-}
renamed common/frq/av/dial.cljc +0 -0
similarity index 100%
rename from src/frq/av/dial.clj
rename to common/frq/av/dial.cljc
similarity index 100%
rename from src/frq/av/dial.clj
rename to common/frq/av/dial.cljc
added common/frq/clock.cljc +92 -0
new file mode 100644
@@ -0,0 +1,92 @@
1+(ns frq.clock
2+ "Wall-clock time, in the reader's own zone.
3+
4+ Messages carry an IRCv3 `time` tag in UTC; a channel is read in local time.
5+ Everything between those two is arithmetic, which is why this namespace is
6+ shared: the only thing it asks the platform for is the offset at an instant
7+ and the current time, both through `frq.io`.
8+
9+ Finding the zone used to live here, and it was four guesses deep — TZ, then
10+ the target of /etc/localtime, then the file itself by path, then Android's
11+ system property. That is a libc question with a different answer on each
12+ side, so it moved to the backends: `frq.io.jolt` still carries the whole
13+ ladder, and `frq.io.dart` answers it with one call, because Dart carries the
14+ zone in its runtime."
15+ (:require [frq.io :as io]))
16+
17+(defn now-ms [] (quot (io/wall-nanos) 1000000))
18+
19+(defn parse-time-tag
20+ "The `time=` value of an IRCv3 tag string as epoch milliseconds, or nil.
21+
22+ Fixed format, always UTC: `2026-08-30T07:05:09.000Z`. Read by hand rather
23+ than through a parser, since this runs once per message of a hundred-message
24+ backlog."
25+ [tags]
26+ (when-let [[_ y mo d h mi s] (re-find #"time=(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})"
27+ (or tags ""))]
28+ (let [[y mo d h mi s] (map parse-long [y mo d h mi s])
29+ ;; days from the civil date, by Howard Hinnant's algorithm
30+ y (if (<= mo 2) (dec y) y)
31+ era (quot (if (>= y 0) y (- y 399)) 400)
32+ yoe (- y (* era 400))
33+ doy (+ (quot (+ (* 153 (+ mo (if (> mo 2) -3 9))) 2) 5) (dec d))
34+ doe (+ (* yoe 365) (quot yoe 4) (- (quot yoe 100)) doy)
35+ days (+ (* era 146097) doe -719468)]
36+ (* 1000 (+ (* days 86400) (* h 3600) (* mi 60) s)))))
37+
38+(defn- civil-from-days
39+ "`[y m d]` for a day number since the epoch — Hinnant's algorithm the other
40+ way round, which is the direction `parse-time-tag` does not go.
41+
42+ This was `jolt.time.local/local-dt` and a `subs` off the string it printed.
43+ Two problems with that: it is jolt's, so the namespace could not be shared,
44+ and it built a formatted date only to take it apart again. The arithmetic is
45+ eleven lines and the same on both platforms."
46+ [days]
47+ (let [z (+ days 719468)
48+ era (Math/floorDiv (long z) 146097)
49+ doe (- z (* era 146097))
50+ yoe (quot (+ (- doe (quot doe 1460)) (quot doe 36524) (- (quot doe 146096))) 365)
51+ y (+ yoe (* era 400))
52+ doy (- doe (+ (* 365 yoe) (quot yoe 4) (- (quot yoe 100))))
53+ mp (quot (+ (* 5 doy) 2) 153)
54+ d (+ (- doy (quot (+ (* 153 mp) 2) 5)) 1)
55+ m (+ mp (if (< mp 10) 3 -9))]
56+ [(if (<= m 2) (inc y) y) m d]))
57+
58+(defn- pad2 [n] (if (< n 10) (str "0" n) (str n)))
59+
60+(defn- local-parts
61+ "`[date hour minute]` in the reader's zone, the hour on a 24-clock."
62+ [ms]
63+ (let [secs (quot ms 1000)
64+ secs (+ secs (io/local-offset-seconds secs))
65+ days (Math/floorDiv (long secs) 86400)
66+ sod (Math/floorMod (long secs) 86400)
67+ [y m d] (civil-from-days days)]
68+ [(str y "-" (pad2 m) "-" (pad2 d))
69+ (quot sod 3600)
70+ (pad2 (quot (mod sod 3600) 60))]))
71+
72+(defn clock-time
73+ "A twelve-hour time: `9:05 AM`, `12:30 PM`."
74+ [ms]
75+ (let [[_ hour minute] (local-parts ms)
76+ display (cond (zero? hour) 12
77+ (> hour 12) (- hour 12)
78+ :else hour)]
79+ (str display ":" minute " " (if (< hour 12) "AM" "PM"))))
80+
81+(defn day [ms] (first (local-parts ms)))
82+
83+(defn day-label
84+ "The heading for a day's messages: today and yesterday by name, anything
85+ older by date."
86+ [ms]
87+ (let [d (day ms)
88+ today (day (now-ms))
89+ yesterday (day (- (now-ms) 86400000))]
90+ (cond (= d today) "Today"
91+ (= d yesterday) "Yesterday"
92+ :else d)))
new file mode 100644
@@ -0,0 +1,92 @@
1+(ns frq.clock
2+ "Wall-clock time, in the reader's own zone.
3+
4+ Messages carry an IRCv3 `time` tag in UTC; a channel is read in local time.
5+ Everything between those two is arithmetic, which is why this namespace is
6+ shared: the only thing it asks the platform for is the offset at an instant
7+ and the current time, both through `frq.io`.
8+
9+ Finding the zone used to live here, and it was four guesses deep — TZ, then
10+ the target of /etc/localtime, then the file itself by path, then Android's
11+ system property. That is a libc question with a different answer on each
12+ side, so it moved to the backends: `frq.io.jolt` still carries the whole
13+ ladder, and `frq.io.dart` answers it with one call, because Dart carries the
14+ zone in its runtime."
15+ (:require [frq.io :as io]))
16+
17+(defn now-ms [] (quot (io/wall-nanos) 1000000))
18+
19+(defn parse-time-tag
20+ "The `time=` value of an IRCv3 tag string as epoch milliseconds, or nil.
21+
22+ Fixed format, always UTC: `2026-08-30T07:05:09.000Z`. Read by hand rather
23+ than through a parser, since this runs once per message of a hundred-message
24+ backlog."
25+ [tags]
26+ (when-let [[_ y mo d h mi s] (re-find #"time=(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})"
27+ (or tags ""))]
28+ (let [[y mo d h mi s] (map parse-long [y mo d h mi s])
29+ ;; days from the civil date, by Howard Hinnant's algorithm
30+ y (if (<= mo 2) (dec y) y)
31+ era (quot (if (>= y 0) y (- y 399)) 400)
32+ yoe (- y (* era 400))
33+ doy (+ (quot (+ (* 153 (+ mo (if (> mo 2) -3 9))) 2) 5) (dec d))
34+ doe (+ (* yoe 365) (quot yoe 4) (- (quot yoe 100)) doy)
35+ days (+ (* era 146097) doe -719468)]
36+ (* 1000 (+ (* days 86400) (* h 3600) (* mi 60) s)))))
37+
38+(defn- civil-from-days
39+ "`[y m d]` for a day number since the epoch — Hinnant's algorithm the other
40+ way round, which is the direction `parse-time-tag` does not go.
41+
42+ This was `jolt.time.local/local-dt` and a `subs` off the string it printed.
43+ Two problems with that: it is jolt's, so the namespace could not be shared,
44+ and it built a formatted date only to take it apart again. The arithmetic is
45+ eleven lines and the same on both platforms."
46+ [days]
47+ (let [z (+ days 719468)
48+ era (Math/floorDiv (long z) 146097)
49+ doe (- z (* era 146097))
50+ yoe (quot (+ (- doe (quot doe 1460)) (quot doe 36524) (- (quot doe 146096))) 365)
51+ y (+ yoe (* era 400))
52+ doy (- doe (+ (* 365 yoe) (quot yoe 4) (- (quot yoe 100))))
53+ mp (quot (+ (* 5 doy) 2) 153)
54+ d (+ (- doy (quot (+ (* 153 mp) 2) 5)) 1)
55+ m (+ mp (if (< mp 10) 3 -9))]
56+ [(if (<= m 2) (inc y) y) m d]))
57+
58+(defn- pad2 [n] (if (< n 10) (str "0" n) (str n)))
59+
60+(defn- local-parts
61+ "`[date hour minute]` in the reader's zone, the hour on a 24-clock."
62+ [ms]
63+ (let [secs (quot ms 1000)
64+ secs (+ secs (io/local-offset-seconds secs))
65+ days (Math/floorDiv (long secs) 86400)
66+ sod (Math/floorMod (long secs) 86400)
67+ [y m d] (civil-from-days days)]
68+ [(str y "-" (pad2 m) "-" (pad2 d))
69+ (quot sod 3600)
70+ (pad2 (quot (mod sod 3600) 60))]))
71+
72+(defn clock-time
73+ "A twelve-hour time: `9:05 AM`, `12:30 PM`."
74+ [ms]
75+ (let [[_ hour minute] (local-parts ms)
76+ display (cond (zero? hour) 12
77+ (> hour 12) (- hour 12)
78+ :else hour)]
79+ (str display ":" minute " " (if (< hour 12) "AM" "PM"))))
80+
81+(defn day [ms] (first (local-parts ms)))
82+
83+(defn day-label
84+ "The heading for a day's messages: today and yesterday by name, anything
85+ older by date."
86+ [ms]
87+ (let [d (day ms)
88+ today (day (now-ms))
89+ yesterday (day (- (now-ms) 86400000))]
90+ (cond (= d today) "Today"
91+ (= d yesterday) "Yesterday"
92+ :else d)))
renamed common/frq/emoji.cljc +0 -0
similarity index 100%
rename from src/frq/emoji.clj
rename to common/frq/emoji.cljc
similarity index 100%
rename from src/frq/emoji.clj
rename to common/frq/emoji.cljc
renamed common/frq/glyphs.cljc +0 -0
similarity index 100%
rename from src/frq/glyphs.clj
rename to common/frq/glyphs.cljc
similarity index 100%
rename from src/frq/glyphs.clj
rename to common/frq/glyphs.cljc
added common/frq/io.cljc +99 -0
new file mode 100644
@@ -0,0 +1,99 @@
1+(ns frq.io
2+ "What the host does, named once so both compilers can answer it.
3+
4+ `src/` is jolt: jolt.host, jolt.socket, jolt.ffi, a Chez runtime and glimmer
5+ under the screens. `android/src/` is ClojureDart: dart:io, dart:ffi and
6+ Flutter. Everything in `common/` is compiled by both, so it cannot mention
7+ either a `(:require [jolt.host])` at the top of a namespace is what keeps
8+ it out of the Android build, not anything about what the code does.
9+
10+ So this is the seam. Nothing here has an implementation; the backend installs
11+ one before anything else runs `frq.io.jolt` on the desktop side,
12+ `frq.io.dart` on the phone. A namespace under `common/` requires this and
13+ stays portable.
14+
15+ The functions are chosen by *intent* rather than by what either platform
16+ happens to call it. `write-private-file!` rather than a chmod, because Dart
17+ has no chmod and jolt has no `File.setPermissions`; `local-offset-seconds`
18+ rather than a zone name, because finding the zone is four platform-specific
19+ guesses on Linux and one property read on Android. Anywhere the seam names a
20+ mechanism instead of a result, one of the two sides ends up faking it."
21+ (:refer-clojure :exclude [slurp spit]))
22+
23+(defonce ^:private impl
24+ ;; Keyword fn. Empty until a backend installs into it, which is a load-time
25+ ;; effect of requiring `frq.io.jolt` or `frq.io.dart`.
26+ (atom {}))
27+
28+(defn install!
29+ "Register the host's answers. Called once, by the backend, before `-main`
30+ does anything see the require list of `frq.app` and of the Flutter entry
31+ point. Merges, so a backend may install in pieces."
32+ [m]
33+ (swap! impl merge m)
34+ nil)
35+
36+(defn installed?
37+ "Whether a backend has answered yet. For the entry points to assert on; the
38+ wrappers below throw on their own."
39+ []
40+ (boolean (seq @impl)))
41+
42+(defn- call
43+ [k args]
44+ (if-let [f (get @impl k)]
45+ (apply f args)
46+ (throw (ex-info (str "frq.io: no host installed for " k
47+ " — require frq.io.jolt (desktop) or frq.io.dart (android) first")
48+ {:op k}))))
49+
50+;; ------------------------------------------------------------ environment
51+
52+(defn getenv [n] (call :getenv [n]))
53+
54+(defn config-dir
55+ "The directory this client keeps its own files in, already frq-specific.
56+
57+ Not a rule about XDG: on Android there is no `HOME` and no config directory
58+ to be relative to, and what the platform hands back is the app's own storage.
59+ The caller wants somewhere to put `session.edn` and does not care which."
60+ []
61+ (call :config-dir []))
62+
63+;; ------------------------------------------------------------------ files
64+
65+(defn file-exists? [path] (call :file-exists? [path]))
66+(defn directory? [path] (call :directory? [path]))
67+(defn list-dir [path] (call :list-dir [path]))
68+(defn mkdirs! [path] (call :mkdirs! [path]))
69+(defn delete-file! [path] (call :delete-file! [path]))
70+
71+(defn slurp
72+ "The file as a string, or nil where it cannot be read. Shadows core's, which
73+ wants a JVM reader."
74+ [path]
75+ (call :slurp [path]))
76+
77+(defn spit
78+ "Write the string whole. True when it landed."
79+ [path s]
80+ (call :spit [path s]))
81+
82+(defn write-private-file!
83+ "`spit`, for a file nobody else may read — mode 600 where that means
84+ something. The broker token goes through this and nothing else does."
85+ [path s]
86+ (call :write-private-file! [path s]))
87+
88+;; ------------------------------------------------------------------- time
89+
90+(defn wall-nanos [] (call :wall-nanos []))
91+(defn mono-nanos [] (call :mono-nanos []))
92+
93+(defn local-offset-seconds
94+ "How far the reader's zone is from UTC at `epoch-secs`, DST included.
95+
96+ An instant rather than a constant: the offset moves twice a year, and a
97+ backlog read in November carries messages from August."
98+ [epoch-secs]
99+ (call :local-offset-seconds [epoch-secs]))
new file mode 100644
@@ -0,0 +1,99 @@
1+(ns frq.io
2+ "What the host does, named once so both compilers can answer it.
3+
4+ `src/` is jolt: jolt.host, jolt.socket, jolt.ffi, a Chez runtime and glimmer
5+ under the screens. `android/src/` is ClojureDart: dart:io, dart:ffi and
6+ Flutter. Everything in `common/` is compiled by both, so it cannot mention
7+ either a `(:require [jolt.host])` at the top of a namespace is what keeps
8+ it out of the Android build, not anything about what the code does.
9+
10+ So this is the seam. Nothing here has an implementation; the backend installs
11+ one before anything else runs `frq.io.jolt` on the desktop side,
12+ `frq.io.dart` on the phone. A namespace under `common/` requires this and
13+ stays portable.
14+
15+ The functions are chosen by *intent* rather than by what either platform
16+ happens to call it. `write-private-file!` rather than a chmod, because Dart
17+ has no chmod and jolt has no `File.setPermissions`; `local-offset-seconds`
18+ rather than a zone name, because finding the zone is four platform-specific
19+ guesses on Linux and one property read on Android. Anywhere the seam names a
20+ mechanism instead of a result, one of the two sides ends up faking it."
21+ (:refer-clojure :exclude [slurp spit]))
22+
23+(defonce ^:private impl
24+ ;; Keyword fn. Empty until a backend installs into it, which is a load-time
25+ ;; effect of requiring `frq.io.jolt` or `frq.io.dart`.
26+ (atom {}))
27+
28+(defn install!
29+ "Register the host's answers. Called once, by the backend, before `-main`
30+ does anything see the require list of `frq.app` and of the Flutter entry
31+ point. Merges, so a backend may install in pieces."
32+ [m]
33+ (swap! impl merge m)
34+ nil)
35+
36+(defn installed?
37+ "Whether a backend has answered yet. For the entry points to assert on; the
38+ wrappers below throw on their own."
39+ []
40+ (boolean (seq @impl)))
41+
42+(defn- call
43+ [k args]
44+ (if-let [f (get @impl k)]
45+ (apply f args)
46+ (throw (ex-info (str "frq.io: no host installed for " k
47+ " — require frq.io.jolt (desktop) or frq.io.dart (android) first")
48+ {:op k}))))
49+
50+;; ------------------------------------------------------------ environment
51+
52+(defn getenv [n] (call :getenv [n]))
53+
54+(defn config-dir
55+ "The directory this client keeps its own files in, already frq-specific.
56+
57+ Not a rule about XDG: on Android there is no `HOME` and no config directory
58+ to be relative to, and what the platform hands back is the app's own storage.
59+ The caller wants somewhere to put `session.edn` and does not care which."
60+ []
61+ (call :config-dir []))
62+
63+;; ------------------------------------------------------------------ files
64+
65+(defn file-exists? [path] (call :file-exists? [path]))
66+(defn directory? [path] (call :directory? [path]))
67+(defn list-dir [path] (call :list-dir [path]))
68+(defn mkdirs! [path] (call :mkdirs! [path]))
69+(defn delete-file! [path] (call :delete-file! [path]))
70+
71+(defn slurp
72+ "The file as a string, or nil where it cannot be read. Shadows core's, which
73+ wants a JVM reader."
74+ [path]
75+ (call :slurp [path]))
76+
77+(defn spit
78+ "Write the string whole. True when it landed."
79+ [path s]
80+ (call :spit [path s]))
81+
82+(defn write-private-file!
83+ "`spit`, for a file nobody else may read — mode 600 where that means
84+ something. The broker token goes through this and nothing else does."
85+ [path s]
86+ (call :write-private-file! [path s]))
87+
88+;; ------------------------------------------------------------------- time
89+
90+(defn wall-nanos [] (call :wall-nanos []))
91+(defn mono-nanos [] (call :mono-nanos []))
92+
93+(defn local-offset-seconds
94+ "How far the reader's zone is from UTC at `epoch-secs`, DST included.
95+
96+ An instant rather than a constant: the offset moves twice a year, and a
97+ backlog read in November carries messages from August."
98+ [epoch-secs]
99+ (call :local-offset-seconds [epoch-secs]))
renamed common/frq/store.cljc +27 -29
similarity index 72%
rename from src/frq/store.clj
rename to common/frq/store.cljc
@@ -6,13 +6,13 @@
66 an ssh key, and never anywhere else. The web-token beside it is single-use
77 and deliberately not saved."
88 (:require [clojure.edn :as edn]
9- [clojure.string :as str]
10- [jolt.host :as host]))
9+ [frq.io :as io]))
1110
12-(defn config-dir []
13- (let [xdg (host/getenv "XDG_CONFIG_HOME")
14- home (host/getenv "HOME")]
15- (str (if (seq xdg) xdg (str home "/.config")) "/frq")))
11+(defn config-dir
12+ "Where this client's files go. The platform's answer, not a rule about XDG:
13+ see `frq.io/config-dir` — Android has no HOME to be relative to."
14+ []
15+ (io/config-dir))
1616
1717 (defn session-file [] (str (config-dir) "/session.edn"))
1818
@@ -21,9 +21,9 @@
2121 a stale credential is not worth an error at startup."
2222 []
2323 (let [path (session-file)]
24- (when (host/file-exists? path)
24+ (when (io/file-exists? path)
2525 (try
26- (let [m (edn/read-string (slurp path))]
26+ (let [m (edn/read-string (io/slurp path))]
2727 (when (and (map? m) (seq (:broker-token m))) m))
2828 (catch Exception _ nil)))))
2929
@@ -33,13 +33,13 @@
3333 (let [dir (config-dir)
3434 path (session-file)]
3535 (try
36- (host/mkdirs! dir)
37- ;; Created before it is written, so the token is never on disk
38- ;; world-readable even for an instant.
39- (host/sh (str "install -m 600 /dev/null '" path "'"))
40- (spit path (pr-str (select-keys session [:broker-token :handle :did :nick])))
41- (host/sh (str "chmod 600 '" path "'"))
42- true
36+ (io/mkdirs! dir)
37+ ;; Through the seam rather than a chmod: what matters is that nobody else
38+ ;; can read it, and each platform makes that guarantee its own way — the
39+ ;; desktop creates the file at mode 600 before writing a byte into it,
40+ ;; Android gets app-private storage from the system.
41+ (io/write-private-file!
42+ path (pr-str (select-keys session [:broker-token :handle :did :nick])))
4343 (catch Exception _ false))))
4444
4545 (defn channels-file [] (str (config-dir) "/channels.edn"))
@@ -50,9 +50,9 @@
5050 nothing writes this file any more."
5151 []
5252 (let [path (channels-file)]
53- (when (host/file-exists? path)
53+ (when (io/file-exists? path)
5454 (try
55- (let [v (edn/read-string (slurp path))]
55+ (let [v (edn/read-string (io/slurp path))]
5656 (when (vector? v) (filterv string? v)))
5757 (catch Exception _ nil)))))
5858
@@ -72,9 +72,9 @@
7272 with no marker, which is exactly what it knew."
7373 []
7474 (let [path (rooms-file)]
75- (or (when (host/file-exists? path)
75+ (or (when (io/file-exists? path)
7676 (try
77- (let [v (edn/read-string (slurp path))]
77+ (let [v (edn/read-string (io/slurp path))]
7878 (when (vector? v)
7979 (filterv #(and (map? %) (string? (:name %)) (seq (:name %))) v)))
8080 (catch Exception _ nil)))
@@ -85,15 +85,14 @@
8585 "Write the room records, most recently used first."
8686 [rooms]
8787 (try
88- (host/mkdirs! (config-dir))
89- (spit (rooms-file) (pr-str (vec rooms)))
90- true
88+ (io/mkdirs! (config-dir))
89+ (io/spit (rooms-file) (pr-str (vec rooms)))
9190 (catch Exception _ false)))
9291
9392 (defn clear-session! []
9493 (try
95- (when (host/file-exists? (session-file))
96- (host/delete-file! (session-file)))
94+ (when (io/file-exists? (session-file))
95+ (io/delete-file! (session-file)))
9796 true
9897 (catch Exception _ false)))
9998
@@ -106,9 +105,9 @@
106105 stopping the launch for."
107106 []
108107 (let [path (prefs-file)]
109- (or (when (host/file-exists? path)
108+ (or (when (io/file-exists? path)
110109 (try
111- (let [m (edn/read-string (slurp path))]
110+ (let [m (edn/read-string (io/slurp path))]
112111 (when (map? m) m))
113112 (catch Exception _ nil)))
114113 {})))
@@ -118,7 +117,6 @@
118117 the caller's job, and a partial write would silently drop the rest."
119118 [prefs]
120119 (try
121- (host/mkdirs! (config-dir))
122- (spit (prefs-file) (pr-str prefs))
123- true
120+ (io/mkdirs! (config-dir))
121+ (io/spit (prefs-file) (pr-str prefs))
124122 (catch Exception _ false)))
similarity index 72%
rename from src/frq/store.clj
rename to common/frq/store.cljc
@@ -6,13 +6,13 @@
6 an ssh key, and never anywhere else. The web-token beside it is single-use6 an ssh key, and never anywhere else. The web-token beside it is single-use
7 and deliberately not saved."7 and deliberately not saved."
8 (:require [clojure.edn :as edn]8 (:require [clojure.edn :as edn]
9- [clojure.string :as str]9+ [frq.io :as io]))
10- [jolt.host :as host]))
11 10
12-(defn config-dir []11+(defn config-dir
13- (let [xdg (host/getenv "XDG_CONFIG_HOME")12+ "Where this client's files go. The platform's answer, not a rule about XDG:
14- home (host/getenv "HOME")]13+ see `frq.io/config-dir` — Android has no HOME to be relative to."
15- (str (if (seq xdg) xdg (str home "/.config")) "/frq")))14+ []
15+ (io/config-dir))
16 16
17 (defn session-file [] (str (config-dir) "/session.edn"))17 (defn session-file [] (str (config-dir) "/session.edn"))
18 18
@@ -21,9 +21,9 @@
21 a stale credential is not worth an error at startup."21 a stale credential is not worth an error at startup."
22 []22 []
23 (let [path (session-file)]23 (let [path (session-file)]
24- (when (host/file-exists? path)24+ (when (io/file-exists? path)
25 (try25 (try
26- (let [m (edn/read-string (slurp path))]26+ (let [m (edn/read-string (io/slurp path))]
27 (when (and (map? m) (seq (:broker-token m))) m))27 (when (and (map? m) (seq (:broker-token m))) m))
28 (catch Exception _ nil)))))28 (catch Exception _ nil)))))
29 29
@@ -33,13 +33,13 @@
33 (let [dir (config-dir)33 (let [dir (config-dir)
34 path (session-file)]34 path (session-file)]
35 (try35 (try
36- (host/mkdirs! dir)36+ (io/mkdirs! dir)
37- ;; Created before it is written, so the token is never on disk37+ ;; Through the seam rather than a chmod: what matters is that nobody else
38- ;; world-readable even for an instant.38+ ;; can read it, and each platform makes that guarantee its own way — the
39- (host/sh (str "install -m 600 /dev/null '" path "'"))39+ ;; desktop creates the file at mode 600 before writing a byte into it,
40- (spit path (pr-str (select-keys session [:broker-token :handle :did :nick])))40+ ;; Android gets app-private storage from the system.
41- (host/sh (str "chmod 600 '" path "'"))41+ (io/write-private-file!
42- true42+ path (pr-str (select-keys session [:broker-token :handle :did :nick])))
43 (catch Exception _ false))))43 (catch Exception _ false))))
44 44
45 (defn channels-file [] (str (config-dir) "/channels.edn"))45 (defn channels-file [] (str (config-dir) "/channels.edn"))
@@ -50,9 +50,9 @@
50 nothing writes this file any more."50 nothing writes this file any more."
51 []51 []
52 (let [path (channels-file)]52 (let [path (channels-file)]
53- (when (host/file-exists? path)53+ (when (io/file-exists? path)
54 (try54 (try
55- (let [v (edn/read-string (slurp path))]55+ (let [v (edn/read-string (io/slurp path))]
56 (when (vector? v) (filterv string? v)))56 (when (vector? v) (filterv string? v)))
57 (catch Exception _ nil)))))57 (catch Exception _ nil)))))
58 58
@@ -72,9 +72,9 @@
72 with no marker, which is exactly what it knew."72 with no marker, which is exactly what it knew."
73 []73 []
74 (let [path (rooms-file)]74 (let [path (rooms-file)]
75- (or (when (host/file-exists? path)75+ (or (when (io/file-exists? path)
76 (try76 (try
77- (let [v (edn/read-string (slurp path))]77+ (let [v (edn/read-string (io/slurp path))]
78 (when (vector? v)78 (when (vector? v)
79 (filterv #(and (map? %) (string? (:name %)) (seq (:name %))) v)))79 (filterv #(and (map? %) (string? (:name %)) (seq (:name %))) v)))
80 (catch Exception _ nil)))80 (catch Exception _ nil)))
@@ -85,15 +85,14 @@
85 "Write the room records, most recently used first."85 "Write the room records, most recently used first."
86 [rooms]86 [rooms]
87 (try87 (try
88- (host/mkdirs! (config-dir))88+ (io/mkdirs! (config-dir))
89- (spit (rooms-file) (pr-str (vec rooms)))89+ (io/spit (rooms-file) (pr-str (vec rooms)))
90- true
91 (catch Exception _ false)))90 (catch Exception _ false)))
92 91
93 (defn clear-session! []92 (defn clear-session! []
94 (try93 (try
95- (when (host/file-exists? (session-file))94+ (when (io/file-exists? (session-file))
96- (host/delete-file! (session-file)))95+ (io/delete-file! (session-file)))
97 true96 true
98 (catch Exception _ false)))97 (catch Exception _ false)))
99 98
@@ -106,9 +105,9 @@
106 stopping the launch for."105 stopping the launch for."
107 []106 []
108 (let [path (prefs-file)]107 (let [path (prefs-file)]
109- (or (when (host/file-exists? path)108+ (or (when (io/file-exists? path)
110 (try109 (try
111- (let [m (edn/read-string (slurp path))]110+ (let [m (edn/read-string (io/slurp path))]
112 (when (map? m) m))111 (when (map? m) m))
113 (catch Exception _ nil)))112 (catch Exception _ nil)))
114 {})))113 {})))
@@ -118,7 +117,6 @@
118 the caller's job, and a partial write would silently drop the rest."117 the caller's job, and a partial write would silently drop the rest."
119 [prefs]118 [prefs]
120 (try119 (try
121- (host/mkdirs! (config-dir))120+ (io/mkdirs! (config-dir))
122- (spit (prefs-file) (pr-str prefs))121+ (io/spit (prefs-file) (pr-str prefs))
123- true
124 (catch Exception _ false)))122 (catch Exception _ false)))
modified deps.edn +33 -28
@@ -1,10 +1,18 @@
1-{:paths ["src"]
1+{;; Two source roots, and the split is the point. `common` is compiled by both
2+ ;; jolt and ClojureDart, so nothing in it may mention either — see
3+ ;; common/frq/io.cljc. `src` is the jolt half: glimmer, jolt.ffi, and the two
4+ ;; desktop backends. The Flutter half is flutter/src and is not on this
5+ ;; classpath at all; it has a deps.edn of its own, and it — not jolt — is
6+ ;; what builds the APK now.
7+ :paths ["common" "src"]
28
3- ;; glimmer owns the reactive half (ratom, components, reconciler);
4- ;; glimmer-jvui paints it as jvui, which is dvui's shape written in jolt on
5- ;; SDL3 — no shared object of its own, and no Rust anywhere under the UI.
6- ;; That is the whole reason for the switch off glimmer-vidya: libvidya meant
7- ;; jolt-native's Cargo workspace, and a Cargo workspace meant a build.
9+ ;; glimmer owns the reactive half (ratom, components, reconciler); which
10+ ;; backend paints it is the entry point's business and is not named here.
11+ ;; glimmer-cosmic lives inside jolt-native beside the libjoltcosmic.so it
12+ ;; binds, so it comes over with -Sdeps from whatever built that object rather
13+ ;; than from a git sha that could drift from it — the same reason glimmer-tui
14+ ;; is handed over rather than declared. There is no jvui and no vidya here
15+ ;; any more: both were experiments, and both are gone.
816 ;;
917 ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A
1018 ;; component unmounted between a cell firing and the queued render running
@@ -14,17 +22,11 @@
1422 ;; under it subscribed, so swapping the phone layout for the split one and
1523 ;; back shredded the chat: one component's props painted onto another's
1624 ;; widget.
17- :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"
18- :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"}
19- nandi/glimmer-jvui {:git/url "https://gitlab.com/nandithebull/jolt-native"
20- :git/sha "4e714e52a800a655efd76ac912be6d35b1909c23"
21- :deps/root "glimmer-backends/glimmer-jvui"}}
25+ :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"
26+ :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"}}
2227
23- ;; What the UI loads is SDL, and jvui declares it itself — SDL3, SDL3_ttf
24- ;; and SDL3_image, all :optional, so the layout tests run on a machine with
25- ;; none of them. Nothing of jolt-native's Cargo workspace is loaded by the
26- ;; window any more; `just tui` still takes libjolttui, which is the one
27- ;; object left and only for the terminal.
28+ ;; What the window loads is libjoltcosmic and what the terminal loads is
29+ ;; libjolttui; each recipe hands its own over, so neither is named here.
2830 ;;
2931 ;; Calls used to be the one thing this client could not do in jolt. The media
3032 ;; half is `frq.av.plane` now — MoQ over QUIC from libmoq_ffi, Opus from
@@ -79,22 +81,25 @@
7981 {:name "asound"
8082 :linux ["libasound.so.2"]}]
8183
82- :aliases {:frq {:main-opts ["-m" "frq.app"]}
83-
84- ;; The same screens in a terminal. glimmer-tui is the other backend
85- ;; for the same reconciler — libjolttui's tree ABI is libvidya's
86- ;; with cells under it — so `frq.tui` requires it after `frq.app`
87- ;; and renders the hiccup that is already written.
84+ ;; No :frq alias: `frq.app` is the screens and has no -main any more. There
85+ ;; is one entry point per backend and each needs its backend handed over, so
86+ ;; the recipes are the way in — `just cosmic` for the window, `just tui` for
87+ ;; the terminal.
88+ :aliases {;; The same screens in a terminal. glimmer-tui is one backend for
89+ ;; the reconciler and glimmer-cosmic is the other — the same tree
90+ ;; ABI with cells under it instead of libcosmic — so `frq.tui`
91+ ;; requires it after `frq.app` and renders the hiccup that is
92+ ;; already written.
8893 ;;
89- ;; A path rather than a sha, like glimmer-vidya's would be if this
90- ;; alias were the way in: `just tui` builds jolt-native out of the
91- ;; flake and hands both Jolt halves over with -Sdeps, so this is for
92- ;; a checkout beside the tree and nothing else reads it.
94+ ;; A path rather than a sha: `just tui` builds jolt-native out of
95+ ;; the flake and hands both Jolt halves over with -Sdeps, so this is
96+ ;; for a checkout beside the tree and nothing else reads it. The
97+ ;; window has no alias of its own for the same reason — see
98+ ;; `just cosmic`.
9399 ;;
94100 ;; just tui
95101 :tui {:extra-deps {nandi/glimmer-tui
96102 {:local/root "../jolt-native/glimmer-backends/glimmer-tui"}}
97103 :main-opts ["-m" "frq.tui"]}}
98104
99- :tasks {frq "jolt -M:frq"
100- tui "jolt -M:tui"}}
105+ :tasks {tui "jolt -M:tui"}}
@@ -1,10 +1,18 @@
1-{:paths ["src"]1+{;; Two source roots, and the split is the point. `common` is compiled by both
2+ ;; jolt and ClojureDart, so nothing in it may mention either — see
3+ ;; common/frq/io.cljc. `src` is the jolt half: glimmer, jolt.ffi, and the two
4+ ;; desktop backends. The Flutter half is flutter/src and is not on this
5+ ;; classpath at all; it has a deps.edn of its own, and it — not jolt — is
6+ ;; what builds the APK now.
7+ :paths ["common" "src"]
2 8
3- ;; glimmer owns the reactive half (ratom, components, reconciler);9+ ;; glimmer owns the reactive half (ratom, components, reconciler); which
4- ;; glimmer-jvui paints it as jvui, which is dvui's shape written in jolt on10+ ;; backend paints it is the entry point's business and is not named here.
5- ;; SDL3 — no shared object of its own, and no Rust anywhere under the UI.11+ ;; glimmer-cosmic lives inside jolt-native beside the libjoltcosmic.so it
6- ;; That is the whole reason for the switch off glimmer-vidya: libvidya meant12+ ;; binds, so it comes over with -Sdeps from whatever built that object rather
7- ;; jolt-native's Cargo workspace, and a Cargo workspace meant a build.13+ ;; than from a git sha that could drift from it — the same reason glimmer-tui
14+ ;; is handed over rather than declared. There is no jvui and no vidya here
15+ ;; any more: both were experiments, and both are gone.
8 ;;16 ;;
9 ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A17 ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A
10 ;; component unmounted between a cell firing and the queued render running18 ;; component unmounted between a cell firing and the queued render running
@@ -14,17 +22,11 @@
14 ;; under it subscribed, so swapping the phone layout for the split one and22 ;; under it subscribed, so swapping the phone layout for the split one and
15 ;; back shredded the chat: one component's props painted onto another's23 ;; back shredded the chat: one component's props painted onto another's
16 ;; widget.24 ;; widget.
17- :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"25+ :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"
18- :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"}26+ :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"}}
19- nandi/glimmer-jvui {:git/url "https://gitlab.com/nandithebull/jolt-native"
20- :git/sha "4e714e52a800a655efd76ac912be6d35b1909c23"
21- :deps/root "glimmer-backends/glimmer-jvui"}}
22 27
23- ;; What the UI loads is SDL, and jvui declares it itself — SDL3, SDL3_ttf28+ ;; What the window loads is libjoltcosmic and what the terminal loads is
24- ;; and SDL3_image, all :optional, so the layout tests run on a machine with29+ ;; libjolttui; each recipe hands its own over, so neither is named here.
25- ;; none of them. Nothing of jolt-native's Cargo workspace is loaded by the
26- ;; window any more; `just tui` still takes libjolttui, which is the one
27- ;; object left and only for the terminal.
28 ;;30 ;;
29 ;; Calls used to be the one thing this client could not do in jolt. The media31 ;; Calls used to be the one thing this client could not do in jolt. The media
30 ;; half is `frq.av.plane` now — MoQ over QUIC from libmoq_ffi, Opus from32 ;; half is `frq.av.plane` now — MoQ over QUIC from libmoq_ffi, Opus from
@@ -79,22 +81,25 @@
79 {:name "asound"81 {:name "asound"
80 :linux ["libasound.so.2"]}]82 :linux ["libasound.so.2"]}]
81 83
82- :aliases {:frq {:main-opts ["-m" "frq.app"]}84+ ;; No :frq alias: `frq.app` is the screens and has no -main any more. There
83-85+ ;; is one entry point per backend and each needs its backend handed over, so
84- ;; The same screens in a terminal. glimmer-tui is the other backend86+ ;; the recipes are the way in — `just cosmic` for the window, `just tui` for
85- ;; for the same reconciler — libjolttui's tree ABI is libvidya's87+ ;; the terminal.
86- ;; with cells under it — so `frq.tui` requires it after `frq.app`88+ :aliases {;; The same screens in a terminal. glimmer-tui is one backend for
87- ;; and renders the hiccup that is already written.89+ ;; the reconciler and glimmer-cosmic is the other — the same tree
90+ ;; ABI with cells under it instead of libcosmic — so `frq.tui`
91+ ;; requires it after `frq.app` and renders the hiccup that is
92+ ;; already written.
88 ;;93 ;;
89- ;; A path rather than a sha, like glimmer-vidya's would be if this94+ ;; A path rather than a sha: `just tui` builds jolt-native out of
90- ;; alias were the way in: `just tui` builds jolt-native out of the95+ ;; the flake and hands both Jolt halves over with -Sdeps, so this is
91- ;; flake and hands both Jolt halves over with -Sdeps, so this is for96+ ;; for a checkout beside the tree and nothing else reads it. The
92- ;; a checkout beside the tree and nothing else reads it.97+ ;; window has no alias of its own for the same reason — see
98+ ;; `just cosmic`.
93 ;;99 ;;
94 ;; just tui100 ;; just tui
95 :tui {:extra-deps {nandi/glimmer-tui101 :tui {:extra-deps {nandi/glimmer-tui
96 {:local/root "../jolt-native/glimmer-backends/glimmer-tui"}}102 {:local/root "../jolt-native/glimmer-backends/glimmer-tui"}}
97 :main-opts ["-m" "frq.tui"]}}103 :main-opts ["-m" "frq.tui"]}}
98 104
99- :tasks {frq "jolt -M:frq"105+ :tasks {tui "jolt -M:tui"}}
100- tui "jolt -M:tui"}}
modified flake.lock +6 -82
@@ -1,24 +1,5 @@
11 {
22 "nodes": {
3- "chez-src": {
4- "flake": false,
5- "locked": {
6- "lastModified": 1778520543,
7- "narHash": "sha256-/AQbCbjfIpZjgQJB2E1QXSOfcTGF24xfBDOyZDUWtO0=",
8- "ref": "refs/tags/v10.4.1",
9- "rev": "e95a7efbafa2cf3bd5343ea542e6bc909a7ab2c4",
10- "revCount": 2525,
11- "submodules": true,
12- "type": "git",
13- "url": "https://github.com/cisco/ChezScheme"
14- },
15- "original": {
16- "ref": "refs/tags/v10.4.1",
17- "submodules": true,
18- "type": "git",
19- "url": "https://github.com/cisco/ChezScheme"
20- }
21- },
223 "crane": {
234 "locked": {
245 "lastModified": 1788465171,
@@ -121,30 +102,6 @@
121102 "url": "https://gitlab.com/nandithebull/glimmer"
122103 }
123104 },
124- "jolt-android-src": {
125- "inputs": {
126- "makes": "makes",
127- "nixpkgs": [
128- "nixpkgs"
129- ]
130- },
131- "locked": {
132- "lastModified": 1788137223,
133- "narHash": "sha256-VeHi864PEtZoHRNA7ms9uaJb6+7nHvDyRXqLLPQ1tao=",
134- "ref": "refs/heads/main",
135- "rev": "2b80d68d1f7a31ba92b208b3957e5fb555617ada",
136- "revCount": 2742,
137- "submodules": true,
138- "type": "git",
139- "url": "https://gitlab.com/nandithebull/jolt"
140- },
141- "original": {
142- "rev": "2b80d68d1f7a31ba92b208b3957e5fb555617ada",
143- "submodules": true,
144- "type": "git",
145- "url": "https://gitlab.com/nandithebull/jolt"
146- }
147- },
148105 "jolt-native": {
149106 "inputs": {
150107 "crane": "crane",
@@ -155,36 +112,23 @@
155112 "rust-overlay": "rust-overlay"
156113 },
157114 "locked": {
158- "lastModified": 1789094762,
159- "narHash": "sha256-frw18DbDI287CaqJRx+ns419AkKGNfarVR4LtUVJhXY=",
115+ "lastModified": 1789172631,
116+ "narHash": "sha256-V4mOTZIHqUW0NKd83Iq9N8YlY5JkSakOyYJQcr1BEMc=",
160117 "ref": "refs/heads/main",
161- "rev": "b48e404b7a731fdd2d303624a0681b7cfcbb93fa",
162- "revCount": 101,
118+ "rev": "4706c920e45ce80b11ee106d05c16d9eacc99fc7",
119+ "revCount": 107,
163120 "type": "git",
164121 "url": "https://gitlab.com/nandithebull/jolt-native"
165122 },
166123 "original": {
167- "rev": "b48e404b7a731fdd2d303624a0681b7cfcbb93fa",
124+ "rev": "4706c920e45ce80b11ee106d05c16d9eacc99fc7",
168125 "type": "git",
169126 "url": "https://gitlab.com/nandithebull/jolt-native"
170127 }
171128 },
172- "jolt-native-android": {
173- "flake": false,
174- "locked": {
175- "lastModified": 1789098143,
176- "narHash": "sha256-HgVSQR2XiS3De/gP9Z1JZ2Tqj4yPzzN6mx2Xyp6DuRg=",
177- "type": "tarball",
178- "url": "https://gitlab.com/api/v4/projects/nandithebull%2Fjolt-native/packages/generic/jolt-native/latest/android-arm64-v8a.tar.gz"
179- },
180- "original": {
181- "type": "tarball",
182- "url": "https://gitlab.com/api/v4/projects/nandithebull%2Fjolt-native/packages/generic/jolt-native/latest/android-arm64-v8a.tar.gz"
183- }
184- },
185129 "jolt-src": {
186130 "inputs": {
187- "makes": "makes_2",
131+ "makes": "makes",
188132 "nixpkgs": [
189133 "nixpkgs"
190134 ],
@@ -228,23 +172,6 @@
228172 "type": "github"
229173 }
230174 },
231- "makes_2": {
232- "flake": false,
233- "locked": {
234- "lastModified": 1785480273,
235- "narHash": "sha256-/g8O4PW0v3NNChKyrWw/EqpEAUCKiI9/KFE2EiXoUlA=",
236- "owner": "makeplus",
237- "repo": "makes",
238- "rev": "d14cb578c2f04e6f9d00e01c7c4e416a9baf94e9",
239- "type": "github"
240- },
241- "original": {
242- "owner": "makeplus",
243- "repo": "makes",
244- "rev": "d14cb578c2f04e6f9d00e01c7c4e416a9baf94e9",
245- "type": "github"
246- }
247- },
248175 "nix-appimage": {
249176 "inputs": {
250177 "flake-compat": "flake-compat",
@@ -306,11 +233,8 @@
306233 },
307234 "root": {
308235 "inputs": {
309- "chez-src": "chez-src",
310236 "glimmer": "glimmer",
311- "jolt-android-src": "jolt-android-src",
312237 "jolt-native": "jolt-native",
313- "jolt-native-android": "jolt-native-android",
314238 "jolt-src": "jolt-src",
315239 "nix-appimage": "nix-appimage",
316240 "nixgl": "nixgl",
@@ -1,24 +1,5 @@
1 {1 {
2 "nodes": {2 "nodes": {
3- "chez-src": {
4- "flake": false,
5- "locked": {
6- "lastModified": 1778520543,
7- "narHash": "sha256-/AQbCbjfIpZjgQJB2E1QXSOfcTGF24xfBDOyZDUWtO0=",
8- "ref": "refs/tags/v10.4.1",
9- "rev": "e95a7efbafa2cf3bd5343ea542e6bc909a7ab2c4",
10- "revCount": 2525,
11- "submodules": true,
12- "type": "git",
13- "url": "https://github.com/cisco/ChezScheme"
14- },
15- "original": {
16- "ref": "refs/tags/v10.4.1",
17- "submodules": true,
18- "type": "git",
19- "url": "https://github.com/cisco/ChezScheme"
20- }
21- },
22 "crane": {3 "crane": {
23 "locked": {4 "locked": {
24 "lastModified": 1788465171,5 "lastModified": 1788465171,
@@ -121,30 +102,6 @@
121 "url": "https://gitlab.com/nandithebull/glimmer"102 "url": "https://gitlab.com/nandithebull/glimmer"
122 }103 }
123 },104 },
124- "jolt-android-src": {
125- "inputs": {
126- "makes": "makes",
127- "nixpkgs": [
128- "nixpkgs"
129- ]
130- },
131- "locked": {
132- "lastModified": 1788137223,
133- "narHash": "sha256-VeHi864PEtZoHRNA7ms9uaJb6+7nHvDyRXqLLPQ1tao=",
134- "ref": "refs/heads/main",
135- "rev": "2b80d68d1f7a31ba92b208b3957e5fb555617ada",
136- "revCount": 2742,
137- "submodules": true,
138- "type": "git",
139- "url": "https://gitlab.com/nandithebull/jolt"
140- },
141- "original": {
142- "rev": "2b80d68d1f7a31ba92b208b3957e5fb555617ada",
143- "submodules": true,
144- "type": "git",
145- "url": "https://gitlab.com/nandithebull/jolt"
146- }
147- },
148 "jolt-native": {105 "jolt-native": {
149 "inputs": {106 "inputs": {
150 "crane": "crane",107 "crane": "crane",
@@ -155,36 +112,23 @@
155 "rust-overlay": "rust-overlay"112 "rust-overlay": "rust-overlay"
156 },113 },
157 "locked": {114 "locked": {
158- "lastModified": 1789094762,115+ "lastModified": 1789172631,
159- "narHash": "sha256-frw18DbDI287CaqJRx+ns419AkKGNfarVR4LtUVJhXY=",116+ "narHash": "sha256-V4mOTZIHqUW0NKd83Iq9N8YlY5JkSakOyYJQcr1BEMc=",
160 "ref": "refs/heads/main",117 "ref": "refs/heads/main",
161- "rev": "b48e404b7a731fdd2d303624a0681b7cfcbb93fa",118+ "rev": "4706c920e45ce80b11ee106d05c16d9eacc99fc7",
162- "revCount": 101,119+ "revCount": 107,
163 "type": "git",120 "type": "git",
164 "url": "https://gitlab.com/nandithebull/jolt-native"121 "url": "https://gitlab.com/nandithebull/jolt-native"
165 },122 },
166 "original": {123 "original": {
167- "rev": "b48e404b7a731fdd2d303624a0681b7cfcbb93fa",124+ "rev": "4706c920e45ce80b11ee106d05c16d9eacc99fc7",
168 "type": "git",125 "type": "git",
169 "url": "https://gitlab.com/nandithebull/jolt-native"126 "url": "https://gitlab.com/nandithebull/jolt-native"
170 }127 }
171 },128 },
172- "jolt-native-android": {
173- "flake": false,
174- "locked": {
175- "lastModified": 1789098143,
176- "narHash": "sha256-HgVSQR2XiS3De/gP9Z1JZ2Tqj4yPzzN6mx2Xyp6DuRg=",
177- "type": "tarball",
178- "url": "https://gitlab.com/api/v4/projects/nandithebull%2Fjolt-native/packages/generic/jolt-native/latest/android-arm64-v8a.tar.gz"
179- },
180- "original": {
181- "type": "tarball",
182- "url": "https://gitlab.com/api/v4/projects/nandithebull%2Fjolt-native/packages/generic/jolt-native/latest/android-arm64-v8a.tar.gz"
183- }
184- },
185 "jolt-src": {129 "jolt-src": {
186 "inputs": {130 "inputs": {
187- "makes": "makes_2",131+ "makes": "makes",
188 "nixpkgs": [132 "nixpkgs": [
189 "nixpkgs"133 "nixpkgs"
190 ],134 ],
@@ -228,23 +172,6 @@
228 "type": "github"172 "type": "github"
229 }173 }
230 },174 },
231- "makes_2": {
232- "flake": false,
233- "locked": {
234- "lastModified": 1785480273,
235- "narHash": "sha256-/g8O4PW0v3NNChKyrWw/EqpEAUCKiI9/KFE2EiXoUlA=",
236- "owner": "makeplus",
237- "repo": "makes",
238- "rev": "d14cb578c2f04e6f9d00e01c7c4e416a9baf94e9",
239- "type": "github"
240- },
241- "original": {
242- "owner": "makeplus",
243- "repo": "makes",
244- "rev": "d14cb578c2f04e6f9d00e01c7c4e416a9baf94e9",
245- "type": "github"
246- }
247- },
248 "nix-appimage": {175 "nix-appimage": {
249 "inputs": {176 "inputs": {
250 "flake-compat": "flake-compat",177 "flake-compat": "flake-compat",
@@ -306,11 +233,8 @@
306 },233 },
307 "root": {234 "root": {
308 "inputs": {235 "inputs": {
309- "chez-src": "chez-src",
310 "glimmer": "glimmer",236 "glimmer": "glimmer",
311- "jolt-android-src": "jolt-android-src",
312 "jolt-native": "jolt-native",237 "jolt-native": "jolt-native",
313- "jolt-native-android": "jolt-native-android",
314 "jolt-src": "jolt-src",238 "jolt-src": "jolt-src",
315 "nix-appimage": "nix-appimage",239 "nix-appimage": "nix-appimage",
316 "nixgl": "nixgl",240 "nixgl": "nixgl",
modified flake.nix +36 -151
@@ -28,27 +28,22 @@
2828 # its own inputs are locked with ours rather than left to float, and
2929 # `vendor/` comes along as the submodule the build needs.
3030 #
31- # The fork rather than jolt-lang/jolt: it is what jolt-android-src already
32- # pins for the boot image, and a desktop runtime built from a different
33- # tree than the APK's is the same drift the jolt-native comment warns
34- # about. Unpinned here — the desktop follows the fork's main, while the
35- # APK stays on the rev below.
31+ # The fork rather than jolt-lang/jolt, and unpinned: the desktop follows
32+ # the fork's main. It used to be paired with a second, pinned input for
33+ # the APK's boot image; there is no jolt APK now, so there is one runtime
34+ # and one rev.
3635 jolt-src = {
3736 url = "git+https://gitlab.com/nandithebull/jolt?submodules=1";
3837 inputs.nixpkgs.follows = "nixpkgs";
3938 };
4039
4140 # The source half of jolt-native: the Jolt code under glimmer-backends/ that
42- # binds the native objects, and the flake that builds the desktop ones. The
43- # Android objects no longer come from here — jolt-native-android below
44- # fetches those prebuilt — so this input is what `just run` builds against
45- # and what an APK's Clojure side is read from, both at this rev.
41+ # binds the native objects, and the flake that builds them. This input is
42+ # what `just run` builds against.
4643 #
47- # It carries jvui and glimmer-jvui — the toolkit the window is painted
48- # with now — and, from no-moq-deps, the dependency split and the
49- # JOLT_WITHOUT_MOQ guard on the Android glue. Both were on the
50- # `jvui-for-frq` branch while they were being written and are merged into
51- # main now, which is why this names a rev on main again.
44+ # It carries both backends that are left — glimmer-cosmic over
45+ # libjoltcosmic for the window, glimmer-tui over libjolttui for the
46+ # terminal — and no longer jvui or vidya, which were experiments.
5247 #
5348 # Pinned all the same, and pinned to a rev, because an
5449 # unpinned `main` is a build whose native half is free to sit at a
@@ -61,47 +56,10 @@
6156 # drift this comment warns about wearing a different hat: one input, and
6257 # the window and the terminal are the same library either way.
6358 jolt-native = {
64- url = "git+https://gitlab.com/nandithebull/jolt-native?rev=b48e404b7a731fdd2d303624a0681b7cfcbb93fa";
59+ url = "git+https://gitlab.com/nandithebull/jolt-native?rev=4706c920e45ce80b11ee106d05c16d9eacc99fc7";
6560 inputs.nixpkgs.follows = "nixpkgs";
6661 };
6762
68- # The Android objects, prebuilt by jolt-native's CI rather than compiled
69- # here: an APK needs libvidya and libjoltmoq for arm64, and building them
70- # locally means an NDK, a Rust cross toolchain and the whole crane graph
71- # for two files that upstream already built and published.
72- #
73- # "latest" is the version its CI overwrites on every default-branch build,
74- # so this input finds a new one on `nix flake update` -- but flake.lock
75- # still records exactly which bytes an APK was built from, which is the
76- # pin that matters. `just bump` decides when to move; this only decides
77- # where to look. The archive is rooted at include/ and lib/arm64-v8a/, so
78- # nothing here has to unpack it.
79- jolt-native-android = {
80- url = "https://gitlab.com/api/v4/projects/nandithebull%2Fjolt-native/packages/generic/jolt-native/latest/android-arm64-v8a.tar.gz";
81- flake = false;
82- };
83-
84- # Chez itself, because the APK needs a cross target nixpkgs does not
85- # build: frq's Scheme is compiled to an arm64 boot image, and that wants
86- # Chez's own `tarm64le` workarea — boot files, xpatch and libkernel.a.
87- # The version is the one the hand-built tree under ~/.cache used, and the
88- # submodules are not optional (zuo builds it, lz4 and zlib link into it).
89- # The same fork jolt-src takes, built here rather than fetched as a
90- # release binary: upstream reads the socket address out of `struct
91- # addrinfo` at glibc's offset, which on Bionic is `ai_canonname`, so an APK
92- # built with upstream cannot open a TLS connection at all. Pinned to a rev
93- # where jolt-src is not: the APK is a release artefact, so its runtime
94- # moves when `just bump` says so rather than when the fork does.
95- jolt-android-src = {
96- url = "git+https://gitlab.com/nandithebull/jolt?rev=2b80d68d1f7a31ba92b208b3957e5fb555617ada&submodules=1";
97- inputs.nixpkgs.follows = "nixpkgs";
98- };
99-
100- chez-src = {
101- url = "git+https://github.com/cisco/ChezScheme?ref=refs/tags/v10.4.1&submodules=1";
102- flake = false;
103- };
104-
10563 # The sha deps.edn pins, on the fork with the reconciler fixes.
10664 glimmer = {
10765 url = "git+https://gitlab.com/nandithebull/glimmer?rev=399df371c790d690fb6e4560c3d4d7f838502857";
@@ -122,7 +80,7 @@
12280 };
12381 };
12482
125- outputs = { self, nixpkgs, jolt-src, jolt-native, jolt-native-android, glimmer, chez-src, jolt-android-src, nixgl, nix-appimage }:
83+ outputs = { self, nixpkgs, jolt-src, jolt-native, glimmer, nixgl, nix-appimage }:
12684 let
12785 systems = [ "x86_64-linux" "aarch64-linux" ];
12886 forEachSystem = f:
@@ -203,7 +161,11 @@
203161 let np = jolt-native.packages.${pkgs.stdenv.hostPlatform.system};
204162 in pkgs.symlinkJoin {
205163 name = "jolt-native-ui";
206- paths = [ np.libjolttui ];
164+ # Both backends there are. libjoltcosmic is the window —
165+ # libcosmic behind the same retained-tree ABI — and libjolttui
166+ # is the terminal. Neither is libvidya and neither is jvui:
167+ # those were experiments and are gone from this tree entirely.
168+ paths = [ np.libjolttui np.libjoltcosmic ];
207169 };
208170
209171 # libmoq_ffi — MoQ over QUIC behind UniFFI's C ABI, FETCHED rather
@@ -301,14 +263,10 @@
301263 # openh264 is here for frqH264's DT_NEEDED; alsa-lib for capture
302264 # and playback. V4L2 needs nothing: it is ioctls against libc and
303265 # the kernel, so there is no library to name.
304- # SDL is what the UI is now: jvui declares SDL3, SDL3_ttf and
305- # SDL3_image in its own :jolt/native and dlopens them by soname,
306- # so they have to be somewhere the loader looks. sdl3-image keeps
307- # its library in a separate `lib` output — the default one holds
308- # only share/, which is an afternoon nobody needs to repeat.
309- sdl = [ pkgs.sdl3 pkgs.sdl3-ttf (pkgs.sdl3-image.lib or pkgs.sdl3-image) ];
310-
311- codecs = [ pkgs.libopus pkgs.openh264 frqH264 pkgs.alsa-lib ] ++ sdl;
266+ # No SDL any more: it was jvui's, declared in jvui's own
267+ # :jolt/native and dlopened by soname. libcosmic paints through wgpu
268+ # and takes what it needs from `runtimeLibs` instead.
269+ codecs = [ pkgs.libopus pkgs.openh264 frqH264 pkgs.alsa-lib ];
312270
313271 # ALSA's PipeWire plugin, which is how `default` resolves to
314272 # anything on a machine running PipeWire — and every machine frq
@@ -320,35 +278,17 @@
320278 # alsa-lib looks plugins up by directory, not by soname.
321279 alsaPluginDir = "${pkgs.pipewire}/lib/alsa-lib";
322280
323- # The faces jvui draws with, from here rather than from whatever
324- # the host happens to have installed. jvui hunts a list of the
325- # usual system paths and, failing that, draws the missing-glyph
326- # box — which is what "the glyphs are broke" has been every time
327- # it has come up. An Arch container has NotoColorEmoji and no
328- # monochrome Noto Emoji, and NotoColorEmoji is a bitmap face with
329- # one 128-pixel strike that jvui rejects on purpose, so the chips
330- # in a message row had nothing left to be drawn from.
331- #
332- # NotoEmoji is the outline companion to NotoColorEmoji: scalable,
333- # monochrome, and full coverage of the emoji the chrome uses.
334- # Symbols2 behind it for the arrows and technical marks that are
335- # not emoji at all.
336- uiFont = "${pkgs.noto-fonts}/share/fonts/noto/NotoSans.ttf";
337- fallbackFonts = lib.concatStringsSep ":" [
338- "${pkgs.noto-fonts-monochrome-emoji}/share/fonts/noto/NotoEmoji.ttf"
339- "${pkgs.noto-fonts}/share/fonts/noto/NotoSansSymbols2-Regular.otf"
340- "${pkgs.noto-fonts}/share/fonts/noto/NotoSansSymbols.ttf"
341- ];
342-
343281 nativeAll = pkgs.symlinkJoin {
344282 name = "frq-native";
345283 paths = [ native moqFfi ] ++ codecs;
346284 };
347285
348286 # Jolt itself: Clojure on Chez, built the way its own flake builds it.
349- # A function, because there are two of them — upstream for the
350- # desktop, and the Bionic-addrinfo fork for the boot image the APK
351- # carries. Nothing else about the build differs.
287+ #
288+ # Still a function taking its source, though there is only one of
289+ # them now: the second was the Bionic-addrinfo fork the APK's boot
290+ # image carried, and there is no jolt APK any more — the phone is
291+ # ClojureDart and Flutter, and jolt does not run there at all.
352292 joltFrom = src: pkgs.stdenv.mkDerivation {
353293 pname = "jolt";
354294 version = "dev";
@@ -395,17 +335,11 @@
395335 };
396336
397337 joltRuntime = joltFrom jolt-src;
398- joltAndroid = joltFrom jolt-android-src;
399-
400- # glimmer-vidya lives inside the jolt-native checkout, and its own
401- # deps.edn asks for glimmer by git — the top-level override below
402- # answers for both.
403- # glimmer-jvui and the toolkit it is a backend for. TWO paths and
404- # not one: glimmer-jvui's own deps.edn names jvui by :local/root,
405- # a relative path that means nothing once nix has copied the
406- # subtree, so the -Sdeps below has to name both.
407- glimmerJvui = "${jolt-native}/glimmer-backends/glimmer-jvui";
408- jvui = "${jolt-native}/jvui";
338+
339+ # The backends' Clojure halves, which live inside the jolt-native
340+ # checkout beside the objects they bind. Their own deps.edn asks for
341+ # glimmer by git — the top-level override below answers for both.
342+ glimmerCosmic = "${jolt-native}/glimmer-backends/glimmer-cosmic";
409343 glimmerTui = "${jolt-native}/glimmer-backends/glimmer-tui";
410344
411345 runtimeLibs = runtimeLibsFor pkgs;
@@ -413,7 +347,7 @@
413347 # The project as jolt sees it: source, deps.edn, nothing else.
414348 frqSource = pkgs.runCommand "frq-source" { } ''
415349 mkdir -p "$out"
416- cp -r ${self}/src ${self}/deps.edn "$out/"
350+ cp -r ${self}/common ${self}/src ${self}/deps.edn "$out/"
417351 '';
418352
419353 # Jolt resolves deps.edn from the working directory, so the launcher
@@ -423,8 +357,6 @@
423357 frqScript = pkgs.writeShellScript "frq" ''
424358 export LD_LIBRARY_PATH="${nativeAll}/lib:${lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
425359 export ALSA_PLUGIN_DIR="${alsaPluginDir}"
426- export JVUI_FONT="${uiFont}"
427- export JVUI_FALLBACK_FONTS="${fallbackFonts}"
428360 cd ${frqSource}
429361
430362 # On NixOS the store's Mesa is the system's and the window opens.
@@ -434,8 +366,8 @@
434366 [ -e /run/current-system ] || runner="${nixGL}/bin/nixGLIntel"
435367
436368 exec ''${runner} ${joltRuntime}/bin/jolt \
437- -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-jvui {:local/root "${glimmerJvui}"} jvui/jvui {:local/root "${jvui}"}}}' \
438- -M:frq "$@"
369+ -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-cosmic {:local/root "${glimmerCosmic}"}}}' \
370+ -m frq.cosmic "$@"
439371 '';
440372
441373 # The same source, the other backend. No GL, no nixGL and no X11 —
@@ -447,7 +379,7 @@
447379 cd ${frqSource}
448380
449381 exec ${joltRuntime}/bin/jolt \
450- -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-jvui {:local/root "${glimmerJvui}"} jvui/jvui {:local/root "${jvui}"} nandi/glimmer-tui {:local/root "${glimmerTui}"}}}' \
382+ -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-tui {:local/root "${glimmerTui}"}}}' \
451383 -m frq.tui "$@"
452384 '';
453385
@@ -476,34 +408,6 @@
476408 mkdir -p "$out/bin"
477409 ln -s ${frqScript} "$out/bin/frq"
478410 '';
479- # --- Android ------------------------------------------------------
480- # The SDK and the NDK are Google's, which means unfree and a licence
481- # to accept — so this is its own import of nixpkgs rather than the
482- # `legacyPackages` everything above uses. Confined to the Android
483- # outputs: `nix build` of frq itself never evaluates it.
484- #
485- # The NDK here is r29, which is the one the pinned libvidya was
486- # built with.
487- androidPkgs = import nixpkgs {
488- inherit (pkgs.stdenv.hostPlatform) system;
489- config = {
490- allowUnfree = true;
491- android_sdk.accept_license = true;
492- };
493- };
494-
495- androidComposition = androidPkgs.androidenv.composeAndroidPackages {
496- buildToolsVersions = [ "36.0.0" ];
497- platformVersions = [ "36" ];
498- includeNDK = true;
499- };
500-
501- android = import ./nix/android.nix {
502- inherit pkgs self chez-src jolt-native jolt-native-android glimmer joltAndroid;
503- inherit (pkgs) lib;
504- androidSdk = androidComposition.androidsdk;
505- ndk = androidComposition.ndk-bundle;
506- };
507411 in
508412 {
509413 inherit native moqFfi frqH264 nativeAll frq;
@@ -518,13 +422,6 @@
518422 # nixGL needs a store Mesa to put the host's driver in front of.
519423 appimage =
520424 nix-appimage.bundlers.${pkgs.stdenv.hostPlatform.system}.default frq;
521- }
522- # An APK is built by a linux-x86_64 NDK and a linux-x86_64 jolt, and
523- # Google ships no other; on aarch64 the Android outputs are simply
524- # absent rather than present and broken.
525- // lib.optionalAttrs (pkgs.stdenv.hostPlatform.system == "x86_64-linux") {
526- inherit (android) apk chezAndroid joltBoot libjoltapp;
527- apk-unsigned = android.apk-unsigned;
528425 });
529426
530427 # Where `just run` runs, and — because entering it realises what it
@@ -575,19 +472,8 @@
575472 # is a different `let`. See `alsaPluginDir` there for why.
576473 ALSA_PLUGIN_DIR = "${pkgs.pipewire}/lib/alsa-lib";
577474 GLIMMER_SRC = glimmer;
578- GLIMMER_JVUI_SRC = "${jolt-native}/glimmer-backends/glimmer-jvui";
579- JVUI_SRC = "${jolt-native}/jvui";
475+ GLIMMER_COSMIC_SRC = "${jolt-native}/glimmer-backends/glimmer-cosmic";
580476 GLIMMER_TUI_SRC = "${jolt-native}/glimmer-backends/glimmer-tui";
581- # See `uiFont` in the packages block for why these are named
582- # here rather than left to whatever the host has installed.
583- # Spelled out again for the same reason ALSA_PLUGIN_DIR is: a
584- # different `let`.
585- JVUI_FONT = "${pkgs.noto-fonts}/share/fonts/noto/NotoSans.ttf";
586- JVUI_FALLBACK_FONTS = lib.concatStringsSep ":" [
587- "${pkgs.noto-fonts-monochrome-emoji}/share/fonts/noto/NotoEmoji.ttf"
588- "${pkgs.noto-fonts}/share/fonts/noto/NotoSansSymbols2-Regular.otf"
589- "${pkgs.noto-fonts}/share/fonts/noto/NotoSansSymbols.ttf"
590- ];
591477 FRQ_LIB_PATH = lib.makeLibraryPath (runtimeLibsFor pkgs);
592478 NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel";
593479
@@ -637,8 +523,7 @@
637523 FRQ_JOLT_NATIVE="$(cd "$FRQ_JOLT_NATIVE" && pwd)"
638524 export FRQ_JOLT_NATIVE
639525 export GLIMMER_TUI_SRC="$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-tui"
640- export GLIMMER_JVUI_SRC="$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-jvui"
641- export JVUI_SRC="$FRQ_JOLT_NATIVE/jvui"
526+ export GLIMMER_COSMIC_SRC="$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-cosmic"
642527 # First, so a cargo build wins over the pin's copy of the
643528 # same object. The rest of the pin's lib directory is still
644529 # behind it.
@@ -28,27 +28,22 @@
28 # its own inputs are locked with ours rather than left to float, and28 # its own inputs are locked with ours rather than left to float, and
29 # `vendor/` comes along as the submodule the build needs.29 # `vendor/` comes along as the submodule the build needs.
30 #30 #
31- # The fork rather than jolt-lang/jolt: it is what jolt-android-src already31+ # The fork rather than jolt-lang/jolt, and unpinned: the desktop follows
32- # pins for the boot image, and a desktop runtime built from a different32+ # the fork's main. It used to be paired with a second, pinned input for
33- # tree than the APK's is the same drift the jolt-native comment warns33+ # the APK's boot image; there is no jolt APK now, so there is one runtime
34- # about. Unpinned here — the desktop follows the fork's main, while the34+ # and one rev.
35- # APK stays on the rev below.
36 jolt-src = {35 jolt-src = {
37 url = "git+https://gitlab.com/nandithebull/jolt?submodules=1";36 url = "git+https://gitlab.com/nandithebull/jolt?submodules=1";
38 inputs.nixpkgs.follows = "nixpkgs";37 inputs.nixpkgs.follows = "nixpkgs";
39 };38 };
40 39
41 # The source half of jolt-native: the Jolt code under glimmer-backends/ that40 # The source half of jolt-native: the Jolt code under glimmer-backends/ that
42- # binds the native objects, and the flake that builds the desktop ones. The41+ # binds the native objects, and the flake that builds them. This input is
43- # Android objects no longer come from here — jolt-native-android below42+ # what `just run` builds against.
44- # fetches those prebuilt — so this input is what `just run` builds against
45- # and what an APK's Clojure side is read from, both at this rev.
46 #43 #
47- # It carries jvui and glimmer-jvui — the toolkit the window is painted44+ # It carries both backends that are left — glimmer-cosmic over
48- # with now — and, from no-moq-deps, the dependency split and the45+ # libjoltcosmic for the window, glimmer-tui over libjolttui for the
49- # JOLT_WITHOUT_MOQ guard on the Android glue. Both were on the46+ # terminal — and no longer jvui or vidya, which were experiments.
50- # `jvui-for-frq` branch while they were being written and are merged into
51- # main now, which is why this names a rev on main again.
52 #47 #
53 # Pinned all the same, and pinned to a rev, because an48 # Pinned all the same, and pinned to a rev, because an
54 # unpinned `main` is a build whose native half is free to sit at a49 # unpinned `main` is a build whose native half is free to sit at a
@@ -61,47 +56,10 @@
61 # drift this comment warns about wearing a different hat: one input, and56 # drift this comment warns about wearing a different hat: one input, and
62 # the window and the terminal are the same library either way.57 # the window and the terminal are the same library either way.
63 jolt-native = {58 jolt-native = {
64- url = "git+https://gitlab.com/nandithebull/jolt-native?rev=b48e404b7a731fdd2d303624a0681b7cfcbb93fa";59+ url = "git+https://gitlab.com/nandithebull/jolt-native?rev=4706c920e45ce80b11ee106d05c16d9eacc99fc7";
65 inputs.nixpkgs.follows = "nixpkgs";60 inputs.nixpkgs.follows = "nixpkgs";
66 };61 };
67 62
68- # The Android objects, prebuilt by jolt-native's CI rather than compiled
69- # here: an APK needs libvidya and libjoltmoq for arm64, and building them
70- # locally means an NDK, a Rust cross toolchain and the whole crane graph
71- # for two files that upstream already built and published.
72- #
73- # "latest" is the version its CI overwrites on every default-branch build,
74- # so this input finds a new one on `nix flake update` -- but flake.lock
75- # still records exactly which bytes an APK was built from, which is the
76- # pin that matters. `just bump` decides when to move; this only decides
77- # where to look. The archive is rooted at include/ and lib/arm64-v8a/, so
78- # nothing here has to unpack it.
79- jolt-native-android = {
80- url = "https://gitlab.com/api/v4/projects/nandithebull%2Fjolt-native/packages/generic/jolt-native/latest/android-arm64-v8a.tar.gz";
81- flake = false;
82- };
83-
84- # Chez itself, because the APK needs a cross target nixpkgs does not
85- # build: frq's Scheme is compiled to an arm64 boot image, and that wants
86- # Chez's own `tarm64le` workarea — boot files, xpatch and libkernel.a.
87- # The version is the one the hand-built tree under ~/.cache used, and the
88- # submodules are not optional (zuo builds it, lz4 and zlib link into it).
89- # The same fork jolt-src takes, built here rather than fetched as a
90- # release binary: upstream reads the socket address out of `struct
91- # addrinfo` at glibc's offset, which on Bionic is `ai_canonname`, so an APK
92- # built with upstream cannot open a TLS connection at all. Pinned to a rev
93- # where jolt-src is not: the APK is a release artefact, so its runtime
94- # moves when `just bump` says so rather than when the fork does.
95- jolt-android-src = {
96- url = "git+https://gitlab.com/nandithebull/jolt?rev=2b80d68d1f7a31ba92b208b3957e5fb555617ada&submodules=1";
97- inputs.nixpkgs.follows = "nixpkgs";
98- };
99-
100- chez-src = {
101- url = "git+https://github.com/cisco/ChezScheme?ref=refs/tags/v10.4.1&submodules=1";
102- flake = false;
103- };
104-
105 # The sha deps.edn pins, on the fork with the reconciler fixes.63 # The sha deps.edn pins, on the fork with the reconciler fixes.
106 glimmer = {64 glimmer = {
107 url = "git+https://gitlab.com/nandithebull/glimmer?rev=399df371c790d690fb6e4560c3d4d7f838502857";65 url = "git+https://gitlab.com/nandithebull/glimmer?rev=399df371c790d690fb6e4560c3d4d7f838502857";
@@ -122,7 +80,7 @@
122 };80 };
123 };81 };
124 82
125- outputs = { self, nixpkgs, jolt-src, jolt-native, jolt-native-android, glimmer, chez-src, jolt-android-src, nixgl, nix-appimage }:83+ outputs = { self, nixpkgs, jolt-src, jolt-native, glimmer, nixgl, nix-appimage }:
126 let84 let
127 systems = [ "x86_64-linux" "aarch64-linux" ];85 systems = [ "x86_64-linux" "aarch64-linux" ];
128 forEachSystem = f:86 forEachSystem = f:
@@ -203,7 +161,11 @@
203 let np = jolt-native.packages.${pkgs.stdenv.hostPlatform.system};161 let np = jolt-native.packages.${pkgs.stdenv.hostPlatform.system};
204 in pkgs.symlinkJoin {162 in pkgs.symlinkJoin {
205 name = "jolt-native-ui";163 name = "jolt-native-ui";
206- paths = [ np.libjolttui ];164+ # Both backends there are. libjoltcosmic is the window —
165+ # libcosmic behind the same retained-tree ABI — and libjolttui
166+ # is the terminal. Neither is libvidya and neither is jvui:
167+ # those were experiments and are gone from this tree entirely.
168+ paths = [ np.libjolttui np.libjoltcosmic ];
207 };169 };
208 170
209 # libmoq_ffi — MoQ over QUIC behind UniFFI's C ABI, FETCHED rather171 # libmoq_ffi — MoQ over QUIC behind UniFFI's C ABI, FETCHED rather
@@ -301,14 +263,10 @@
301 # openh264 is here for frqH264's DT_NEEDED; alsa-lib for capture263 # openh264 is here for frqH264's DT_NEEDED; alsa-lib for capture
302 # and playback. V4L2 needs nothing: it is ioctls against libc and264 # and playback. V4L2 needs nothing: it is ioctls against libc and
303 # the kernel, so there is no library to name.265 # the kernel, so there is no library to name.
304- # SDL is what the UI is now: jvui declares SDL3, SDL3_ttf and266+ # No SDL any more: it was jvui's, declared in jvui's own
305- # SDL3_image in its own :jolt/native and dlopens them by soname,267+ # :jolt/native and dlopened by soname. libcosmic paints through wgpu
306- # so they have to be somewhere the loader looks. sdl3-image keeps268+ # and takes what it needs from `runtimeLibs` instead.
307- # its library in a separate `lib` output — the default one holds269+ codecs = [ pkgs.libopus pkgs.openh264 frqH264 pkgs.alsa-lib ];
308- # only share/, which is an afternoon nobody needs to repeat.
309- sdl = [ pkgs.sdl3 pkgs.sdl3-ttf (pkgs.sdl3-image.lib or pkgs.sdl3-image) ];
310-
311- codecs = [ pkgs.libopus pkgs.openh264 frqH264 pkgs.alsa-lib ] ++ sdl;
312 270
313 # ALSA's PipeWire plugin, which is how `default` resolves to271 # ALSA's PipeWire plugin, which is how `default` resolves to
314 # anything on a machine running PipeWire — and every machine frq272 # anything on a machine running PipeWire — and every machine frq
@@ -320,35 +278,17 @@
320 # alsa-lib looks plugins up by directory, not by soname.278 # alsa-lib looks plugins up by directory, not by soname.
321 alsaPluginDir = "${pkgs.pipewire}/lib/alsa-lib";279 alsaPluginDir = "${pkgs.pipewire}/lib/alsa-lib";
322 280
323- # The faces jvui draws with, from here rather than from whatever
324- # the host happens to have installed. jvui hunts a list of the
325- # usual system paths and, failing that, draws the missing-glyph
326- # box — which is what "the glyphs are broke" has been every time
327- # it has come up. An Arch container has NotoColorEmoji and no
328- # monochrome Noto Emoji, and NotoColorEmoji is a bitmap face with
329- # one 128-pixel strike that jvui rejects on purpose, so the chips
330- # in a message row had nothing left to be drawn from.
331- #
332- # NotoEmoji is the outline companion to NotoColorEmoji: scalable,
333- # monochrome, and full coverage of the emoji the chrome uses.
334- # Symbols2 behind it for the arrows and technical marks that are
335- # not emoji at all.
336- uiFont = "${pkgs.noto-fonts}/share/fonts/noto/NotoSans.ttf";
337- fallbackFonts = lib.concatStringsSep ":" [
338- "${pkgs.noto-fonts-monochrome-emoji}/share/fonts/noto/NotoEmoji.ttf"
339- "${pkgs.noto-fonts}/share/fonts/noto/NotoSansSymbols2-Regular.otf"
340- "${pkgs.noto-fonts}/share/fonts/noto/NotoSansSymbols.ttf"
341- ];
342-
343 nativeAll = pkgs.symlinkJoin {281 nativeAll = pkgs.symlinkJoin {
344 name = "frq-native";282 name = "frq-native";
345 paths = [ native moqFfi ] ++ codecs;283 paths = [ native moqFfi ] ++ codecs;
346 };284 };
347 285
348 # Jolt itself: Clojure on Chez, built the way its own flake builds it.286 # Jolt itself: Clojure on Chez, built the way its own flake builds it.
349- # A function, because there are two of them — upstream for the287+ #
350- # desktop, and the Bionic-addrinfo fork for the boot image the APK288+ # Still a function taking its source, though there is only one of
351- # carries. Nothing else about the build differs.289+ # them now: the second was the Bionic-addrinfo fork the APK's boot
290+ # image carried, and there is no jolt APK any more — the phone is
291+ # ClojureDart and Flutter, and jolt does not run there at all.
352 joltFrom = src: pkgs.stdenv.mkDerivation {292 joltFrom = src: pkgs.stdenv.mkDerivation {
353 pname = "jolt";293 pname = "jolt";
354 version = "dev";294 version = "dev";
@@ -395,17 +335,11 @@
395 };335 };
396 336
397 joltRuntime = joltFrom jolt-src;337 joltRuntime = joltFrom jolt-src;
398- joltAndroid = joltFrom jolt-android-src;338+
399-339+ # The backends' Clojure halves, which live inside the jolt-native
400- # glimmer-vidya lives inside the jolt-native checkout, and its own340+ # checkout beside the objects they bind. Their own deps.edn asks for
401- # deps.edn asks for glimmer by git — the top-level override below341+ # glimmer by git — the top-level override below answers for both.
402- # answers for both.342+ glimmerCosmic = "${jolt-native}/glimmer-backends/glimmer-cosmic";
403- # glimmer-jvui and the toolkit it is a backend for. TWO paths and
404- # not one: glimmer-jvui's own deps.edn names jvui by :local/root,
405- # a relative path that means nothing once nix has copied the
406- # subtree, so the -Sdeps below has to name both.
407- glimmerJvui = "${jolt-native}/glimmer-backends/glimmer-jvui";
408- jvui = "${jolt-native}/jvui";
409 glimmerTui = "${jolt-native}/glimmer-backends/glimmer-tui";343 glimmerTui = "${jolt-native}/glimmer-backends/glimmer-tui";
410 344
411 runtimeLibs = runtimeLibsFor pkgs;345 runtimeLibs = runtimeLibsFor pkgs;
@@ -413,7 +347,7 @@
413 # The project as jolt sees it: source, deps.edn, nothing else.347 # The project as jolt sees it: source, deps.edn, nothing else.
414 frqSource = pkgs.runCommand "frq-source" { } ''348 frqSource = pkgs.runCommand "frq-source" { } ''
415 mkdir -p "$out"349 mkdir -p "$out"
416- cp -r ${self}/src ${self}/deps.edn "$out/"350+ cp -r ${self}/common ${self}/src ${self}/deps.edn "$out/"
417 '';351 '';
418 352
419 # Jolt resolves deps.edn from the working directory, so the launcher353 # Jolt resolves deps.edn from the working directory, so the launcher
@@ -423,8 +357,6 @@
423 frqScript = pkgs.writeShellScript "frq" ''357 frqScript = pkgs.writeShellScript "frq" ''
424 export LD_LIBRARY_PATH="${nativeAll}/lib:${lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"358 export LD_LIBRARY_PATH="${nativeAll}/lib:${lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
425 export ALSA_PLUGIN_DIR="${alsaPluginDir}"359 export ALSA_PLUGIN_DIR="${alsaPluginDir}"
426- export JVUI_FONT="${uiFont}"
427- export JVUI_FALLBACK_FONTS="${fallbackFonts}"
428 cd ${frqSource}360 cd ${frqSource}
429 361
430 # On NixOS the store's Mesa is the system's and the window opens.362 # On NixOS the store's Mesa is the system's and the window opens.
@@ -434,8 +366,8 @@
434 [ -e /run/current-system ] || runner="${nixGL}/bin/nixGLIntel"366 [ -e /run/current-system ] || runner="${nixGL}/bin/nixGLIntel"
435 367
436 exec ''${runner} ${joltRuntime}/bin/jolt \368 exec ''${runner} ${joltRuntime}/bin/jolt \
437- -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-jvui {:local/root "${glimmerJvui}"} jvui/jvui {:local/root "${jvui}"}}}' \369+ -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-cosmic {:local/root "${glimmerCosmic}"}}}' \
438- -M:frq "$@"370+ -m frq.cosmic "$@"
439 '';371 '';
440 372
441 # The same source, the other backend. No GL, no nixGL and no X11 —373 # The same source, the other backend. No GL, no nixGL and no X11 —
@@ -447,7 +379,7 @@
447 cd ${frqSource}379 cd ${frqSource}
448 380
449 exec ${joltRuntime}/bin/jolt \381 exec ${joltRuntime}/bin/jolt \
450- -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-jvui {:local/root "${glimmerJvui}"} jvui/jvui {:local/root "${jvui}"} nandi/glimmer-tui {:local/root "${glimmerTui}"}}}' \382+ -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-tui {:local/root "${glimmerTui}"}}}' \
451 -m frq.tui "$@"383 -m frq.tui "$@"
452 '';384 '';
453 385
@@ -476,34 +408,6 @@
476 mkdir -p "$out/bin"408 mkdir -p "$out/bin"
477 ln -s ${frqScript} "$out/bin/frq"409 ln -s ${frqScript} "$out/bin/frq"
478 '';410 '';
479- # --- Android ------------------------------------------------------
480- # The SDK and the NDK are Google's, which means unfree and a licence
481- # to accept — so this is its own import of nixpkgs rather than the
482- # `legacyPackages` everything above uses. Confined to the Android
483- # outputs: `nix build` of frq itself never evaluates it.
484- #
485- # The NDK here is r29, which is the one the pinned libvidya was
486- # built with.
487- androidPkgs = import nixpkgs {
488- inherit (pkgs.stdenv.hostPlatform) system;
489- config = {
490- allowUnfree = true;
491- android_sdk.accept_license = true;
492- };
493- };
494-
495- androidComposition = androidPkgs.androidenv.composeAndroidPackages {
496- buildToolsVersions = [ "36.0.0" ];
497- platformVersions = [ "36" ];
498- includeNDK = true;
499- };
500-
501- android = import ./nix/android.nix {
502- inherit pkgs self chez-src jolt-native jolt-native-android glimmer joltAndroid;
503- inherit (pkgs) lib;
504- androidSdk = androidComposition.androidsdk;
505- ndk = androidComposition.ndk-bundle;
506- };
507 in411 in
508 {412 {
509 inherit native moqFfi frqH264 nativeAll frq;413 inherit native moqFfi frqH264 nativeAll frq;
@@ -518,13 +422,6 @@
518 # nixGL needs a store Mesa to put the host's driver in front of.422 # nixGL needs a store Mesa to put the host's driver in front of.
519 appimage =423 appimage =
520 nix-appimage.bundlers.${pkgs.stdenv.hostPlatform.system}.default frq;424 nix-appimage.bundlers.${pkgs.stdenv.hostPlatform.system}.default frq;
521- }
522- # An APK is built by a linux-x86_64 NDK and a linux-x86_64 jolt, and
523- # Google ships no other; on aarch64 the Android outputs are simply
524- # absent rather than present and broken.
525- // lib.optionalAttrs (pkgs.stdenv.hostPlatform.system == "x86_64-linux") {
526- inherit (android) apk chezAndroid joltBoot libjoltapp;
527- apk-unsigned = android.apk-unsigned;
528 });425 });
529 426
530 # Where `just run` runs, and — because entering it realises what it427 # Where `just run` runs, and — because entering it realises what it
@@ -575,19 +472,8 @@
575 # is a different `let`. See `alsaPluginDir` there for why.472 # is a different `let`. See `alsaPluginDir` there for why.
576 ALSA_PLUGIN_DIR = "${pkgs.pipewire}/lib/alsa-lib";473 ALSA_PLUGIN_DIR = "${pkgs.pipewire}/lib/alsa-lib";
577 GLIMMER_SRC = glimmer;474 GLIMMER_SRC = glimmer;
578- GLIMMER_JVUI_SRC = "${jolt-native}/glimmer-backends/glimmer-jvui";475+ GLIMMER_COSMIC_SRC = "${jolt-native}/glimmer-backends/glimmer-cosmic";
579- JVUI_SRC = "${jolt-native}/jvui";
580 GLIMMER_TUI_SRC = "${jolt-native}/glimmer-backends/glimmer-tui";476 GLIMMER_TUI_SRC = "${jolt-native}/glimmer-backends/glimmer-tui";
581- # See `uiFont` in the packages block for why these are named
582- # here rather than left to whatever the host has installed.
583- # Spelled out again for the same reason ALSA_PLUGIN_DIR is: a
584- # different `let`.
585- JVUI_FONT = "${pkgs.noto-fonts}/share/fonts/noto/NotoSans.ttf";
586- JVUI_FALLBACK_FONTS = lib.concatStringsSep ":" [
587- "${pkgs.noto-fonts-monochrome-emoji}/share/fonts/noto/NotoEmoji.ttf"
588- "${pkgs.noto-fonts}/share/fonts/noto/NotoSansSymbols2-Regular.otf"
589- "${pkgs.noto-fonts}/share/fonts/noto/NotoSansSymbols.ttf"
590- ];
591 FRQ_LIB_PATH = lib.makeLibraryPath (runtimeLibsFor pkgs);477 FRQ_LIB_PATH = lib.makeLibraryPath (runtimeLibsFor pkgs);
592 NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel";478 NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel";
593 479
@@ -637,8 +523,7 @@
637 FRQ_JOLT_NATIVE="$(cd "$FRQ_JOLT_NATIVE" && pwd)"523 FRQ_JOLT_NATIVE="$(cd "$FRQ_JOLT_NATIVE" && pwd)"
638 export FRQ_JOLT_NATIVE524 export FRQ_JOLT_NATIVE
639 export GLIMMER_TUI_SRC="$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-tui"525 export GLIMMER_TUI_SRC="$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-tui"
640- export GLIMMER_JVUI_SRC="$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-jvui"526+ export GLIMMER_COSMIC_SRC="$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-cosmic"
641- export JVUI_SRC="$FRQ_JOLT_NATIVE/jvui"
642 # First, so a cargo build wins over the pin's copy of the527 # First, so a cargo build wins over the pin's copy of the
643 # same object. The rest of the pin's lib directory is still528 # same object. The rest of the pin's lib directory is still
644 # behind it.529 # behind it.
added flutter/README.md +90 -0
new file mode 100644
@@ -0,0 +1,90 @@
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+
77+## To make this real
78+
79+1. Pin `tensegritics/clojuredart` in `deps.edn` — it says `PIN-ME`.
80+2. Get a Flutter SDK and the cljd toolchain into the flake. Neither is there.
81+3. `clj -M:cljd init`, then `clj -M:cljd flutter`. (There is no `release`
82+ subcommand — a release build is `clj -M:cljd compile` followed by
83+ `flutter build`.)
84+4. `frq.io.dart` is written from the docs and has never been compiled. Expect
85+ its interop to be wrong in detail.
86+
87+There is no other APK to fall back on. The jolt one — `nix/android.nix`, the
88+`android/` manifest and Java glue, the `.#apk` outputs and `just apk` — is
89+gone, along with the jvui and Vidya backends it painted through. Until step 2
90+exists, frq has no Android build at all, and that is the honest state.
new file mode 100644
@@ -0,0 +1,90 @@
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+
77+## To make this real
78+
79+1. Pin `tensegritics/clojuredart` in `deps.edn` — it says `PIN-ME`.
80+2. Get a Flutter SDK and the cljd toolchain into the flake. Neither is there.
81+3. `clj -M:cljd init`, then `clj -M:cljd flutter`. (There is no `release`
82+ subcommand — a release build is `clj -M:cljd compile` followed by
83+ `flutter build`.)
84+4. `frq.io.dart` is written from the docs and has never been compiled. Expect
85+ its interop to be wrong in detail.
86+
87+There is no other APK to fall back on. The jolt one — `nix/android.nix`, the
88+`android/` manifest and Java glue, the `.#apk` outputs and `just apk` — is
89+gone, along with the jvui and Vidya backends it painted through. Until step 2
90+exists, frq has no Android build at all, and that is the honest state.
added flutter/deps.edn +17 -0
new file mode 100644
@@ -0,0 +1,17 @@
1+{;; The ClojureDart half. `../common` is the same directory `../deps.edn` puts
2+ ;; on jolt's classpath — one copy of frq.clock, frq.store and frq.io, compiled
3+ ;; twice. ClojureDart reads .cljd and .cljc and never .clj, which is why
4+ ;; everything shared is .cljc and everything jolt-only stays .clj: the
5+ ;; extension is the boundary, enforced by the compiler rather than by
6+ ;; convention.
7+ :paths ["src" "../common"]
8+
9+ :deps {tensegritics/clojuredart
10+ {:git/url "https://github.com/Tensegritics/ClojureDart.git"
11+ ;; UNPINNED. Nothing builds this yet — see flutter/README.md.
12+ :git/sha "PIN-ME"}}
13+
14+ :aliases {:cljd {:main-opts ["-m" "cljd.build"]}}
15+
16+ :cljd/opts {:kind :flutter
17+ :main frq.main}}
new file mode 100644
@@ -0,0 +1,17 @@
1+{;; The ClojureDart half. `../common` is the same directory `../deps.edn` puts
2+ ;; on jolt's classpath — one copy of frq.clock, frq.store and frq.io, compiled
3+ ;; twice. ClojureDart reads .cljd and .cljc and never .clj, which is why
4+ ;; everything shared is .cljc and everything jolt-only stays .clj: the
5+ ;; extension is the boundary, enforced by the compiler rather than by
6+ ;; convention.
7+ :paths ["src" "../common"]
8+
9+ :deps {tensegritics/clojuredart
10+ {:git/url "https://github.com/Tensegritics/ClojureDart.git"
11+ ;; UNPINNED. Nothing builds this yet — see flutter/README.md.
12+ :git/sha "PIN-ME"}}
13+
14+ :aliases {:cljd {:main-opts ["-m" "cljd.build"]}}
15+
16+ :cljd/opts {:kind :flutter
17+ :main frq.main}}
added flutter/src/frq/io/dart.cljd +70 -0
new file mode 100644
@@ -0,0 +1,70 @@
1+(ns frq.io.dart
2+ "The phone's answers to `frq.io`, over dart:io.
3+
4+ The mirror of `frq.io.jolt`, and the reason the seam exists at all: every
5+ line here is something the desktop gets from libc through jolt.ffi and the
6+ APK could not get at all. `dart:io` has TLS, sockets and a filesystem in the
7+ runtime, so the Android half of frq stops being the half where login does not
8+ work.
9+
10+ Unlike its jolt counterpart this one cannot install itself at load time: the
11+ app's storage directory arrives from path_provider as a Future, so the
12+ Flutter entry point awaits it and calls `install!` with the answer before it
13+ runs anything else.
14+
15+ UNVERIFIED. Nothing in this tree compiles ClojureDart yet there is no cljd
16+ toolchain in the flake and no Flutter SDK so treat the interop here as the
17+ shape it should take rather than as code known to compile."
18+ (:require ["dart:io" :as io]
19+ [frq.io :as fio]))
20+
21+(defonce ^:private uptime
22+ ;; Dart has no monotonic clock as such; a Stopwatch started at load is one.
23+ (doto (Stopwatch.) (.start)))
24+
25+(defn- slurp* [path]
26+ (try
27+ (let [f (io/File. path)]
28+ (when (.existsSync f) (.readAsStringSync f)))
29+ (catch Exception _ nil)))
30+
31+(defn- spit* [path s]
32+ (try
33+ (.writeAsStringSync (io/File. path) s)
34+ true
35+ (catch Exception _ false)))
36+
37+(defn- write-private-file!
38+ "On Android the app's own storage is already private to the app — there is no
39+ other uid that can read it so mode 600 has nobody to exclude and this is an
40+ ordinary write. That is the whole reason `frq.io` names the intent: the
41+ desktop shells out to `install -m 600` for the same guarantee the platform
42+ makes here for free."
43+ [path s]
44+ (spit* path s))
45+
46+(defn- local-offset-seconds
47+ "Dart carries the zone in the runtime; no TZ, no /etc/localtime, no getprop.
48+ The offset is per-instant, which is what the caller wants."
49+ [secs]
50+ (-> (DateTime/fromMillisecondsSinceEpoch (* 1000 secs))
51+ (.-timeZoneOffset)
52+ (.-inSeconds)))
53+
54+(defn install!
55+ "`dir` is the app's storage directory, already awaited from path_provider."
56+ [dir]
57+ (fio/install!
58+ {:getenv (fn [n] (get (.-environment io/Platform) n))
59+ :config-dir (fn [] dir)
60+ :file-exists? (fn [p] (.existsSync (io/File. p)))
61+ :directory? (fn [p] (.existsSync (io/Directory. p)))
62+ :list-dir (fn [p] (mapv #(.-path %) (.listSync (io/Directory. p))))
63+ :mkdirs! (fn [p] (.createSync (io/Directory. p) .recursive true))
64+ :delete-file! (fn [p] (.deleteSync (io/File. p)))
65+ :slurp slurp*
66+ :spit spit*
67+ :write-private-file! write-private-file!
68+ :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now))))
69+ :mono-nanos (fn [] (* 1000 (.-inMicroseconds (.-elapsed uptime))))
70+ :local-offset-seconds local-offset-seconds}))
new file mode 100644
@@ -0,0 +1,70 @@
1+(ns frq.io.dart
2+ "The phone's answers to `frq.io`, over dart:io.
3+
4+ The mirror of `frq.io.jolt`, and the reason the seam exists at all: every
5+ line here is something the desktop gets from libc through jolt.ffi and the
6+ APK could not get at all. `dart:io` has TLS, sockets and a filesystem in the
7+ runtime, so the Android half of frq stops being the half where login does not
8+ work.
9+
10+ Unlike its jolt counterpart this one cannot install itself at load time: the
11+ app's storage directory arrives from path_provider as a Future, so the
12+ Flutter entry point awaits it and calls `install!` with the answer before it
13+ runs anything else.
14+
15+ UNVERIFIED. Nothing in this tree compiles ClojureDart yet there is no cljd
16+ toolchain in the flake and no Flutter SDK so treat the interop here as the
17+ shape it should take rather than as code known to compile."
18+ (:require ["dart:io" :as io]
19+ [frq.io :as fio]))
20+
21+(defonce ^:private uptime
22+ ;; Dart has no monotonic clock as such; a Stopwatch started at load is one.
23+ (doto (Stopwatch.) (.start)))
24+
25+(defn- slurp* [path]
26+ (try
27+ (let [f (io/File. path)]
28+ (when (.existsSync f) (.readAsStringSync f)))
29+ (catch Exception _ nil)))
30+
31+(defn- spit* [path s]
32+ (try
33+ (.writeAsStringSync (io/File. path) s)
34+ true
35+ (catch Exception _ false)))
36+
37+(defn- write-private-file!
38+ "On Android the app's own storage is already private to the app — there is no
39+ other uid that can read it so mode 600 has nobody to exclude and this is an
40+ ordinary write. That is the whole reason `frq.io` names the intent: the
41+ desktop shells out to `install -m 600` for the same guarantee the platform
42+ makes here for free."
43+ [path s]
44+ (spit* path s))
45+
46+(defn- local-offset-seconds
47+ "Dart carries the zone in the runtime; no TZ, no /etc/localtime, no getprop.
48+ The offset is per-instant, which is what the caller wants."
49+ [secs]
50+ (-> (DateTime/fromMillisecondsSinceEpoch (* 1000 secs))
51+ (.-timeZoneOffset)
52+ (.-inSeconds)))
53+
54+(defn install!
55+ "`dir` is the app's storage directory, already awaited from path_provider."
56+ [dir]
57+ (fio/install!
58+ {:getenv (fn [n] (get (.-environment io/Platform) n))
59+ :config-dir (fn [] dir)
60+ :file-exists? (fn [p] (.existsSync (io/File. p)))
61+ :directory? (fn [p] (.existsSync (io/Directory. p)))
62+ :list-dir (fn [p] (mapv #(.-path %) (.listSync (io/Directory. p))))
63+ :mkdirs! (fn [p] (.createSync (io/Directory. p) .recursive true))
64+ :delete-file! (fn [p] (.deleteSync (io/File. p)))
65+ :slurp slurp*
66+ :spit spit*
67+ :write-private-file! write-private-file!
68+ :wall-nanos (fn [] (* 1000 (.-microsecondsSinceEpoch (DateTime/now))))
69+ :mono-nanos (fn [] (* 1000 (.-inMicroseconds (.-elapsed uptime))))
70+ :local-offset-seconds local-offset-seconds}))
added flutter/src/frq/main.cljd +36 -0
new file mode 100644
@@ -0,0 +1,36 @@
1+(ns frq.main
2+ "The Flutter entry point: what `frq.app` is to the jolt half.
3+
4+ Two jobs, and the first one is why `frq.io.dart` cannot install itself the
5+ way `frq.io.jolt` does: the app's storage directory arrives from
6+ path_provider as a Future, so it is awaited here and handed over before
7+ anything under ../common runs.
8+
9+ The screens themselves are not written. `frq.app` is 1,834 lines of hiccup
10+ over glimmer's widget tags, and glimmer is not here — Flutter brings its own
11+ reconciler, so those screens are a rewrite against cljd.flutter rather than a
12+ port. This file is the socket they plug into."
13+ (:require ["package:flutter/material.dart" :as m]
14+ ["package:path_provider/path_provider.dart" :as pp]
15+ [cljd.flutter :as f]
16+ [frq.io.dart :as host]
17+ [frq.clock :as clock]
18+ [frq.store :as store]))
19+
20+(defn main []
21+ (f/run
22+ (f/widget
23+ :watch [dir (.then (pp/getApplicationSupportDirectory)
24+ (fn [d] (.-path d)))]
25+ (m/MaterialApp)
26+ (m/Scaffold)
27+ (m/Center)
28+ ;; Proof the shared half is alive under a second compiler, and nothing
29+ ;; more: install the host, then read the clock and the saved session
30+ ;; through exactly the namespaces the desktop reads them through.
31+ (do (host/install! dir)
32+ (m/Text (str "frq\n"
33+ (clock/clock-time (clock/now-ms)) "\n"
34+ (if-let [s (store/load-session)]
35+ (str "signed in as " (:handle s))
36+ "no saved session")))))))
new file mode 100644
@@ -0,0 +1,36 @@
1+(ns frq.main
2+ "The Flutter entry point: what `frq.app` is to the jolt half.
3+
4+ Two jobs, and the first one is why `frq.io.dart` cannot install itself the
5+ way `frq.io.jolt` does: the app's storage directory arrives from
6+ path_provider as a Future, so it is awaited here and handed over before
7+ anything under ../common runs.
8+
9+ The screens themselves are not written. `frq.app` is 1,834 lines of hiccup
10+ over glimmer's widget tags, and glimmer is not here — Flutter brings its own
11+ reconciler, so those screens are a rewrite against cljd.flutter rather than a
12+ port. This file is the socket they plug into."
13+ (:require ["package:flutter/material.dart" :as m]
14+ ["package:path_provider/path_provider.dart" :as pp]
15+ [cljd.flutter :as f]
16+ [frq.io.dart :as host]
17+ [frq.clock :as clock]
18+ [frq.store :as store]))
19+
20+(defn main []
21+ (f/run
22+ (f/widget
23+ :watch [dir (.then (pp/getApplicationSupportDirectory)
24+ (fn [d] (.-path d)))]
25+ (m/MaterialApp)
26+ (m/Scaffold)
27+ (m/Center)
28+ ;; Proof the shared half is alive under a second compiler, and nothing
29+ ;; more: install the host, then read the clock and the saved session
30+ ;; through exactly the namespaces the desktop reads them through.
31+ (do (host/install! dir)
32+ (m/Text (str "frq\n"
33+ (clock/clock-time (clock/now-ms)) "\n"
34+ (if-let [s (store/load-session)]
35+ (str "signed in as " (:handle s))
36+ "no saved session")))))))
modified justfile +14 -130
@@ -32,78 +32,9 @@ jobs := env("FRQ_MAX_JOBS", "0")
3232 default:
3333 @just --list
3434
35-# The build itself is nix/android.nix, reached as `.#apk`; this only asks nix
36-# for the file and then does what was asked with it. Nothing here names an
37-# Android SDK, an NDK, a Chez cross target or an OpenSSL: the derivation builds
38-# or fetches every one of them.
39-#
40-# The APK is signed with a debug key generated inside the derivation, so the
41-# output is installable and not reproducible; anything meant for a store gets
42-# signed from `.#apk-unsigned` instead. See nix/android.nix.
43-#
44-# FRQ_NIX_STORE builds the whole graph somewhere else rather than here —
45-#
46-# FRQ_NIX_STORE=ssh-ng://eu.nixbuild.net just apk
47-#
48-# which is the shape android.nix asks for: with a `builders` entry instead, nix
49-# copies every remotely-built output back, and androidenv's NDK is both
50-# preferLocalBuild and absent from cache.nixos.org, so 3.1 GB of toolchain is
51-# built here and uploaded. With the remote as the *store* only .drv files go
52-# up. An install then needs the file here, so it is fetched back at the end —
53-# one APK rather than the closure that made it.
54-#
55-# The APK, out of the flake. `just apk install` puts it on the device.
56-apk action="build":
57- #!/usr/bin/env bash
58- set -euo pipefail
59- cd "{{justfile_directory()}}"
60- adb="${ADB:-$HOME/.local/share/android-sdk/platform-tools/adb}"
61- package="uk.nandi.frq"
62-
63- # --eval-store auto goes with a remote store and only with one: evaluation
64- # wants this tree, which is here.
65- store=()
66- [ -n "${FRQ_NIX_STORE:-}" ] && store=(--store "$FRQ_NIX_STORE" --eval-store auto)
67-
68- build() {
69- # The Android objects come from jolt-native's CI under a "latest" alias,
70- # and a flake input is locked once and then stays put — so without this
71- # an APK is built against whatever flake.lock recorded the first time,
72- # however old. Only this input: a bare `nix flake update` would move
73- # jolt, nixpkgs and glimmer too, and the point of their pins is that
74- # they move when someone decides they should.
75- {{nix}} flake update jolt-native-android --flake . >&2
76-
77- # Built without a `result` symlink: the path is what the caller wants,
78- # and a symlink into a store that may not be this one is not a useful
79- # thing to leave in the tree.
80- local out
81- out=$({{nix}} build .#apk --no-link --print-out-paths "${store[@]}" \
82- | grep '^/nix/store/' | tail -1)
83- [ -n "$out" ] || { echo "nix build printed no store path" >&2; exit 1; }
84-
85- # Off a remote store the path names a file on the builder, so adb has
86- # nothing to open. `nix copy --from` brings just that one path here.
87- if [ -n "${FRQ_NIX_STORE:-}" ]; then
88- {{nix}} copy --no-check-sigs --from "$FRQ_NIX_STORE" "$out" >&2
89- fi
90- echo "$out"
91- }
92-
93- case "{{action}}" in
94- build) build ;;
95- install) "$adb" install -r "$(build)" ;;
96- run) file=$(build)
97- "$adb" install -r "$file"
98- "$adb" shell am force-stop "$package"
99- "$adb" shell am start -n "$package/.FrqActivity" ;;
100- log) "$adb" logcat -s VidyaJolt Vidya ;;
101- *) echo "usage: just apk [build|install|run|log]" >&2; exit 1 ;;
102- esac
103-
10435 # Two halves, and the split is the point. The frq source is the files on disk,
105-# uncommitted edits and all. Everything under it — jolt, glimmer, glimmer-vidya,
106-# both native objects — is the flake's, built rather than fetched.
36+# uncommitted edits and all. Everything under it — jolt, glimmer, glimmer-cosmic
37+# and the native objects — is the flake's, built rather than fetched.
10738 #
10839 # Deliberately not `nix run .#frq`. That builds the flake's own copy of the
10940 # source, which is the tree as git has it — so an edit that has not been
@@ -111,6 +42,12 @@ apk action="build":
11142 # whatever was there before, silently. A run meant to answer "does my change
11243 # work" has to be the files on disk.
11344 #
45+# libcosmic paints through wgpu, so this needs nixGL off NixOS for the same
46+# reason the window always did: the real driver is the host's.
47+#
48+# There is no jvui and no vidya here any more — both were experiments. The
49+# window is libcosmic and the terminal is libjolttui, and those are the two.
50+#
11451 # The app: this tree's source on the flake's everything-else, in the dev shell.
11552 run *args:
11653 #!/usr/bin/env bash
@@ -120,27 +57,19 @@ run *args:
12057 exec {{nix}} develop . --max-jobs {{jobs}} --command just run "$@"
12158 fi
12259
123- # The Jolt halves that have to match those objects. glimmer-vidya lives
124- # inside jolt-native and binds libvidya's ABI, so it comes out of the same
125- # input that was built rather than deps.edn's git sha — the pin drifting
126- # from the library is exactly what the flake input's comment describes.
12760 deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
128- deps="$deps nandi/glimmer-jvui {:local/root \"$GLIMMER_JVUI_SRC\"}"
129- deps="$deps jvui/jvui {:local/root \"$JVUI_SRC\"}}}"
61+ deps="$deps nandi/glimmer-cosmic {:local/root \"$GLIMMER_COSMIC_SRC\"}}}"
13062
13163 export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
13264
133- # On NixOS the store's Mesa is the system's and the window opens. Anywhere
134- # else — a bare host, or the distrobox above — the real driver is the
135- # host's, so defer to nixGL, which prepends it.
13665 runner=()
13766 [ -e /run/current-system ] || runner=("$NIXGL")
13867
139- exec "${runner[@]}" jolt -Sdeps "$deps" -M:frq "$@"
68+ exec "${runner[@]}" jolt -Sdeps "$deps" -m frq.cosmic "$@"
14069
141-# `run` with the other backend under it. It still loads libvidya as well as
142-# libjolttui: `frq.app` requires glimmer-vidya, and `frq.tui` requires
143-# glimmer-tui after it so the backend installed last is the terminal.
70+# `run` with the other backend under it. Only libjolttui: `frq.app` names no
71+# backend at all any more, and `frq.tui` requires glimmer-tui so the one
72+# installed is the terminal.
14473 #
14574 # No nixGL here, unlike `run`: a terminal wants nothing from the host's GL
14675 # driver, which is the reason this output exists on machines that have none.
@@ -155,54 +84,12 @@ tui *args:
15584 fi
15685
15786 deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
158- deps="$deps nandi/glimmer-jvui {:local/root \"$GLIMMER_JVUI_SRC\"}"
159- deps="$deps jvui/jvui {:local/root \"$JVUI_SRC\"}"
16087 deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}"
16188
16289 export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
16390
16491 exec jolt -Sdeps "$deps" -m frq.tui "$@"
16592
166-# `run` with glimmer-cosmic under it, libcosmic doing the painting. There is
167-# no pin to fall back on: the flake's jolt-native builds no libjoltcosmic, so
168-# this needs a jolt-native checkout with one built, named by FRQ_JOLT_NATIVE
169-# or found beside this tree the way the shell always looks.
170-#
171-# nix develop --command cargo build --release -p jolt-cosmic in that checkout
172-#
173-# Inside jolt-native's shell and not with the host's cargo: jolt runs on the
174-# store's glibc, and an object linked against a newer host one (Arch's, here)
175-# asks for symbol versions that glibc does not have — dlopen refuses it, and
176-# jolt reports that as the library not being found at all.
177-#
178-# nixGL for the same reason as `run`: libcosmic paints through wgpu.
179-#
180-# The same screens, painted by libcosmic. A spike: most tags paint as columns.
181-cosmic *args:
182- #!/usr/bin/env bash
183- set -euo pipefail
184- cd "{{justfile_directory()}}"
185- if [ -z "${JOLT_NATIVE_LIB:-}" ]; then
186- exec {{nix}} develop . --max-jobs {{jobs}} --command just cosmic "$@"
187- fi
188-
189- if [ -z "${FRQ_JOLT_NATIVE:-}" ] || [ ! -e "$FRQ_JOLT_NATIVE/target/release/libjoltcosmic.so" ]; then
190- echo "frq: no libjoltcosmic.so in ${FRQ_JOLT_NATIVE:-the pin} — nix develop --command cargo build --release -p jolt-cosmic in a jolt-native checkout, then FRQ_JOLT_NATIVE=<it> just cosmic" >&2
191- exit 1
192- fi
193-
194- deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
195- deps="$deps nandi/glimmer-jvui {:local/root \"$GLIMMER_JVUI_SRC\"}"
196- deps="$deps jvui/jvui {:local/root \"$JVUI_SRC\"}"
197- deps="$deps nandi/glimmer-cosmic {:local/root \"$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-cosmic\"}}}"
198-
199- export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
200-
201- runner=()
202- [ -e /run/current-system ] || runner=("$NIXGL")
203-
204- exec "${runner[@]}" jolt -Sdeps "$deps" -m frq.cosmic "$@"
205-
20693 # The same classpath as `tui`, with an nREPL on it instead of a `-main`: a
20794 # session that can require `frq.tui` and then redefine a component while it is
20895 # on screen, which is a second and not the minute a rebuild costs.
@@ -223,8 +110,6 @@ nrepl *args:
223110 fi
224111
225112 deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
226- deps="$deps nandi/glimmer-jvui {:local/root \"$GLIMMER_JVUI_SRC\"}"
227- deps="$deps jvui/jvui {:local/root \"$JVUI_SRC\"}"
228113 deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}"
229114
230115 export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
@@ -247,8 +132,7 @@ repl *args:
247132 fi
248133
249134 deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
250- deps="$deps nandi/glimmer-jvui {:local/root \"$GLIMMER_JVUI_SRC\"}"
251- deps="$deps jvui/jvui {:local/root \"$JVUI_SRC\"}}}"
135+ deps="$deps nandi/glimmer-cosmic {:local/root \"$GLIMMER_COSMIC_SRC\"}}}"
252136
253137 export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
254138
@@ -32,78 +32,9 @@ jobs := env("FRQ_MAX_JOBS", "0")
32 default:32 default:
33 @just --list33 @just --list
34 34
35-# The build itself is nix/android.nix, reached as `.#apk`; this only asks nix
36-# for the file and then does what was asked with it. Nothing here names an
37-# Android SDK, an NDK, a Chez cross target or an OpenSSL: the derivation builds
38-# or fetches every one of them.
39-#
40-# The APK is signed with a debug key generated inside the derivation, so the
41-# output is installable and not reproducible; anything meant for a store gets
42-# signed from `.#apk-unsigned` instead. See nix/android.nix.
43-#
44-# FRQ_NIX_STORE builds the whole graph somewhere else rather than here —
45-#
46-# FRQ_NIX_STORE=ssh-ng://eu.nixbuild.net just apk
47-#
48-# which is the shape android.nix asks for: with a `builders` entry instead, nix
49-# copies every remotely-built output back, and androidenv's NDK is both
50-# preferLocalBuild and absent from cache.nixos.org, so 3.1 GB of toolchain is
51-# built here and uploaded. With the remote as the *store* only .drv files go
52-# up. An install then needs the file here, so it is fetched back at the end —
53-# one APK rather than the closure that made it.
54-#
55-# The APK, out of the flake. `just apk install` puts it on the device.
56-apk action="build":
57- #!/usr/bin/env bash
58- set -euo pipefail
59- cd "{{justfile_directory()}}"
60- adb="${ADB:-$HOME/.local/share/android-sdk/platform-tools/adb}"
61- package="uk.nandi.frq"
62-
63- # --eval-store auto goes with a remote store and only with one: evaluation
64- # wants this tree, which is here.
65- store=()
66- [ -n "${FRQ_NIX_STORE:-}" ] && store=(--store "$FRQ_NIX_STORE" --eval-store auto)
67-
68- build() {
69- # The Android objects come from jolt-native's CI under a "latest" alias,
70- # and a flake input is locked once and then stays put — so without this
71- # an APK is built against whatever flake.lock recorded the first time,
72- # however old. Only this input: a bare `nix flake update` would move
73- # jolt, nixpkgs and glimmer too, and the point of their pins is that
74- # they move when someone decides they should.
75- {{nix}} flake update jolt-native-android --flake . >&2
76-
77- # Built without a `result` symlink: the path is what the caller wants,
78- # and a symlink into a store that may not be this one is not a useful
79- # thing to leave in the tree.
80- local out
81- out=$({{nix}} build .#apk --no-link --print-out-paths "${store[@]}" \
82- | grep '^/nix/store/' | tail -1)
83- [ -n "$out" ] || { echo "nix build printed no store path" >&2; exit 1; }
84-
85- # Off a remote store the path names a file on the builder, so adb has
86- # nothing to open. `nix copy --from` brings just that one path here.
87- if [ -n "${FRQ_NIX_STORE:-}" ]; then
88- {{nix}} copy --no-check-sigs --from "$FRQ_NIX_STORE" "$out" >&2
89- fi
90- echo "$out"
91- }
92-
93- case "{{action}}" in
94- build) build ;;
95- install) "$adb" install -r "$(build)" ;;
96- run) file=$(build)
97- "$adb" install -r "$file"
98- "$adb" shell am force-stop "$package"
99- "$adb" shell am start -n "$package/.FrqActivity" ;;
100- log) "$adb" logcat -s VidyaJolt Vidya ;;
101- *) echo "usage: just apk [build|install|run|log]" >&2; exit 1 ;;
102- esac
103-
104 # Two halves, and the split is the point. The frq source is the files on disk,35 # Two halves, and the split is the point. The frq source is the files on disk,
105-# uncommitted edits and all. Everything under it — jolt, glimmer, glimmer-vidya,36+# uncommitted edits and all. Everything under it — jolt, glimmer, glimmer-cosmic
106-# both native objects — is the flake's, built rather than fetched.37+# and the native objects — is the flake's, built rather than fetched.
107 #38 #
108 # Deliberately not `nix run .#frq`. That builds the flake's own copy of the39 # Deliberately not `nix run .#frq`. That builds the flake's own copy of the
109 # source, which is the tree as git has it — so an edit that has not been40 # source, which is the tree as git has it — so an edit that has not been
@@ -111,6 +42,12 @@ apk action="build":
111 # whatever was there before, silently. A run meant to answer "does my change42 # whatever was there before, silently. A run meant to answer "does my change
112 # work" has to be the files on disk.43 # work" has to be the files on disk.
113 #44 #
45+# libcosmic paints through wgpu, so this needs nixGL off NixOS for the same
46+# reason the window always did: the real driver is the host's.
47+#
48+# There is no jvui and no vidya here any more — both were experiments. The
49+# window is libcosmic and the terminal is libjolttui, and those are the two.
50+#
114 # The app: this tree's source on the flake's everything-else, in the dev shell.51 # The app: this tree's source on the flake's everything-else, in the dev shell.
115 run *args:52 run *args:
116 #!/usr/bin/env bash53 #!/usr/bin/env bash
@@ -120,27 +57,19 @@ run *args:
120 exec {{nix}} develop . --max-jobs {{jobs}} --command just run "$@"57 exec {{nix}} develop . --max-jobs {{jobs}} --command just run "$@"
121 fi58 fi
122 59
123- # The Jolt halves that have to match those objects. glimmer-vidya lives
124- # inside jolt-native and binds libvidya's ABI, so it comes out of the same
125- # input that was built rather than deps.edn's git sha — the pin drifting
126- # from the library is exactly what the flake input's comment describes.
127 deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"60 deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
128- deps="$deps nandi/glimmer-jvui {:local/root \"$GLIMMER_JVUI_SRC\"}"61+ deps="$deps nandi/glimmer-cosmic {:local/root \"$GLIMMER_COSMIC_SRC\"}}}"
129- deps="$deps jvui/jvui {:local/root \"$JVUI_SRC\"}}}"
130 62
131 export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"63 export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
132 64
133- # On NixOS the store's Mesa is the system's and the window opens. Anywhere
134- # else — a bare host, or the distrobox above — the real driver is the
135- # host's, so defer to nixGL, which prepends it.
136 runner=()65 runner=()
137 [ -e /run/current-system ] || runner=("$NIXGL")66 [ -e /run/current-system ] || runner=("$NIXGL")
138 67
139- exec "${runner[@]}" jolt -Sdeps "$deps" -M:frq "$@"68+ exec "${runner[@]}" jolt -Sdeps "$deps" -m frq.cosmic "$@"
140 69
141-# `run` with the other backend under it. It still loads libvidya as well as70+# `run` with the other backend under it. Only libjolttui: `frq.app` names no
142-# libjolttui: `frq.app` requires glimmer-vidya, and `frq.tui` requires71+# backend at all any more, and `frq.tui` requires glimmer-tui so the one
143-# glimmer-tui after it so the backend installed last is the terminal.72+# installed is the terminal.
144 #73 #
145 # No nixGL here, unlike `run`: a terminal wants nothing from the host's GL74 # No nixGL here, unlike `run`: a terminal wants nothing from the host's GL
146 # driver, which is the reason this output exists on machines that have none.75 # driver, which is the reason this output exists on machines that have none.
@@ -155,54 +84,12 @@ tui *args:
155 fi84 fi
156 85
157 deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"86 deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
158- deps="$deps nandi/glimmer-jvui {:local/root \"$GLIMMER_JVUI_SRC\"}"
159- deps="$deps jvui/jvui {:local/root \"$JVUI_SRC\"}"
160 deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}"87 deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}"
161 88
162 export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"89 export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
163 90
164 exec jolt -Sdeps "$deps" -m frq.tui "$@"91 exec jolt -Sdeps "$deps" -m frq.tui "$@"
165 92
166-# `run` with glimmer-cosmic under it, libcosmic doing the painting. There is
167-# no pin to fall back on: the flake's jolt-native builds no libjoltcosmic, so
168-# this needs a jolt-native checkout with one built, named by FRQ_JOLT_NATIVE
169-# or found beside this tree the way the shell always looks.
170-#
171-# nix develop --command cargo build --release -p jolt-cosmic in that checkout
172-#
173-# Inside jolt-native's shell and not with the host's cargo: jolt runs on the
174-# store's glibc, and an object linked against a newer host one (Arch's, here)
175-# asks for symbol versions that glibc does not have — dlopen refuses it, and
176-# jolt reports that as the library not being found at all.
177-#
178-# nixGL for the same reason as `run`: libcosmic paints through wgpu.
179-#
180-# The same screens, painted by libcosmic. A spike: most tags paint as columns.
181-cosmic *args:
182- #!/usr/bin/env bash
183- set -euo pipefail
184- cd "{{justfile_directory()}}"
185- if [ -z "${JOLT_NATIVE_LIB:-}" ]; then
186- exec {{nix}} develop . --max-jobs {{jobs}} --command just cosmic "$@"
187- fi
188-
189- if [ -z "${FRQ_JOLT_NATIVE:-}" ] || [ ! -e "$FRQ_JOLT_NATIVE/target/release/libjoltcosmic.so" ]; then
190- echo "frq: no libjoltcosmic.so in ${FRQ_JOLT_NATIVE:-the pin} — nix develop --command cargo build --release -p jolt-cosmic in a jolt-native checkout, then FRQ_JOLT_NATIVE=<it> just cosmic" >&2
191- exit 1
192- fi
193-
194- deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
195- deps="$deps nandi/glimmer-jvui {:local/root \"$GLIMMER_JVUI_SRC\"}"
196- deps="$deps jvui/jvui {:local/root \"$JVUI_SRC\"}"
197- deps="$deps nandi/glimmer-cosmic {:local/root \"$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-cosmic\"}}}"
198-
199- export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
200-
201- runner=()
202- [ -e /run/current-system ] || runner=("$NIXGL")
203-
204- exec "${runner[@]}" jolt -Sdeps "$deps" -m frq.cosmic "$@"
205-
206 # The same classpath as `tui`, with an nREPL on it instead of a `-main`: a93 # The same classpath as `tui`, with an nREPL on it instead of a `-main`: a
207 # session that can require `frq.tui` and then redefine a component while it is94 # session that can require `frq.tui` and then redefine a component while it is
208 # on screen, which is a second and not the minute a rebuild costs.95 # on screen, which is a second and not the minute a rebuild costs.
@@ -223,8 +110,6 @@ nrepl *args:
223 fi110 fi
224 111
225 deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"112 deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
226- deps="$deps nandi/glimmer-jvui {:local/root \"$GLIMMER_JVUI_SRC\"}"
227- deps="$deps jvui/jvui {:local/root \"$JVUI_SRC\"}"
228 deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}"113 deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}"
229 114
230 export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"115 export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
@@ -247,8 +132,7 @@ repl *args:
247 fi132 fi
248 133
249 deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"134 deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
250- deps="$deps nandi/glimmer-jvui {:local/root \"$GLIMMER_JVUI_SRC\"}"135+ deps="$deps nandi/glimmer-cosmic {:local/root \"$GLIMMER_COSMIC_SRC\"}}}"
251- deps="$deps jvui/jvui {:local/root \"$JVUI_SRC\"}}}"
252 136
253 export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"137 export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
254 138
deleted nix/android.nix +0 -395
deleted file mode 100644
@@ -1,395 +0,0 @@
1-# The APK, as derivations.
2-#
3-# The only APK build there is: this replaced a buck2 graph and the script
4-# before it, both of which named an Android SDK, an NDK, a hand-built Chez
5-# cross target and an OpenSSL by absolute path and stopped when one was
6-# missing. Every one of those is built or fetched here instead, so this works
7-# from nothing on a machine with none of them and no ~/.cache at all:
8-#
9-# nix build .#apk
10-#
11-# `just apk` is the same build with the store path handed to adb afterwards;
12-# see the `apk` recipe in the justfile.
13-#
14-# On a machine with a remote builder configured, prefer
15-#
16-# nix build .#apk --store ssh-ng://eu.nixbuild.net --eval-store auto
17-#
18-# rather than letting `builders` do it. With `builders`, nix copies the output
19-# of every remotely-built derivation back, and androidenv's NDK is both
20-# `preferLocalBuild` and absent from cache.nixos.org — so the 3.1 GB unpacked
21-# toolchain is built here and uploaded. With the remote as the *store* the
22-# whole graph stays there, only .drv files go up, and the builder fetches
23-# Google's zip over its own link.
24-#
25-# The steps are the ones the graph before it ran, in the same order; where a
26-# genrule read a path out of `read_root_config`, a derivation takes an
27-# argument.
28-{ pkgs, lib, self, chez-src, jolt-native, jolt-native-android, glimmer, joltAndroid, androidSdk, ndk }:
29-
30-let
31- # What the APK targets, in the three spellings the tools want it in.
32- apiLevel = "28";
33- targetSdk = "36";
34- abi = "arm64-v8a";
35- package = "uk.nandi.frq";
36- version = "0.1.0";
37-
38- sdk = "${androidSdk}/libexec/android-sdk";
39- buildTools = "${sdk}/build-tools/36.0.0";
40- androidJar = "${sdk}/platforms/android-${targetSdk}/android.jar";
41-
42- # The NDK's clang finds its sysroot, resource directory and the rest of LLVM
43- # relative to itself, so it is named by path rather than copied anywhere.
44- # androidenv installs the tree under libexec/android-sdk and leaves
45- # `ndk-bundle` pointing at the versioned directory beside it.
46- ndkRoot = "${ndk}/libexec/android-sdk/ndk-bundle";
47- ndkBin = "${ndkRoot}/toolchains/llvm/prebuilt/linux-x86_64/bin";
48- cc = "${ndkBin}/aarch64-linux-android${apiLevel}-clang";
49-
50- # What comes out of jolt-native's CI, as a flake input rather than a fetchurl:
51- # nix unpacks a tarball input itself, and flake.lock holds the digest that
52- # used to be written here by hand. `just bump` moves it with
53- # `nix flake update jolt-native-android` and nothing in this file changes.
54- #
55- # One archive. libvidya (the retained-tree UI) is what is taken from it, and
56- # include/ beside it for the glue's headers.
57- #
58- # It also carries libjoltmoq and the libc++_shared that object needs, and
59- # neither is packed any more. frq's media plane is `frq.av.plane` now — MoQ
60- # over QUIC through libmoq_ffi, Opus, H.264 and the devices — and none of
61- # that is built for Android yet, so a phone has no media plane at all and
62- # `frq.av/available?` says so. Shipping the Rust one it no longer loads was
63- # seventeen megabytes of APK for an object nothing opens.
64- nativeLibs = jolt-native-android;
65-
66- # No libc++_shared. It was here because openh264 is C++ and libjoltmoq
67- # carried it as a DT_NEEDED; libvidya links neither — its NEEDED list is
68- # libdl, libandroid, liblog, libm and libc — so with the media plane gone
69- # the C++ runtime has nothing to serve.
70-
71- # --- Chez's arm64 cross target ------------------------------------------
72- # The one piece with no nixpkgs equivalent: `pkgs.chez` builds a Scheme for
73- # this machine, and what the boot image needs is Chez's `tarm64le` workarea —
74- # the target boot files, the cross compiler's xpatch, and the arm64
75- # libkernel.a that libjoltapp links.
76- #
77- # Three builds in one derivation, because each needs the one before it:
78- #
79- # ta6le a host Scheme, which is what cross-compiles anything
80- # boot XM=... the target's boot files and xpatch, made by that host
81- # tarm64le the target kernel, compiled by the NDK
82- #
83- # The flags are the ones the hand-built tree under ~/.cache/vidya-chez-android
84- # was configured with, read back out of its Mf-config. zlib is Android's own
85- # (`-lz`, which Bionic has); lz4 is the in-tree submodule, built for arm64
86- # here because a cross configure links it rather than building it.
87- chezAndroid = pkgs.stdenv.mkDerivation {
88- pname = "chez-scheme-android";
89- version = "10.4.1";
90- src = chez-src;
91-
92- strictDeps = true;
93- nativeBuildInputs = with pkgs; [ gnumake which ];
94-
95- dontConfigure = true;
96-
97- buildPhase = ''
98- runHook preBuild
99-
100- # Both workareas turn off the expression editor's two dependencies, which
101- # is what ~/.cache/vidya-chez-android was configured with (its Mf-config
102- # has empty cursesLib/ncursesLib, and disablex11=yes on the target). The
103- # host Scheme here is only ever a cross compiler, and Bionic has no
104- # curses.h at all so on the target it is not a preference but a
105- # requirement.
106- ./configure -m=ta6le --disable-x11 --disable-curses CC_FOR_BUILD="$CC"
107- make -j"$NIX_BUILD_CORES"
108- make boot XM=tarm64le -j"$NIX_BUILD_CORES"
109-
110- # lz4 for the phone, not for this machine: the host build above left an
111- # x86_64 liblz4.a in the same place, and the cross link needs it gone.
112- make -C lz4/lib clean
113- make -C lz4/lib liblz4.a -j"$NIX_BUILD_CORES" \
114- CC=${cc} AR=${ndkBin}/llvm-ar
115-
116- # --disable-auto-flags stops configure appending -lrt and -lpthread, which
117- # is what its unix branch does for a glibc host and what Bionic has no
118- # separate libraries for both live in libc there. Everything it would
119- # otherwise add is passed explicitly below, matching the Mf-config of
120- # the tree this was reconstructed from.
121- ./configure -m=tarm64le --cross --disable-x11 --disable-curses \
122- --disable-auto-flags \
123- LIBS="-ldl -lm" \
124- CC=${cc} \
125- AR=${ndkBin}/llvm-ar \
126- CC_FOR_BUILD="$CC" \
127- ZLIB=-lz \
128- LZ4="$PWD/lz4/lib/liblz4.a" \
129- CPPFLAGS="-I$PWD/lz4/lib" \
130- CFLAGS="-O2 -D_REENTRANT -pthread -fPIC"
131- make -j"$NIX_BUILD_CORES"
132-
133- runHook postBuild
134- '';
135-
136- # The whole workarea, at the paths CHEZ_ANDROID means: the host Scheme
137- # loads xpatch out of xc-tarm64le/s, and the link below reads two archives
138- # from elsewhere in the tree. Pruning it would only be guessing at which
139- # of those the cross compiler still opens.
140- installPhase = ''
141- runHook preInstall
142- mkdir -p "$out"
143- cp -r . "$out/"
144- runHook postInstall
145- '';
146-
147- # A Scheme built for this machine and a kernel built for another one; the
148- # usual fixups have an opinion about both, and neither wants it.
149- dontStrip = true;
150- dontPatchELF = true;
151- };
152-
153- hostScheme = "${chezAndroid}/ta6le/bin/ta6le/scheme";
154- targetBoot = "${chezAndroid}/boot/tarm64le";
155- xpatch = "${chezAndroid}/xc-tarm64le/s/xpatch";
156-
157- # --- the Jolt half --------------------------------------------------------
158- # frq's Scheme, cross-compiled to an arm64 boot image. The deps.edn below is
159- # written out by hand rather than resolved: there is no dependency resolution
160- # inside a cross compile, so every source root deps.edn would have resolved is
161- # named as a :path instead.
162- #
163- # The jolt that runs it is the fork, not upstream and not the one the desktop
164- # package builds: upstream reads the socket address out of `struct addrinfo`
165- # at glibc's offset, which on Bionic is `ai_canonname`, and an APK built with
166- # it cannot open a TLS connection at all.
167- joltBoot = pkgs.stdenv.mkDerivation {
168- pname = "frq-jolt-boot";
169- inherit version;
170-
171- dontUnpack = true;
172- strictDeps = true;
173- nativeBuildInputs = [ joltAndroid ];
174-
175- buildPhase = ''
176- runHook preBuild
177-
178- export HOME="$TMPDIR"
179- mkdir -p project cross
180-
181- cat > project/deps.edn <<EOF
182- {:paths ["${self}/src" "${glimmer}/src" "${jolt-native}/jolt/glimmer-vidya/src"]}
183- EOF
184-
185- # The flat build, which is the one shape make-boot-file can take.
186- ( cd project && JOLT_NO_FLAT_SPLIT=1 jolt build -m frq.app -o app )
187-
188- cat > cross/compile.ss <<EOF
189- (import (chezscheme))
190- (load "${xpatch}")
191- (optimize-level 2)
192- (generate-inspector-information #f)
193- ;; Packed harder, not packed for the first time: fasl output is
194- ;; compressed already, but with lz4 at its fastest setting, and on
195- ;; this image that leaves 2.3 MB on the table. What reads it back is
196- ;; the kernel linked into libjoltapp, which has zlib because
197- ;; chezAndroid is configured ZLIB=-lz so nothing extra ships to
198- ;; decompress it.
199- ;;
200- ;; Less than the ratio of the whole file suggests (15.9 MB to 13.6):
201- ;; compression is per fasl entry rather than over the image.
202- (fasl-compressed #t)
203- (compress-format 'gzip)
204- (compress-level 'maximum)
205- (compile-file "$PWD/project/app.build/flat.ss" "$PWD/cross/flat.so")
206- (make-boot-file "$PWD/jolt.boot" '()
207- "${targetBoot}/petite.boot"
208- "${targetBoot}/scheme.boot"
209- "$PWD/cross/flat.so")
210- EOF
211-
212- SCHEMEHEAPDIRS="${chezAndroid}/ta6le/boot/ta6le" \
213- ${hostScheme} --script cross/compile.ss
214-
215- runHook postBuild
216- '';
217-
218- # scheme.h travels with the image because jolt_main.c includes it.
219- installPhase = ''
220- runHook preInstall
221- mkdir -p "$out"
222- cp jolt.boot "$out/jolt.boot"
223- cp ${targetBoot}/scheme.h "$out/scheme.h"
224- runHook postInstall
225- '';
226- };
227-
228- # The image travels as a blob in an object file's data section. The
229- # _binary_jolt_boot_{start,end} symbols jolt_main.c reads are named after the
230- # input *path*, so this copies the file somewhere it is called exactly
231- # `jolt.boot` before converting it.
232- joltBootObj = pkgs.runCommand "jolt-boot-obj" { } ''
233- cp ${joltBoot}/jolt.boot jolt.boot
234- ${ndkBin}/llvm-objcopy \
235- --input-target=binary --output-target=elf64-littleaarch64 \
236- --binary-architecture=aarch64 jolt.boot "$out"
237- '';
238-
239- # The glue: jolt-native's jolt_main.c over the boot image, linked against
240- # libvidya by name. --no-undefined is what makes a symbol the Scheme side
241- # registers but the ABI no longer exports a build failure here rather than a
242- # crash on the phone.
243- libjoltapp = pkgs.runCommand "libjoltapp.so" { } ''
244- mkdir -p lib
245- cp ${nativeLibs}/lib/${abi}/libvidya.so lib/libvidya.so
246-
247- # -DJOLT_WITHOUT_MOQ: the glue registers libjoltmoq's symbols for the
248- # Scheme side, and --no-undefined below turns each of those into a link
249- # error once the object is not linked. The define takes them out, which
250- # is the honest way to say "this app has no Rust media plane" rather
251- # than linking one to satisfy a reference nothing calls.
252- ${cc} -shared -fPIC -O2 -DJOLT_WITHOUT_MOQ -o "$out" \
253- ${jolt-native}/android/jolt_main.c \
254- ${joltBootObj} \
255- -I${joltBoot} \
256- -I${nativeLibs}/include \
257- -Llib \
258- ${chezAndroid}/tarm64le/boot/tarm64le/libkernel.a \
259- ${chezAndroid}/lz4/lib/liblz4.a \
260- -lvidya -landroid -llog -lz -ldl -lm -Wl,--no-undefined
261- '';
262-
263- # --- the Java half --------------------------------------------------------
264- # One class: the photo chooser's result has to land somewhere, and native
265- # code is not somewhere.
266- classesDex = pkgs.runCommand "classes.dex"
267- {
268- nativeBuildInputs = [ pkgs.jdk17 ];
269- } ''
270- mkdir -p classes out
271- # -encoding, because a build sandbox has no locale and javac then reads
272- # the source as US-ASCII on which the comments' em dashes are errors.
273- javac --release 17 -encoding UTF-8 --class-path ${androidJar} -d classes \
274- $(find ${self}/android/java -name '*.java')
275- ${buildTools}/d8 --min-api ${apiLevel} --output out $(find classes -name '*.class')
276- cp out/classes.dex "$out"
277- '';
278-
279- # --- the package ----------------------------------------------------------
280- # OpenSSL travels with the app because the platform's own is not ours to
281- # load: an app's linker namespace refuses /system/lib64/libssl.so, and
282- # without one there is no TLS on the phone at all.
283- # Built by the NDK rather than by pkgsCross.aarch64-android: that cross
284- # stdenv cannot build its own compiler-rt on this nixpkgs — os_version_check.c
285- # includes <pthread.h> and the sysroot it is handed has no such header — and
286- # an APK has no use for a second toolchain anyway. OpenSSL's own android-arm64
287- # target wants the NDK's llvm on PATH and takes the API level from the flag.
288- # The version is the one ~/.cache/frq-openssl-android was built from.
289- opensslAndroid = pkgs.stdenv.mkDerivation {
290- pname = "openssl-android";
291- version = "3.5.4";
292-
293- src = pkgs.fetchurl {
294- url = "https://github.com/openssl/openssl/releases/download/openssl-3.5.4/openssl-3.5.4.tar.gz";
295- sha256 = "16ay6ppxsky3qhg6573370iz93kihfwx9n5ipmlnjcam97w12wwn";
296- };
297-
298- strictDeps = true;
299- nativeBuildInputs = with pkgs; [ perl ];
300-
301- configurePhase = ''
302- runHook preConfigure
303- export ANDROID_NDK_ROOT="${ndkRoot}"
304- export PATH="${ndkBin}:$PATH"
305- # Through perl rather than as a program: its shebang is /usr/bin/env,
306- # which a build sandbox does not have.
307- perl ./Configure android-arm64 -D__ANDROID_API__=${apiLevel} \
308- shared no-tests no-docs \
309- --prefix="$out" --openssldir="$out/etc/ssl"
310- runHook postConfigure
311- '';
312-
313- # install_sw, not install: the rest of an OpenSSL install is for a machine
314- # that runs it, and this one only ships two .so files into an APK.
315- installTargets = [ "install_sw" ];
316-
317- dontStrip = true;
318- dontPatchELF = true;
319- };
320-
321- # The libraries are stored rather than deflated: the loader maps them
322- # straight out of the APK. The dex is read rather than mapped, so it may as
323- # well compress.
324- apkUnsigned = pkgs.runCommand "frq-unsigned.apk"
325- {
326- nativeBuildInputs = [ pkgs.zip ];
327- } ''
328- mkdir -p stage/lib/${abi}
329- cp ${nativeLibs}/lib/${abi}/libvidya.so stage/lib/${abi}/libvidya.so
330- cp ${libjoltapp} stage/lib/${abi}/libjoltapp.so
331- cp ${opensslAndroid.out}/lib/libssl.so stage/lib/${abi}/libssl.so
332- cp ${opensslAndroid.out}/lib/libcrypto.so stage/lib/${abi}/libcrypto.so
333- cp ${classesDex} stage/classes.dex
334- chmod -R u+w stage
335-
336- # Everything the loader needs is in .dynsym, and that is what --strip-all
337- # keeps: what goes is .symtab and the debug sections, which are read by a
338- # debugger and by nothing on the phone. Worth less than it used to be
339- # libjoltmoq and libc++_shared were most of it and are no longer packed
340- # but the release libraries still arrive unstripped, because jolt-native's
341- # own build does not strip them.
342- #
343- # Here rather than in the derivations that produce them: the inputs stay
344- # whole (a stripped libjoltapp is a worse thing to hand a debugger, and
345- # `nix build .#libjoltapp` is how it is looked at), and this is the one
346- # place that knows the difference between an object and a shipped one.
347- # The NDK's, not nixpkgs' the host strip has no opinion worth trusting
348- # about an arm64 object.
349- ${ndkBin}/llvm-strip --strip-all stage/lib/${abi}/*.so
350-
351- ${buildTools}/aapt2 link -o "$out" -I ${androidJar} \
352- --manifest ${self}/android/AndroidManifest.xml \
353- --min-sdk-version ${apiLevel} --target-sdk-version ${targetSdk} \
354- --version-code 1 --version-name ${version}
355-
356- ( cd stage && \
357- zip -q -0 "$out" lib/${abi}/libvidya.so lib/${abi}/libjoltapp.so \
358- lib/${abi}/libssl.so lib/${abi}/libcrypto.so && \
359- zip -q "$out" classes.dex )
360- '';
361-
362- # Aligned and signed with a debug key. The key is generated here rather than
363- # read from ~/.android, which is the one place this build is deliberately
364- # not what the builds before it did: a keystore outside the store would make
365- # the output
366- # depend on the machine, and a release key has no business in the store at
367- # all. So this output is installable and not reproducible — keytool stamps
368- # the certificate with the time — and anything meant for a store should be
369- # signed from .#apk-unsigned instead.
370- apk = pkgs.runCommand "frq-${version}.apk"
371- {
372- nativeBuildInputs = [ pkgs.jdk17 ];
373- meta = {
374- description = "frq for Android, debug-signed";
375- platforms = [ "x86_64-linux" ];
376- };
377- } ''
378- export HOME="$TMPDIR"
379- keytool -genkeypair -keystore debug.keystore \
380- -storepass android -keypass android -alias androiddebugkey \
381- -keyalg RSA -keysize 2048 -validity 10000 \
382- -dname 'CN=Android Debug,O=Android,C=US'
383-
384- ${buildTools}/zipalign -f -p 4 ${apkUnsigned} aligned.apk
385- ${buildTools}/apksigner sign --ks debug.keystore \
386- --ks-key-alias androiddebugkey \
387- --ks-pass pass:android --key-pass pass:android \
388- --out "$out" aligned.apk
389- ${buildTools}/apksigner verify "$out"
390- '';
391-in
392-{
393- inherit chezAndroid joltBoot libjoltapp classesDex apk;
394- apk-unsigned = apkUnsigned;
395-}
deleted file mode 100644
@@ -1,395 +0,0 @@
1-# The APK, as derivations.
2-#
3-# The only APK build there is: this replaced a buck2 graph and the script
4-# before it, both of which named an Android SDK, an NDK, a hand-built Chez
5-# cross target and an OpenSSL by absolute path and stopped when one was
6-# missing. Every one of those is built or fetched here instead, so this works
7-# from nothing on a machine with none of them and no ~/.cache at all:
8-#
9-# nix build .#apk
10-#
11-# `just apk` is the same build with the store path handed to adb afterwards;
12-# see the `apk` recipe in the justfile.
13-#
14-# On a machine with a remote builder configured, prefer
15-#
16-# nix build .#apk --store ssh-ng://eu.nixbuild.net --eval-store auto
17-#
18-# rather than letting `builders` do it. With `builders`, nix copies the output
19-# of every remotely-built derivation back, and androidenv's NDK is both
20-# `preferLocalBuild` and absent from cache.nixos.org — so the 3.1 GB unpacked
21-# toolchain is built here and uploaded. With the remote as the *store* the
22-# whole graph stays there, only .drv files go up, and the builder fetches
23-# Google's zip over its own link.
24-#
25-# The steps are the ones the graph before it ran, in the same order; where a
26-# genrule read a path out of `read_root_config`, a derivation takes an
27-# argument.
28-{ pkgs, lib, self, chez-src, jolt-native, jolt-native-android, glimmer, joltAndroid, androidSdk, ndk }:
29-
30-let
31- # What the APK targets, in the three spellings the tools want it in.
32- apiLevel = "28";
33- targetSdk = "36";
34- abi = "arm64-v8a";
35- package = "uk.nandi.frq";
36- version = "0.1.0";
37-
38- sdk = "${androidSdk}/libexec/android-sdk";
39- buildTools = "${sdk}/build-tools/36.0.0";
40- androidJar = "${sdk}/platforms/android-${targetSdk}/android.jar";
41-
42- # The NDK's clang finds its sysroot, resource directory and the rest of LLVM
43- # relative to itself, so it is named by path rather than copied anywhere.
44- # androidenv installs the tree under libexec/android-sdk and leaves
45- # `ndk-bundle` pointing at the versioned directory beside it.
46- ndkRoot = "${ndk}/libexec/android-sdk/ndk-bundle";
47- ndkBin = "${ndkRoot}/toolchains/llvm/prebuilt/linux-x86_64/bin";
48- cc = "${ndkBin}/aarch64-linux-android${apiLevel}-clang";
49-
50- # What comes out of jolt-native's CI, as a flake input rather than a fetchurl:
51- # nix unpacks a tarball input itself, and flake.lock holds the digest that
52- # used to be written here by hand. `just bump` moves it with
53- # `nix flake update jolt-native-android` and nothing in this file changes.
54- #
55- # One archive. libvidya (the retained-tree UI) is what is taken from it, and
56- # include/ beside it for the glue's headers.
57- #
58- # It also carries libjoltmoq and the libc++_shared that object needs, and
59- # neither is packed any more. frq's media plane is `frq.av.plane` now — MoQ
60- # over QUIC through libmoq_ffi, Opus, H.264 and the devices — and none of
61- # that is built for Android yet, so a phone has no media plane at all and
62- # `frq.av/available?` says so. Shipping the Rust one it no longer loads was
63- # seventeen megabytes of APK for an object nothing opens.
64- nativeLibs = jolt-native-android;
65-
66- # No libc++_shared. It was here because openh264 is C++ and libjoltmoq
67- # carried it as a DT_NEEDED; libvidya links neither — its NEEDED list is
68- # libdl, libandroid, liblog, libm and libc — so with the media plane gone
69- # the C++ runtime has nothing to serve.
70-
71- # --- Chez's arm64 cross target ------------------------------------------
72- # The one piece with no nixpkgs equivalent: `pkgs.chez` builds a Scheme for
73- # this machine, and what the boot image needs is Chez's `tarm64le` workarea —
74- # the target boot files, the cross compiler's xpatch, and the arm64
75- # libkernel.a that libjoltapp links.
76- #
77- # Three builds in one derivation, because each needs the one before it:
78- #
79- # ta6le a host Scheme, which is what cross-compiles anything
80- # boot XM=... the target's boot files and xpatch, made by that host
81- # tarm64le the target kernel, compiled by the NDK
82- #
83- # The flags are the ones the hand-built tree under ~/.cache/vidya-chez-android
84- # was configured with, read back out of its Mf-config. zlib is Android's own
85- # (`-lz`, which Bionic has); lz4 is the in-tree submodule, built for arm64
86- # here because a cross configure links it rather than building it.
87- chezAndroid = pkgs.stdenv.mkDerivation {
88- pname = "chez-scheme-android";
89- version = "10.4.1";
90- src = chez-src;
91-
92- strictDeps = true;
93- nativeBuildInputs = with pkgs; [ gnumake which ];
94-
95- dontConfigure = true;
96-
97- buildPhase = ''
98- runHook preBuild
99-
100- # Both workareas turn off the expression editor's two dependencies, which
101- # is what ~/.cache/vidya-chez-android was configured with (its Mf-config
102- # has empty cursesLib/ncursesLib, and disablex11=yes on the target). The
103- # host Scheme here is only ever a cross compiler, and Bionic has no
104- # curses.h at all so on the target it is not a preference but a
105- # requirement.
106- ./configure -m=ta6le --disable-x11 --disable-curses CC_FOR_BUILD="$CC"
107- make -j"$NIX_BUILD_CORES"
108- make boot XM=tarm64le -j"$NIX_BUILD_CORES"
109-
110- # lz4 for the phone, not for this machine: the host build above left an
111- # x86_64 liblz4.a in the same place, and the cross link needs it gone.
112- make -C lz4/lib clean
113- make -C lz4/lib liblz4.a -j"$NIX_BUILD_CORES" \
114- CC=${cc} AR=${ndkBin}/llvm-ar
115-
116- # --disable-auto-flags stops configure appending -lrt and -lpthread, which
117- # is what its unix branch does for a glibc host and what Bionic has no
118- # separate libraries for both live in libc there. Everything it would
119- # otherwise add is passed explicitly below, matching the Mf-config of
120- # the tree this was reconstructed from.
121- ./configure -m=tarm64le --cross --disable-x11 --disable-curses \
122- --disable-auto-flags \
123- LIBS="-ldl -lm" \
124- CC=${cc} \
125- AR=${ndkBin}/llvm-ar \
126- CC_FOR_BUILD="$CC" \
127- ZLIB=-lz \
128- LZ4="$PWD/lz4/lib/liblz4.a" \
129- CPPFLAGS="-I$PWD/lz4/lib" \
130- CFLAGS="-O2 -D_REENTRANT -pthread -fPIC"
131- make -j"$NIX_BUILD_CORES"
132-
133- runHook postBuild
134- '';
135-
136- # The whole workarea, at the paths CHEZ_ANDROID means: the host Scheme
137- # loads xpatch out of xc-tarm64le/s, and the link below reads two archives
138- # from elsewhere in the tree. Pruning it would only be guessing at which
139- # of those the cross compiler still opens.
140- installPhase = ''
141- runHook preInstall
142- mkdir -p "$out"
143- cp -r . "$out/"
144- runHook postInstall
145- '';
146-
147- # A Scheme built for this machine and a kernel built for another one; the
148- # usual fixups have an opinion about both, and neither wants it.
149- dontStrip = true;
150- dontPatchELF = true;
151- };
152-
153- hostScheme = "${chezAndroid}/ta6le/bin/ta6le/scheme";
154- targetBoot = "${chezAndroid}/boot/tarm64le";
155- xpatch = "${chezAndroid}/xc-tarm64le/s/xpatch";
156-
157- # --- the Jolt half --------------------------------------------------------
158- # frq's Scheme, cross-compiled to an arm64 boot image. The deps.edn below is
159- # written out by hand rather than resolved: there is no dependency resolution
160- # inside a cross compile, so every source root deps.edn would have resolved is
161- # named as a :path instead.
162- #
163- # The jolt that runs it is the fork, not upstream and not the one the desktop
164- # package builds: upstream reads the socket address out of `struct addrinfo`
165- # at glibc's offset, which on Bionic is `ai_canonname`, and an APK built with
166- # it cannot open a TLS connection at all.
167- joltBoot = pkgs.stdenv.mkDerivation {
168- pname = "frq-jolt-boot";
169- inherit version;
170-
171- dontUnpack = true;
172- strictDeps = true;
173- nativeBuildInputs = [ joltAndroid ];
174-
175- buildPhase = ''
176- runHook preBuild
177-
178- export HOME="$TMPDIR"
179- mkdir -p project cross
180-
181- cat > project/deps.edn <<EOF
182- {:paths ["${self}/src" "${glimmer}/src" "${jolt-native}/jolt/glimmer-vidya/src"]}
183- EOF
184-
185- # The flat build, which is the one shape make-boot-file can take.
186- ( cd project && JOLT_NO_FLAT_SPLIT=1 jolt build -m frq.app -o app )
187-
188- cat > cross/compile.ss <<EOF
189- (import (chezscheme))
190- (load "${xpatch}")
191- (optimize-level 2)
192- (generate-inspector-information #f)
193- ;; Packed harder, not packed for the first time: fasl output is
194- ;; compressed already, but with lz4 at its fastest setting, and on
195- ;; this image that leaves 2.3 MB on the table. What reads it back is
196- ;; the kernel linked into libjoltapp, which has zlib because
197- ;; chezAndroid is configured ZLIB=-lz so nothing extra ships to
198- ;; decompress it.
199- ;;
200- ;; Less than the ratio of the whole file suggests (15.9 MB to 13.6):
201- ;; compression is per fasl entry rather than over the image.
202- (fasl-compressed #t)
203- (compress-format 'gzip)
204- (compress-level 'maximum)
205- (compile-file "$PWD/project/app.build/flat.ss" "$PWD/cross/flat.so")
206- (make-boot-file "$PWD/jolt.boot" '()
207- "${targetBoot}/petite.boot"
208- "${targetBoot}/scheme.boot"
209- "$PWD/cross/flat.so")
210- EOF
211-
212- SCHEMEHEAPDIRS="${chezAndroid}/ta6le/boot/ta6le" \
213- ${hostScheme} --script cross/compile.ss
214-
215- runHook postBuild
216- '';
217-
218- # scheme.h travels with the image because jolt_main.c includes it.
219- installPhase = ''
220- runHook preInstall
221- mkdir -p "$out"
222- cp jolt.boot "$out/jolt.boot"
223- cp ${targetBoot}/scheme.h "$out/scheme.h"
224- runHook postInstall
225- '';
226- };
227-
228- # The image travels as a blob in an object file's data section. The
229- # _binary_jolt_boot_{start,end} symbols jolt_main.c reads are named after the
230- # input *path*, so this copies the file somewhere it is called exactly
231- # `jolt.boot` before converting it.
232- joltBootObj = pkgs.runCommand "jolt-boot-obj" { } ''
233- cp ${joltBoot}/jolt.boot jolt.boot
234- ${ndkBin}/llvm-objcopy \
235- --input-target=binary --output-target=elf64-littleaarch64 \
236- --binary-architecture=aarch64 jolt.boot "$out"
237- '';
238-
239- # The glue: jolt-native's jolt_main.c over the boot image, linked against
240- # libvidya by name. --no-undefined is what makes a symbol the Scheme side
241- # registers but the ABI no longer exports a build failure here rather than a
242- # crash on the phone.
243- libjoltapp = pkgs.runCommand "libjoltapp.so" { } ''
244- mkdir -p lib
245- cp ${nativeLibs}/lib/${abi}/libvidya.so lib/libvidya.so
246-
247- # -DJOLT_WITHOUT_MOQ: the glue registers libjoltmoq's symbols for the
248- # Scheme side, and --no-undefined below turns each of those into a link
249- # error once the object is not linked. The define takes them out, which
250- # is the honest way to say "this app has no Rust media plane" rather
251- # than linking one to satisfy a reference nothing calls.
252- ${cc} -shared -fPIC -O2 -DJOLT_WITHOUT_MOQ -o "$out" \
253- ${jolt-native}/android/jolt_main.c \
254- ${joltBootObj} \
255- -I${joltBoot} \
256- -I${nativeLibs}/include \
257- -Llib \
258- ${chezAndroid}/tarm64le/boot/tarm64le/libkernel.a \
259- ${chezAndroid}/lz4/lib/liblz4.a \
260- -lvidya -landroid -llog -lz -ldl -lm -Wl,--no-undefined
261- '';
262-
263- # --- the Java half --------------------------------------------------------
264- # One class: the photo chooser's result has to land somewhere, and native
265- # code is not somewhere.
266- classesDex = pkgs.runCommand "classes.dex"
267- {
268- nativeBuildInputs = [ pkgs.jdk17 ];
269- } ''
270- mkdir -p classes out
271- # -encoding, because a build sandbox has no locale and javac then reads
272- # the source as US-ASCII on which the comments' em dashes are errors.
273- javac --release 17 -encoding UTF-8 --class-path ${androidJar} -d classes \
274- $(find ${self}/android/java -name '*.java')
275- ${buildTools}/d8 --min-api ${apiLevel} --output out $(find classes -name '*.class')
276- cp out/classes.dex "$out"
277- '';
278-
279- # --- the package ----------------------------------------------------------
280- # OpenSSL travels with the app because the platform's own is not ours to
281- # load: an app's linker namespace refuses /system/lib64/libssl.so, and
282- # without one there is no TLS on the phone at all.
283- # Built by the NDK rather than by pkgsCross.aarch64-android: that cross
284- # stdenv cannot build its own compiler-rt on this nixpkgs — os_version_check.c
285- # includes <pthread.h> and the sysroot it is handed has no such header — and
286- # an APK has no use for a second toolchain anyway. OpenSSL's own android-arm64
287- # target wants the NDK's llvm on PATH and takes the API level from the flag.
288- # The version is the one ~/.cache/frq-openssl-android was built from.
289- opensslAndroid = pkgs.stdenv.mkDerivation {
290- pname = "openssl-android";
291- version = "3.5.4";
292-
293- src = pkgs.fetchurl {
294- url = "https://github.com/openssl/openssl/releases/download/openssl-3.5.4/openssl-3.5.4.tar.gz";
295- sha256 = "16ay6ppxsky3qhg6573370iz93kihfwx9n5ipmlnjcam97w12wwn";
296- };
297-
298- strictDeps = true;
299- nativeBuildInputs = with pkgs; [ perl ];
300-
301- configurePhase = ''
302- runHook preConfigure
303- export ANDROID_NDK_ROOT="${ndkRoot}"
304- export PATH="${ndkBin}:$PATH"
305- # Through perl rather than as a program: its shebang is /usr/bin/env,
306- # which a build sandbox does not have.
307- perl ./Configure android-arm64 -D__ANDROID_API__=${apiLevel} \
308- shared no-tests no-docs \
309- --prefix="$out" --openssldir="$out/etc/ssl"
310- runHook postConfigure
311- '';
312-
313- # install_sw, not install: the rest of an OpenSSL install is for a machine
314- # that runs it, and this one only ships two .so files into an APK.
315- installTargets = [ "install_sw" ];
316-
317- dontStrip = true;
318- dontPatchELF = true;
319- };
320-
321- # The libraries are stored rather than deflated: the loader maps them
322- # straight out of the APK. The dex is read rather than mapped, so it may as
323- # well compress.
324- apkUnsigned = pkgs.runCommand "frq-unsigned.apk"
325- {
326- nativeBuildInputs = [ pkgs.zip ];
327- } ''
328- mkdir -p stage/lib/${abi}
329- cp ${nativeLibs}/lib/${abi}/libvidya.so stage/lib/${abi}/libvidya.so
330- cp ${libjoltapp} stage/lib/${abi}/libjoltapp.so
331- cp ${opensslAndroid.out}/lib/libssl.so stage/lib/${abi}/libssl.so
332- cp ${opensslAndroid.out}/lib/libcrypto.so stage/lib/${abi}/libcrypto.so
333- cp ${classesDex} stage/classes.dex
334- chmod -R u+w stage
335-
336- # Everything the loader needs is in .dynsym, and that is what --strip-all
337- # keeps: what goes is .symtab and the debug sections, which are read by a
338- # debugger and by nothing on the phone. Worth less than it used to be
339- # libjoltmoq and libc++_shared were most of it and are no longer packed
340- # but the release libraries still arrive unstripped, because jolt-native's
341- # own build does not strip them.
342- #
343- # Here rather than in the derivations that produce them: the inputs stay
344- # whole (a stripped libjoltapp is a worse thing to hand a debugger, and
345- # `nix build .#libjoltapp` is how it is looked at), and this is the one
346- # place that knows the difference between an object and a shipped one.
347- # The NDK's, not nixpkgs' the host strip has no opinion worth trusting
348- # about an arm64 object.
349- ${ndkBin}/llvm-strip --strip-all stage/lib/${abi}/*.so
350-
351- ${buildTools}/aapt2 link -o "$out" -I ${androidJar} \
352- --manifest ${self}/android/AndroidManifest.xml \
353- --min-sdk-version ${apiLevel} --target-sdk-version ${targetSdk} \
354- --version-code 1 --version-name ${version}
355-
356- ( cd stage && \
357- zip -q -0 "$out" lib/${abi}/libvidya.so lib/${abi}/libjoltapp.so \
358- lib/${abi}/libssl.so lib/${abi}/libcrypto.so && \
359- zip -q "$out" classes.dex )
360- '';
361-
362- # Aligned and signed with a debug key. The key is generated here rather than
363- # read from ~/.android, which is the one place this build is deliberately
364- # not what the builds before it did: a keystore outside the store would make
365- # the output
366- # depend on the machine, and a release key has no business in the store at
367- # all. So this output is installable and not reproducible — keytool stamps
368- # the certificate with the time — and anything meant for a store should be
369- # signed from .#apk-unsigned instead.
370- apk = pkgs.runCommand "frq-${version}.apk"
371- {
372- nativeBuildInputs = [ pkgs.jdk17 ];
373- meta = {
374- description = "frq for Android, debug-signed";
375- platforms = [ "x86_64-linux" ];
376- };
377- } ''
378- export HOME="$TMPDIR"
379- keytool -genkeypair -keystore debug.keystore \
380- -storepass android -keypass android -alias androiddebugkey \
381- -keyalg RSA -keysize 2048 -validity 10000 \
382- -dname 'CN=Android Debug,O=Android,C=US'
383-
384- ${buildTools}/zipalign -f -p 4 ${apkUnsigned} aligned.apk
385- ${buildTools}/apksigner sign --ks debug.keystore \
386- --ks-key-alias androiddebugkey \
387- --ks-pass pass:android --key-pass pass:android \
388- --out "$out" aligned.apk
389- ${buildTools}/apksigner verify "$out"
390- '';
391-in
392-{
393- inherit chezAndroid joltBoot libjoltapp classesDex apk;
394- apk-unsigned = apkUnsigned;
395-}
modified src/frq/app.clj +14 -19
@@ -1,16 +1,27 @@
11 (ns frq.app
2- "frq — a freeq client written as glimmer components, painted by Vidya/egui.
2+ "frq a freeq client, as glimmer components.
33
44 The screens follow sleek's: connect, chats, chat, discover, settings, under a
55 tab bar. Where sleek draws them in Rust against egui directly, here each is a
6- hiccup component over the same widgets."
6+ hiccup component over glimmer's widget tags.
7+
8+ No backend and no `-main`. This namespace is the screens and nothing else —
9+ which backend paints them is the entry point's business, and there is one
10+ entry point per backend: `frq.cosmic` for the window, `frq.tui` for the
11+ terminal. Each hands `start!` the timers and the window measurements its own
12+ loop can give."
713 (:require [clojure.string :as str]
814 [glimmer.ratom :as r :refer [atom]]
915 [glimmer.core :as ui]
10- [glimmer-jvui.core :as gui]
1116 [frq.av :as av]
1217 [frq.avatars :as avatars]
1318 [frq.clock :as clock]
19+ ;; For the side effect: this installs the desktop's answers to
20+ ;; `frq.io`, which everything under common/ asks its questions of.
21+ ;; Required here rather than in each -main because frq.tui and
22+ ;; frq.cosmic both come through frq.app, and the Flutter entry
23+ ;; point requires frq.io.dart instead and never loads this file.
24+ [frq.io.jolt]
1425 [frq.glyphs :as glyphs]
1526 [frq.media :as media]
1627 [frq.platform :as platform]
@@ -1816,19 +1827,3 @@
18161827 (reset! shown title)
18171828 (title! title))))))
18181829 nil)
1819-
1820-(defn -main [& _]
1821- (start! {:after! gui/after!
1822- :every! gui/every!
1823- :title! gui/set-title!
1824- ;; The height comes off the same tick, from `screen-size` rather
1825- ;; than a second call: it is the window's content size, and the
1826- ;; pictures in the conversation are sized against it.
1827- :measure! (fn []
1828- (let [w (gui/window-width)
1829- h (long (second (gui/screen-size)))]
1830- (when (not= w @s/window-width)
1831- (reset! s/window-width w))
1832- (when (not= h @s/window-height)
1833- (reset! s/window-height h))))})
1834- (ui/run app :title "frq" :width 520 :height 860))
@@ -1,16 +1,27 @@
1 (ns frq.app1 (ns frq.app
2- "frq — a freeq client written as glimmer components, painted by Vidya/egui.2+ "frq a freeq client, as glimmer components.
3 3
4 The screens follow sleek's: connect, chats, chat, discover, settings, under a4 The screens follow sleek's: connect, chats, chat, discover, settings, under a
5 tab bar. Where sleek draws them in Rust against egui directly, here each is a5 tab bar. Where sleek draws them in Rust against egui directly, here each is a
6- hiccup component over the same widgets."6+ hiccup component over glimmer's widget tags.
7+
8+ No backend and no `-main`. This namespace is the screens and nothing else —
9+ which backend paints them is the entry point's business, and there is one
10+ entry point per backend: `frq.cosmic` for the window, `frq.tui` for the
11+ terminal. Each hands `start!` the timers and the window measurements its own
12+ loop can give."
7 (:require [clojure.string :as str]13 (:require [clojure.string :as str]
8 [glimmer.ratom :as r :refer [atom]]14 [glimmer.ratom :as r :refer [atom]]
9 [glimmer.core :as ui]15 [glimmer.core :as ui]
10- [glimmer-jvui.core :as gui]
11 [frq.av :as av]16 [frq.av :as av]
12 [frq.avatars :as avatars]17 [frq.avatars :as avatars]
13 [frq.clock :as clock]18 [frq.clock :as clock]
19+ ;; For the side effect: this installs the desktop's answers to
20+ ;; `frq.io`, which everything under common/ asks its questions of.
21+ ;; Required here rather than in each -main because frq.tui and
22+ ;; frq.cosmic both come through frq.app, and the Flutter entry
23+ ;; point requires frq.io.dart instead and never loads this file.
24+ [frq.io.jolt]
14 [frq.glyphs :as glyphs]25 [frq.glyphs :as glyphs]
15 [frq.media :as media]26 [frq.media :as media]
16 [frq.platform :as platform]27 [frq.platform :as platform]
@@ -1816,19 +1827,3 @@
1816 (reset! shown title)1827 (reset! shown title)
1817 (title! title))))))1828 (title! title))))))
1818 nil)1829 nil)
1819-
1820-(defn -main [& _]
1821- (start! {:after! gui/after!
1822- :every! gui/every!
1823- :title! gui/set-title!
1824- ;; The height comes off the same tick, from `screen-size` rather
1825- ;; than a second call: it is the window's content size, and the
1826- ;; pictures in the conversation are sized against it.
1827- :measure! (fn []
1828- (let [w (gui/window-width)
1829- h (long (second (gui/screen-size)))]
1830- (when (not= w @s/window-width)
1831- (reset! s/window-width w))
1832- (when (not= h @s/window-height)
1833- (reset! s/window-height h))))})
1834- (ui/run app :title "frq" :width 520 :height 860))
modified src/frq/av.clj +8 -8
@@ -27,7 +27,7 @@
2727 borrowed pointers because a frame at thirty a second cannot afford a copy."
2828 (:require [clojure.string :as str]
2929 [glimmer.ratom :as r :refer [atom]]
30- [glimmer-jvui.core :as gui]
30+ [frq.platform :as platform]
3131 [frq.irc :as irc]
3232 [frq.av.dial :as dial]
3333 [frq.av.plane :as plane]
@@ -258,7 +258,7 @@
258258 (defn- drop-feeds!
259259 "Stop painting every feed we have been pushing."
260260 []
261- (doseq [k @painted-feeds] (gui/frame-drop! k))
261+ (doseq [k @painted-feeds] (platform/frame-drop! k))
262262 (reset! painted-feeds #{})
263263 (reset! feeds []))
264264
@@ -412,7 +412,7 @@
412412 ;; The tile goes when the camera does: the plane stops publishing, so no
413413 ;; frame arrives to replace the last one.
414414 (when-not on?
415- (gui/frame-drop! local-feed)
415+ (platform/frame-drop! local-feed)
416416 (swap! painted-feeds disj local-feed)))
417417
418418 ;; Switching a device mid-call reopens it, which the plane can only do by
@@ -478,7 +478,7 @@
478478 []
479479 (doseq [{:keys [key w h rgba]} (plane/poll-frames!)]
480480 (when (and (seq key) (pos? w) (pos? h) rgba)
481- (gui/frame-rgba! key w h rgba)
481+ (platform/frame-rgba! key w h rgba)
482482 (swap! painted-feeds conj key))))
483483
484484 (defn- order-feeds
@@ -502,7 +502,7 @@
502502 []
503503 (let [live (plane/feed-keys)]
504504 (doseq [k (remove live @painted-feeds)]
505- (gui/frame-drop! k)
505+ (platform/frame-drop! k)
506506 (swap! painted-feeds disj k))
507507 (let [ordered (order-feeds live)]
508508 (when-not (= ordered @feeds)
@@ -519,7 +519,7 @@
519519 one would re-render the wall for a third of a point of difference nobody can
520520 see."
521521 []
522- (let [[w h] (gui/screen-size)
522+ (let [[w h] (platform/screen-size)
523523 w (long w)
524524 h (long h)]
525525 (when-not (= w @window-width) (reset! window-width w))
@@ -528,7 +528,7 @@
528528 (defn pump!
529529 "One frame's worth of the media plane. Cheap when no call is up.
530530
531- Runs on the loop thread — `gui/frame-rgba!` may not be called from anywhere
531+ Runs on the loop thread — `platform/frame-rgba!` may not be called from anywhere
532532 else, and neither may anything that touches a node."
533533 []
534534 (pump-window!)
@@ -559,7 +559,7 @@
559559 arrives: polling slower than the window paints would show every other frame."
560560 []
561561 (when (available?)
562- (gui/every! 16 pump!)))
562+ (platform/every! 16 pump!)))
563563
564564 (defn tiles
565565 "Everyone with a picture in the current call, the self-view last.
@@ -27,7 +27,7 @@
27 borrowed pointers because a frame at thirty a second cannot afford a copy."27 borrowed pointers because a frame at thirty a second cannot afford a copy."
28 (:require [clojure.string :as str]28 (:require [clojure.string :as str]
29 [glimmer.ratom :as r :refer [atom]]29 [glimmer.ratom :as r :refer [atom]]
30- [glimmer-jvui.core :as gui]30+ [frq.platform :as platform]
31 [frq.irc :as irc]31 [frq.irc :as irc]
32 [frq.av.dial :as dial]32 [frq.av.dial :as dial]
33 [frq.av.plane :as plane]33 [frq.av.plane :as plane]
@@ -258,7 +258,7 @@
258 (defn- drop-feeds!258 (defn- drop-feeds!
259 "Stop painting every feed we have been pushing."259 "Stop painting every feed we have been pushing."
260 []260 []
261- (doseq [k @painted-feeds] (gui/frame-drop! k))261+ (doseq [k @painted-feeds] (platform/frame-drop! k))
262 (reset! painted-feeds #{})262 (reset! painted-feeds #{})
263 (reset! feeds []))263 (reset! feeds []))
264 264
@@ -412,7 +412,7 @@
412 ;; The tile goes when the camera does: the plane stops publishing, so no412 ;; The tile goes when the camera does: the plane stops publishing, so no
413 ;; frame arrives to replace the last one.413 ;; frame arrives to replace the last one.
414 (when-not on?414 (when-not on?
415- (gui/frame-drop! local-feed)415+ (platform/frame-drop! local-feed)
416 (swap! painted-feeds disj local-feed)))416 (swap! painted-feeds disj local-feed)))
417 417
418 ;; Switching a device mid-call reopens it, which the plane can only do by418 ;; Switching a device mid-call reopens it, which the plane can only do by
@@ -478,7 +478,7 @@
478 []478 []
479 (doseq [{:keys [key w h rgba]} (plane/poll-frames!)]479 (doseq [{:keys [key w h rgba]} (plane/poll-frames!)]
480 (when (and (seq key) (pos? w) (pos? h) rgba)480 (when (and (seq key) (pos? w) (pos? h) rgba)
481- (gui/frame-rgba! key w h rgba)481+ (platform/frame-rgba! key w h rgba)
482 (swap! painted-feeds conj key))))482 (swap! painted-feeds conj key))))
483 483
484 (defn- order-feeds484 (defn- order-feeds
@@ -502,7 +502,7 @@
502 []502 []
503 (let [live (plane/feed-keys)]503 (let [live (plane/feed-keys)]
504 (doseq [k (remove live @painted-feeds)]504 (doseq [k (remove live @painted-feeds)]
505- (gui/frame-drop! k)505+ (platform/frame-drop! k)
506 (swap! painted-feeds disj k))506 (swap! painted-feeds disj k))
507 (let [ordered (order-feeds live)]507 (let [ordered (order-feeds live)]
508 (when-not (= ordered @feeds)508 (when-not (= ordered @feeds)
@@ -519,7 +519,7 @@
519 one would re-render the wall for a third of a point of difference nobody can519 one would re-render the wall for a third of a point of difference nobody can
520 see."520 see."
521 []521 []
522- (let [[w h] (gui/screen-size)522+ (let [[w h] (platform/screen-size)
523 w (long w)523 w (long w)
524 h (long h)]524 h (long h)]
525 (when-not (= w @window-width) (reset! window-width w))525 (when-not (= w @window-width) (reset! window-width w))
@@ -528,7 +528,7 @@
528 (defn pump!528 (defn pump!
529 "One frame's worth of the media plane. Cheap when no call is up.529 "One frame's worth of the media plane. Cheap when no call is up.
530 530
531- Runs on the loop thread — `gui/frame-rgba!` may not be called from anywhere531+ Runs on the loop thread — `platform/frame-rgba!` may not be called from anywhere
532 else, and neither may anything that touches a node."532 else, and neither may anything that touches a node."
533 []533 []
534 (pump-window!)534 (pump-window!)
@@ -559,7 +559,7 @@
559 arrives: polling slower than the window paints would show every other frame."559 arrives: polling slower than the window paints would show every other frame."
560 []560 []
561 (when (available?)561 (when (available?)
562- (gui/every! 16 pump!)))562+ (platform/every! 16 pump!)))
563 563
564 (defn tiles564 (defn tiles
565 "Everyone with a picture in the current call, the self-view last.565 "Everyone with a picture in the current call, the self-view last.
deleted src/frq/clock.clj +0 -96
deleted file mode 100644
@@ -1,96 +0,0 @@
1-(ns frq.clock
2- "Wall-clock time, in the reader's own zone.
3-
4- Messages carry an IRCv3 `time` tag in UTC; a channel is read in local time.
5- jolt's time library works in epoch days and nanoseconds-of-day, and the
6- offset needs a zone name, which no single call hands over — so the zone is
7- found once here and everything else is arithmetic."
8- (:require [clojure.string :as str]
9- [jolt.host :as host]
10- [jolt.time.local :as local]))
11-
12-(def ^:private zone
13- ;; TZ if it is set, otherwise whatever /etc/localtime points at, otherwise
14- ;; UTC — which is wrong by hours but never wrong by a day's worth of parsing.
15- ;;
16- ;; /etc/localtime is not always a symlink. In a container it is often the
17- ;; bytes themselves — distrobox's Arch has it as an overlay-mounted regular
18- ;; file — and then `readlink -f` answers with the path it was given and there
19- ;; is no zone name to read out of it at all. That fell through to UTC, so a
20- ;; channel read seven hours ahead of the clock on the same screen.
21- ;;
22- ;; The colon form is what libc has for exactly this: `TZ=:/etc/localtime`
23- ;; names the tzfile by path rather than by zone, and tzset reads it with the
24- ;; transitions intact — the offset it gives is DST-correct per instant, not a
25- ;; fixed one. Nothing downstream cares that this is a path: the zone is only
26- ;; ever handed back to tz-offset-seconds, never shown.
27- (delay
28- (or (let [tz (host/getenv "TZ")] (when (seq tz) tz))
29- (try
30- (second (re-find #"/zoneinfo/(.+)$"
31- (str/trim (str (host/sh-out "readlink -f /etc/localtime")))))
32- (catch Exception _ nil))
33- (when (host/file-exists? "/etc/localtime") ":/etc/localtime")
34- ;; Android has neither: no TZ in the environment and no /etc/localtime
35- ;; to point at or read, so both branches above come up empty and the
36- ;; APK read every channel in UTC. The zone is a system property there,
37- ;; and bionic resolves the name against its own tzdata just as glibc
38- ;; resolves it against /usr/share/zoneinfo — so the name is all we need.
39- (try
40- (let [tz (str/trim (str (host/sh-out "getprop persist.sys.timezone")))]
41- (when (seq tz) tz))
42- (catch Exception _ nil))
43- "UTC")))
44-
45-(defn now-ms [] (quot (host/wall-nanos) 1000000))
46-
47-(defn parse-time-tag
48- "The `time=` value of an IRCv3 tag string as epoch milliseconds, or nil.
49-
50- Fixed format, always UTC: `2026-08-30T07:05:09.000Z`. Read by hand rather
51- than through a parser, since this runs once per message of a hundred-message
52- backlog."
53- [tags]
54- (when-let [[_ y mo d h mi s] (re-find #"time=(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})"
55- (or tags ""))]
56- (let [[y mo d h mi s] (map parse-long [y mo d h mi s])
57- ;; days from the civil date, by Howard Hinnant's算 — the same one
58- ;; jolt's own local-date uses in reverse.
59- y (if (<= mo 2) (dec y) y)
60- era (quot (if (>= y 0) y (- y 399)) 400)
61- yoe (- y (* era 400))
62- doy (+ (quot (+ (* 153 (+ mo (if (> mo 2) -3 9))) 2) 5) (dec d))
63- doe (+ (* yoe 365) (quot yoe 4) (- (quot yoe 100)) doy)
64- days (+ (* era 146097) doe -719468)]
65- (* 1000 (+ (* days 86400) (* h 3600) (* mi 60) s)))))
66-
67-(defn- local-parts
68- "`[date hour minute]` in the reader's zone, the hour on a 24-clock."
69- [ms]
70- (let [secs (quot ms 1000)
71- secs (+ secs (host/tz-offset-seconds @zone secs))
72- text (str (local/local-dt (Math/floorDiv secs 86400)
73- (* (Math/floorMod secs 86400) 1000000000)))]
74- [(subs text 0 10) (parse-long (subs text 11 13)) (subs text 14 16)]))
75-
76-(defn clock-time
77- "A twelve-hour time: `9:05 AM`, `12:30 PM`."
78- [ms]
79- (let [[_ hour minute] (local-parts ms)
80- display (cond (zero? hour) 12
81- (> hour 12) (- hour 12)
82- :else hour)]
83- (str display ":" minute " " (if (< hour 12) "AM" "PM"))))
84-
85-(defn day [ms] (first (local-parts ms)))
86-
87-(defn day-label
88- "The heading for a day's messages: today and yesterday by name, anything
89- older by date."
90- [ms]
91- (let [d (day ms)
92- today (day (now-ms))
93- yesterday (day (- (now-ms) 86400000))]
94- (cond (= d today) "Today"
95- (= d yesterday) "Yesterday"
96- :else d)))
deleted file mode 100644
@@ -1,96 +0,0 @@
1-(ns frq.clock
2- "Wall-clock time, in the reader's own zone.
3-
4- Messages carry an IRCv3 `time` tag in UTC; a channel is read in local time.
5- jolt's time library works in epoch days and nanoseconds-of-day, and the
6- offset needs a zone name, which no single call hands over — so the zone is
7- found once here and everything else is arithmetic."
8- (:require [clojure.string :as str]
9- [jolt.host :as host]
10- [jolt.time.local :as local]))
11-
12-(def ^:private zone
13- ;; TZ if it is set, otherwise whatever /etc/localtime points at, otherwise
14- ;; UTC — which is wrong by hours but never wrong by a day's worth of parsing.
15- ;;
16- ;; /etc/localtime is not always a symlink. In a container it is often the
17- ;; bytes themselves — distrobox's Arch has it as an overlay-mounted regular
18- ;; file — and then `readlink -f` answers with the path it was given and there
19- ;; is no zone name to read out of it at all. That fell through to UTC, so a
20- ;; channel read seven hours ahead of the clock on the same screen.
21- ;;
22- ;; The colon form is what libc has for exactly this: `TZ=:/etc/localtime`
23- ;; names the tzfile by path rather than by zone, and tzset reads it with the
24- ;; transitions intact — the offset it gives is DST-correct per instant, not a
25- ;; fixed one. Nothing downstream cares that this is a path: the zone is only
26- ;; ever handed back to tz-offset-seconds, never shown.
27- (delay
28- (or (let [tz (host/getenv "TZ")] (when (seq tz) tz))
29- (try
30- (second (re-find #"/zoneinfo/(.+)$"
31- (str/trim (str (host/sh-out "readlink -f /etc/localtime")))))
32- (catch Exception _ nil))
33- (when (host/file-exists? "/etc/localtime") ":/etc/localtime")
34- ;; Android has neither: no TZ in the environment and no /etc/localtime
35- ;; to point at or read, so both branches above come up empty and the
36- ;; APK read every channel in UTC. The zone is a system property there,
37- ;; and bionic resolves the name against its own tzdata just as glibc
38- ;; resolves it against /usr/share/zoneinfo — so the name is all we need.
39- (try
40- (let [tz (str/trim (str (host/sh-out "getprop persist.sys.timezone")))]
41- (when (seq tz) tz))
42- (catch Exception _ nil))
43- "UTC")))
44-
45-(defn now-ms [] (quot (host/wall-nanos) 1000000))
46-
47-(defn parse-time-tag
48- "The `time=` value of an IRCv3 tag string as epoch milliseconds, or nil.
49-
50- Fixed format, always UTC: `2026-08-30T07:05:09.000Z`. Read by hand rather
51- than through a parser, since this runs once per message of a hundred-message
52- backlog."
53- [tags]
54- (when-let [[_ y mo d h mi s] (re-find #"time=(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})"
55- (or tags ""))]
56- (let [[y mo d h mi s] (map parse-long [y mo d h mi s])
57- ;; days from the civil date, by Howard Hinnant's算 — the same one
58- ;; jolt's own local-date uses in reverse.
59- y (if (<= mo 2) (dec y) y)
60- era (quot (if (>= y 0) y (- y 399)) 400)
61- yoe (- y (* era 400))
62- doy (+ (quot (+ (* 153 (+ mo (if (> mo 2) -3 9))) 2) 5) (dec d))
63- doe (+ (* yoe 365) (quot yoe 4) (- (quot yoe 100)) doy)
64- days (+ (* era 146097) doe -719468)]
65- (* 1000 (+ (* days 86400) (* h 3600) (* mi 60) s)))))
66-
67-(defn- local-parts
68- "`[date hour minute]` in the reader's zone, the hour on a 24-clock."
69- [ms]
70- (let [secs (quot ms 1000)
71- secs (+ secs (host/tz-offset-seconds @zone secs))
72- text (str (local/local-dt (Math/floorDiv secs 86400)
73- (* (Math/floorMod secs 86400) 1000000000)))]
74- [(subs text 0 10) (parse-long (subs text 11 13)) (subs text 14 16)]))
75-
76-(defn clock-time
77- "A twelve-hour time: `9:05 AM`, `12:30 PM`."
78- [ms]
79- (let [[_ hour minute] (local-parts ms)
80- display (cond (zero? hour) 12
81- (> hour 12) (- hour 12)
82- :else hour)]
83- (str display ":" minute " " (if (< hour 12) "AM" "PM"))))
84-
85-(defn day [ms] (first (local-parts ms)))
86-
87-(defn day-label
88- "The heading for a day's messages: today and yesterday by name, anything
89- older by date."
90- [ms]
91- (let [d (day ms)
92- today (day (now-ms))
93- yesterday (day (- (now-ms) 86400000))]
94- (cond (= d today) "Today"
95- (= d yesterday) "Yesterday"
96- :else d)))
modified src/frq/cosmic.clj +7 -4
@@ -1,5 +1,5 @@
11 (ns frq.cosmic
2- "frq's own screens, painted by libcosmic.
2+ "frq's own screens, painted by libcosmic. The desktop entry point.
33
44 The same trick as `frq.tui`: the components in `frq.app` do not know what is
55 under the reconciler, so requiring `glimmer-cosmic.core` after `frq.app`
@@ -34,11 +34,14 @@
3434 (cosmic/every! 3000 #(spit path (cosmic/dump-str))))
3535
3636 (defn -main [& _]
37- ;; What frq asks of glimmer-jvui directly, answered by this backend instead:
38- ;; jvui's timers only run inside jvui's loop, its quit closes jvui's window,
39- ;; its picture chooser and its clipboard of pictures belong to jvui's window.
37+ ;; Everything this backend can do. What is missing is missing on purpose:
38+ ;; libcosmic has no `open-url!`, so the connect screen falls back to showing
39+ ;; the URL, and no texture to push call frames into, which is the same thing
40+ ;; `:av? false` below says from the other end.
4041 (platform/override! {:after! cosmic/after!
42+ :every! cosmic/every!
4143 :quit! cosmic/quit!
44+ :screen-size cosmic/window-size
4245 :pick-image! cosmic/pick-image!
4346 :picked-image! cosmic/picked-image!
4447 :clipboard-image-png! cosmic/clipboard-image-png!})
@@ -1,5 +1,5 @@
1 (ns frq.cosmic1 (ns frq.cosmic
2- "frq's own screens, painted by libcosmic.2+ "frq's own screens, painted by libcosmic. The desktop entry point.
3 3
4 The same trick as `frq.tui`: the components in `frq.app` do not know what is4 The same trick as `frq.tui`: the components in `frq.app` do not know what is
5 under the reconciler, so requiring `glimmer-cosmic.core` after `frq.app`5 under the reconciler, so requiring `glimmer-cosmic.core` after `frq.app`
@@ -34,11 +34,14 @@
34 (cosmic/every! 3000 #(spit path (cosmic/dump-str))))34 (cosmic/every! 3000 #(spit path (cosmic/dump-str))))
35 35
36 (defn -main [& _]36 (defn -main [& _]
37- ;; What frq asks of glimmer-jvui directly, answered by this backend instead:37+ ;; Everything this backend can do. What is missing is missing on purpose:
38- ;; jvui's timers only run inside jvui's loop, its quit closes jvui's window,38+ ;; libcosmic has no `open-url!`, so the connect screen falls back to showing
39- ;; its picture chooser and its clipboard of pictures belong to jvui's window.39+ ;; the URL, and no texture to push call frames into, which is the same thing
40+ ;; `:av? false` below says from the other end.
40 (platform/override! {:after! cosmic/after!41 (platform/override! {:after! cosmic/after!
42+ :every! cosmic/every!
41 :quit! cosmic/quit!43 :quit! cosmic/quit!
44+ :screen-size cosmic/window-size
42 :pick-image! cosmic/pick-image!45 :pick-image! cosmic/pick-image!
43 :picked-image! cosmic/picked-image!46 :picked-image! cosmic/picked-image!
44 :clipboard-image-png! cosmic/clipboard-image-png!})47 :clipboard-image-png! cosmic/clipboard-image-png!})
added src/frq/io/jolt.clj +78 -0
new file mode 100644
@@ -0,0 +1,78 @@
1+(ns frq.io.jolt
2+ "The desktop's answers to `frq.io`, over jolt.host.
3+
4+ Requiring this installs them — there is no init to call, because the point of
5+ the seam is that `frq.store` and `frq.clock` under `common/` never mention a
6+ backend. Every desktop entry point requires this before `frq.app`."
7+ (:require [clojure.string :as str]
8+ [frq.io :as io]
9+ [jolt.host :as host]))
10+
11+(def ^:private zone
12+ ;; TZ if it is set, otherwise whatever /etc/localtime points at, otherwise
13+ ;; UTC — which is wrong by hours but never wrong by a day's worth of parsing.
14+ ;;
15+ ;; /etc/localtime is not always a symlink. In a container it is often the
16+ ;; bytes themselves — distrobox's Arch has it as an overlay-mounted regular
17+ ;; file — and then `readlink -f` answers with the path it was given and there
18+ ;; is no zone name to read out of it at all. That fell through to UTC, so a
19+ ;; channel read seven hours ahead of the clock on the same screen.
20+ ;;
21+ ;; The colon form is what libc has for exactly this: `TZ=:/etc/localtime`
22+ ;; names the tzfile by path rather than by zone, and tzset reads it with the
23+ ;; transitions intact — the offset it gives is DST-correct per instant, not a
24+ ;; fixed one. Nothing downstream cares that this is a path: the zone is only
25+ ;; ever handed to tz-offset-seconds, never shown.
26+ ;;
27+ ;; The `getprop` branch is Android's, and it stays here rather than moving to
28+ ;; `frq.io.dart`: this is the code the APK runs today, and it runs it through
29+ ;; jolt. When the Flutter APK is the only APK, this branch is dead and goes.
30+ (delay
31+ (or (let [tz (host/getenv "TZ")] (when (seq tz) tz))
32+ (try
33+ (second (re-find #"/zoneinfo/(.+)$"
34+ (str/trim (str (host/sh-out "readlink -f /etc/localtime")))))
35+ (catch Exception _ nil))
36+ (when (host/file-exists? "/etc/localtime") ":/etc/localtime")
37+ (try
38+ (let [tz (str/trim (str (host/sh-out "getprop persist.sys.timezone")))]
39+ (when (seq tz) tz))
40+ (catch Exception _ nil))
41+ "UTC")))
42+
43+(defn- config-dir []
44+ (let [xdg (host/getenv "XDG_CONFIG_HOME")
45+ home (host/getenv "HOME")]
46+ (str (if (seq xdg) xdg (str home "/.config")) "/frq")))
47+
48+(defn- slurp* [path]
49+ (try (slurp path) (catch Exception _ nil)))
50+
51+(defn- spit* [path s]
52+ (try (spit path s) true (catch Exception _ false)))
53+
54+(defn- write-private-file!
55+ "Created before it is written, so the token is never on disk world-readable
56+ even for an instant."
57+ [path s]
58+ (try
59+ (host/sh (str "install -m 600 /dev/null '" path "'"))
60+ (spit path s)
61+ (host/sh (str "chmod 600 '" path "'"))
62+ true
63+ (catch Exception _ false)))
64+
65+(io/install!
66+ {:getenv host/getenv
67+ :config-dir config-dir
68+ :file-exists? host/file-exists?
69+ :directory? host/directory?
70+ :list-dir host/list-dir
71+ :mkdirs! host/mkdirs!
72+ :delete-file! host/delete-file!
73+ :slurp slurp*
74+ :spit spit*
75+ :write-private-file! write-private-file!
76+ :wall-nanos host/wall-nanos
77+ :mono-nanos host/mono-nanos
78+ :local-offset-seconds (fn [secs] (host/tz-offset-seconds @zone secs))})
new file mode 100644
@@ -0,0 +1,78 @@
1+(ns frq.io.jolt
2+ "The desktop's answers to `frq.io`, over jolt.host.
3+
4+ Requiring this installs them — there is no init to call, because the point of
5+ the seam is that `frq.store` and `frq.clock` under `common/` never mention a
6+ backend. Every desktop entry point requires this before `frq.app`."
7+ (:require [clojure.string :as str]
8+ [frq.io :as io]
9+ [jolt.host :as host]))
10+
11+(def ^:private zone
12+ ;; TZ if it is set, otherwise whatever /etc/localtime points at, otherwise
13+ ;; UTC — which is wrong by hours but never wrong by a day's worth of parsing.
14+ ;;
15+ ;; /etc/localtime is not always a symlink. In a container it is often the
16+ ;; bytes themselves — distrobox's Arch has it as an overlay-mounted regular
17+ ;; file — and then `readlink -f` answers with the path it was given and there
18+ ;; is no zone name to read out of it at all. That fell through to UTC, so a
19+ ;; channel read seven hours ahead of the clock on the same screen.
20+ ;;
21+ ;; The colon form is what libc has for exactly this: `TZ=:/etc/localtime`
22+ ;; names the tzfile by path rather than by zone, and tzset reads it with the
23+ ;; transitions intact — the offset it gives is DST-correct per instant, not a
24+ ;; fixed one. Nothing downstream cares that this is a path: the zone is only
25+ ;; ever handed to tz-offset-seconds, never shown.
26+ ;;
27+ ;; The `getprop` branch is Android's, and it stays here rather than moving to
28+ ;; `frq.io.dart`: this is the code the APK runs today, and it runs it through
29+ ;; jolt. When the Flutter APK is the only APK, this branch is dead and goes.
30+ (delay
31+ (or (let [tz (host/getenv "TZ")] (when (seq tz) tz))
32+ (try
33+ (second (re-find #"/zoneinfo/(.+)$"
34+ (str/trim (str (host/sh-out "readlink -f /etc/localtime")))))
35+ (catch Exception _ nil))
36+ (when (host/file-exists? "/etc/localtime") ":/etc/localtime")
37+ (try
38+ (let [tz (str/trim (str (host/sh-out "getprop persist.sys.timezone")))]
39+ (when (seq tz) tz))
40+ (catch Exception _ nil))
41+ "UTC")))
42+
43+(defn- config-dir []
44+ (let [xdg (host/getenv "XDG_CONFIG_HOME")
45+ home (host/getenv "HOME")]
46+ (str (if (seq xdg) xdg (str home "/.config")) "/frq")))
47+
48+(defn- slurp* [path]
49+ (try (slurp path) (catch Exception _ nil)))
50+
51+(defn- spit* [path s]
52+ (try (spit path s) true (catch Exception _ false)))
53+
54+(defn- write-private-file!
55+ "Created before it is written, so the token is never on disk world-readable
56+ even for an instant."
57+ [path s]
58+ (try
59+ (host/sh (str "install -m 600 /dev/null '" path "'"))
60+ (spit path s)
61+ (host/sh (str "chmod 600 '" path "'"))
62+ true
63+ (catch Exception _ false)))
64+
65+(io/install!
66+ {:getenv host/getenv
67+ :config-dir config-dir
68+ :file-exists? host/file-exists?
69+ :directory? host/directory?
70+ :list-dir host/list-dir
71+ :mkdirs! host/mkdirs!
72+ :delete-file! host/delete-file!
73+ :slurp slurp*
74+ :spit spit*
75+ :write-private-file! write-private-file!
76+ :wall-nanos host/wall-nanos
77+ :mono-nanos host/mono-nanos
78+ :local-offset-seconds (fn [secs] (host/tz-offset-seconds @zone secs))})
modified src/frq/platform.clj +65 -22
@@ -1,7 +1,6 @@
11 (ns frq.platform
22 "The few things that are the platform's job rather than the app's."
3- (:require [glimmer-jvui.core :as gui]
4- [jolt.host :as host]))
3+ (:require [jolt.host :as host]))
54
65 (defn android?
76 "Android, told from a desktop by a binary only it has. What hangs on this is
@@ -19,55 +18,68 @@
1918 []
2019 @desktop)
2120
22-(defn open-url!
23- "Hand a URL to whatever shows web pages here. The backend knows what that
24- means xdg-open on a desktop, an ACTION_VIEW intent on Android, where no
25- shelled-out `am start` is allowed to. False leaves the connect screen's
26- \"if the browser did not open\" line to carry the URL across."
27- [url]
28- (try
29- (gui/open-url! (or url ""))
30- (catch Exception _ false)))
31-
3221 (defonce ^:private overrides
33- ;; What a backend other than glimmer-jvui does in place of jvui's own. Filled
34- ;; in by the entry point that installs that backend — `frq.cosmic` — before
35- ;; the app starts; empty means the window's.
22+ ;; What the backend does. Filled in by the entry point that installs it —
23+ ;; `frq.cosmic`, `frq.tui` — before the app starts.
24+ ;;
25+ ;; This used to be a *diff* against glimmer-jvui: every key here fell back to
26+ ;; a jvui call, and a backend named only the ones it did differently. jvui is
27+ ;; gone, so there is no default backend to fall back to and the fallbacks
28+ ;; below are no-ops instead. That is the honest shape — the terminal has no
29+ ;; picture chooser and libcosmic has no video texture, and both used to get
30+ ;; one that quietly did nothing anyway.
3631 (atom {}))
3732
3833 (defn override!
39- "Replace some of what the platform does, for a backend other than jvui.
40- Keys: :after! :quit! :pick-image! :picked-image! :clipboard-image-png!."
34+ "Install what this backend does. Keys: :after! :every! :quit! :open-url!
35+ :pick-image! :picked-image! :clipboard-image-png! :screen-size :frame-rgba!
36+ :frame-drop!. Anything left out does nothing."
4137 [m]
4238 (swap! overrides merge m)
4339 nil)
4440
41+(defn- op
42+ "The backend's answer for `k`, or `default` where it has none."
43+ [k default]
44+ (get @overrides k default))
45+
46+(defn open-url!
47+ "Hand a URL to whatever shows web pages here. The backend knows what that
48+ means xdg-open on a desktop, an ACTION_VIEW intent on Android, where no
49+ shelled-out `am start` is allowed to. False leaves the connect screen's
50+ \"if the browser did not open\" line to carry the URL across, which is what
51+ happens under a backend with no browser to hand it to."
52+ [url]
53+ (try
54+ ((op :open-url! (fn [_] false)) (or url ""))
55+ (catch Exception _ false)))
56+
4557 (defn after!
4658 "Run `f` on the UI thread in about `ms` milliseconds. jvui's timers only run
4759 inside jvui's loop, so a backend with a loop of its own has to lend its own."
4860 [ms f]
49- ((get @overrides :after! gui/after!) ms f))
61+ ((op :after! (fn [_ _] nil)) ms f))
5062
5163 (defn quit!
5264 "Close the window."
5365 []
54- ((get @overrides :quit! gui/quit!)))
66+ ((op :quit! (fn [] nil))))
5567
5668 (defn pick-image!
5769 "Open the platform's picture chooser; true when there is one and it opened."
5870 []
59- ((get @overrides :pick-image! gui/pick-image!)))
71+ ((op :pick-image! (fn [] false))))
6072
6173 (defn picked-image!
6274 "Write the chosen picture to `path`; true once, when one has been chosen."
6375 [path]
64- ((get @overrides :picked-image! gui/picked-image!) path))
76+ ((op :picked-image! (fn [_] nil)) path))
6577
6678 (defn clipboard-image-png!
6779 "Write the picture on the clipboard to `path` as PNG; true when there was one.
6880 jvui reads it through its own window, so another backend lends its own."
6981 [path]
70- ((get @overrides :clipboard-image-png! gui/clipboard-image-png!) path))
82+ ((op :clipboard-image-png! (fn [_] false)) path))
7183
7284 (defn return-url
7385 "The link that brings the app back to the front once the browser is done, or
@@ -76,3 +88,34 @@
7688 forward rather than a second copy of it."
7789 []
7890 (when (android?) "frq://auth"))
91+
92+;; ------------------------------------------------------- the window itself
93+
94+(defn every!
95+ "Run `f` on the UI thread every `ms` milliseconds. Returns a timer id where
96+ the backend has one to give."
97+ [ms f]
98+ ((op :every! (fn [_ _] nil)) ms f))
99+
100+(defn screen-size
101+ "The window's content size as `[w h]`, or `[0 0]` under a backend with no
102+ window — which the callers already treat as \"do not lay anything out yet\"."
103+ []
104+ ((op :screen-size (fn [] [0 0]))))
105+
106+;; --------------------------------------------------------------- video
107+
108+;; Call frames are pixels pushed straight into a backend texture, never a jolt
109+;; value — see `frq.av/pump-frames!`. Only a backend with a GPU surface can
110+;; take them, so the default is to drop them on the floor, which is what
111+;; `:av? false` at the entry point already says in the other direction.
112+
113+(defn frame-rgba!
114+ "Hand one decoded frame to the backend's texture for `key`."
115+ [key w h rgba]
116+ ((op :frame-rgba! (fn [_ _ _ _] nil)) key w h rgba))
117+
118+(defn frame-drop!
119+ "Forget the texture for `key`."
120+ [key]
121+ ((op :frame-drop! (fn [_] nil)) key))
@@ -1,7 +1,6 @@
1 (ns frq.platform1 (ns frq.platform
2 "The few things that are the platform's job rather than the app's."2 "The few things that are the platform's job rather than the app's."
3- (:require [glimmer-jvui.core :as gui]3+ (:require [jolt.host :as host]))
4- [jolt.host :as host]))
5 4
6 (defn android?5 (defn android?
7 "Android, told from a desktop by a binary only it has. What hangs on this is6 "Android, told from a desktop by a binary only it has. What hangs on this is
@@ -19,55 +18,68 @@
19 []18 []
20 @desktop)19 @desktop)
21 20
22-(defn open-url!
23- "Hand a URL to whatever shows web pages here. The backend knows what that
24- means xdg-open on a desktop, an ACTION_VIEW intent on Android, where no
25- shelled-out `am start` is allowed to. False leaves the connect screen's
26- \"if the browser did not open\" line to carry the URL across."
27- [url]
28- (try
29- (gui/open-url! (or url ""))
30- (catch Exception _ false)))
31-
32 (defonce ^:private overrides21 (defonce ^:private overrides
33- ;; What a backend other than glimmer-jvui does in place of jvui's own. Filled22+ ;; What the backend does. Filled in by the entry point that installs it —
34- ;; in by the entry point that installs that backend — `frq.cosmic` — before23+ ;; `frq.cosmic`, `frq.tui` — before the app starts.
35- ;; the app starts; empty means the window's.24+ ;;
25+ ;; This used to be a *diff* against glimmer-jvui: every key here fell back to
26+ ;; a jvui call, and a backend named only the ones it did differently. jvui is
27+ ;; gone, so there is no default backend to fall back to and the fallbacks
28+ ;; below are no-ops instead. That is the honest shape — the terminal has no
29+ ;; picture chooser and libcosmic has no video texture, and both used to get
30+ ;; one that quietly did nothing anyway.
36 (atom {}))31 (atom {}))
37 32
38 (defn override!33 (defn override!
39- "Replace some of what the platform does, for a backend other than jvui.34+ "Install what this backend does. Keys: :after! :every! :quit! :open-url!
40- Keys: :after! :quit! :pick-image! :picked-image! :clipboard-image-png!."35+ :pick-image! :picked-image! :clipboard-image-png! :screen-size :frame-rgba!
36+ :frame-drop!. Anything left out does nothing."
41 [m]37 [m]
42 (swap! overrides merge m)38 (swap! overrides merge m)
43 nil)39 nil)
44 40
41+(defn- op
42+ "The backend's answer for `k`, or `default` where it has none."
43+ [k default]
44+ (get @overrides k default))
45+
46+(defn open-url!
47+ "Hand a URL to whatever shows web pages here. The backend knows what that
48+ means xdg-open on a desktop, an ACTION_VIEW intent on Android, where no
49+ shelled-out `am start` is allowed to. False leaves the connect screen's
50+ \"if the browser did not open\" line to carry the URL across, which is what
51+ happens under a backend with no browser to hand it to."
52+ [url]
53+ (try
54+ ((op :open-url! (fn [_] false)) (or url ""))
55+ (catch Exception _ false)))
56+
45 (defn after!57 (defn after!
46 "Run `f` on the UI thread in about `ms` milliseconds. jvui's timers only run58 "Run `f` on the UI thread in about `ms` milliseconds. jvui's timers only run
47 inside jvui's loop, so a backend with a loop of its own has to lend its own."59 inside jvui's loop, so a backend with a loop of its own has to lend its own."
48 [ms f]60 [ms f]
49- ((get @overrides :after! gui/after!) ms f))61+ ((op :after! (fn [_ _] nil)) ms f))
50 62
51 (defn quit!63 (defn quit!
52 "Close the window."64 "Close the window."
53 []65 []
54- ((get @overrides :quit! gui/quit!)))66+ ((op :quit! (fn [] nil))))
55 67
56 (defn pick-image!68 (defn pick-image!
57 "Open the platform's picture chooser; true when there is one and it opened."69 "Open the platform's picture chooser; true when there is one and it opened."
58 []70 []
59- ((get @overrides :pick-image! gui/pick-image!)))71+ ((op :pick-image! (fn [] false))))
60 72
61 (defn picked-image!73 (defn picked-image!
62 "Write the chosen picture to `path`; true once, when one has been chosen."74 "Write the chosen picture to `path`; true once, when one has been chosen."
63 [path]75 [path]
64- ((get @overrides :picked-image! gui/picked-image!) path))76+ ((op :picked-image! (fn [_] nil)) path))
65 77
66 (defn clipboard-image-png!78 (defn clipboard-image-png!
67 "Write the picture on the clipboard to `path` as PNG; true when there was one.79 "Write the picture on the clipboard to `path` as PNG; true when there was one.
68 jvui reads it through its own window, so another backend lends its own."80 jvui reads it through its own window, so another backend lends its own."
69 [path]81 [path]
70- ((get @overrides :clipboard-image-png! gui/clipboard-image-png!) path))82+ ((op :clipboard-image-png! (fn [_] false)) path))
71 83
72 (defn return-url84 (defn return-url
73 "The link that brings the app back to the front once the browser is done, or85 "The link that brings the app back to the front once the browser is done, or
@@ -76,3 +88,34 @@
76 forward rather than a second copy of it."88 forward rather than a second copy of it."
77 []89 []
78 (when (android?) "frq://auth"))90 (when (android?) "frq://auth"))
91+
92+;; ------------------------------------------------------- the window itself
93+
94+(defn every!
95+ "Run `f` on the UI thread every `ms` milliseconds. Returns a timer id where
96+ the backend has one to give."
97+ [ms f]
98+ ((op :every! (fn [_ _] nil)) ms f))
99+
100+(defn screen-size
101+ "The window's content size as `[w h]`, or `[0 0]` under a backend with no
102+ window — which the callers already treat as \"do not lay anything out yet\"."
103+ []
104+ ((op :screen-size (fn [] [0 0]))))
105+
106+;; --------------------------------------------------------------- video
107+
108+;; Call frames are pixels pushed straight into a backend texture, never a jolt
109+;; value — see `frq.av/pump-frames!`. Only a backend with a GPU surface can
110+;; take them, so the default is to drop them on the floor, which is what
111+;; `:av? false` at the entry point already says in the other direction.
112+
113+(defn frame-rgba!
114+ "Hand one decoded frame to the backend's texture for `key`."
115+ [key w h rgba]
116+ ((op :frame-rgba! (fn [_ _ _ _] nil)) key w h rgba))
117+
118+(defn frame-drop!
119+ "Forget the texture for `key`."
120+ [key]
121+ ((op :frame-drop! (fn [_] nil)) key))
modified src/frq/tui.clj +9 -0
@@ -35,6 +35,7 @@
3535 [frq.state :as s]
3636 [jolt.host :as host]
3737 [glimmer.core :as ui]
38+ [frq.platform :as platform]
3839 [glimmer-tui.ffi :as tui-ffi]
3940 ;; last, so its install! is the one that stands
4041 [glimmer-tui.core :as tui]))
@@ -170,6 +171,14 @@
170171 ;; And how a scroll here answers "am I at the newest line?", which the
171172 ;; window's scroll area answers for itself.
172173 (reset! s/at-end-probe at-end?)
174+ ;; What the terminal can do of the platform's job. It used to inherit jvui's
175+ ;; answers here — a window's timers, running in a loop that was never
176+ ;; started — and the ones it has no answer for now do nothing instead of
177+ ;; doing nothing slowly.
178+ (platform/override! {:after! tui/after!
179+ :every! tui/every!
180+ :quit! tui/quit!
181+ :screen-size tui/screen-size})
173182 (app/start! {:after! tui/after!
174183 :every! tui/every!
175184 :title! nil
@@ -35,6 +35,7 @@
35 [frq.state :as s]35 [frq.state :as s]
36 [jolt.host :as host]36 [jolt.host :as host]
37 [glimmer.core :as ui]37 [glimmer.core :as ui]
38+ [frq.platform :as platform]
38 [glimmer-tui.ffi :as tui-ffi]39 [glimmer-tui.ffi :as tui-ffi]
39 ;; last, so its install! is the one that stands40 ;; last, so its install! is the one that stands
40 [glimmer-tui.core :as tui]))41 [glimmer-tui.core :as tui]))
@@ -170,6 +171,14 @@
170 ;; And how a scroll here answers "am I at the newest line?", which the171 ;; And how a scroll here answers "am I at the newest line?", which the
171 ;; window's scroll area answers for itself.172 ;; window's scroll area answers for itself.
172 (reset! s/at-end-probe at-end?)173 (reset! s/at-end-probe at-end?)
174+ ;; What the terminal can do of the platform's job. It used to inherit jvui's
175+ ;; answers here — a window's timers, running in a loop that was never
176+ ;; started — and the ones it has no answer for now do nothing instead of
177+ ;; doing nothing slowly.
178+ (platform/override! {:after! tui/after!
179+ :every! tui/every!
180+ :quit! tui/quit!
181+ :screen-size tui/screen-size})
173 (app/start! {:after! tui/after!182 (app/start! {:after! tui/after!
174 :every! tui/every!183 :every! tui/every!
175 :title! nil184 :title! nil