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>
7070931 parent: 9dd480b modified
CLAUDE.md +30 -3 | @@ -9,11 +9,38 @@ nix commands directly — do not wrap them in `distrobox enter`: | ||
| 9 | 9 | nix build .#frq |
| 10 | 10 | ``` |
| 11 | 11 | |
| 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. | |
| 15 | 16 | |
| 16 | 17 | One thing this container is *not* representative of: `/etc/localtime` is a |
| 17 | 18 | regular file here rather than a symlink, so anything that reads the zone out |
| 18 | 19 | of its path sees nothing. That is a real deployment shape, not an artefact — |
| 19 | 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. | |
| @@ -9,11 +9,38 @@ nix commands directly — do not wrap them in `distrobox enter`: | |||
| 9 | nix build .#frq | 9 | nix build .#frq |
| 10 | ``` | 10 | ``` |
| 11 | 11 | ||
| 12 | -A remote builder (`eu.nixbuild.net`) is already configured here; the Android | 12 | +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 the | 13 | +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 a | 17 | 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 out | 18 | 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 @@ | ||
| 3 | 3 | A **[freeq](https://github.com/codegod100/freeq)** client written in |
| 4 | 4 | **[jolt](https://github.com/jolt-lang/jolt)**, as |
| 5 | 5 | [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 | 10 | It is a proof of concept port of [sleek](../sleek), which is the same client in |
| 9 | 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 | 13 | tags rather than immediate-mode drawing code, and state lives in ratoms instead |
| 12 | 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 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 | |
| 28 | 45 | ``` |
| 29 | 46 | |
| 30 | 47 | ## Tracing |
| @@ -45,20 +62,18 @@ Every recipe lives in the `justfile` itself. Each one that runs frq re-enters | ||
| 45 | 62 | `nix develop --command just run` are one code path rather than two. Nothing has |
| 46 | 63 | to be installed for that but Nix. |
| 47 | 64 | |
| 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. | |
| 62 | 77 | |
| 63 | 78 | `jolt` on its own does not work in this tree: `deps.edn` names both libraries |
| 64 | 79 | 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 | 93 | The screens are hiccup over glimmer's reconciler, and the reconciler does not |
| 79 | 94 | 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 | |
| 81 | 96 | grid of cells instead of a GPU window. |
| 82 | 97 | |
| 83 | 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 | 136 | `just tui` is `just run`'s two halves with the other backend under them: this |
| 122 | 137 | 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 | |
| 124 | 139 | window, glimmer-tui for the terminal — so one input answers for either, and |
| 125 | 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 | 174 | ## Android |
| 160 | 175 | |
| 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. | |
| 165 | 178 | |
| 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. | |
| 170 | 185 | |
| 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. | |
| 178 | 192 | |
| 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. | |
| 184 | 196 | |
| 185 | 197 | ## What the PoC covers |
| 186 | 198 | |
| @@ -194,7 +206,7 @@ surface — that surface does not work on Android either, while the syscalls do. | ||
| 194 | 206 | * A chip above a reply quoting what it answers, and a click that goes there; |
| 195 | 207 | ↩ beside a sender to answer them, with `+draft/reply` on the way out |
| 196 | 208 | * 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 | |
| 198 | 210 | groups, searchable by name), and a second click on a pill to take yours off |
| 199 | 211 | — sent as `TAGMSG`, and restored from the server's own tally when the |
| 200 | 212 | backlog comes back |
| @@ -236,8 +248,8 @@ shape this side: | ||
| 236 | 248 | |
| 237 | 249 | * **Nothing calls back.** Status and video are polled, drained by a timer that |
| 238 | 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 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 | |
| 241 | 253 | jolt value and are never copied on this side, which is the only way thirty |
| 242 | 254 | frames a second is affordable here. |
| 243 | 255 | |
| @@ -257,17 +269,18 @@ in a loop that looks exactly like a hang. | ||
| 257 | 269 | format, and a fetch needs TLS, so the phone shows links. The link is left in |
| 258 | 270 | place either way. |
| 259 | 271 | * **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. | |
| 263 | 276 | * **One call at a time**, which is the media plane's rule and the microphone's. |
| 264 | 277 | * **No call is offered in a DM** — freeq's AV signaling is a channel's. |
| 265 | 278 | * **Pasting a picture needs a sign-in and a desktop.** The upload is filed |
| 266 | 279 | 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 | |
| 269 | 282 | nothing to your PDS and posts nothing to Bluesky — those fields are opt-in |
| 270 | 283 | and this client does not send them. |
| 271 | 284 | * **No scrollback trimming or threads.** |
| 272 | 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. | |
| @@ -3,7 +3,9 @@ | |||
| 3 | A **[freeq](https://github.com/codegod100/freeq)** client written in | 3 | A **[freeq](https://github.com/codegod100/freeq)** client written in |
| 4 | **[jolt](https://github.com/jolt-lang/jolt)**, as | 4 | **[jolt](https://github.com/jolt-lang/jolt)**, as |
| 5 | [glimmer](https://github.com/jolt-lang/glimmer) components painted by | 5 | [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 in | 10 | 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 instead | 13 | 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 payloads | 30 | +common/frq/io.cljc the seam: filesystem, environment, config dir, clock |
| 16 | -src/frq/oauth.clj the broker flow: login URL, loopback capture, /session | 31 | +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 directory | 32 | +common/frq/store.cljc the saved sign-in, mode 600 in the config directory |
| 18 | -src/frq/avatars.clj profile pictures, by DID or handle | 33 | +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 nick | 34 | +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 disk | 35 | +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 multipart | 36 | +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 plane | 37 | +src/frq/avatars.clj profile pictures, by DID or handle |
| 23 | -src/frq/clock.clj the reader's own zone, twelve-hour times, day headings | 38 | +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 name | 39 | +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, PRIVMSG | 40 | +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 screens | 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 | ||
| 28 | ``` | 45 | ``` |
| 29 | 46 | ||
| 30 | ## Tracing | 47 | ## 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 has | 62 | `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 of | 66 | +`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 both | 67 | +of [jolt-native](https://gitlab.com/nandithebull/jolt-native), which carries |
| 51 | -shared objects this client loads: `libvidya`, the retained-tree ABI glimmer | 68 | +the shared objects this client loads: `libjoltcosmic`, the retained-tree ABI |
| 52 | -paints through, and `libjoltmoq`, the AV media plane. The source frq runs is | 69 | +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 revs | 70 | +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-native | 71 | +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 of | 74 | +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 a | 75 | +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 — and | 76 | +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 libraries | 78 | `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 and | 79 | 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 not | 93 | 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 through | 94 | 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 |
| 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: this | 136 | `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-native | 137 | 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 |
| 124 | window, glimmer-tui for the terminal — so one input answers for either, and | 139 | 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 | ## Android | 174 | ## Android |
| 160 | 175 | ||
| 161 | -An APK whose native halves come from jolt-native's release and whose Jolt half | 176 | +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 the | 177 | +[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 | -```bash | 179 | +There was a jolt APK: `libvidya.so` painting through a NativeActivity, with frq |
| 167 | -just apk run # build, install, launch on a connected device | 180 | +compiled to an arm64 Chez boot image beside it. It is gone, and so are |
| 168 | -just apk log # logcat, filtered | 181 | +`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 is | 186 | +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 Chez | 187 | +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 remote | 189 | +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 of | 191 | +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, and | 193 | +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 the | 194 | +`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 raw | 195 | +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 covers | 197 | ## 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 out | 207 | ↩ 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 a | 208 | * 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 |
| 198 | groups, searchable by name), and a second click on a pill to take yours off | 210 | 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 the | 211 | — sent as `TAGMSG`, and restored from the server's own tally when the |
| 200 | backlog comes back | 212 | 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 that | 249 | * **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 a | 251 | +* **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 a | 252 | + 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 thirty | 253 | 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 in | 269 | 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, and | 272 | +* **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 runtime | 273 | + 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 filed | 278 | * **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 read | 279 | 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 | 280 | + off the clipboard through the backend's `clipboard-image-png!`, which |
| 268 | - arboard backs on desktop and nothing backs on Android. It also shares | 281 | + 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-in | 282 | 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 @@ | ||
| 6 | 6 | an ssh key, and never anywhere else. The web-token beside it is single-use |
| 7 | 7 | and deliberately not saved." |
| 8 | 8 | (:require [clojure.edn :as edn] |
| 9 | - [clojure.string :as str] | |
| 10 | - [jolt.host :as host])) | |
| 9 | + [frq.io :as io])) | |
| 11 | 10 | |
| 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)) | |
| 16 | 16 | |
| 17 | 17 | (defn session-file [] (str (config-dir) "/session.edn")) |
| 18 | 18 | |
| @@ -21,9 +21,9 @@ | ||
| 21 | 21 | a stale credential is not worth an error at startup." |
| 22 | 22 | [] |
| 23 | 23 | (let [path (session-file)] |
| 24 | - (when (host/file-exists? path) | |
| 24 | + (when (io/file-exists? path) | |
| 25 | 25 | (try |
| 26 | - (let [m (edn/read-string (slurp path))] | |
| 26 | + (let [m (edn/read-string (io/slurp path))] | |
| 27 | 27 | (when (and (map? m) (seq (:broker-token m))) m)) |
| 28 | 28 | (catch Exception _ nil))))) |
| 29 | 29 | |
| @@ -33,13 +33,13 @@ | ||
| 33 | 33 | (let [dir (config-dir) |
| 34 | 34 | path (session-file)] |
| 35 | 35 | (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]))) | |
| 43 | 43 | (catch Exception _ false)))) |
| 44 | 44 | |
| 45 | 45 | (defn channels-file [] (str (config-dir) "/channels.edn")) |
| @@ -50,9 +50,9 @@ | ||
| 50 | 50 | nothing writes this file any more." |
| 51 | 51 | [] |
| 52 | 52 | (let [path (channels-file)] |
| 53 | - (when (host/file-exists? path) | |
| 53 | + (when (io/file-exists? path) | |
| 54 | 54 | (try |
| 55 | - (let [v (edn/read-string (slurp path))] | |
| 55 | + (let [v (edn/read-string (io/slurp path))] | |
| 56 | 56 | (when (vector? v) (filterv string? v))) |
| 57 | 57 | (catch Exception _ nil))))) |
| 58 | 58 | |
| @@ -72,9 +72,9 @@ | ||
| 72 | 72 | with no marker, which is exactly what it knew." |
| 73 | 73 | [] |
| 74 | 74 | (let [path (rooms-file)] |
| 75 | - (or (when (host/file-exists? path) | |
| 75 | + (or (when (io/file-exists? path) | |
| 76 | 76 | (try |
| 77 | - (let [v (edn/read-string (slurp path))] | |
| 77 | + (let [v (edn/read-string (io/slurp path))] | |
| 78 | 78 | (when (vector? v) |
| 79 | 79 | (filterv #(and (map? %) (string? (:name %)) (seq (:name %))) v))) |
| 80 | 80 | (catch Exception _ nil))) |
| @@ -85,15 +85,14 @@ | ||
| 85 | 85 | "Write the room records, most recently used first." |
| 86 | 86 | [rooms] |
| 87 | 87 | (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))) | |
| 91 | 90 | (catch Exception _ false))) |
| 92 | 91 | |
| 93 | 92 | (defn clear-session! [] |
| 94 | 93 | (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))) | |
| 97 | 96 | true |
| 98 | 97 | (catch Exception _ false))) |
| 99 | 98 | |
| @@ -106,9 +105,9 @@ | ||
| 106 | 105 | stopping the launch for." |
| 107 | 106 | [] |
| 108 | 107 | (let [path (prefs-file)] |
| 109 | - (or (when (host/file-exists? path) | |
| 108 | + (or (when (io/file-exists? path) | |
| 110 | 109 | (try |
| 111 | - (let [m (edn/read-string (slurp path))] | |
| 110 | + (let [m (edn/read-string (io/slurp path))] | |
| 112 | 111 | (when (map? m) m)) |
| 113 | 112 | (catch Exception _ nil))) |
| 114 | 113 | {}))) |
| @@ -118,7 +117,6 @@ | ||
| 118 | 117 | the caller's job, and a partial write would silently drop the rest." |
| 119 | 118 | [prefs] |
| 120 | 119 | (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)) | |
| 124 | 122 | (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-use | 6 | 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 | (try | 25 | (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 | (try | 35 | (try |
| 36 | - (host/mkdirs! dir) | 36 | + (io/mkdirs! dir) |
| 37 | - ;; Created before it is written, so the token is never on disk | 37 | + ;; 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 | - true | 42 | + 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 | (try | 54 | (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 | (try | 76 | (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 | (try | 87 | (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 | (try | 93 | (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 | true | 96 | 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 | (try | 109 | (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 | (try | 119 | (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"] | |
| 2 | 8 | |
| 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. | |
| 8 | 16 | ;; |
| 9 | 17 | ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A |
| 10 | 18 | ;; component unmounted between a cell firing and the queued render running |
| @@ -14,17 +22,11 @@ | ||
| 14 | 22 | ;; under it subscribed, so swapping the phone layout for the split one and |
| 15 | 23 | ;; back shredded the chat: one component's props painted onto another's |
| 16 | 24 | ;; 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"}} | |
| 22 | 27 | |
| 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. | |
| 28 | 30 | ;; |
| 29 | 31 | ;; Calls used to be the one thing this client could not do in jolt. The media |
| 30 | 32 | ;; half is `frq.av.plane` now — MoQ over QUIC from libmoq_ffi, Opus from |
| @@ -79,22 +81,25 @@ | ||
| 79 | 81 | {:name "asound" |
| 80 | 82 | :linux ["libasound.so.2"]}] |
| 81 | 83 | |
| 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. | |
| 88 | 93 | ;; |
| 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`. | |
| 93 | 99 | ;; |
| 94 | 100 | ;; just tui |
| 95 | 101 | :tui {:extra-deps {nandi/glimmer-tui |
| 96 | 102 | {:local/root "../jolt-native/glimmer-backends/glimmer-tui"}} |
| 97 | 103 | :main-opts ["-m" "frq.tui"]}} |
| 98 | 104 | |
| 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 on | 10 | + ;; 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 meant | 12 | + ;; 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. A | 17 | ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A |
| 10 | ;; component unmounted between a cell firing and the queued render running | 18 | ;; 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 and | 22 | ;; 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's | 23 | ;; 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_ttf | 28 | + ;; 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 with | 29 | + ;; 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 media | 31 | ;; 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 from | 32 | ;; 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 backend | 86 | + ;; 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's | 87 | + ;; 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 this | 94 | + ;; 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 the | 95 | + ;; 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 for | 96 | + ;; 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 tui | 100 | ;; just tui |
| 95 | :tui {:extra-deps {nandi/glimmer-tui | 101 | :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 @@ | ||
| 1 | 1 | { |
| 2 | 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 | 3 | "crane": { |
| 23 | 4 | "locked": { |
| 24 | 5 | "lastModified": 1788465171, |
| @@ -121,30 +102,6 @@ | ||
| 121 | 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 | 105 | "jolt-native": { |
| 149 | 106 | "inputs": { |
| 150 | 107 | "crane": "crane", |
| @@ -155,36 +112,23 @@ | ||
| 155 | 112 | "rust-overlay": "rust-overlay" |
| 156 | 113 | }, |
| 157 | 114 | "locked": { |
| 158 | - "lastModified": 1789094762, | |
| 159 | - "narHash": "sha256-frw18DbDI287CaqJRx+ns419AkKGNfarVR4LtUVJhXY=", | |
| 115 | + "lastModified": 1789172631, | |
| 116 | + "narHash": "sha256-V4mOTZIHqUW0NKd83Iq9N8YlY5JkSakOyYJQcr1BEMc=", | |
| 160 | 117 | "ref": "refs/heads/main", |
| 161 | - "rev": "b48e404b7a731fdd2d303624a0681b7cfcbb93fa", | |
| 162 | - "revCount": 101, | |
| 118 | + "rev": "4706c920e45ce80b11ee106d05c16d9eacc99fc7", | |
| 119 | + "revCount": 107, | |
| 163 | 120 | "type": "git", |
| 164 | 121 | "url": "https://gitlab.com/nandithebull/jolt-native" |
| 165 | 122 | }, |
| 166 | 123 | "original": { |
| 167 | - "rev": "b48e404b7a731fdd2d303624a0681b7cfcbb93fa", | |
| 124 | + "rev": "4706c920e45ce80b11ee106d05c16d9eacc99fc7", | |
| 168 | 125 | "type": "git", |
| 169 | 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 | 129 | "jolt-src": { |
| 186 | 130 | "inputs": { |
| 187 | - "makes": "makes_2", | |
| 131 | + "makes": "makes", | |
| 188 | 132 | "nixpkgs": [ |
| 189 | 133 | "nixpkgs" |
| 190 | 134 | ], |
| @@ -228,23 +172,6 @@ | ||
| 228 | 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 | 175 | "nix-appimage": { |
| 249 | 176 | "inputs": { |
| 250 | 177 | "flake-compat": "flake-compat", |
| @@ -306,11 +233,8 @@ | ||
| 306 | 233 | }, |
| 307 | 234 | "root": { |
| 308 | 235 | "inputs": { |
| 309 | - "chez-src": "chez-src", | |
| 310 | 236 | "glimmer": "glimmer", |
| 311 | - "jolt-android-src": "jolt-android-src", | |
| 312 | 237 | "jolt-native": "jolt-native", |
| 313 | - "jolt-native-android": "jolt-native-android", | |
| 314 | 238 | "jolt-src": "jolt-src", |
| 315 | 239 | "nix-appimage": "nix-appimage", |
| 316 | 240 | "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 @@ | ||
| 28 | 28 | # its own inputs are locked with ours rather than left to float, and |
| 29 | 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 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. | |
| 36 | 35 | jolt-src = { |
| 37 | 36 | url = "git+https://gitlab.com/nandithebull/jolt?submodules=1"; |
| 38 | 37 | inputs.nixpkgs.follows = "nixpkgs"; |
| 39 | 38 | }; |
| 40 | 39 | |
| 41 | 40 | # 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. | |
| 46 | 43 | # |
| 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. | |
| 52 | 47 | # |
| 53 | 48 | # Pinned all the same, and pinned to a rev, because an |
| 54 | 49 | # unpinned `main` is a build whose native half is free to sit at a |
| @@ -61,47 +56,10 @@ | ||
| 61 | 56 | # drift this comment warns about wearing a different hat: one input, and |
| 62 | 57 | # the window and the terminal are the same library either way. |
| 63 | 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 | 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 | 63 | # The sha deps.edn pins, on the fork with the reconciler fixes. |
| 106 | 64 | glimmer = { |
| 107 | 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 | 84 | let |
| 127 | 85 | systems = [ "x86_64-linux" "aarch64-linux" ]; |
| 128 | 86 | forEachSystem = f: |
| @@ -203,7 +161,11 @@ | ||
| 203 | 161 | let np = jolt-native.packages.${pkgs.stdenv.hostPlatform.system}; |
| 204 | 162 | in pkgs.symlinkJoin { |
| 205 | 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 | 171 | # libmoq_ffi — MoQ over QUIC behind UniFFI's C ABI, FETCHED rather |
| @@ -301,14 +263,10 @@ | ||
| 301 | 263 | # openh264 is here for frqH264's DT_NEEDED; alsa-lib for capture |
| 302 | 264 | # and playback. V4L2 needs nothing: it is ioctls against libc and |
| 303 | 265 | # 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 ]; | |
| 312 | 270 | |
| 313 | 271 | # ALSA's PipeWire plugin, which is how `default` resolves to |
| 314 | 272 | # anything on a machine running PipeWire — and every machine frq |
| @@ -320,35 +278,17 @@ | ||
| 320 | 278 | # alsa-lib looks plugins up by directory, not by soname. |
| 321 | 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 | 281 | nativeAll = pkgs.symlinkJoin { |
| 344 | 282 | name = "frq-native"; |
| 345 | 283 | paths = [ native moqFfi ] ++ codecs; |
| 346 | 284 | }; |
| 347 | 285 | |
| 348 | 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 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. | |
| 352 | 292 | joltFrom = src: pkgs.stdenv.mkDerivation { |
| 353 | 293 | pname = "jolt"; |
| 354 | 294 | version = "dev"; |
| @@ -395,17 +335,11 @@ | ||
| 395 | 335 | }; |
| 396 | 336 | |
| 397 | 337 | 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"; | |
| 409 | 343 | glimmerTui = "${jolt-native}/glimmer-backends/glimmer-tui"; |
| 410 | 344 | |
| 411 | 345 | runtimeLibs = runtimeLibsFor pkgs; |
| @@ -413,7 +347,7 @@ | ||
| 413 | 347 | # The project as jolt sees it: source, deps.edn, nothing else. |
| 414 | 348 | frqSource = pkgs.runCommand "frq-source" { } '' |
| 415 | 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 | 353 | # Jolt resolves deps.edn from the working directory, so the launcher |
| @@ -423,8 +357,6 @@ | ||
| 423 | 357 | frqScript = pkgs.writeShellScript "frq" '' |
| 424 | 358 | export LD_LIBRARY_PATH="${nativeAll}/lib:${lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" |
| 425 | 359 | export ALSA_PLUGIN_DIR="${alsaPluginDir}" |
| 426 | - export JVUI_FONT="${uiFont}" | |
| 427 | - export JVUI_FALLBACK_FONTS="${fallbackFonts}" | |
| 428 | 360 | cd ${frqSource} |
| 429 | 361 | |
| 430 | 362 | # On NixOS the store's Mesa is the system's and the window opens. |
| @@ -434,8 +366,8 @@ | ||
| 434 | 366 | [ -e /run/current-system ] || runner="${nixGL}/bin/nixGLIntel" |
| 435 | 367 | |
| 436 | 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}"}}}' \ | |
| 438 | - -M:frq "$@" | |
| 369 | + -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-cosmic {:local/root "${glimmerCosmic}"}}}' \ | |
| 370 | + -m frq.cosmic "$@" | |
| 439 | 371 | ''; |
| 440 | 372 | |
| 441 | 373 | # The same source, the other backend. No GL, no nixGL and no X11 — |
| @@ -447,7 +379,7 @@ | ||
| 447 | 379 | cd ${frqSource} |
| 448 | 380 | |
| 449 | 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 | 383 | -m frq.tui "$@" |
| 452 | 384 | ''; |
| 453 | 385 | |
| @@ -476,34 +408,6 @@ | ||
| 476 | 408 | mkdir -p "$out/bin" |
| 477 | 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 | 411 | in |
| 508 | 412 | { |
| 509 | 413 | inherit native moqFfi frqH264 nativeAll frq; |
| @@ -518,13 +422,6 @@ | ||
| 518 | 422 | # nixGL needs a store Mesa to put the host's driver in front of. |
| 519 | 423 | appimage = |
| 520 | 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 | 427 | # Where `just run` runs, and — because entering it realises what it |
| @@ -575,19 +472,8 @@ | ||
| 575 | 472 | # is a different `let`. See `alsaPluginDir` there for why. |
| 576 | 473 | ALSA_PLUGIN_DIR = "${pkgs.pipewire}/lib/alsa-lib"; |
| 577 | 474 | 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"; | |
| 580 | 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 | 477 | FRQ_LIB_PATH = lib.makeLibraryPath (runtimeLibsFor pkgs); |
| 592 | 478 | NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel"; |
| 593 | 479 | |
| @@ -637,8 +523,7 @@ | ||
| 637 | 523 | FRQ_JOLT_NATIVE="$(cd "$FRQ_JOLT_NATIVE" && pwd)" |
| 638 | 524 | export FRQ_JOLT_NATIVE |
| 639 | 525 | 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" | |
| 642 | 527 | # First, so a cargo build wins over the pin's copy of the |
| 643 | 528 | # same object. The rest of the pin's lib directory is still |
| 644 | 529 | # behind it. |
| @@ -28,27 +28,22 @@ | |||
| 28 | # its own inputs are locked with ours rather than left to float, and | 28 | # 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 already | 31 | + # 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 different | 32 | + # 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 warns | 33 | + # 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 the | 34 | + # 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/ that | 40 | # 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 | 41 | + # binds the native objects, and the flake that builds them. This input is |
| 43 | - # Android objects no longer come from here — jolt-native-android below | 42 | + # 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 painted | 44 | + # It carries both backends that are left — glimmer-cosmic over |
| 48 | - # with now — and, from no-moq-deps, the dependency split and the | 45 | + # libjoltcosmic for the window, glimmer-tui over libjolttui for the |
| 49 | - # JOLT_WITHOUT_MOQ guard on the Android glue. Both were on the | 46 | + # 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 an | 48 | # 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 a | 49 | # 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, and | 56 | # 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 | let | 84 | 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 rather | 171 | # 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 capture | 263 | # openh264 is here for frqH264's DT_NEEDED; alsa-lib for capture |
| 302 | # and playback. V4L2 needs nothing: it is ioctls against libc and | 264 | # 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 and | 266 | + # 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 keeps | 268 | + # and takes what it needs from `runtimeLibs` instead. |
| 307 | - # its library in a separate `lib` output — the default one holds | 269 | + 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 to | 271 | # ALSA's PipeWire plugin, which is how `default` resolves to |
| 314 | # anything on a machine running PipeWire — and every machine frq | 272 | # 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 the | 287 | + # |
| 350 | - # desktop, and the Bionic-addrinfo fork for the boot image the APK | 288 | + # 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 own | 340 | + # checkout beside the objects they bind. Their own deps.edn asks for |
| 401 | - # deps.edn asks for glimmer by git — the top-level override below | 341 | + # 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 launcher | 353 | # 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 | in | 411 | 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 it | 427 | # 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_NATIVE | 524 | 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 the | 527 | # 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 still | 528 | # 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") | ||
| 32 | 32 | default: |
| 33 | 33 | @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 | 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, | |
| 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. | |
| 107 | 38 | # |
| 108 | 39 | # Deliberately not `nix run .#frq`. That builds the flake's own copy of the |
| 109 | 40 | # source, which is the tree as git has it — so an edit that has not been |
| @@ -111,6 +42,12 @@ apk action="build": | ||
| 111 | 42 | # whatever was there before, silently. A run meant to answer "does my change |
| 112 | 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 | 51 | # The app: this tree's source on the flake's everything-else, in the dev shell. |
| 115 | 52 | run *args: |
| 116 | 53 | #!/usr/bin/env bash |
| @@ -120,27 +57,19 @@ run *args: | ||
| 120 | 57 | exec {{nix}} develop . --max-jobs {{jobs}} --command just run "$@" |
| 121 | 58 | 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 | 60 | 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\"}}}" | |
| 130 | 62 | |
| 131 | 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 | 65 | runner=() |
| 137 | 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 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. | |
| 144 | 73 | # |
| 145 | 74 | # No nixGL here, unlike `run`: a terminal wants nothing from the host's GL |
| 146 | 75 | # driver, which is the reason this output exists on machines that have none. |
| @@ -155,54 +84,12 @@ tui *args: | ||
| 155 | 84 | fi |
| 156 | 85 | |
| 157 | 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 | 87 | deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}" |
| 161 | 88 | |
| 162 | 89 | export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" |
| 163 | 90 | |
| 164 | 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 | 93 | # The same classpath as `tui`, with an nREPL on it instead of a `-main`: a |
| 207 | 94 | # session that can require `frq.tui` and then redefine a component while it is |
| 208 | 95 | # on screen, which is a second and not the minute a rebuild costs. |
| @@ -223,8 +110,6 @@ nrepl *args: | ||
| 223 | 110 | fi |
| 224 | 111 | |
| 225 | 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 | 113 | deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}" |
| 229 | 114 | |
| 230 | 115 | export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" |
| @@ -247,8 +132,7 @@ repl *args: | ||
| 247 | 132 | fi |
| 248 | 133 | |
| 249 | 134 | 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\"}}}" | |
| 252 | 136 | |
| 253 | 137 | export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" |
| 254 | 138 | |
| @@ -32,78 +32,9 @@ jobs := env("FRQ_MAX_JOBS", "0") | |||
| 32 | default: | 32 | default: |
| 33 | @just --list | 33 | @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 the | 39 | # 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 been | 40 | # 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 change | 42 | # 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 bash | 53 | #!/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 | fi | 58 | 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 as | 70 | +# `run` with the other backend under it. Only libjolttui: `frq.app` names no |
| 142 | -# libjolttui: `frq.app` requires glimmer-vidya, and `frq.tui` requires | 71 | +# 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 GL | 74 | # 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 | fi | 84 | 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`: a | 93 | # 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 is | 94 | # 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 | fi | 110 | 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 | fi | 132 | 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 @@ | ||
| 1 | 1 | (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 | 4 | The screens follow sleek's: connect, chats, chat, discover, settings, under a |
| 5 | 5 | 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 | 13 | (:require [clojure.string :as str] |
| 8 | 14 | [glimmer.ratom :as r :refer [atom]] |
| 9 | 15 | [glimmer.core :as ui] |
| 10 | - [glimmer-jvui.core :as gui] | |
| 11 | 16 | [frq.av :as av] |
| 12 | 17 | [frq.avatars :as avatars] |
| 13 | 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 | 25 | [frq.glyphs :as glyphs] |
| 15 | 26 | [frq.media :as media] |
| 16 | 27 | [frq.platform :as platform] |
| @@ -1816,19 +1827,3 @@ | ||
| 1816 | 1827 | (reset! shown title) |
| 1817 | 1828 | (title! title)))))) |
| 1818 | 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)) | |
| @@ -1,16 +1,27 @@ | |||
| 1 | (ns frq.app | 1 | (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 a | 4 | 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 a | 5 | 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 @@ | ||
| 27 | 27 | borrowed pointers because a frame at thirty a second cannot afford a copy." |
| 28 | 28 | (:require [clojure.string :as str] |
| 29 | 29 | [glimmer.ratom :as r :refer [atom]] |
| 30 | - [glimmer-jvui.core :as gui] | |
| 30 | + [frq.platform :as platform] | |
| 31 | 31 | [frq.irc :as irc] |
| 32 | 32 | [frq.av.dial :as dial] |
| 33 | 33 | [frq.av.plane :as plane] |
| @@ -258,7 +258,7 @@ | ||
| 258 | 258 | (defn- drop-feeds! |
| 259 | 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 | 262 | (reset! painted-feeds #{}) |
| 263 | 263 | (reset! feeds [])) |
| 264 | 264 | |
| @@ -412,7 +412,7 @@ | ||
| 412 | 412 | ;; The tile goes when the camera does: the plane stops publishing, so no |
| 413 | 413 | ;; frame arrives to replace the last one. |
| 414 | 414 | (when-not on? |
| 415 | - (gui/frame-drop! local-feed) | |
| 415 | + (platform/frame-drop! local-feed) | |
| 416 | 416 | (swap! painted-feeds disj local-feed))) |
| 417 | 417 | |
| 418 | 418 | ;; Switching a device mid-call reopens it, which the plane can only do by |
| @@ -478,7 +478,7 @@ | ||
| 478 | 478 | [] |
| 479 | 479 | (doseq [{:keys [key w h rgba]} (plane/poll-frames!)] |
| 480 | 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 | 482 | (swap! painted-feeds conj key)))) |
| 483 | 483 | |
| 484 | 484 | (defn- order-feeds |
| @@ -502,7 +502,7 @@ | ||
| 502 | 502 | [] |
| 503 | 503 | (let [live (plane/feed-keys)] |
| 504 | 504 | (doseq [k (remove live @painted-feeds)] |
| 505 | - (gui/frame-drop! k) | |
| 505 | + (platform/frame-drop! k) | |
| 506 | 506 | (swap! painted-feeds disj k)) |
| 507 | 507 | (let [ordered (order-feeds live)] |
| 508 | 508 | (when-not (= ordered @feeds) |
| @@ -519,7 +519,7 @@ | ||
| 519 | 519 | one would re-render the wall for a third of a point of difference nobody can |
| 520 | 520 | see." |
| 521 | 521 | [] |
| 522 | - (let [[w h] (gui/screen-size) | |
| 522 | + (let [[w h] (platform/screen-size) | |
| 523 | 523 | w (long w) |
| 524 | 524 | h (long h)] |
| 525 | 525 | (when-not (= w @window-width) (reset! window-width w)) |
| @@ -528,7 +528,7 @@ | ||
| 528 | 528 | (defn pump! |
| 529 | 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 anywhere | |
| 531 | + Runs on the loop thread — `platform/frame-rgba!` may not be called from anywhere | |
| 532 | 532 | else, and neither may anything that touches a node." |
| 533 | 533 | [] |
| 534 | 534 | (pump-window!) |
| @@ -559,7 +559,7 @@ | ||
| 559 | 559 | arrives: polling slower than the window paints would show every other frame." |
| 560 | 560 | [] |
| 561 | 561 | (when (available?) |
| 562 | - (gui/every! 16 pump!))) | |
| 562 | + (platform/every! 16 pump!))) | |
| 563 | 563 | |
| 564 | 564 | (defn tiles |
| 565 | 565 | "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 no | 412 | ;; 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 by | 418 | ;; 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-feeds | 484 | (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 can | 519 | 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 anywhere | 531 | + 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 tiles | 564 | (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 @@ | ||
| 1 | 1 | (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 | 4 | The same trick as `frq.tui`: the components in `frq.app` do not know what is |
| 5 | 5 | under the reconciler, so requiring `glimmer-cosmic.core` after `frq.app` |
| @@ -34,11 +34,14 @@ | ||
| 34 | 34 | (cosmic/every! 3000 #(spit path (cosmic/dump-str)))) |
| 35 | 35 | |
| 36 | 36 | (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. | |
| 40 | 41 | (platform/override! {:after! cosmic/after! |
| 42 | + :every! cosmic/every! | |
| 41 | 43 | :quit! cosmic/quit! |
| 44 | + :screen-size cosmic/window-size | |
| 42 | 45 | :pick-image! cosmic/pick-image! |
| 43 | 46 | :picked-image! cosmic/picked-image! |
| 44 | 47 | :clipboard-image-png! cosmic/clipboard-image-png!}) |
| @@ -1,5 +1,5 @@ | |||
| 1 | (ns frq.cosmic | 1 | (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 is | 4 | 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 @@ | ||
| 1 | 1 | (ns frq.platform |
| 2 | 2 | "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])) | |
| 5 | 4 | |
| 6 | 5 | (defn android? |
| 7 | 6 | "Android, told from a desktop by a binary only it has. What hangs on this is |
| @@ -19,55 +18,68 @@ | ||
| 19 | 18 | [] |
| 20 | 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 | 21 | (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. | |
| 36 | 31 | (atom {})) |
| 37 | 32 | |
| 38 | 33 | (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." | |
| 41 | 37 | [m] |
| 42 | 38 | (swap! overrides merge m) |
| 43 | 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 | 57 | (defn after! |
| 46 | 58 | "Run `f` on the UI thread in about `ms` milliseconds. jvui's timers only run |
| 47 | 59 | inside jvui's loop, so a backend with a loop of its own has to lend its own." |
| 48 | 60 | [ms f] |
| 49 | - ((get @overrides :after! gui/after!) ms f)) | |
| 61 | + ((op :after! (fn [_ _] nil)) ms f)) | |
| 50 | 62 | |
| 51 | 63 | (defn quit! |
| 52 | 64 | "Close the window." |
| 53 | 65 | [] |
| 54 | - ((get @overrides :quit! gui/quit!))) | |
| 66 | + ((op :quit! (fn [] nil)))) | |
| 55 | 67 | |
| 56 | 68 | (defn pick-image! |
| 57 | 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 | 73 | (defn picked-image! |
| 62 | 74 | "Write the chosen picture to `path`; true once, when one has been chosen." |
| 63 | 75 | [path] |
| 64 | - ((get @overrides :picked-image! gui/picked-image!) path)) | |
| 76 | + ((op :picked-image! (fn [_] nil)) path)) | |
| 65 | 77 | |
| 66 | 78 | (defn clipboard-image-png! |
| 67 | 79 | "Write the picture on the clipboard to `path` as PNG; true when there was one. |
| 68 | 80 | jvui reads it through its own window, so another backend lends its own." |
| 69 | 81 | [path] |
| 70 | - ((get @overrides :clipboard-image-png! gui/clipboard-image-png!) path)) | |
| 82 | + ((op :clipboard-image-png! (fn [_] false)) path)) | |
| 71 | 83 | |
| 72 | 84 | (defn return-url |
| 73 | 85 | "The link that brings the app back to the front once the browser is done, or |
| @@ -76,3 +88,34 @@ | ||
| 76 | 88 | forward rather than a second copy of it." |
| 77 | 89 | [] |
| 78 | 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)) | |
| @@ -1,7 +1,6 @@ | |||
| 1 | (ns frq.platform | 1 | (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 is | 6 | "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 overrides | 21 | (defonce ^:private overrides |
| 33 | - ;; What a backend other than glimmer-jvui does in place of jvui's own. Filled | 22 | + ;; 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` — before | 23 | + ;; `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 run | 58 | "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-url | 84 | (defn return-url |
| 73 | "The link that brings the app back to the front once the browser is done, or | 85 | "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 @@ | ||
| 35 | 35 | [frq.state :as s] |
| 36 | 36 | [jolt.host :as host] |
| 37 | 37 | [glimmer.core :as ui] |
| 38 | + [frq.platform :as platform] | |
| 38 | 39 | [glimmer-tui.ffi :as tui-ffi] |
| 39 | 40 | ;; last, so its install! is the one that stands |
| 40 | 41 | [glimmer-tui.core :as tui])) |
| @@ -170,6 +171,14 @@ | ||
| 170 | 171 | ;; And how a scroll here answers "am I at the newest line?", which the |
| 171 | 172 | ;; window's scroll area answers for itself. |
| 172 | 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 | 182 | (app/start! {:after! tui/after! |
| 174 | 183 | :every! tui/every! |
| 175 | 184 | :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 stands | 40 | ;; 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 the | 171 | ;; 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! nil | 184 | :title! nil |