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

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

One frontend where there were three, and a core that is not Clojure

The libcosmic window and the terminal are gone, and `src/` with them: 27
namespaces, every one of which required jolt or glimmer, carrying the two
desktop backends, the AV media plane over MoQ, and the codecs and capture
beneath it. Flutter is the only frontend now — Android, the Linux desktop and
the web, from one `clojure -M:cljd compile`.

What went with it: the `cosmic`, `tui`, `repl`, `nrepl`, `theme`, `gen-moq`
and `desktop` recipes; the jolt-src, jolt-native and glimmer flake inputs and
every output built from them; the root deps.edn, which existed to put `src/`
on a classpath; `c/frq_h264.c` and the vtable adapter around it; the
`.modal/frq` container and the two toolchain scripts that assembled its
bundle; and the scheduled GitLab job that followed a flake input nothing reads
any more.

`common/` is compiled by one compiler rather than two, so the `#?(:jolt ...)`
reader conditionals in it are collapsed to the branch that survives — and
`frq.av.dial`, which only ever described how to dial libjoltmoq's SFU, is
deleted rather than left orphaned. `flutter/src/frq/theme/cosmic.cljd` was a
generated read of a COSMIC theme and is now `theme/tokens.cljd`: the same
numbers, kept as source, with no generator to re-run.

`nim/` is new, and is where the logic under the screens starts moving: the
portable rules — the wire format, the signatures, the atproto flows — in a
language with a type checker and a test runner that needs no Flutter to run.
`frq.irc.parse` is the first across, with 29 tests where it had none, behind a
C ABI whose two rules (caller frees, init once) are exercised from C through
dlopen over a hundred thousand parse/free cycles.

That half is real. The Dart binding is not: `flutter/src/frq/core/ffi.cljd` is
written but nothing requires it, so no build has compiled a line of it, and
`common/frq/irc/parse.cljc` is still what every target runs. Both READMEs say
so where someone would look. The originals stay regardless of how far the port
gets — a browser cannot load a native library, so until there is a wasm build
they are the web's implementation rather than dead code.

Verified: `just check-common` and `just nim-test` pass, the flake evaluates to
its Flutter outputs alone, and `modal run .modal/flutter-web/container.py`
builds the bundle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-18T19:16:18-07:00 Browse files
438b247 parent: b4172e9
deleted .buckconfig.local +0 -7
deleted file mode 100644
@@ -1,7 +0,0 @@
1-[frq]
2- jolt_native = /var/home/nandi/code/jolt-native
3- android_home = /var/home/nandi/.local/share/android-sdk
4- chez_android = /var/home/nandi/.cache/vidya-chez-android
5- openssl_android = /var/home/nandi/.cache/frq-openssl-android/lib
6- libvidya = checkout
7- boot_stamp = 99766f8764e29cdb652b6841cd8949f8815d17e836f4949d17991a67e0e38096
deleted file mode 100644
@@ -1,7 +0,0 @@
1-[frq]
2- jolt_native = /var/home/nandi/code/jolt-native
3- android_home = /var/home/nandi/.local/share/android-sdk
4- chez_android = /var/home/nandi/.cache/vidya-chez-android
5- openssl_android = /var/home/nandi/.cache/frq-openssl-android/lib
6- libvidya = checkout
7- boot_stamp = 99766f8764e29cdb652b6841cd8949f8815d17e836f4949d17991a67e0e38096
modified .gitignore +7 -8
@@ -1,6 +1,12 @@
1-# The native libraries `just lib` links out of the DotSlash cache.
1+# Build output — `just nim-lib` writes build/nim, the Flutter targets the rest.
22 /build/
33
4+# Nim's own object/cache tree, and the test binaries `nim c -r` leaves beside
5+# the sources — one per tests/t*.nim, named without an extension.
6+/nim/nimcache/
7+/nim/tests/t*
8+!/nim/tests/t*.nim
9+
410 # The pinned Flutter/JDK/Clojure tarballs `tools/toolchain.sh` fetches, plus
511 # the pub cache, gitlibs and maven repo it keeps beside them. A gigabyte of
612 # SDK, reproducible from the hashes in that script.
@@ -8,7 +14,6 @@
814
915 # Tool caches.
1016 /.cpcache/
11-/.jolt/
1217 /.lsp/
1318 /.clj-kondo/
1419
@@ -16,12 +21,6 @@
1621 /result
1722 /result-*
1823
19-# buck2 output tree.
20-/buck-out/
21-
22-# nrepl-server drops this next to the deps file for editors to find.
23-.nrepl-port
24-
2524 # samizdat writes its journal into whatever project it is pointed at.
2625 .samizdat/
2726
@@ -1,6 +1,12 @@
1-# The native libraries `just lib` links out of the DotSlash cache.1+# Build output — `just nim-lib` writes build/nim, the Flutter targets the rest.
2 /build/2 /build/
3 3
4+# Nim's own object/cache tree, and the test binaries `nim c -r` leaves beside
5+# the sources — one per tests/t*.nim, named without an extension.
6+/nim/nimcache/
7+/nim/tests/t*
8+!/nim/tests/t*.nim
9+
4 # The pinned Flutter/JDK/Clojure tarballs `tools/toolchain.sh` fetches, plus10 # The pinned Flutter/JDK/Clojure tarballs `tools/toolchain.sh` fetches, plus
5 # the pub cache, gitlibs and maven repo it keeps beside them. A gigabyte of11 # the pub cache, gitlibs and maven repo it keeps beside them. A gigabyte of
6 # SDK, reproducible from the hashes in that script.12 # SDK, reproducible from the hashes in that script.
@@ -8,7 +14,6 @@
8 14
9 # Tool caches.15 # Tool caches.
10 /.cpcache/16 /.cpcache/
11-/.jolt/
12 /.lsp/17 /.lsp/
13 /.clj-kondo/18 /.clj-kondo/
14 19
@@ -16,12 +21,6 @@
16 /result21 /result
17 /result-*22 /result-*
18 23
19-# buck2 output tree.
20-/buck-out/
21-
22-# nrepl-server drops this next to the deps file for editors to find.
23-.nrepl-port
24-
25 # samizdat writes its journal into whatever project it is pointed at.24 # samizdat writes its journal into whatever project it is pointed at.
26 .samizdat/25 .samizdat/
27 26
modified .gitlab-ci.yml +20 -61
@@ -1,70 +1,29 @@
1-# Two jobs: one that checks what common/ is allowed to contain on every push,
2-# and one that re-resolves the jolt-native input on a schedule.
1+# One job: a check on what common/ is allowed to contain, on every push.
32 #
4-# jolt-native publishes its Android objects under a "latest" alias on every
5-# default-branch build, and frq takes them as a flake input. A flake input is
6-# locked once and then stays put, so "latest" only means latest when something
7-# re-resolves it. The `apk` recipe does that for the machine building an APK;
8-# this does it for the repository, so main's flake.lock names a recent build
9-# rather than whichever one it was first locked against.
10-#
11-# Nothing is built by either. The update job resolves one input, and if that
12-# moved, commits the lock file — whether the new objects actually work is what
13-# an APK build answers, and that is deliberately not this job's business. The
14-# check reads source and no more. So this file needs no toolchain at all, which
15-# is what keeps it honest about running on every push.
16-stages: [check, update]
3+# Nothing is built here. The check reads source and no more, so this file needs
4+# no toolchain at all, which is what keeps it honest about running on every
5+# push. There used to be a second, scheduled job that re-resolved the
6+# jolt-native flake input; there is no jolt half any more and no input to
7+# follow, so there is nothing for a schedule to do.
8+stages: [check]
179
18-# common/ is compiled by jolt AND by ClojureDart, and only the jolt half is on
19-# the way to anything anyone runs day to day. So shared code reaching for the
20-# JVM breaks nothing the author can see, and the phone stops compiling at a
21-# namespace nobody touched — `Math/ceil` in the compose bar was the third time.
22-# Reading the source is enough to catch it, which is why this needs no toolchain
23-# and no builder: python and a checkout, a few seconds, on every push.
10+# common/ is compiled by ClojureDart for two targets — the APK and the Linux
11+# desktop — and shared code reaching for the JVM or a `dart:` library breaks
12+# one of them at a namespace nobody touched. `Math/ceil` in the compose bar was
13+# the third time. Reading the source is enough to catch it, which is why this
14+# needs no toolchain and no builder: python and a checkout, a few seconds, on
15+# every push.
2416 check-common:
2517 stage: check
2618 image: python:3-alpine
27- rules:
28- - if: $CI_PIPELINE_SOURCE != "schedule"
2919 script:
3020 - python3 tools/check-common.py common
3121
32-update-jolt-native:
33- stage: update
34- image: nixos/nix:latest
35- # Schedules only: on a push this would race whoever pushed, and on a merge
36- # request it would commit to a branch nobody asked it to touch.
37- rules:
38- - if: $CI_PIPELINE_SOURCE == "schedule"
39- variables:
40- # Committing reads the previous lock, so the checkout needs the git tree
41- # rather than a shallow single commit.
42- GIT_DEPTH: "0"
43- before_script:
44- - echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
22+# The Nim core's tests. A second job rather than a step in the first, because
23+# it wants a compiler where check-common wants nothing: one can fail without
24+# hiding the other, and the pair of them is still seconds.
25+nim-test:
26+ stage: check
27+ image: nimlang/nim:2.2.0-alpine
4528 script:
46- - |
47- set -eu
48- nix flake update jolt-native-android
49- if git diff --quiet -- flake.lock; then
50- echo "already on the newest build; nothing to commit."
51- exit 0
52- fi
53- # The alias moves whenever jolt-native builds, so name *which* build this
54- # landed on rather than saying "update flake.lock". lastModified is the
55- # only readable identity a tarball input carries — the jolt-native commit
56- # that produced it is not in the archive's metadata.
57- epoch=$(nix eval --raw --impure --expr \
58- "builtins.toString (builtins.fromJSON (builtins.readFile ./flake.lock)
59- ).nodes.jolt-native-android.locked.lastModified")
60- stamp=$(date -u -d "@$epoch" +%Y-%m-%dT%H:%MZ)
61- git config user.email "$GITLAB_USER_EMAIL"
62- git config user.name "nightly"
63- git add flake.lock
64- # [skip ci] because this pushes to the default branch, and the only job
65- # in this file is the one already running.
66- git commit -m "Follow jolt-native to its $stamp build [skip ci]"
67- # CI_JOB_TOKEN cannot push. FRQ_PUSH_TOKEN is a project access token with
68- # write_repository, which is the one thing this job needs configured.
69- git push "https://oauth2:$FRQ_PUSH_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH.git" \
70- "HEAD:$CI_DEFAULT_BRANCH"
29+ - cd nim && for t in tests/t*.nim; do nim c -r --hints:off --path:src "$t"; done
@@ -1,70 +1,29 @@
1-# Two jobs: one that checks what common/ is allowed to contain on every push,1+# One job: a check on what common/ is allowed to contain, on every push.
2-# and one that re-resolves the jolt-native input on a schedule.
3 #2 #
4-# jolt-native publishes its Android objects under a "latest" alias on every3+# Nothing is built here. The check reads source and no more, so this file needs
5-# default-branch build, and frq takes them as a flake input. A flake input is4+# no toolchain at all, which is what keeps it honest about running on every
6-# locked once and then stays put, so "latest" only means latest when something5+# push. There used to be a second, scheduled job that re-resolved the
7-# re-resolves it. The `apk` recipe does that for the machine building an APK;6+# jolt-native flake input; there is no jolt half any more and no input to
8-# this does it for the repository, so main's flake.lock names a recent build7+# follow, so there is nothing for a schedule to do.
9-# rather than whichever one it was first locked against.8+stages: [check]
10-#
11-# Nothing is built by either. The update job resolves one input, and if that
12-# moved, commits the lock file — whether the new objects actually work is what
13-# an APK build answers, and that is deliberately not this job's business. The
14-# check reads source and no more. So this file needs no toolchain at all, which
15-# is what keeps it honest about running on every push.
16-stages: [check, update]
17 9
18-# common/ is compiled by jolt AND by ClojureDart, and only the jolt half is on10+# common/ is compiled by ClojureDart for two targets — the APK and the Linux
19-# the way to anything anyone runs day to day. So shared code reaching for the11+# desktop — and shared code reaching for the JVM or a `dart:` library breaks
20-# JVM breaks nothing the author can see, and the phone stops compiling at a12+# one of them at a namespace nobody touched. `Math/ceil` in the compose bar was
21-# namespace nobody touched — `Math/ceil` in the compose bar was the third time.13+# the third time. Reading the source is enough to catch it, which is why this
22-# Reading the source is enough to catch it, which is why this needs no toolchain14+# needs no toolchain and no builder: python and a checkout, a few seconds, on
23-# and no builder: python and a checkout, a few seconds, on every push.15+# every push.
24 check-common:16 check-common:
25 stage: check17 stage: check
26 image: python:3-alpine18 image: python:3-alpine
27- rules:
28- - if: $CI_PIPELINE_SOURCE != "schedule"
29 script:19 script:
30 - python3 tools/check-common.py common20 - python3 tools/check-common.py common
31 21
32-update-jolt-native:22+# The Nim core's tests. A second job rather than a step in the first, because
33- stage: update23+# it wants a compiler where check-common wants nothing: one can fail without
34- image: nixos/nix:latest24+# hiding the other, and the pair of them is still seconds.
35- # Schedules only: on a push this would race whoever pushed, and on a merge25+nim-test:
36- # request it would commit to a branch nobody asked it to touch.26+ stage: check
37- rules:27+ image: nimlang/nim:2.2.0-alpine
38- - if: $CI_PIPELINE_SOURCE == "schedule"
39- variables:
40- # Committing reads the previous lock, so the checkout needs the git tree
41- # rather than a shallow single commit.
42- GIT_DEPTH: "0"
43- before_script:
44- - echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
45 script:28 script:
46- - |29+ - cd nim && for t in tests/t*.nim; do nim c -r --hints:off --path:src "$t"; done
47- set -eu
48- nix flake update jolt-native-android
49- if git diff --quiet -- flake.lock; then
50- echo "already on the newest build; nothing to commit."
51- exit 0
52- fi
53- # The alias moves whenever jolt-native builds, so name *which* build this
54- # landed on rather than saying "update flake.lock". lastModified is the
55- # only readable identity a tarball input carries — the jolt-native commit
56- # that produced it is not in the archive's metadata.
57- epoch=$(nix eval --raw --impure --expr \
58- "builtins.toString (builtins.fromJSON (builtins.readFile ./flake.lock)
59- ).nodes.jolt-native-android.locked.lastModified")
60- stamp=$(date -u -d "@$epoch" +%Y-%m-%dT%H:%MZ)
61- git config user.email "$GITLAB_USER_EMAIL"
62- git config user.name "nightly"
63- git add flake.lock
64- # [skip ci] because this pushes to the default branch, and the only job
65- # in this file is the one already running.
66- git commit -m "Follow jolt-native to its $stamp build [skip ci]"
67- # CI_JOB_TOKEN cannot push. FRQ_PUSH_TOKEN is a project access token with
68- # write_repository, which is the one thing this job needs configured.
69- git push "https://oauth2:$FRQ_PUSH_TOKEN@$CI_SERVER_HOST/$CI_PROJECT_PATH.git" \
70- "HEAD:$CI_DEFAULT_BRANCH"
modified .modal/flutter-dev/container.toml +7 -6
@@ -11,9 +11,10 @@ runtime = "sandbox"
1111 # levels up and `.` is the whole tree.
1212 context = "../.."
1313 include = ["."]
14-# `dev` is the shell you actually want. Bare `nix develop` here resolves to the
15-# flake's default shell -- libcosmic, jolt-native, a thousand crates of Rust --
16-# which is the wrong tree and an expensive way to find out.
14+# `dev` is the shell you actually want. There is no default shell in this
15+# flake any more -- the one that used to be there belonged to the retired
16+# libcosmic frontend -- so a bare `nix develop` here fails rather than
17+# resolving to the wrong tree.
1718 # The devShell, baked in rather than entered. `print-dev-env` writes the whole
1819 # environment out as shell -- PATH, the compiler, every variable mkShell sets
1920 # -- and realises its inputs on the way, so the closure becomes an image layer
@@ -29,11 +30,11 @@ commands = [
2930 ]
3031 # The build state a local checkout carries: 395MB of a 441MB repo, uploaded on
3132 # every start and wanted by nothing out there. Flutter builds into a volume of
32-# its own, and the jolt and clojure caches are this machine's.
33+# its own, and the clojure caches are this machine's.
3334 ignore = [
3435 "flutter/build", "flutter/.home", "flutter/.dart_tool",
3536 "flutter/.clojuredart", "flutter/.cpcache",
36- ".jolt", ".cpcache", "result", "build", ".git",
37+ ".cpcache", "result", "build", ".git",
3738 ]
3839
3940 # Two volumes doing two different jobs. `nix-cache` is the binary cache every
@@ -139,7 +140,7 @@ fi
139140 """
140141 # Nix's own cache, on the volume rather than in the container. Without it
141142 # every Sandbox starts empty and `nix develop` re-clones the flake's git
142-# inputs -- jolt-src, jolt-native, glimmer, nixgl and their transitives --
143+# inputs -- nixgl and its transitives --
143144 # because flake.lock pins which revision to fetch, not whether it is already
144145 # on disk. Set here rather than in the command so an interactive shell into
145146 # this container gets it too.
@@ -11,9 +11,10 @@ runtime = "sandbox"
11 # levels up and `.` is the whole tree.11 # levels up and `.` is the whole tree.
12 context = "../.."12 context = "../.."
13 include = ["."]13 include = ["."]
14-# `dev` is the shell you actually want. Bare `nix develop` here resolves to the14+# `dev` is the shell you actually want. There is no default shell in this
15-# flake's default shell -- libcosmic, jolt-native, a thousand crates of Rust --15+# flake any more -- the one that used to be there belonged to the retired
16-# which is the wrong tree and an expensive way to find out.16+# libcosmic frontend -- so a bare `nix develop` here fails rather than
17+# resolving to the wrong tree.
17 # The devShell, baked in rather than entered. `print-dev-env` writes the whole18 # The devShell, baked in rather than entered. `print-dev-env` writes the whole
18 # environment out as shell -- PATH, the compiler, every variable mkShell sets19 # environment out as shell -- PATH, the compiler, every variable mkShell sets
19 # -- and realises its inputs on the way, so the closure becomes an image layer20 # -- and realises its inputs on the way, so the closure becomes an image layer
@@ -29,11 +30,11 @@ commands = [
29 ]30 ]
30 # The build state a local checkout carries: 395MB of a 441MB repo, uploaded on31 # The build state a local checkout carries: 395MB of a 441MB repo, uploaded on
31 # every start and wanted by nothing out there. Flutter builds into a volume of32 # every start and wanted by nothing out there. Flutter builds into a volume of
32-# its own, and the jolt and clojure caches are this machine's.33+# its own, and the clojure caches are this machine's.
33 ignore = [34 ignore = [
34 "flutter/build", "flutter/.home", "flutter/.dart_tool",35 "flutter/build", "flutter/.home", "flutter/.dart_tool",
35 "flutter/.clojuredart", "flutter/.cpcache",36 "flutter/.clojuredart", "flutter/.cpcache",
36- ".jolt", ".cpcache", "result", "build", ".git",37+ ".cpcache", "result", "build", ".git",
37 ]38 ]
38 39
39 # Two volumes doing two different jobs. `nix-cache` is the binary cache every40 # Two volumes doing two different jobs. `nix-cache` is the binary cache every
@@ -139,7 +140,7 @@ fi
139 """140 """
140 # Nix's own cache, on the volume rather than in the container. Without it141 # Nix's own cache, on the volume rather than in the container. Without it
141 # every Sandbox starts empty and `nix develop` re-clones the flake's git142 # every Sandbox starts empty and `nix develop` re-clones the flake's git
142-# inputs -- jolt-src, jolt-native, glimmer, nixgl and their transitives --143+# inputs -- nixgl and its transitives --
143 # because flake.lock pins which revision to fetch, not whether it is already144 # because flake.lock pins which revision to fetch, not whether it is already
144 # on disk. Set here rather than in the command so an interactive shell into145 # on disk. Set here rather than in the command so an interactive shell into
145 # this container gets it too.146 # this container gets it too.
modified .modal/flutter-web/container.toml +2 -2
@@ -30,7 +30,7 @@ setup = [
3030 "apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git rsync tar unzip xz-utils && rm -rf /var/lib/apt/lists/*",
3131 ]
3232 # The build state a local checkout carries, wanted by nothing out here: this
33-# container builds into a volume of its own, and the jolt and clojure caches
33+# container builds into a volume of its own, and the clojure caches
3434 # are the laptop's.
3535 ignore = [
3636 "flutter/build", "flutter/.home", "flutter/.dart_tool",
@@ -47,7 +47,7 @@ ignore = [
4747 # The toolchain, which is a gigabyte of Flutter SDK and lives on the
4848 # volume out here.
4949 ".toolchain",
50- ".jolt", ".cpcache", "result", "build", ".git",
50+ ".cpcache", "result", "build", ".git",
5151 # An editor's linter rewrites this while the upload is reading it, and
5252 # Modal fails the whole run with "was modified during build process".
5353 # Nothing out here reads it.
@@ -30,7 +30,7 @@ setup = [
30 "apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git rsync tar unzip xz-utils && rm -rf /var/lib/apt/lists/*",30 "apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git rsync tar unzip xz-utils && rm -rf /var/lib/apt/lists/*",
31 ]31 ]
32 # The build state a local checkout carries, wanted by nothing out here: this32 # The build state a local checkout carries, wanted by nothing out here: this
33-# container builds into a volume of its own, and the jolt and clojure caches33+# container builds into a volume of its own, and the clojure caches
34 # are the laptop's.34 # are the laptop's.
35 ignore = [35 ignore = [
36 "flutter/build", "flutter/.home", "flutter/.dart_tool",36 "flutter/build", "flutter/.home", "flutter/.dart_tool",
@@ -47,7 +47,7 @@ ignore = [
47 # The toolchain, which is a gigabyte of Flutter SDK and lives on the47 # The toolchain, which is a gigabyte of Flutter SDK and lives on the
48 # volume out here.48 # volume out here.
49 ".toolchain",49 ".toolchain",
50- ".jolt", ".cpcache", "result", "build", ".git",50+ ".cpcache", "result", "build", ".git",
51 # An editor's linter rewrites this while the upload is reading it, and51 # An editor's linter rewrites this while the upload is reading it, and
52 # Modal fails the whole run with "was modified during build process".52 # Modal fails the whole run with "was modified during build process".
53 # Nothing out here reads it.53 # Nothing out here reads it.
deleted .modal/frq/README.md +0 -78
deleted file mode 100644
@@ -1,78 +0,0 @@
1-# `frq`
2-
3- modal run .modal/frq/container.py
4- just modal frq
5-
6-Defined by `container.toml`; see `../spec.md` for the keys.
7-Built on `debian:13-slim`.
8-
9-No nix, and — as with `flutter-web` — that is the point of this
10-container rather than an incidental fact about it. What comes out is
11-`build/frq-desktop-x86_64-linux.tar.gz`: a directory with a jolt
12-binary, the native objects, this tree's source, glimmer and
13-glimmer-cosmic, and a launcher. Unpack it anywhere and run
14-`bin/frq`.
15-
16-## What replaced the AppImage
17-
18-It used to be `nix build .#appimage` on an Arch-with-nix image,
19-against a `nix-cache` volume, with an hour's timeout sized for
20-libjoltcosmic's dependency tree and a substituter test to decide
21-whether writing the cache back was worth more than the build it
22-avoided.
23-
24-None of that was nix doing a bad job. It was nix building from
25-source what is now published in a form a machine without nix can
26-use:
27-
28-| piece | where it comes from now |
29-| --- | --- |
30-| `jolt` | a release binary, chez linked in statically — stock `/lib64` interpreter, NEEDED `libc` and `libm` |
31-| `libjoltcosmic`, `libjolttui`, `libvidya`, `libjoltmoq` | jolt-native's `x86_64-linux-portable.tar.gz` — RUNPATH `$ORIGIN`, NEEDED closure alongside |
32-| `libmoq_ffi` | an upstream release object |
33-| `glimmer`, `glimmer-cosmic` | source, cloned at a pinned rev |
34-| `libfrqh264.so` | one `.c` file, compiled here |
35-
36-What was left after that was a Mesa, and a nixGL to put the host's
37-driver in front of it. The AppImage existed to carry the closure
38-that Mesa was part of. Not carrying a Mesa means not needing a
39-nixGL, which means not needing the bundle — the GL driver is the
40-host's, the way it is for every other program on the machine.
41-
42-`tools/desktop-toolchain.sh` holds the pins and
43-`tools/build-desktop.sh` does the assembly; `just desktop` runs the
44-same script on a laptop. The toolchain lands on the `devshell`
45-volume and a second run finds it there.
46-
47-## The pin that has to be filled in
48-
49-`JOLT_NATIVE_SHA` in `tools/desktop-toolchain.sh` is a placeholder
50-until jolt-native's pipeline has published a portable tarball for
51-the revision named beside it. The script refuses to fetch until it
52-is real rather than falling back to an unpinned download — the
53-package registry serves the newest upload under a given name, which
54-is exactly the moving target a pin is for. It prints the two
55-commands that fix it.
56-
57-## What is not in the bundle
58-
59-The GL driver and glibc, on purpose — both are the host's, and a
60-newer loader can load an older program's libraries rather than the
61-reverse.
62-
63-The ALSA PipeWire plugin, also the host's. The flake named a store
64-path for it; the launcher looks in the three places a distro puts
65-it. Without it `default` resolves only to raw hardware devices,
66-which PipeWire is already holding.
67-
68-`libjolttui` rides along in the tarball because it is in the
69-portable one, but nothing in this bundle starts it — `.#tui` is
70-still a nix output and the terminal backend needs no bundle to be
71-useful.
72-
73-## Runs as a Sandbox
74-
75-On a real VM (kernel 6.x, not gVisor), the command being the
76-sandbox's own process, so it dies when the command exits. It builds
77-only: there is no GL and no display here, and nothing tries to open
78-the window.
deleted file mode 100644
@@ -1,78 +0,0 @@
1-# `frq`
2-
3- modal run .modal/frq/container.py
4- just modal frq
5-
6-Defined by `container.toml`; see `../spec.md` for the keys.
7-Built on `debian:13-slim`.
8-
9-No nix, and — as with `flutter-web` — that is the point of this
10-container rather than an incidental fact about it. What comes out is
11-`build/frq-desktop-x86_64-linux.tar.gz`: a directory with a jolt
12-binary, the native objects, this tree's source, glimmer and
13-glimmer-cosmic, and a launcher. Unpack it anywhere and run
14-`bin/frq`.
15-
16-## What replaced the AppImage
17-
18-It used to be `nix build .#appimage` on an Arch-with-nix image,
19-against a `nix-cache` volume, with an hour's timeout sized for
20-libjoltcosmic's dependency tree and a substituter test to decide
21-whether writing the cache back was worth more than the build it
22-avoided.
23-
24-None of that was nix doing a bad job. It was nix building from
25-source what is now published in a form a machine without nix can
26-use:
27-
28-| piece | where it comes from now |
29-| --- | --- |
30-| `jolt` | a release binary, chez linked in statically — stock `/lib64` interpreter, NEEDED `libc` and `libm` |
31-| `libjoltcosmic`, `libjolttui`, `libvidya`, `libjoltmoq` | jolt-native's `x86_64-linux-portable.tar.gz` — RUNPATH `$ORIGIN`, NEEDED closure alongside |
32-| `libmoq_ffi` | an upstream release object |
33-| `glimmer`, `glimmer-cosmic` | source, cloned at a pinned rev |
34-| `libfrqh264.so` | one `.c` file, compiled here |
35-
36-What was left after that was a Mesa, and a nixGL to put the host's
37-driver in front of it. The AppImage existed to carry the closure
38-that Mesa was part of. Not carrying a Mesa means not needing a
39-nixGL, which means not needing the bundle — the GL driver is the
40-host's, the way it is for every other program on the machine.
41-
42-`tools/desktop-toolchain.sh` holds the pins and
43-`tools/build-desktop.sh` does the assembly; `just desktop` runs the
44-same script on a laptop. The toolchain lands on the `devshell`
45-volume and a second run finds it there.
46-
47-## The pin that has to be filled in
48-
49-`JOLT_NATIVE_SHA` in `tools/desktop-toolchain.sh` is a placeholder
50-until jolt-native's pipeline has published a portable tarball for
51-the revision named beside it. The script refuses to fetch until it
52-is real rather than falling back to an unpinned download — the
53-package registry serves the newest upload under a given name, which
54-is exactly the moving target a pin is for. It prints the two
55-commands that fix it.
56-
57-## What is not in the bundle
58-
59-The GL driver and glibc, on purpose — both are the host's, and a
60-newer loader can load an older program's libraries rather than the
61-reverse.
62-
63-The ALSA PipeWire plugin, also the host's. The flake named a store
64-path for it; the launcher looks in the three places a distro puts
65-it. Without it `default` resolves only to raw hardware devices,
66-which PipeWire is already holding.
67-
68-`libjolttui` rides along in the tarball because it is in the
69-portable one, but nothing in this bundle starts it — `.#tui` is
70-still a nix output and the terminal backend needs no bundle to be
71-useful.
72-
73-## Runs as a Sandbox
74-
75-On a real VM (kernel 6.x, not gVisor), the command being the
76-sandbox's own process, so it dies when the command exits. It builds
77-only: there is no GL and no display here, and nothing tries to open
78-the window.
deleted .modal/frq/container.py +0 -22
deleted file mode 100644
@@ -1,22 +0,0 @@
1-"""Generated stub -- the container is defined by container.toml.
2-
3-Edit container.toml, not this file.
4-"""
5-
6-import os
7-import sys
8-
9-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10-
11-from _loader import Container # noqa: E402
12-
13-c = Container.from_toml(__file__)
14-image, app = c.image, c.app
15-
16-
17-# No @app.function here: a Sandbox runs its command as its own process and
18-# nothing of this module is imported into it. Registering a Function would be
19-# dead weight, and its kwargs are where vm_runtime would be wrongly applied.
20-@app.local_entrypoint()
21-def main(command: str = ""):
22- c.run_sandbox(command)
deleted file mode 100644
@@ -1,22 +0,0 @@
1-"""Generated stub -- the container is defined by container.toml.
2-
3-Edit container.toml, not this file.
4-"""
5-
6-import os
7-import sys
8-
9-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10-
11-from _loader import Container # noqa: E402
12-
13-c = Container.from_toml(__file__)
14-image, app = c.image, c.app
15-
16-
17-# No @app.function here: a Sandbox runs its command as its own process and
18-# nothing of this module is imported into it. Registering a Function would be
19-# dead weight, and its kwargs are where vm_runtime would be wrongly applied.
20-@app.local_entrypoint()
21-def main(command: str = ""):
22- c.run_sandbox(command)
deleted .modal/frq/container.toml +0 -106
deleted file mode 100644
@@ -1,106 +0,0 @@
1-[container]
2-name = "frq"
3-description = "the cosmic desktop bundle, from a pinned toolchain"
4-# `debian:13-slim` and not `arch-nix`: there is no nix in this container any
5-# more, and the AppImage it used to build is gone with it. What this assembles
6-# is a directory — a jolt binary, the native objects, the source and a
7-# launcher — and what it owes that is curl, git, tar, a C compiler and
8-# openh264's headers.
9-#
10-# The reason this could stop being a nix build is not that nix was doing it
11-# badly. It is that everything it was building from source is now published as
12-# something a machine without nix can use: jolt as one statically-linked
13-# binary, the backends as jolt-native's `portable` tarball (RUNPATH $ORIGIN,
14-# NEEDED closure alongside), libmoq_ffi as an upstream release object. What
15-# was left was a Mesa and a nixGL to put the host driver in front of it —
16-# and not carrying a Mesa is what means not needing a nixGL.
17-registry = "debian:13-slim"
18-# A Sandbox, not a Function: it runs on a real VM and the command is the
19-# sandbox's own process, so it dies when the command does.
20-runtime = "sandbox"
21-
22-[build]
23-# The container lives inside the repo it builds, so the copy is rooted two
24-# levels up and `.` is the whole tree -- uncommitted edits included, which is
25-# the point of copying rather than fetching.
26-context = "../.."
27-include = ["."]
28-# One apt line, where there used to be a nix store to populate. gcc and
29-# pkg-config for `c/frq_h264.c`, which is the only thing here that compiles;
30-# libopenh264-dev for its header and link; libopus and libasound because the
31-# bundle carries them out to the machine that runs it. patchelf to set
32-# $ORIGIN on the objects that arrive without it. git for the two pinned source
33-# checkouts, ca-certificates so curl can verify what it fetches.
34-setup = [
35- "apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git gcc libc6-dev pkg-config libopenh264-dev libopus-dev libasound2-dev patchelf rsync tar xz-utils && rm -rf /var/lib/apt/lists/*",
36-]
37-# The build state a local checkout carries, wanted by nothing out here.
38-ignore = [
39- "flutter/build", "flutter/.home", "flutter/.dart_tool",
40- "flutter/.clojuredart", "flutter/.cpcache", "flutter/lib/cljd-out",
41- # The toolchains, which live on the volume out here.
42- ".toolchain", ".toolchain-desktop",
43- ".jolt", ".cpcache", "result", "build", ".git",
44- # An editor's linter rewrites this while the upload is reading it, and
45- # Modal fails the whole run with "was modified during build process".
46- ".clj-kondo",
47-]
48-
49-# `devshell` and not `nix-cache`, which went with nix — the same volume every
50-# other container's working state lives on, under a directory named for this
51-# one. The toolchain is what makes a second run cheap now: ~30MB of jolt and
52-# objects fetched once and found there afterwards.
53-[volumes]
54-devshell = "/devshell"
55-
56-[resources]
57-# Four, not eight: nothing here compiles except one .c file. What this job
58-# spends its time on is fetching and copying.
59-cpu = 4
60-memory = 8192
61-# Minutes, not an hour. The old timeout was sized for libjoltcosmic's
62-# dependency tree, which is now somebody else's pipeline's problem.
63-timeout = 900
64-
65-[run]
66-workdir = "/app"
67-# Source in, toolchain out of the volume, assemble, tar. The old command was a
68-# `nix build` of a closure with ~200 store paths in it, a substituter test to
69-# decide whether writing the cache back was worth it, and a copy of the
70-# AppImage into the volume. This is four steps and none of them evaluates
71-# anything.
72-command = """
73-set -e
74-BUILD_DIR=/devshell/frq-desktop
75-
76-# Beside the working tree and NOT inside it: the rsync below runs with
77-# --delete, so anything under $BUILD_DIR that is not in /app is removed on
78-# every run. A toolchain kept in there would be deleted moments before it was
79-# consulted.
80-export FRQ_DESKTOP_TOOLCHAIN=/devshell/frq-desktop.toolchain
81-mkdir -p "$BUILD_DIR" "$FRQ_DESKTOP_TOOLCHAIN" /devshell/artifacts
82-
83-echo "sync: /app -> $BUILD_DIR"
84-rsync -a --checksum --delete \
85- --exclude '.toolchain/' \
86- --exclude '.toolchain-desktop/' \
87- --exclude '.git' \
88- /app/ "$BUILD_DIR/"
89-
90-cd "$BUILD_DIR"
91-[ -d "$FRQ_DESKTOP_TOOLCHAIN/jolt" ] && echo " carried over: the toolchain"
92-
93-# The same script `just desktop` runs, and the same single action: what a
94-# laptop assembles and what this ships are the same directory.
95-tools/build-desktop.sh tar
96-
97-# Where the runner fetches it from, the way the AppImage was left in the
98-# nix-cache volume before. One file, already a squashed tree.
99-cp -L build/frq-desktop-x86_64-linux.tar.gz /devshell/artifacts/
100-ls -la /devshell/artifacts/frq-desktop-x86_64-linux.tar.gz
101-"""
102-# Set here rather than in the command so a shell into this container gets them
103-# too. FRQ_JOLT_NATIVE_SHA is the one pin the script will not invent: until
104-# jolt-native's pipeline has published a portable tarball for the revision
105-# tools/desktop-toolchain.sh names, this container fails fast and says so.
106-env = { FRQ_DESKTOP_TOOLCHAIN = "/devshell/frq-desktop.toolchain" }
deleted file mode 100644
@@ -1,106 +0,0 @@
1-[container]
2-name = "frq"
3-description = "the cosmic desktop bundle, from a pinned toolchain"
4-# `debian:13-slim` and not `arch-nix`: there is no nix in this container any
5-# more, and the AppImage it used to build is gone with it. What this assembles
6-# is a directory — a jolt binary, the native objects, the source and a
7-# launcher — and what it owes that is curl, git, tar, a C compiler and
8-# openh264's headers.
9-#
10-# The reason this could stop being a nix build is not that nix was doing it
11-# badly. It is that everything it was building from source is now published as
12-# something a machine without nix can use: jolt as one statically-linked
13-# binary, the backends as jolt-native's `portable` tarball (RUNPATH $ORIGIN,
14-# NEEDED closure alongside), libmoq_ffi as an upstream release object. What
15-# was left was a Mesa and a nixGL to put the host driver in front of it —
16-# and not carrying a Mesa is what means not needing a nixGL.
17-registry = "debian:13-slim"
18-# A Sandbox, not a Function: it runs on a real VM and the command is the
19-# sandbox's own process, so it dies when the command does.
20-runtime = "sandbox"
21-
22-[build]
23-# The container lives inside the repo it builds, so the copy is rooted two
24-# levels up and `.` is the whole tree -- uncommitted edits included, which is
25-# the point of copying rather than fetching.
26-context = "../.."
27-include = ["."]
28-# One apt line, where there used to be a nix store to populate. gcc and
29-# pkg-config for `c/frq_h264.c`, which is the only thing here that compiles;
30-# libopenh264-dev for its header and link; libopus and libasound because the
31-# bundle carries them out to the machine that runs it. patchelf to set
32-# $ORIGIN on the objects that arrive without it. git for the two pinned source
33-# checkouts, ca-certificates so curl can verify what it fetches.
34-setup = [
35- "apt-get update && apt-get install -y --no-install-recommends ca-certificates curl git gcc libc6-dev pkg-config libopenh264-dev libopus-dev libasound2-dev patchelf rsync tar xz-utils && rm -rf /var/lib/apt/lists/*",
36-]
37-# The build state a local checkout carries, wanted by nothing out here.
38-ignore = [
39- "flutter/build", "flutter/.home", "flutter/.dart_tool",
40- "flutter/.clojuredart", "flutter/.cpcache", "flutter/lib/cljd-out",
41- # The toolchains, which live on the volume out here.
42- ".toolchain", ".toolchain-desktop",
43- ".jolt", ".cpcache", "result", "build", ".git",
44- # An editor's linter rewrites this while the upload is reading it, and
45- # Modal fails the whole run with "was modified during build process".
46- ".clj-kondo",
47-]
48-
49-# `devshell` and not `nix-cache`, which went with nix — the same volume every
50-# other container's working state lives on, under a directory named for this
51-# one. The toolchain is what makes a second run cheap now: ~30MB of jolt and
52-# objects fetched once and found there afterwards.
53-[volumes]
54-devshell = "/devshell"
55-
56-[resources]
57-# Four, not eight: nothing here compiles except one .c file. What this job
58-# spends its time on is fetching and copying.
59-cpu = 4
60-memory = 8192
61-# Minutes, not an hour. The old timeout was sized for libjoltcosmic's
62-# dependency tree, which is now somebody else's pipeline's problem.
63-timeout = 900
64-
65-[run]
66-workdir = "/app"
67-# Source in, toolchain out of the volume, assemble, tar. The old command was a
68-# `nix build` of a closure with ~200 store paths in it, a substituter test to
69-# decide whether writing the cache back was worth it, and a copy of the
70-# AppImage into the volume. This is four steps and none of them evaluates
71-# anything.
72-command = """
73-set -e
74-BUILD_DIR=/devshell/frq-desktop
75-
76-# Beside the working tree and NOT inside it: the rsync below runs with
77-# --delete, so anything under $BUILD_DIR that is not in /app is removed on
78-# every run. A toolchain kept in there would be deleted moments before it was
79-# consulted.
80-export FRQ_DESKTOP_TOOLCHAIN=/devshell/frq-desktop.toolchain
81-mkdir -p "$BUILD_DIR" "$FRQ_DESKTOP_TOOLCHAIN" /devshell/artifacts
82-
83-echo "sync: /app -> $BUILD_DIR"
84-rsync -a --checksum --delete \
85- --exclude '.toolchain/' \
86- --exclude '.toolchain-desktop/' \
87- --exclude '.git' \
88- /app/ "$BUILD_DIR/"
89-
90-cd "$BUILD_DIR"
91-[ -d "$FRQ_DESKTOP_TOOLCHAIN/jolt" ] && echo " carried over: the toolchain"
92-
93-# The same script `just desktop` runs, and the same single action: what a
94-# laptop assembles and what this ships are the same directory.
95-tools/build-desktop.sh tar
96-
97-# Where the runner fetches it from, the way the AppImage was left in the
98-# nix-cache volume before. One file, already a squashed tree.
99-cp -L build/frq-desktop-x86_64-linux.tar.gz /devshell/artifacts/
100-ls -la /devshell/artifacts/frq-desktop-x86_64-linux.tar.gz
101-"""
102-# Set here rather than in the command so a shell into this container gets them
103-# too. FRQ_JOLT_NATIVE_SHA is the one pin the script will not invent: until
104-# jolt-native's pipeline has published a portable tarball for the revision
105-# tools/desktop-toolchain.sh names, this container fails fast and says so.
106-env = { FRQ_DESKTOP_TOOLCHAIN = "/devshell/frq-desktop.toolchain" }
deleted .modal/frq/main.py +0 -15
deleted file mode 100644
@@ -1,15 +0,0 @@
1-#!/usr/bin/env python3
2-"""The app. Replace this with something that earns its container."""
3-
4-import os
5-import platform
6-
7-
8-def main():
9- print(os.environ.get("GREETING", "hello"))
10- print(f"python {platform.python_version()}")
11- print(f"host {platform.node()} ({platform.machine()})")
12-
13-
14-if __name__ == "__main__":
15- main()
deleted file mode 100644
@@ -1,15 +0,0 @@
1-#!/usr/bin/env python3
2-"""The app. Replace this with something that earns its container."""
3-
4-import os
5-import platform
6-
7-
8-def main():
9- print(os.environ.get("GREETING", "hello"))
10- print(f"python {platform.python_version()}")
11- print(f"host {platform.node()} ({platform.machine()})")
12-
13-
14-if __name__ == "__main__":
15- main()
modified .rickub/workflows/build.yml +31 -33
@@ -1,19 +1,12 @@
11 # The build, on rickub. GitLab CI next door reads source and no more —
2-# check-common on every push, the flake.lock nightly — and deliberately builds
3-# nothing. This is the other half: the desktop bundle, actually assembled.
2+# check-common on every push — and deliberately builds nothing. This is the
3+# other half: the web bundle, actually compiled.
44 #
5-# It is not assembled *here*, though it nearly could be now. The job hands the
6-# work to Modal exactly as a person at a terminal would, and the Sandbox does
7-# it against the `devshell` volume. What a runner contributes is a checkout, a
8-# python, and somewhere to put the result afterwards.
9-#
10-# It used to be `nix build .#appimage`, which a rickub runner could not have
11-# done at all — a container against libjoltcosmic's dependency tree is the
12-# laptop-shaped death CLAUDE.md warns about. That is no longer what the
13-# container runs: jolt, the backends and libmoq_ffi all arrive pinned and
14-# prebuilt, so the Modal side fetches and copies rather than compiling. The
15-# reason it still goes to Modal is the volume the toolchain is cached on, not
16-# the size of the build.
5+# It is not compiled *here*. The job hands the work to Modal exactly as a
6+# person at a terminal would, and the Sandbox does it against the `devshell`
7+# volume. What a runner contributes is a checkout, a python, and somewhere to
8+# put the result afterwards. The reason it goes to Modal is the volume the
9+# toolchain is cached on, not the size of the build.
1710 #
1811 # Lives in .rickub/workflows/ rather than .github/workflows/ because rickub
1912 # reads one or the other and never both: with this directory present, a
@@ -28,22 +21,30 @@ on:
2821
2922 jobs:
3023 # The same read-only check GitLab runs, for the same reason: common/ compiles
31- # twice and only the jolt half is on the way to anything anyone runs, so a
32- # JVM call in shared code breaks the phone at a namespace nobody touched.
33- # Seconds, no toolchain. Worth having on both hosts rather than depending on
34- # which one a given push reaches.
24+ # for two targets, so a host-specific call in shared code breaks one of them
25+ # at a namespace nobody touched. Seconds, no toolchain. Worth having on both
26+ # hosts rather than depending on which one a given push reaches.
3527 check-common:
3628 runs-on: ubuntu-latest
3729 steps:
3830 - uses: actions/checkout@v4
3931 - run: python3 tools/check-common.py common
4032
41- desktop:
33+ # The Nim core's tests. No Flutter, no Dart, no Android SDK — which is the
34+ # reason the logic is moving there: a rule about the IRC wire format gets
35+ # checked in seconds rather than behind a toolchain.
36+ nim-test:
37+ runs-on: ubuntu-latest
38+ container: nimlang/nim:2.2.0-alpine
39+ steps:
40+ - uses: actions/checkout@v4
41+ - run: cd nim && for t in tests/t*.nim; do nim c -r --hints:off --path:src "$t"; done
42+
43+ web:
4244 runs-on: ubuntu-latest
43- needs: check-common
44- # Minutes now, not an hour. The Modal side compiles one .c file; what it
45- # spends its time on is fetching the pinned pieces on a cold toolchain and
46- # the upload of the tree from here.
45+ needs: [check-common, nim-test]
46+ # What it spends its time on is the ClojureDart compile and, on a cold
47+ # toolchain, fetching the pinned Flutter/JDK/Clojure tarballs.
4748 timeout-minutes: 30
4849 steps:
4950 # The container copies `.` — the whole working tree, uncommitted edits
@@ -60,7 +61,7 @@ jobs:
6061 # Two secrets, set under Settings -> Secrets and variables. A Modal
6162 # token is the whole of this job's configuration: no nix, no builder,
6263 # no cache of its own.
63- - name: Assemble the desktop bundle, on Modal
64+ - name: Build the web bundle, on Modal
6465 env:
6566 MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
6667 MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
@@ -68,25 +69,22 @@ jobs:
6869 # nowhere else, and `modal app logs` cannot reach an ephemeral run —
6970 # so this terminal is the only place the build is visible. tee, not
7071 # tail: a run killed mid-pipe through tail takes its output with it.
71- run: modal run .modal/frq/container.py 2>&1 | tee /tmp/frq-build.log
72+ run: modal run .modal/flutter-web/container.py 2>&1 | tee /tmp/frq-build.log
7273
73- # The Sandbox leaves the tarball on the devshell volume rather than
74- # anywhere a runner can see, so fetch it back out. One file, already a
75- # squashed tree — nothing to import on the other end, and nothing to
76- # unpack before it can be uploaded.
74+ # The Sandbox leaves the bundle on the devshell volume rather than
75+ # anywhere a runner can see, so fetch it back out.
7776 - name: Fetch the bundle out of the volume
7877 env:
7978 MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
8079 MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
8180 run: |
8281 modal volume get --force devshell \
83- artifacts/frq-desktop-x86_64-linux.tar.gz \
84- frq-desktop-x86_64-linux.tar.gz
82+ frq-flutter-web/flutter/build/web web
8583
8684 - uses: actions/upload-artifact@v4
8785 with:
88- name: frq-desktop-${{ github.sha }}
89- path: frq-desktop-x86_64-linux.tar.gz
86+ name: frq-web-${{ github.sha }}
87+ path: web
9088 if-no-files-found: error
9189
9290 # Kept whether or not the build succeeded: a failed run's log is the
@@ -1,19 +1,12 @@
1 # The build, on rickub. GitLab CI next door reads source and no more —1 # The build, on rickub. GitLab CI next door reads source and no more —
2-# check-common on every push, the flake.lock nightly — and deliberately builds2+# check-common on every push — and deliberately builds nothing. This is the
3-# nothing. This is the other half: the desktop bundle, actually assembled.3+# other half: the web bundle, actually compiled.
4 #4 #
5-# It is not assembled *here*, though it nearly could be now. The job hands the5+# It is not compiled *here*. The job hands the work to Modal exactly as a
6-# work to Modal exactly as a person at a terminal would, and the Sandbox does6+# person at a terminal would, and the Sandbox does it against the `devshell`
7-# it against the `devshell` volume. What a runner contributes is a checkout, a7+# volume. What a runner contributes is a checkout, a python, and somewhere to
8-# python, and somewhere to put the result afterwards.8+# put the result afterwards. The reason it goes to Modal is the volume the
9-#9+# toolchain is cached on, not the size of the build.
10-# It used to be `nix build .#appimage`, which a rickub runner could not have
11-# done at all — a container against libjoltcosmic's dependency tree is the
12-# laptop-shaped death CLAUDE.md warns about. That is no longer what the
13-# container runs: jolt, the backends and libmoq_ffi all arrive pinned and
14-# prebuilt, so the Modal side fetches and copies rather than compiling. The
15-# reason it still goes to Modal is the volume the toolchain is cached on, not
16-# the size of the build.
17 #10 #
18 # Lives in .rickub/workflows/ rather than .github/workflows/ because rickub11 # Lives in .rickub/workflows/ rather than .github/workflows/ because rickub
19 # reads one or the other and never both: with this directory present, a12 # reads one or the other and never both: with this directory present, a
@@ -28,22 +21,30 @@ on:
28 21
29 jobs:22 jobs:
30 # The same read-only check GitLab runs, for the same reason: common/ compiles23 # The same read-only check GitLab runs, for the same reason: common/ compiles
31- # twice and only the jolt half is on the way to anything anyone runs, so a24+ # for two targets, so a host-specific call in shared code breaks one of them
32- # JVM call in shared code breaks the phone at a namespace nobody touched.25+ # at a namespace nobody touched. Seconds, no toolchain. Worth having on both
33- # Seconds, no toolchain. Worth having on both hosts rather than depending on26+ # hosts rather than depending on which one a given push reaches.
34- # which one a given push reaches.
35 check-common:27 check-common:
36 runs-on: ubuntu-latest28 runs-on: ubuntu-latest
37 steps:29 steps:
38 - uses: actions/checkout@v430 - uses: actions/checkout@v4
39 - run: python3 tools/check-common.py common31 - run: python3 tools/check-common.py common
40 32
41- desktop:33+ # The Nim core's tests. No Flutter, no Dart, no Android SDK — which is the
34+ # reason the logic is moving there: a rule about the IRC wire format gets
35+ # checked in seconds rather than behind a toolchain.
36+ nim-test:
37+ runs-on: ubuntu-latest
38+ container: nimlang/nim:2.2.0-alpine
39+ steps:
40+ - uses: actions/checkout@v4
41+ - run: cd nim && for t in tests/t*.nim; do nim c -r --hints:off --path:src "$t"; done
42+
43+ web:
42 runs-on: ubuntu-latest44 runs-on: ubuntu-latest
43- needs: check-common45+ needs: [check-common, nim-test]
44- # Minutes now, not an hour. The Modal side compiles one .c file; what it46+ # What it spends its time on is the ClojureDart compile and, on a cold
45- # spends its time on is fetching the pinned pieces on a cold toolchain and47+ # toolchain, fetching the pinned Flutter/JDK/Clojure tarballs.
46- # the upload of the tree from here.
47 timeout-minutes: 3048 timeout-minutes: 30
48 steps:49 steps:
49 # The container copies `.` — the whole working tree, uncommitted edits50 # The container copies `.` — the whole working tree, uncommitted edits
@@ -60,7 +61,7 @@ jobs:
60 # Two secrets, set under Settings -> Secrets and variables. A Modal61 # Two secrets, set under Settings -> Secrets and variables. A Modal
61 # token is the whole of this job's configuration: no nix, no builder,62 # token is the whole of this job's configuration: no nix, no builder,
62 # no cache of its own.63 # no cache of its own.
63- - name: Assemble the desktop bundle, on Modal64+ - name: Build the web bundle, on Modal
64 env:65 env:
65 MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}66 MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
66 MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}67 MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
@@ -68,25 +69,22 @@ jobs:
68 # nowhere else, and `modal app logs` cannot reach an ephemeral run —69 # nowhere else, and `modal app logs` cannot reach an ephemeral run —
69 # so this terminal is the only place the build is visible. tee, not70 # so this terminal is the only place the build is visible. tee, not
70 # tail: a run killed mid-pipe through tail takes its output with it.71 # tail: a run killed mid-pipe through tail takes its output with it.
71- run: modal run .modal/frq/container.py 2>&1 | tee /tmp/frq-build.log72+ run: modal run .modal/flutter-web/container.py 2>&1 | tee /tmp/frq-build.log
72 73
73- # The Sandbox leaves the tarball on the devshell volume rather than74+ # The Sandbox leaves the bundle on the devshell volume rather than
74- # anywhere a runner can see, so fetch it back out. One file, already a75+ # anywhere a runner can see, so fetch it back out.
75- # squashed tree — nothing to import on the other end, and nothing to
76- # unpack before it can be uploaded.
77 - name: Fetch the bundle out of the volume76 - name: Fetch the bundle out of the volume
78 env:77 env:
79 MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}78 MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }}
80 MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}79 MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }}
81 run: |80 run: |
82 modal volume get --force devshell \81 modal volume get --force devshell \
83- artifacts/frq-desktop-x86_64-linux.tar.gz \82+ frq-flutter-web/flutter/build/web web
84- frq-desktop-x86_64-linux.tar.gz
85 83
86 - uses: actions/upload-artifact@v484 - uses: actions/upload-artifact@v4
87 with:85 with:
88- name: frq-desktop-${{ github.sha }}86+ name: frq-web-${{ github.sha }}
89- path: frq-desktop-x86_64-linux.tar.gz87+ path: web
90 if-no-files-found: error88 if-no-files-found: error
91 89
92 # Kept whether or not the build succeeded: a failed run's log is the90 # Kept whether or not the build succeeded: a failed run's log is the
modified .zed/settings.json +1 -1
@@ -1,5 +1,5 @@
11 {
22 "file_types": {
3- "Clojure": ["*.clj"]
3+ "Clojure": ["*.cljc", "*.cljd"]
44 }
55 }
@@ -1,5 +1,5 @@
1 {1 {
2 "file_types": {2 "file_types": {
3- "Clojure": ["*.clj"]3+ "Clojure": ["*.cljc", "*.cljd"]
4 }4 }
5 }5 }
modified CLAUDE.md +65 -42
@@ -4,16 +4,17 @@
44
55 **STOP BUILDING LOCALLY. Build on Modal.** This machine is for editing and for
66 evaluating — `nix flake check`, `nix eval`, `nix build --dry-run`, `nix repl`
7-— and not for realising a derivation. A local `nix build .#frq` gets killed for
8-memory long before it finishes, and the minutes spent finding that out are
9-minutes not spent on the change. So:
7+— and not for realising a derivation. A local build of the whole graph gets
8+killed for memory long before it finishes, and the minutes spent finding that
9+out are minutes not spent on the change. So:
1010
1111 ```bash
12-modal run .modal/frq/container.py # the desktop bundle, on Modal
12+modal run .modal/flutter-web/container.py # the web bundle, on Modal
13+modal run .modal/flutter-dev/container.py # the incremental Flutter loop
1314 ```
1415
15-The container is `.modal/`, not a fourth source tree: it is CI config that
16-happens to live here, the way `.github/` would be.
16+The containers in `.modal/` are not a third source tree: they are CI config
17+that happens to live here, the way `.github/` would be.
1718
1819 `--dry-run` locally to see what *would* be built, then hand the build to Modal.
1920 The one exception is a derivation you already know is trivial and already
@@ -27,15 +28,15 @@ nowhere else.
2728 You are already running inside the Arch distrobox, where `nix` lives, so run
2829 the evaluating commands directly — do not wrap them in `distrobox enter`.
2930
30-The container runs as a Modal **Sandbox on a real VM**, which is what makes
31-`nix build` work out there at all: the ptyshim that used to stand in for a
32-working pty under gVisor is deprecated, and nothing here should reintroduce it.
33-Substitution comes from the `nix-cache` Modal Volume plus cache.nixos.org and
34-nix-cache.wasix.org — libjoltcosmic's dependency tree is the one that makes
35-that cache worth having.
31+The containers run as Modal **Sandboxes on a real VM**, which is what makes a
32+build work out there at all: the ptyshim that used to stand in for a working
33+pty under gVisor is deprecated, and nothing here should reintroduce it. Neither
34+container carries nix: `tools/toolchain.sh` fetches Flutter, a JDK and the
35+Clojure CLI by sha256 onto the `devshell` Volume, and the build runs out of
36+those.
3637
3738 A remote builder (`eu.nixbuild.net`) is also configured here, for the case
38-where you want the graph built somewhere other than Modal: `--store
39+where you want a derivation built somewhere other than Modal: `--store
3940 ssh-ng://eu.nixbuild.net --eval-store auto` rather than a `builders` entry, so
4041 the whole graph stays there and only .drv files go up.
4142
@@ -55,7 +56,7 @@ Let them write to the terminal, or `tee` them if you want a copy to grep
5556 afterwards:
5657
5758 ```bash
58-modal run .modal/frq/container.py 2>&1 | tee /tmp/frq-build.log
59+modal run .modal/flutter-web/container.py 2>&1 | tee /tmp/frq-build.log
5960 ```
6061
6162 Trim afterwards, on the file, where the whole run is still there to re-read.
@@ -63,49 +64,71 @@ The same goes for `grep` and `awk` in a live pipeline: they buffer when their
6364 output is not a terminal, so pass `--line-buffered` / `fflush()` or watch the
6465 file instead.
6566
66-## The three source trees
67+## The Nim core
68+
69+`nim/` is the portable logic, moving out of `common/` one module at a time as
70+a native library the Dart side calls through FFI. Read `nim/README.md` before
71+touching it — in particular the status section, which says what is actually
72+wired up (the Nim half and its ABI) and what is not (the Dart binding).
73+
74+Two rules the ABI has, both of which cost a segfault to rediscover:
75+
76+* Every string the core returns is the **caller's** to free, with `frq_free`.
77+ Nim's allocator is not Dart's.
78+* `frq_init` runs once before anything else.
79+
80+`just nim-test` needs no Flutter and no Dart, which is most of the point.
81+
82+A module is not deleted from `common/` when its Nim version lands: the web
83+target cannot load a native library, so the ClojureDart original is the web's
84+implementation until there is a wasm build. Deleting one would take the web
85+build with it.
86+
87+## The two source trees
6788
6889 ```
69-common/ .cljc jolt AND ClojureDart
70-src/ .clj jolt only
71-flutter/ .cljd ClojureDart only
90+common/ .cljc portable — every target compiles it
91+flutter/ .cljd the Flutter half, and the host implementations
7292 ```
7393
7494 The extension is the boundary, not a convention: ClojureDart reads `.cljd` and
75-`.cljc` and never `.clj`, jolt reads all three. So the rule for anything under
76-`common/` is that it may not require `jolt.*`, `glimmer*` or a `dart:` library
77-— if it needs the host, it asks `frq.io`, and the backend that installed itself
78-answers. `frq.io.jolt` is required for its side effect by `frq.app`;
79-`frq.io.dart` is installed by `flutter/src/frq/main.cljd`, which has to await
80-the storage directory first.
95+`.cljc` and never `.clj`. The rule for anything under `common/` is that it may
96+not require a `dart:` library — if it needs the host, it asks `frq.io`, and the
97+implementation that installed itself answers. `frq.io.dart` is installed by
98+`flutter/src/frq/main.cljd`, which has to await the storage directory first;
99+`frq.io.web` by `main_web.cljd`.
81100
82101 Adding a host call means adding it to the seam in `common/frq/io.cljc` and to
83-both implementations. Name it for the result rather than the mechanism — the
102+every implementation. Name it for the result rather than the mechanism — the
84103 seam has `write-private-file!` and not a chmod, because Dart has no chmod.
85104
86-`flutter/` builds two things, from one `clojure -M:cljd compile`:
105+There used to be a third tree, `src/`, and a second runtime under it: jolt,
106+glimmer, and a libcosmic desktop window painting the same screens. It is gone,
107+along with `just cosmic`, `just tui`, the AV/MoQ media plane and the native
108+objects they loaded. Flutter is the only frontend now, and `common/` is
109+compiled by one compiler rather than two — which is why the `#?(:jolt ...)`
110+reader conditionals that used to be scattered through it are not there any
111+more. `tools/check-common.py` still guards the seam, and CI still runs it on
112+every push.
113+
114+`flutter/` builds three things, from one `clojure -M:cljd compile`:
87115
88116 `just apk`, out of the flake's own `.#flutter` shell (clojure, jdk17, flutter)
89117 and its `.#android-sdk` package. Impure on purpose: Gradle fetches its own
90118 dependencies and writes into `ANDROID_HOME`, so the recipe copies the store SDK
91-to `flutter/.home` and lets it finish there. It is the only APK there is — the
92-jolt APK, `nix/android.nix`, `android/` and the `.#apk` outputs are gone,
93-because every backend that APK could paint with is retired.
119+to `flutter/.home` and lets it finish there.
94120
95121 `just flutter-desktop`, out of `.#flutter-desktop` (the same clojure and
96122 flutter, with cmake, ninja, pkg-config and gtk3 where the JDK and the SDK are).
97-Impure for the network half of the same reasons and no writable-SDK dance, since
98-nothing writes into the store. nixGL off NixOS, like `just cosmic run`.
99-
100-So there are two desktop GUIs and they are both first-class: `just cosmic
101-run` is libcosmic under jolt, `just flutter-desktop` is Flutter's Linux target
102-over
103-`frq.hiccup`. Same screens out of `common/frq/screens/`, two renderers. jvui and
104-Vidya were experiments and are gone; libcosmic is a desktop window and does not
105-cross to a phone, which is what the Flutter half is for.
106-
107-The consequence for `common/` is that "the phone" is no longer a synonym for
108-"the ClojureDart side" — two targets compile it. An implementation that branches
109-on the platform has to ask (`Platform.isAndroid`) rather than assume; see
123+Impure for the network half of the same reasons and no writable-SDK dance,
124+since nothing writes into the store. nixGL off NixOS.
125+
126+`just flutter-web`, out of no nix shell at all — `tools/toolchain.sh` fetches
127+the three pinned tarballs it needs, which is what lets `.modal/flutter-web/`
128+run the same script on a plain Debian image.
129+
130+The consequence for `common/` is that "the phone" is not a synonym for "the
131+ClojureDart side": three targets compile it. An implementation that branches on
132+the platform has to ask (`Platform.isAndroid`) rather than assume; see
110133 `frq.io.dart/write-private-file!`, where assuming cost a token its file mode.
111134 See flutter/README.md.
@@ -4,16 +4,17 @@
4 4
5 **STOP BUILDING LOCALLY. Build on Modal.** This machine is for editing and for5 **STOP BUILDING LOCALLY. Build on Modal.** This machine is for editing and for
6 evaluating — `nix flake check`, `nix eval`, `nix build --dry-run`, `nix repl`6 evaluating — `nix flake check`, `nix eval`, `nix build --dry-run`, `nix repl`
7-— and not for realising a derivation. A local `nix build .#frq` gets killed for7+— and not for realising a derivation. A local build of the whole graph gets
8-memory long before it finishes, and the minutes spent finding that out are8+killed for memory long before it finishes, and the minutes spent finding that
9-minutes not spent on the change. So:9+out are minutes not spent on the change. So:
10 10
11 ```bash11 ```bash
12-modal run .modal/frq/container.py # the desktop bundle, on Modal12+modal run .modal/flutter-web/container.py # the web bundle, on Modal
13+modal run .modal/flutter-dev/container.py # the incremental Flutter loop
13 ```14 ```
14 15
15-The container is `.modal/`, not a fourth source tree: it is CI config that16+The containers in `.modal/` are not a third source tree: they are CI config
16-happens to live here, the way `.github/` would be.17+that happens to live here, the way `.github/` would be.
17 18
18 `--dry-run` locally to see what *would* be built, then hand the build to Modal.19 `--dry-run` locally to see what *would* be built, then hand the build to Modal.
19 The one exception is a derivation you already know is trivial and already20 The one exception is a derivation you already know is trivial and already
@@ -27,15 +28,15 @@ nowhere else.
27 You are already running inside the Arch distrobox, where `nix` lives, so run28 You are already running inside the Arch distrobox, where `nix` lives, so run
28 the evaluating commands directly — do not wrap them in `distrobox enter`.29 the evaluating commands directly — do not wrap them in `distrobox enter`.
29 30
30-The container runs as a Modal **Sandbox on a real VM**, which is what makes31+The containers run as Modal **Sandboxes on a real VM**, which is what makes a
31-`nix build` work out there at all: the ptyshim that used to stand in for a32+build work out there at all: the ptyshim that used to stand in for a working
32-working pty under gVisor is deprecated, and nothing here should reintroduce it.33+pty under gVisor is deprecated, and nothing here should reintroduce it. Neither
33-Substitution comes from the `nix-cache` Modal Volume plus cache.nixos.org and34+container carries nix: `tools/toolchain.sh` fetches Flutter, a JDK and the
34-nix-cache.wasix.org — libjoltcosmic's dependency tree is the one that makes35+Clojure CLI by sha256 onto the `devshell` Volume, and the build runs out of
35-that cache worth having.36+those.
36 37
37 A remote builder (`eu.nixbuild.net`) is also configured here, for the case38 A remote builder (`eu.nixbuild.net`) is also configured here, for the case
38-where you want the graph built somewhere other than Modal: `--store39+where you want a derivation built somewhere other than Modal: `--store
39 ssh-ng://eu.nixbuild.net --eval-store auto` rather than a `builders` entry, so40 ssh-ng://eu.nixbuild.net --eval-store auto` rather than a `builders` entry, so
40 the whole graph stays there and only .drv files go up.41 the whole graph stays there and only .drv files go up.
41 42
@@ -55,7 +56,7 @@ Let them write to the terminal, or `tee` them if you want a copy to grep
55 afterwards:56 afterwards:
56 57
57 ```bash58 ```bash
58-modal run .modal/frq/container.py 2>&1 | tee /tmp/frq-build.log59+modal run .modal/flutter-web/container.py 2>&1 | tee /tmp/frq-build.log
59 ```60 ```
60 61
61 Trim afterwards, on the file, where the whole run is still there to re-read.62 Trim afterwards, on the file, where the whole run is still there to re-read.
@@ -63,49 +64,71 @@ The same goes for `grep` and `awk` in a live pipeline: they buffer when their
63 output is not a terminal, so pass `--line-buffered` / `fflush()` or watch the64 output is not a terminal, so pass `--line-buffered` / `fflush()` or watch the
64 file instead.65 file instead.
65 66
66-## The three source trees67+## The Nim core
68+
69+`nim/` is the portable logic, moving out of `common/` one module at a time as
70+a native library the Dart side calls through FFI. Read `nim/README.md` before
71+touching it — in particular the status section, which says what is actually
72+wired up (the Nim half and its ABI) and what is not (the Dart binding).
73+
74+Two rules the ABI has, both of which cost a segfault to rediscover:
75+
76+* Every string the core returns is the **caller's** to free, with `frq_free`.
77+ Nim's allocator is not Dart's.
78+* `frq_init` runs once before anything else.
79+
80+`just nim-test` needs no Flutter and no Dart, which is most of the point.
81+
82+A module is not deleted from `common/` when its Nim version lands: the web
83+target cannot load a native library, so the ClojureDart original is the web's
84+implementation until there is a wasm build. Deleting one would take the web
85+build with it.
86+
87+## The two source trees
67 88
68 ```89 ```
69-common/ .cljc jolt AND ClojureDart90+common/ .cljc portable — every target compiles it
70-src/ .clj jolt only91+flutter/ .cljd the Flutter half, and the host implementations
71-flutter/ .cljd ClojureDart only
72 ```92 ```
73 93
74 The extension is the boundary, not a convention: ClojureDart reads `.cljd` and94 The extension is the boundary, not a convention: ClojureDart reads `.cljd` and
75-`.cljc` and never `.clj`, jolt reads all three. So the rule for anything under95+`.cljc` and never `.clj`. The rule for anything under `common/` is that it may
76-`common/` is that it may not require `jolt.*`, `glimmer*` or a `dart:` library96+not require a `dart:` library — if it needs the host, it asks `frq.io`, and the
77-— if it needs the host, it asks `frq.io`, and the backend that installed itself97+implementation that installed itself answers. `frq.io.dart` is installed by
78-answers. `frq.io.jolt` is required for its side effect by `frq.app`;98+`flutter/src/frq/main.cljd`, which has to await the storage directory first;
79-`frq.io.dart` is installed by `flutter/src/frq/main.cljd`, which has to await99+`frq.io.web` by `main_web.cljd`.
80-the storage directory first.
81 100
82 Adding a host call means adding it to the seam in `common/frq/io.cljc` and to101 Adding a host call means adding it to the seam in `common/frq/io.cljc` and to
83-both implementations. Name it for the result rather than the mechanism — the102+every implementation. Name it for the result rather than the mechanism — the
84 seam has `write-private-file!` and not a chmod, because Dart has no chmod.103 seam has `write-private-file!` and not a chmod, because Dart has no chmod.
85 104
86-`flutter/` builds two things, from one `clojure -M:cljd compile`:105+There used to be a third tree, `src/`, and a second runtime under it: jolt,
106+glimmer, and a libcosmic desktop window painting the same screens. It is gone,
107+along with `just cosmic`, `just tui`, the AV/MoQ media plane and the native
108+objects they loaded. Flutter is the only frontend now, and `common/` is
109+compiled by one compiler rather than two — which is why the `#?(:jolt ...)`
110+reader conditionals that used to be scattered through it are not there any
111+more. `tools/check-common.py` still guards the seam, and CI still runs it on
112+every push.
113+
114+`flutter/` builds three things, from one `clojure -M:cljd compile`:
87 115
88 `just apk`, out of the flake's own `.#flutter` shell (clojure, jdk17, flutter)116 `just apk`, out of the flake's own `.#flutter` shell (clojure, jdk17, flutter)
89 and its `.#android-sdk` package. Impure on purpose: Gradle fetches its own117 and its `.#android-sdk` package. Impure on purpose: Gradle fetches its own
90 dependencies and writes into `ANDROID_HOME`, so the recipe copies the store SDK118 dependencies and writes into `ANDROID_HOME`, so the recipe copies the store SDK
91-to `flutter/.home` and lets it finish there. It is the only APK there is — the119+to `flutter/.home` and lets it finish there.
92-jolt APK, `nix/android.nix`, `android/` and the `.#apk` outputs are gone,
93-because every backend that APK could paint with is retired.
94 120
95 `just flutter-desktop`, out of `.#flutter-desktop` (the same clojure and121 `just flutter-desktop`, out of `.#flutter-desktop` (the same clojure and
96 flutter, with cmake, ninja, pkg-config and gtk3 where the JDK and the SDK are).122 flutter, with cmake, ninja, pkg-config and gtk3 where the JDK and the SDK are).
97-Impure for the network half of the same reasons and no writable-SDK dance, since123+Impure for the network half of the same reasons and no writable-SDK dance,
98-nothing writes into the store. nixGL off NixOS, like `just cosmic run`.124+since nothing writes into the store. nixGL off NixOS.
99-125+
100-So there are two desktop GUIs and they are both first-class: `just cosmic126+`just flutter-web`, out of no nix shell at all — `tools/toolchain.sh` fetches
101-run` is libcosmic under jolt, `just flutter-desktop` is Flutter's Linux target127+the three pinned tarballs it needs, which is what lets `.modal/flutter-web/`
102-over128+run the same script on a plain Debian image.
103-`frq.hiccup`. Same screens out of `common/frq/screens/`, two renderers. jvui and129+
104-Vidya were experiments and are gone; libcosmic is a desktop window and does not130+The consequence for `common/` is that "the phone" is not a synonym for "the
105-cross to a phone, which is what the Flutter half is for.131+ClojureDart side": three targets compile it. An implementation that branches on
106-132+the platform has to ask (`Platform.isAndroid`) rather than assume; see
107-The consequence for `common/` is that "the phone" is no longer a synonym for
108-"the ClojureDart side" — two targets compile it. An implementation that branches
109-on the platform has to ask (`Platform.isAndroid`) rather than assume; see
110 `frq.io.dart/write-private-file!`, where assuming cost a token its file mode.133 `frq.io.dart/write-private-file!`, where assuming cost a token its file mode.
111 See flutter/README.md.134 See flutter/README.md.
modified README.md +85 -179
@@ -1,49 +1,59 @@
11 # frq
22
33 A **[freeq](https://github.com/codegod100/freeq)** client written in
4-**[jolt](https://github.com/jolt-lang/jolt)**, as
5-[glimmer](https://github.com/jolt-lang/glimmer) components painted by
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).
4+**[ClojureDart](https://github.com/tensegritics/ClojureDart)**, painted by
5+Flutter — one set of screens on Android, on the Linux desktop and in a browser.
6+See [flutter/README.md](flutter/README.md).
97
108 It is a proof of concept port of [sleek](../sleek), which is the same client in
119 Rust against egui directly. The screens are sleek's — connect, chats, chat,
12-discover, settings, under a tab bar — but each is hiccup over glimmer's widget
13-tags rather than immediate-mode drawing code, and state lives in ratoms instead
14-of an `AppState` struct.
10+discover, settings, under a tab bar — but each is hiccup over the widget tags
11+`frq.hiccup` translates into Flutter, and state lives in atoms instead of an
12+`AppState` struct.
1513
16-Source lives in three trees, and the file extension is the boundary:
14+Source lives in three trees:
1715
1816 ```
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
17+common/ .cljc portable: the screens, the state, the protocol — no dart:
18+flutter/ .cljd the Flutter half, and the host's answers — flutter/README.md
19+nim/ .nim the portable logic as a native library — nim/README.md
2220 ```
2321
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.
22+The first two are the client as it runs today; `nim/` is where the logic under
23+the screens is moving, a module at a time, behind a C ABI the Dart side calls
24+through FFI. One module has made the trip so far. See `nim/README.md` for
25+what is wired up and what is not.
26+
27+What `common/` needs of the host it asks `common/frq/io.cljc` for — the seam,
28+named once and answered per target: `frq.io.dart` on Android and the desktop,
29+`frq.io.web` in a browser.
2830
2931 ```
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
32+common/frq/io.cljc the seam: filesystem, environment, config dir, clock
33+common/frq/clock.cljc IRCv3 time tags → the reader's own zone
34+common/frq/store.cljc the saved sign-in, mode 600 in the config directory
35+common/frq/emoji.cljc the picker's catalog: every emoji and its name
36+common/frq/rooms.cljc the rooms this client has been in, and their order
37+common/frq/cells.cljc the atoms every screen reads
38+common/frq/screens/ connect, chats, chat, discover, settings
39+common/frq/irc/parse.cljc the IRC line parser, tags and all
40+common/frq/irc/handshake.cljc SASL, driven from shared code
41+common/frq/atproto/core.cljc handle → DID → PDS → session, and the SASL payloads
42+common/frq/oauth/core.cljc the broker flow, as far as it is portable
43+common/frq/msgsig.cljc message signatures
44+flutter/src/frq/main.cljd the entry point: installs the host, then starts
45+flutter/src/frq/hiccup.cljd the widget tags, as Flutter
46+flutter/src/frq/net/ sockets: dart:io on native, WebSocket on the web
47+flutter/src/frq/io/ the host's answers to the seam
4548 ```
4649
50+There used to be a third tree, `src/`, and another runtime under it: jolt, with
51+[glimmer](https://github.com/jolt-lang/glimmer) components painted by
52+**libcosmic** in a desktop window and by `libjolttui` in a terminal, plus an
53+AV media plane over MoQ. It is gone. Flutter is the only frontend now, which is
54+why `common/` no longer carries `#?(:jolt ...)` reader conditionals and why the
55+calls, terminal and `nix run .#frq` sections that used to be here are not.
56+
4757 ## Tracing
4858
4959 `FRQ_TRACE=1` prints every IRC line sent and received to stderr, which on
@@ -51,94 +61,35 @@ Android is logcat.
5161
5262 ## Running
5363
54-The app:
55-
5664 ```bash
57-just cosmic run
65+just flutter-desktop run # the Linux window
66+just apk run # onto a connected Android device
67+just flutter-web serve # a browser, on :8080
5868 ```
5969
60-Every recipe lives in the `justfile` itself. Each one that runs frq re-enters
61-`nix develop` and comes back to the same recipe, so `just cosmic run` and
62-`nix develop --command just cosmic run` are one code path rather than two.
63-Nothing has to be installed for that but Nix.
64-
65-`just cosmic 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.
77-
78-`jolt` on its own does not work in this tree: `deps.edn` names both libraries
79-under `:jolt/native`, so every invocation loads them before it reads a line and
80-dies if the loader cannot find them. `just repl` is that jolt with the shell
81-under it — a REPL, or `just repl nrepl-server` for an editor.
70+Every recipe lives in the `justfile` itself. The two that need a toolchain from
71+Nix re-enter `nix develop` and come back to the same recipe, so `just
72+flutter-desktop` and `nix develop .#flutter-desktop --command just
73+flutter-desktop` are one code path rather than two. `just flutter-web` needs no
74+Nix at all: `tools/toolchain.sh` fetches Flutter, a JDK and the Clojure CLI by
75+sha256, which is what lets `.modal/flutter-web/` run the same script on a plain
76+Debian image.
77+
78+All three are one `clojure -M:cljd compile` over `flutter/src` and `common/`,
79+and differ only in which Flutter target runs afterwards.
8280
8381 frq connects to `irc.freeq.at:6697` over TLS and joins `#test`. Untick TLS on
84-the connect screen (or point it at `127.0.0.1`) for a local server's
85-plain listener:
82+the connect screen (or point it at `127.0.0.1`) for a local server's plain
83+listener:
8684
8785 ```bash
8886 cargo run --release --bin freeq-server # in the freeq checkout
8987 ```
9088
91-## In a terminal
92-
93-The screens are hiccup over glimmer's reconciler, and the reconciler does not
94-know what is under it — so the same tree paints into a terminal through
95-jolt-native's `libjolttui`, which exports the same retained-tree ABI over a
96-grid of cells instead of a GPU window.
97-
98-It is the client, not a preview of it. `frq.app/start!` is what a launch does —
99-the saved settings, the rooms this client has been in, the sign-in that
100-connects itself — and `src/frq/tui.clj` hands it the terminal's timers instead
101-of the window's. Nothing in `frq.app` changed.
102-
103-```bash
104-nix run .#tui # or: just tui
105-just tui --headless --cols=90 --rows=60 # one screenshot on stdout
106-just tui --headless --demo # a buffer of its own, no server
107-just tui --headless --wait=9000 # long enough to have connected
108-just tui --headless --dump # and the tree the library holds
109-```
110-
111-The headless one is `tui_headless` — the same layout and the same painting with
112-the writer taken off the end — which is what a screenshot in a bug report or a
113-CI check should be. It paints once and prints, so `--wait=` is how long the
114-client is given first: the default is a picture of the connect screen, because
115-that is where a client is a moment after launch, and `--demo` fills a `#tui`
116-buffer for a screenshot that is not waiting on a server at all.
117-
118-Logs go to stderr, which in a terminal session is the screen frq is painting.
119-Send them somewhere: `nix run .#tui 2>/tmp/frq.log`.
120-
121-What a terminal has not got, frq does without: pictures, avatars and the
122-lightbox draw nothing, and calls are off — the media plane paints frames into
123-a texture, and there is no texture here.
124-
125-The spacing is written in points, for a window, and a cell is about eight of
126-them across and sixteen down — so the backend is handed both numbers and each
127-prop is divided by the axis it measures. A gap of half a cell rounds to
128-nothing, which is what `:spacing 8` against a 16-point row is: thirteen
129-messages fit where rounding it up left room for two.
130-
131-The two reserves in `src/frq/app.clj` are the one thing a scale cannot
132-answer, because they are counted in rows of chrome rather than in lengths: a
133-window's row is 34 points and a terminal's is one cell. `chrome-row` is where
134-that is said, and `frq.tui` sets it.
135-
136-`just tui` is `just cosmic run`'s two halves with the other backend under
137-them: this tree's source on the flake's everything-else, in the dev shell.
138-jolt-native
139-carries both native libraries and both Jolt sides — glimmer-cosmic for the
140-window, glimmer-tui for the terminal — so one input answers for either, and
141-nothing here needs a checkout beside the tree.
89+A browser has no TCP, so the web build wants a WebSocket URL in the Server
90+field — `wss://irc.freeq.at/irc`. And Bluesky sign-in only completes on
91+`localhost`, because that is the one origin freeq's auth broker will redirect
92+back to: `just web-local` is what serves the Modal-built bundle there.
14293
14394 ## Signing in
14495
@@ -172,32 +123,26 @@ server's nonce echoed back so the token cannot be replayed elsewhere.
172123
173124 A refused sign-in is reported and the connection carries on as a guest.
174125
175-## Android
126+## Targets
176127
177-The APK is **ClojureDart and Flutter**, not jolt — see
178-[flutter/README.md](flutter/README.md). Nothing here builds it yet.
128+Three, from one compile, and what separates them is the host half rather than
129+the screens.
179130
180-There was a jolt APK: `libvidya.so` painting through a NativeActivity, with frq
181-compiled to an arm64 Chez boot image beside it. It is gone, and so are
182-`nix/android.nix`, the `.#apk` outputs and the `just apk` recipe. The reason is
183-not the build, which worked — it is that every backend it could paint with is
184-retired. Vidya and jvui were experiments, and libcosmic is Wayland, X11 and
185-wgpu, so it does not cross to a phone at all.
131+**Android** and **the Linux desktop** are both `dart:io` underneath:
132+`frq.io.dart` answers the seam, `frq.net.dart` opens a real TCP or TLS socket.
133+`frq.io.dart/write-private-file!` is the one place that asks which of the two it
134+is on (`Platform.isAndroid`), because assuming cost a token its file mode.
186135
187-What the phone gains by the move is most of what it never had. TLS was the
188-worst of it: jolt reaches OpenSSL through the dynamic loader and Android has no
189-public `libssl`, so sign-in was desktop-only and the connect screen fell back to
190-the plain `:6667` listener on its own. `dart:io` carries TLS in the runtime.
191-The same goes for the media plane — V4L2 and ALSA are not there either, and
192-Flutter has camera and audio plugins that are.
136+**The web** is not: a browser has no TCP and no filesystem, so `frq.net.web`
137+carries an IRC WebSocket and `frq.io.web` keeps the seam's files in local
138+storage. Bluesky sign-in works there only on `localhost` — see Running.
193139
194-What carries over untouched is `common/` — see the three trees at the top.
195-`frq.clock`, `frq.store` and the rest are compiled by both jolt and
196-ClojureDart, and what they need from the host they ask `frq.io` for.
140+What carries over untouched is `common/` — the screens, the state, the parser,
141+the protocol. See the two trees at the top.
197142
198143 ## What the PoC covers
199144
200-* TLS (`:6697`, via jolt.mvn-http's OpenSSL bindings) or plain TCP (`:6667`)
145+* TLS (`:6697`, out of `dart:io`) or plain TCP (`:6667`); a WebSocket on the web
201146 * Guest connect (`NICK`/`USER`), `001` welcome, `PING`/`PONG` keepalive
202147 * Auto-joins `#test` on `irc.freeq.at`
203148 * Join channels, channel buffers with unread counts, send and receive `PRIVMSG`
@@ -226,62 +171,23 @@ ClojureDart, and what they need from the host they ask `frq.io` for.
226171 * Conversations listed most recently opened first
227172 * Bluesky avatars beside the sender, resolved from the DID freeq tags each
228173 message with
229-* Calls: a Call button opens one in a channel, a banner offers Join where
230- somebody already has, and in one there is mute, deafen, video and leave. Mute
231- and deafen are separate — a deafened microphone still carries your voice.
232- Whoever turns a camera on appears as a tile; the self-view is labelled You
233- and sits last, where it cannot push a face you are talking to off the row
234-
235-## Calls
236-
237-Signaling is IRC and lives here: `+freeq.at/av-start`, `av-join` and `av-leave`
238-go out as TAGMSGs and the server broadcasts `+freeq.at/av-state` back, which is
239-what actually moves this client's state — a press is optimistic, and the server
240-settles it. Losing a race to open a call (`start-collision`) is answered by
241-joining the call that won rather than by reporting an error, since the person
242-asked to be in a call in that room and there is one.
243-
244-Media is not IRC and is not here. Audio and video ride MoQ — Media over QUIC —
245-through freeq's SFU, and that is `libjoltmoq`: Opus, H.264, capture and
246-transport, lifted out of sleek rather than written a second time in jolt.
247-`src/frq/av.clj` is the whole of what frq says to it, and two of its rules
248-shape this side:
249-
250-* **Nothing calls back.** Status and video are polled, drained by a timer that
251- glimmer runs on the loop thread — the only thread allowed to touch a node.
252-* **A frame is borrowed.** The decoder's own buffer is handed to the backend as
253- a pointer and painted by an `:image` with a `:feed`. The pixels never become a
254- jolt value and are never copied on this side, which is the only way thirty
255- frames a second is affordable here.
256-
257-The SFU is dialled once the server has minted a token, not when we ask to join:
258-a remote SFU refuses a connection without one, and the MoQ client then retries
259-in a loop that looks exactly like a hang.
260174
261175 ## Limits
262176
263177 * **TLS and plain TCP only** — no WebSocket, no iroh. On Android, plain only.
264-* **No `did:key` signing, no credential gates, no E2EE.** Sign-in of either
265- kind needs TLS, so it is desktop-only — the Android build connects as a
266- guest.
178+* **No `did:key` signing, no credential gates, no E2EE.**
267179 * **Only the broker token is persisted**, and only for OAuth. An app-password
268180 sign-in is not remembered.
269-* **Previews are PNG only** — the tree backend's decoder reads no other
270- format, and a fetch needs TLS, so the phone shows links. The link is left in
271- place either way.
181+* **Previews are PNG only.** The link is left in place either way.
272182 * **Nothing evicts the media cache.**
273-* **Calls are desktop-only.** The media plane is V4L2 and ALSA, which Android
274- does not have — and libcosmic, which paints the frames, does not run there
275- either. Flutter's camera and audio plugins are the way in on the phone, and
276- that is its own project.
277-* **One call at a time**, which is the media plane's rule and the microphone's.
278-* **No call is offered in a DM** — freeq's AV signaling is a channel's.
279-* **Pasting a picture needs a sign-in and a desktop.** The upload is filed
280- under the DID of a live session, so a guest cannot make one; and it is read
281- off the clipboard through the backend's `clipboard-image-png!`, which
282- libcosmic backs on the desktop and nothing backs in a terminal. It also shares
283- nothing to your PDS and posts nothing to Bluesky — those fields are opt-in
284- and this client does not send them.
183+* **No calls.** The AV signaling is still in the screens, but the media plane
184+ it drove was `libjoltmoq` under the retired jolt half — Opus, H.264, V4L2 and
185+ ALSA, none of which crosses to Flutter. The Call controls are wired to
186+ actions no target installs. Flutter's camera and audio plugins are the way
187+ back in, and that is its own project.
188+* **Attaching a picture needs a sign-in.** The upload is filed under the DID
189+ of a live session, so a guest cannot make one. It also shares nothing to your
190+ PDS and posts nothing to Bluesky — those fields are opt-in and this client
191+ does not send them.
285192 * **No scrollback trimming or threads.**
286-* A sent line waits up to 200ms for the reader thread to flush it.
287-* Message lists are keyed vboxes; glimmer-cosmic has no `:listbox` yet.
193+* A sent line waits up to 200ms for the reader to flush it.
@@ -1,49 +1,59 @@
1 # frq1 # frq
2 2
3 A **[freeq](https://github.com/codegod100/freeq)** client written in3 A **[freeq](https://github.com/codegod100/freeq)** client written in
4-**[jolt](https://github.com/jolt-lang/jolt)**, as4+**[ClojureDart](https://github.com/tensegritics/ClojureDart)**, painted by
5-[glimmer](https://github.com/jolt-lang/glimmer) components painted by5+Flutter — one set of screens on Android, on the Linux desktop and in a browser.
6-**libcosmic** on the desktop — and, on the phone, by Flutter through6+See [flutter/README.md](flutter/README.md).
7-[ClojureDart](https://github.com/tensegritics/ClojureDart) over the same
8-shared namespaces. See [flutter/README.md](flutter/README.md).
9 7
10 It is a proof of concept port of [sleek](../sleek), which is the same client in8 It is a proof of concept port of [sleek](../sleek), which is the same client in
11 Rust against egui directly. The screens are sleek's — connect, chats, chat,9 Rust against egui directly. The screens are sleek's — connect, chats, chat,
12-discover, settings, under a tab bar — but each is hiccup over glimmer's widget10+discover, settings, under a tab bar — but each is hiccup over the widget tags
13-tags rather than immediate-mode drawing code, and state lives in ratoms instead11+`frq.hiccup` translates into Flutter, and state lives in atoms instead of an
14-of an `AppState` struct.12+`AppState` struct.
15 13
16-Source lives in three trees, and the file extension is the boundary:14+Source lives in three trees:
17 15
18 ```16 ```
19-common/ .cljc compiled by both jolt and ClojureDart — no jolt, no glimmer17+common/ .cljc portable: the screens, the state, the protocol — no dart:
20-src/ .clj the jolt half: glimmer, jolt.ffi, cosmic + tui backends18+flutter/ .cljd the Flutter half, and the host's answers — flutter/README.md
21-flutter/ .cljd the ClojureDart half: Flutter, dart:io — see flutter/README.md19+nim/ .nim the portable logic as a native library — nim/README.md
22 ```20 ```
23 21
24-ClojureDart reads `.cljd` and `.cljc` and never `.clj`, so a namespace that22+The first two are the client as it runs today; `nim/` is where the logic under
25-reaches for `jolt.host` cannot end up in a Flutter build by accident. What the23+the screens is moving, a module at a time, behind a C ABI the Dart side calls
26-two halves share, they ask of `common/frq/io.cljc` — the host's job named once,24+through FFI. One module has made the trip so far. See `nim/README.md` for
27-answered by `frq.io.jolt` on one side and `frq.io.dart` on the other.25+what is wired up and what is not.
26+
27+What `common/` needs of the host it asks `common/frq/io.cljc` for — the seam,
28+named once and answered per target: `frq.io.dart` on Android and the desktop,
29+`frq.io.web` in a browser.
28 30
29 ```31 ```
30-common/frq/io.cljc the seam: filesystem, environment, config dir, clock32+common/frq/io.cljc the seam: filesystem, environment, config dir, clock
31-common/frq/clock.cljc IRCv3 time tags → the reader's own zone33+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 directory34+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 name35+common/frq/emoji.cljc the picker's catalog: every emoji and its name
34-src/frq/io/jolt.clj the desktop's answers to the seam36+common/frq/rooms.cljc the rooms this client has been in, and their order
35-src/frq/atproto.clj handle → DID → PDS → session, and the SASL payloads37+common/frq/cells.cljc the atoms every screen reads
36-src/frq/oauth.clj the broker flow: login URL, loopback capture, /session38+common/frq/screens/ connect, chats, chat, discover, settings
37-src/frq/avatars.clj profile pictures, by DID or handle39+common/frq/irc/parse.cljc the IRC line parser, tags and all
38-src/frq/profile.clj who someone is: the Bluesky profile behind a nick40+common/frq/irc/handshake.cljc SASL, driven from shared code
39-src/frq/media.clj image links: spot them, fetch them once, cache on disk41+common/frq/atproto/core.cljc handle → DID → PDS → session, and the SASL payloads
40-src/frq/upload.clj a pasted picture to freeq's media endpoint, as multipart42+common/frq/oauth/core.cljc the broker flow, as far as it is portable
41-src/frq/av.clj calls: the signaling, and a handle on the media plane43+common/frq/msgsig.cljc message signatures
42-src/frq/irc.clj IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG44+flutter/src/frq/main.cljd the entry point: installs the host, then starts
43-src/frq/state.clj the ratoms every screen reads, and `apply-msg!`45+flutter/src/frq/hiccup.cljd the widget tags, as Flutter
44-src/frq/app.clj the screens46+flutter/src/frq/net/ sockets: dart:io on native, WebSocket on the web
47+flutter/src/frq/io/ the host's answers to the seam
45 ```48 ```
46 49
50+There used to be a third tree, `src/`, and another runtime under it: jolt, with
51+[glimmer](https://github.com/jolt-lang/glimmer) components painted by
52+**libcosmic** in a desktop window and by `libjolttui` in a terminal, plus an
53+AV media plane over MoQ. It is gone. Flutter is the only frontend now, which is
54+why `common/` no longer carries `#?(:jolt ...)` reader conditionals and why the
55+calls, terminal and `nix run .#frq` sections that used to be here are not.
56+
47 ## Tracing57 ## Tracing
48 58
49 `FRQ_TRACE=1` prints every IRC line sent and received to stderr, which on59 `FRQ_TRACE=1` prints every IRC line sent and received to stderr, which on
@@ -51,94 +61,35 @@ Android is logcat.
51 61
52 ## Running62 ## Running
53 63
54-The app:
55-
56 ```bash64 ```bash
57-just cosmic run65+just flutter-desktop run # the Linux window
66+just apk run # onto a connected Android device
67+just flutter-web serve # a browser, on :8080
58 ```68 ```
59 69
60-Every recipe lives in the `justfile` itself. Each one that runs frq re-enters70+Every recipe lives in the `justfile` itself. The two that need a toolchain from
61-`nix develop` and comes back to the same recipe, so `just cosmic run` and71+Nix re-enter `nix develop` and come back to the same recipe, so `just
62-`nix develop --command just cosmic run` are one code path rather than two.72+flutter-desktop` and `nix develop .#flutter-desktop --command just
63-Nothing has to be installed for that but Nix.73+flutter-desktop` are one code path rather than two. `just flutter-web` needs no
64-74+Nix at all: `tools/toolchain.sh` fetches Flutter, a JDK and the Clojure CLI by
65-`just cosmic run` is `jolt -m frq.cosmic` inside `nix develop`, with75+sha256, which is what lets `.modal/flutter-web/` run the same script on a plain
66-`LD_LIBRARY_PATH` pointed at the shell's `JOLT_NATIVE_LIB` — the flake's build76+Debian image.
67-of [jolt-native](https://gitlab.com/nandithebull/jolt-native), which carries77+
68-the shared objects this client loads: `libjoltcosmic`, the retained-tree ABI78+All three are one `clojure -M:cljd compile` over `flutter/src` and `common/`,
69-glimmer paints the window through, and `libjolttui`, the same ABI over a grid79+and differ only in which Flutter target runs afterwards.
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.
77-
78-`jolt` on its own does not work in this tree: `deps.edn` names both libraries
79-under `:jolt/native`, so every invocation loads them before it reads a line and
80-dies if the loader cannot find them. `just repl` is that jolt with the shell
81-under it — a REPL, or `just repl nrepl-server` for an editor.
82 80
83 frq connects to `irc.freeq.at:6697` over TLS and joins `#test`. Untick TLS on81 frq connects to `irc.freeq.at:6697` over TLS and joins `#test`. Untick TLS on
84-the connect screen (or point it at `127.0.0.1`) for a local server's82+the connect screen (or point it at `127.0.0.1`) for a local server's plain
85-plain listener:83+listener:
86 84
87 ```bash85 ```bash
88 cargo run --release --bin freeq-server # in the freeq checkout86 cargo run --release --bin freeq-server # in the freeq checkout
89 ```87 ```
90 88
91-## In a terminal89+A browser has no TCP, so the web build wants a WebSocket URL in the Server
92-90+field — `wss://irc.freeq.at/irc`. And Bluesky sign-in only completes on
93-The screens are hiccup over glimmer's reconciler, and the reconciler does not91+`localhost`, because that is the one origin freeq's auth broker will redirect
94-know what is under it — so the same tree paints into a terminal through92+back to: `just web-local` is what serves the Modal-built bundle there.
95-jolt-native's `libjolttui`, which exports the same retained-tree ABI over a
96-grid of cells instead of a GPU window.
97-
98-It is the client, not a preview of it. `frq.app/start!` is what a launch does —
99-the saved settings, the rooms this client has been in, the sign-in that
100-connects itself — and `src/frq/tui.clj` hands it the terminal's timers instead
101-of the window's. Nothing in `frq.app` changed.
102-
103-```bash
104-nix run .#tui # or: just tui
105-just tui --headless --cols=90 --rows=60 # one screenshot on stdout
106-just tui --headless --demo # a buffer of its own, no server
107-just tui --headless --wait=9000 # long enough to have connected
108-just tui --headless --dump # and the tree the library holds
109-```
110-
111-The headless one is `tui_headless` — the same layout and the same painting with
112-the writer taken off the end — which is what a screenshot in a bug report or a
113-CI check should be. It paints once and prints, so `--wait=` is how long the
114-client is given first: the default is a picture of the connect screen, because
115-that is where a client is a moment after launch, and `--demo` fills a `#tui`
116-buffer for a screenshot that is not waiting on a server at all.
117-
118-Logs go to stderr, which in a terminal session is the screen frq is painting.
119-Send them somewhere: `nix run .#tui 2>/tmp/frq.log`.
120-
121-What a terminal has not got, frq does without: pictures, avatars and the
122-lightbox draw nothing, and calls are off — the media plane paints frames into
123-a texture, and there is no texture here.
124-
125-The spacing is written in points, for a window, and a cell is about eight of
126-them across and sixteen down — so the backend is handed both numbers and each
127-prop is divided by the axis it measures. A gap of half a cell rounds to
128-nothing, which is what `:spacing 8` against a 16-point row is: thirteen
129-messages fit where rounding it up left room for two.
130-
131-The two reserves in `src/frq/app.clj` are the one thing a scale cannot
132-answer, because they are counted in rows of chrome rather than in lengths: a
133-window's row is 34 points and a terminal's is one cell. `chrome-row` is where
134-that is said, and `frq.tui` sets it.
135-
136-`just tui` is `just cosmic run`'s two halves with the other backend under
137-them: this tree's source on the flake's everything-else, in the dev shell.
138-jolt-native
139-carries both native libraries and both Jolt sides — glimmer-cosmic for the
140-window, glimmer-tui for the terminal — so one input answers for either, and
141-nothing here needs a checkout beside the tree.
142 93
143 ## Signing in94 ## Signing in
144 95
@@ -172,32 +123,26 @@ server's nonce echoed back so the token cannot be replayed elsewhere.
172 123
173 A refused sign-in is reported and the connection carries on as a guest.124 A refused sign-in is reported and the connection carries on as a guest.
174 125
175-## Android126+## Targets
176 127
177-The APK is **ClojureDart and Flutter**, not jolt — see128+Three, from one compile, and what separates them is the host half rather than
178-[flutter/README.md](flutter/README.md). Nothing here builds it yet.129+the screens.
179 130
180-There was a jolt APK: `libvidya.so` painting through a NativeActivity, with frq131+**Android** and **the Linux desktop** are both `dart:io` underneath:
181-compiled to an arm64 Chez boot image beside it. It is gone, and so are132+`frq.io.dart` answers the seam, `frq.net.dart` opens a real TCP or TLS socket.
182-`nix/android.nix`, the `.#apk` outputs and the `just apk` recipe. The reason is133+`frq.io.dart/write-private-file!` is the one place that asks which of the two it
183-not the build, which worked — it is that every backend it could paint with is134+is on (`Platform.isAndroid`), because assuming cost a token its file mode.
184-retired. Vidya and jvui were experiments, and libcosmic is Wayland, X11 and
185-wgpu, so it does not cross to a phone at all.
186 135
187-What the phone gains by the move is most of what it never had. TLS was the136+**The web** is not: a browser has no TCP and no filesystem, so `frq.net.web`
188-worst of it: jolt reaches OpenSSL through the dynamic loader and Android has no137+carries an IRC WebSocket and `frq.io.web` keeps the seam's files in local
189-public `libssl`, so sign-in was desktop-only and the connect screen fell back to138+storage. Bluesky sign-in works there only on `localhost` — see Running.
190-the plain `:6667` listener on its own. `dart:io` carries TLS in the runtime.
191-The same goes for the media plane — V4L2 and ALSA are not there either, and
192-Flutter has camera and audio plugins that are.
193 139
194-What carries over untouched is `common/` — see the three trees at the top.140+What carries over untouched is `common/` — the screens, the state, the parser,
195-`frq.clock`, `frq.store` and the rest are compiled by both jolt and141+the protocol. See the two trees at the top.
196-ClojureDart, and what they need from the host they ask `frq.io` for.
197 142
198 ## What the PoC covers143 ## What the PoC covers
199 144
200-* TLS (`:6697`, via jolt.mvn-http's OpenSSL bindings) or plain TCP (`:6667`)145+* TLS (`:6697`, out of `dart:io`) or plain TCP (`:6667`); a WebSocket on the web
201 * Guest connect (`NICK`/`USER`), `001` welcome, `PING`/`PONG` keepalive146 * Guest connect (`NICK`/`USER`), `001` welcome, `PING`/`PONG` keepalive
202 * Auto-joins `#test` on `irc.freeq.at`147 * Auto-joins `#test` on `irc.freeq.at`
203 * Join channels, channel buffers with unread counts, send and receive `PRIVMSG`148 * Join channels, channel buffers with unread counts, send and receive `PRIVMSG`
@@ -226,62 +171,23 @@ ClojureDart, and what they need from the host they ask `frq.io` for.
226 * Conversations listed most recently opened first171 * Conversations listed most recently opened first
227 * Bluesky avatars beside the sender, resolved from the DID freeq tags each172 * Bluesky avatars beside the sender, resolved from the DID freeq tags each
228 message with173 message with
229-* Calls: a Call button opens one in a channel, a banner offers Join where
230- somebody already has, and in one there is mute, deafen, video and leave. Mute
231- and deafen are separate — a deafened microphone still carries your voice.
232- Whoever turns a camera on appears as a tile; the self-view is labelled You
233- and sits last, where it cannot push a face you are talking to off the row
234-
235-## Calls
236-
237-Signaling is IRC and lives here: `+freeq.at/av-start`, `av-join` and `av-leave`
238-go out as TAGMSGs and the server broadcasts `+freeq.at/av-state` back, which is
239-what actually moves this client's state — a press is optimistic, and the server
240-settles it. Losing a race to open a call (`start-collision`) is answered by
241-joining the call that won rather than by reporting an error, since the person
242-asked to be in a call in that room and there is one.
243-
244-Media is not IRC and is not here. Audio and video ride MoQ — Media over QUIC —
245-through freeq's SFU, and that is `libjoltmoq`: Opus, H.264, capture and
246-transport, lifted out of sleek rather than written a second time in jolt.
247-`src/frq/av.clj` is the whole of what frq says to it, and two of its rules
248-shape this side:
249-
250-* **Nothing calls back.** Status and video are polled, drained by a timer that
251- glimmer runs on the loop thread — the only thread allowed to touch a node.
252-* **A frame is borrowed.** The decoder's own buffer is handed to the backend as
253- a pointer and painted by an `:image` with a `:feed`. The pixels never become a
254- jolt value and are never copied on this side, which is the only way thirty
255- frames a second is affordable here.
256-
257-The SFU is dialled once the server has minted a token, not when we ask to join:
258-a remote SFU refuses a connection without one, and the MoQ client then retries
259-in a loop that looks exactly like a hang.
260 174
261 ## Limits175 ## Limits
262 176
263 * **TLS and plain TCP only** — no WebSocket, no iroh. On Android, plain only.177 * **TLS and plain TCP only** — no WebSocket, no iroh. On Android, plain only.
264-* **No `did:key` signing, no credential gates, no E2EE.** Sign-in of either178+* **No `did:key` signing, no credential gates, no E2EE.**
265- kind needs TLS, so it is desktop-only — the Android build connects as a
266- guest.
267 * **Only the broker token is persisted**, and only for OAuth. An app-password179 * **Only the broker token is persisted**, and only for OAuth. An app-password
268 sign-in is not remembered.180 sign-in is not remembered.
269-* **Previews are PNG only** — the tree backend's decoder reads no other181+* **Previews are PNG only.** The link is left in place either way.
270- format, and a fetch needs TLS, so the phone shows links. The link is left in
271- place either way.
272 * **Nothing evicts the media cache.**182 * **Nothing evicts the media cache.**
273-* **Calls are desktop-only.** The media plane is V4L2 and ALSA, which Android183+* **No calls.** The AV signaling is still in the screens, but the media plane
274- does not have — and libcosmic, which paints the frames, does not run there184+ it drove was `libjoltmoq` under the retired jolt half — Opus, H.264, V4L2 and
275- either. Flutter's camera and audio plugins are the way in on the phone, and185+ ALSA, none of which crosses to Flutter. The Call controls are wired to
276- that is its own project.186+ actions no target installs. Flutter's camera and audio plugins are the way
277-* **One call at a time**, which is the media plane's rule and the microphone's.187+ back in, and that is its own project.
278-* **No call is offered in a DM** — freeq's AV signaling is a channel's.188+* **Attaching a picture needs a sign-in.** The upload is filed under the DID
279-* **Pasting a picture needs a sign-in and a desktop.** The upload is filed189+ of a live session, so a guest cannot make one. It also shares nothing to your
280- under the DID of a live session, so a guest cannot make one; and it is read190+ PDS and posts nothing to Bluesky — those fields are opt-in and this client
281- off the clipboard through the backend's `clipboard-image-png!`, which191+ does not send them.
282- libcosmic backs on the desktop and nothing backs in a terminal. It also shares
283- nothing to your PDS and posts nothing to Bluesky — those fields are opt-in
284- and this client does not send them.
285 * **No scrollback trimming or threads.**192 * **No scrollback trimming or threads.**
286-* A sent line waits up to 200ms for the reader thread to flush it.193+* A sent line waits up to 200ms for the reader to flush it.
287-* Message lists are keyed vboxes; glimmer-cosmic has no `:listbox` yet.
deleted c/frq_h264.c +0 -289
deleted file mode 100644
@@ -1,289 +0,0 @@
1-/* A flat C face for openh264's encoder.
2- *
3- * WHY THIS EXISTS. openh264's C API is not flat. `ISVCEncoder` is
4- * `const ISVCEncoderVtbl*` — a pointer to a table of function pointers — so
5- * calling Initialize or EncodeFrame means dereferencing the object, reading a
6- * slot, and calling through it. jolt.ffi cannot do that: Chez fixes a foreign
7- * procedure's types when it COMPILES it, and the target has to be a literal C
8- * symbol name rather than a function pointer (see jolt/ffi.clj, "the target
9- * must be a literal C symbol name"). So the vtable is walked here, in C, and
10- * what jolt binds is the five plain symbols below.
11- *
12- * It is deliberately thin. No policy, no buffering beyond what openh264's own
13- * output demands, and no decisions that belong in frq — the shim exists to
14- * change a calling convention, not to be a video pipeline.
15- *
16- * THE ONE THING IT DOES DO is flatten the output. openh264 hands back an
17- * SFrameBSInfo describing up to MAX_LAYER_NUM_OF_FRAME layers, each with its
18- * own NAL count and a shared bitstream buffer. A caller wanting one Annex B
19- * frame has to walk that; doing it in jolt would mean reading nested C structs
20- * whose layout is openh264's business. It is copied into one contiguous
21- * buffer owned by the encoder handle and handed over as a borrowed span,
22- * valid until the next encode — which is exactly the contract frq.av already
23- * has for a video frame.
24- */
25-#include <stdlib.h>
26-#include <string.h>
27-#include <stdint.h>
28-#include <wels/codec_api.h>
29-#include <wels/codec_app_def.h>
30-
31-typedef struct {
32- ISVCEncoder *enc;
33- unsigned char *out; /* flattened Annex B, grown as needed */
34- size_t out_cap;
35- int width, height;
36-} frq_h264;
37-
38-/* Answers 0 on success, or openh264's own non-zero return. */
39-int frq_h264_open(int width, int height, int fps, int bitrate, void **handle) {
40- ISVCEncoder *enc = NULL;
41- frq_h264 *h;
42- SEncParamBase p;
43- int rc;
44-
45- *handle = NULL;
46- rc = WelsCreateSVCEncoder(&enc);
47- if (rc != 0 || enc == NULL) return rc ? rc : -1;
48-
49- memset(&p, 0, sizeof(p));
50- p.iUsageType = CAMERA_VIDEO_REAL_TIME;
51- p.iPicWidth = width;
52- p.iPicHeight = height;
53- p.iTargetBitrate = bitrate;
54- p.fMaxFrameRate = (float)fps;
55-
56- rc = (*enc)->Initialize(enc, &p);
57- if (rc != 0) { WelsDestroySVCEncoder(enc); return rc; }
58-
59- h = (frq_h264 *)calloc(1, sizeof(frq_h264));
60- if (h == NULL) { (*enc)->Uninitialize(enc); WelsDestroySVCEncoder(enc); return -1; }
61- h->enc = enc; h->width = width; h->height = height;
62- *handle = h;
63- return 0;
64-}
65-
66-/* Encode one I420 frame.
67- *
68- * `i420` is width*height luma followed by two (width/2)*(height/2) planes.
69- * On success answers 0 and sets *out / *out_len to a BORROWED span, valid
70- * until the next call on this handle. *keyframe says whether it is an IDR.
71- * A frame openh264 chose to skip answers 0 with *out_len == 0. */
72-int frq_h264_encode(void *handle, const unsigned char *i420, long long pts_us,
73- const unsigned char **out, int *out_len, int *keyframe) {
74- frq_h264 *h = (frq_h264 *)handle;
75- SSourcePicture pic;
76- SFrameBSInfo info;
77- int rc, i, j, total = 0, off = 0;
78-
79- *out = NULL; *out_len = 0; *keyframe = 0;
80-
81- memset(&pic, 0, sizeof(pic));
82- pic.iPicWidth = h->width;
83- pic.iPicHeight = h->height;
84- pic.iColorFormat = videoFormatI420;
85- pic.iStride[0] = h->width;
86- pic.iStride[1] = h->width / 2;
87- pic.iStride[2] = h->width / 2;
88- pic.pData[0] = (unsigned char *)i420;
89- pic.pData[1] = pic.pData[0] + h->width * h->height;
90- pic.pData[2] = pic.pData[1] + (h->width / 2) * (h->height / 2);
91- pic.uiTimeStamp = pts_us / 1000; /* openh264 counts milliseconds */
92-
93- memset(&info, 0, sizeof(info));
94- rc = (*h->enc)->EncodeFrame(h->enc, &pic, &info);
95- if (rc != cmResultSuccess) return rc;
96- if (info.eFrameType == videoFrameTypeSkip) return 0;
97-
98- for (i = 0; i < info.iLayerNum; i++)
99- for (j = 0; j < info.sLayerInfo[i].iNalCount; j++)
100- total += info.sLayerInfo[i].pNalLengthInByte[j];
101-
102- if ((size_t)total > h->out_cap) {
103- unsigned char *grown = (unsigned char *)realloc(h->out, (size_t)total);
104- if (grown == NULL) return -1;
105- h->out = grown; h->out_cap = (size_t)total;
106- }
107- for (i = 0; i < info.iLayerNum; i++) {
108- int n = 0, k;
109- for (k = 0; k < info.sLayerInfo[i].iNalCount; k++)
110- n += info.sLayerInfo[i].pNalLengthInByte[k];
111- memcpy(h->out + off, info.sLayerInfo[i].pBsBuf, (size_t)n);
112- off += n;
113- }
114-
115- *out = h->out;
116- *out_len = total;
117- *keyframe = (info.eFrameType == videoFrameTypeIDR);
118- return 0;
119-}
120-
121-int frq_h264_force_keyframe(void *handle) {
122- frq_h264 *h = (frq_h264 *)handle;
123- return (*h->enc)->ForceIntraFrame(h->enc, true);
124-}
125-
126-void frq_h264_close(void *handle) {
127- frq_h264 *h = (frq_h264 *)handle;
128- if (h == NULL) return;
129- if (h->enc) { (*h->enc)->Uninitialize(h->enc); WelsDestroySVCEncoder(h->enc); }
130- free(h->out);
131- free(h);
132-}
133-
134-/* --- decoding -------------------------------------------------------------
135- *
136- * Same vtable problem, same answer. ISVCDecoder is `const ISVCDecoderVtbl*`,
137- * so DecodeFrameNoDelay is a function pointer and jolt cannot reach it.
138- *
139- * What comes out is I420 in the decoder's OWN buffers, three planes with
140- * their own strides — which are not the width. A decoder pads its rows, so
141- * copying `width` bytes per row from a `stride`-wide plane is the mistake
142- * that produces a picture sheared diagonally, and it is why the strides are
143- * carried through to the converter below rather than assumed away.
144- */
145-#include <wels/codec_def.h>
146-
147-typedef struct {
148- ISVCDecoder *dec;
149- unsigned char *rgba; /* converted output, grown as needed */
150- size_t rgba_cap;
151-} frq_h264_dec;
152-
153-int frq_h264_decoder_open(void **handle) {
154- ISVCDecoder *dec = NULL;
155- frq_h264_dec *d;
156- SDecodingParam p;
157- int rc;
158-
159- *handle = NULL;
160- rc = WelsCreateDecoder(&dec);
161- if (rc != 0 || dec == NULL) return rc ? rc : -1;
162-
163- memset(&p, 0, sizeof(p));
164- p.eEcActiveIdc = ERROR_CON_SLICE_COPY;
165- p.sVideoProperty.eVideoBsType = VIDEO_BITSTREAM_AVC;
166-
167- rc = (int)(*dec)->Initialize(dec, &p);
168- if (rc != 0) { WelsDestroyDecoder(dec); return rc; }
169-
170- d = (frq_h264_dec *)calloc(1, sizeof(frq_h264_dec));
171- if (d == NULL) { (*dec)->Uninitialize(dec); WelsDestroyDecoder(dec); return -1; }
172- d->dec = dec;
173- *handle = d;
174- return 0;
175-}
176-
177-/* Decode one Annex B frame and convert it to RGBA.
178- *
179- * RGBA rather than I420 because that is what the far end of this is:
180- * vidya/frame-rgba! takes a tightly packed RGBA buffer, and converting here
181- * means the pixels are touched once, in C, instead of crossing into jolt to
182- * be rearranged. On success answers 0; *out is NULL and *w/*h are 0 when the
183- * decoder has no picture yet, which is normal for the first packets. */
184-int frq_h264_decode_rgba(void *handle, const unsigned char *annexb, int len,
185- const unsigned char **out, int *w, int *h) {
186- frq_h264_dec *d = (frq_h264_dec *)handle;
187- unsigned char *planes[3] = {NULL, NULL, NULL};
188- SBufferInfo info;
189- DECODING_STATE st;
190- int width, height, y, x, sy, su, sv;
191- size_t need;
192-
193- *out = NULL; *w = 0; *h = 0;
194- memset(&info, 0, sizeof(info));
195-
196- st = (*d->dec)->DecodeFrameNoDelay(d->dec, annexb, len, planes, &info);
197- if (st != dsErrorFree) return (int)st;
198- if (info.iBufferStatus != 1) return 0; /* no picture this time */
199-
200- width = info.UsrData.sSystemBuffer.iWidth;
201- height = info.UsrData.sSystemBuffer.iHeight;
202- sy = info.UsrData.sSystemBuffer.iStride[0];
203- su = info.UsrData.sSystemBuffer.iStride[1];
204- sv = su;
205- if (width <= 0 || height <= 0) return 0;
206-
207- need = (size_t)width * (size_t)height * 4u;
208- if (need > d->rgba_cap) {
209- unsigned char *grown = (unsigned char *)realloc(d->rgba, need);
210- if (grown == NULL) return -1;
211- d->rgba = grown; d->rgba_cap = need;
212- }
213-
214- /* BT.601 limited range, integer. Not a quality decision worth agonising
215- * over here: it is what a webcam stream is tagged as, and the alternative
216- * is dragging a colour-management dependency in for a video call. */
217- for (y = 0; y < height; y++) {
218- const unsigned char *Y = planes[0] + (size_t)y * sy;
219- const unsigned char *U = planes[1] + (size_t)(y / 2) * su;
220- const unsigned char *V = planes[2] + (size_t)(y / 2) * sv;
221- unsigned char *dst = d->rgba + (size_t)y * width * 4;
222- for (x = 0; x < width; x++) {
223- int c = (int)Y[x] - 16;
224- int u = (int)U[x / 2] - 128;
225- int v = (int)V[x / 2] - 128;
226- int r = (298 * c + 409 * v + 128) >> 8;
227- int g = (298 * c - 100 * u - 208 * v + 128) >> 8;
228- int b = (298 * c + 516 * u + 128) >> 8;
229- dst[x * 4 + 0] = (unsigned char)(r < 0 ? 0 : r > 255 ? 255 : r);
230- dst[x * 4 + 1] = (unsigned char)(g < 0 ? 0 : g > 255 ? 255 : g);
231- dst[x * 4 + 2] = (unsigned char)(b < 0 ? 0 : b > 255 ? 255 : b);
232- dst[x * 4 + 3] = 255;
233- }
234- }
235-
236- *out = d->rgba; *w = width; *h = height;
237- return 0;
238-}
239-
240-void frq_h264_decoder_close(void *handle) {
241- frq_h264_dec *d = (frq_h264_dec *)handle;
242- if (d == NULL) return;
243- if (d->dec) { (*d->dec)->Uninitialize(d->dec); WelsDestroyDecoder(d->dec); }
244- free(d->rgba);
245- free(d);
246-}
247-
248-/* --- YUYV to I420 ---------------------------------------------------------
249- *
250- * A webcam almost never hands you I420. YUYV (4:2:2 packed) is the format
251- * every UVC device supports, and openh264 wants I420 (4:2:0 planar), so
252- * something has to transpose and subsample between them. Doing it in jolt
253- * would be a per-pixel loop through ffi/read and ffi/write at thirty frames
254- * a second; doing it here is one pass over the row pairs.
255- *
256- * The chroma is AVERAGED down the row pair rather than dropped. Taking every
257- * other line instead is a line cheaper and shows up as combing on anything
258- * with a hard colour edge — a red shirt against a pale wall is the usual
259- * way to see it.
260- *
261- * `src` is width*height*2 bytes; `dst` is width*height*3/2. Both even
262- * dimensions, which V4L2 will have negotiated anyway.
263- */
264-void frq_yuyv_to_i420(const unsigned char *src, unsigned char *dst,
265- int width, int height) {
266- int x, y;
267- unsigned char *Y = dst;
268- unsigned char *U = dst + width * height;
269- unsigned char *V = U + (width / 2) * (height / 2);
270-
271- for (y = 0; y < height; y++) {
272- const unsigned char *row = src + (size_t)y * width * 2;
273- unsigned char *yr = Y + (size_t)y * width;
274- for (x = 0; x < width; x++) yr[x] = row[x * 2];
275- }
276- for (y = 0; y < height; y += 2) {
277- const unsigned char *r0 = src + (size_t)y * width * 2;
278- const unsigned char *r1 = src + (size_t)(y + 1) * width * 2;
279- unsigned char *ur = U + (size_t)(y / 2) * (width / 2);
280- unsigned char *vr = V + (size_t)(y / 2) * (width / 2);
281- for (x = 0; x < width; x += 2) {
282- /* One U and one V per two pixels per row; averaged over the pair. */
283- int u = (r0[x * 2 + 1] + r1[x * 2 + 1] + 1) >> 1;
284- int v = (r0[x * 2 + 3] + r1[x * 2 + 3] + 1) >> 1;
285- ur[x / 2] = (unsigned char)u;
286- vr[x / 2] = (unsigned char)v;
287- }
288- }
289-}
deleted file mode 100644
@@ -1,289 +0,0 @@
1-/* A flat C face for openh264's encoder.
2- *
3- * WHY THIS EXISTS. openh264's C API is not flat. `ISVCEncoder` is
4- * `const ISVCEncoderVtbl*` — a pointer to a table of function pointers — so
5- * calling Initialize or EncodeFrame means dereferencing the object, reading a
6- * slot, and calling through it. jolt.ffi cannot do that: Chez fixes a foreign
7- * procedure's types when it COMPILES it, and the target has to be a literal C
8- * symbol name rather than a function pointer (see jolt/ffi.clj, "the target
9- * must be a literal C symbol name"). So the vtable is walked here, in C, and
10- * what jolt binds is the five plain symbols below.
11- *
12- * It is deliberately thin. No policy, no buffering beyond what openh264's own
13- * output demands, and no decisions that belong in frq — the shim exists to
14- * change a calling convention, not to be a video pipeline.
15- *
16- * THE ONE THING IT DOES DO is flatten the output. openh264 hands back an
17- * SFrameBSInfo describing up to MAX_LAYER_NUM_OF_FRAME layers, each with its
18- * own NAL count and a shared bitstream buffer. A caller wanting one Annex B
19- * frame has to walk that; doing it in jolt would mean reading nested C structs
20- * whose layout is openh264's business. It is copied into one contiguous
21- * buffer owned by the encoder handle and handed over as a borrowed span,
22- * valid until the next encode — which is exactly the contract frq.av already
23- * has for a video frame.
24- */
25-#include <stdlib.h>
26-#include <string.h>
27-#include <stdint.h>
28-#include <wels/codec_api.h>
29-#include <wels/codec_app_def.h>
30-
31-typedef struct {
32- ISVCEncoder *enc;
33- unsigned char *out; /* flattened Annex B, grown as needed */
34- size_t out_cap;
35- int width, height;
36-} frq_h264;
37-
38-/* Answers 0 on success, or openh264's own non-zero return. */
39-int frq_h264_open(int width, int height, int fps, int bitrate, void **handle) {
40- ISVCEncoder *enc = NULL;
41- frq_h264 *h;
42- SEncParamBase p;
43- int rc;
44-
45- *handle = NULL;
46- rc = WelsCreateSVCEncoder(&enc);
47- if (rc != 0 || enc == NULL) return rc ? rc : -1;
48-
49- memset(&p, 0, sizeof(p));
50- p.iUsageType = CAMERA_VIDEO_REAL_TIME;
51- p.iPicWidth = width;
52- p.iPicHeight = height;
53- p.iTargetBitrate = bitrate;
54- p.fMaxFrameRate = (float)fps;
55-
56- rc = (*enc)->Initialize(enc, &p);
57- if (rc != 0) { WelsDestroySVCEncoder(enc); return rc; }
58-
59- h = (frq_h264 *)calloc(1, sizeof(frq_h264));
60- if (h == NULL) { (*enc)->Uninitialize(enc); WelsDestroySVCEncoder(enc); return -1; }
61- h->enc = enc; h->width = width; h->height = height;
62- *handle = h;
63- return 0;
64-}
65-
66-/* Encode one I420 frame.
67- *
68- * `i420` is width*height luma followed by two (width/2)*(height/2) planes.
69- * On success answers 0 and sets *out / *out_len to a BORROWED span, valid
70- * until the next call on this handle. *keyframe says whether it is an IDR.
71- * A frame openh264 chose to skip answers 0 with *out_len == 0. */
72-int frq_h264_encode(void *handle, const unsigned char *i420, long long pts_us,
73- const unsigned char **out, int *out_len, int *keyframe) {
74- frq_h264 *h = (frq_h264 *)handle;
75- SSourcePicture pic;
76- SFrameBSInfo info;
77- int rc, i, j, total = 0, off = 0;
78-
79- *out = NULL; *out_len = 0; *keyframe = 0;
80-
81- memset(&pic, 0, sizeof(pic));
82- pic.iPicWidth = h->width;
83- pic.iPicHeight = h->height;
84- pic.iColorFormat = videoFormatI420;
85- pic.iStride[0] = h->width;
86- pic.iStride[1] = h->width / 2;
87- pic.iStride[2] = h->width / 2;
88- pic.pData[0] = (unsigned char *)i420;
89- pic.pData[1] = pic.pData[0] + h->width * h->height;
90- pic.pData[2] = pic.pData[1] + (h->width / 2) * (h->height / 2);
91- pic.uiTimeStamp = pts_us / 1000; /* openh264 counts milliseconds */
92-
93- memset(&info, 0, sizeof(info));
94- rc = (*h->enc)->EncodeFrame(h->enc, &pic, &info);
95- if (rc != cmResultSuccess) return rc;
96- if (info.eFrameType == videoFrameTypeSkip) return 0;
97-
98- for (i = 0; i < info.iLayerNum; i++)
99- for (j = 0; j < info.sLayerInfo[i].iNalCount; j++)
100- total += info.sLayerInfo[i].pNalLengthInByte[j];
101-
102- if ((size_t)total > h->out_cap) {
103- unsigned char *grown = (unsigned char *)realloc(h->out, (size_t)total);
104- if (grown == NULL) return -1;
105- h->out = grown; h->out_cap = (size_t)total;
106- }
107- for (i = 0; i < info.iLayerNum; i++) {
108- int n = 0, k;
109- for (k = 0; k < info.sLayerInfo[i].iNalCount; k++)
110- n += info.sLayerInfo[i].pNalLengthInByte[k];
111- memcpy(h->out + off, info.sLayerInfo[i].pBsBuf, (size_t)n);
112- off += n;
113- }
114-
115- *out = h->out;
116- *out_len = total;
117- *keyframe = (info.eFrameType == videoFrameTypeIDR);
118- return 0;
119-}
120-
121-int frq_h264_force_keyframe(void *handle) {
122- frq_h264 *h = (frq_h264 *)handle;
123- return (*h->enc)->ForceIntraFrame(h->enc, true);
124-}
125-
126-void frq_h264_close(void *handle) {
127- frq_h264 *h = (frq_h264 *)handle;
128- if (h == NULL) return;
129- if (h->enc) { (*h->enc)->Uninitialize(h->enc); WelsDestroySVCEncoder(h->enc); }
130- free(h->out);
131- free(h);
132-}
133-
134-/* --- decoding -------------------------------------------------------------
135- *
136- * Same vtable problem, same answer. ISVCDecoder is `const ISVCDecoderVtbl*`,
137- * so DecodeFrameNoDelay is a function pointer and jolt cannot reach it.
138- *
139- * What comes out is I420 in the decoder's OWN buffers, three planes with
140- * their own strides — which are not the width. A decoder pads its rows, so
141- * copying `width` bytes per row from a `stride`-wide plane is the mistake
142- * that produces a picture sheared diagonally, and it is why the strides are
143- * carried through to the converter below rather than assumed away.
144- */
145-#include <wels/codec_def.h>
146-
147-typedef struct {
148- ISVCDecoder *dec;
149- unsigned char *rgba; /* converted output, grown as needed */
150- size_t rgba_cap;
151-} frq_h264_dec;
152-
153-int frq_h264_decoder_open(void **handle) {
154- ISVCDecoder *dec = NULL;
155- frq_h264_dec *d;
156- SDecodingParam p;
157- int rc;
158-
159- *handle = NULL;
160- rc = WelsCreateDecoder(&dec);
161- if (rc != 0 || dec == NULL) return rc ? rc : -1;
162-
163- memset(&p, 0, sizeof(p));
164- p.eEcActiveIdc = ERROR_CON_SLICE_COPY;
165- p.sVideoProperty.eVideoBsType = VIDEO_BITSTREAM_AVC;
166-
167- rc = (int)(*dec)->Initialize(dec, &p);
168- if (rc != 0) { WelsDestroyDecoder(dec); return rc; }
169-
170- d = (frq_h264_dec *)calloc(1, sizeof(frq_h264_dec));
171- if (d == NULL) { (*dec)->Uninitialize(dec); WelsDestroyDecoder(dec); return -1; }
172- d->dec = dec;
173- *handle = d;
174- return 0;
175-}
176-
177-/* Decode one Annex B frame and convert it to RGBA.
178- *
179- * RGBA rather than I420 because that is what the far end of this is:
180- * vidya/frame-rgba! takes a tightly packed RGBA buffer, and converting here
181- * means the pixels are touched once, in C, instead of crossing into jolt to
182- * be rearranged. On success answers 0; *out is NULL and *w/*h are 0 when the
183- * decoder has no picture yet, which is normal for the first packets. */
184-int frq_h264_decode_rgba(void *handle, const unsigned char *annexb, int len,
185- const unsigned char **out, int *w, int *h) {
186- frq_h264_dec *d = (frq_h264_dec *)handle;
187- unsigned char *planes[3] = {NULL, NULL, NULL};
188- SBufferInfo info;
189- DECODING_STATE st;
190- int width, height, y, x, sy, su, sv;
191- size_t need;
192-
193- *out = NULL; *w = 0; *h = 0;
194- memset(&info, 0, sizeof(info));
195-
196- st = (*d->dec)->DecodeFrameNoDelay(d->dec, annexb, len, planes, &info);
197- if (st != dsErrorFree) return (int)st;
198- if (info.iBufferStatus != 1) return 0; /* no picture this time */
199-
200- width = info.UsrData.sSystemBuffer.iWidth;
201- height = info.UsrData.sSystemBuffer.iHeight;
202- sy = info.UsrData.sSystemBuffer.iStride[0];
203- su = info.UsrData.sSystemBuffer.iStride[1];
204- sv = su;
205- if (width <= 0 || height <= 0) return 0;
206-
207- need = (size_t)width * (size_t)height * 4u;
208- if (need > d->rgba_cap) {
209- unsigned char *grown = (unsigned char *)realloc(d->rgba, need);
210- if (grown == NULL) return -1;
211- d->rgba = grown; d->rgba_cap = need;
212- }
213-
214- /* BT.601 limited range, integer. Not a quality decision worth agonising
215- * over here: it is what a webcam stream is tagged as, and the alternative
216- * is dragging a colour-management dependency in for a video call. */
217- for (y = 0; y < height; y++) {
218- const unsigned char *Y = planes[0] + (size_t)y * sy;
219- const unsigned char *U = planes[1] + (size_t)(y / 2) * su;
220- const unsigned char *V = planes[2] + (size_t)(y / 2) * sv;
221- unsigned char *dst = d->rgba + (size_t)y * width * 4;
222- for (x = 0; x < width; x++) {
223- int c = (int)Y[x] - 16;
224- int u = (int)U[x / 2] - 128;
225- int v = (int)V[x / 2] - 128;
226- int r = (298 * c + 409 * v + 128) >> 8;
227- int g = (298 * c - 100 * u - 208 * v + 128) >> 8;
228- int b = (298 * c + 516 * u + 128) >> 8;
229- dst[x * 4 + 0] = (unsigned char)(r < 0 ? 0 : r > 255 ? 255 : r);
230- dst[x * 4 + 1] = (unsigned char)(g < 0 ? 0 : g > 255 ? 255 : g);
231- dst[x * 4 + 2] = (unsigned char)(b < 0 ? 0 : b > 255 ? 255 : b);
232- dst[x * 4 + 3] = 255;
233- }
234- }
235-
236- *out = d->rgba; *w = width; *h = height;
237- return 0;
238-}
239-
240-void frq_h264_decoder_close(void *handle) {
241- frq_h264_dec *d = (frq_h264_dec *)handle;
242- if (d == NULL) return;
243- if (d->dec) { (*d->dec)->Uninitialize(d->dec); WelsDestroyDecoder(d->dec); }
244- free(d->rgba);
245- free(d);
246-}
247-
248-/* --- YUYV to I420 ---------------------------------------------------------
249- *
250- * A webcam almost never hands you I420. YUYV (4:2:2 packed) is the format
251- * every UVC device supports, and openh264 wants I420 (4:2:0 planar), so
252- * something has to transpose and subsample between them. Doing it in jolt
253- * would be a per-pixel loop through ffi/read and ffi/write at thirty frames
254- * a second; doing it here is one pass over the row pairs.
255- *
256- * The chroma is AVERAGED down the row pair rather than dropped. Taking every
257- * other line instead is a line cheaper and shows up as combing on anything
258- * with a hard colour edge — a red shirt against a pale wall is the usual
259- * way to see it.
260- *
261- * `src` is width*height*2 bytes; `dst` is width*height*3/2. Both even
262- * dimensions, which V4L2 will have negotiated anyway.
263- */
264-void frq_yuyv_to_i420(const unsigned char *src, unsigned char *dst,
265- int width, int height) {
266- int x, y;
267- unsigned char *Y = dst;
268- unsigned char *U = dst + width * height;
269- unsigned char *V = U + (width / 2) * (height / 2);
270-
271- for (y = 0; y < height; y++) {
272- const unsigned char *row = src + (size_t)y * width * 2;
273- unsigned char *yr = Y + (size_t)y * width;
274- for (x = 0; x < width; x++) yr[x] = row[x * 2];
275- }
276- for (y = 0; y < height; y += 2) {
277- const unsigned char *r0 = src + (size_t)y * width * 2;
278- const unsigned char *r1 = src + (size_t)(y + 1) * width * 2;
279- unsigned char *ur = U + (size_t)(y / 2) * (width / 2);
280- unsigned char *vr = V + (size_t)(y / 2) * (width / 2);
281- for (x = 0; x < width; x += 2) {
282- /* One U and one V per two pixels per row; averaged over the pair. */
283- int u = (r0[x * 2 + 1] + r1[x * 2 + 1] + 1) >> 1;
284- int v = (r0[x * 2 + 3] + r1[x * 2 + 3] + 1) >> 1;
285- ur[x / 2] = (unsigned char)u;
286- vr[x / 2] = (unsigned char)v;
287- }
288- }
289-}
deleted common/frq/av/dial.cljc +0 -117
deleted file mode 100644
@@ -1,117 +0,0 @@
1-(ns frq.av.dial
2- "Which SFU to dial, and whether to bother.
3-
4- Three things `libjoltmoq` did that were never media at all: building the
5- MoQ URL for a freeq server, deciding whether dialling it is worth
6- attempting, and minting a per-device call instance. They lived in Rust
7- because that is where the media plane was, not because they needed to.
8-
9- The rules are transcribed from `av::sfu_moq_dial_url` and
10- `av::can_dial_sfu` rather than reinvented, because they are fiddly and
11- were already tested on that side: which scheme maps to which, where the
12- port survives and where it does not, and what happens to a path that was
13- already there."
14- (:require [clojure.string :as str]))
15-
16-;; --- the SFU URL -------------------------------------------------------------
17-
18-(def ^:private schemes ["ws://" "wss://" "http://" "https://"])
19-
20-(defn- absolute? [s] (some #(str/starts-with? s %) schemes))
21-
22-(defn- local-host?
23- "localhost and the loopback range, which are dialled over plain HTTP.
24-
25- Everything else is assumed to be a public freeq host and therefore TLS —
26- the same assumption the Rust made, and the reason a self-hosted server on
27- a LAN address needs a scheme spelled out."
28- [host]
29- (let [h (str/lower-case (or host ""))]
30- (or (= h "localhost") (str/starts-with? h "127."))))
31-
32-(defn- split-url
33- "scheme, host[:port], path — enough of a URL for this and no more."
34- [u]
35- (let [[scheme rest] (str/split u #"://" 2)]
36- (when rest
37- (let [slash (str/index-of rest "/")
38- authority (if slash (subs rest 0 slash) rest)
39- ;; A query on the way in is dropped, not merged: the one we
40- ;; build replaces it entirely.
41- authority (first (str/split authority #"\?"))]
42- {:scheme scheme :authority authority}))))
43-
44-(defn- encode
45- "Percent-encode everything that is not unreserved.
46-
47- An instance id is eight hex characters and needs none of this, but the id
48- is the caller's to choose and a `+` or a space in one would otherwise
49- become a different instance on the far side."
50- [s]
51- (str/join
52- (map (fn [ch]
53- (let [c (int ch)]
54- (if (or (<= 48 c 57) (<= 65 c 90) (<= 97 c 122)
55- (contains? #{\- \_ \. \~} ch))
56- (str ch)
57- (format "%%%02X" c))))
58- s)))
59-
60-(defn sfu-url
61- "The MoQ URL for `server`, or nil when the server is not one a URL can be
62- made of.
63-
64- `irc.freeq.at:6697` and `wss://irc.freeq.at/irc` both become
65- `https://irc.freeq.at/av/moq`. Note what happens to the PORT: on a bare
66- host:port form it is dropped, because 6697 is the IRC port and the SFU is
67- not there — but on an absolute URL the authority is kept as given, since
68- someone who wrote a port into a URL meant it."
69- [server jwt instance]
70- (let [trimmed (str/trim (or server ""))]
71- (when-not (str/blank? trimmed)
72- (let [normalised
73- (if (absolute? trimmed)
74- trimmed
75- (let [host (first (str/split trimmed #":"))]
76- (if (local-host? host)
77- (str "http://" trimmed)
78- (str "https://" host))))
79- {:keys [scheme authority]} (split-url normalised)
80- scheme (case scheme
81- ("https" "wss") "https"
82- ("http" "ws") "http"
83- nil)]
84- (when (and scheme (seq authority))
85- (let [pairs (cond-> []
86- (seq instance) (conj (str "inst=" (encode instance)))
87- ;; JWTs are base64url and pass through unencoded,
88- ;; which is what freeq-sdk-ffi and freeq-app do.
89- (seq jwt) (conj (str "jwt=" jwt)))]
90- (str scheme "://" authority "/av/moq"
91- (when (seq pairs) (str "?" (str/join "&" pairs))))))))))
92-
93-(defn can-dial?
94- "Whether dialling this server is worth attempting.
95-
96- A remote SFU with no token accepts the connection and closes it, and the
97- MoQ client then retries in a tight loop that looks, from the outside,
98- exactly like a hang. Asking first is cheaper than explaining that."
99- [server jwt]
100- (if (seq jwt)
101- true
102- (let [trimmed (str/trim (or server ""))
103- host (if (absolute? trimmed)
104- (:authority (split-url trimmed))
105- (-> trimmed (str/split #"/") first (str/split #":") first))
106- host (first (str/split (or host "") #":"))]
107- (local-host? host))))
108-
109-;; --- the instance id ---------------------------------------------------------
110-
111-(defn new-instance
112- "A per-device call instance id — eight hex characters.
113-
114- Two devices signed in as the same person need different ones, or their
115- MoQ broadcast paths collide and each unpublishes the other."
116- []
117- (format "%08x" (long (rand-int 2147483647))))
deleted file mode 100644
@@ -1,117 +0,0 @@
1-(ns frq.av.dial
2- "Which SFU to dial, and whether to bother.
3-
4- Three things `libjoltmoq` did that were never media at all: building the
5- MoQ URL for a freeq server, deciding whether dialling it is worth
6- attempting, and minting a per-device call instance. They lived in Rust
7- because that is where the media plane was, not because they needed to.
8-
9- The rules are transcribed from `av::sfu_moq_dial_url` and
10- `av::can_dial_sfu` rather than reinvented, because they are fiddly and
11- were already tested on that side: which scheme maps to which, where the
12- port survives and where it does not, and what happens to a path that was
13- already there."
14- (:require [clojure.string :as str]))
15-
16-;; --- the SFU URL -------------------------------------------------------------
17-
18-(def ^:private schemes ["ws://" "wss://" "http://" "https://"])
19-
20-(defn- absolute? [s] (some #(str/starts-with? s %) schemes))
21-
22-(defn- local-host?
23- "localhost and the loopback range, which are dialled over plain HTTP.
24-
25- Everything else is assumed to be a public freeq host and therefore TLS —
26- the same assumption the Rust made, and the reason a self-hosted server on
27- a LAN address needs a scheme spelled out."
28- [host]
29- (let [h (str/lower-case (or host ""))]
30- (or (= h "localhost") (str/starts-with? h "127."))))
31-
32-(defn- split-url
33- "scheme, host[:port], path — enough of a URL for this and no more."
34- [u]
35- (let [[scheme rest] (str/split u #"://" 2)]
36- (when rest
37- (let [slash (str/index-of rest "/")
38- authority (if slash (subs rest 0 slash) rest)
39- ;; A query on the way in is dropped, not merged: the one we
40- ;; build replaces it entirely.
41- authority (first (str/split authority #"\?"))]
42- {:scheme scheme :authority authority}))))
43-
44-(defn- encode
45- "Percent-encode everything that is not unreserved.
46-
47- An instance id is eight hex characters and needs none of this, but the id
48- is the caller's to choose and a `+` or a space in one would otherwise
49- become a different instance on the far side."
50- [s]
51- (str/join
52- (map (fn [ch]
53- (let [c (int ch)]
54- (if (or (<= 48 c 57) (<= 65 c 90) (<= 97 c 122)
55- (contains? #{\- \_ \. \~} ch))
56- (str ch)
57- (format "%%%02X" c))))
58- s)))
59-
60-(defn sfu-url
61- "The MoQ URL for `server`, or nil when the server is not one a URL can be
62- made of.
63-
64- `irc.freeq.at:6697` and `wss://irc.freeq.at/irc` both become
65- `https://irc.freeq.at/av/moq`. Note what happens to the PORT: on a bare
66- host:port form it is dropped, because 6697 is the IRC port and the SFU is
67- not there — but on an absolute URL the authority is kept as given, since
68- someone who wrote a port into a URL meant it."
69- [server jwt instance]
70- (let [trimmed (str/trim (or server ""))]
71- (when-not (str/blank? trimmed)
72- (let [normalised
73- (if (absolute? trimmed)
74- trimmed
75- (let [host (first (str/split trimmed #":"))]
76- (if (local-host? host)
77- (str "http://" trimmed)
78- (str "https://" host))))
79- {:keys [scheme authority]} (split-url normalised)
80- scheme (case scheme
81- ("https" "wss") "https"
82- ("http" "ws") "http"
83- nil)]
84- (when (and scheme (seq authority))
85- (let [pairs (cond-> []
86- (seq instance) (conj (str "inst=" (encode instance)))
87- ;; JWTs are base64url and pass through unencoded,
88- ;; which is what freeq-sdk-ffi and freeq-app do.
89- (seq jwt) (conj (str "jwt=" jwt)))]
90- (str scheme "://" authority "/av/moq"
91- (when (seq pairs) (str "?" (str/join "&" pairs))))))))))
92-
93-(defn can-dial?
94- "Whether dialling this server is worth attempting.
95-
96- A remote SFU with no token accepts the connection and closes it, and the
97- MoQ client then retries in a tight loop that looks, from the outside,
98- exactly like a hang. Asking first is cheaper than explaining that."
99- [server jwt]
100- (if (seq jwt)
101- true
102- (let [trimmed (str/trim (or server ""))
103- host (if (absolute? trimmed)
104- (:authority (split-url trimmed))
105- (-> trimmed (str/split #"/") first (str/split #":") first))
106- host (first (str/split (or host "") #":"))]
107- (local-host? host))))
108-
109-;; --- the instance id ---------------------------------------------------------
110-
111-(defn new-instance
112- "A per-device call instance id — eight hex characters.
113-
114- Two devices signed in as the same person need different ones, or their
115- MoQ broadcast paths collide and each unpublishes the other."
116- []
117- (format "%08x" (long (rand-int 2147483647))))
modified common/frq/cells.cljc +12 -26
@@ -1,22 +1,12 @@
11 (ns frq.cells
22 "The cells the connect screen reads, and the constants beside them.
33
4- Moved out of `frq.state` so a screen can be shared: `frq.state` is 1,930
5- lines that reach `frq.irc` and `frq.av` and will not compile under
6- ClojureDart for a long time yet, but the cells themselves are atoms and
7- atoms are portable. `frq.state` re-defs every name here, so its own thousand
8- lines did not move and neither did anything reading `s/form-handle`.
9-
10- The reader conditional is the whole trick. On jolt these are glimmer ratoms
11- a component that derefs one re-renders when it changes, which is what the
12- desktop's reconciler is built on. Under ClojureDart they are ordinary atoms,
13- and `cljd.flutter`'s `:watch` does the same job from the other end. Neither
14- compiler sees the other's require.
15-
16- jolt answers to `:jolt` and ClojureDart to `:cljd`; ClojureDart also has
17- `:clj` always on, which is why neither branch is spelled that way."
18- (:require #?@(:cljd []
19- :jolt [[glimmer.ratom :refer [atom reaction]]])))
4+ These were split out of a `frq.state` that could not be shared, back when
5+ the desktop was a second compiler; the cells themselves are atoms, and atoms
6+ were portable when little else was. They are ordinary atoms now, and
7+ `cljd.flutter`'s `:watch` is what makes a widget rebuild when one changes —
8+ they used to be glimmer ratoms under the libcosmic frontend, behind a reader
9+ conditional, and that frontend is gone.")
2010
2111 (def default-host "irc.freeq.at")
2212 (def default-port "6697")
@@ -199,16 +189,12 @@
199189 reaction is recomputed on each such change, which is a comparison, but it
200190 wakes the rows that read it only when its answer changes.
201191
202- None of which applies under ClojureDart, where Flutter rebuilds the screen
203- and diffs its own element tree: there is no subtree to wake, so the question
204- is simply asked. That is the whole of the difference, and it is why this is
205- a value rather than a cell `@(derived k f)` could not be written once."
206- [k f]
207- #?(:cljd (f)
208- :jolt (deref (or (get @derived-cells k)
209- (let [cell (reaction (f))]
210- (swap! derived-cells assoc k cell)
211- cell)))))
192+ None of which applies under Flutter, which rebuilds the screen and diffs
193+ its own element tree: there is no subtree to wake, so the question is simply
194+ asked. This stays a function rather than a cell because it was one on both
195+ halves `@(derived k f)` could not have been written once."
196+ [_k f]
197+ (f))
212198
213199 ;; The pill the pointer is resting on, or nil `{:id msgid :emoji glyph}`.
214200 ;; One at a time, and named by the message as well as the glyph: the same emoji
@@ -1,22 +1,12 @@
1 (ns frq.cells1 (ns frq.cells
2 "The cells the connect screen reads, and the constants beside them.2 "The cells the connect screen reads, and the constants beside them.
3 3
4- Moved out of `frq.state` so a screen can be shared: `frq.state` is 1,9304+ These were split out of a `frq.state` that could not be shared, back when
5- lines that reach `frq.irc` and `frq.av` and will not compile under5+ the desktop was a second compiler; the cells themselves are atoms, and atoms
6- ClojureDart for a long time yet, but the cells themselves are atoms and6+ were portable when little else was. They are ordinary atoms now, and
7- atoms are portable. `frq.state` re-defs every name here, so its own thousand7+ `cljd.flutter`'s `:watch` is what makes a widget rebuild when one changes —
8- lines did not move and neither did anything reading `s/form-handle`.8+ they used to be glimmer ratoms under the libcosmic frontend, behind a reader
9-9+ conditional, and that frontend is gone.")
10- The reader conditional is the whole trick. On jolt these are glimmer ratoms
11- a component that derefs one re-renders when it changes, which is what the
12- desktop's reconciler is built on. Under ClojureDart they are ordinary atoms,
13- and `cljd.flutter`'s `:watch` does the same job from the other end. Neither
14- compiler sees the other's require.
15-
16- jolt answers to `:jolt` and ClojureDart to `:cljd`; ClojureDart also has
17- `:clj` always on, which is why neither branch is spelled that way."
18- (:require #?@(:cljd []
19- :jolt [[glimmer.ratom :refer [atom reaction]]])))
20 10
21 (def default-host "irc.freeq.at")11 (def default-host "irc.freeq.at")
22 (def default-port "6697")12 (def default-port "6697")
@@ -199,16 +189,12 @@
199 reaction is recomputed on each such change, which is a comparison, but it189 reaction is recomputed on each such change, which is a comparison, but it
200 wakes the rows that read it only when its answer changes.190 wakes the rows that read it only when its answer changes.
201 191
202- None of which applies under ClojureDart, where Flutter rebuilds the screen192+ None of which applies under Flutter, which rebuilds the screen and diffs
203- and diffs its own element tree: there is no subtree to wake, so the question193+ its own element tree: there is no subtree to wake, so the question is simply
204- is simply asked. That is the whole of the difference, and it is why this is194+ asked. This stays a function rather than a cell because it was one on both
205- a value rather than a cell `@(derived k f)` could not be written once."195+ halves `@(derived k f)` could not have been written once."
206- [k f]196+ [_k f]
207- #?(:cljd (f)197+ (f))
208- :jolt (deref (or (get @derived-cells k)
209- (let [cell (reaction (f))]
210- (swap! derived-cells assoc k cell)
211- cell)))))
212 198
213 ;; The pill the pointer is resting on, or nil `{:id msgid :emoji glyph}`.199 ;; The pill the pointer is resting on, or nil `{:id msgid :emoji glyph}`.
214 ;; One at a time, and named by the message as well as the glyph: the same emoji200 ;; One at a time, and named by the message as well as the glyph: the same emoji
modified common/frq/io.cljc +35 -35
@@ -1,40 +1,40 @@
11 (ns frq.io
2- "What the host does, named once so both compilers can answer it.
2+ "What the host does, named once so every target can answer it.
33
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.
4+ `flutter/src/` is ClojureDart: dart:io, dart:ffi and Flutter on Android and
5+ the Linux desktop, dart:html in a browser. Everything in `common/` is
6+ compiled for all three, so it cannot mention any of them — a require of a
7+ `dart:` library at the top of a namespace is what keeps it out of the web
8+ build, not anything about what the code does.
99
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
10+ So this is the seam. Nothing here has an implementation; the host installs
11+ one before anything else runs — `frq.io.dart` on Android and the desktop,
12+ `frq.io.web` in a browser. A namespace under `common/` requires this and
1313 stays portable.
1414
15- The functions are chosen by *intent* rather than by what either platform
15+ The functions are chosen by *intent* rather than by what any one platform
1616 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."
17+ has no chmod; `local-offset-seconds` rather than a zone name, because finding
18+ the zone is four platform-specific guesses on Linux and one property read on
19+ Android. Anywhere the seam names a mechanism instead of a result, one of the
20+ sides ends up faking it."
2121 (:refer-clojure :exclude [slurp spit]))
2222
2323 (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`.
24+ ;; Keyword → fn. Empty until a host installs into it, which is a load-time
25+ ;; effect of requiring `frq.io.dart` or `frq.io.web`.
2626 (atom {}))
2727
2828 (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."
29+ "Register the host's answers. Called once, by the host, before `-main` does
30+ anything — see the require list of the Flutter entry point. Merges, so a host
31+ may install in pieces."
3232 [m]
3333 (swap! impl merge m)
3434 nil)
3535
3636 (defn installed?
37- "Whether a backend has answered yet. For the entry points to assert on; the
37+ "Whether a host has answered yet. For the entry points to assert on; the
3838 wrappers below throw on their own."
3939 []
4040 (boolean (seq @impl)))
@@ -44,7 +44,7 @@
4444 (if-let [f (get @impl k)]
4545 (apply f args)
4646 (throw (ex-info (str "frq.io: no host installed for " k
47- " — require frq.io.jolt (desktop) or frq.io.dart (android) first")
47+ " — require frq.io.dart (native) or frq.io.web (browser) first")
4848 {:op k}))))
4949
5050 ;; ------------------------------------------------------------ environment
@@ -69,8 +69,7 @@
6969 (defn delete-file! [path] (call :delete-file! [path]))
7070
7171 (defn slurp
72- "The file as a string, or nil where it cannot be read. Shadows core's, which
73- wants a JVM reader."
72+ "The file as a string, or nil where it cannot be read. Shadows core's."
7473 [path]
7574 (call :slurp [path]))
7675
@@ -86,7 +85,7 @@
8685 Named for the result, like the rest of the seam, because \"where downloads
8786 go\" is a different question on each of the three targets this has to answer
8887 on: an XDG directory on a Linux desktop, the shared Download store on
89- Android, the app's own storage where neither of those is there. The caller
88+ Android, a browser download where neither of those is there. The caller
9089 has a picture out of the media cache and wants it kept somewhere a file
9190 manager will find it; which directory that is is the host's business.
9291
@@ -108,9 +107,9 @@
108107 (defn utf8-bytes
109108 "A string as a sequence of byte values, 0-255.
110109
111- In the seam because there is no portable way to say it: jolt has
112- `.getBytes`, which is Java, and ClojureDart has `dart:convert`. `frq.atproto`
113- needs it for base64url — SASL is bytes, and a handle with a non-ASCII
110+ In the seam because there is no portable way to say it: `dart:convert` is a
111+ host library, and `common/` may not name one. `frq.atproto` needs it for
112+ base64url — SASL is bytes, and a handle with a non-ASCII
114113 character in it encodes to more of them than it has characters."
115114 [s]
116115 (call :utf8-bytes [s]))
@@ -126,9 +125,10 @@
126125 "Hand `url` to whatever shows web pages here, and say whether that worked.
127126
128127 Named for the result and not the mechanism, like the rest of this seam: the
129- desktop shells out to the portal and the phone asks Android to pick an
130- activity, and neither is the other's business. A false answer is not fatal —
131- the OAuth screen shows the URL so it can be opened by hand."
128+ desktop shells out to the portal, the phone asks Android to pick an activity
129+ and the browser opens a tab, and none is the others' business. A false
130+ answer is not fatal — the OAuth screen shows the URL so it can be opened by
131+ hand."
132132 [url]
133133 (boolean (call :open-url! [url])))
134134
@@ -136,16 +136,16 @@
136136 "Ask `url` for its body, as text, and hand it to `on-done` — nil where the
137137 request could not be made or the server refused.
138138
139- Off whatever thread the caller is on: the desktop drops it on a future and
140- Dart awaits it, so neither blocks a frame. The callback is the only answer;
141- there is no synchronous form, because one of the two sides cannot give one.
139+ Off whatever the caller is on: Dart awaits it, so nothing blocks a frame.
140+ The callback is the only answer; there is no synchronous form, because not
141+ every target can give one.
142142
143143 `headers` is a map of name to value, which is the whole reason this is here
144144 rather than in `frq.media`: a picture is fetched by URL alone, and asking
145145 freeq about a message means carrying the bearer it handed out at sign-in.
146146
147- Text and not parsed JSON: the two sides have very different JSON, and
148- `frq.atproto.core/json-*` reads a string on both."
147+ Text and not parsed JSON: `frq.atproto.core/json-*` reads a string, on every
148+ target."
149149 [url headers on-done]
150150 (call :fetch-text! [url headers on-done]))
151151
@@ -1,40 +1,40 @@
1 (ns frq.io1 (ns frq.io
2- "What the host does, named once so both compilers can answer it.2+ "What the host does, named once so every target can answer it.
3 3
4- `src/` is jolt: jolt.host, jolt.socket, jolt.ffi, a Chez runtime and glimmer4+ `flutter/src/` is ClojureDart: dart:io, dart:ffi and Flutter on Android and
5- under the screens. `android/src/` is ClojureDart: dart:io, dart:ffi and5+ the Linux desktop, dart:html in a browser. Everything in `common/` is
6- Flutter. Everything in `common/` is compiled by both, so it cannot mention6+ compiled for all three, so it cannot mention any of them — a require of a
7- either — a `(:require [jolt.host])` at the top of a namespace is what keeps7+ `dart:` library at the top of a namespace is what keeps it out of the web
8- it out of the Android build, not anything about what the code does.8+ build, not anything about what the code does.
9 9
10- So this is the seam. Nothing here has an implementation; the backend installs10+ So this is the seam. Nothing here has an implementation; the host installs
11- one before anything else runs — `frq.io.jolt` on the desktop side,11+ one before anything else runs — `frq.io.dart` on Android and the desktop,
12- `frq.io.dart` on the phone. A namespace under `common/` requires this and12+ `frq.io.web` in a browser. A namespace under `common/` requires this and
13 stays portable.13 stays portable.
14 14
15- The functions are chosen by *intent* rather than by what either platform15+ The functions are chosen by *intent* rather than by what any one platform
16 happens to call it. `write-private-file!` rather than a chmod, because Dart16 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`17+ has no chmod; `local-offset-seconds` rather than a zone name, because finding
18- rather than a zone name, because finding the zone is four platform-specific18+ the zone is four platform-specific guesses on Linux and one property read on
19- guesses on Linux and one property read on Android. Anywhere the seam names a19+ Android. Anywhere the seam names a mechanism instead of a result, one of the
20- mechanism instead of a result, one of the two sides ends up faking it."20+ sides ends up faking it."
21 (:refer-clojure :exclude [slurp spit]))21 (:refer-clojure :exclude [slurp spit]))
22 22
23 (defonce ^:private impl23 (defonce ^:private impl
24- ;; Keyword → fn. Empty until a backend installs into it, which is a load-time24+ ;; Keyword → fn. Empty until a host installs into it, which is a load-time
25- ;; effect of requiring `frq.io.jolt` or `frq.io.dart`.25+ ;; effect of requiring `frq.io.dart` or `frq.io.web`.
26 (atom {}))26 (atom {}))
27 27
28 (defn install!28 (defn install!
29- "Register the host's answers. Called once, by the backend, before `-main`29+ "Register the host's answers. Called once, by the host, before `-main` does
30- does anything — see the require list of `frq.app` and of the Flutter entry30+ anything — see the require list of the Flutter entry point. Merges, so a host
31- point. Merges, so a backend may install in pieces."31+ may install in pieces."
32 [m]32 [m]
33 (swap! impl merge m)33 (swap! impl merge m)
34 nil)34 nil)
35 35
36 (defn installed?36 (defn installed?
37- "Whether a backend has answered yet. For the entry points to assert on; the37+ "Whether a host has answered yet. For the entry points to assert on; the
38 wrappers below throw on their own."38 wrappers below throw on their own."
39 []39 []
40 (boolean (seq @impl)))40 (boolean (seq @impl)))
@@ -44,7 +44,7 @@
44 (if-let [f (get @impl k)]44 (if-let [f (get @impl k)]
45 (apply f args)45 (apply f args)
46 (throw (ex-info (str "frq.io: no host installed for " k46 (throw (ex-info (str "frq.io: no host installed for " k
47- " — require frq.io.jolt (desktop) or frq.io.dart (android) first")47+ " — require frq.io.dart (native) or frq.io.web (browser) first")
48 {:op k}))))48 {:op k}))))
49 49
50 ;; ------------------------------------------------------------ environment50 ;; ------------------------------------------------------------ environment
@@ -69,8 +69,7 @@
69 (defn delete-file! [path] (call :delete-file! [path]))69 (defn delete-file! [path] (call :delete-file! [path]))
70 70
71 (defn slurp71 (defn slurp
72- "The file as a string, or nil where it cannot be read. Shadows core's, which72+ "The file as a string, or nil where it cannot be read. Shadows core's."
73- wants a JVM reader."
74 [path]73 [path]
75 (call :slurp [path]))74 (call :slurp [path]))
76 75
@@ -86,7 +85,7 @@
86 Named for the result, like the rest of the seam, because \"where downloads85 Named for the result, like the rest of the seam, because \"where downloads
87 go\" is a different question on each of the three targets this has to answer86 go\" is a different question on each of the three targets this has to answer
88 on: an XDG directory on a Linux desktop, the shared Download store on87 on: an XDG directory on a Linux desktop, the shared Download store on
89- Android, the app's own storage where neither of those is there. The caller88+ Android, a browser download where neither of those is there. The caller
90 has a picture out of the media cache and wants it kept somewhere a file89 has a picture out of the media cache and wants it kept somewhere a file
91 manager will find it; which directory that is is the host's business.90 manager will find it; which directory that is is the host's business.
92 91
@@ -108,9 +107,9 @@
108 (defn utf8-bytes107 (defn utf8-bytes
109 "A string as a sequence of byte values, 0-255.108 "A string as a sequence of byte values, 0-255.
110 109
111- In the seam because there is no portable way to say it: jolt has110+ In the seam because there is no portable way to say it: `dart:convert` is a
112- `.getBytes`, which is Java, and ClojureDart has `dart:convert`. `frq.atproto`111+ host library, and `common/` may not name one. `frq.atproto` needs it for
113- needs it for base64url — SASL is bytes, and a handle with a non-ASCII112+ base64url — SASL is bytes, and a handle with a non-ASCII
114 character in it encodes to more of them than it has characters."113 character in it encodes to more of them than it has characters."
115 [s]114 [s]
116 (call :utf8-bytes [s]))115 (call :utf8-bytes [s]))
@@ -126,9 +125,10 @@
126 "Hand `url` to whatever shows web pages here, and say whether that worked.125 "Hand `url` to whatever shows web pages here, and say whether that worked.
127 126
128 Named for the result and not the mechanism, like the rest of this seam: the127 Named for the result and not the mechanism, like the rest of this seam: the
129- desktop shells out to the portal and the phone asks Android to pick an128+ desktop shells out to the portal, the phone asks Android to pick an activity
130- activity, and neither is the other's business. A false answer is not fatal —129+ and the browser opens a tab, and none is the others' business. A false
131- the OAuth screen shows the URL so it can be opened by hand."130+ answer is not fatal — the OAuth screen shows the URL so it can be opened by
131+ hand."
132 [url]132 [url]
133 (boolean (call :open-url! [url])))133 (boolean (call :open-url! [url])))
134 134
@@ -136,16 +136,16 @@
136 "Ask `url` for its body, as text, and hand it to `on-done` — nil where the136 "Ask `url` for its body, as text, and hand it to `on-done` — nil where the
137 request could not be made or the server refused.137 request could not be made or the server refused.
138 138
139- Off whatever thread the caller is on: the desktop drops it on a future and139+ Off whatever the caller is on: Dart awaits it, so nothing blocks a frame.
140- Dart awaits it, so neither blocks a frame. The callback is the only answer;140+ The callback is the only answer; there is no synchronous form, because not
141- there is no synchronous form, because one of the two sides cannot give one.141+ every target can give one.
142 142
143 `headers` is a map of name to value, which is the whole reason this is here143 `headers` is a map of name to value, which is the whole reason this is here
144 rather than in `frq.media`: a picture is fetched by URL alone, and asking144 rather than in `frq.media`: a picture is fetched by URL alone, and asking
145 freeq about a message means carrying the bearer it handed out at sign-in.145 freeq about a message means carrying the bearer it handed out at sign-in.
146 146
147- Text and not parsed JSON: the two sides have very different JSON, and147+ Text and not parsed JSON: `frq.atproto.core/json-*` reads a string, on every
148- `frq.atproto.core/json-*` reads a string on both."148+ target."
149 [url headers on-done]149 [url headers on-done]
150 (call :fetch-text! [url headers on-done]))150 (call :fetch-text! [url headers on-done]))
151 151
modified common/frq/metrics.cljc +4 -7
@@ -7,15 +7,12 @@
77 strip under the list actually needs which costs a message a row, and a
88 conversation is measured in how many of those fit.
99
10- So the counts stay where the reasoning is and a backend whose rows are a
11- different height says so here. `frq.tui` sets `chrome-row` before its first
12- paint; a window and a phone leave it alone.
10+ So the counts stay where the reasoning is. `chrome-row` is a cell rather
11+ than a constant because a terminal frontend used to set it before its first
12+ paint; nothing writes it now, and a window and a phone read the default.
1313
1414 Shared because the screens that count against it are shared: `below-list` in
15- `frq.screens.chats` is the first, and the chat screen's reserves will be next.
16- `frq.app` re-defs all three, so `frq.tui` still writes `app/chrome-row`."
17- (:require #?@(:cljd []
18- :jolt [[glimmer.ratom :refer [atom]]])))
15+ `frq.screens.chats` is the first, and the chat screen's reserves will be next.")
1916
2017 (def window-row 34)
2118 (defonce chrome-row (atom window-row))
@@ -7,15 +7,12 @@
7 strip under the list actually needs which costs a message a row, and a7 strip under the list actually needs which costs a message a row, and a
8 conversation is measured in how many of those fit.8 conversation is measured in how many of those fit.
9 9
10- So the counts stay where the reasoning is and a backend whose rows are a10+ So the counts stay where the reasoning is. `chrome-row` is a cell rather
11- different height says so here. `frq.tui` sets `chrome-row` before its first11+ than a constant because a terminal frontend used to set it before its first
12- paint; a window and a phone leave it alone.12+ paint; nothing writes it now, and a window and a phone read the default.
13 13
14 Shared because the screens that count against it are shared: `below-list` in14 Shared because the screens that count against it are shared: `below-list` in
15- `frq.screens.chats` is the first, and the chat screen's reserves will be next.15+ `frq.screens.chats` is the first, and the chat screen's reserves will be next.")
16- `frq.app` re-defs all three, so `frq.tui` still writes `app/chrome-row`."
17- (:require #?@(:cljd []
18- :jolt [[glimmer.ratom :refer [atom]]])))
19 16
20 (def window-row 34)17 (def window-row 34)
21 (defonce chrome-row (atom window-row))18 (defonce chrome-row (atom window-row))
modified common/frq/screens/chat.cljc +16 -26
@@ -1443,32 +1443,22 @@
14431443 ;; something else on it".
14441444 ;; And the same picture chosen rather than pasted, for a phone — which has
14451445 ;; no Ctrl+V, and no clipboard of pictures to read if it had.
1446- ;; The same picture on both halves: a Delight 2 tile (src/frq/icons, on
1447- ;; the geometry delight-icons generates its tiles with) — grey frame,
1448- ;; dark square, cream glyph, where the emoji was a colour photo that
1449- ;; matched nothing else in the bar. Only where the tile can be found,
1450- ;; which is not the same place twice. jolt reads it out of the working
1451- ;; directory, which is the source tree under `just run` and `just
1452- ;; cosmic` and the store copy under the flake's launcher. ClojureDart is
1453- ;; launched from `flutter/` and bundles no src/, on the phone and the
1454- ;; desktop target alike, so it asks for the copy in `flutter/assets/`
1455- ;; that `pubspec.yaml` bundles — hence `asset:` rather than a path.
1456- ;; A terminal has no pixels to put a picture in and keeps the glyph.
1457- (if #?(:jolt (and (actions/desktop?) (not @terminal?)) :cljd true)
1458- [:image {:src #?(:jolt "src/frq/icons/insert-image.png"
1459- :cljd "asset:assets/insert-image.png")
1460- :size [36 36]
1461- ;; On the middle of the field rather than the top of it: the
1462- ;; box grows downwards as a message is typed, and a button
1463- ;; pinned to its first row drifts away from the thing it acts
1464- ;; on. Read by the terminal, where the row can be several
1465- ;; cells tall; a window's backends ignore it.
1466- :valign :center
1467- ;; for a backend that sizes a picture by its bounds instead
1468- :max-width 36
1469- :max-height 36
1470- :on-click actions/open-image-picker!}]
1471- [:button {:label "🖼" :valign :center :on-click actions/open-image-picker!}])
1446+ ;; A Delight 2 tile — grey frame, dark square, cream glyph, where the
1447+ ;; emoji was a colour photo that matched nothing else in the bar.
1448+ ;; ClojureDart is launched from `flutter/`, on every target, so it asks
1449+ ;; for the copy in `flutter/assets/` that `pubspec.yaml` bundles —
1450+ ;; hence `asset:` rather than a path.
1451+ [:image {:src "asset:assets/insert-image.png"
1452+ :size [36 36]
1453+ ;; On the middle of the field rather than the top of it: the
1454+ ;; box grows downwards as a message is typed, and a button
1455+ ;; pinned to its first row drifts away from the thing it acts
1456+ ;; on.
1457+ :valign :center
1458+ ;; for a backend that sizes a picture by its bounds instead
1459+ :max-width 36
1460+ :max-height 36
1461+ :on-click actions/open-image-picker!}]
14721462 ;; In a terminal the row is the width of the screen, so the field takes
14731463 ;; the surplus rather than scrolling one line sideways — and it is as
14741464 ;; tall as what has been typed into it. Three rows were kept for it
@@ -1443,32 +1443,22 @@
1443 ;; something else on it".1443 ;; something else on it".
1444 ;; And the same picture chosen rather than pasted, for a phone — which has1444 ;; And the same picture chosen rather than pasted, for a phone — which has
1445 ;; no Ctrl+V, and no clipboard of pictures to read if it had.1445 ;; no Ctrl+V, and no clipboard of pictures to read if it had.
1446- ;; The same picture on both halves: a Delight 2 tile (src/frq/icons, on1446+ ;; A Delight 2 tile — grey frame, dark square, cream glyph, where the
1447- ;; the geometry delight-icons generates its tiles with) — grey frame,1447+ ;; emoji was a colour photo that matched nothing else in the bar.
1448- ;; dark square, cream glyph, where the emoji was a colour photo that1448+ ;; ClojureDart is launched from `flutter/`, on every target, so it asks
1449- ;; matched nothing else in the bar. Only where the tile can be found,1449+ ;; for the copy in `flutter/assets/` that `pubspec.yaml` bundles —
1450- ;; which is not the same place twice. jolt reads it out of the working1450+ ;; hence `asset:` rather than a path.
1451- ;; directory, which is the source tree under `just run` and `just1451+ [:image {:src "asset:assets/insert-image.png"
1452- ;; cosmic` and the store copy under the flake's launcher. ClojureDart is1452+ :size [36 36]
1453- ;; launched from `flutter/` and bundles no src/, on the phone and the1453+ ;; On the middle of the field rather than the top of it: the
1454- ;; desktop target alike, so it asks for the copy in `flutter/assets/`1454+ ;; box grows downwards as a message is typed, and a button
1455- ;; that `pubspec.yaml` bundles — hence `asset:` rather than a path.1455+ ;; pinned to its first row drifts away from the thing it acts
1456- ;; A terminal has no pixels to put a picture in and keeps the glyph.1456+ ;; on.
1457- (if #?(:jolt (and (actions/desktop?) (not @terminal?)) :cljd true)1457+ :valign :center
1458- [:image {:src #?(:jolt "src/frq/icons/insert-image.png"1458+ ;; for a backend that sizes a picture by its bounds instead
1459- :cljd "asset:assets/insert-image.png")1459+ :max-width 36
1460- :size [36 36]1460+ :max-height 36
1461- ;; On the middle of the field rather than the top of it: the1461+ :on-click actions/open-image-picker!}]
1462- ;; box grows downwards as a message is typed, and a button
1463- ;; pinned to its first row drifts away from the thing it acts
1464- ;; on. Read by the terminal, where the row can be several
1465- ;; cells tall; a window's backends ignore it.
1466- :valign :center
1467- ;; for a backend that sizes a picture by its bounds instead
1468- :max-width 36
1469- :max-height 36
1470- :on-click actions/open-image-picker!}]
1471- [:button {:label "🖼" :valign :center :on-click actions/open-image-picker!}])
1472 ;; In a terminal the row is the width of the screen, so the field takes1462 ;; In a terminal the row is the width of the screen, so the field takes
1473 ;; the surplus rather than scrolling one line sideways — and it is as1463 ;; the surplus rather than scrolling one line sideways — and it is as
1474 ;; tall as what has been typed into it. Three rows were kept for it1464 ;; tall as what has been typed into it. Three rows were kept for it
modified common/frq/screens/connect.cljc +1 -2
@@ -26,8 +26,7 @@
2626
2727 Sign-in itself is no longer one of those differences: `frq.irc.handshake`
2828 drives SASL from common/, so both halves sign in the same way."
29- #?(:cljd "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener."
30- :jolt "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener. Sign-in needs TLS, so it is desktop-only."))
29+ "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener.")
3130
3231 (defn error-note
3332 "Always a node, never nil.
@@ -26,8 +26,7 @@
26 26
27 Sign-in itself is no longer one of those differences: `frq.irc.handshake`27 Sign-in itself is no longer one of those differences: `frq.irc.handshake`
28 drives SASL from common/, so both halves sign in the same way."28 drives SASL from common/, so both halves sign in the same way."
29- #?(:cljd "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener."29+ "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener.")
30- :jolt "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener. Sign-in needs TLS, so it is desktop-only."))
31 30
32 (defn error-note31 (defn error-note
33 "Always a node, never nil.32 "Always a node, never nil.
modified common/frq/screens/settings.cljc +3 -5
@@ -78,11 +78,9 @@
7878 [:dim-label {:label "Hides other people arriving, leaving and quitting. The people panel still follows who is here."}]]
7979 [:card {}
8080 [:title-2 {:label "frq"}]
81- ;; What this is, which is not the same sentence on both: the components
82- ;; are the same file either way, and what paints them is not. Vidya and
83- ;; egui were what it said, and both have been gone a while.
84- [:dim-label {:label #?(:cljd "freeq client — the same glimmer components as the desktop, compiled by ClojureDart and painted by Flutter."
85- :jolt "freeq client in jolt — glimmer components, painted by libcosmic.")}]
81+ ;; What this is. It used to be a different sentence per frontend, back
82+ ;; when there was more than one; there is Flutter now, on three targets.
83+ [:dim-label {:label "freeq client — ClojureDart over Flutter, on Android, Linux and the web."}]
8684 ;; No Quit where there is nothing to quit: closing an app is a window's
8785 ;; idea, and Android has its own way of leaving one.
8886 (when (actions/desktop?)
@@ -78,11 +78,9 @@
78 [:dim-label {:label "Hides other people arriving, leaving and quitting. The people panel still follows who is here."}]]78 [:dim-label {:label "Hides other people arriving, leaving and quitting. The people panel still follows who is here."}]]
79 [:card {}79 [:card {}
80 [:title-2 {:label "frq"}]80 [:title-2 {:label "frq"}]
81- ;; What this is, which is not the same sentence on both: the components81+ ;; What this is. It used to be a different sentence per frontend, back
82- ;; are the same file either way, and what paints them is not. Vidya and82+ ;; when there was more than one; there is Flutter now, on three targets.
83- ;; egui were what it said, and both have been gone a while.83+ [:dim-label {:label "freeq client — ClojureDart over Flutter, on Android, Linux and the web."}]
84- [:dim-label {:label #?(:cljd "freeq client — the same glimmer components as the desktop, compiled by ClojureDart and painted by Flutter."
85- :jolt "freeq client in jolt — glimmer components, painted by libcosmic.")}]
86 ;; No Quit where there is nothing to quit: closing an app is a window's84 ;; No Quit where there is nothing to quit: closing an app is a window's
87 ;; idea, and Android has its own way of leaving one.85 ;; idea, and Android has its own way of leaving one.
88 (when (actions/desktop?)86 (when (actions/desktop?)
deleted deps.edn +0 -105
deleted file mode 100644
@@ -1,105 +0,0 @@
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"]
8-
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.
16- ;;
17- ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A
18- ;; component unmounted between a cell firing and the queued render running
19- ;; used to render anyway, into widgets the backend had already freed and
20- ;; handed out again — which took the message list apart in a busy channel.
21- ;; And replacing a native node with one of another tag left every watcher
22- ;; under it subscribed, so swapping the phone layout for the split one and
23- ;; back shredded the chat: one component's props painted onto another's
24- ;; widget.
25- :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"
26- :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"}}
27-
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.
30- ;;
31- ;; Calls used to be the one thing this client could not do in jolt. The media
32- ;; half is `frq.av.plane` now — MoQ over QUIC from libmoq_ffi, Opus from
33- ;; libopus, H.264 from openh264 through a shim, and the devices from V4L2
34- ;; and ALSA. Signaling was always IRC and still lives in src/frq/av.clj.
35- ;;
36- ;; just cosmic run
37- :jolt/native [;; The transport half of a call: MoQ over QUIC, the one
38- ;; piece with no C implementation anywhere. Fetched from
39- ;; moq-ffi's release rather than built — building it means a
40- ;; 1062-crate workspace.
41- ;;
42- ;; The codecs are NOT in it. moq-ffi's `audio` and `video`
43- ;; features are off in every Linux artifact upstream ships,
44- ;; so Opus and H.264 come from their own C libraries below,
45- ;; which is the whole point of doing this over FFI rather
46- ;; than over a Rust facade.
47- ;;
48- ;; No :darwin: :systems is x86_64-linux and aarch64-linux, so
49- ;; a .dylib named here would name a file nothing fetches.
50- {:name "moq_ffi"
51- :linux ["libmoq_ffi.so"]}
52-
53- ;; The codecs, as C libraries rather than as somebody's
54- ;; bindings to them. Opus first; openh264 beside it when the
55- ;; video half lands.
56- ;;
57- ;; The SONAME, not the bare .so: a `libopus.so` is the -dev
58- ;; symlink and is not what a runtime closure carries. The
59- ;; loader looks these up by name in one directory, which the
60- ;; flake's `nativeAll` is.
61- {:name "opus"
62- :linux ["libopus.so.0"]
63- :darwin ["libopus.0.dylib"]}
64-
65- ;; H.264, one step removed. openh264's C API is a vtable —
66- ;; `ISVCEncoder` is `const ISVCEncoderVtbl*` — and jolt.ffi
67- ;; can only call a literal C symbol, never a function
68- ;; pointer. c/frq_h264.c walks the vtable and exports flat
69- ;; symbols; this is that. libopenh264 itself comes along as
70- ;; its DT_NEEDED and is never named here.
71- {:name "frqh264"
72- :linux ["libfrqh264.so"]
73- :darwin ["libfrqh264.dylib"]}
74-
75- ;; Audio devices. No :darwin — CoreAudio is the other side of
76- ;; that door and is not this library.
77- ;;
78- ;; V4L2 is deliberately absent from this list: the camera is
79- ;; ioctls against libc and the kernel, so there is nothing to
80- ;; load. See frq.capture.v4l2.
81- {:name "asound"
82- :linux ["libasound.so.2"]}]
83-
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.
93- ;;
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`.
99- ;;
100- ;; just tui
101- :tui {:extra-deps {nandi/glimmer-tui
102- {:local/root "../jolt-native/glimmer-backends/glimmer-tui"}}
103- :main-opts ["-m" "frq.tui"]}}
104-
105- :tasks {tui "jolt -M:tui"}}
deleted file mode 100644
@@ -1,105 +0,0 @@
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"]
8-
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.
16- ;;
17- ;; Our fork of glimmer, until the reconciler fixes in it land upstream. A
18- ;; component unmounted between a cell firing and the queued render running
19- ;; used to render anyway, into widgets the backend had already freed and
20- ;; handed out again — which took the message list apart in a busy channel.
21- ;; And replacing a native node with one of another tag left every watcher
22- ;; under it subscribed, so swapping the phone layout for the split one and
23- ;; back shredded the chat: one component's props painted onto another's
24- ;; widget.
25- :deps {jolt-lang/glimmer {:git/url "https://gitlab.com/nandithebull/glimmer"
26- :git/sha "399df371c790d690fb6e4560c3d4d7f838502857"}}
27-
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.
30- ;;
31- ;; Calls used to be the one thing this client could not do in jolt. The media
32- ;; half is `frq.av.plane` now — MoQ over QUIC from libmoq_ffi, Opus from
33- ;; libopus, H.264 from openh264 through a shim, and the devices from V4L2
34- ;; and ALSA. Signaling was always IRC and still lives in src/frq/av.clj.
35- ;;
36- ;; just cosmic run
37- :jolt/native [;; The transport half of a call: MoQ over QUIC, the one
38- ;; piece with no C implementation anywhere. Fetched from
39- ;; moq-ffi's release rather than built — building it means a
40- ;; 1062-crate workspace.
41- ;;
42- ;; The codecs are NOT in it. moq-ffi's `audio` and `video`
43- ;; features are off in every Linux artifact upstream ships,
44- ;; so Opus and H.264 come from their own C libraries below,
45- ;; which is the whole point of doing this over FFI rather
46- ;; than over a Rust facade.
47- ;;
48- ;; No :darwin: :systems is x86_64-linux and aarch64-linux, so
49- ;; a .dylib named here would name a file nothing fetches.
50- {:name "moq_ffi"
51- :linux ["libmoq_ffi.so"]}
52-
53- ;; The codecs, as C libraries rather than as somebody's
54- ;; bindings to them. Opus first; openh264 beside it when the
55- ;; video half lands.
56- ;;
57- ;; The SONAME, not the bare .so: a `libopus.so` is the -dev
58- ;; symlink and is not what a runtime closure carries. The
59- ;; loader looks these up by name in one directory, which the
60- ;; flake's `nativeAll` is.
61- {:name "opus"
62- :linux ["libopus.so.0"]
63- :darwin ["libopus.0.dylib"]}
64-
65- ;; H.264, one step removed. openh264's C API is a vtable —
66- ;; `ISVCEncoder` is `const ISVCEncoderVtbl*` — and jolt.ffi
67- ;; can only call a literal C symbol, never a function
68- ;; pointer. c/frq_h264.c walks the vtable and exports flat
69- ;; symbols; this is that. libopenh264 itself comes along as
70- ;; its DT_NEEDED and is never named here.
71- {:name "frqh264"
72- :linux ["libfrqh264.so"]
73- :darwin ["libfrqh264.dylib"]}
74-
75- ;; Audio devices. No :darwin — CoreAudio is the other side of
76- ;; that door and is not this library.
77- ;;
78- ;; V4L2 is deliberately absent from this list: the camera is
79- ;; ioctls against libc and the kernel, so there is nothing to
80- ;; load. See frq.capture.v4l2.
81- {:name "asound"
82- :linux ["libasound.so.2"]}]
83-
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.
93- ;;
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`.
99- ;;
100- ;; just tui
101- :tui {:extra-deps {nandi/glimmer-tui
102- {:local/root "../jolt-native/glimmer-backends/glimmer-tui"}}
103- :main-opts ["-m" "frq.tui"]}}
104-
105- :tasks {tui "jolt -M:tui"}}
modified flake.lock +1 -262
@@ -1,20 +1,5 @@
11 {
22 "nodes": {
3- "crane": {
4- "locked": {
5- "lastModified": 1788465171,
6- "narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=",
7- "owner": "ipetkov",
8- "repo": "crane",
9- "rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54",
10- "type": "github"
11- },
12- "original": {
13- "owner": "ipetkov",
14- "repo": "crane",
15- "type": "github"
16- }
17- },
183 "flake-utils": {
194 "inputs": {
205 "systems": "systems"
@@ -33,114 +18,9 @@
3318 "type": "github"
3419 }
3520 },
36- "flake-utils_2": {
37- "inputs": {
38- "systems": "systems_2"
39- },
40- "locked": {
41- "lastModified": 1731533236,
42- "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
43- "owner": "numtide",
44- "repo": "flake-utils",
45- "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
46- "type": "github"
47- },
48- "original": {
49- "owner": "numtide",
50- "repo": "flake-utils",
51- "type": "github"
52- }
53- },
54- "glimmer": {
55- "flake": false,
56- "locked": {
57- "lastModified": 1788135429,
58- "narHash": "sha256-20W4kmcL05O7V7EQKXuH+kVBpBtQZhhj5yTm9QqxQss=",
59- "ref": "refs/heads/main",
60- "rev": "399df371c790d690fb6e4560c3d4d7f838502857",
61- "revCount": 32,
62- "type": "git",
63- "url": "https://gitlab.com/nandithebull/glimmer"
64- },
65- "original": {
66- "rev": "399df371c790d690fb6e4560c3d4d7f838502857",
67- "type": "git",
68- "url": "https://gitlab.com/nandithebull/glimmer"
69- }
70- },
71- "jolt-native": {
72- "inputs": {
73- "crane": "crane",
74- "flake-utils": "flake-utils",
75- "nixpkgs": [
76- "nixpkgs"
77- ],
78- "rust-overlay": "rust-overlay"
79- },
80- "locked": {
81- "lastModified": 1789236164,
82- "narHash": "sha256-Bb8AYcr2XTj9KCl7QsIT6eTEo/WfQ5DQKm+9VSEnG4o=",
83- "ref": "refs/heads/main",
84- "rev": "8e8cd5192dc161b423c0ee5dd41a7a058b24b409",
85- "revCount": 117,
86- "type": "git",
87- "url": "https://gitlab.com/nandithebull/jolt-native"
88- },
89- "original": {
90- "rev": "8e8cd5192dc161b423c0ee5dd41a7a058b24b409",
91- "type": "git",
92- "url": "https://gitlab.com/nandithebull/jolt-native"
93- }
94- },
95- "jolt-src": {
96- "inputs": {
97- "makes": "makes",
98- "nixpkgs": [
99- "nixpkgs"
100- ],
101- "vendor-clojure-test-suite": "vendor-clojure-test-suite",
102- "vendor-fs": "vendor-fs",
103- "vendor-grenadine": "vendor-grenadine",
104- "vendor-irregex": "vendor-irregex",
105- "vendor-process": "vendor-process",
106- "vendor-sci": "vendor-sci"
107- },
108- "locked": {
109- "lastModified": 1788673555,
110- "narHash": "sha256-QMHvZyLFzKCQsEqsBljLksrdxMc4g5ncBExJCVUoxzA=",
111- "ref": "refs/heads/main",
112- "rev": "b7b738b37b16532be2f539c22367bdfc3f4d708d",
113- "revCount": 3026,
114- "submodules": true,
115- "type": "git",
116- "url": "https://gitlab.com/nandithebull/jolt"
117- },
118- "original": {
119- "submodules": true,
120- "type": "git",
121- "url": "https://gitlab.com/nandithebull/jolt"
122- }
123- },
124- "makes": {
125- "flake": false,
126- "locked": {
127- "lastModified": 1785480273,
128- "narHash": "sha256-/g8O4PW0v3NNChKyrWw/EqpEAUCKiI9/KFE2EiXoUlA=",
129- "owner": "makeplus",
130- "repo": "makes",
131- "rev": "d14cb578c2f04e6f9d00e01c7c4e416a9baf94e9",
132- "type": "github"
133- },
134- "original": {
135- "owner": "makeplus",
136- "repo": "makes",
137- "rev": "d14cb578c2f04e6f9d00e01c7c4e416a9baf94e9",
138- "type": "github"
139- }
140- },
14121 "nixgl": {
14222 "inputs": {
143- "flake-utils": "flake-utils_2",
23+ "flake-utils": "flake-utils",
14424 "nixpkgs": [
14525 "nixpkgs"
14626 ]
@@ -177,34 +57,10 @@
17757 },
17858 "root": {
17959 "inputs": {
180- "glimmer": "glimmer",
181- "jolt-native": "jolt-native",
182- "jolt-src": "jolt-src",
18360 "nixgl": "nixgl",
18461 "nixpkgs": "nixpkgs"
18562 }
18663 },
187- "rust-overlay": {
188- "inputs": {
189- "nixpkgs": [
190- "jolt-native",
191- "nixpkgs"
192- ]
193- },
194- "locked": {
195- "lastModified": 1788591095,
196- "narHash": "sha256-Vh+BeLWfbTT9AecazIsQ/Tkg/RzJeX3lEduANf256WA=",
197- "owner": "oxalica",
198- "repo": "rust-overlay",
199- "rev": "c361047d3a538f547f1617bb6b410411929ac9cc",
200- "type": "github"
201- },
202- "original": {
203- "owner": "oxalica",
204- "repo": "rust-overlay",
205- "type": "github"
206- }
207- },
20864 "systems": {
20965 "locked": {
21066 "lastModified": 1681028828,
@@ -219,123 +75,6 @@
21975 "repo": "default",
22076 "type": "github"
22177 }
222- },
223- "systems_2": {
224- "locked": {
225- "lastModified": 1681028828,
226- "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
227- "owner": "nix-systems",
228- "repo": "default",
229- "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
230- "type": "github"
231- },
232- "original": {
233- "owner": "nix-systems",
234- "repo": "default",
235- "type": "github"
236- }
237- },
238- "vendor-clojure-test-suite": {
239- "flake": false,
240- "locked": {
241- "lastModified": 1782670040,
242- "narHash": "sha256-NzNoVBdImoxriFTD+qr42YjDfnBtUG1SG/K5VeyviXQ=",
243- "owner": "jank-lang",
244- "repo": "clojure-test-suite",
245- "rev": "489b6743e8421687ef96cec557830acf258d1886",
246- "type": "github"
247- },
248- "original": {
249- "owner": "jank-lang",
250- "repo": "clojure-test-suite",
251- "rev": "489b6743e8421687ef96cec557830acf258d1886",
252- "type": "github"
253- }
254- },
255- "vendor-fs": {
256- "flake": false,
257- "locked": {
258- "lastModified": 1783722973,
259- "narHash": "sha256-cMUQmVOR6zxd96ndnQ9UXkit9Nt39eZzfI9HmQRsLtE=",
260- "owner": "babashka",
261- "repo": "fs",
262- "rev": "5b273b8a943a622593fbc85fca6761c5a39d6d66",
263- "type": "github"
264- },
265- "original": {
266- "owner": "babashka",
267- "repo": "fs",
268- "rev": "5b273b8a943a622593fbc85fca6761c5a39d6d66",
269- "type": "github"
270- }
271- },
272- "vendor-grenadine": {
273- "flake": false,
274- "locked": {
275- "lastModified": 1787000287,
276- "narHash": "sha256-l1eO8B3F8GkgJP+OOzz2hFV0aCDox8dQI0jtiGdGEFo=",
277- "owner": "clojurestar",
278- "repo": "grenadine",
279- "rev": "77992327c0b220c186ece61d74f7eb570978a6f5",
280- "type": "github"
281- },
282- "original": {
283- "owner": "clojurestar",
284- "repo": "grenadine",
285- "rev": "77992327c0b220c186ece61d74f7eb570978a6f5",
286- "type": "github"
287- }
288- },
289- "vendor-irregex": {
290- "flake": false,
291- "locked": {
292- "lastModified": 1770512408,
293- "narHash": "sha256-eii7VP3sJuirqxz3SbUorN/Xe+USan6AEjBI1qXt36c=",
294- "owner": "ashinn",
295- "repo": "irregex",
296- "rev": "c948a704fc732914a243c1643bfe359913d11c7b",
297- "type": "github"
298- },
299- "original": {
300- "owner": "ashinn",
301- "repo": "irregex",
302- "rev": "c948a704fc732914a243c1643bfe359913d11c7b",
303- "type": "github"
304- }
305- },
306- "vendor-process": {
307- "flake": false,
308- "locked": {
309- "lastModified": 1786767494,
310- "narHash": "sha256-d9ei6RCU9cUoq9+108jKIsaL04/ODRzVdrMueDMnRPo=",
311- "owner": "jolt-lang",
312- "repo": "process",
313- "rev": "7d881f3a3ec6d273cf014c8172dea037abb31da3",
314- "type": "github"
315- },
316- "original": {
317- "owner": "jolt-lang",
318- "repo": "process",
319- "rev": "7d881f3a3ec6d273cf014c8172dea037abb31da3",
320- "type": "github"
321- }
322- },
323- "vendor-sci": {
324- "flake": false,
325- "locked": {
326- "lastModified": 1781990664,
327- "narHash": "sha256-nBpQg7IkxmnfkOGpXEdloPbvLpEs0sdE79O/vhygzDo=",
328- "owner": "borkdude",
329- "repo": "sci",
330- "rev": "32d62a5136ad3dc148588752f5bcc4cc30b14752",
331- "type": "github"
332- },
333- "original": {
334- "owner": "borkdude",
335- "repo": "sci",
336- "rev": "32d62a5136ad3dc148588752f5bcc4cc30b14752",
337- "type": "github"
338- }
33978 }
34079 },
34180 "root": "root",
@@ -1,20 +1,5 @@
1 {1 {
2 "nodes": {2 "nodes": {
3- "crane": {
4- "locked": {
5- "lastModified": 1788465171,
6- "narHash": "sha256-Y1/TTVXjYXGF068IThQH9fPSZ0SIE74PABlUxnWTUH0=",
7- "owner": "ipetkov",
8- "repo": "crane",
9- "rev": "eb35abda9f232cc6610b1d1e3200d15c49b7ac54",
10- "type": "github"
11- },
12- "original": {
13- "owner": "ipetkov",
14- "repo": "crane",
15- "type": "github"
16- }
17- },
18 "flake-utils": {3 "flake-utils": {
19 "inputs": {4 "inputs": {
20 "systems": "systems"5 "systems": "systems"
@@ -33,114 +18,9 @@
33 "type": "github"18 "type": "github"
34 }19 }
35 },20 },
36- "flake-utils_2": {
37- "inputs": {
38- "systems": "systems_2"
39- },
40- "locked": {
41- "lastModified": 1731533236,
42- "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
43- "owner": "numtide",
44- "repo": "flake-utils",
45- "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
46- "type": "github"
47- },
48- "original": {
49- "owner": "numtide",
50- "repo": "flake-utils",
51- "type": "github"
52- }
53- },
54- "glimmer": {
55- "flake": false,
56- "locked": {
57- "lastModified": 1788135429,
58- "narHash": "sha256-20W4kmcL05O7V7EQKXuH+kVBpBtQZhhj5yTm9QqxQss=",
59- "ref": "refs/heads/main",
60- "rev": "399df371c790d690fb6e4560c3d4d7f838502857",
61- "revCount": 32,
62- "type": "git",
63- "url": "https://gitlab.com/nandithebull/glimmer"
64- },
65- "original": {
66- "rev": "399df371c790d690fb6e4560c3d4d7f838502857",
67- "type": "git",
68- "url": "https://gitlab.com/nandithebull/glimmer"
69- }
70- },
71- "jolt-native": {
72- "inputs": {
73- "crane": "crane",
74- "flake-utils": "flake-utils",
75- "nixpkgs": [
76- "nixpkgs"
77- ],
78- "rust-overlay": "rust-overlay"
79- },
80- "locked": {
81- "lastModified": 1789236164,
82- "narHash": "sha256-Bb8AYcr2XTj9KCl7QsIT6eTEo/WfQ5DQKm+9VSEnG4o=",
83- "ref": "refs/heads/main",
84- "rev": "8e8cd5192dc161b423c0ee5dd41a7a058b24b409",
85- "revCount": 117,
86- "type": "git",
87- "url": "https://gitlab.com/nandithebull/jolt-native"
88- },
89- "original": {
90- "rev": "8e8cd5192dc161b423c0ee5dd41a7a058b24b409",
91- "type": "git",
92- "url": "https://gitlab.com/nandithebull/jolt-native"
93- }
94- },
95- "jolt-src": {
96- "inputs": {
97- "makes": "makes",
98- "nixpkgs": [
99- "nixpkgs"
100- ],
101- "vendor-clojure-test-suite": "vendor-clojure-test-suite",
102- "vendor-fs": "vendor-fs",
103- "vendor-grenadine": "vendor-grenadine",
104- "vendor-irregex": "vendor-irregex",
105- "vendor-process": "vendor-process",
106- "vendor-sci": "vendor-sci"
107- },
108- "locked": {
109- "lastModified": 1788673555,
110- "narHash": "sha256-QMHvZyLFzKCQsEqsBljLksrdxMc4g5ncBExJCVUoxzA=",
111- "ref": "refs/heads/main",
112- "rev": "b7b738b37b16532be2f539c22367bdfc3f4d708d",
113- "revCount": 3026,
114- "submodules": true,
115- "type": "git",
116- "url": "https://gitlab.com/nandithebull/jolt"
117- },
118- "original": {
119- "submodules": true,
120- "type": "git",
121- "url": "https://gitlab.com/nandithebull/jolt"
122- }
123- },
124- "makes": {
125- "flake": false,
126- "locked": {
127- "lastModified": 1785480273,
128- "narHash": "sha256-/g8O4PW0v3NNChKyrWw/EqpEAUCKiI9/KFE2EiXoUlA=",
129- "owner": "makeplus",
130- "repo": "makes",
131- "rev": "d14cb578c2f04e6f9d00e01c7c4e416a9baf94e9",
132- "type": "github"
133- },
134- "original": {
135- "owner": "makeplus",
136- "repo": "makes",
137- "rev": "d14cb578c2f04e6f9d00e01c7c4e416a9baf94e9",
138- "type": "github"
139- }
140- },
141 "nixgl": {21 "nixgl": {
142 "inputs": {22 "inputs": {
143- "flake-utils": "flake-utils_2",23+ "flake-utils": "flake-utils",
144 "nixpkgs": [24 "nixpkgs": [
145 "nixpkgs"25 "nixpkgs"
146 ]26 ]
@@ -177,34 +57,10 @@
177 },57 },
178 "root": {58 "root": {
179 "inputs": {59 "inputs": {
180- "glimmer": "glimmer",
181- "jolt-native": "jolt-native",
182- "jolt-src": "jolt-src",
183 "nixgl": "nixgl",60 "nixgl": "nixgl",
184 "nixpkgs": "nixpkgs"61 "nixpkgs": "nixpkgs"
185 }62 }
186 },63 },
187- "rust-overlay": {
188- "inputs": {
189- "nixpkgs": [
190- "jolt-native",
191- "nixpkgs"
192- ]
193- },
194- "locked": {
195- "lastModified": 1788591095,
196- "narHash": "sha256-Vh+BeLWfbTT9AecazIsQ/Tkg/RzJeX3lEduANf256WA=",
197- "owner": "oxalica",
198- "repo": "rust-overlay",
199- "rev": "c361047d3a538f547f1617bb6b410411929ac9cc",
200- "type": "github"
201- },
202- "original": {
203- "owner": "oxalica",
204- "repo": "rust-overlay",
205- "type": "github"
206- }
207- },
208 "systems": {64 "systems": {
209 "locked": {65 "locked": {
210 "lastModified": 1681028828,66 "lastModified": 1681028828,
@@ -219,123 +75,6 @@
219 "repo": "default",75 "repo": "default",
220 "type": "github"76 "type": "github"
221 }77 }
222- },
223- "systems_2": {
224- "locked": {
225- "lastModified": 1681028828,
226- "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
227- "owner": "nix-systems",
228- "repo": "default",
229- "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
230- "type": "github"
231- },
232- "original": {
233- "owner": "nix-systems",
234- "repo": "default",
235- "type": "github"
236- }
237- },
238- "vendor-clojure-test-suite": {
239- "flake": false,
240- "locked": {
241- "lastModified": 1782670040,
242- "narHash": "sha256-NzNoVBdImoxriFTD+qr42YjDfnBtUG1SG/K5VeyviXQ=",
243- "owner": "jank-lang",
244- "repo": "clojure-test-suite",
245- "rev": "489b6743e8421687ef96cec557830acf258d1886",
246- "type": "github"
247- },
248- "original": {
249- "owner": "jank-lang",
250- "repo": "clojure-test-suite",
251- "rev": "489b6743e8421687ef96cec557830acf258d1886",
252- "type": "github"
253- }
254- },
255- "vendor-fs": {
256- "flake": false,
257- "locked": {
258- "lastModified": 1783722973,
259- "narHash": "sha256-cMUQmVOR6zxd96ndnQ9UXkit9Nt39eZzfI9HmQRsLtE=",
260- "owner": "babashka",
261- "repo": "fs",
262- "rev": "5b273b8a943a622593fbc85fca6761c5a39d6d66",
263- "type": "github"
264- },
265- "original": {
266- "owner": "babashka",
267- "repo": "fs",
268- "rev": "5b273b8a943a622593fbc85fca6761c5a39d6d66",
269- "type": "github"
270- }
271- },
272- "vendor-grenadine": {
273- "flake": false,
274- "locked": {
275- "lastModified": 1787000287,
276- "narHash": "sha256-l1eO8B3F8GkgJP+OOzz2hFV0aCDox8dQI0jtiGdGEFo=",
277- "owner": "clojurestar",
278- "repo": "grenadine",
279- "rev": "77992327c0b220c186ece61d74f7eb570978a6f5",
280- "type": "github"
281- },
282- "original": {
283- "owner": "clojurestar",
284- "repo": "grenadine",
285- "rev": "77992327c0b220c186ece61d74f7eb570978a6f5",
286- "type": "github"
287- }
288- },
289- "vendor-irregex": {
290- "flake": false,
291- "locked": {
292- "lastModified": 1770512408,
293- "narHash": "sha256-eii7VP3sJuirqxz3SbUorN/Xe+USan6AEjBI1qXt36c=",
294- "owner": "ashinn",
295- "repo": "irregex",
296- "rev": "c948a704fc732914a243c1643bfe359913d11c7b",
297- "type": "github"
298- },
299- "original": {
300- "owner": "ashinn",
301- "repo": "irregex",
302- "rev": "c948a704fc732914a243c1643bfe359913d11c7b",
303- "type": "github"
304- }
305- },
306- "vendor-process": {
307- "flake": false,
308- "locked": {
309- "lastModified": 1786767494,
310- "narHash": "sha256-d9ei6RCU9cUoq9+108jKIsaL04/ODRzVdrMueDMnRPo=",
311- "owner": "jolt-lang",
312- "repo": "process",
313- "rev": "7d881f3a3ec6d273cf014c8172dea037abb31da3",
314- "type": "github"
315- },
316- "original": {
317- "owner": "jolt-lang",
318- "repo": "process",
319- "rev": "7d881f3a3ec6d273cf014c8172dea037abb31da3",
320- "type": "github"
321- }
322- },
323- "vendor-sci": {
324- "flake": false,
325- "locked": {
326- "lastModified": 1781990664,
327- "narHash": "sha256-nBpQg7IkxmnfkOGpXEdloPbvLpEs0sdE79O/vhygzDo=",
328- "owner": "borkdude",
329- "repo": "sci",
330- "rev": "32d62a5136ad3dc148588752f5bcc4cc30b14752",
331- "type": "github"
332- },
333- "original": {
334- "owner": "borkdude",
335- "repo": "sci",
336- "rev": "32d62a5136ad3dc148588752f5bcc4cc30b14752",
337- "type": "github"
338- }
339 }78 }
340 },79 },
341 "root": "root",80 "root": "root",
modified flake.nix +46 -501
@@ -1,70 +1,20 @@
11 {
2- # frq is Jolt source, so "building" it is three things, not one:
2+ # frq is a Flutter app whose source is ClojureDart, so what this flake
3+ # provides is toolchains rather than a built program: the `flutter` shell
4+ # that `just apk` compiles in, the `flutter-desktop` shell for the Linux
5+ # target, and the Android SDK the first of those copies somewhere writable.
36 #
4- # jolt the runtime that reads it (github:jolt-lang/jolt)
5- # jolt-native libvidya and libjoltmoq, in Rust (gitlab:nandithebull/jolt-native)
6- # frq this tree, with its deps resolved to store paths
7- #
8- # Jolt resolves deps.edn by running git at startup, which a build sandbox has
9- # no network for — so every dep is fetched by Nix instead and handed back as
10- # a :local/root through -Sdeps.
11- #
12- # nix build .#frq && ./result/bin/frq
7+ # nix develop .#flutter-desktop --command just flutter-desktop run
138 #
149 # On a machine that is not NixOS the GL driver is the host's and the loader
1510 # will not find it, so the window never opens ("GL display: argument does not
16- # name a valid config"). The launcher handles that itself: off NixOS it hands
17- # the process to nixGL, which puts the host's driver ahead of the store's.
18- # Nothing extra to type, and a distrobox/container Arch is the same case as
19- # a bare one.
20- description = "frq a freeq client in jolt";
11+ # name a valid config"). The recipes handle that themselves: off NixOS they
12+ # hand the process to nixGL, which puts the host's driver ahead of the
13+ # store's. A distrobox/container Arch is the same case as a bare one.
14+ description = "frq a freeq client in Flutter";
2115
2216 inputs = {
2317 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
24- # `git+https` with `?submodules=1` rather than the github scheme: Jolt's
25- # own flake declares `self.submodules`, which this Nix rejects when the
26- # flake is fetched as `github:`. Its outputs are not what we take — the
27- # runtime is built here, by joltFrom — but it is a flake all the same, so
28- # its own inputs are locked with ours rather than left to float, and
29- # `vendor/` comes along as the submodule the build needs.
30- #
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.
35- jolt-src = {
36- url = "git+https://gitlab.com/nandithebull/jolt?submodules=1";
37- inputs.nixpkgs.follows = "nixpkgs";
38- };
39-
40- # The source half of jolt-native: the Jolt code under glimmer-backends/ that
41- # binds the native objects, and the flake that builds them. This input is
42- # what `just cosmic run` builds against.
43- #
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.
47- #
48- # Pinned all the same, and pinned to a rev, because an
49- # unpinned `main` is a build whose native half is free to sit at a
50- # different commit from the tree that talks to it. It did, and what the
51- # drift cost was silence: the Jolt half sent a reaction pill's hover card
52- # to a libvidya with no handler for one, and the pill said nothing.
53- # It also carries the terminal backend — crates/jolt-tui, the same tree ABI
54- # over a grid of cells, and jolt/glimmer-tui beside glimmer-vidya. That was
55- # a second input at a second rev while it lived on a branch, which is the
56- # drift this comment warns about wearing a different hat: one input, and
57- # the window and the terminal are the same library either way.
58- jolt-native = {
59- url = "git+https://gitlab.com/nandithebull/jolt-native?rev=8e8cd5192dc161b423c0ee5dd41a7a058b24b409";
60- inputs.nixpkgs.follows = "nixpkgs";
61- };
62-
63- # The sha deps.edn pins, on the fork with the reconciler fixes.
64- glimmer = {
65- url = "git+https://gitlab.com/nandithebull/glimmer?rev=399df371c790d690fb6e4560c3d4d7f838502857";
66- flake = false;
67- };
6818
6919 # Only ever used off NixOS, to put the host GL driver on the loader path.
7020 nixgl = {
@@ -73,7 +23,7 @@
7323 };
7424 };
7525
76- outputs = { self, nixpkgs, jolt-src, jolt-native, glimmer, nixgl }:
26+ outputs = { self, nixpkgs, nixgl }:
7727 let
7828 systems = [ "x86_64-linux" "aarch64-linux" ];
7929 forEachSystem = f:
@@ -130,339 +80,26 @@
13080 includeNDK = false;
13181 }).androidsdk;
13282
133- # egui reaches for these with dlopen rather than linking them, so being
134- # in the cdylib's buildInputs is not enough — whatever starts frq has to
135- # put them on the loader path itself. Without libx11 here, vidya reports
136- # "X11 unavailable", falls back to Wayland, and winit refuses to build a
137- # second event loop after the failed first one.
138- #
139- # Out here rather than beside the package that first needed them: the
140- # dev shell starts frq too, on this tree's source rather than the store's
141- # copy of it, and a second copy of this list is a second chance for the
142- # two ways of running to disagree about what the window needs.
143- runtimeLibsFor = pkgs: with pkgs; [
144- libGL
145- libxkbcommon
146- wayland
147- libx11
148- libxcursor
149- libxi
150- libxrandr
151- vulkan-loader
152- ];
15383 in
15484 {
15585 packages = forEachSystem (pkgs:
15686 let
15787 inherit (pkgs) lib;
158-
159- nixGL = nixGLFor pkgs;
160-
161- # libvidya (the retained-tree ABI glimmer-vidya binds, on egui),
162- # libjolttui (the same tree over a grid of cells) and libjoltmoq (the
163- # AV media plane) — one workspace, three cdylibs, taken from
164- # jolt-native's own flake rather than rebuilt here.
165- #
166- # This used to be a rustPlatform.buildRustPackage over the same
167- # source, which meant restating upstream's build: the seven git deps
168- # hashed by hand in `cargoLock.outputHashes` and re-hashed whenever
169- # its Cargo.lock moved, the linuxHeaders path v4l2r's bindgen wants,
170- # and a postPatch dropping the .cargo/config.toml that pointed the
171- # build at DotSlash. Upstream's flake says all of that itself now,
172- # and says it once. It also builds cpal with the `pipewire` feature,
173- # which the restatement did not — so device names in a call are
174- # PipeWire's rather than raw ALSA PCMs.
175- # libjolttui only. Not libjoltmoq, whose job `frq.av.plane` does
176- # now, and no longer libvidya either: the window is jvui on SDL,
177- # so the only object left out of that Cargo workspace is the
178- # terminal backend, and only `just tui` loads it.
179- #
180- # This makes the closure smaller and the APK smaller. It does NOT
181- # make the build shorter, and it is worth being exact about why:
182- # jolt-native compiles its external crates ONCE, in a
183- # `buildDepsOnly` derivation shared by all three objects, so
184- # asking for two of them still builds every dependency the third
185- # has — the 440 crates that are jolt-moq's alone. Getting those
186- # out of the build is a change in jolt-native, not here: either
187- # jolt-moq leaves that workspace, or its deps artifact stops
188- # being workspace-wide.
189- native =
190- let np = jolt-native.packages.${pkgs.stdenv.hostPlatform.system};
191- in pkgs.symlinkJoin {
192- name = "jolt-native-ui";
193- # Both backends there are. libjoltcosmic is the window —
194- # libcosmic behind the same retained-tree ABI — and libjolttui
195- # is the terminal. Neither is libvidya and neither is jvui:
196- # those were experiments and are gone from this tree entirely.
197- paths = [ np.libjolttui np.libjoltcosmic ];
198- };
199-
200- # libmoq_ffi — MoQ over QUIC behind UniFFI's C ABI, FETCHED rather
201- # than built. This is the object `frq.moq.raw` is generated from.
202- #
203- # Fetched because building it is the thing this whole exercise is
204- # about: moq-ffi pulls moq-native, iroh, quinn, rustls and aws-lc-sys
205- # behind it, which is 440 crates that nothing else in this tree
206- # needs. Upstream already publishes the object for both Linux
207- # architectures, so we take those bytes.
208- #
209- # Pinned to a release and to a hash, and the hashes below are
210- # upstream's own published .sha256 files rather than ones observed
211- # here — a `nix-prefetch` of whatever the URL serves today would
212- # record that it downloaded something, not that it downloaded the
213- # right thing.
214- #
215- # WHAT THIS BUILD IS NOT: moq-ffi's `audio` and `video` features are
216- # on by default upstream and are OFF in these artifacts, so there is
217- # no publish_audio/publish_video and no moqaudio*/moqvideo* here —
218- # 206 functions where the Apple artifact has 230. That is why the
219- # bindings are generated from the object (`just gen-moq`) and not
220- # from the C header the release ships, which describes the Apple one.
221- moqFfi =
222- let
223- version = "0.3.17";
224- target = {
225- "x86_64-linux" = "x86_64-unknown-linux-gnu";
226- "aarch64-linux" = "aarch64-unknown-linux-gnu";
227- }.${pkgs.stdenv.hostPlatform.system};
228- hash = {
229- "x86_64-linux" = "sha256-dzQXpV4JgdtD+g33WX51FFAQdfCUXkNsx1xPbobPfUI=";
230- "aarch64-linux" = "sha256-PdzRwbJFqOZWRgI0HHX2XUH+Ljh4V3jvQ9asfvCuIPA=";
231- }.${pkgs.stdenv.hostPlatform.system};
232- in
233- pkgs.stdenv.mkDerivation {
234- pname = "libmoq-ffi";
235- inherit version;
236- src = pkgs.fetchurl {
237- url = "https://github.com/kixelated/moq/releases/download/moq-ffi-v${version}/moq-ffi-${version}-${target}-libmoq_ffi.so";
238- inherit hash;
239- };
240- dontUnpack = true;
241- # It carries no RUNPATH and needs libgcc_s, libm and libc — the
242- # host's on an ordinary distro, and nothing at all on NixOS
243- # unless they are bound here.
244- nativeBuildInputs = [ pkgs.autoPatchelfHook ];
245- buildInputs = [ pkgs.stdenv.cc.cc.lib ];
246- installPhase = ''
247- mkdir -p $out/lib
248- cp $src $out/lib/libmoq_ffi.so
249- chmod +w $out/lib/libmoq_ffi.so
250- '';
251- };
252-
253- # One directory for the loader to look in. jolt resolves every
254- # :jolt/native name against JOLT_NATIVE_LIB, and the objects now come
255- # from two places — jolt-native's flake, and the moq-ffi release — so
256- # they are joined rather than the path being made a list, which the
257- # loader does not take.
258- # The C codecs, from nixpkgs. libmoq_ffi carries the transport and
259- # nothing else — moq-ffi's `audio` and `video` features would have
260- # brought Opus and H.264 with them, at the price of compiling a
261- # 1062-crate workspace — so the codecs are linked here instead,
262- # where they have always lived.
263- #
264- # Named in :jolt/native, so the loader resolves them the same way it
265- # resolves libvidya: by name, out of one directory.
266- # A flat C face for openh264, because openh264 has none. Its
267- # `ISVCEncoder` is `const ISVCEncoderVtbl*` — every method is a
268- # function pointer in a vtable — and jolt.ffi cannot call one: Chez
269- # fixes a foreign procedure's types when it compiles it, and the
270- # target must be a literal C symbol name. So the vtable is walked in
271- # c/frq_h264.c and jolt binds the five plain symbols it exports.
272- #
273- # One translation unit against a library nixpkgs already has. It is
274- # a calling convention adapter, not a second media plane, and the
275- # distinction from the moq-ffi build it replaces is the whole point:
276- # this compiles one .c file, not a 1062-crate workspace.
277- frqH264 = pkgs.stdenv.mkDerivation {
278- pname = "frq-h264";
279- version = "0.1";
280- src = ./c;
281- nativeBuildInputs = [ pkgs.pkg-config ];
282- buildInputs = [ pkgs.openh264 ];
283- buildPhase = ''
284- $CC -O2 -fPIC -shared frq_h264.c -o libfrqh264.so \
285- $(pkg-config --cflags --libs openh264)
286- '';
287- installPhase = ''
288- mkdir -p $out/lib && cp libfrqh264.so $out/lib/
289- '';
290- };
291-
292- # openh264 is here for frqH264's DT_NEEDED; alsa-lib for capture
293- # and playback. V4L2 needs nothing: it is ioctls against libc and
294- # the kernel, so there is no library to name.
295- # No SDL any more: it was jvui's, declared in jvui's own
296- # :jolt/native and dlopened by soname. libcosmic paints through wgpu
297- # and takes what it needs from `runtimeLibs` instead.
298- codecs = [ pkgs.libopus pkgs.openh264 frqH264 pkgs.alsa-lib ];
299-
300- # ALSA's PipeWire plugin, which is how `default` resolves to
301- # anything on a machine running PipeWire — and every machine frq
302- # targets does. Without it alsa-lib fails to dlopen
303- # libasound_module_pcm_pipewire.so and the only devices that open
304- # are raw hardware ones, which PipeWire is already holding.
305- #
306- # An environment variable rather than a library in the join:
307- # alsa-lib looks plugins up by directory, not by soname.
308- alsaPluginDir = "${pkgs.pipewire}/lib/alsa-lib";
309-
310- nativeAll = pkgs.symlinkJoin {
311- name = "frq-native";
312- paths = [ native moqFfi ] ++ codecs;
313- };
314-
315- # Jolt itself: Clojure on Chez, built the way its own flake builds it.
316- #
317- # Still a function taking its source, though there is only one of
318- # them now: the second was the Bionic-addrinfo fork the APK's boot
319- # image carried, and there is no jolt APK any more — the phone is
320- # ClojureDart and Flutter, and jolt does not run there at all.
321- joltFrom = src: pkgs.stdenv.mkDerivation {
322- pname = "jolt";
323- version = "dev";
324- inherit src;
325-
326- strictDeps = true;
327- nativeBuildInputs = with pkgs; [ chez makeWrapper pkg-config xxd ];
328- buildInputs = with pkgs; [ lz4 zlib ncurses openssl libuuid ];
329-
330- JOLT_VERSION = "dev";
331- dontConfigure = true;
332-
333- buildPhase = ''
334- runHook preBuild
335- scheme --script host/chez/build-jolt.ss release target/release/jolt
336- runHook postBuild
337- '';
338-
339- installPhase = ''
340- runHook preInstall
341- mkdir -p "$out/bin"
342- install -m755 target/release/jolt "$out/bin/jolt"
343- runHook postInstall
344- '';
345-
346- # jolt.deps shells out to git and unzip, and jolt.mvn-http dlopens
347- # OpenSSL through the JOLT_OPENSSL_LIBDIR seam. gitMinimal rather
348- # than git: all jolt.deps asks for is clone/fetch/rev-parse, and
349- # the full package carries Perl and Python for the subcommands
350- # written in them — a quarter of a gigabyte for git-send-email.
351- #
352- # TZDIR so a zone *name* resolves wherever this runs: frq.clock
353- # hands one to tzset, and glibc then looks for the tzfile under
354- # /usr/share/zoneinfo unless told otherwise — which a NixOS host
355- # does not have. The store's own tzdata is there on both kinds of
356- # machine. --set-default, so a TZDIR the user set still wins.
357- postFixup = ''
358- wrapProgram "$out/bin/jolt" \
359- --prefix PATH : "${pkgs.lib.makeBinPath [ pkgs.gitMinimal pkgs.unzip ]}" \
360- --set-default JOLT_OPENSSL_LIBDIR "${pkgs.lib.makeLibraryPath [ pkgs.openssl ]}" \
361- --set-default TZDIR "${pkgs.tzdata}/share/zoneinfo" \
362- --set-default SSL_CERT_FILE "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
363- '';
364- };
365-
366- joltRuntime = joltFrom jolt-src;
367-
368- # The backends' Clojure halves, which live inside the jolt-native
369- # checkout beside the objects they bind. Their own deps.edn asks for
370- # glimmer by git — the top-level override below answers for both.
371- glimmerCosmic = "${jolt-native}/glimmer-backends/glimmer-cosmic";
372- glimmerTui = "${jolt-native}/glimmer-backends/glimmer-tui";
373-
374- runtimeLibs = runtimeLibsFor pkgs;
375-
376- # The project as jolt sees it: source, deps.edn, nothing else.
377- frqSource = pkgs.runCommand "frq-source" { } ''
378- mkdir -p "$out"
379- cp -r ${self}/common ${self}/src ${self}/deps.edn "$out/"
380- '';
381-
382- # Jolt resolves deps.edn from the working directory, so the launcher
383- # runs from the store copy. Its .jolt/cpcache write lands on a
384- # read-only directory and jolt treats that as a quiet cache miss, so
385- # the only cost is re-resolving the (already local) graph per start.
386- frqScript = pkgs.writeShellScript "frq" ''
387- export LD_LIBRARY_PATH="${nativeAll}/lib:${lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
388- export ALSA_PLUGIN_DIR="${alsaPluginDir}"
389- cd ${frqSource}
390-
391- # On NixOS the store's Mesa is the system's and the window opens.
392- # Anywhere else the real driver is the host's, so defer to nixGL
393- # it prepends the host driver, which has to win over ours.
394- runner=""
395- [ -e /run/current-system ] || runner="${nixGL}/bin/nixGLIntel"
396-
397- exec ''${runner} ${joltRuntime}/bin/jolt \
398- -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-cosmic {:local/root "${glimmerCosmic}"}}}' \
399- -m frq.cosmic "$@"
400- '';
401-
402- # The same source, the other backend. No GL, no nixGL and no X11 —
403- # a terminal is the one surface that needs nothing from the host but
404- # a terminal, which is the reason this output exists.
405- tuiScript = pkgs.writeShellScript "frq-tui" ''
406- export LD_LIBRARY_PATH="${nativeAll}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
407- export ALSA_PLUGIN_DIR="${alsaPluginDir}"
408- cd ${frqSource}
409-
410- exec ${joltRuntime}/bin/jolt \
411- -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-tui {:local/root "${glimmerTui}"}}}' \
412- -m frq.tui "$@"
413- '';
414-
415- tui = pkgs.runCommand "frq-tui-0.1.0"
416- {
417- meta = {
418- description = "frq's screens in a terminal";
419- mainProgram = "frq-tui";
420- platforms = systems;
421- };
422- }
423- ''
424- mkdir -p "$out/bin"
425- ln -s ${tuiScript} "$out/bin/frq-tui"
426- '';
427-
428- frq = pkgs.runCommand "frq-0.1.0"
429- {
430- meta = {
431- description = "A freeq client in jolt";
432- mainProgram = "frq";
433- platforms = systems;
434- };
435- }
436- ''
437- mkdir -p "$out/bin"
438- ln -s ${frqScript} "$out/bin/frq"
439- '';
44088 in
44189 {
442- inherit native moqFfi frqH264 nativeAll frq;
443- inherit (pkgs) pipewire;
444- inherit tui;
445- jolt = joltRuntime;
446- default = frq;
447-
44890 # The Android SDK `just apk` copies into flutter/.home. A package
44991 # rather than something the recipe evaluates inline, so that
45092 # `nix build .#android-sdk` is how you pre-warm it and `nix flake
45193 # show` admits it exists.
45294 android-sdk = androidSdkFor pkgs.stdenv.hostPlatform.system;
45395
454- # There were two `appimage` outputs here — this GUI's and Flutter's —
455- # and what they were for was a host without Nix. They squashed the
456- # whole closure into one runnable file, Mesa included, and the Mesa
457- # was not waste: off NixOS the launcher goes through nixGL, which
458- # needs a store Mesa to put the host's driver in front of.
459- #
460- # `tools/build-desktop.sh` answers that now, and answers it without
461- # a closure to carry: jolt ships as one static binary, the backends
462- # as jolt-native's `portable` tarball, and the GL driver is simply
463- # the host's. No Mesa to get in front of, so no nixGL, so nothing to
464- # squash. These outputs were the last thing evaluating nix-appimage,
465- # which is why that input is gone too.
96+ # There were `appimage` outputs here, and what they were for was a
97+ # host without Nix: they squashed the whole closure into one
98+ # runnable file, Mesa included, and the Mesa was not waste — off
99+ # NixOS the launcher goes through nixGL, which needs a store Mesa to
100+ # put the host's driver in front of. Nothing asks for that shape any
101+ # more, and they were the last thing evaluating nix-appimage, which
102+ # is why that input is gone too.
466103
467104 # Everything `clojure -M:cljd compile` would otherwise reach the
468105 # network for, fetched once and hashed.
@@ -659,9 +296,8 @@
659296 # The Flutter desktop GUI, built rather than run out of the tree.
660297 #
661298 # `just flutter-desktop` is the working-tree loop and this is its
662- # opposite number, the same way `nix build .#frq` is `just cosmic
663- # run`'s: the source is the flake's, the output is a store path, and the build is
664- # a sandbox with no network. It is the first thing here that builds
299+ # opposite number: the source is the flake's, the output is a store
300+ # path, and the build is a sandbox with no network. It is the first thing here that builds
665301 # purely — the APK cannot, because Gradle fetches as it goes.
666302 #
667303 # Two stages, because the Dart does not exist until ClojureDart writes
@@ -690,7 +326,7 @@
690326 # is the screens its deps.edn puts on the classpath. The root is
691327 # still the source root because of that `../common`, but letting
692328 # the *whole* root in means every file in the repo is an input —
693- # so editing flake.nix, or CLAUDE.md, or the jolt half in `src/`,
329+ # so editing flake.nix or CLAUDE.md
694330 # invalidated the entire Dart compile and paid ten minutes for a
695331 # change the Flutter build cannot even see.
696332 #
@@ -709,7 +345,7 @@
709345 # and would make every one of them a new store path.
710346 && !(builtins.elem (baseNameOf path) [
711347 "build" ".home" ".clojuredart" ".cpcache" "cljd-out"
712- ".dart_tool" "result" ".git" ".jolt" "buck-out"
348+ ".dart_tool" "result" ".git" "buck-out"
713349 ]);
714350 };
715351 sourceRoot = "source/flutter";
@@ -818,111 +454,34 @@
818454 '';
819455 });
820456
821- # Where `just cosmic run` runs, and because entering it realises what it
822- # names what builds the half of frq that is not this working tree.
823- #
824- # The two halves, and the split is the whole point of the shell. The frq
825- # source is the files on disk, uncommitted edits and all. Everything
826- # under it jolt, glimmer, glimmer-vidya, both native objects is the
827- # flake's, at the revs flake.lock names, so a run says what it ran
828- # against and both halves of glimmer-vidya move together. That is the
829- # drift the `jolt-native` input's comment is about, and a pin frq can
830- # answer for is worth more here than the convenience of a checkout.
831- #
832- # `native` is jolt-native's own flake output. It was a buck2 graph when
833- # this comment was first written and a cargo build restated here when it
834- # was second: buck2 fetches its rustc, zig and every third-party crate as
835- # it goes and writes buck-out into the tree it builds, so a sandbox with
836- # no network and a read-only store was the one place it could not run.
837- # Upstream builds with nix now, so the thing its CI runs and the thing
838- # this shell hands a builder are the same derivation.
839- #
840- # Nothing here says "nixbuild", though: it is a plain derivation, and
841- # where it gets built is the machine's business. The `cosmic` recipe asks for
842- # the shell with --max-jobs 0, which is what sends it to the `builders`
843- # entry rather than compiling egui on a laptop.
844457 devShells = forEachSystem (pkgs:
845458 let
846459 inherit (pkgs) lib;
847- inherit (self.packages.${pkgs.stdenv.hostPlatform.system}) jolt native nativeAll;
848460 in
849461 {
850- default = pkgs.mkShellNoCC {
851- name = "frq";
852-
853- # jolt, because the runtime frq is run by should be the flake's
854- # too. nixGL for the same reason the launcher reaches for it see
855- # frqScript. just so the recipe runner comes from here too rather
856- # than the host `nix develop` and then `just cosmic run` is the
857- # whole of what a machine with nix needs.
858- packages = [ jolt pkgs.just (nixGLFor pkgs) ];
859-
860- # Read by the recipes rather than baked into a wrapper: the frq
861- # source `just cosmic run` runs is the working tree, so the
862- # launcher has to live in that tree and the shell has to hand it its answers.
863- # Naming these is also what makes the shell build them.
864- JOLT_NATIVE_LIB = "${nativeAll}/lib";
865- # Spelled out rather than shared with the packages block, which
866- # is a different `let`. See `alsaPluginDir` there for why.
867- ALSA_PLUGIN_DIR = "${pkgs.pipewire}/lib/alsa-lib";
868- GLIMMER_SRC = glimmer;
869- GLIMMER_COSMIC_SRC = "${jolt-native}/glimmer-backends/glimmer-cosmic";
870- GLIMMER_TUI_SRC = "${jolt-native}/glimmer-backends/glimmer-tui";
871- FRQ_LIB_PATH = lib.makeLibraryPath (runtimeLibsFor pkgs);
872- NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel";
873-
874- # A checkout of jolt-native in place of the pin, when one is NAMED.
875- #
876- # The pin is a rev on a server, so the loop for a change to a
877- # backend would be commit, push, re-pin, re-lock four steps and
878- # an upload for a line of Rust. Pointing FRQ_JOLT_NATIVE at a
879- # working copy makes the loop `cargo build` and `just tui`.
880- #
881- # This used to find that copy by itself ../jolt-native beside the
882- # checkout and that is gone, because a found tree is the wrong
883- # default twice over. It fired on a sibling nobody had asked about,
884- # so a shell could be running something other than the pin on the
885- # strength of a directory existing. And what it prepended was a raw
886- # `cargo build` artifact: no store RUNPATH, so its libc.so.6
887- # resolves to the host's, and the Nix-glibc jolt that dlopens it
888- # gets a second libc and fails. jolt reports that as "required
889- # native library ... not found", which names neither libc nor the
890- # tree the object came from. An override worth having is one you
891- # can see in the command you typed.
892- #
893- # So: named or nothing. A named tree is taken even unbuilt (with a
894- # word about what to run) naming it is asking for it and it has
895- # to carry the RUNPATH the store copy does, which in practice means
896- # building it under Nix.
897- #
898- # Only the sources and the objects a checkout actually builds move.
899- # Everything else on the library path libopus, libmoq_ffi, the
900- # ALSA plugins stays the pin's, since a checkout has no build of
901- # those to offer.
902- shellHook = ''
903- if [ -n "''${FRQ_JOLT_NATIVE:-}" ]; then
904- if [ -d "$FRQ_JOLT_NATIVE/crates/jolt-tui" ]; then
905- FRQ_JOLT_NATIVE="$(cd "$FRQ_JOLT_NATIVE" && pwd)"
906- export FRQ_JOLT_NATIVE
907- export GLIMMER_TUI_SRC="$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-tui"
908- export GLIMMER_COSMIC_SRC="$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-cosmic"
909- # First, so a cargo build wins over the pin's copy of the
910- # same object. The rest of the pin's lib directory is still
911- # behind it.
912- export JOLT_NATIVE_LIB="$FRQ_JOLT_NATIVE/target/release:$JOLT_NATIVE_LIB"
913- echo "frq: jolt-native from $FRQ_JOLT_NATIVE, not the pin (unset FRQ_JOLT_NATIVE for the pin)" >&2
914- if [ ! -e "$FRQ_JOLT_NATIVE/target/release/libjolttui.so" ]; then
915- echo "frq: no libjolttui.so there yet cargo build --release --features terminal -p jolt-tui" >&2
916- fi
917- else
918- echo "frq: FRQ_JOLT_NATIVE=$FRQ_JOLT_NATIVE is not a jolt-native checkout; using the pin" >&2
919- fi
920- fi
921- '';
462+ # Nim, for `nim/` the portable core as a native library. Just the
463+ # compiler: the core has no dependencies outside Nim's own standard
464+ # library, deliberately, because a dependency here is one that has
465+ # to cross-compile to every target the Dart side runs on.
466+ #
467+ # `nim c` shells out to a C compiler, so this is mkShell and not
468+ # mkShellNoCC: stdenv brings the one Nim will find.
469+ nim = pkgs.mkShell {
470+ name = "frq-nim";
471+ packages = [ pkgs.nim pkgs.just ];
472+
473+ # The recipe's re-entry test, the way FRQ_FLUTTER_DESKTOP is the
474+ # desktop one's.
475+ FRQ_NIM = "1";
922476 };
923477
924- # The APK toolchain, which the default shell deliberately does not
925- # carry: Flutter brings its own Dart, Gradle and a JDK's worth of
478+ # The APK toolchain. It is not in a default shell any more because
479+ # there is no default shell: what used to be one belonged to the
480+ # libcosmic frontend, and a Flutter build asks for a toolchain by
481+ # name.
482+ #
483+ # Flutter brings its own Dart, Gradle and a JDK's worth of
484+ # closure: Flutter brings its own Dart, Gradle and a JDK's worth of
926485 # closure, and a desktop build has no use for any of it.
927486 #
928487 # `just apk` used to name these as `nix shell nixpkgs#clojure
@@ -1005,10 +564,8 @@
1005564 # pure Dart over the XDG directories.
1006565 buildInputs = [ pkgs.gtk3 pkgs.glib ];
1007566
1008- # Same reason `just cosmic run` reaches for it: Flutter paints
1009- # through GL, and off NixOS the driver that can do that is the
1010- # host's, not the store's. The recipe reads this exactly as
1011- # `cosmic` does.
567+ # Flutter paints through GL, and off NixOS the driver that can do
568+ # that is the host's, not the store's.
1012569 NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel";
1013570
1014571 # The `flutter` shell's, deliberately the same one and for the
@@ -1020,8 +577,7 @@
1020577 # for the flake output itself.
1021578 FRQ_CLJD_DEPS = "${self.packages.${pkgs.stdenv.hostPlatform.system}.cljd-deps}";
1022579
1023- # The recipe's re-entry test, the way JOLT_NATIVE_LIB is the
1024- # default shell's. Nothing else sets it, so `just flutter-desktop`
580+ # The recipe's re-entry test. Nothing else sets it, so `just flutter-desktop`
1025581 # outside the shell re-enters and lands back on the same recipe
1026582 # no flag to forget, and no second code path for someone who runs
1027583 # `nix develop .#flutter-desktop --command just flutter-desktop`
@@ -1029,7 +585,6 @@
1029585 FRQ_FLUTTER_DESKTOP = "1";
1030586 };
1031587
1032- # The third frontend, and the first that is not a window: the same
1033588 # No `flutter-web` shell here any more. The web target was the one
1034589 # that needed nothing of the host -- no JDK and no Android SDK as
1035590 # the APK wants, no GTK and no C++ and no nixGL as the desktop one
@@ -1040,19 +595,9 @@
1040595 # `.modal/flutter-web/` runs that same script on a plain Debian
1041596 # image with no store to populate.
1042597 #
1043- # The other two shells stay. What they supply is a host toolchain,
598+ # The two shells above stay. What they supply is a host toolchain,
1044599 # which is exactly what nix is better at than a tarball.
1045600 });
1046601
1047- apps = forEachSystem (pkgs: {
1048- default = {
1049- type = "app";
1050- program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.frq}/bin/frq";
1051- };
1052- tui = {
1053- type = "app";
1054- program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.tui}/bin/frq-tui";
1055- };
1056- });
1057602 };
1058603 }
@@ -1,70 +1,20 @@
1 {1 {
2- # frq is Jolt source, so "building" it is three things, not one:2+ # frq is a Flutter app whose source is ClojureDart, so what this flake
3+ # provides is toolchains rather than a built program: the `flutter` shell
4+ # that `just apk` compiles in, the `flutter-desktop` shell for the Linux
5+ # target, and the Android SDK the first of those copies somewhere writable.
3 #6 #
4- # jolt the runtime that reads it (github:jolt-lang/jolt)7+ # nix develop .#flutter-desktop --command just flutter-desktop run
5- # jolt-native libvidya and libjoltmoq, in Rust (gitlab:nandithebull/jolt-native)
6- # frq this tree, with its deps resolved to store paths
7- #
8- # Jolt resolves deps.edn by running git at startup, which a build sandbox has
9- # no network for — so every dep is fetched by Nix instead and handed back as
10- # a :local/root through -Sdeps.
11- #
12- # nix build .#frq && ./result/bin/frq
13 #8 #
14 # On a machine that is not NixOS the GL driver is the host's and the loader9 # On a machine that is not NixOS the GL driver is the host's and the loader
15 # will not find it, so the window never opens ("GL display: argument does not10 # will not find it, so the window never opens ("GL display: argument does not
16- # name a valid config"). The launcher handles that itself: off NixOS it hands11+ # name a valid config"). The recipes handle that themselves: off NixOS they
17- # the process to nixGL, which puts the host's driver ahead of the store's.12+ # hand the process to nixGL, which puts the host's driver ahead of the
18- # Nothing extra to type, and a distrobox/container Arch is the same case as13+ # store's. A distrobox/container Arch is the same case as a bare one.
19- # a bare one.14+ description = "frq a freeq client in Flutter";
20- description = "frq a freeq client in jolt";
21 15
22 inputs = {16 inputs = {
23 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";17 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
24- # `git+https` with `?submodules=1` rather than the github scheme: Jolt's
25- # own flake declares `self.submodules`, which this Nix rejects when the
26- # flake is fetched as `github:`. Its outputs are not what we take — the
27- # runtime is built here, by joltFrom — but it is a flake all the same, so
28- # its own inputs are locked with ours rather than left to float, and
29- # `vendor/` comes along as the submodule the build needs.
30- #
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.
35- jolt-src = {
36- url = "git+https://gitlab.com/nandithebull/jolt?submodules=1";
37- inputs.nixpkgs.follows = "nixpkgs";
38- };
39-
40- # The source half of jolt-native: the Jolt code under glimmer-backends/ that
41- # binds the native objects, and the flake that builds them. This input is
42- # what `just cosmic run` builds against.
43- #
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.
47- #
48- # Pinned all the same, and pinned to a rev, because an
49- # unpinned `main` is a build whose native half is free to sit at a
50- # different commit from the tree that talks to it. It did, and what the
51- # drift cost was silence: the Jolt half sent a reaction pill's hover card
52- # to a libvidya with no handler for one, and the pill said nothing.
53- # It also carries the terminal backend — crates/jolt-tui, the same tree ABI
54- # over a grid of cells, and jolt/glimmer-tui beside glimmer-vidya. That was
55- # a second input at a second rev while it lived on a branch, which is the
56- # drift this comment warns about wearing a different hat: one input, and
57- # the window and the terminal are the same library either way.
58- jolt-native = {
59- url = "git+https://gitlab.com/nandithebull/jolt-native?rev=8e8cd5192dc161b423c0ee5dd41a7a058b24b409";
60- inputs.nixpkgs.follows = "nixpkgs";
61- };
62-
63- # The sha deps.edn pins, on the fork with the reconciler fixes.
64- glimmer = {
65- url = "git+https://gitlab.com/nandithebull/glimmer?rev=399df371c790d690fb6e4560c3d4d7f838502857";
66- flake = false;
67- };
68 18
69 # Only ever used off NixOS, to put the host GL driver on the loader path.19 # Only ever used off NixOS, to put the host GL driver on the loader path.
70 nixgl = {20 nixgl = {
@@ -73,7 +23,7 @@
73 };23 };
74 };24 };
75 25
76- outputs = { self, nixpkgs, jolt-src, jolt-native, glimmer, nixgl }:26+ outputs = { self, nixpkgs, nixgl }:
77 let27 let
78 systems = [ "x86_64-linux" "aarch64-linux" ];28 systems = [ "x86_64-linux" "aarch64-linux" ];
79 forEachSystem = f:29 forEachSystem = f:
@@ -130,339 +80,26 @@
130 includeNDK = false;80 includeNDK = false;
131 }).androidsdk;81 }).androidsdk;
132 82
133- # egui reaches for these with dlopen rather than linking them, so being
134- # in the cdylib's buildInputs is not enough — whatever starts frq has to
135- # put them on the loader path itself. Without libx11 here, vidya reports
136- # "X11 unavailable", falls back to Wayland, and winit refuses to build a
137- # second event loop after the failed first one.
138- #
139- # Out here rather than beside the package that first needed them: the
140- # dev shell starts frq too, on this tree's source rather than the store's
141- # copy of it, and a second copy of this list is a second chance for the
142- # two ways of running to disagree about what the window needs.
143- runtimeLibsFor = pkgs: with pkgs; [
144- libGL
145- libxkbcommon
146- wayland
147- libx11
148- libxcursor
149- libxi
150- libxrandr
151- vulkan-loader
152- ];
153 in83 in
154 {84 {
155 packages = forEachSystem (pkgs:85 packages = forEachSystem (pkgs:
156 let86 let
157 inherit (pkgs) lib;87 inherit (pkgs) lib;
158-
159- nixGL = nixGLFor pkgs;
160-
161- # libvidya (the retained-tree ABI glimmer-vidya binds, on egui),
162- # libjolttui (the same tree over a grid of cells) and libjoltmoq (the
163- # AV media plane) — one workspace, three cdylibs, taken from
164- # jolt-native's own flake rather than rebuilt here.
165- #
166- # This used to be a rustPlatform.buildRustPackage over the same
167- # source, which meant restating upstream's build: the seven git deps
168- # hashed by hand in `cargoLock.outputHashes` and re-hashed whenever
169- # its Cargo.lock moved, the linuxHeaders path v4l2r's bindgen wants,
170- # and a postPatch dropping the .cargo/config.toml that pointed the
171- # build at DotSlash. Upstream's flake says all of that itself now,
172- # and says it once. It also builds cpal with the `pipewire` feature,
173- # which the restatement did not — so device names in a call are
174- # PipeWire's rather than raw ALSA PCMs.
175- # libjolttui only. Not libjoltmoq, whose job `frq.av.plane` does
176- # now, and no longer libvidya either: the window is jvui on SDL,
177- # so the only object left out of that Cargo workspace is the
178- # terminal backend, and only `just tui` loads it.
179- #
180- # This makes the closure smaller and the APK smaller. It does NOT
181- # make the build shorter, and it is worth being exact about why:
182- # jolt-native compiles its external crates ONCE, in a
183- # `buildDepsOnly` derivation shared by all three objects, so
184- # asking for two of them still builds every dependency the third
185- # has — the 440 crates that are jolt-moq's alone. Getting those
186- # out of the build is a change in jolt-native, not here: either
187- # jolt-moq leaves that workspace, or its deps artifact stops
188- # being workspace-wide.
189- native =
190- let np = jolt-native.packages.${pkgs.stdenv.hostPlatform.system};
191- in pkgs.symlinkJoin {
192- name = "jolt-native-ui";
193- # Both backends there are. libjoltcosmic is the window —
194- # libcosmic behind the same retained-tree ABI — and libjolttui
195- # is the terminal. Neither is libvidya and neither is jvui:
196- # those were experiments and are gone from this tree entirely.
197- paths = [ np.libjolttui np.libjoltcosmic ];
198- };
199-
200- # libmoq_ffi — MoQ over QUIC behind UniFFI's C ABI, FETCHED rather
201- # than built. This is the object `frq.moq.raw` is generated from.
202- #
203- # Fetched because building it is the thing this whole exercise is
204- # about: moq-ffi pulls moq-native, iroh, quinn, rustls and aws-lc-sys
205- # behind it, which is 440 crates that nothing else in this tree
206- # needs. Upstream already publishes the object for both Linux
207- # architectures, so we take those bytes.
208- #
209- # Pinned to a release and to a hash, and the hashes below are
210- # upstream's own published .sha256 files rather than ones observed
211- # here — a `nix-prefetch` of whatever the URL serves today would
212- # record that it downloaded something, not that it downloaded the
213- # right thing.
214- #
215- # WHAT THIS BUILD IS NOT: moq-ffi's `audio` and `video` features are
216- # on by default upstream and are OFF in these artifacts, so there is
217- # no publish_audio/publish_video and no moqaudio*/moqvideo* here —
218- # 206 functions where the Apple artifact has 230. That is why the
219- # bindings are generated from the object (`just gen-moq`) and not
220- # from the C header the release ships, which describes the Apple one.
221- moqFfi =
222- let
223- version = "0.3.17";
224- target = {
225- "x86_64-linux" = "x86_64-unknown-linux-gnu";
226- "aarch64-linux" = "aarch64-unknown-linux-gnu";
227- }.${pkgs.stdenv.hostPlatform.system};
228- hash = {
229- "x86_64-linux" = "sha256-dzQXpV4JgdtD+g33WX51FFAQdfCUXkNsx1xPbobPfUI=";
230- "aarch64-linux" = "sha256-PdzRwbJFqOZWRgI0HHX2XUH+Ljh4V3jvQ9asfvCuIPA=";
231- }.${pkgs.stdenv.hostPlatform.system};
232- in
233- pkgs.stdenv.mkDerivation {
234- pname = "libmoq-ffi";
235- inherit version;
236- src = pkgs.fetchurl {
237- url = "https://github.com/kixelated/moq/releases/download/moq-ffi-v${version}/moq-ffi-${version}-${target}-libmoq_ffi.so";
238- inherit hash;
239- };
240- dontUnpack = true;
241- # It carries no RUNPATH and needs libgcc_s, libm and libc — the
242- # host's on an ordinary distro, and nothing at all on NixOS
243- # unless they are bound here.
244- nativeBuildInputs = [ pkgs.autoPatchelfHook ];
245- buildInputs = [ pkgs.stdenv.cc.cc.lib ];
246- installPhase = ''
247- mkdir -p $out/lib
248- cp $src $out/lib/libmoq_ffi.so
249- chmod +w $out/lib/libmoq_ffi.so
250- '';
251- };
252-
253- # One directory for the loader to look in. jolt resolves every
254- # :jolt/native name against JOLT_NATIVE_LIB, and the objects now come
255- # from two places — jolt-native's flake, and the moq-ffi release — so
256- # they are joined rather than the path being made a list, which the
257- # loader does not take.
258- # The C codecs, from nixpkgs. libmoq_ffi carries the transport and
259- # nothing else — moq-ffi's `audio` and `video` features would have
260- # brought Opus and H.264 with them, at the price of compiling a
261- # 1062-crate workspace — so the codecs are linked here instead,
262- # where they have always lived.
263- #
264- # Named in :jolt/native, so the loader resolves them the same way it
265- # resolves libvidya: by name, out of one directory.
266- # A flat C face for openh264, because openh264 has none. Its
267- # `ISVCEncoder` is `const ISVCEncoderVtbl*` — every method is a
268- # function pointer in a vtable — and jolt.ffi cannot call one: Chez
269- # fixes a foreign procedure's types when it compiles it, and the
270- # target must be a literal C symbol name. So the vtable is walked in
271- # c/frq_h264.c and jolt binds the five plain symbols it exports.
272- #
273- # One translation unit against a library nixpkgs already has. It is
274- # a calling convention adapter, not a second media plane, and the
275- # distinction from the moq-ffi build it replaces is the whole point:
276- # this compiles one .c file, not a 1062-crate workspace.
277- frqH264 = pkgs.stdenv.mkDerivation {
278- pname = "frq-h264";
279- version = "0.1";
280- src = ./c;
281- nativeBuildInputs = [ pkgs.pkg-config ];
282- buildInputs = [ pkgs.openh264 ];
283- buildPhase = ''
284- $CC -O2 -fPIC -shared frq_h264.c -o libfrqh264.so \
285- $(pkg-config --cflags --libs openh264)
286- '';
287- installPhase = ''
288- mkdir -p $out/lib && cp libfrqh264.so $out/lib/
289- '';
290- };
291-
292- # openh264 is here for frqH264's DT_NEEDED; alsa-lib for capture
293- # and playback. V4L2 needs nothing: it is ioctls against libc and
294- # the kernel, so there is no library to name.
295- # No SDL any more: it was jvui's, declared in jvui's own
296- # :jolt/native and dlopened by soname. libcosmic paints through wgpu
297- # and takes what it needs from `runtimeLibs` instead.
298- codecs = [ pkgs.libopus pkgs.openh264 frqH264 pkgs.alsa-lib ];
299-
300- # ALSA's PipeWire plugin, which is how `default` resolves to
301- # anything on a machine running PipeWire — and every machine frq
302- # targets does. Without it alsa-lib fails to dlopen
303- # libasound_module_pcm_pipewire.so and the only devices that open
304- # are raw hardware ones, which PipeWire is already holding.
305- #
306- # An environment variable rather than a library in the join:
307- # alsa-lib looks plugins up by directory, not by soname.
308- alsaPluginDir = "${pkgs.pipewire}/lib/alsa-lib";
309-
310- nativeAll = pkgs.symlinkJoin {
311- name = "frq-native";
312- paths = [ native moqFfi ] ++ codecs;
313- };
314-
315- # Jolt itself: Clojure on Chez, built the way its own flake builds it.
316- #
317- # Still a function taking its source, though there is only one of
318- # them now: the second was the Bionic-addrinfo fork the APK's boot
319- # image carried, and there is no jolt APK any more — the phone is
320- # ClojureDart and Flutter, and jolt does not run there at all.
321- joltFrom = src: pkgs.stdenv.mkDerivation {
322- pname = "jolt";
323- version = "dev";
324- inherit src;
325-
326- strictDeps = true;
327- nativeBuildInputs = with pkgs; [ chez makeWrapper pkg-config xxd ];
328- buildInputs = with pkgs; [ lz4 zlib ncurses openssl libuuid ];
329-
330- JOLT_VERSION = "dev";
331- dontConfigure = true;
332-
333- buildPhase = ''
334- runHook preBuild
335- scheme --script host/chez/build-jolt.ss release target/release/jolt
336- runHook postBuild
337- '';
338-
339- installPhase = ''
340- runHook preInstall
341- mkdir -p "$out/bin"
342- install -m755 target/release/jolt "$out/bin/jolt"
343- runHook postInstall
344- '';
345-
346- # jolt.deps shells out to git and unzip, and jolt.mvn-http dlopens
347- # OpenSSL through the JOLT_OPENSSL_LIBDIR seam. gitMinimal rather
348- # than git: all jolt.deps asks for is clone/fetch/rev-parse, and
349- # the full package carries Perl and Python for the subcommands
350- # written in them — a quarter of a gigabyte for git-send-email.
351- #
352- # TZDIR so a zone *name* resolves wherever this runs: frq.clock
353- # hands one to tzset, and glibc then looks for the tzfile under
354- # /usr/share/zoneinfo unless told otherwise — which a NixOS host
355- # does not have. The store's own tzdata is there on both kinds of
356- # machine. --set-default, so a TZDIR the user set still wins.
357- postFixup = ''
358- wrapProgram "$out/bin/jolt" \
359- --prefix PATH : "${pkgs.lib.makeBinPath [ pkgs.gitMinimal pkgs.unzip ]}" \
360- --set-default JOLT_OPENSSL_LIBDIR "${pkgs.lib.makeLibraryPath [ pkgs.openssl ]}" \
361- --set-default TZDIR "${pkgs.tzdata}/share/zoneinfo" \
362- --set-default SSL_CERT_FILE "${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
363- '';
364- };
365-
366- joltRuntime = joltFrom jolt-src;
367-
368- # The backends' Clojure halves, which live inside the jolt-native
369- # checkout beside the objects they bind. Their own deps.edn asks for
370- # glimmer by git — the top-level override below answers for both.
371- glimmerCosmic = "${jolt-native}/glimmer-backends/glimmer-cosmic";
372- glimmerTui = "${jolt-native}/glimmer-backends/glimmer-tui";
373-
374- runtimeLibs = runtimeLibsFor pkgs;
375-
376- # The project as jolt sees it: source, deps.edn, nothing else.
377- frqSource = pkgs.runCommand "frq-source" { } ''
378- mkdir -p "$out"
379- cp -r ${self}/common ${self}/src ${self}/deps.edn "$out/"
380- '';
381-
382- # Jolt resolves deps.edn from the working directory, so the launcher
383- # runs from the store copy. Its .jolt/cpcache write lands on a
384- # read-only directory and jolt treats that as a quiet cache miss, so
385- # the only cost is re-resolving the (already local) graph per start.
386- frqScript = pkgs.writeShellScript "frq" ''
387- export LD_LIBRARY_PATH="${nativeAll}/lib:${lib.makeLibraryPath runtimeLibs}''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
388- export ALSA_PLUGIN_DIR="${alsaPluginDir}"
389- cd ${frqSource}
390-
391- # On NixOS the store's Mesa is the system's and the window opens.
392- # Anywhere else the real driver is the host's, so defer to nixGL
393- # it prepends the host driver, which has to win over ours.
394- runner=""
395- [ -e /run/current-system ] || runner="${nixGL}/bin/nixGLIntel"
396-
397- exec ''${runner} ${joltRuntime}/bin/jolt \
398- -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-cosmic {:local/root "${glimmerCosmic}"}}}' \
399- -m frq.cosmic "$@"
400- '';
401-
402- # The same source, the other backend. No GL, no nixGL and no X11 —
403- # a terminal is the one surface that needs nothing from the host but
404- # a terminal, which is the reason this output exists.
405- tuiScript = pkgs.writeShellScript "frq-tui" ''
406- export LD_LIBRARY_PATH="${nativeAll}/lib''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
407- export ALSA_PLUGIN_DIR="${alsaPluginDir}"
408- cd ${frqSource}
409-
410- exec ${joltRuntime}/bin/jolt \
411- -Sdeps '{:deps {jolt-lang/glimmer {:local/root "${glimmer}"} nandi/glimmer-tui {:local/root "${glimmerTui}"}}}' \
412- -m frq.tui "$@"
413- '';
414-
415- tui = pkgs.runCommand "frq-tui-0.1.0"
416- {
417- meta = {
418- description = "frq's screens in a terminal";
419- mainProgram = "frq-tui";
420- platforms = systems;
421- };
422- }
423- ''
424- mkdir -p "$out/bin"
425- ln -s ${tuiScript} "$out/bin/frq-tui"
426- '';
427-
428- frq = pkgs.runCommand "frq-0.1.0"
429- {
430- meta = {
431- description = "A freeq client in jolt";
432- mainProgram = "frq";
433- platforms = systems;
434- };
435- }
436- ''
437- mkdir -p "$out/bin"
438- ln -s ${frqScript} "$out/bin/frq"
439- '';
440 in88 in
441 {89 {
442- inherit native moqFfi frqH264 nativeAll frq;
443- inherit (pkgs) pipewire;
444- inherit tui;
445- jolt = joltRuntime;
446- default = frq;
447-
448 # The Android SDK `just apk` copies into flutter/.home. A package90 # The Android SDK `just apk` copies into flutter/.home. A package
449 # rather than something the recipe evaluates inline, so that91 # rather than something the recipe evaluates inline, so that
450 # `nix build .#android-sdk` is how you pre-warm it and `nix flake92 # `nix build .#android-sdk` is how you pre-warm it and `nix flake
451 # show` admits it exists.93 # show` admits it exists.
452 android-sdk = androidSdkFor pkgs.stdenv.hostPlatform.system;94 android-sdk = androidSdkFor pkgs.stdenv.hostPlatform.system;
453 95
454- # There were two `appimage` outputs here — this GUI's and Flutter's —96+ # There were `appimage` outputs here, and what they were for was a
455- # and what they were for was a host without Nix. They squashed the97+ # host without Nix: they squashed the whole closure into one
456- # whole closure into one runnable file, Mesa included, and the Mesa98+ # runnable file, Mesa included, and the Mesa was not waste — off
457- # was not waste: off NixOS the launcher goes through nixGL, which99+ # NixOS the launcher goes through nixGL, which needs a store Mesa to
458- # needs a store Mesa to put the host's driver in front of.100+ # put the host's driver in front of. Nothing asks for that shape any
459- #101+ # more, and they were the last thing evaluating nix-appimage, which
460- # `tools/build-desktop.sh` answers that now, and answers it without102+ # is why that input is gone too.
461- # a closure to carry: jolt ships as one static binary, the backends
462- # as jolt-native's `portable` tarball, and the GL driver is simply
463- # the host's. No Mesa to get in front of, so no nixGL, so nothing to
464- # squash. These outputs were the last thing evaluating nix-appimage,
465- # which is why that input is gone too.
466 103
467 # Everything `clojure -M:cljd compile` would otherwise reach the104 # Everything `clojure -M:cljd compile` would otherwise reach the
468 # network for, fetched once and hashed.105 # network for, fetched once and hashed.
@@ -659,9 +296,8 @@
659 # The Flutter desktop GUI, built rather than run out of the tree.296 # The Flutter desktop GUI, built rather than run out of the tree.
660 #297 #
661 # `just flutter-desktop` is the working-tree loop and this is its298 # `just flutter-desktop` is the working-tree loop and this is its
662- # opposite number, the same way `nix build .#frq` is `just cosmic299+ # opposite number: the source is the flake's, the output is a store
663- # run`'s: the source is the flake's, the output is a store path, and the build is300+ # path, and the build is a sandbox with no network. It is the first thing here that builds
664- # a sandbox with no network. It is the first thing here that builds
665 # purely — the APK cannot, because Gradle fetches as it goes.301 # purely — the APK cannot, because Gradle fetches as it goes.
666 #302 #
667 # Two stages, because the Dart does not exist until ClojureDart writes303 # Two stages, because the Dart does not exist until ClojureDart writes
@@ -690,7 +326,7 @@
690 # is the screens its deps.edn puts on the classpath. The root is326 # is the screens its deps.edn puts on the classpath. The root is
691 # still the source root because of that `../common`, but letting327 # still the source root because of that `../common`, but letting
692 # the *whole* root in means every file in the repo is an input —328 # the *whole* root in means every file in the repo is an input —
693- # so editing flake.nix, or CLAUDE.md, or the jolt half in `src/`,329+ # so editing flake.nix or CLAUDE.md
694 # invalidated the entire Dart compile and paid ten minutes for a330 # invalidated the entire Dart compile and paid ten minutes for a
695 # change the Flutter build cannot even see.331 # change the Flutter build cannot even see.
696 #332 #
@@ -709,7 +345,7 @@
709 # and would make every one of them a new store path.345 # and would make every one of them a new store path.
710 && !(builtins.elem (baseNameOf path) [346 && !(builtins.elem (baseNameOf path) [
711 "build" ".home" ".clojuredart" ".cpcache" "cljd-out"347 "build" ".home" ".clojuredart" ".cpcache" "cljd-out"
712- ".dart_tool" "result" ".git" ".jolt" "buck-out"348+ ".dart_tool" "result" ".git" "buck-out"
713 ]);349 ]);
714 };350 };
715 sourceRoot = "source/flutter";351 sourceRoot = "source/flutter";
@@ -818,111 +454,34 @@
818 '';454 '';
819 });455 });
820 456
821- # Where `just cosmic run` runs, and because entering it realises what it
822- # names what builds the half of frq that is not this working tree.
823- #
824- # The two halves, and the split is the whole point of the shell. The frq
825- # source is the files on disk, uncommitted edits and all. Everything
826- # under it jolt, glimmer, glimmer-vidya, both native objects is the
827- # flake's, at the revs flake.lock names, so a run says what it ran
828- # against and both halves of glimmer-vidya move together. That is the
829- # drift the `jolt-native` input's comment is about, and a pin frq can
830- # answer for is worth more here than the convenience of a checkout.
831- #
832- # `native` is jolt-native's own flake output. It was a buck2 graph when
833- # this comment was first written and a cargo build restated here when it
834- # was second: buck2 fetches its rustc, zig and every third-party crate as
835- # it goes and writes buck-out into the tree it builds, so a sandbox with
836- # no network and a read-only store was the one place it could not run.
837- # Upstream builds with nix now, so the thing its CI runs and the thing
838- # this shell hands a builder are the same derivation.
839- #
840- # Nothing here says "nixbuild", though: it is a plain derivation, and
841- # where it gets built is the machine's business. The `cosmic` recipe asks for
842- # the shell with --max-jobs 0, which is what sends it to the `builders`
843- # entry rather than compiling egui on a laptop.
844 devShells = forEachSystem (pkgs:457 devShells = forEachSystem (pkgs:
845 let458 let
846 inherit (pkgs) lib;459 inherit (pkgs) lib;
847- inherit (self.packages.${pkgs.stdenv.hostPlatform.system}) jolt native nativeAll;
848 in460 in
849 {461 {
850- default = pkgs.mkShellNoCC {462+ # Nim, for `nim/` the portable core as a native library. Just the
851- name = "frq";463+ # compiler: the core has no dependencies outside Nim's own standard
852-464+ # library, deliberately, because a dependency here is one that has
853- # jolt, because the runtime frq is run by should be the flake's465+ # to cross-compile to every target the Dart side runs on.
854- # too. nixGL for the same reason the launcher reaches for it see466+ #
855- # frqScript. just so the recipe runner comes from here too rather467+ # `nim c` shells out to a C compiler, so this is mkShell and not
856- # than the host `nix develop` and then `just cosmic run` is the468+ # mkShellNoCC: stdenv brings the one Nim will find.
857- # whole of what a machine with nix needs.469+ nim = pkgs.mkShell {
858- packages = [ jolt pkgs.just (nixGLFor pkgs) ];470+ name = "frq-nim";
859-471+ packages = [ pkgs.nim pkgs.just ];
860- # Read by the recipes rather than baked into a wrapper: the frq472+
861- # source `just cosmic run` runs is the working tree, so the473+ # The recipe's re-entry test, the way FRQ_FLUTTER_DESKTOP is the
862- # launcher has to live in that tree and the shell has to hand it its answers.474+ # desktop one's.
863- # Naming these is also what makes the shell build them.475+ FRQ_NIM = "1";
864- JOLT_NATIVE_LIB = "${nativeAll}/lib";
865- # Spelled out rather than shared with the packages block, which
866- # is a different `let`. See `alsaPluginDir` there for why.
867- ALSA_PLUGIN_DIR = "${pkgs.pipewire}/lib/alsa-lib";
868- GLIMMER_SRC = glimmer;
869- GLIMMER_COSMIC_SRC = "${jolt-native}/glimmer-backends/glimmer-cosmic";
870- GLIMMER_TUI_SRC = "${jolt-native}/glimmer-backends/glimmer-tui";
871- FRQ_LIB_PATH = lib.makeLibraryPath (runtimeLibsFor pkgs);
872- NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel";
873-
874- # A checkout of jolt-native in place of the pin, when one is NAMED.
875- #
876- # The pin is a rev on a server, so the loop for a change to a
877- # backend would be commit, push, re-pin, re-lock four steps and
878- # an upload for a line of Rust. Pointing FRQ_JOLT_NATIVE at a
879- # working copy makes the loop `cargo build` and `just tui`.
880- #
881- # This used to find that copy by itself ../jolt-native beside the
882- # checkout and that is gone, because a found tree is the wrong
883- # default twice over. It fired on a sibling nobody had asked about,
884- # so a shell could be running something other than the pin on the
885- # strength of a directory existing. And what it prepended was a raw
886- # `cargo build` artifact: no store RUNPATH, so its libc.so.6
887- # resolves to the host's, and the Nix-glibc jolt that dlopens it
888- # gets a second libc and fails. jolt reports that as "required
889- # native library ... not found", which names neither libc nor the
890- # tree the object came from. An override worth having is one you
891- # can see in the command you typed.
892- #
893- # So: named or nothing. A named tree is taken even unbuilt (with a
894- # word about what to run) naming it is asking for it and it has
895- # to carry the RUNPATH the store copy does, which in practice means
896- # building it under Nix.
897- #
898- # Only the sources and the objects a checkout actually builds move.
899- # Everything else on the library path libopus, libmoq_ffi, the
900- # ALSA plugins stays the pin's, since a checkout has no build of
901- # those to offer.
902- shellHook = ''
903- if [ -n "''${FRQ_JOLT_NATIVE:-}" ]; then
904- if [ -d "$FRQ_JOLT_NATIVE/crates/jolt-tui" ]; then
905- FRQ_JOLT_NATIVE="$(cd "$FRQ_JOLT_NATIVE" && pwd)"
906- export FRQ_JOLT_NATIVE
907- export GLIMMER_TUI_SRC="$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-tui"
908- export GLIMMER_COSMIC_SRC="$FRQ_JOLT_NATIVE/glimmer-backends/glimmer-cosmic"
909- # First, so a cargo build wins over the pin's copy of the
910- # same object. The rest of the pin's lib directory is still
911- # behind it.
912- export JOLT_NATIVE_LIB="$FRQ_JOLT_NATIVE/target/release:$JOLT_NATIVE_LIB"
913- echo "frq: jolt-native from $FRQ_JOLT_NATIVE, not the pin (unset FRQ_JOLT_NATIVE for the pin)" >&2
914- if [ ! -e "$FRQ_JOLT_NATIVE/target/release/libjolttui.so" ]; then
915- echo "frq: no libjolttui.so there yet cargo build --release --features terminal -p jolt-tui" >&2
916- fi
917- else
918- echo "frq: FRQ_JOLT_NATIVE=$FRQ_JOLT_NATIVE is not a jolt-native checkout; using the pin" >&2
919- fi
920- fi
921- '';
922 };476 };
923 477
924- # The APK toolchain, which the default shell deliberately does not478+ # The APK toolchain. It is not in a default shell any more because
925- # carry: Flutter brings its own Dart, Gradle and a JDK's worth of479+ # there is no default shell: what used to be one belonged to the
480+ # libcosmic frontend, and a Flutter build asks for a toolchain by
481+ # name.
482+ #
483+ # Flutter brings its own Dart, Gradle and a JDK's worth of
484+ # closure: Flutter brings its own Dart, Gradle and a JDK's worth of
926 # closure, and a desktop build has no use for any of it.485 # closure, and a desktop build has no use for any of it.
927 #486 #
928 # `just apk` used to name these as `nix shell nixpkgs#clojure487 # `just apk` used to name these as `nix shell nixpkgs#clojure
@@ -1005,10 +564,8 @@
1005 # pure Dart over the XDG directories.564 # pure Dart over the XDG directories.
1006 buildInputs = [ pkgs.gtk3 pkgs.glib ];565 buildInputs = [ pkgs.gtk3 pkgs.glib ];
1007 566
1008- # Same reason `just cosmic run` reaches for it: Flutter paints567+ # Flutter paints through GL, and off NixOS the driver that can do
1009- # through GL, and off NixOS the driver that can do that is the568+ # that is the host's, not the store's.
1010- # host's, not the store's. The recipe reads this exactly as
1011- # `cosmic` does.
1012 NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel";569 NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel";
1013 570
1014 # The `flutter` shell's, deliberately the same one and for the571 # The `flutter` shell's, deliberately the same one and for the
@@ -1020,8 +577,7 @@
1020 # for the flake output itself.577 # for the flake output itself.
1021 FRQ_CLJD_DEPS = "${self.packages.${pkgs.stdenv.hostPlatform.system}.cljd-deps}";578 FRQ_CLJD_DEPS = "${self.packages.${pkgs.stdenv.hostPlatform.system}.cljd-deps}";
1022 579
1023- # The recipe's re-entry test, the way JOLT_NATIVE_LIB is the580+ # The recipe's re-entry test. Nothing else sets it, so `just flutter-desktop`
1024- # default shell's. Nothing else sets it, so `just flutter-desktop`
1025 # outside the shell re-enters and lands back on the same recipe 581 # outside the shell re-enters and lands back on the same recipe
1026 # no flag to forget, and no second code path for someone who runs582 # no flag to forget, and no second code path for someone who runs
1027 # `nix develop .#flutter-desktop --command just flutter-desktop`583 # `nix develop .#flutter-desktop --command just flutter-desktop`
@@ -1029,7 +585,6 @@
1029 FRQ_FLUTTER_DESKTOP = "1";585 FRQ_FLUTTER_DESKTOP = "1";
1030 };586 };
1031 587
1032- # The third frontend, and the first that is not a window: the same
1033 # No `flutter-web` shell here any more. The web target was the one588 # No `flutter-web` shell here any more. The web target was the one
1034 # that needed nothing of the host -- no JDK and no Android SDK as589 # that needed nothing of the host -- no JDK and no Android SDK as
1035 # the APK wants, no GTK and no C++ and no nixGL as the desktop one590 # the APK wants, no GTK and no C++ and no nixGL as the desktop one
@@ -1040,19 +595,9 @@
1040 # `.modal/flutter-web/` runs that same script on a plain Debian595 # `.modal/flutter-web/` runs that same script on a plain Debian
1041 # image with no store to populate.596 # image with no store to populate.
1042 #597 #
1043- # The other two shells stay. What they supply is a host toolchain,598+ # The two shells above stay. What they supply is a host toolchain,
1044 # which is exactly what nix is better at than a tarball.599 # which is exactly what nix is better at than a tarball.
1045 });600 });
1046 601
1047- apps = forEachSystem (pkgs: {
1048- default = {
1049- type = "app";
1050- program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.frq}/bin/frq";
1051- };
1052- tui = {
1053- type = "app";
1054- program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.tui}/bin/frq-tui";
1055- };
1056- });
1057 };602 };
1058 }603 }
modified flutter/README.md +109 -147
@@ -4,81 +4,76 @@ This is the boundary, drawn before the port rather than after it, so that the
44 question "can this file go on the phone?" has a filesystem answer. It builds:
55 see "Building it" below.
66
7-## The three trees
7+## The two trees
88
99 ```
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.
10+common/ .cljc portable. No dart: library, no host call except through frq.io.
11+flutter/ .cljd Flutter widgets, dart:io, dart:ffi, and the host's answers.
1312 ```
1413
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.
14+The extension is the boundary and the compiler enforces half of it: ClojureDart
15+reads `.cljd` and `.cljc` and never `.clj`. `tools/check-common.py` enforces the
16+rest, on every push — a `dart:` library named under `common/` is a namespace
17+that compiles for one target and not the others.
2018
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
19+Where a shared namespace needs a different answer per target, `.cljd` wins over
20+`.cljc` in ClojureDart's own resolution, so a file here shadows a shared one
21+without either side knowing. Reader conditionals work too, with one trap from
2422 ClojureDart's FAQ: the `:clj` feature is always on under cljd, so `:clj` goes
2523 **last** in a conditional, and macro code that wants the Clojure path during
2624 host evaluation asks for `:cljd/clj-host`.
2725
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.
26+## The seam
27+
28+`frq.io` is the host's job named once. `frq.io.dart` answers it on Android and
29+the Linux desktop; `frq.io.web` answers it in a browser. It carries the
30+filesystem, the environment, the config directory and the clock.
31+
32+`frq.clock` is the shape the rest follows. It used to open with four guesses at
33+the reader's zone — `TZ`, the target of `/etc/localtime`, the file itself by
34+path, then Android's `persist.sys.timezone` — and then convert days to a date
35+by printing one and taking a `subs` of the result. Both are gone: the guessing
36+is a libc question and lives behind `local-offset-seconds`, which Dart answers
37+in one call; the conversion is eleven lines of Hinnant's algorithm, checked
38+against `java.time.LocalDate` for every day from 1901 to 2052.
39+
40+Adding a host call means adding it to `common/frq/io.cljc` and to every
41+implementation. Name it for the result rather than the mechanism — the seam has
42+`write-private-file!` and not a chmod, because Dart has no chmod.
43+
44+## The port, as it finished
45+
46+This tree began as the phone half of a client whose desktop was jolt: glimmer
47+components painted by libcosmic, with `src/` holding the half that could not
48+cross. That half is gone now, and what was a migration plan is the whole
49+program. The namespaces that made the trip:
50+
51+| namespace | note |
52+|--------------------|-------------------------------------------------------|
53+| `frq.emoji` | data; nothing to port |
54+| `frq.glyphs` | data |
55+| `frq.clock` | zone-hunting moved behind the seam |
56+| `frq.store` | `install -m 600` became `write-private-file!` |
57+| `frq.irc.parse` | the parser was always pure |
58+| `frq.irc.handshake`| SASL, driven from shared code |
59+| `frq.msgsig` | needed a crypto seam beside the io one |
60+| `frq.atproto.core` | hand-rolled HTTPS became `dart:io`, which has TLS |
61+| `frq.oauth.core` | the broker flow, as far as it is portable |
62+| `frq.rooms`, `frq.cells`, `frq.screens/*` | rewritten against `cljd.flutter` |
63+
64+What did not come: the terminal frontend (there is no terminal Flutter), the
65+libcosmic one (it is Wayland, X11 and wgpu, and does not cross to a phone), and
66+the media plane — `moq/`, `codec/`, `capture/`, `av/`, about 3,800 lines of FFI
67+against C libraries Android does not have either way. `dart:ffi` does not
68+conjure V4L2; that half wants Flutter's camera and audio plugins and is its own
69+project. The Call controls in `frq.screens.chat` are still wired to actions no
70+target installs, which is the visible edge of that.
7671
7772 ## Building it
7873
79-Two targets out of one tree. The ClojureDart compile is the same command for
80-both — `clojure -M:cljd compile` over `src/` and `../common` — and what differs
81-is only what Flutter is asked to wrap it in.
74+Three targets out of one tree. The ClojureDart compile is the same command for
75+all of them — `clojure -M:cljd compile` over `src/` and `../common` — and what
76+differs is only what Flutter is asked to wrap it in.
8277
8378 ```bash
8479 just apk # the debug APK
@@ -88,31 +83,32 @@ just apk log # logcat
8883
8984 just flutter-desktop # the debug Linux bundle
9085 just flutter-desktop run # and the window
86+
87+just flutter-web # the web bundle
88+just flutter-web serve # and served on :8080
9189 ```
9290
9391 ### The desktop one
9492
95-There are two desktop GUIs now, and they are not a fallback for each other:
96-`just cosmic run` is libcosmic under jolt, and `just flutter-desktop` is this
97-tree under Flutter's Linux target. Same screens out of `common/frq/screens/`, two
98-renderers — `glimmer-cosmic` walks the hiccup on one side and `frq.hiccup`
99-emits Flutter widgets on the other.
93+`just flutter-desktop` is this tree under Flutter's Linux target — the same
94+screens out of `common/frq/screens/` as the APK, with `frq.hiccup` emitting
95+Flutter widgets. There used to be a second desktop GUI beside it, libcosmic
96+under jolt, walking the same hiccup through a different renderer; it is gone.
10097
10198 Its toolchain is `devShells.flutter-desktop`, which is the APK shell with the
10299 Android half swapped out: clojure and Flutter are the same two packages at the
103100 same pinned rev, and CMake, Ninja, pkg-config and GTK stand where the JDK and
104101 the SDK do. Kept separate rather than merged into one shell because the halves
105102 are disjoint — a desktop build has no use for a few hundred megabytes of
106-Android SDK, which is the same argument that keeps Flutter out of the default
107-shell.
103+Android SDK.
108104
109105 Still impure, for one of the two reasons the APK is: pub.dev resolution and
110106 Flutter's engine artifacts are network. What it does *not* need is the
111107 writable-`ANDROID_HOME` dance, since nothing here writes into the store — so
112108 there is no `flutter/.home` on this path.
113109
114-nixGL off NixOS, for the reason `just cosmic run` needs it and `just tui` does
115-not: Flutter paints through GL and the driver that can do that is the host's.
110+nixGL off NixOS: Flutter paints through GL and the driver that can do that is
111+the host's.
116112
117113 `linux/` is the Flutter template's GTK runner, renamed — `frq` rather than
118114 `cljd_flutter`, and `uk.nandi.frq` rather than `com.example.cljd_flutter`, so
@@ -125,9 +121,9 @@ Two things the desktop target changed in the Dart, both of them cases where
125121 * `frq.io.dart/write-private-file!` was a plain write, on the grounds that
126122 Android storage is already private to the app. On a Linux desktop it is not:
127123 the file lands under the XDG data directory with the process umask, and it
128- holds a broker token. The desktop branch now does what `frq.io.jolt` does —
129- create, chmod, then write — and Dart having no chmod is why that is a
130- process.
124+ holds a broker token. The desktop branch creates the file, restricts it, then
125+ writes — and Dart having no chmod is why that is a three-step process rather
126+ than a mode argument.
131127 * `frq.oauth.dart` handed the capture page `frq://auth` unconditionally, to
132128 raise the app from behind Chrome. Nothing on a desktop claims that scheme, so
133129 it is now nil there — which `core/capture-html` already documented as the
@@ -169,52 +165,35 @@ targets.
169165 It is signed with `~/.android/debug.keystore`, through the template's
170166 `signingConfig = signingConfigs.getByName("debug")` — which release builds also
171167 use, so `flutter build apk --release` is not shippable until a real
172-`signingConfigs.release` is wired up. The jolt APK's key was generated inside
173-its own nix derivation and never written anywhere, which is why the first
174-install over it needed an uninstall: Android will not update a package across a
175-signature change.
168+`signingConfigs.release` is wired up.
176169
177170 ## The screens are not rewritten
178171
179-`frq.hiccup` is a glimmer backend, the same way glimmer-cosmic and glimmer-tui
180-are. It walks the hiccup `frq.app` already produces and emits Flutter widgets,
181-so the screens are shared rather than forked.
172+`frq.hiccup` is an interpreter, not a port. It walks the hiccup the screens
173+already produce — `[:vbox {:spacing 6} ...]` over about twenty tags, naming no
174+toolkit — and emits Flutter widgets, so `common/frq/screens/` is shared across
175+all three targets rather than forked per target.
182176
183177 This is worth being precise about, because the first read of the port said
184-otherwise. Measured against the source:
185-
186-* `frq.state` is 1,930 lines and makes **zero** glimmer calls. Its whole
187- dependency on glimmer is `:refer [atom]` — it shadows core's `atom` with a
188- ratom, and everything after that is `swap!`, `reset!` and `deref`.
189-* `frq.app` is 1,834 lines and makes **one**: `r/reaction`. The rest is data —
190- `[:vbox {:spacing 6} ...]` over about twenty tags, naming no toolkit.
191-
192-So what a Flutter port needs is an interpreter for that data, not a rewrite of
193-it. What genuinely has to be ported is the other end: `frq.irc`, `frq.atproto`,
194-`frq.oauth`, `frq.avatars`, `frq.media`, `frq.profile`, `frq.platform` — the
195-namespaces that touch the host. Which is what `frq.io` is for, and where
196-`dart:io` pays for the whole exercise.
178+otherwise. The screens were data all along; what genuinely had to be written
179+was the other end — the namespaces that touch the host. Which is what `frq.io`
180+is for, and where `dart:io` paid for the whole exercise.
197181
198-What `frq.hiccup` does not do is glimmer's reconciliation: Flutter rebuilds
182+What `frq.hiccup` does not do is reconciliation of its own: Flutter rebuilds
199183 from the top and diffs its own element tree, so a cell firing rebuilds the
200184 screen rather than the subtree that read it. Fine at this size.
201185
202-`frq.main` paints `frq.app`'s own connect screen, out of
203-`common/frq/screens/connect.cljc` — the same file the desktop renders. What it
204-reads is `frq.cells` and what it calls is `frq.actions`, and each platform
205-fills those in: `frq.state`'s reducers on the desktop, dart:io here.
206-
207186 ## Every screen shared, and the root that picks between them
208187
209-All of them are in `common/frq/screens/` now — `connect`, `chats`, `chat`,
210-`settings` (with Discover and the tab frame) and `app`, which carries the
211-split view, the three dialogs and the decision about which screen is showing.
212-The phone renders `[screens/app]` and nothing else; it was switching by hand
213-until that moved. With the cells under them in `frq.cells`, with the cells under them in `frq.cells`, the derivations in
214-`frq.rooms`, the backend metrics in `frq.metrics` and everything a screen
215-cannot do itself behind `frq.actions`. `frq.app` is 226 lines and was 1,744. What is left in it is the part that
216-cannot move: `derived` and the two asset lookups, which are a glimmer reaction
217-over a fetch-and-cache, the metrics aliases `frq.tui` writes, and `start!`.
188+All of them are in `common/frq/screens/` — `connect`, `chats`, `chat`,
189+`settings` (with Discover and the tab frame) and `app`, which carries the split
190+view, the three dialogs and the decision about which screen is showing. Each
191+entry point renders `[screens/app]` and nothing else.
192+
193+Under them: the cells in `frq.cells`, the derivations in `frq.rooms`, the
194+chrome metrics in `frq.metrics`, and everything a screen cannot do itself
195+behind `frq.actions` — which each target fills in for itself, the way it fills
196+in `frq.io`.
218197
219198 What `Length::Fill` means took four goes to get right, and the rule it ended
220199 at is worth stating once: a child that fills is Flutter's `Expanded`, the
@@ -258,43 +237,26 @@ red error box does not appear, and the log stays empty. `FlutterError.onError`
258237 is where they go, and installing that handler in `frq.main` should have been
259238 the first move rather than the tenth.
260239
261-## The order to do the rest in
262-
263-1. ~~**`frq.irc`**~~ — started. The parser is `common/frq/irc/parse.cljc` now,
264- shared, with `frq.irc` re-exporting it so the twenty-three `irc/tag-value`
265- and `irc/nick-of` call sites in `frq.state` and `frq.av` did not move. The
266- transport is `frq.net.dart`: `SecureSocket`, a `Stream`, no thread and no
267- outbox. **TLS reaches irc.freeq.at:6697 from the phone** — registration and
268- MOTD, which is the thing the jolt APK could never do. What is left of this
269- one is the protocol half: CAP, SASL and the idle-ping logic still live in
270- `src/frq/irc.clj` and want `frq.msgsig` and `frq.atproto` under them first.
271-2. ~~**`frq.atproto`**~~ — done. `common/frq/atproto/core.cljc` is the JSON,
272- the base64url, the SASL payloads, and a `-req`/`-parse` pair per step of the
273- flow; `frq.atproto` and `frq.atproto.dart` supply the middle. **handle → DID
274- → PDS resolves on the phone**, over `HttpClient`.
275-
276- **`frq.oauth`** — half done. `common/frq/oauth/core.cljc` has the URL, the
277- handoff payload and the session refresh. What has no Android answer yet is
278- the capture: the desktop binds a loopback socket and serves a page the
279- browser redirects to, and an Android app cannot listen on localhost for a
280- browser it does not own. That wants an app link or a custom scheme, an
281- intent filter, and a redirect URI the broker will accept — a decision about
282- freeq's broker, not a porting problem.
283-3. ~~**`frq.msgsig`**~~ — done. The signing is shared; the four primitives
284- under it are `frq.crypto`, which the desktop answers with the same OpenSSL
285- it loads for TLS and the phone with `package:ed25519_edwards` and
286- `package:crypto`, both pure Dart and both synchronous — a signature is
287- minted in the middle of sending a reaction and there is nothing to await
288- on. Verified on both against RFC 8032 test 1: same public key, same
289- signature, byte for byte.
290-
291- **`frq.wire`** (81) still wants the seam extended.
292-4. **`frq.avatars`**, **`frq.media`**, **`frq.profile`**, **`frq.platform`** —
293- small, and mostly fetch-and-cache.
294-5. **`frq.state`** moves to `common/` as `.cljc`, with `atom` resolved per
295- platform by reader conditional.
296-6. **`frq.app`** follows it, and the tags it uses that `frq.hiccup` does not
297- cover yet paint as an orange `?tag` until they do.
240+## What is still open
241+
242+The port is finished in the sense that matters — there is no other tree left to
243+move from. What remains is work the port never covered:
244+
245+1. **The OAuth capture on Android.** `common/frq/oauth/core.cljc` has the URL,
246+ the handoff payload and the session refresh. What has no Android answer is
247+ the capture itself: the flow was written for a client that binds a loopback
248+ socket and serves a page the browser redirects to, and an Android app cannot
249+ listen on localhost for a browser it does not own. That wants an app link or
250+ a custom scheme, an intent filter, and a redirect URI the broker will accept
251+ — a decision about freeq's broker, not a porting problem. The web build has
252+ its own answer in `frq.oauth.web`, and `just web-local` is why it only
253+ completes on localhost.
254+2. **Calls.** The signaling is IRC and is still in the screens; the media plane
255+ it drove was `libjoltmoq` — Opus, H.264, V4L2, ALSA, MoQ over QUIC — under
256+ the retired jolt half, and none of it crosses. The Call controls are wired
257+ to actions no target installs, so they are dead buttons today. Flutter's
258+ camera and audio plugins are the way back in, and that is its own project:
259+ either remove the controls or build behind them.
298260
299261 ## What a missing tag property looks like
300262
@@ -4,81 +4,76 @@ This is the boundary, drawn before the port rather than after it, so that the
4 question "can this file go on the phone?" has a filesystem answer. It builds:4 question "can this file go on the phone?" has a filesystem answer. It builds:
5 see "Building it" below.5 see "Building it" below.
6 6
7-## The three trees7+## The two trees
8 8
9 ```9 ```
10-common/ .cljc both compilers. No jolt, no glimmer, no dart.10+common/ .cljc portable. No dart: library, no host call except through frq.io.
11-src/ .clj jolt: glimmer, jolt.ffi, the cosmic and tui backends.11+flutter/ .cljd Flutter widgets, dart:io, dart:ffi, and the host's answers.
12-flutter/ .cljd ClojureDart: Flutter widgets, dart:io, dart:ffi.
13 ```12 ```
14 13
15-The extension is the boundary and the compilers enforce it. ClojureDart reads14+The extension is the boundary and the compiler enforces half of it: ClojureDart
16-`.cljd` and `.cljc` and never `.clj`, so a namespace that reaches for15+reads `.cljd` and `.cljc` and never `.clj`. `tools/check-common.py` enforces the
17-`jolt.host` cannot accidentally end up in the APK — it is a `.clj` and the Dart16+rest, on every push — a `dart:` library named under `common/` is a namespace
18-compiler cannot see it. jolt reads all three, which is why `common/` works at17+that compiles for one target and not the others.
19-all: one copy of `frq.clock`, compiled twice.
20 18
21-Where both need a namespace but the answer differs, `.cljd` wins over `.cljc`19+Where a shared namespace needs a different answer per target, `.cljd` wins over
22-in ClojureDart's own resolution, so a file here shadows a shared one without20+`.cljc` in ClojureDart's own resolution, so a file here shadows a shared one
23-either side knowing. Reader conditionals work too, with one trap from21+without 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` goes22 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 during23 **last** in a conditional, and macro code that wants the Clojure path during
26 host evaluation asks for `:cljd/clj-host`.24 host evaluation asks for `:cljd/clj-host`.
27 25
28-## What has crossed26+## The seam
29-27+
30-`frq.io` is the seam — the host's job named once, with `frq.io.jolt` answering28+`frq.io` is the host's job named once. `frq.io.dart` answers it on Android and
31-it on the desktop and `frq.io.dart` here. It carries the filesystem, the29+the Linux desktop; `frq.io.web` answers it in a browser. It carries the
32-environment, the config directory and the clock.30+filesystem, the environment, the config directory and the clock.
33-31+
34-Moved to `common/` and running under jolt today:32+`frq.clock` is the shape the rest follows. It used to open with four guesses at
35-33+the reader's zone — `TZ`, the target of `/etc/localtime`, the file itself by
36-| namespace | lines | note |34+path, then Android's `persist.sys.timezone` — and then convert days to a date
37-|---------------|-------|-------------------------------------------------|35+by printing one and taking a `subs` of the result. Both are gone: the guessing
38-| `frq.emoji` | 1,914 | data; nothing to port |36+is a libc question and lives behind `local-offset-seconds`, which Dart answers
39-| `frq.glyphs` | 84 | data |37+in one call; the conversion is eleven lines of Hinnant's algorithm, checked
40-| `frq.av.dial` | 117 | already touched neither jolt nor glimmer |38+against `java.time.LocalDate` for every day from 1901 to 2052.
41-| `frq.clock` | 93 | zone-hunting moved into the backends |39+
42-| `frq.store` | 122 | `install -m 600` became `write-private-file!` |40+Adding a host call means adding it to `common/frq/io.cljc` and to every
43-41+implementation. Name it for the result rather than the mechanism — the seam has
44-`frq.clock` is the shape the rest should follow. It used to open with four42+`write-private-file!` and not a chmod, because Dart has no chmod.
45-guesses at the reader's zone — `TZ`, the target of `/etc/localtime`, the file43+
46-itself by path, then Android's `persist.sys.timezone` — and then convert days44+## The port, as it finished
47-to a date by printing one with `jolt.time.local` and taking a `subs` of the45+
48-result. Both are gone: the guessing is a libc question and lives in46+This tree began as the phone half of a client whose desktop was jolt: glimmer
49-`frq.io.jolt`, where Dart answers it in one call instead; the conversion is47+components painted by libcosmic, with `src/` holding the half that could not
50-eleven lines of Hinnant's algorithm, checked against `java.time.LocalDate` for48+cross. That half is gone now, and what was a migration plan is the whole
51-every day from 1901 to 2052.49+program. The namespaces that made the trip:
52-50+
53-## What has not51+| namespace | note |
54-52+|--------------------|-------------------------------------------------------|
55-Roughly 4,000 lines are portable in substance and still `.clj` because the seam53+| `frq.emoji` | data; nothing to port |
56-does not reach far enough yet. In the order worth doing them:54+| `frq.glyphs` | data |
57-55+| `frq.clock` | zone-hunting moved behind the seam |
58-1. **`frq.wire`, `frq.msgsig`** — need a crypto seam beside the io one.56+| `frq.store` | `install -m 600` became `write-private-file!` |
59-2. **`frq.irc`** (433) — the parser is pure; the reader is a blocking thread in57+| `frq.irc.parse` | the parser was always pure |
60- a `future`, and Dart has no threads. It becomes a `Stream` over58+| `frq.irc.handshake`| SASL, driven from shared code |
61- `SecureSocket`, which is also what makes TLS work on the phone at all.59+| `frq.msgsig` | needed a crypto seam beside the io one |
62-3. **`frq.atproto`** (209), **`frq.oauth`** (182) — hand-rolled HTTPS over60+| `frq.atproto.core` | hand-rolled HTTPS became `dart:io`, which has TLS |
63- `jolt.mvn-http`'s OpenSSL bindings, which is why sign-in is desktop-only61+| `frq.oauth.core` | the broker flow, as far as it is portable |
64- today. `dart:io` has TLS in the runtime; this is the single biggest thing62+| `frq.rooms`, `frq.cells`, `frq.screens/*` | rewritten against `cljd.flutter` |
65- the port buys.63+
66-4. **`frq.state`** (1,930) — mostly portable logic, but its ratoms are64+What did not come: the terminal frontend (there is no terminal Flutter), the
67- glimmer's. Needs the reactive layer decided first.65+libcosmic one (it is Wayland, X11 and wgpu, and does not cross to a phone), and
68-5. **`frq.app`** (1,834) — not a port. Flutter brings its own reconciler, so66+the media plane — `moq/`, `codec/`, `capture/`, `av/`, about 3,800 lines of FFI
69- the screens are rewritten against `cljd.flutter`.67+against C libraries Android does not have either way. `dart:ffi` does not
70-68+conjure V4L2; that half wants Flutter's camera and audio plugins and is its own
71-Not coming: `frq.tui` (no terminal Flutter), `frq.cosmic` (libcosmic is69+project. The Call controls in `frq.screens.chat` are still wired to actions no
72-desktop-only), and the media plane — `moq/`, `codec/`, `capture/`, `av/`, about70+target installs, which is the visible edge of that.
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 71
77 ## Building it72 ## Building it
78 73
79-Two targets out of one tree. The ClojureDart compile is the same command for74+Three targets out of one tree. The ClojureDart compile is the same command for
80-both — `clojure -M:cljd compile` over `src/` and `../common` — and what differs75+all of them — `clojure -M:cljd compile` over `src/` and `../common` — and what
81-is only what Flutter is asked to wrap it in.76+differs is only what Flutter is asked to wrap it in.
82 77
83 ```bash78 ```bash
84 just apk # the debug APK79 just apk # the debug APK
@@ -88,31 +83,32 @@ just apk log # logcat
88 83
89 just flutter-desktop # the debug Linux bundle84 just flutter-desktop # the debug Linux bundle
90 just flutter-desktop run # and the window85 just flutter-desktop run # and the window
86+
87+just flutter-web # the web bundle
88+just flutter-web serve # and served on :8080
91 ```89 ```
92 90
93 ### The desktop one91 ### The desktop one
94 92
95-There are two desktop GUIs now, and they are not a fallback for each other:93+`just flutter-desktop` is this tree under Flutter's Linux target — the same
96-`just cosmic run` is libcosmic under jolt, and `just flutter-desktop` is this94+screens out of `common/frq/screens/` as the APK, with `frq.hiccup` emitting
97-tree under Flutter's Linux target. Same screens out of `common/frq/screens/`, two95+Flutter widgets. There used to be a second desktop GUI beside it, libcosmic
98-renderers — `glimmer-cosmic` walks the hiccup on one side and `frq.hiccup`96+under jolt, walking the same hiccup through a different renderer; it is gone.
99-emits Flutter widgets on the other.
100 97
101 Its toolchain is `devShells.flutter-desktop`, which is the APK shell with the98 Its toolchain is `devShells.flutter-desktop`, which is the APK shell with the
102 Android half swapped out: clojure and Flutter are the same two packages at the99 Android half swapped out: clojure and Flutter are the same two packages at the
103 same pinned rev, and CMake, Ninja, pkg-config and GTK stand where the JDK and100 same pinned rev, and CMake, Ninja, pkg-config and GTK stand where the JDK and
104 the SDK do. Kept separate rather than merged into one shell because the halves101 the SDK do. Kept separate rather than merged into one shell because the halves
105 are disjoint — a desktop build has no use for a few hundred megabytes of102 are disjoint — a desktop build has no use for a few hundred megabytes of
106-Android SDK, which is the same argument that keeps Flutter out of the default103+Android SDK.
107-shell.
108 104
109 Still impure, for one of the two reasons the APK is: pub.dev resolution and105 Still impure, for one of the two reasons the APK is: pub.dev resolution and
110 Flutter's engine artifacts are network. What it does *not* need is the106 Flutter's engine artifacts are network. What it does *not* need is the
111 writable-`ANDROID_HOME` dance, since nothing here writes into the store — so107 writable-`ANDROID_HOME` dance, since nothing here writes into the store — so
112 there is no `flutter/.home` on this path.108 there is no `flutter/.home` on this path.
113 109
114-nixGL off NixOS, for the reason `just cosmic run` needs it and `just tui` does110+nixGL off NixOS: Flutter paints through GL and the driver that can do that is
115-not: Flutter paints through GL and the driver that can do that is the host's.111+the host's.
116 112
117 `linux/` is the Flutter template's GTK runner, renamed — `frq` rather than113 `linux/` is the Flutter template's GTK runner, renamed — `frq` rather than
118 `cljd_flutter`, and `uk.nandi.frq` rather than `com.example.cljd_flutter`, so114 `cljd_flutter`, and `uk.nandi.frq` rather than `com.example.cljd_flutter`, so
@@ -125,9 +121,9 @@ Two things the desktop target changed in the Dart, both of them cases where
125 * `frq.io.dart/write-private-file!` was a plain write, on the grounds that121 * `frq.io.dart/write-private-file!` was a plain write, on the grounds that
126 Android storage is already private to the app. On a Linux desktop it is not:122 Android storage is already private to the app. On a Linux desktop it is not:
127 the file lands under the XDG data directory with the process umask, and it123 the file lands under the XDG data directory with the process umask, and it
128- holds a broker token. The desktop branch now does what `frq.io.jolt` does —124+ holds a broker token. The desktop branch creates the file, restricts it, then
129- create, chmod, then write — and Dart having no chmod is why that is a125+ writes — and Dart having no chmod is why that is a three-step process rather
130- process.126+ than a mode argument.
131 * `frq.oauth.dart` handed the capture page `frq://auth` unconditionally, to127 * `frq.oauth.dart` handed the capture page `frq://auth` unconditionally, to
132 raise the app from behind Chrome. Nothing on a desktop claims that scheme, so128 raise the app from behind Chrome. Nothing on a desktop claims that scheme, so
133 it is now nil there — which `core/capture-html` already documented as the129 it is now nil there — which `core/capture-html` already documented as the
@@ -169,52 +165,35 @@ targets.
169 It is signed with `~/.android/debug.keystore`, through the template's165 It is signed with `~/.android/debug.keystore`, through the template's
170 `signingConfig = signingConfigs.getByName("debug")` — which release builds also166 `signingConfig = signingConfigs.getByName("debug")` — which release builds also
171 use, so `flutter build apk --release` is not shippable until a real167 use, so `flutter build apk --release` is not shippable until a real
172-`signingConfigs.release` is wired up. The jolt APK's key was generated inside168+`signingConfigs.release` is wired up.
173-its own nix derivation and never written anywhere, which is why the first
174-install over it needed an uninstall: Android will not update a package across a
175-signature change.
176 169
177 ## The screens are not rewritten170 ## The screens are not rewritten
178 171
179-`frq.hiccup` is a glimmer backend, the same way glimmer-cosmic and glimmer-tui172+`frq.hiccup` is an interpreter, not a port. It walks the hiccup the screens
180-are. It walks the hiccup `frq.app` already produces and emits Flutter widgets,173+already produce — `[:vbox {:spacing 6} ...]` over about twenty tags, naming no
181-so the screens are shared rather than forked.174+toolkit — and emits Flutter widgets, so `common/frq/screens/` is shared across
175+all three targets rather than forked per target.
182 176
183 This is worth being precise about, because the first read of the port said177 This is worth being precise about, because the first read of the port said
184-otherwise. Measured against the source:178+otherwise. The screens were data all along; what genuinely had to be written
185-179+was the other end — the namespaces that touch the host. Which is what `frq.io`
186-* `frq.state` is 1,930 lines and makes **zero** glimmer calls. Its whole180+is for, and where `dart:io` paid for the whole exercise.
187- dependency on glimmer is `:refer [atom]` — it shadows core's `atom` with a
188- ratom, and everything after that is `swap!`, `reset!` and `deref`.
189-* `frq.app` is 1,834 lines and makes **one**: `r/reaction`. The rest is data —
190- `[:vbox {:spacing 6} ...]` over about twenty tags, naming no toolkit.
191-
192-So what a Flutter port needs is an interpreter for that data, not a rewrite of
193-it. What genuinely has to be ported is the other end: `frq.irc`, `frq.atproto`,
194-`frq.oauth`, `frq.avatars`, `frq.media`, `frq.profile`, `frq.platform` — the
195-namespaces that touch the host. Which is what `frq.io` is for, and where
196-`dart:io` pays for the whole exercise.
197 181
198-What `frq.hiccup` does not do is glimmer's reconciliation: Flutter rebuilds182+What `frq.hiccup` does not do is reconciliation of its own: Flutter rebuilds
199 from the top and diffs its own element tree, so a cell firing rebuilds the183 from the top and diffs its own element tree, so a cell firing rebuilds the
200 screen rather than the subtree that read it. Fine at this size.184 screen rather than the subtree that read it. Fine at this size.
201 185
202-`frq.main` paints `frq.app`'s own connect screen, out of
203-`common/frq/screens/connect.cljc` — the same file the desktop renders. What it
204-reads is `frq.cells` and what it calls is `frq.actions`, and each platform
205-fills those in: `frq.state`'s reducers on the desktop, dart:io here.
206-
207 ## Every screen shared, and the root that picks between them186 ## Every screen shared, and the root that picks between them
208 187
209-All of them are in `common/frq/screens/` now — `connect`, `chats`, `chat`,188+All of them are in `common/frq/screens/` — `connect`, `chats`, `chat`,
210-`settings` (with Discover and the tab frame) and `app`, which carries the189+`settings` (with Discover and the tab frame) and `app`, which carries the split
211-split view, the three dialogs and the decision about which screen is showing.190+view, the three dialogs and the decision about which screen is showing. Each
212-The phone renders `[screens/app]` and nothing else; it was switching by hand191+entry point renders `[screens/app]` and nothing else.
213-until that moved. With the cells under them in `frq.cells`, with the cells under them in `frq.cells`, the derivations in192+
214-`frq.rooms`, the backend metrics in `frq.metrics` and everything a screen193+Under them: the cells in `frq.cells`, the derivations in `frq.rooms`, the
215-cannot do itself behind `frq.actions`. `frq.app` is 226 lines and was 1,744. What is left in it is the part that194+chrome metrics in `frq.metrics`, and everything a screen cannot do itself
216-cannot move: `derived` and the two asset lookups, which are a glimmer reaction195+behind `frq.actions` — which each target fills in for itself, the way it fills
217-over a fetch-and-cache, the metrics aliases `frq.tui` writes, and `start!`.196+in `frq.io`.
218 197
219 What `Length::Fill` means took four goes to get right, and the rule it ended198 What `Length::Fill` means took four goes to get right, and the rule it ended
220 at is worth stating once: a child that fills is Flutter's `Expanded`, the199 at is worth stating once: a child that fills is Flutter's `Expanded`, the
@@ -258,43 +237,26 @@ red error box does not appear, and the log stays empty. `FlutterError.onError`
258 is where they go, and installing that handler in `frq.main` should have been237 is where they go, and installing that handler in `frq.main` should have been
259 the first move rather than the tenth.238 the first move rather than the tenth.
260 239
261-## The order to do the rest in240+## What is still open
262-241+
263-1. ~~**`frq.irc`**~~ — started. The parser is `common/frq/irc/parse.cljc` now,242+The port is finished in the sense that matters — there is no other tree left to
264- shared, with `frq.irc` re-exporting it so the twenty-three `irc/tag-value`243+move from. What remains is work the port never covered:
265- and `irc/nick-of` call sites in `frq.state` and `frq.av` did not move. The244+
266- transport is `frq.net.dart`: `SecureSocket`, a `Stream`, no thread and no245+1. **The OAuth capture on Android.** `common/frq/oauth/core.cljc` has the URL,
267- outbox. **TLS reaches irc.freeq.at:6697 from the phone** — registration and246+ the handoff payload and the session refresh. What has no Android answer is
268- MOTD, which is the thing the jolt APK could never do. What is left of this247+ the capture itself: the flow was written for a client that binds a loopback
269- one is the protocol half: CAP, SASL and the idle-ping logic still live in248+ socket and serves a page the browser redirects to, and an Android app cannot
270- `src/frq/irc.clj` and want `frq.msgsig` and `frq.atproto` under them first.249+ listen on localhost for a browser it does not own. That wants an app link or
271-2. ~~**`frq.atproto`**~~ — done. `common/frq/atproto/core.cljc` is the JSON,250+ a custom scheme, an intent filter, and a redirect URI the broker will accept
272- the base64url, the SASL payloads, and a `-req`/`-parse` pair per step of the251+ — a decision about freeq's broker, not a porting problem. The web build has
273- flow; `frq.atproto` and `frq.atproto.dart` supply the middle. **handle → DID252+ its own answer in `frq.oauth.web`, and `just web-local` is why it only
274- → PDS resolves on the phone**, over `HttpClient`.253+ completes on localhost.
275-254+2. **Calls.** The signaling is IRC and is still in the screens; the media plane
276- **`frq.oauth`** — half done. `common/frq/oauth/core.cljc` has the URL, the255+ it drove was `libjoltmoq` — Opus, H.264, V4L2, ALSA, MoQ over QUIC — under
277- handoff payload and the session refresh. What has no Android answer yet is256+ the retired jolt half, and none of it crosses. The Call controls are wired
278- the capture: the desktop binds a loopback socket and serves a page the257+ to actions no target installs, so they are dead buttons today. Flutter's
279- browser redirects to, and an Android app cannot listen on localhost for a258+ camera and audio plugins are the way back in, and that is its own project:
280- browser it does not own. That wants an app link or a custom scheme, an259+ either remove the controls or build behind them.
281- intent filter, and a redirect URI the broker will accept — a decision about
282- freeq's broker, not a porting problem.
283-3. ~~**`frq.msgsig`**~~ — done. The signing is shared; the four primitives
284- under it are `frq.crypto`, which the desktop answers with the same OpenSSL
285- it loads for TLS and the phone with `package:ed25519_edwards` and
286- `package:crypto`, both pure Dart and both synchronous — a signature is
287- minted in the middle of sending a reaction and there is nothing to await
288- on. Verified on both against RFC 8032 test 1: same public key, same
289- signature, byte for byte.
290-
291- **`frq.wire`** (81) still wants the seam extended.
292-4. **`frq.avatars`**, **`frq.media`**, **`frq.profile`**, **`frq.platform`** —
293- small, and mostly fetch-and-cache.
294-5. **`frq.state`** moves to `common/` as `.cljc`, with `atom` resolved per
295- platform by reader conditional.
296-6. **`frq.app`** follows it, and the tags it uses that `frq.hiccup` does not
297- cover yet paint as an orange `?tag` until they do.
298 260
299 ## What a missing tag property looks like261 ## What a missing tag property looks like
300 262
added flutter/src/frq/core/ffi.cljd +76 -0
new file mode 100644
@@ -0,0 +1,76 @@
1+(ns frq.core.ffi
2+ "Loading `libfrqcore.so`, and the two rules for talking to it.
3+
4+ **NOT YET COMPILED.** Nothing requires this namespace, so no build has
5+ type-checked a line of it. The Nim side it calls is tested and its ABI is
6+ proven from C (`nim/`, and `just nim-test`); this half is the shape the
7+ binding should take and not a working one. Before trusting it: add `ffi` to
8+ `flutter/pubspec.yaml`, require this from a native entry point, and build
9+ `just flutter-desktop` `.lookupFunction` needs native and Dart type
10+ arguments, and how ClojureDart spells those is the first thing to find out.
11+
12+ The Nim core is the portable logic see `nim/README.md` for why it is
13+ there rather than under `common/`. This namespace is the whole of what knows
14+ it is a native library: everything above it calls ordinary functions.
15+
16+ **Not required by `frq.main`.** `dart:ffi` has no web implementation, and
17+ `clojure -M:cljd compile` walks out from one namespace, so requiring this
18+ from the shared entry point would break the web build to serve the native
19+ ones the same trap `frq.main-web` exists to avoid. A caller reaches for
20+ `frq.core.irc`, which decides.
21+
22+ The two rules, both of them wrapped here so no call site repeats them:
23+
24+ * `frq_init` runs once before anything else, setting Nim's runtime up.
25+ `library` does it on the way out, so holding the handle means it is done.
26+ * Every string the core returns is **ours to free**, with `frq_free`.
27+ `take-string!` is that: read the bytes, hand the pointer back, and do it
28+ in a `finally` so a throw between the two does not leak. Nim's allocator
29+ is not Dart's, so `calloc.free` on one of these pointers is undefined
30+ rather than merely wrong."
31+ (:require ["dart:ffi" :as ffi]
32+ ["dart:io" :as io]
33+ ["package:ffi/ffi.dart" :as pffi]))
34+
35+;; The name the loader is given, per platform. Android resolves a bare soname
36+;; out of the APK's lib/<abi>/ directory; a desktop build has no such rule, so
37+;; it is looked for beside the executable and then on the usual path.
38+(def ^:private lib-name "libfrqcore.so")
39+
40+(defn- open-library []
41+ (if (.-isAndroid io/Platform)
42+ (ffi/DynamicLibrary.open lib-name)
43+ (try
44+ (ffi/DynamicLibrary.open lib-name)
45+ (catch Object _
46+ ;; The development path: `just nim-lib` writes into build/nim, which
47+ ;; is not anywhere a loader looks. Named explicitly rather than by
48+ ;; setting LD_LIBRARY_PATH, because a variable set in a launcher is a
49+ ;; thing that works until someone starts the binary another way.
50+ (ffi/DynamicLibrary.open "build/nim/libfrqcore.so")))))
51+
52+(defonce ^{:doc "The handle, opened once and initialised on the way out."}
53+ library
54+ (delay
55+ (let [lib (open-library)
56+ init (.lookupFunction lib "frq_init")]
57+ (init)
58+ lib)))
59+
60+(defn free!
61+ "Hand a pointer the core returned back to it."
62+ [^ffi/Pointer p]
63+ (when-not (.-isNull (.-address p))
64+ ((.lookupFunction @library "frq_free") p)))
65+
66+(defn take-string!
67+ "The string at `p`, with `p` freed afterwards — nil for a null pointer.
68+
69+ The `finally` is the point: `toDartString` decodes UTF-8 and can throw on
70+ bytes that are not, and a throw between the read and the free is a leak
71+ that only shows up under the malformed input this core exists to survive."
72+ [^ffi/Pointer p]
73+ (when (not= 0 (.-address p))
74+ (try
75+ (.toDartString (.cast p))
76+ (finally (free! p)))))
new file mode 100644
@@ -0,0 +1,76 @@
1+(ns frq.core.ffi
2+ "Loading `libfrqcore.so`, and the two rules for talking to it.
3+
4+ **NOT YET COMPILED.** Nothing requires this namespace, so no build has
5+ type-checked a line of it. The Nim side it calls is tested and its ABI is
6+ proven from C (`nim/`, and `just nim-test`); this half is the shape the
7+ binding should take and not a working one. Before trusting it: add `ffi` to
8+ `flutter/pubspec.yaml`, require this from a native entry point, and build
9+ `just flutter-desktop` `.lookupFunction` needs native and Dart type
10+ arguments, and how ClojureDart spells those is the first thing to find out.
11+
12+ The Nim core is the portable logic see `nim/README.md` for why it is
13+ there rather than under `common/`. This namespace is the whole of what knows
14+ it is a native library: everything above it calls ordinary functions.
15+
16+ **Not required by `frq.main`.** `dart:ffi` has no web implementation, and
17+ `clojure -M:cljd compile` walks out from one namespace, so requiring this
18+ from the shared entry point would break the web build to serve the native
19+ ones the same trap `frq.main-web` exists to avoid. A caller reaches for
20+ `frq.core.irc`, which decides.
21+
22+ The two rules, both of them wrapped here so no call site repeats them:
23+
24+ * `frq_init` runs once before anything else, setting Nim's runtime up.
25+ `library` does it on the way out, so holding the handle means it is done.
26+ * Every string the core returns is **ours to free**, with `frq_free`.
27+ `take-string!` is that: read the bytes, hand the pointer back, and do it
28+ in a `finally` so a throw between the two does not leak. Nim's allocator
29+ is not Dart's, so `calloc.free` on one of these pointers is undefined
30+ rather than merely wrong."
31+ (:require ["dart:ffi" :as ffi]
32+ ["dart:io" :as io]
33+ ["package:ffi/ffi.dart" :as pffi]))
34+
35+;; The name the loader is given, per platform. Android resolves a bare soname
36+;; out of the APK's lib/<abi>/ directory; a desktop build has no such rule, so
37+;; it is looked for beside the executable and then on the usual path.
38+(def ^:private lib-name "libfrqcore.so")
39+
40+(defn- open-library []
41+ (if (.-isAndroid io/Platform)
42+ (ffi/DynamicLibrary.open lib-name)
43+ (try
44+ (ffi/DynamicLibrary.open lib-name)
45+ (catch Object _
46+ ;; The development path: `just nim-lib` writes into build/nim, which
47+ ;; is not anywhere a loader looks. Named explicitly rather than by
48+ ;; setting LD_LIBRARY_PATH, because a variable set in a launcher is a
49+ ;; thing that works until someone starts the binary another way.
50+ (ffi/DynamicLibrary.open "build/nim/libfrqcore.so")))))
51+
52+(defonce ^{:doc "The handle, opened once and initialised on the way out."}
53+ library
54+ (delay
55+ (let [lib (open-library)
56+ init (.lookupFunction lib "frq_init")]
57+ (init)
58+ lib)))
59+
60+(defn free!
61+ "Hand a pointer the core returned back to it."
62+ [^ffi/Pointer p]
63+ (when-not (.-isNull (.-address p))
64+ ((.lookupFunction @library "frq_free") p)))
65+
66+(defn take-string!
67+ "The string at `p`, with `p` freed afterwards — nil for a null pointer.
68+
69+ The `finally` is the point: `toDartString` decodes UTF-8 and can throw on
70+ bytes that are not, and a throw between the read and the free is a leak
71+ that only shows up under the malformed input this core exists to survive."
72+ [^ffi/Pointer p]
73+ (when (not= 0 (.-address p))
74+ (try
75+ (.toDartString (.cast p))
76+ (finally (free! p)))))
modified flutter/src/frq/io/dart.cljd +8 -10
@@ -1,16 +1,14 @@
11 (ns frq.io.dart
22 "Flutter's answers to `frq.io`, over dart:io.
33
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.
4+ `dart:io` has TLS, sockets and a filesystem in the runtime, which is the
5+ reason the seam exists at all: this used to be the half of frq where login
6+ did not work, because the runtime under it reached OpenSSL through the
7+ dynamic loader and Android has no public libssl.
8+
9+ This cannot install itself at load time: the app's storage directory arrives
10+ from path_provider as a Future, so the Flutter entry point awaits it and
11+ calls `install!` with the answer before it runs anything else.
1412
1513 Two Flutter targets share this file, not one: `just apk` and
1614 `just flutter-desktop` compile the same ClojureDart, so \"the phone\" is no
@@ -1,16 +1,14 @@
1 (ns frq.io.dart1 (ns frq.io.dart
2 "Flutter's answers to `frq.io`, over dart:io.2 "Flutter's answers to `frq.io`, over dart:io.
3 3
4- The mirror of `frq.io.jolt`, and the reason the seam exists at all: every4+ `dart:io` has TLS, sockets and a filesystem in the runtime, which is the
5- line here is something the desktop gets from libc through jolt.ffi and the5+ reason the seam exists at all: this used to be the half of frq where login
6- APK could not get at all. `dart:io` has TLS, sockets and a filesystem in the6+ did not work, because the runtime under it reached OpenSSL through the
7- runtime, so the Android half of frq stops being the half where login does not7+ dynamic loader and Android has no public libssl.
8- work.8+
9-9+ This cannot install itself at load time: the app's storage directory arrives
10- Unlike its jolt counterpart this one cannot install itself at load time: the10+ from path_provider as a Future, so the Flutter entry point awaits it and
11- app's storage directory arrives from path_provider as a Future, so the11+ calls `install!` with the answer before it runs anything else.
12- Flutter entry point awaits it and calls `install!` with the answer before it
13- runs anything else.
14 12
15 Two Flutter targets share this file, not one: `just apk` and13 Two Flutter targets share this file, not one: `just apk` and
16 `just flutter-desktop` compile the same ClojureDart, so \"the phone\" is no14 `just flutter-desktop` compile the same ClojureDart, so \"the phone\" is no
modified flutter/src/frq/theme.cljd +5 -11
@@ -1,18 +1,12 @@
11 (ns frq.theme
2- "COSMIC's design tokens, as Flutter values.
2+ "The design tokens, as Flutter values.
33
4- The colours, radii and spacing are the user's own, read out of cosmic-config
5- by tools/cosmic2cljd.py into `frq.theme.cosmic` — so the phone is painted in
6- whatever accent and surfaces COSMIC Settings is set to, rather than in a
7- guess at what COSMIC looks like. `just theme` moves them.
8-
9- What is not in cosmic-config is the typography scale: libcosmic carries
10- title1..title4, heading, body and caption as code rather than as
11- configuration. Those numbers are here, and the mapping from glimmer's tags
12- to them is `crates/jolt-cosmic`'s — `:title` is title3, `:title-2` is title4,
4+ The colours, radii and spacing are in `frq.theme.tokens`; the typography
5+ scale is here, because it never came from configuration. The tag names are
6+ the ones the screens use — `:title` is title3, `:title-2` is title4,
137 `:label` is body, `:dim-label` is caption."
148 (:require ["package:flutter/material.dart" :as m]
15- [frq.theme.cosmic :as c]))
9+ [frq.theme.tokens :as c]))
1610
1711 ;; ------------------------------------------------------------------ scale
1812
@@ -1,18 +1,12 @@
1 (ns frq.theme1 (ns frq.theme
2- "COSMIC's design tokens, as Flutter values.2+ "The design tokens, as Flutter values.
3 3
4- The colours, radii and spacing are the user's own, read out of cosmic-config4+ The colours, radii and spacing are in `frq.theme.tokens`; the typography
5- by tools/cosmic2cljd.py into `frq.theme.cosmic` — so the phone is painted in5+ scale is here, because it never came from configuration. The tag names are
6- whatever accent and surfaces COSMIC Settings is set to, rather than in a6+ the ones the screens use — `:title` is title3, `:title-2` is title4,
7- guess at what COSMIC looks like. `just theme` moves them.
8-
9- What is not in cosmic-config is the typography scale: libcosmic carries
10- title1..title4, heading, body and caption as code rather than as
11- configuration. Those numbers are here, and the mapping from glimmer's tags
12- to them is `crates/jolt-cosmic`'s — `:title` is title3, `:title-2` is title4,
13 `:label` is body, `:dim-label` is caption."7 `:label` is body, `:dim-label` is caption."
14 (:require ["package:flutter/material.dart" :as m]8 (:require ["package:flutter/material.dart" :as m]
15- [frq.theme.cosmic :as c]))9+ [frq.theme.tokens :as c]))
16 10
17 ;; ------------------------------------------------------------------ scale11 ;; ------------------------------------------------------------------ scale
18 12
deleted flutter/src/frq/theme/cosmic.cljd +0 -52
deleted file mode 100644
@@ -1,52 +0,0 @@
1-(ns frq.theme.cosmic
2- "The COSMIC theme, as it was on the machine that built this.
3-
4- GENERATED by tools/cosmic2cljd.py `just theme`. Do not edit.
5-
6- libcosmic asks cosmic-config for these at run time, so `just cosmic run`
7- follows COSMIC Settings as it changes. A phone has no cosmic-config, so
8- the APK carries them instead. That is the one real difference between
9- the two, and it is why this file is in git."
10- (:require ["package:flutter/material.dart" :as m]))
11-
12-;; Dark theme, from ~/.config/cosmic.
13-(def dark? true)
14-
15-;; accent.base = #F4E3CFFF
16-(def accent (m/Color. 0xFFF4E3CF))
17-;; accent.on = #000000FF
18-(def on-accent (m/Color. 0xFF000000))
19-;; background.base = #202833FF
20-(def bg (m/Color. 0xFF202833))
21-;; background.component.on = #CCD1D7FF
22-(def on-bg (m/Color. 0xFFCCD1D7))
23-;; background.component.base = #343C48FF
24-(def component (m/Color. 0xFF343C48))
25-;; background.component.hover = #48505AFF
26-(def component-hover (m/Color. 0xFF48505A))
27-;; background.component.divider = #CCD1D733
28-(def divider (m/Color. 0x33CCD1D7))
29-;; primary.base = #2C3440FF
30-(def card (m/Color. 0xFF2C3440))
31-;; primary.component.base = #3B4450FF
32-(def card-component (m/Color. 0xFF3B4450))
33-;; primary.component.on = #FFFFFFFF
34-(def on-card (m/Color. 0xFFFFFFFF))
35-;; destructive.base = #FDA1A0FF
36-(def destructive (m/Color. 0xFFFDA1A0))
37-;; destructive.on = #000000FF
38-(def on-destructive (m/Color. 0xFF000000))
39-;; success.base = #92CF9CFF
40-(def success (m/Color. 0xFF92CF9C))
41-
42-;; corner_radii
43-(def radius-xs 2.0)
44-(def radius-s 8.0)
45-(def radius-m 8.0)
46-
47-;; spacing
48-(def space-xxxs 4.0)
49-(def space-xxs 8.0)
50-(def space-xs 12.0)
51-(def space-s 16.0)
52-(def space-m 24.0)
deleted file mode 100644
@@ -1,52 +0,0 @@
1-(ns frq.theme.cosmic
2- "The COSMIC theme, as it was on the machine that built this.
3-
4- GENERATED by tools/cosmic2cljd.py `just theme`. Do not edit.
5-
6- libcosmic asks cosmic-config for these at run time, so `just cosmic run`
7- follows COSMIC Settings as it changes. A phone has no cosmic-config, so
8- the APK carries them instead. That is the one real difference between
9- the two, and it is why this file is in git."
10- (:require ["package:flutter/material.dart" :as m]))
11-
12-;; Dark theme, from ~/.config/cosmic.
13-(def dark? true)
14-
15-;; accent.base = #F4E3CFFF
16-(def accent (m/Color. 0xFFF4E3CF))
17-;; accent.on = #000000FF
18-(def on-accent (m/Color. 0xFF000000))
19-;; background.base = #202833FF
20-(def bg (m/Color. 0xFF202833))
21-;; background.component.on = #CCD1D7FF
22-(def on-bg (m/Color. 0xFFCCD1D7))
23-;; background.component.base = #343C48FF
24-(def component (m/Color. 0xFF343C48))
25-;; background.component.hover = #48505AFF
26-(def component-hover (m/Color. 0xFF48505A))
27-;; background.component.divider = #CCD1D733
28-(def divider (m/Color. 0x33CCD1D7))
29-;; primary.base = #2C3440FF
30-(def card (m/Color. 0xFF2C3440))
31-;; primary.component.base = #3B4450FF
32-(def card-component (m/Color. 0xFF3B4450))
33-;; primary.component.on = #FFFFFFFF
34-(def on-card (m/Color. 0xFFFFFFFF))
35-;; destructive.base = #FDA1A0FF
36-(def destructive (m/Color. 0xFFFDA1A0))
37-;; destructive.on = #000000FF
38-(def on-destructive (m/Color. 0xFF000000))
39-;; success.base = #92CF9CFF
40-(def success (m/Color. 0xFF92CF9C))
41-
42-;; corner_radii
43-(def radius-xs 2.0)
44-(def radius-s 8.0)
45-(def radius-m 8.0)
46-
47-;; spacing
48-(def space-xxxs 4.0)
49-(def space-xxs 8.0)
50-(def space-xs 12.0)
51-(def space-s 16.0)
52-(def space-m 24.0)
added flutter/src/frq/theme/tokens.cljd +35 -0
new file mode 100644
@@ -0,0 +1,35 @@
1+(ns frq.theme.tokens
2+ "The palette and the metrics, as numbers.
3+
4+ These began as a read of a COSMIC theme and kept its proportions after the
5+ libcosmic frontend was retired — the app is painted in them directly now,
6+ so they are source rather than the generated file they used to be. Edit
7+ them here; there is no generator to re-run."
8+ (:require ["package:flutter/material.dart" :as m]))
9+
10+;; Dark.
11+(def dark? true)
12+
13+(def accent (m/Color. 0xFFF4E3CF))
14+(def on-accent (m/Color. 0xFF000000))
15+(def bg (m/Color. 0xFF202833))
16+(def on-bg (m/Color. 0xFFCCD1D7))
17+(def component (m/Color. 0xFF343C48))
18+(def component-hover (m/Color. 0xFF48505A))
19+(def divider (m/Color. 0x33CCD1D7))
20+(def card (m/Color. 0xFF2C3440))
21+(def card-component (m/Color. 0xFF3B4450))
22+(def on-card (m/Color. 0xFFFFFFFF))
23+(def destructive (m/Color. 0xFFFDA1A0))
24+(def on-destructive (m/Color. 0xFF000000))
25+(def success (m/Color. 0xFF92CF9C))
26+
27+(def radius-xs 2.0)
28+(def radius-s 8.0)
29+(def radius-m 8.0)
30+
31+(def space-xxxs 4.0)
32+(def space-xxs 8.0)
33+(def space-xs 12.0)
34+(def space-s 16.0)
35+(def space-m 24.0)
new file mode 100644
@@ -0,0 +1,35 @@
1+(ns frq.theme.tokens
2+ "The palette and the metrics, as numbers.
3+
4+ These began as a read of a COSMIC theme and kept its proportions after the
5+ libcosmic frontend was retired — the app is painted in them directly now,
6+ so they are source rather than the generated file they used to be. Edit
7+ them here; there is no generator to re-run."
8+ (:require ["package:flutter/material.dart" :as m]))
9+
10+;; Dark.
11+(def dark? true)
12+
13+(def accent (m/Color. 0xFFF4E3CF))
14+(def on-accent (m/Color. 0xFF000000))
15+(def bg (m/Color. 0xFF202833))
16+(def on-bg (m/Color. 0xFFCCD1D7))
17+(def component (m/Color. 0xFF343C48))
18+(def component-hover (m/Color. 0xFF48505A))
19+(def divider (m/Color. 0x33CCD1D7))
20+(def card (m/Color. 0xFF2C3440))
21+(def card-component (m/Color. 0xFF3B4450))
22+(def on-card (m/Color. 0xFFFFFFFF))
23+(def destructive (m/Color. 0xFFFDA1A0))
24+(def on-destructive (m/Color. 0xFF000000))
25+(def success (m/Color. 0xFF92CF9C))
26+
27+(def radius-xs 2.0)
28+(def radius-s 8.0)
29+(def radius-m 8.0)
30+
31+(def space-xxxs 4.0)
32+(def space-xxs 8.0)
33+(def space-xs 12.0)
34+(def space-s 16.0)
35+(def space-m 24.0)
modified justfile +73 -232
@@ -3,18 +3,17 @@
33 # That indirection bought one thing worth having, a shared way to reach nix on
44 # a host that keeps it in a container, and `nix` below is the whole of it.
55 #
6-# Every recipe that runs frq has the same shape: outside the dev shell, re-enter
7-# it and come back to this same recipe; inside, hand jolt the deps overrides and
8-# the library path the shell exported. The re-entry test is JOLT_NATIVE_LIB,
9-# which only the shell sets — no flag to forget, and no second code path for
10-# someone who runs `nix develop --command just cosmic run` by hand.
6+# Every recipe that builds has the same shape: outside the dev shell, re-enter
7+# it and come back to this same recipe; inside, do the work. The re-entry test
8+# is an environment variable only the shell sets — no flag to forget, and no
9+# second code path for someone who runs `nix develop --command just ...` by
10+# hand.
1111
1212 set shell := ["bash", "-euo", "pipefail", "-c"]
1313
1414 # Every recipe below is a `#!` script and passes its arguments on with "$@".
1515 # Without this that is empty in one — just interpolates into a shebang recipe
16-# rather than handing it argv — and `just tui --headless` silently ran the
17-# terminal instead.
16+# rather than handing it argv — and a recipe silently ignored its flags.
1817 set positional-arguments
1918
2019 # nix is not on every host this runs on: on the machine these recipes were
@@ -24,7 +23,7 @@ nix := `command -v nix >/dev/null 2>&1 && echo nix || echo "distrobox enter arch
2423
2524 # --max-jobs 0 is what sends the work to the `builders` entry rather than
2625 # compiling it here. Left to the default, nix prefers the local machine, and a
27-# cold jolt-native is egui, openh264 and quinn on a laptop — for a derivation a
26+# cold Flutter toolchain is a lot of compiling on a laptop — for a derivation a
2827 # remote builder has likely built already. FRQ_MAX_JOBS=auto is the way out on
2928 # a machine with no builder configured.
3029 jobs := env("FRQ_MAX_JOBS", "0")
@@ -32,22 +31,6 @@ jobs := env("FRQ_MAX_JOBS", "0")
3231 default:
3332 @just --list
3433
35-# Re-read the COSMIC theme into the APK.
36-#
37-# libcosmic asks cosmic-config for the accent and the surfaces at run time, so
38-# `just cosmic run` already follows COSMIC Settings as it changes. A phone has
39-# no cosmic-config, so the APK carries them instead — read here, on the machine
40-# that has them, and compiled in. That is the one real difference between the
41-# two, and it is why the generated file is in git rather than gitignored: a
42-# checkout on a machine with no COSMIC still builds.
43-#
44-# Run it after changing the theme in COSMIC Settings, then `just apk`.
45-theme:
46- #!/usr/bin/env bash
47- set -euo pipefail
48- cd "{{justfile_directory()}}"
49- python3 tools/cosmic2cljd.py flutter/src/frq/theme/cosmic.cljd
50-
5134 # The APK: ClojureDart compiled to Dart, then Flutter's Gradle build.
5235 #
5336 # Impure on purpose, and worth saying why rather than leaving it to be
@@ -162,183 +145,18 @@ apk action="build":
162145 *) echo "usage: just apk [build|install|run|log]" >&2; exit 1 ;;
163146 esac
164147
165-# Two halves, and the split is the point. The frq source is the files on disk,
166-# uncommitted edits and all. Everything under it — jolt, glimmer, glimmer-cosmic
167-# and the native objects — is the flake's, built rather than fetched.
168-#
169-# Deliberately not `nix run .#frq`. That builds the flake's own copy of the
170-# source, which is the tree as git has it — so an edit that has not been
171-# committed, or committed on a branch the command was not pointed at, runs as
172-# whatever was there before, silently. A run meant to answer "does my change
173-# work" has to be the files on disk.
174-#
175-# libcosmic paints through wgpu, so this needs nixGL off NixOS for the same
176-# reason the window always did: the real driver is the host's.
177-#
178-# There is no jvui and no vidya here any more — both were experiments. The
179-# window is libcosmic and the terminal is libjolttui, and those are the two.
180-#
181-# The app: this tree's source on the flake's everything-else, in the dev shell.
182-#
183-# Named for the backend rather than for the verb, the way `flutter-desktop`
184-# is: two desktop GUIs, neither of them the default one.
185-#
186-# just cosmic run [args...] open the window
187-cosmic action="run" *args:
188- #!/usr/bin/env bash
189- set -euo pipefail
190- cd "{{justfile_directory()}}"
191- if [ "{{action}}" != "run" ]; then
192- echo "usage: just cosmic run [args...]" >&2
193- exit 1
194- fi
195- shift
196- if [ -z "${JOLT_NATIVE_LIB:-}" ]; then
197- exec {{nix}} develop . --max-jobs {{jobs}} --command just cosmic run "$@"
198- fi
199-
200- deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
201- deps="$deps nandi/glimmer-cosmic {:local/root \"$GLIMMER_COSMIC_SRC\"}}}"
202-
203- export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
204-
205- runner=()
206- [ -e /run/current-system ] || runner=("$NIXGL")
207-
208- exec "${runner[@]}" jolt -Sdeps "$deps" -m frq.cosmic "$@"
209-
210-# `cosmic` with the other backend under it. Only libjolttui: `frq.app` names no
211-# backend at all any more, and `frq.tui` requires glimmer-tui so the one
212-# installed is the terminal.
213-#
214-# No nixGL here, unlike `cosmic`: a terminal wants nothing from the host's GL
215-# driver, which is the reason this output exists on machines that have none.
216-#
217-# What may appear in common/, checked — the half of the tree both backends
218-# compile. Needs nothing built: it reads the source, so it is the one check
219-# that runs anywhere, and CI runs exactly this.
148+# What may appear in common/, checked. Needs nothing built: it reads the
149+# source, so it is the one check that runs anywhere, and CI runs exactly this.
220150 check-common:
221151 #!/usr/bin/env bash
222152 python3 tools/check-common.py common
223153
224-# The same screens in a terminal. `just tui --headless` prints one screenshot.
225-tui *args:
226- #!/usr/bin/env bash
227- set -euo pipefail
228- cd "{{justfile_directory()}}"
229- if [ -z "${JOLT_NATIVE_LIB:-}" ]; then
230- exec {{nix}} develop . --max-jobs {{jobs}} --command just tui "$@"
231- fi
232-
233- deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
234- deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}"
235-
236- export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
237-
238- exec jolt -Sdeps "$deps" -m frq.tui "$@"
239-
240-# The same classpath as `tui`, with an nREPL on it instead of a `-main`: a
241-# session that can require `frq.tui` and then redefine a component while it is
242-# on screen, which is a second and not the minute a rebuild costs.
243-#
244-# just nrepl then, from an editor or a client on 7888:
245-# (require (quote frq.tui)) both backends, terminal installed last
246-# (frq.tui/-main "--headless" "--demo")
247-# (glimmer.core/reload!) re-mount after redefining a component
154+# The desktop GUI: the same screens the APK paints, on Flutter's Linux target.
248155 #
249-# `just repl nrepl-server` is the window's half of this — the same thing minus
250-# glimmer-tui. Port is nrepl-server's own positional: `just nrepl 7889`.
251-nrepl *args:
252- #!/usr/bin/env bash
253- set -euo pipefail
254- cd "{{justfile_directory()}}"
255- if [ -z "${JOLT_NATIVE_LIB:-}" ]; then
256- exec {{nix}} develop . --max-jobs {{jobs}} --command just nrepl "$@"
257- fi
258-
259- deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
260- deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}"
261-
262- export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
263-
264- exec jolt -Sdeps "$deps" nrepl-server "$@"
265-
266-# `jolt` in the repo root does not work on its own: deps.edn carries
267-# :jolt/native, so every invocation here loads libvidya and libjoltmoq before it
268-# reads a line, and dies naming the library if the loader cannot find them. So
269-# this is `cosmic` without the app — and `cosmic` is this with a window's
270-# worth of
271-# extra care about the GL driver.
272-#
273-# A jolt with the native libraries under it: a REPL, or `just repl nrepl-server`.
274-repl *args:
275- #!/usr/bin/env bash
276- set -euo pipefail
277- cd "{{justfile_directory()}}"
278- if [ -z "${JOLT_NATIVE_LIB:-}" ]; then
279- exec {{nix}} develop . --max-jobs {{jobs}} --command just repl "$@"
280- fi
281-
282- deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
283- deps="$deps nandi/glimmer-cosmic {:local/root \"$GLIMMER_COSMIC_SRC\"}}}"
284-
285- export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
286-
287- exec jolt -Sdeps "$deps" "$@"
288-
289-# Regenerate src/frq/moq/raw.clj from the libmoq_ffi we actually load.
290-#
291-# UniFFI embeds its interface metadata in the object, so `uniffi-bindgen
292-# --library` reads the truth out of the .so rather than a header shipped
293-# beside it. Those differ at one version number: the published Linux and
294-# Android objects are built without moq-ffi's `audio` and `video` features
295-# (206 functions, no codecs), while the shipped C header describes the Apple
296-# build (230). The object is the only source this recipe will accept.
297-#
298-# The bindgen must match the uniffi that built the object — 0.32 for moq-ffi
299-# 0.3.17 — and it is built once into the scratch dir rather than pinned into
300-# the flake: nothing in a normal build needs it, and a regeneration is a thing
301-# done when the moq-ffi pin moves, by hand, on purpose.
302-#
303-# just gen-moq # the loaded library
304-# just gen-moq path/to/libmoq_ffi.so
305-#
306-# Regenerate the libmoq_ffi bindings from the object's own embedded metadata.
307-gen-moq lib="":
308- #!/usr/bin/env bash
309- set -euo pipefail
310- lib="${1:-${JOLT_NATIVE_LIB:-}/libmoq_ffi.so}"
311- [ -f "$lib" ] || { echo "no libmoq_ffi.so at $lib — pass one: just gen-moq <path>" >&2; exit 1; }
312- work="${TMPDIR:-/tmp}/frq-gen-moq"
313- mkdir -p "$work/ubg/src"
314- cat > "$work/ubg/Cargo.toml" <<'TOML'
315- [package]
316- name = "ubg"
317- version = "0.1.0"
318- edition = "2021"
319- [[bin]]
320- name = "uniffi-bindgen"
321- path = "src/main.rs"
322- [dependencies]
323- uniffi = { version = "0.32", features = ["cli"] }
324- TOML
325- echo 'fn main() { uniffi::uniffi_bindgen_main() }' > "$work/ubg/src/main.rs"
326- ( cd "$work/ubg" && cargo build --release -q )
327- ( cd "$work/ubg" && ./target/release/uniffi-bindgen generate \
328- --library "$lib" --language python --out-dir "$work/py" --no-format )
329- python3 tools/py2jolt.py "$work"/py/*.py > "$work/body.clj"
330- { sed -n '1,/^ (:require \[jolt.ffi :as ffi\]))$/p' src/frq/moq/raw.clj; echo; cat "$work/body.clj"; } > "$work/raw.clj"
331- mv "$work/raw.clj" src/frq/moq/raw.clj
332- echo "wrote src/frq/moq/raw.clj ($(grep -c '^(ffi/defcfn' src/frq/moq/raw.clj) entry points)"
333-
334-# The other desktop GUI: the same screens, painted by Flutter instead of
335-# libcosmic.
336-#
337-# `just cosmic run` and this one are two frontends over one tree, and the
338-# split is the same one the APK already draws. Everything under `common/` — the
339-# screens, the cells, `frq.io` — is shared; what differs is who paints it and
340-# who answers the host. So this recipe is `just apk` with the Android half
341-# taken out: the same `clojure -M:cljd compile` over the same flutter/src,
156+# This recipe and `just apk` are two targets over one tree, and the split is
157+# the one the APK already draws. Everything under `common/` — the screens, the
158+# cells, `frq.io` — is shared; what differs is who answers the host. So this
159+# is `just apk` with the Android half taken out: the same `clojure -M:cljd compile` over the same flutter/src,
342160 # then Flutter's Linux target rather than its Android one. CMake and Ninja
343161 # instead of Gradle, `flutter/linux/` as the runner, no SDK and no JDK.
344162 #
@@ -347,8 +165,8 @@ gen-moq lib="":
347165 # writable-ANDROID_HOME dance — nothing here writes into the store — so there
348166 # is no `flutter/.home` on this path.
349167 #
350-# nixGL for the reason `cosmic` needs it and `tui` does not: Flutter paints
351-# through GL, and off NixOS the driver is the host's.
168+# nixGL because Flutter paints through GL, and off NixOS the driver is the
169+# host's.
352170 #
353171 # just flutter-desktop build the debug bundle
354172 # just flutter-desktop run build it and open the window
@@ -462,46 +280,19 @@ flutter-web action="build" port="8080":
462280 # start would be one dependency away from the point.
463281 exec "{{justfile_directory()}}/tools/build-web.sh" {{action}} {{port}}
464282
465-# The cosmic GUI as a directory anyone can unpack, with no nix on either end.
466-#
467-# `just cosmic run` is the edit loop — a devShell, this tree's source, a store
468-# path per dependency. This is the other end of the same program: a jolt
469-# binary, the backends out of jolt-native's portable tarball, libmoq_ffi off
470-# its release, glimmer and glimmer-cosmic at pinned revs, and one .c file
471-# compiled on the spot. `tools/desktop-toolchain.sh` fetches; nothing is
472-# built from source that somebody else has already published.
473-#
474-# It replaces `nix build .#appimage`, and what it drops with it is the reason
475-# that output existed. nix-appimage squashed a closure into one file so a
476-# machine without nix could run it, and the heaviest thing in that closure was
477-# a Mesa — carried so that nixGL had something to put the host driver in front
478-# of. There is no Mesa here, so there is no nixGL: the GL driver is the
479-# host's, the way it is for everything else on the machine.
480-#
481-# just desktop assemble build/desktop
482-# just desktop tar ...and tar it up for another machine
483-# just desktop run ...and start it
484-desktop action="build":
485- #!/usr/bin/env bash
486- set -euo pipefail
487- # A wrapper and nothing else, for the reason `flutter-web` is one: the
488- # container runs the same script, and a container that had to install
489- # `just` first would be one dependency away from the point.
490- exec "{{justfile_directory()}}/tools/build-desktop.sh" {{action}}
491-
492283 # The containers in `.modal/`, run on Modal rather than here. This machine
493284 # evaluates and Modal builds — see CLAUDE.md, which says so rather more
494285 # firmly — and these two recipes are the whole interface to that.
495286 #
496-# Named for where the work happens, the way `cosmic` and `flutter-desktop`
497-# are named for what paints: there is no re-entry test here because nothing
287+# Named for where the work happens, the way `flutter-desktop` is named for
288+# what paints: there is no re-entry test here because nothing
498289 # re-enters. There is no `nix` variable either, and that used to be because
499-# nix ran out there — now it is because two of these three containers have no
500-# nix in them at all.
290+# nix ran out there — now it is because neither of these containers has any
291+# nix in it at all.
501292 #
502-# just modal frq assemble the desktop bundle on Modal
503293 # just modal flutter-dev the incremental Flutter loop
504-modal container="frq" *args:
294+# just modal flutter-web the web bundle
295+modal container="flutter-dev" *args:
505296 #!/usr/bin/env bash
506297 set -euo pipefail
507298 cd "{{justfile_directory()}}"
@@ -557,9 +348,59 @@ web-local port="8080":
557348 # the sandbox bills until you do.
558349 #
559350 # just modal-shell flutter-dev, the usual one
560-# just modal-shell frq the desktop bundle container
351+# just modal-shell flutter-web the web bundle container
561352 modal-shell container="flutter-dev":
562353 #!/usr/bin/env bash
563354 set -euo pipefail
564355 cd "{{justfile_directory()}}"
565356 exec modal run ".modal/{{container}}/container.py" --shell
357+
358+# The Nim core's test suite.
359+#
360+# It needs no Flutter, no Dart and no Android SDK — which is the point of
361+# having the logic here rather than under `common/`: a rule about the IRC wire
362+# format can be checked in a second, on any machine, without a toolchain that
363+# takes minutes to enter.
364+#
365+# just nim-test the whole suite
366+# just nim-test tircparse one file
367+nim-test file="":
368+ #!/usr/bin/env bash
369+ set -euo pipefail
370+ cd "{{justfile_directory()}}"
371+ if [ -z "${FRQ_NIM:-}" ]; then
372+ exec {{nix}} develop .#nim --max-jobs {{jobs}} --command just nim-test "$@"
373+ fi
374+ cd nim
375+ if [ -n "{{file}}" ]; then
376+ exec nim c -r --hints:off --path:src "tests/{{file}}.nim"
377+ fi
378+ for t in tests/t*.nim; do
379+ echo "== $t"
380+ nim c -r --hints:off --path:src "$t"
381+ done
382+
383+# The Nim core as a shared library, into build/nim.
384+#
385+# `--mm:orc` rather than the default: this is a library loaded by a Dart
386+# process that owns its own lifetime, so reference counting with a cycle
387+# collector is the memory model that does not need a GC thread of its own or a
388+# stack it can scan.
389+#
390+# `-d:release` and not `-d:danger`: the bounds checks are what turn a
391+# malformed line off a socket into an exception instead of a read past the end
392+# of a buffer, and this parses exactly that.
393+nim-lib:
394+ #!/usr/bin/env bash
395+ set -euo pipefail
396+ cd "{{justfile_directory()}}"
397+ if [ -z "${FRQ_NIM:-}" ]; then
398+ exec {{nix}} develop .#nim --max-jobs {{jobs}} --command just nim-lib
399+ fi
400+ out="{{justfile_directory()}}/build/nim"
401+ mkdir -p "$out"
402+ cd nim
403+ nim c --app:lib --mm:orc -d:release --hints:off \
404+ --path:src --out:"$out/libfrqcore.so" src/frq_core.nim
405+ echo "built $out/libfrqcore.so"
406+ nm -D --defined-only "$out/libfrqcore.so" | grep ' T frq_' || true
@@ -3,18 +3,17 @@
3 # That indirection bought one thing worth having, a shared way to reach nix on3 # That indirection bought one thing worth having, a shared way to reach nix on
4 # a host that keeps it in a container, and `nix` below is the whole of it.4 # a host that keeps it in a container, and `nix` below is the whole of it.
5 #5 #
6-# Every recipe that runs frq has the same shape: outside the dev shell, re-enter6+# Every recipe that builds has the same shape: outside the dev shell, re-enter
7-# it and come back to this same recipe; inside, hand jolt the deps overrides and7+# it and come back to this same recipe; inside, do the work. The re-entry test
8-# the library path the shell exported. The re-entry test is JOLT_NATIVE_LIB,8+# is an environment variable only the shell sets — no flag to forget, and no
9-# which only the shell sets — no flag to forget, and no second code path for9+# second code path for someone who runs `nix develop --command just ...` by
10-# someone who runs `nix develop --command just cosmic run` by hand.10+# hand.
11 11
12 set shell := ["bash", "-euo", "pipefail", "-c"]12 set shell := ["bash", "-euo", "pipefail", "-c"]
13 13
14 # Every recipe below is a `#!` script and passes its arguments on with "$@".14 # Every recipe below is a `#!` script and passes its arguments on with "$@".
15 # Without this that is empty in one — just interpolates into a shebang recipe15 # Without this that is empty in one — just interpolates into a shebang recipe
16-# rather than handing it argv — and `just tui --headless` silently ran the16+# rather than handing it argv — and a recipe silently ignored its flags.
17-# terminal instead.
18 set positional-arguments17 set positional-arguments
19 18
20 # nix is not on every host this runs on: on the machine these recipes were19 # nix is not on every host this runs on: on the machine these recipes were
@@ -24,7 +23,7 @@ nix := `command -v nix >/dev/null 2>&1 && echo nix || echo "distrobox enter arch
24 23
25 # --max-jobs 0 is what sends the work to the `builders` entry rather than24 # --max-jobs 0 is what sends the work to the `builders` entry rather than
26 # compiling it here. Left to the default, nix prefers the local machine, and a25 # compiling it here. Left to the default, nix prefers the local machine, and a
27-# cold jolt-native is egui, openh264 and quinn on a laptop — for a derivation a26+# cold Flutter toolchain is a lot of compiling on a laptop — for a derivation a
28 # remote builder has likely built already. FRQ_MAX_JOBS=auto is the way out on27 # remote builder has likely built already. FRQ_MAX_JOBS=auto is the way out on
29 # a machine with no builder configured.28 # a machine with no builder configured.
30 jobs := env("FRQ_MAX_JOBS", "0")29 jobs := env("FRQ_MAX_JOBS", "0")
@@ -32,22 +31,6 @@ jobs := env("FRQ_MAX_JOBS", "0")
32 default:31 default:
33 @just --list32 @just --list
34 33
35-# Re-read the COSMIC theme into the APK.
36-#
37-# libcosmic asks cosmic-config for the accent and the surfaces at run time, so
38-# `just cosmic run` already follows COSMIC Settings as it changes. A phone has
39-# no cosmic-config, so the APK carries them instead — read here, on the machine
40-# that has them, and compiled in. That is the one real difference between the
41-# two, and it is why the generated file is in git rather than gitignored: a
42-# checkout on a machine with no COSMIC still builds.
43-#
44-# Run it after changing the theme in COSMIC Settings, then `just apk`.
45-theme:
46- #!/usr/bin/env bash
47- set -euo pipefail
48- cd "{{justfile_directory()}}"
49- python3 tools/cosmic2cljd.py flutter/src/frq/theme/cosmic.cljd
50-
51 # The APK: ClojureDart compiled to Dart, then Flutter's Gradle build.34 # The APK: ClojureDart compiled to Dart, then Flutter's Gradle build.
52 #35 #
53 # Impure on purpose, and worth saying why rather than leaving it to be36 # Impure on purpose, and worth saying why rather than leaving it to be
@@ -162,183 +145,18 @@ apk action="build":
162 *) echo "usage: just apk [build|install|run|log]" >&2; exit 1 ;;145 *) echo "usage: just apk [build|install|run|log]" >&2; exit 1 ;;
163 esac146 esac
164 147
165-# Two halves, and the split is the point. The frq source is the files on disk,148+# What may appear in common/, checked. Needs nothing built: it reads the
166-# uncommitted edits and all. Everything under it — jolt, glimmer, glimmer-cosmic149+# source, so it is the one check that runs anywhere, and CI runs exactly this.
167-# and the native objects — is the flake's, built rather than fetched.
168-#
169-# Deliberately not `nix run .#frq`. That builds the flake's own copy of the
170-# source, which is the tree as git has it — so an edit that has not been
171-# committed, or committed on a branch the command was not pointed at, runs as
172-# whatever was there before, silently. A run meant to answer "does my change
173-# work" has to be the files on disk.
174-#
175-# libcosmic paints through wgpu, so this needs nixGL off NixOS for the same
176-# reason the window always did: the real driver is the host's.
177-#
178-# There is no jvui and no vidya here any more — both were experiments. The
179-# window is libcosmic and the terminal is libjolttui, and those are the two.
180-#
181-# The app: this tree's source on the flake's everything-else, in the dev shell.
182-#
183-# Named for the backend rather than for the verb, the way `flutter-desktop`
184-# is: two desktop GUIs, neither of them the default one.
185-#
186-# just cosmic run [args...] open the window
187-cosmic action="run" *args:
188- #!/usr/bin/env bash
189- set -euo pipefail
190- cd "{{justfile_directory()}}"
191- if [ "{{action}}" != "run" ]; then
192- echo "usage: just cosmic run [args...]" >&2
193- exit 1
194- fi
195- shift
196- if [ -z "${JOLT_NATIVE_LIB:-}" ]; then
197- exec {{nix}} develop . --max-jobs {{jobs}} --command just cosmic run "$@"
198- fi
199-
200- deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
201- deps="$deps nandi/glimmer-cosmic {:local/root \"$GLIMMER_COSMIC_SRC\"}}}"
202-
203- export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
204-
205- runner=()
206- [ -e /run/current-system ] || runner=("$NIXGL")
207-
208- exec "${runner[@]}" jolt -Sdeps "$deps" -m frq.cosmic "$@"
209-
210-# `cosmic` with the other backend under it. Only libjolttui: `frq.app` names no
211-# backend at all any more, and `frq.tui` requires glimmer-tui so the one
212-# installed is the terminal.
213-#
214-# No nixGL here, unlike `cosmic`: a terminal wants nothing from the host's GL
215-# driver, which is the reason this output exists on machines that have none.
216-#
217-# What may appear in common/, checked — the half of the tree both backends
218-# compile. Needs nothing built: it reads the source, so it is the one check
219-# that runs anywhere, and CI runs exactly this.
220 check-common:150 check-common:
221 #!/usr/bin/env bash151 #!/usr/bin/env bash
222 python3 tools/check-common.py common152 python3 tools/check-common.py common
223 153
224-# The same screens in a terminal. `just tui --headless` prints one screenshot.154+# The desktop GUI: the same screens the APK paints, on Flutter's Linux target.
225-tui *args:
226- #!/usr/bin/env bash
227- set -euo pipefail
228- cd "{{justfile_directory()}}"
229- if [ -z "${JOLT_NATIVE_LIB:-}" ]; then
230- exec {{nix}} develop . --max-jobs {{jobs}} --command just tui "$@"
231- fi
232-
233- deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
234- deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}"
235-
236- export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
237-
238- exec jolt -Sdeps "$deps" -m frq.tui "$@"
239-
240-# The same classpath as `tui`, with an nREPL on it instead of a `-main`: a
241-# session that can require `frq.tui` and then redefine a component while it is
242-# on screen, which is a second and not the minute a rebuild costs.
243-#
244-# just nrepl then, from an editor or a client on 7888:
245-# (require (quote frq.tui)) both backends, terminal installed last
246-# (frq.tui/-main "--headless" "--demo")
247-# (glimmer.core/reload!) re-mount after redefining a component
248 #155 #
249-# `just repl nrepl-server` is the window's half of this — the same thing minus156+# This recipe and `just apk` are two targets over one tree, and the split is
250-# glimmer-tui. Port is nrepl-server's own positional: `just nrepl 7889`.157+# the one the APK already draws. Everything under `common/` — the screens, the
251-nrepl *args:158+# cells, `frq.io` — is shared; what differs is who answers the host. So this
252- #!/usr/bin/env bash159+# is `just apk` with the Android half taken out: the same `clojure -M:cljd compile` over the same flutter/src,
253- set -euo pipefail
254- cd "{{justfile_directory()}}"
255- if [ -z "${JOLT_NATIVE_LIB:-}" ]; then
256- exec {{nix}} develop . --max-jobs {{jobs}} --command just nrepl "$@"
257- fi
258-
259- deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
260- deps="$deps nandi/glimmer-tui {:local/root \"$GLIMMER_TUI_SRC\"}}}"
261-
262- export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
263-
264- exec jolt -Sdeps "$deps" nrepl-server "$@"
265-
266-# `jolt` in the repo root does not work on its own: deps.edn carries
267-# :jolt/native, so every invocation here loads libvidya and libjoltmoq before it
268-# reads a line, and dies naming the library if the loader cannot find them. So
269-# this is `cosmic` without the app — and `cosmic` is this with a window's
270-# worth of
271-# extra care about the GL driver.
272-#
273-# A jolt with the native libraries under it: a REPL, or `just repl nrepl-server`.
274-repl *args:
275- #!/usr/bin/env bash
276- set -euo pipefail
277- cd "{{justfile_directory()}}"
278- if [ -z "${JOLT_NATIVE_LIB:-}" ]; then
279- exec {{nix}} develop . --max-jobs {{jobs}} --command just repl "$@"
280- fi
281-
282- deps="{:deps {jolt-lang/glimmer {:local/root \"$GLIMMER_SRC\"}"
283- deps="$deps nandi/glimmer-cosmic {:local/root \"$GLIMMER_COSMIC_SRC\"}}}"
284-
285- export LD_LIBRARY_PATH="$JOLT_NATIVE_LIB:$FRQ_LIB_PATH${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
286-
287- exec jolt -Sdeps "$deps" "$@"
288-
289-# Regenerate src/frq/moq/raw.clj from the libmoq_ffi we actually load.
290-#
291-# UniFFI embeds its interface metadata in the object, so `uniffi-bindgen
292-# --library` reads the truth out of the .so rather than a header shipped
293-# beside it. Those differ at one version number: the published Linux and
294-# Android objects are built without moq-ffi's `audio` and `video` features
295-# (206 functions, no codecs), while the shipped C header describes the Apple
296-# build (230). The object is the only source this recipe will accept.
297-#
298-# The bindgen must match the uniffi that built the object — 0.32 for moq-ffi
299-# 0.3.17 — and it is built once into the scratch dir rather than pinned into
300-# the flake: nothing in a normal build needs it, and a regeneration is a thing
301-# done when the moq-ffi pin moves, by hand, on purpose.
302-#
303-# just gen-moq # the loaded library
304-# just gen-moq path/to/libmoq_ffi.so
305-#
306-# Regenerate the libmoq_ffi bindings from the object's own embedded metadata.
307-gen-moq lib="":
308- #!/usr/bin/env bash
309- set -euo pipefail
310- lib="${1:-${JOLT_NATIVE_LIB:-}/libmoq_ffi.so}"
311- [ -f "$lib" ] || { echo "no libmoq_ffi.so at $lib — pass one: just gen-moq <path>" >&2; exit 1; }
312- work="${TMPDIR:-/tmp}/frq-gen-moq"
313- mkdir -p "$work/ubg/src"
314- cat > "$work/ubg/Cargo.toml" <<'TOML'
315- [package]
316- name = "ubg"
317- version = "0.1.0"
318- edition = "2021"
319- [[bin]]
320- name = "uniffi-bindgen"
321- path = "src/main.rs"
322- [dependencies]
323- uniffi = { version = "0.32", features = ["cli"] }
324- TOML
325- echo 'fn main() { uniffi::uniffi_bindgen_main() }' > "$work/ubg/src/main.rs"
326- ( cd "$work/ubg" && cargo build --release -q )
327- ( cd "$work/ubg" && ./target/release/uniffi-bindgen generate \
328- --library "$lib" --language python --out-dir "$work/py" --no-format )
329- python3 tools/py2jolt.py "$work"/py/*.py > "$work/body.clj"
330- { sed -n '1,/^ (:require \[jolt.ffi :as ffi\]))$/p' src/frq/moq/raw.clj; echo; cat "$work/body.clj"; } > "$work/raw.clj"
331- mv "$work/raw.clj" src/frq/moq/raw.clj
332- echo "wrote src/frq/moq/raw.clj ($(grep -c '^(ffi/defcfn' src/frq/moq/raw.clj) entry points)"
333-
334-# The other desktop GUI: the same screens, painted by Flutter instead of
335-# libcosmic.
336-#
337-# `just cosmic run` and this one are two frontends over one tree, and the
338-# split is the same one the APK already draws. Everything under `common/` — the
339-# screens, the cells, `frq.io` — is shared; what differs is who paints it and
340-# who answers the host. So this recipe is `just apk` with the Android half
341-# taken out: the same `clojure -M:cljd compile` over the same flutter/src,
342 # then Flutter's Linux target rather than its Android one. CMake and Ninja160 # then Flutter's Linux target rather than its Android one. CMake and Ninja
343 # instead of Gradle, `flutter/linux/` as the runner, no SDK and no JDK.161 # instead of Gradle, `flutter/linux/` as the runner, no SDK and no JDK.
344 #162 #
@@ -347,8 +165,8 @@ gen-moq lib="":
347 # writable-ANDROID_HOME dance — nothing here writes into the store — so there165 # writable-ANDROID_HOME dance — nothing here writes into the store — so there
348 # is no `flutter/.home` on this path.166 # is no `flutter/.home` on this path.
349 #167 #
350-# nixGL for the reason `cosmic` needs it and `tui` does not: Flutter paints168+# nixGL because Flutter paints through GL, and off NixOS the driver is the
351-# through GL, and off NixOS the driver is the host's.169+# host's.
352 #170 #
353 # just flutter-desktop build the debug bundle171 # just flutter-desktop build the debug bundle
354 # just flutter-desktop run build it and open the window172 # just flutter-desktop run build it and open the window
@@ -462,46 +280,19 @@ flutter-web action="build" port="8080":
462 # start would be one dependency away from the point.280 # start would be one dependency away from the point.
463 exec "{{justfile_directory()}}/tools/build-web.sh" {{action}} {{port}}281 exec "{{justfile_directory()}}/tools/build-web.sh" {{action}} {{port}}
464 282
465-# The cosmic GUI as a directory anyone can unpack, with no nix on either end.
466-#
467-# `just cosmic run` is the edit loop — a devShell, this tree's source, a store
468-# path per dependency. This is the other end of the same program: a jolt
469-# binary, the backends out of jolt-native's portable tarball, libmoq_ffi off
470-# its release, glimmer and glimmer-cosmic at pinned revs, and one .c file
471-# compiled on the spot. `tools/desktop-toolchain.sh` fetches; nothing is
472-# built from source that somebody else has already published.
473-#
474-# It replaces `nix build .#appimage`, and what it drops with it is the reason
475-# that output existed. nix-appimage squashed a closure into one file so a
476-# machine without nix could run it, and the heaviest thing in that closure was
477-# a Mesa — carried so that nixGL had something to put the host driver in front
478-# of. There is no Mesa here, so there is no nixGL: the GL driver is the
479-# host's, the way it is for everything else on the machine.
480-#
481-# just desktop assemble build/desktop
482-# just desktop tar ...and tar it up for another machine
483-# just desktop run ...and start it
484-desktop action="build":
485- #!/usr/bin/env bash
486- set -euo pipefail
487- # A wrapper and nothing else, for the reason `flutter-web` is one: the
488- # container runs the same script, and a container that had to install
489- # `just` first would be one dependency away from the point.
490- exec "{{justfile_directory()}}/tools/build-desktop.sh" {{action}}
491-
492 # The containers in `.modal/`, run on Modal rather than here. This machine283 # The containers in `.modal/`, run on Modal rather than here. This machine
493 # evaluates and Modal builds — see CLAUDE.md, which says so rather more284 # evaluates and Modal builds — see CLAUDE.md, which says so rather more
494 # firmly — and these two recipes are the whole interface to that.285 # firmly — and these two recipes are the whole interface to that.
495 #286 #
496-# Named for where the work happens, the way `cosmic` and `flutter-desktop`287+# Named for where the work happens, the way `flutter-desktop` is named for
497-# are named for what paints: there is no re-entry test here because nothing288+# what paints: there is no re-entry test here because nothing
498 # re-enters. There is no `nix` variable either, and that used to be because289 # re-enters. There is no `nix` variable either, and that used to be because
499-# nix ran out there — now it is because two of these three containers have no290+# nix ran out there — now it is because neither of these containers has any
500-# nix in them at all.291+# nix in it at all.
501 #292 #
502-# just modal frq assemble the desktop bundle on Modal
503 # just modal flutter-dev the incremental Flutter loop293 # just modal flutter-dev the incremental Flutter loop
504-modal container="frq" *args:294+# just modal flutter-web the web bundle
295+modal container="flutter-dev" *args:
505 #!/usr/bin/env bash296 #!/usr/bin/env bash
506 set -euo pipefail297 set -euo pipefail
507 cd "{{justfile_directory()}}"298 cd "{{justfile_directory()}}"
@@ -557,9 +348,59 @@ web-local port="8080":
557 # the sandbox bills until you do.348 # the sandbox bills until you do.
558 #349 #
559 # just modal-shell flutter-dev, the usual one350 # just modal-shell flutter-dev, the usual one
560-# just modal-shell frq the desktop bundle container351+# just modal-shell flutter-web the web bundle container
561 modal-shell container="flutter-dev":352 modal-shell container="flutter-dev":
562 #!/usr/bin/env bash353 #!/usr/bin/env bash
563 set -euo pipefail354 set -euo pipefail
564 cd "{{justfile_directory()}}"355 cd "{{justfile_directory()}}"
565 exec modal run ".modal/{{container}}/container.py" --shell356 exec modal run ".modal/{{container}}/container.py" --shell
357+
358+# The Nim core's test suite.
359+#
360+# It needs no Flutter, no Dart and no Android SDK — which is the point of
361+# having the logic here rather than under `common/`: a rule about the IRC wire
362+# format can be checked in a second, on any machine, without a toolchain that
363+# takes minutes to enter.
364+#
365+# just nim-test the whole suite
366+# just nim-test tircparse one file
367+nim-test file="":
368+ #!/usr/bin/env bash
369+ set -euo pipefail
370+ cd "{{justfile_directory()}}"
371+ if [ -z "${FRQ_NIM:-}" ]; then
372+ exec {{nix}} develop .#nim --max-jobs {{jobs}} --command just nim-test "$@"
373+ fi
374+ cd nim
375+ if [ -n "{{file}}" ]; then
376+ exec nim c -r --hints:off --path:src "tests/{{file}}.nim"
377+ fi
378+ for t in tests/t*.nim; do
379+ echo "== $t"
380+ nim c -r --hints:off --path:src "$t"
381+ done
382+
383+# The Nim core as a shared library, into build/nim.
384+#
385+# `--mm:orc` rather than the default: this is a library loaded by a Dart
386+# process that owns its own lifetime, so reference counting with a cycle
387+# collector is the memory model that does not need a GC thread of its own or a
388+# stack it can scan.
389+#
390+# `-d:release` and not `-d:danger`: the bounds checks are what turn a
391+# malformed line off a socket into an exception instead of a read past the end
392+# of a buffer, and this parses exactly that.
393+nim-lib:
394+ #!/usr/bin/env bash
395+ set -euo pipefail
396+ cd "{{justfile_directory()}}"
397+ if [ -z "${FRQ_NIM:-}" ]; then
398+ exec {{nix}} develop .#nim --max-jobs {{jobs}} --command just nim-lib
399+ fi
400+ out="{{justfile_directory()}}/build/nim"
401+ mkdir -p "$out"
402+ cd nim
403+ nim c --app:lib --mm:orc -d:release --hints:off \
404+ --path:src --out:"$out/libfrqcore.so" src/frq_core.nim
405+ echo "built $out/libfrqcore.so"
406+ nm -D --defined-only "$out/libfrqcore.so" | grep ' T frq_' || true
added nim/README.md +88 -0
new file mode 100644
@@ -0,0 +1,88 @@
1+# The Nim core
2+
3+The portable half of frq, as a native library the Dart side calls through FFI.
4+
5+## Why this exists
6+
7+`common/` is ClojureDart, compiled into every target. That works, and the
8+reason to move any of it is not that it is broken: it is that the logic under
9+the screens — the IRC wire format, the atproto flows, the message signatures —
10+is the part with the most rules per line and the least to do with Flutter, and
11+it is the part worth having in a language with a type checker and a test runner
12+that does not need a Flutter toolchain to run.
13+
14+So the plan is a seam, not a rewrite-in-place. Each module moves one at a time:
15+the Nim implementation lands here with tests, the Dart binding lands in
16+`flutter/src/frq/core/`, and the ClojureDart original stays until the binding
17+is proven against it. Nothing is deleted on faith.
18+
19+## What is here
20+
21+```
22+src/frq_core.nim the C ABI: every exported symbol, and nothing else
23+src/frq/ircparse.nim the IRC wire format
24+tests/ one per module, run by `just nim-test`
25+```
26+
27+`src/frq_core.nim` is the only file that knows about C. Everything under
28+`src/frq/` is ordinary Nim with ordinary Nim types, so the tests test the logic
29+rather than the marshalling.
30+
31+## The ABI
32+
33+Strings in, strings out, and JSON where the answer is not a single string.
34+
35+That is a deliberate choice against a struct-based ABI. A struct means the Dart
36+side and the Nim side have to agree on a memory layout, and every field added
37+later is a version skew that segfaults instead of failing. JSON costs a parse
38+per call, which is nothing against a network round trip, and it lets one side
39+gain a field without the other crashing.
40+
41+Every function that returns a string returns memory the **caller must free**
42+with `frq_free`. Nim's allocator is not Dart's; a `free()` from the Dart side
43+on a Nim pointer is undefined. The bindings in `flutter/src/frq/core/` wrap
44+that in a `try/finally` so no call site has to remember.
45+
46+`frq_init` must be called once before anything else, and calls `NimMain` to set
47+up Nim's runtime. The bindings do it on first use.
48+
49+## The web
50+
51+A native library does not load in a browser, so the web target cannot call this
52+through `dart:ffi`. Nim compiles through C, so the route is emscripten to wasm
53+and a JS binding rather than a second implementation — but that is not built
54+yet, and until it is, **the web build must keep using the ClojureDart
55+originals**. This is why the originals stay in `common/` rather than being
56+deleted as each module lands: they are the web's implementation, not dead code.
57+
58+## Status
59+
60+`frq/ircparse.nim` is ported and tested — 29 cases, `just nim-test` — and the
61+ABI is exercised from C through `dlopen`, including the allocation contract
62+under a hundred thousand parse/free cycles. That half is real.
63+
64+The Dart binding (`flutter/src/frq/core/ffi.cljd`) is **not**: it is written
65+but nothing requires it, so no build has compiled it. Nothing calls the core
66+yet, and `common/frq/irc/parse.cljc` is still what every target actually runs.
67+
68+The next step is that binding, and it is the one with the unknown in it:
69+`.lookupFunction` takes native and Dart type arguments, and how ClojureDart
70+spells a generic interop call is the thing to establish before porting a
71+second module. After it: the library has to reach the targets — `jniLibs` for
72+the APK, beside the executable for the desktop bundle — and then
73+`frq.core.irc` can choose between the Nim implementation and the ClojureDart
74+one per target, which is also how the web keeps working.
75+
76+Modules still in `common/` and not yet here: `rooms`, `msgsig`, `crypto`,
77+`atproto/core`, `oauth/core`, `store`, `irc/handshake`, `irc/mutate`,
78+`profile`, `members`, `reactions`, `replies`, `edits`, `clock`.
79+
80+## Building
81+
82+```bash
83+just nim-test # the Nim test suite
84+just nim-lib # libfrqcore.so into build/nim
85+```
86+
87+Both want `nix develop .#nim`, and re-enter it themselves if they are not
88+already inside.
new file mode 100644
@@ -0,0 +1,88 @@
1+# The Nim core
2+
3+The portable half of frq, as a native library the Dart side calls through FFI.
4+
5+## Why this exists
6+
7+`common/` is ClojureDart, compiled into every target. That works, and the
8+reason to move any of it is not that it is broken: it is that the logic under
9+the screens — the IRC wire format, the atproto flows, the message signatures —
10+is the part with the most rules per line and the least to do with Flutter, and
11+it is the part worth having in a language with a type checker and a test runner
12+that does not need a Flutter toolchain to run.
13+
14+So the plan is a seam, not a rewrite-in-place. Each module moves one at a time:
15+the Nim implementation lands here with tests, the Dart binding lands in
16+`flutter/src/frq/core/`, and the ClojureDart original stays until the binding
17+is proven against it. Nothing is deleted on faith.
18+
19+## What is here
20+
21+```
22+src/frq_core.nim the C ABI: every exported symbol, and nothing else
23+src/frq/ircparse.nim the IRC wire format
24+tests/ one per module, run by `just nim-test`
25+```
26+
27+`src/frq_core.nim` is the only file that knows about C. Everything under
28+`src/frq/` is ordinary Nim with ordinary Nim types, so the tests test the logic
29+rather than the marshalling.
30+
31+## The ABI
32+
33+Strings in, strings out, and JSON where the answer is not a single string.
34+
35+That is a deliberate choice against a struct-based ABI. A struct means the Dart
36+side and the Nim side have to agree on a memory layout, and every field added
37+later is a version skew that segfaults instead of failing. JSON costs a parse
38+per call, which is nothing against a network round trip, and it lets one side
39+gain a field without the other crashing.
40+
41+Every function that returns a string returns memory the **caller must free**
42+with `frq_free`. Nim's allocator is not Dart's; a `free()` from the Dart side
43+on a Nim pointer is undefined. The bindings in `flutter/src/frq/core/` wrap
44+that in a `try/finally` so no call site has to remember.
45+
46+`frq_init` must be called once before anything else, and calls `NimMain` to set
47+up Nim's runtime. The bindings do it on first use.
48+
49+## The web
50+
51+A native library does not load in a browser, so the web target cannot call this
52+through `dart:ffi`. Nim compiles through C, so the route is emscripten to wasm
53+and a JS binding rather than a second implementation — but that is not built
54+yet, and until it is, **the web build must keep using the ClojureDart
55+originals**. This is why the originals stay in `common/` rather than being
56+deleted as each module lands: they are the web's implementation, not dead code.
57+
58+## Status
59+
60+`frq/ircparse.nim` is ported and tested — 29 cases, `just nim-test` — and the
61+ABI is exercised from C through `dlopen`, including the allocation contract
62+under a hundred thousand parse/free cycles. That half is real.
63+
64+The Dart binding (`flutter/src/frq/core/ffi.cljd`) is **not**: it is written
65+but nothing requires it, so no build has compiled it. Nothing calls the core
66+yet, and `common/frq/irc/parse.cljc` is still what every target actually runs.
67+
68+The next step is that binding, and it is the one with the unknown in it:
69+`.lookupFunction` takes native and Dart type arguments, and how ClojureDart
70+spells a generic interop call is the thing to establish before porting a
71+second module. After it: the library has to reach the targets — `jniLibs` for
72+the APK, beside the executable for the desktop bundle — and then
73+`frq.core.irc` can choose between the Nim implementation and the ClojureDart
74+one per target, which is also how the web keeps working.
75+
76+Modules still in `common/` and not yet here: `rooms`, `msgsig`, `crypto`,
77+`atproto/core`, `oauth/core`, `store`, `irc/handshake`, `irc/mutate`,
78+`profile`, `members`, `reactions`, `replies`, `edits`, `clock`.
79+
80+## Building
81+
82+```bash
83+just nim-test # the Nim test suite
84+just nim-lib # libfrqcore.so into build/nim
85+```
86+
87+Both want `nix develop .#nim`, and re-enter it themselves if they are not
88+already inside.
added nim/frq_core.nimble +10 -0
new file mode 100644
@@ -0,0 +1,10 @@
1+version = "0.1.0"
2+author = "nandi"
3+description = "The portable core of frq, as a C-callable library"
4+license = "MIT"
5+srcDir = "src"
6+
7+requires "nim >= 2.0.0"
8+
9+task test, "Run the test suite":
10+ exec "nim c -r --hints:off tests/tircparse.nim"
new file mode 100644
@@ -0,0 +1,10 @@
1+version = "0.1.0"
2+author = "nandi"
3+description = "The portable core of frq, as a C-callable library"
4+license = "MIT"
5+srcDir = "src"
6+
7+requires "nim >= 2.0.0"
8+
9+task test, "Run the test suite":
10+ exec "nim c -r --hints:off tests/tircparse.nim"
added nim/src/frq/ircparse.nim +155 -0
new file mode 100644
@@ -0,0 +1,155 @@
1+## The IRC wire format, as text. No socket, no host, no platform.
2+##
3+## A transcription of `common/frq/irc/parse.cljc`, which stays where it is
4+## until the Dart binding for this has been proven against it — see
5+## ../../README.md. The rules here are IRCv3's and the comments explaining
6+## *why* a rule is fiddly are kept from the original rather than rewritten,
7+## because they record bugs that were actually paid for.
8+
9+import std/strutils
10+
11+type
12+ IrcLine* = object
13+ ## An IRC line, taken apart. `raw` is what actually arrived, kept so a
14+ ## reader can say that rather than what we made of it — a tag the server
15+ ## dropped is invisible in every other field.
16+ raw*: string
17+ tags*: string ## "" where the line carried none
18+ hasTags*: bool ## distinguishes no tags at all from an empty "@ "
19+ account*: string
20+ hasAccount*: bool
21+ prefix*: string
22+ hasPrefix*: bool
23+ command*: string
24+ params*: seq[string]
25+
26+func unescapeTag*(v: string): string =
27+ ## An IRCv3 tag value with its escapes undone.
28+ ##
29+ ## `\:` is a semicolon, `\s` a space, and `\\`, `\r` and `\n` themselves —
30+ ## the escaping exists because `;` separates tags and a space ends them. It
31+ ## matters for any value that can contain either: a reaction tally is
32+ ## `emoji:nick;emoji:nick` on the wire and arrives with every one of those
33+ ## semicolons written `\:`, so a reader that skips this step sees one tally
34+ ## where there were three, and counts to match.
35+ result = newStringOfCap(v.len)
36+ var i = 0
37+ while i < v.len:
38+ if v[i] == '\\' and i + 1 < v.len:
39+ result.add(case v[i + 1]
40+ of ':': ';'
41+ of 's': ' '
42+ of 'r': '\r'
43+ of 'n': '\n'
44+ else: v[i + 1])
45+ i += 2
46+ else:
47+ result.add v[i]
48+ i += 1
49+
50+func escapeTagValue*(v: string): string =
51+ ## The inverse, for a tag this client sends. An emoji needs none of it; a
52+ ## message id could, and the cost of being right is a pass over a short
53+ ## string.
54+ ##
55+ ## One pass rather than the original's five chained replaces, which is not
56+ ## an optimisation but a correctness fix waiting to happen: replacing `\`
57+ ## with `\\` first and `;` with `\:` second is only safe because of the
58+ ## order, and a sixth rule inserted in the wrong place would double-escape.
59+ result = newStringOfCap(v.len)
60+ for c in v:
61+ case c
62+ of '\\': result.add "\\\\"
63+ of ';': result.add "\\:"
64+ of ' ': result.add "\\s"
65+ of '\r': result.add "\\r"
66+ of '\n': result.add "\\n"
67+ else: result.add c
68+
69+func tagValue*(tags, key: string): (string, bool) =
70+ ## One IRCv3 tag's value, unescaped, and whether it was there at all.
71+ ##
72+ ## A tag written with no value and one written `key=` say the same thing,
73+ ## which IRCv3 spells out and which this used to disagree with: the bare
74+ ## form fell through as absent and the empty one came back as "". A caller
75+ ## asks whether a fact is there, and an empty string is a fact that is
76+ ## there — which is how a `+reply=` on a line answering nothing put a reply
77+ ## chip above it, pointing at a message no id could find.
78+ ##
79+ ## So an empty value reads as absent, exactly as the Clojure did through
80+ ## `not-empty`. The bool is that answer; the string is meaningless when it
81+ ## is false.
82+ for pair in tags.split(';'):
83+ let eq = pair.find('=')
84+ let k = if eq < 0: pair else: pair[0 ..< eq]
85+ if k == key:
86+ let v = if eq < 0: "" else: unescapeTag(pair[eq + 1 .. ^1])
87+ return (v, v.len > 0)
88+ ("", false)
89+
90+func nickOf*(prefix: string): string =
91+ ## The nick half of a `nick!user@host` prefix.
92+ let i = prefix.find('!')
93+ if i < 0: prefix else: prefix[0 ..< i]
94+
95+func accountOf(tags: string): (string, bool) =
96+ ## The `account` tag, raw rather than unescaped — which is what the Clojure
97+ ## regex did, and is kept so the two agree byte for byte. A handle has
98+ ## nothing in it that needs escaping, so the two only differ on input no
99+ ## server sends.
100+ for pair in tags.split(';'):
101+ let eq = pair.find('=')
102+ if eq >= 0 and pair[0 ..< eq] == "account":
103+ return (pair[eq + 1 .. ^1], true)
104+ elif eq < 0 and pair == "account":
105+ return ("", false)
106+ ("", false)
107+
108+func parseLine*(line: string): IrcLine =
109+ ## An IRC line into its parts. The trailing parameter (after " :") keeps its
110+ ## spaces; everything before it splits on whitespace.
111+ ##
112+ ## IRCv3 tags come first when there are any. A connection that negotiates
113+ ## CAP gets them where a bare one does not — which is why a client that
114+ ## ignores them looks fine as a guest and goes silent once it authenticates.
115+ result.raw = line.strip(leading = false, trailing = true)
116+ var rest = result.raw
117+
118+ if rest.startsWith("@"):
119+ let i = rest.find(' ')
120+ if i < 0:
121+ # No space after the tags: the whole line was tags and nothing else.
122+ # Clojure's `subs` would throw on the nil index here; answering with an
123+ # empty remainder is what the caller can actually use.
124+ result.tags = rest[1 .. ^1]
125+ result.hasTags = true
126+ rest = ""
127+ else:
128+ result.tags = rest[1 ..< i]
129+ result.hasTags = true
130+ rest = rest[i .. ^1].strip(leading = true, trailing = false)
131+
132+ if result.hasTags:
133+ (result.account, result.hasAccount) = accountOf(result.tags)
134+
135+ if rest.startsWith(":"):
136+ let i = rest.find(' ')
137+ if i < 0:
138+ result.prefix = rest[1 .. ^1]
139+ result.hasPrefix = true
140+ rest = ""
141+ else:
142+ result.prefix = rest[1 ..< i]
143+ result.hasPrefix = true
144+ rest = rest[i + 1 .. ^1]
145+
146+ let i = rest.find(" :")
147+ let head = if i < 0: rest else: rest[0 ..< i]
148+ var parts: seq[string] = @[]
149+ for p in head.split(' '):
150+ if p.len > 0: parts.add p
151+
152+ result.command = if parts.len > 0: parts[0].toUpperAscii else: ""
153+ result.params = if parts.len > 1: parts[1 .. ^1] else: @[]
154+ if i >= 0:
155+ result.params.add rest[i + 2 .. ^1]
new file mode 100644
@@ -0,0 +1,155 @@
1+## The IRC wire format, as text. No socket, no host, no platform.
2+##
3+## A transcription of `common/frq/irc/parse.cljc`, which stays where it is
4+## until the Dart binding for this has been proven against it — see
5+## ../../README.md. The rules here are IRCv3's and the comments explaining
6+## *why* a rule is fiddly are kept from the original rather than rewritten,
7+## because they record bugs that were actually paid for.
8+
9+import std/strutils
10+
11+type
12+ IrcLine* = object
13+ ## An IRC line, taken apart. `raw` is what actually arrived, kept so a
14+ ## reader can say that rather than what we made of it — a tag the server
15+ ## dropped is invisible in every other field.
16+ raw*: string
17+ tags*: string ## "" where the line carried none
18+ hasTags*: bool ## distinguishes no tags at all from an empty "@ "
19+ account*: string
20+ hasAccount*: bool
21+ prefix*: string
22+ hasPrefix*: bool
23+ command*: string
24+ params*: seq[string]
25+
26+func unescapeTag*(v: string): string =
27+ ## An IRCv3 tag value with its escapes undone.
28+ ##
29+ ## `\:` is a semicolon, `\s` a space, and `\\`, `\r` and `\n` themselves —
30+ ## the escaping exists because `;` separates tags and a space ends them. It
31+ ## matters for any value that can contain either: a reaction tally is
32+ ## `emoji:nick;emoji:nick` on the wire and arrives with every one of those
33+ ## semicolons written `\:`, so a reader that skips this step sees one tally
34+ ## where there were three, and counts to match.
35+ result = newStringOfCap(v.len)
36+ var i = 0
37+ while i < v.len:
38+ if v[i] == '\\' and i + 1 < v.len:
39+ result.add(case v[i + 1]
40+ of ':': ';'
41+ of 's': ' '
42+ of 'r': '\r'
43+ of 'n': '\n'
44+ else: v[i + 1])
45+ i += 2
46+ else:
47+ result.add v[i]
48+ i += 1
49+
50+func escapeTagValue*(v: string): string =
51+ ## The inverse, for a tag this client sends. An emoji needs none of it; a
52+ ## message id could, and the cost of being right is a pass over a short
53+ ## string.
54+ ##
55+ ## One pass rather than the original's five chained replaces, which is not
56+ ## an optimisation but a correctness fix waiting to happen: replacing `\`
57+ ## with `\\` first and `;` with `\:` second is only safe because of the
58+ ## order, and a sixth rule inserted in the wrong place would double-escape.
59+ result = newStringOfCap(v.len)
60+ for c in v:
61+ case c
62+ of '\\': result.add "\\\\"
63+ of ';': result.add "\\:"
64+ of ' ': result.add "\\s"
65+ of '\r': result.add "\\r"
66+ of '\n': result.add "\\n"
67+ else: result.add c
68+
69+func tagValue*(tags, key: string): (string, bool) =
70+ ## One IRCv3 tag's value, unescaped, and whether it was there at all.
71+ ##
72+ ## A tag written with no value and one written `key=` say the same thing,
73+ ## which IRCv3 spells out and which this used to disagree with: the bare
74+ ## form fell through as absent and the empty one came back as "". A caller
75+ ## asks whether a fact is there, and an empty string is a fact that is
76+ ## there — which is how a `+reply=` on a line answering nothing put a reply
77+ ## chip above it, pointing at a message no id could find.
78+ ##
79+ ## So an empty value reads as absent, exactly as the Clojure did through
80+ ## `not-empty`. The bool is that answer; the string is meaningless when it
81+ ## is false.
82+ for pair in tags.split(';'):
83+ let eq = pair.find('=')
84+ let k = if eq < 0: pair else: pair[0 ..< eq]
85+ if k == key:
86+ let v = if eq < 0: "" else: unescapeTag(pair[eq + 1 .. ^1])
87+ return (v, v.len > 0)
88+ ("", false)
89+
90+func nickOf*(prefix: string): string =
91+ ## The nick half of a `nick!user@host` prefix.
92+ let i = prefix.find('!')
93+ if i < 0: prefix else: prefix[0 ..< i]
94+
95+func accountOf(tags: string): (string, bool) =
96+ ## The `account` tag, raw rather than unescaped — which is what the Clojure
97+ ## regex did, and is kept so the two agree byte for byte. A handle has
98+ ## nothing in it that needs escaping, so the two only differ on input no
99+ ## server sends.
100+ for pair in tags.split(';'):
101+ let eq = pair.find('=')
102+ if eq >= 0 and pair[0 ..< eq] == "account":
103+ return (pair[eq + 1 .. ^1], true)
104+ elif eq < 0 and pair == "account":
105+ return ("", false)
106+ ("", false)
107+
108+func parseLine*(line: string): IrcLine =
109+ ## An IRC line into its parts. The trailing parameter (after " :") keeps its
110+ ## spaces; everything before it splits on whitespace.
111+ ##
112+ ## IRCv3 tags come first when there are any. A connection that negotiates
113+ ## CAP gets them where a bare one does not — which is why a client that
114+ ## ignores them looks fine as a guest and goes silent once it authenticates.
115+ result.raw = line.strip(leading = false, trailing = true)
116+ var rest = result.raw
117+
118+ if rest.startsWith("@"):
119+ let i = rest.find(' ')
120+ if i < 0:
121+ # No space after the tags: the whole line was tags and nothing else.
122+ # Clojure's `subs` would throw on the nil index here; answering with an
123+ # empty remainder is what the caller can actually use.
124+ result.tags = rest[1 .. ^1]
125+ result.hasTags = true
126+ rest = ""
127+ else:
128+ result.tags = rest[1 ..< i]
129+ result.hasTags = true
130+ rest = rest[i .. ^1].strip(leading = true, trailing = false)
131+
132+ if result.hasTags:
133+ (result.account, result.hasAccount) = accountOf(result.tags)
134+
135+ if rest.startsWith(":"):
136+ let i = rest.find(' ')
137+ if i < 0:
138+ result.prefix = rest[1 .. ^1]
139+ result.hasPrefix = true
140+ rest = ""
141+ else:
142+ result.prefix = rest[1 ..< i]
143+ result.hasPrefix = true
144+ rest = rest[i + 1 .. ^1]
145+
146+ let i = rest.find(" :")
147+ let head = if i < 0: rest else: rest[0 ..< i]
148+ var parts: seq[string] = @[]
149+ for p in head.split(' '):
150+ if p.len > 0: parts.add p
151+
152+ result.command = if parts.len > 0: parts[0].toUpperAscii else: ""
153+ result.params = if parts.len > 1: parts[1 .. ^1] else: @[]
154+ if i >= 0:
155+ result.params.add rest[i + 2 .. ^1]
added nim/src/frq_core.nim +96 -0
new file mode 100644
@@ -0,0 +1,96 @@
1+## The C ABI, and nothing else.
2+##
3+## Every exported symbol is here so there is one file to read when asking what
4+## the Dart side can call. The logic lives under `frq/` in ordinary Nim with
5+## ordinary Nim types, which is what lets the tests test the rules rather than
6+## the marshalling.
7+##
8+## Two conventions, both of which the bindings in `flutter/src/frq/core/`
9+## wrap so no call site has to remember them:
10+##
11+## * Every returned string is the **caller's** to free, with `frq_free`. Nim's
12+## allocator is not Dart's, so a `free()` on this side of the boundary is
13+## undefined behaviour rather than a leak you can live with.
14+## * `frq_init` runs once before anything else. Nim's runtime needs setting up
15+## and `--app:lib` does not do it for you on every platform.
16+##
17+## Answers that are not a single string come back as JSON. A struct would mean
18+## both sides agreeing on a memory layout, and a field added later would be a
19+## version skew that segfaults rather than one that fails; JSON costs a parse
20+## per call, which is nothing against the network round trip that produced the
21+## line being parsed.
22+
23+import std/json
24+import frq/ircparse
25+
26+proc NimMain() {.importc.}
27+
28+var initialised = false
29+
30+proc frq_init*() {.exportc, dynlib.} =
31+ ## Set Nim's runtime up. Idempotent, because a binding that guesses wrong
32+ ## about whether it has been called should be harmless rather than fatal.
33+ if not initialised:
34+ NimMain()
35+ initialised = true
36+
37+proc dup(s: string): cstring =
38+ ## A copy of `s` that outlives this call, for the caller to `frq_free`.
39+ ## `allocShared0` and not `alloc0`: the Dart side may free it from a
40+ ## different thread than the one that made it.
41+ let n = s.len
42+ let p = cast[cstring](allocShared0(n + 1))
43+ if n > 0:
44+ copyMem(p, unsafeAddr s[0], n)
45+ p
46+
47+proc frq_free*(p: cstring) {.exportc, dynlib.} =
48+ ## Free what one of the functions below returned. Null is fine.
49+ if p != nil:
50+ deallocShared(p)
51+
52+proc frq_version*(): cstring {.exportc, dynlib.} =
53+ ## Static storage, deliberately: this one is NOT freed, and is the only
54+ ## exception to the rule above. It exists so a binding can check at load
55+ ## time that the library it found is the one it was built against.
56+ "0.1.0"
57+
58+# ----------------------------------------------------------------- irc/parse
59+
60+proc frq_irc_parse_line*(line: cstring): cstring {.exportc, dynlib.} =
61+ ## An IRC line as JSON: `{raw, tags, account, prefix, command, params}`.
62+ ##
63+ ## `tags`, `account` and `prefix` are JSON null where the line carried none,
64+ ## which is the distinction `frq.irc.parse` draws with nil and every caller
65+ ## of it depends on — a PRIVMSG from a server with no prefix is not the same
66+ ## line as one from a nick.
67+ if line == nil: return dup("null")
68+ let p = parseLine($line)
69+ var o = newJObject()
70+ o["raw"] = %p.raw
71+ o["tags"] = if p.hasTags: %p.tags else: newJNull()
72+ o["account"] = if p.hasAccount: %p.account else: newJNull()
73+ o["prefix"] = if p.hasPrefix: %p.prefix else: newJNull()
74+ o["command"] = %p.command
75+ o["params"] = %p.params
76+ dup($o)
77+
78+proc frq_irc_tag_value*(tags, key: cstring): cstring {.exportc, dynlib.} =
79+ ## One tag's value, unescaped — or **null** where the tag is absent or
80+ ## empty, which IRCv3 says are the same thing. Null and not "" on purpose:
81+ ## see `tagValue`.
82+ if tags == nil or key == nil: return nil
83+ let (v, ok) = tagValue($tags, $key)
84+ if ok: dup(v) else: nil
85+
86+proc frq_irc_unescape_tag*(v: cstring): cstring {.exportc, dynlib.} =
87+ if v == nil: return nil
88+ dup(unescapeTag($v))
89+
90+proc frq_irc_escape_tag_value*(v: cstring): cstring {.exportc, dynlib.} =
91+ if v == nil: return dup("")
92+ dup(escapeTagValue($v))
93+
94+proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =
95+ if prefix == nil: return nil
96+ dup(nickOf($prefix))
new file mode 100644
@@ -0,0 +1,96 @@
1+## The C ABI, and nothing else.
2+##
3+## Every exported symbol is here so there is one file to read when asking what
4+## the Dart side can call. The logic lives under `frq/` in ordinary Nim with
5+## ordinary Nim types, which is what lets the tests test the rules rather than
6+## the marshalling.
7+##
8+## Two conventions, both of which the bindings in `flutter/src/frq/core/`
9+## wrap so no call site has to remember them:
10+##
11+## * Every returned string is the **caller's** to free, with `frq_free`. Nim's
12+## allocator is not Dart's, so a `free()` on this side of the boundary is
13+## undefined behaviour rather than a leak you can live with.
14+## * `frq_init` runs once before anything else. Nim's runtime needs setting up
15+## and `--app:lib` does not do it for you on every platform.
16+##
17+## Answers that are not a single string come back as JSON. A struct would mean
18+## both sides agreeing on a memory layout, and a field added later would be a
19+## version skew that segfaults rather than one that fails; JSON costs a parse
20+## per call, which is nothing against the network round trip that produced the
21+## line being parsed.
22+
23+import std/json
24+import frq/ircparse
25+
26+proc NimMain() {.importc.}
27+
28+var initialised = false
29+
30+proc frq_init*() {.exportc, dynlib.} =
31+ ## Set Nim's runtime up. Idempotent, because a binding that guesses wrong
32+ ## about whether it has been called should be harmless rather than fatal.
33+ if not initialised:
34+ NimMain()
35+ initialised = true
36+
37+proc dup(s: string): cstring =
38+ ## A copy of `s` that outlives this call, for the caller to `frq_free`.
39+ ## `allocShared0` and not `alloc0`: the Dart side may free it from a
40+ ## different thread than the one that made it.
41+ let n = s.len
42+ let p = cast[cstring](allocShared0(n + 1))
43+ if n > 0:
44+ copyMem(p, unsafeAddr s[0], n)
45+ p
46+
47+proc frq_free*(p: cstring) {.exportc, dynlib.} =
48+ ## Free what one of the functions below returned. Null is fine.
49+ if p != nil:
50+ deallocShared(p)
51+
52+proc frq_version*(): cstring {.exportc, dynlib.} =
53+ ## Static storage, deliberately: this one is NOT freed, and is the only
54+ ## exception to the rule above. It exists so a binding can check at load
55+ ## time that the library it found is the one it was built against.
56+ "0.1.0"
57+
58+# ----------------------------------------------------------------- irc/parse
59+
60+proc frq_irc_parse_line*(line: cstring): cstring {.exportc, dynlib.} =
61+ ## An IRC line as JSON: `{raw, tags, account, prefix, command, params}`.
62+ ##
63+ ## `tags`, `account` and `prefix` are JSON null where the line carried none,
64+ ## which is the distinction `frq.irc.parse` draws with nil and every caller
65+ ## of it depends on — a PRIVMSG from a server with no prefix is not the same
66+ ## line as one from a nick.
67+ if line == nil: return dup("null")
68+ let p = parseLine($line)
69+ var o = newJObject()
70+ o["raw"] = %p.raw
71+ o["tags"] = if p.hasTags: %p.tags else: newJNull()
72+ o["account"] = if p.hasAccount: %p.account else: newJNull()
73+ o["prefix"] = if p.hasPrefix: %p.prefix else: newJNull()
74+ o["command"] = %p.command
75+ o["params"] = %p.params
76+ dup($o)
77+
78+proc frq_irc_tag_value*(tags, key: cstring): cstring {.exportc, dynlib.} =
79+ ## One tag's value, unescaped — or **null** where the tag is absent or
80+ ## empty, which IRCv3 says are the same thing. Null and not "" on purpose:
81+ ## see `tagValue`.
82+ if tags == nil or key == nil: return nil
83+ let (v, ok) = tagValue($tags, $key)
84+ if ok: dup(v) else: nil
85+
86+proc frq_irc_unescape_tag*(v: cstring): cstring {.exportc, dynlib.} =
87+ if v == nil: return nil
88+ dup(unescapeTag($v))
89+
90+proc frq_irc_escape_tag_value*(v: cstring): cstring {.exportc, dynlib.} =
91+ if v == nil: return dup("")
92+ dup(escapeTagValue($v))
93+
94+proc frq_irc_nick_of*(prefix: cstring): cstring {.exportc, dynlib.} =
95+ if prefix == nil: return nil
96+ dup(nickOf($prefix))
added nim/tests/tircparse.nim +137 -0
new file mode 100644
@@ -0,0 +1,137 @@
1+## The IRC wire format, tested against the cases that cost something.
2+##
3+## These are not a transcription of the Clojure's tests, because it had none —
4+## which is half the argument for the move. Every case here is either a rule
5+## IRCv3 states or a bug the comments in `common/frq/irc/parse.cljc` record
6+## having been paid for once already.
7+
8+import std/unittest
9+import frq/ircparse
10+
11+suite "parseLine":
12+ test "a bare line":
13+ let p = parseLine("PING :12345")
14+ check p.command == "PING"
15+ check p.params == @["12345"]
16+ check not p.hasPrefix
17+ check not p.hasTags
18+
19+ test "a prefix is split off and the command upcased":
20+ let p = parseLine(":nick!user@host privmsg #chan :hello there")
21+ check p.prefix == "nick!user@host"
22+ check p.hasPrefix
23+ check p.command == "PRIVMSG"
24+ check p.params == @["#chan", "hello there"]
25+
26+ test "the trailing parameter keeps its spaces and its colons":
27+ let p = parseLine(":a!b@c PRIVMSG #chan :look: a b c")
28+ check p.params == @["#chan", "look: a b c"]
29+
30+ test "no trailing parameter at all":
31+ let p = parseLine(":a!b@c JOIN #chan")
32+ check p.params == @["#chan"]
33+
34+ test "an empty trailing parameter is a parameter":
35+ # `:` with nothing after it is how a client sends an empty topic, and
36+ # dropping it turns a clear into a no-op.
37+ let p = parseLine(":a!b@c TOPIC #chan :")
38+ check p.params == @["#chan", ""]
39+
40+ test "tags are taken off the front":
41+ let p = parseLine("@time=2026-01-01T00:00:00Z;account=alice :a!b@c PRIVMSG #chan :hi")
42+ check p.hasTags
43+ check p.tags == "time=2026-01-01T00:00:00Z;account=alice"
44+ check p.account == "alice"
45+ check p.command == "PRIVMSG"
46+ check p.params == @["#chan", "hi"]
47+
48+ test "a line with tags and no account":
49+ let p = parseLine("@time=x :a!b@c PRIVMSG #chan :hi")
50+ check p.hasTags
51+ check not p.hasAccount
52+
53+ test "trailing whitespace is trimmed but leading structure is not":
54+ let p = parseLine("PING :12345\r\n")
55+ check p.raw == "PING :12345"
56+ check p.params == @["12345"]
57+
58+ test "raw is what arrived, not what we made of it":
59+ let p = parseLine("@a=1 :n!u@h PRIVMSG #c :x ")
60+ check p.raw == "@a=1 :n!u@h PRIVMSG #c :x"
61+
62+ test "runs of spaces in the head do not become empty parameters":
63+ let p = parseLine(":a!b@c PRIVMSG #chan :hi")
64+ check p.params == @["#chan", "hi"]
65+
66+ test "a line that is only tags does not throw":
67+ # The Clojure indexes with `subs` on a nil `index-of` here and dies. It
68+ # cannot arrive from a conforming server, but a parser reading a socket
69+ # answers malformed input with a value rather than an exception.
70+ let p = parseLine("@only=tags")
71+ check p.hasTags
72+ check p.tags == "only=tags"
73+ check p.command == ""
74+
75+ test "an empty line":
76+ let p = parseLine("")
77+ check p.command == ""
78+ check p.params.len == 0
79+
80+suite "unescapeTag":
81+ test "the five escapes":
82+ check unescapeTag("a\\:b") == "a;b"
83+ check unescapeTag("a\\sb") == "a b"
84+ check unescapeTag("a\\\\b") == "a\\b"
85+ check unescapeTag("a\\rb") == "a\rb"
86+ check unescapeTag("a\\nb") == "a\nb"
87+
88+ test "an unknown escape is the character itself":
89+ check unescapeTag("a\\qb") == "aqb"
90+
91+ test "a trailing backslash is kept rather than eating the terminator":
92+ check unescapeTag("ab\\") == "ab\\"
93+
94+ test "a reaction tally survives the trip":
95+ # The bug this function exists for: three tallies, not one.
96+ check unescapeTag("x\\:alice\\:bob") == "x;alice;bob"
97+
98+suite "escapeTagValue":
99+ test "round-trips everything unescapeTag undoes":
100+ for s in ["plain", "a;b", "a b", "a\\b", "a\r\nb", "", "😀", "a;;b"]:
101+ check unescapeTag(escapeTagValue(s)) == s
102+
103+ test "a backslash is not double-escaped":
104+ check escapeTagValue("a\\b") == "a\\\\b"
105+
106+suite "tagValue":
107+ test "a present value":
108+ check tagValue("a=1;b=2", "b") == ("2", true)
109+
110+ test "an absent tag":
111+ check tagValue("a=1", "b") == ("", false)
112+
113+ test "an empty value and a bare key are both absent":
114+ # IRCv3 says `key` and `key=` mean the same thing, and a caller asking
115+ # `tagValue` is asking whether a fact is there. `+reply=` on a line
116+ # answering nothing used to put a reply chip above it.
117+ check tagValue("a=;b=2", "a") == ("", false)
118+ check tagValue("a;b=2", "a") == ("", false)
119+
120+ test "the value is unescaped":
121+ check tagValue("t=a\\sb", "t") == ("a b", true)
122+
123+ test "a key that is a prefix of another does not match it":
124+ check tagValue("account-x=1;account=2", "account") == ("2", true)
125+
126+ test "a value containing = keeps the rest of it":
127+ check tagValue("t=a=b", "t") == ("a=b", true)
128+
129+suite "nickOf":
130+ test "a full prefix":
131+ check nickOf("nick!user@host") == "nick"
132+
133+ test "a prefix that is only a nick":
134+ check nickOf("nick") == "nick"
135+
136+ test "a server prefix has no bang and comes back whole":
137+ check nickOf("irc.freeq.at") == "irc.freeq.at"
new file mode 100644
@@ -0,0 +1,137 @@
1+## The IRC wire format, tested against the cases that cost something.
2+##
3+## These are not a transcription of the Clojure's tests, because it had none —
4+## which is half the argument for the move. Every case here is either a rule
5+## IRCv3 states or a bug the comments in `common/frq/irc/parse.cljc` record
6+## having been paid for once already.
7+
8+import std/unittest
9+import frq/ircparse
10+
11+suite "parseLine":
12+ test "a bare line":
13+ let p = parseLine("PING :12345")
14+ check p.command == "PING"
15+ check p.params == @["12345"]
16+ check not p.hasPrefix
17+ check not p.hasTags
18+
19+ test "a prefix is split off and the command upcased":
20+ let p = parseLine(":nick!user@host privmsg #chan :hello there")
21+ check p.prefix == "nick!user@host"
22+ check p.hasPrefix
23+ check p.command == "PRIVMSG"
24+ check p.params == @["#chan", "hello there"]
25+
26+ test "the trailing parameter keeps its spaces and its colons":
27+ let p = parseLine(":a!b@c PRIVMSG #chan :look: a b c")
28+ check p.params == @["#chan", "look: a b c"]
29+
30+ test "no trailing parameter at all":
31+ let p = parseLine(":a!b@c JOIN #chan")
32+ check p.params == @["#chan"]
33+
34+ test "an empty trailing parameter is a parameter":
35+ # `:` with nothing after it is how a client sends an empty topic, and
36+ # dropping it turns a clear into a no-op.
37+ let p = parseLine(":a!b@c TOPIC #chan :")
38+ check p.params == @["#chan", ""]
39+
40+ test "tags are taken off the front":
41+ let p = parseLine("@time=2026-01-01T00:00:00Z;account=alice :a!b@c PRIVMSG #chan :hi")
42+ check p.hasTags
43+ check p.tags == "time=2026-01-01T00:00:00Z;account=alice"
44+ check p.account == "alice"
45+ check p.command == "PRIVMSG"
46+ check p.params == @["#chan", "hi"]
47+
48+ test "a line with tags and no account":
49+ let p = parseLine("@time=x :a!b@c PRIVMSG #chan :hi")
50+ check p.hasTags
51+ check not p.hasAccount
52+
53+ test "trailing whitespace is trimmed but leading structure is not":
54+ let p = parseLine("PING :12345\r\n")
55+ check p.raw == "PING :12345"
56+ check p.params == @["12345"]
57+
58+ test "raw is what arrived, not what we made of it":
59+ let p = parseLine("@a=1 :n!u@h PRIVMSG #c :x ")
60+ check p.raw == "@a=1 :n!u@h PRIVMSG #c :x"
61+
62+ test "runs of spaces in the head do not become empty parameters":
63+ let p = parseLine(":a!b@c PRIVMSG #chan :hi")
64+ check p.params == @["#chan", "hi"]
65+
66+ test "a line that is only tags does not throw":
67+ # The Clojure indexes with `subs` on a nil `index-of` here and dies. It
68+ # cannot arrive from a conforming server, but a parser reading a socket
69+ # answers malformed input with a value rather than an exception.
70+ let p = parseLine("@only=tags")
71+ check p.hasTags
72+ check p.tags == "only=tags"
73+ check p.command == ""
74+
75+ test "an empty line":
76+ let p = parseLine("")
77+ check p.command == ""
78+ check p.params.len == 0
79+
80+suite "unescapeTag":
81+ test "the five escapes":
82+ check unescapeTag("a\\:b") == "a;b"
83+ check unescapeTag("a\\sb") == "a b"
84+ check unescapeTag("a\\\\b") == "a\\b"
85+ check unescapeTag("a\\rb") == "a\rb"
86+ check unescapeTag("a\\nb") == "a\nb"
87+
88+ test "an unknown escape is the character itself":
89+ check unescapeTag("a\\qb") == "aqb"
90+
91+ test "a trailing backslash is kept rather than eating the terminator":
92+ check unescapeTag("ab\\") == "ab\\"
93+
94+ test "a reaction tally survives the trip":
95+ # The bug this function exists for: three tallies, not one.
96+ check unescapeTag("x\\:alice\\:bob") == "x;alice;bob"
97+
98+suite "escapeTagValue":
99+ test "round-trips everything unescapeTag undoes":
100+ for s in ["plain", "a;b", "a b", "a\\b", "a\r\nb", "", "😀", "a;;b"]:
101+ check unescapeTag(escapeTagValue(s)) == s
102+
103+ test "a backslash is not double-escaped":
104+ check escapeTagValue("a\\b") == "a\\\\b"
105+
106+suite "tagValue":
107+ test "a present value":
108+ check tagValue("a=1;b=2", "b") == ("2", true)
109+
110+ test "an absent tag":
111+ check tagValue("a=1", "b") == ("", false)
112+
113+ test "an empty value and a bare key are both absent":
114+ # IRCv3 says `key` and `key=` mean the same thing, and a caller asking
115+ # `tagValue` is asking whether a fact is there. `+reply=` on a line
116+ # answering nothing used to put a reply chip above it.
117+ check tagValue("a=;b=2", "a") == ("", false)
118+ check tagValue("a;b=2", "a") == ("", false)
119+
120+ test "the value is unescaped":
121+ check tagValue("t=a\\sb", "t") == ("a b", true)
122+
123+ test "a key that is a prefix of another does not match it":
124+ check tagValue("account-x=1;account=2", "account") == ("2", true)
125+
126+ test "a value containing = keeps the rest of it":
127+ check tagValue("t=a=b", "t") == ("a=b", true)
128+
129+suite "nickOf":
130+ test "a full prefix":
131+ check nickOf("nick!user@host") == "nick"
132+
133+ test "a prefix that is only a nick":
134+ check nickOf("nick") == "nick"
135+
136+ test "a server prefix has no bang and comes back whole":
137+ check nickOf("irc.freeq.at") == "irc.freeq.at"
deleted src/frq/app.clj +0 -220
deleted file mode 100644
@@ -1,220 +0,0 @@
1-(ns frq.app
2- "frq — a freeq client, as glimmer components.
3-
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
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."
13- (:require [clojure.string :as str]
14- [glimmer.ratom :as r :refer [atom]]
15- [glimmer.core :as ui]
16- [frq.av :as av]
17- [frq.avatars :as avatars]
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]
25- [frq.glyphs :as glyphs]
26- [frq.media :as media]
27- [frq.platform :as platform]
28- [frq.profile.host :as profile]
29- ;; The connect screen lives in common/ now — the same file the
30- ;; phone renders. It reads frq.cells and calls frq.actions, and
31- ;; this requires it exactly where its own copy used to be.
32- [frq.actions :as actions]
33- [frq.metrics :as metrics]
34- [frq.screens.connect :as connect :refer [connect-screen error-note]]
35- ;; The conversation list moved out too, with the tab bar and the
36- ;; row it paints. Same arrangement as the connect screen: the
37- ;; phone renders this very file.
38- [frq.screens.chats :refer [below-list chats-screen conversation-row
39- preview-line tab-bar]]
40- [frq.screens.app :as screens]
41- [frq.screens.settings :refer [discover-screen settings-screen
42- tab-screen]]
43- [frq.screens.chat :refer [chat-screen emoji-picker message-row
44- reactor-dialog sidebar-width
45- users-panel]]
46- [frq.state :as s]))
47-
48-;; ---------------------------------------------------------------- pieces
49-
50-(def terminal? metrics/terminal?)
51-
52-;; And whether that terminal draws pictures over its cells — Kitty's graphics
53-;; protocol, which kitty, Ghostty and WezTerm answer and an xterm does not.
54-;; `frq.tui` works it out from the environment and sets this beside the flag
55-;; above; a terminal that has not got it is the one described there, a column
56-;; of names with the words hanging under them.
57-(def terminal-graphics? metrics/terminal-graphics?)
58-
59-(def ^:private terminal-face? metrics/terminal-face?)
60-
61-(defonce ^:private derived-cells
62- ;; One cell per question, kept for the session: a cell made afresh on every
63- ;; render would add a watch to its source each time and never take it off.
64- (clojure.core/atom {}))
65-
66-(defn- derived
67- "A reactive cell for one row's answer to a question about shared state, made
68- once per `k` and kept.
69-
70- A message row that read `s/highlight` itself was re-rendered whenever the
71- highlight moved anywhere — every row in the backlog, for one jump — and the
72- same for a hover, an open picker, or any face or picture finishing a fetch.
73- A reaction is recomputed on each such change, which is a comparison, but it
74- wakes the rows that read it only when its answer changes: the two rows a
75- jump moves between, the one face under the pointer.
76-
77- Kept rather than collected: glimmer's reactions have no way to unsubscribe,
78- so a cell per message lasts as long as the session does."
79- [k f]
80- (or (get @derived-cells k)
81- (let [cell (r/reaction (f))]
82- (swap! derived-cells assoc k cell)
83- cell)))
84-
85-(defn- avatar-path
86- "The cell answering where `actor`'s face is on disk, once it is."
87- [actor]
88- (derived [:avatar actor] #(do @s/media-tick (avatars/path-when-ready actor))))
89-
90-(defn- image-path
91- "The cell answering where the picture behind `url` is on disk, once it is."
92- [url]
93- (derived [:image url] #(do @s/media-tick (media/path-when-ready url))))
94-
95-;; How big a face is on a message, in points — the size the window has always
96-;; drawn one at. A terminal's cell is eight points across and sixteen down, so
97-;; the same number is four columns by two rows there: a cached 128-pixel
98-;; portrait at a quarter size, and about the smallest a face is still a face at.
99-;; ---------------------------------------------------------------- chats
100-
101-;; The way into a room. Standard rather than `:primary`: the theme paints a
102-;; suggested button as a filled lozenge, which is the highest-contrast fill it
103-;; has — and a filled lozenge on every card made the brightest, most regular
104-;; rhythm down the list the one word that is the same on every row, with the
105-;; name that differs set quieter than it. Nothing moves; the button stops
106-;; shouting.
107-(def ^:private window-row metrics/window-row)
108-;; Re-defined rather than moved out of reach: `frq.tui` resets this before its
109-;; first paint and says `app/chrome-row` when it does.
110-(def chrome-row metrics/chrome-row)
111-
112-(def ^:private chrome-scale metrics/chrome-scale)
113-
114-;; ---------------------------------------------------------------- chat
115-
116-;; ---------------------------------------------------------------- split
117-
118-;; ---------------------------------------------------------------- discover
119-
120-;; ---------------------------------------------------------------- settings
121-
122-;; ---------------------------------------------------------------- shell
123-
124-(defn start!
125- "Everything a launch does before the loop starts, for whichever backend is
126- about to run it.
127-
128- This used to be the first half of `-main`, and a second entry point copied it
129- — which is how frq.tui came up on a client that had restored nothing, was
130- signed into nothing and was connected to nothing: an empty buffer with nobody
131- in it, which reads as a broken screen rather than as a client that was never
132- told to start.
133-
134- The timers are handed in because they belong to the backend: `after!` and
135- `every!` are how anything gets onto the loop thread, and the terminal has its
136- own pair. So are the two things only a window has — `title!` renames one, and
137- `measure!` reports its size — and either may be nil where there is none.
138-
139- `av?` is the media plane. It wants a window: a call paints frames into a
140- texture, and there is no texture in a terminal."
141- [{:keys [after! every! title! measure! av?] :or {av? true}}]
142- ;; Before the loop: the settings, the rooms this client has been in, and a
143- ;; saved sign-in deciding which mode the connect screen opens in and what it
144- ;; says.
145- (s/restore-prefs!)
146- (s/restore-channels!)
147- (when (s/restore-session!)
148- ;; And then it connects on its own. A remembered account has already said
149- ;; what it wants; making it say so again at every launch is a click that
150- ;; carries no information. It is a timer rather than a call here so the
151- ;; screen is up first — the connect screen with its status is what the
152- ;; user should be looking at while this happens, and if it fails, the
153- ;; error lands somewhere visible.
154- (after! 150 s/connect!))
155- (when av?
156- ;; Calls arrive rather than being asked for, so the media plane is drained
157- ;; every frame whether or not one is up — the drain costs a single integer
158- ;; read when it is not. It has to be a timer: `frame-rgba!` and everything
159- ;; else that touches a node belongs to the loop thread, and this is
160- ;; glimmer's way of getting onto it.
161- (av/init-logging!)
162- ;; Who to tell when a call ends under us rather than at our asking. Set
163- ;; here rather than in frq.av because sending a TAGMSG needs the
164- ;; connection, and that belongs to the state layer.
165- (reset! av/on-dropped s/announce-leave!)
166- (after! 0 av/install-pump!))
167- ;; The surface's size, into a ratom, a few times a second. Polled rather than
168- ;; delivered: the backend reports a size by writing it onto the window node,
169- ;; and only what a component derefs re-renders — so the layout follows a drag
170- ;; of the edge without every frame touching the tree.
171- (when measure!
172- (measure!)
173- (every! 200 measure!))
174- ;; The nick in the window title, so a second window of this client is told
175- ;; apart from the first by the one thing that differs — and so the answer to
176- ;; "who am I here?" is on screen without opening Settings.
177- ;;
178- ;; Polled, like the size above and for the same reason: the title belongs to
179- ;; the window rather than to the tree, so no render puts it there, and the
180- ;; nick is only settled once a connection has been made. Twice a second is
181- ;; far more often than a nick changes, and a string compare is what a tick
182- ;; costs when it has not.
183- ;; Seeded with the title `run` opens the window under, so the first tick of
184- ;; a launch that has nobody signed in yet sets nothing.
185- (when title!
186- (let [shown (atom "frq")]
187- (every! 500 #(let [title (if (and (s/connected?) (seq @s/form-nick))
188- (str "frq — " @s/form-nick)
189- "frq")]
190- (when (not= title @shown)
191- (reset! shown title)
192- (title! title))))))
193- nil)
194-
195-;; Where a face or a picture is on disk, once it is. These stay here because
196-;; both are a glimmer reaction over a fetch-and-cache — `derived` is the one
197-;; place in this namespace that uses glimmer's own API — and a phone has
198-;; neither the reaction nor the cache. It installs nothing and the screens
199-;; draw what they draw before a face arrives.
200-;; The root, re-defined here: `frq.tui` and `frq.cosmic` both say `app/app`
201-;; when they mount, and the namespace they say it to is this one.
202-(def app screens/app)
203-
204-(actions/install!
205- ;; The value rather than the cell: a platform without a cache answers nil,
206- ;; and `@nil` is not a thing. Derefing the reaction here keeps the desktop's
207- ;; per-row waking, because the deref still happens inside the render.
208- {:avatar-path (fn [actor] @(avatar-path actor))
209- :image-path (fn [url] @(image-path url))
210- ;; The profile half lives here rather than in frq.state, which does not
211- ;; require frq.profile — whose card is open is a question about the screen,
212- ;; not about the connection.
213- :viewing (fn [] @profile/viewing)
214- :profile-open! profile/open!
215- :profile-close! profile/close!
216- :profile-entry profile/entry
217- :profile-stats-line profile/stats-line
218- :profile-tick (fn [] @profile/tick)
219- :profile-truncate profile/truncate
220- :profile-web-url profile/web-url})
deleted file mode 100644
@@ -1,220 +0,0 @@
1-(ns frq.app
2- "frq — a freeq client, as glimmer components.
3-
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
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."
13- (:require [clojure.string :as str]
14- [glimmer.ratom :as r :refer [atom]]
15- [glimmer.core :as ui]
16- [frq.av :as av]
17- [frq.avatars :as avatars]
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]
25- [frq.glyphs :as glyphs]
26- [frq.media :as media]
27- [frq.platform :as platform]
28- [frq.profile.host :as profile]
29- ;; The connect screen lives in common/ now — the same file the
30- ;; phone renders. It reads frq.cells and calls frq.actions, and
31- ;; this requires it exactly where its own copy used to be.
32- [frq.actions :as actions]
33- [frq.metrics :as metrics]
34- [frq.screens.connect :as connect :refer [connect-screen error-note]]
35- ;; The conversation list moved out too, with the tab bar and the
36- ;; row it paints. Same arrangement as the connect screen: the
37- ;; phone renders this very file.
38- [frq.screens.chats :refer [below-list chats-screen conversation-row
39- preview-line tab-bar]]
40- [frq.screens.app :as screens]
41- [frq.screens.settings :refer [discover-screen settings-screen
42- tab-screen]]
43- [frq.screens.chat :refer [chat-screen emoji-picker message-row
44- reactor-dialog sidebar-width
45- users-panel]]
46- [frq.state :as s]))
47-
48-;; ---------------------------------------------------------------- pieces
49-
50-(def terminal? metrics/terminal?)
51-
52-;; And whether that terminal draws pictures over its cells — Kitty's graphics
53-;; protocol, which kitty, Ghostty and WezTerm answer and an xterm does not.
54-;; `frq.tui` works it out from the environment and sets this beside the flag
55-;; above; a terminal that has not got it is the one described there, a column
56-;; of names with the words hanging under them.
57-(def terminal-graphics? metrics/terminal-graphics?)
58-
59-(def ^:private terminal-face? metrics/terminal-face?)
60-
61-(defonce ^:private derived-cells
62- ;; One cell per question, kept for the session: a cell made afresh on every
63- ;; render would add a watch to its source each time and never take it off.
64- (clojure.core/atom {}))
65-
66-(defn- derived
67- "A reactive cell for one row's answer to a question about shared state, made
68- once per `k` and kept.
69-
70- A message row that read `s/highlight` itself was re-rendered whenever the
71- highlight moved anywhere — every row in the backlog, for one jump — and the
72- same for a hover, an open picker, or any face or picture finishing a fetch.
73- A reaction is recomputed on each such change, which is a comparison, but it
74- wakes the rows that read it only when its answer changes: the two rows a
75- jump moves between, the one face under the pointer.
76-
77- Kept rather than collected: glimmer's reactions have no way to unsubscribe,
78- so a cell per message lasts as long as the session does."
79- [k f]
80- (or (get @derived-cells k)
81- (let [cell (r/reaction (f))]
82- (swap! derived-cells assoc k cell)
83- cell)))
84-
85-(defn- avatar-path
86- "The cell answering where `actor`'s face is on disk, once it is."
87- [actor]
88- (derived [:avatar actor] #(do @s/media-tick (avatars/path-when-ready actor))))
89-
90-(defn- image-path
91- "The cell answering where the picture behind `url` is on disk, once it is."
92- [url]
93- (derived [:image url] #(do @s/media-tick (media/path-when-ready url))))
94-
95-;; How big a face is on a message, in points — the size the window has always
96-;; drawn one at. A terminal's cell is eight points across and sixteen down, so
97-;; the same number is four columns by two rows there: a cached 128-pixel
98-;; portrait at a quarter size, and about the smallest a face is still a face at.
99-;; ---------------------------------------------------------------- chats
100-
101-;; The way into a room. Standard rather than `:primary`: the theme paints a
102-;; suggested button as a filled lozenge, which is the highest-contrast fill it
103-;; has — and a filled lozenge on every card made the brightest, most regular
104-;; rhythm down the list the one word that is the same on every row, with the
105-;; name that differs set quieter than it. Nothing moves; the button stops
106-;; shouting.
107-(def ^:private window-row metrics/window-row)
108-;; Re-defined rather than moved out of reach: `frq.tui` resets this before its
109-;; first paint and says `app/chrome-row` when it does.
110-(def chrome-row metrics/chrome-row)
111-
112-(def ^:private chrome-scale metrics/chrome-scale)
113-
114-;; ---------------------------------------------------------------- chat
115-
116-;; ---------------------------------------------------------------- split
117-
118-;; ---------------------------------------------------------------- discover
119-
120-;; ---------------------------------------------------------------- settings
121-
122-;; ---------------------------------------------------------------- shell
123-
124-(defn start!
125- "Everything a launch does before the loop starts, for whichever backend is
126- about to run it.
127-
128- This used to be the first half of `-main`, and a second entry point copied it
129- — which is how frq.tui came up on a client that had restored nothing, was
130- signed into nothing and was connected to nothing: an empty buffer with nobody
131- in it, which reads as a broken screen rather than as a client that was never
132- told to start.
133-
134- The timers are handed in because they belong to the backend: `after!` and
135- `every!` are how anything gets onto the loop thread, and the terminal has its
136- own pair. So are the two things only a window has — `title!` renames one, and
137- `measure!` reports its size — and either may be nil where there is none.
138-
139- `av?` is the media plane. It wants a window: a call paints frames into a
140- texture, and there is no texture in a terminal."
141- [{:keys [after! every! title! measure! av?] :or {av? true}}]
142- ;; Before the loop: the settings, the rooms this client has been in, and a
143- ;; saved sign-in deciding which mode the connect screen opens in and what it
144- ;; says.
145- (s/restore-prefs!)
146- (s/restore-channels!)
147- (when (s/restore-session!)
148- ;; And then it connects on its own. A remembered account has already said
149- ;; what it wants; making it say so again at every launch is a click that
150- ;; carries no information. It is a timer rather than a call here so the
151- ;; screen is up first — the connect screen with its status is what the
152- ;; user should be looking at while this happens, and if it fails, the
153- ;; error lands somewhere visible.
154- (after! 150 s/connect!))
155- (when av?
156- ;; Calls arrive rather than being asked for, so the media plane is drained
157- ;; every frame whether or not one is up — the drain costs a single integer
158- ;; read when it is not. It has to be a timer: `frame-rgba!` and everything
159- ;; else that touches a node belongs to the loop thread, and this is
160- ;; glimmer's way of getting onto it.
161- (av/init-logging!)
162- ;; Who to tell when a call ends under us rather than at our asking. Set
163- ;; here rather than in frq.av because sending a TAGMSG needs the
164- ;; connection, and that belongs to the state layer.
165- (reset! av/on-dropped s/announce-leave!)
166- (after! 0 av/install-pump!))
167- ;; The surface's size, into a ratom, a few times a second. Polled rather than
168- ;; delivered: the backend reports a size by writing it onto the window node,
169- ;; and only what a component derefs re-renders — so the layout follows a drag
170- ;; of the edge without every frame touching the tree.
171- (when measure!
172- (measure!)
173- (every! 200 measure!))
174- ;; The nick in the window title, so a second window of this client is told
175- ;; apart from the first by the one thing that differs — and so the answer to
176- ;; "who am I here?" is on screen without opening Settings.
177- ;;
178- ;; Polled, like the size above and for the same reason: the title belongs to
179- ;; the window rather than to the tree, so no render puts it there, and the
180- ;; nick is only settled once a connection has been made. Twice a second is
181- ;; far more often than a nick changes, and a string compare is what a tick
182- ;; costs when it has not.
183- ;; Seeded with the title `run` opens the window under, so the first tick of
184- ;; a launch that has nobody signed in yet sets nothing.
185- (when title!
186- (let [shown (atom "frq")]
187- (every! 500 #(let [title (if (and (s/connected?) (seq @s/form-nick))
188- (str "frq — " @s/form-nick)
189- "frq")]
190- (when (not= title @shown)
191- (reset! shown title)
192- (title! title))))))
193- nil)
194-
195-;; Where a face or a picture is on disk, once it is. These stay here because
196-;; both are a glimmer reaction over a fetch-and-cache — `derived` is the one
197-;; place in this namespace that uses glimmer's own API — and a phone has
198-;; neither the reaction nor the cache. It installs nothing and the screens
199-;; draw what they draw before a face arrives.
200-;; The root, re-defined here: `frq.tui` and `frq.cosmic` both say `app/app`
201-;; when they mount, and the namespace they say it to is this one.
202-(def app screens/app)
203-
204-(actions/install!
205- ;; The value rather than the cell: a platform without a cache answers nil,
206- ;; and `@nil` is not a thing. Derefing the reaction here keeps the desktop's
207- ;; per-row waking, because the deref still happens inside the render.
208- {:avatar-path (fn [actor] @(avatar-path actor))
209- :image-path (fn [url] @(image-path url))
210- ;; The profile half lives here rather than in frq.state, which does not
211- ;; require frq.profile — whose card is open is a question about the screen,
212- ;; not about the connection.
213- :viewing (fn [] @profile/viewing)
214- :profile-open! profile/open!
215- :profile-close! profile/close!
216- :profile-entry profile/entry
217- :profile-stats-line profile/stats-line
218- :profile-tick (fn [] @profile/tick)
219- :profile-truncate profile/truncate
220- :profile-web-url profile/web-url})
deleted src/frq/atproto.clj +0 -99
deleted file mode 100644
@@ -1,99 +0,0 @@
1-(ns frq.atproto
2- "The desktop's AT Protocol: `frq.atproto.core` with a socket under it.
3-
4- The protocol itself moved to common/ — the JSON, the base64url, the SASL
5- payloads, and a `-req`/`-parse` pair per step of the flow. What could not
6- move is this: HTTPS hand-rolled over jolt.mvn-http's TLS bindings, because
7- `fetch` there writes to a file and cannot POST. That is also why sign-in is
8- desktop-only on jolt — there is no libssl to load on Android — and why the
9- phone has `frq.atproto.dart`, where TLS is in the runtime.
10-
11- Everything in core is re-exported here, so the twenty-nine call sites that
12- say `atproto/json-str` or `atproto/request` did not move."
13- (:require [clojure.string :as str]
14- [frq.atproto.core :as core]
15- [jolt.mvn-http :as tls]))
16-
17-(def directory-host core/directory-host)
18-(def plc-host core/plc-host)
19-(def json-str core/json-str)
20-(def json-num core/json-num)
21-(def json-unescape core/json-unescape)
22-(def json-object core/json-object)
23-(def b64-encode core/b64-encode)
24-(def b64-decode core/b64-decode)
25-(def sasl-response core/sasl-response)
26-
27-;; ------------------------------------------------------------------ HTTP
28-
29-(defn- read-all!
30- "Drain a TLS connection into a string."
31- [t]
32- (loop [acc ""]
33- (let [b (try (tls/tls-read t) (catch Exception _ nil))]
34- (if (or (nil? b) (zero? (count b)))
35- acc
36- (recur (str acc (String. b)))))))
37-
38-(defn request
39- "One HTTPS request, connection-per-request. Returns the response body.
40- `body` nil makes it a GET.
41-
42- `headers` are written after the ones every request here carries, for a
43- caller that has to identify itself — `frq.io.jolt`'s `fetch-text!` sends the
44- freeq bearer this way. A header named twice is the caller's problem; nothing
45- here overrides what it is given."
46- ([host path body] (request host path body nil))
47- ([host path body headers]
48- (tls/ensure-native!)
49- (let [t (tls/tls-connect host 443)
50- payload (or body "")
51- head (str (if body "POST " "GET ") path " HTTP/1.1\r\n"
52- "Host: " host "\r\n"
53- "User-Agent: frq\r\n"
54- "Accept: application/json\r\n"
55- (apply str (for [[k v] headers] (str k ": " v "\r\n")))
56- (when body
57- (str "Content-Type: application/json\r\n"
58- "Content-Length: " (count (.getBytes payload)) "\r\n"))
59- "Connection: close\r\n\r\n")]
60- (try
61- (tls/tls-write t (.getBytes (str head payload)))
62- (let [resp (read-all! t)
63- [_ b] (str/split resp #"\r\n\r\n" 2)]
64- (or b ""))
65- (finally (try (tls/tls-close t) (catch Exception _ nil)))))))
66-
67-;; ------------------------------------------------------------------ JSON
68-
69-(defn- fetch
70- "Perform one `-req` descriptor."
71- [{:keys [host path body]}]
72- (request host path body))
73-
74-;; ------------------------------------------------------------------ identity
75-;;
76-;; The flow, put back together: core says what to ask and what the answer
77-;; means, and this is the only part that touches a socket.
78-
79-(defn resolve-handle
80- "A handle (alice.bsky.social) to its DID. A DID passes through untouched."
81- [handle]
82- (core/resolve-handle-parse
83- handle
84- (when-let [req (core/resolve-handle-req handle)] (fetch req))))
85-
86-(defn pds-endpoint
87- "The DID's PDS service endpoint, from its DID document."
88- [did]
89- (core/pds-endpoint-parse did (fetch (core/pds-doc-req did))))
90-
91-(defn create-session
92- "Sign in to the PDS with an app password. Returns
93- {:did :handle :access-jwt :pds}."
94- [identifier password]
95- (let [did (resolve-handle identifier)
96- pds (pds-endpoint did)]
97- (core/create-session-parse
98- identifier did pds
99- (fetch (core/create-session-req pds identifier password)))))
deleted file mode 100644
@@ -1,99 +0,0 @@
1-(ns frq.atproto
2- "The desktop's AT Protocol: `frq.atproto.core` with a socket under it.
3-
4- The protocol itself moved to common/ — the JSON, the base64url, the SASL
5- payloads, and a `-req`/`-parse` pair per step of the flow. What could not
6- move is this: HTTPS hand-rolled over jolt.mvn-http's TLS bindings, because
7- `fetch` there writes to a file and cannot POST. That is also why sign-in is
8- desktop-only on jolt — there is no libssl to load on Android — and why the
9- phone has `frq.atproto.dart`, where TLS is in the runtime.
10-
11- Everything in core is re-exported here, so the twenty-nine call sites that
12- say `atproto/json-str` or `atproto/request` did not move."
13- (:require [clojure.string :as str]
14- [frq.atproto.core :as core]
15- [jolt.mvn-http :as tls]))
16-
17-(def directory-host core/directory-host)
18-(def plc-host core/plc-host)
19-(def json-str core/json-str)
20-(def json-num core/json-num)
21-(def json-unescape core/json-unescape)
22-(def json-object core/json-object)
23-(def b64-encode core/b64-encode)
24-(def b64-decode core/b64-decode)
25-(def sasl-response core/sasl-response)
26-
27-;; ------------------------------------------------------------------ HTTP
28-
29-(defn- read-all!
30- "Drain a TLS connection into a string."
31- [t]
32- (loop [acc ""]
33- (let [b (try (tls/tls-read t) (catch Exception _ nil))]
34- (if (or (nil? b) (zero? (count b)))
35- acc
36- (recur (str acc (String. b)))))))
37-
38-(defn request
39- "One HTTPS request, connection-per-request. Returns the response body.
40- `body` nil makes it a GET.
41-
42- `headers` are written after the ones every request here carries, for a
43- caller that has to identify itself — `frq.io.jolt`'s `fetch-text!` sends the
44- freeq bearer this way. A header named twice is the caller's problem; nothing
45- here overrides what it is given."
46- ([host path body] (request host path body nil))
47- ([host path body headers]
48- (tls/ensure-native!)
49- (let [t (tls/tls-connect host 443)
50- payload (or body "")
51- head (str (if body "POST " "GET ") path " HTTP/1.1\r\n"
52- "Host: " host "\r\n"
53- "User-Agent: frq\r\n"
54- "Accept: application/json\r\n"
55- (apply str (for [[k v] headers] (str k ": " v "\r\n")))
56- (when body
57- (str "Content-Type: application/json\r\n"
58- "Content-Length: " (count (.getBytes payload)) "\r\n"))
59- "Connection: close\r\n\r\n")]
60- (try
61- (tls/tls-write t (.getBytes (str head payload)))
62- (let [resp (read-all! t)
63- [_ b] (str/split resp #"\r\n\r\n" 2)]
64- (or b ""))
65- (finally (try (tls/tls-close t) (catch Exception _ nil)))))))
66-
67-;; ------------------------------------------------------------------ JSON
68-
69-(defn- fetch
70- "Perform one `-req` descriptor."
71- [{:keys [host path body]}]
72- (request host path body))
73-
74-;; ------------------------------------------------------------------ identity
75-;;
76-;; The flow, put back together: core says what to ask and what the answer
77-;; means, and this is the only part that touches a socket.
78-
79-(defn resolve-handle
80- "A handle (alice.bsky.social) to its DID. A DID passes through untouched."
81- [handle]
82- (core/resolve-handle-parse
83- handle
84- (when-let [req (core/resolve-handle-req handle)] (fetch req))))
85-
86-(defn pds-endpoint
87- "The DID's PDS service endpoint, from its DID document."
88- [did]
89- (core/pds-endpoint-parse did (fetch (core/pds-doc-req did))))
90-
91-(defn create-session
92- "Sign in to the PDS with an app password. Returns
93- {:did :handle :access-jwt :pds}."
94- [identifier password]
95- (let [did (resolve-handle identifier)
96- pds (pds-endpoint did)]
97- (core/create-session-parse
98- identifier did pds
99- (fetch (core/create-session-req pds identifier password)))))
deleted src/frq/av.clj +0 -657
deleted file mode 100644
@@ -1,657 +0,0 @@
1-(ns frq.av
2- "Calls: the signaling, in jolt, and a handle on the media plane, which is not.
3-
4- A freeq call has two halves. The one that is written here is *signaling* —
5- `+freeq.at/av-start`, `av-join` and `av-leave` go out as TAGMSGs and the
6- server broadcasts `+freeq.at/av-state` back, which is IRC and nothing more,
7- so it is written in the language the rest of the client is written in.
8-
9- The other half is audio and video over MoQ, and it used to be `libjoltmoq`
10- — three thousand lines of Rust behind a C ABI. It is `frq.av.plane` now:
11- MoQ over QUIC from `libmoq_ffi`, Opus from libopus, H.264 from openh264,
12- and the camera and sound devices from V4L2 and ALSA, all bound directly.
13- This namespace is the whole of what frq says to it, and what it says has
14- barely changed — the plane was built to joltmoq's shape on purpose.
15-
16- Two rules come from that side and shape everything here:
17-
18- * **Nothing calls back.** Status and video are *polled* — `pump!` drains both
19- and is called from a timer, which glimmer runs on the loop thread.
20- * **A video frame is borrowed**, valid only until the next poll. `pump!`
21- hands each one to Vidya as it arrives and never keeps one, which is also
22- why a frame never becomes a jolt value: it goes from the decoder's buffer
23- to the texture as a pointer, and is never copied on this side at all.
24-
25- Both still hold, and for the same reasons — the plane is pumped because a
26- blocking foreign call would pin glimmer's loop thread, and it hands out
27- borrowed pointers because a frame at thirty a second cannot afford a copy."
28- (:require [clojure.string :as str]
29- [glimmer.ratom :as r :refer [atom]]
30- [frq.platform :as platform]
31- [frq.irc :as irc]
32- [frq.av.dial :as dial]
33- [frq.av.plane :as plane]
34- [frq.capture.alsa :as alsa]
35- [frq.capture.v4l2 :as v4l2]))
36-
37-;; --- the media plane ---------------------------------------------------------
38-;; `frq.av.plane`, where this used to be twenty-five `joltmoq_*` symbols.
39-;; The shape is deliberately the same one — start, stop, poll status, poll
40-;; frames — because that is what let the plane be swapped in underneath this
41-;; file rather than through it.
42-
43-(def ^:private status-live :live)
44-(def ^:private status-ended :ended)
45-(def ^:private status-failed :failed)
46-
47-(defonce ^:private media-plane
48- ;; Whether the media plane can run here at all, asked once. It cannot on
49- ;; the phone: the plane is V4L2 and ALSA, and Android has neither — the
50- ;; camera is Camera2 through JNI and the audio is AAudio. A client that
51- ;; cannot make calls is still a client, so the answer gates the call
52- ;; surface rather than ending the run.
53- ;;
54- ;; Asked by looking for a sound device rather than by loading anything:
55- ;; every library the plane needs is declared in deps.edn and already
56- ;; resolved by the time this runs, so the question is not "is the code
57- ;; here" but "is there anything for it to talk to".
58- (delay
59- (try (boolean (seq (alsa/devices :playback)))
60- (catch Exception _ false))))
61-
62-(defn available?
63- "Whether calls can happen here at all."
64- []
65- @media-plane)
66-
67-(defn- pref [s] (or s ""))
68-
69-(defn live? [] (plane/live?))
70-
71-(defn can-dial?
72- "Whether dialling this server is worth attempting. A remote SFU with no token
73- accepts the connection and closes it, and the MoQ client then retries in a
74- tight loop that looks exactly like a hang."
75- [server jwt]
76- (dial/can-dial? server jwt))
77-
78-(defn sfu-url
79- "The SFU to dial for this server, or nil when the server is not one a URL can
80- be made of."
81- [server jwt instance]
82- (dial/sfu-url server jwt instance))
83-
84-(defn new-instance
85- "A per-device call instance id. Two devices signed in as the same person need
86- different ones, or their broadcast paths collide."
87- []
88- (dial/new-instance))
89-
90-;; --- devices -----------------------------------------------------------------
91-;; Where joltmoq answered a tab-separated string that this file parsed, the
92-;; bindings answer the maps directly. The shape the UI reads is unchanged:
93-;; {:id :name :default?}.
94-
95-(defn cameras [] (try (v4l2/devices) (catch Exception _ [])))
96-(defn microphones [] (try (alsa/devices :capture) (catch Exception _ [])))
97-(defn speakers [] (try (alsa/devices :playback) (catch Exception _ [])))
98-
99-;; --- the signaling tags ------------------------------------------------------
100-;; Every one of these is a TAGMSG to the channel. The server answers with an
101-;; `+freeq.at/av-state` broadcast, which is what actually moves this client's
102-;; state — nothing below assumes a request succeeded.
103-
104-(defn start-tags
105- "Open a call on this channel."
106- [instance title]
107- (cond-> {"+freeq.at/av-start" ""
108- "+freeq.at/av-instance" instance}
109- (seq title) (assoc "+freeq.at/av-title" title)))
110-
111-(defn join-tags
112- "Join the call already open on this channel."
113- [session-id instance]
114- {"+freeq.at/av-join" ""
115- "+freeq.at/av-id" session-id
116- "+freeq.at/av-instance" instance})
117-
118-(defn leave-tags
119- [session-id instance]
120- {"+freeq.at/av-leave" ""
121- "+freeq.at/av-id" session-id
122- "+freeq.at/av-instance" instance})
123-
124-(defn parse-state
125- "An `+freeq.at/av-state` broadcast, or nil for any other TAGMSG.
126-
127- Safe to apply to every TAGMSG that arrives: a reaction answers nil."
128- [tags]
129- (let [action (irc/tag-value tags "+freeq.at/av-state")]
130- (when (contains? #{"started" "joined" "left" "ended"} action)
131- {:action (keyword action)
132- :session-id (or (irc/tag-value tags "+freeq.at/av-id") "")
133- :actor (irc/tag-value tags "+freeq.at/av-actor")
134- :participants (when-let [p (irc/tag-value tags "+freeq.at/av-participants")]
135- (try (Integer/parseInt p) (catch Exception _ nil)))
136- :title (let [t (irc/tag-value tags "+freeq.at/av-title")]
137- (when (seq t) t))})))
138-
139-(defn state-message
140- "The system line a state change is worth showing as."
141- [{:keys [action actor participants title]}]
142- (let [who (or actor "someone")
143- n (if participants (str " · " participants " in call") "")]
144- (case action
145- :started (str "Call started by " who
146- (if (seq title) (str " “" title "”") "")
147- n)
148- :joined (str who " joined the call" n)
149- :left (str who " left the call" n)
150- :ended (str "Call ended" n)
151- "")))
152-
153-;; --- what the UI reads -------------------------------------------------------
154-
155-;; channel -> {:session-id :title :participants :last-actor}. What the server
156-;; says is happening in a room, whether or not we are in it: this is what puts
157-;; a "join the call" banner above a channel nobody here has joined.
158-(defonce channel-calls (atom {}))
159-
160-;; nil, or the call this device is in. `:media` is how far the other half has
161-;; got: :dialling until the plane says otherwise, then :live or :failed.
162-(defonce local-call (atom nil))
163-
164-;; The last thing the media plane failed with, for the line under the controls.
165-(defonce media-error (atom nil))
166-
167-;; What to do when this device stops being in a call without having asked to.
168-;;
169-;; The media plane failing is not the server hearing about it: freeq counts a
170-;; participant until an `av-leave` says otherwise. Dropping out quietly leaves
171-;; a ghost in the room, and pressing Join again adds a second one — which is
172-;; how a channel ends up reporting seven people in a call with two.
173-;;
174-;; `frq.state` puts the TAGMSG here. This namespace cannot send one itself
175-;; without depending on the client that owns the connection.
176-(defonce on-dropped (atom nil))
177-
178-;; SFU tokens, by session id.
179-;;
180-;; The server mints one when you join, and does not necessarily mint another
181-;; when you join the same call again — so a client that forgets it on the way
182-;; out has nothing to dial with on the way back in, and rejoining signals
183-;; correctly and then sits there with no video. Kept past the call for exactly
184-;; that, and overwritten whenever a fresh one arrives.
185-(defonce ^:private session-tokens (atom {}))
186-
187-;; Feeds pushed to Vidya, so the ones that stop can be dropped again. Without
188-;; this the last frame of someone who left hangs on the wall for the rest of
189-;; the call.
190-(defonce ^:private painted-feeds (atom #{}))
191-
192-;; Who has a picture, in the order they should be drawn.
193-;;
194-;; A cell rather than a question asked at render time, and that is the whole
195-;; point: glimmer re-renders a component when a ratom it read has changed, so a
196-;; view that asked the media plane directly would render once with nobody on
197-;; screen and never be told that someone had appeared. The frames would arrive,
198-;; be handed to Vidya, and paint into no node at all.
199-(defonce feeds (atom []))
200-
201-;; The window's width in points, so a tile can be a share of it rather than a
202-;; number somebody picked. A cell for the same reason `feeds` is one: a
203-;; component that asked the backend at render time would lay itself out once,
204-;; on the first frame, and keep that shape however the window was dragged.
205-(defonce window-width (atom 0))
206-(defonce window-height (atom 0))
207-
208-;; The self-view is keyed this way by the media plane; the UI wants to know
209-;; which tile is its own, to label it and to mirror nothing else.
210-(def local-feed "__local__")
211-
212-(declare stop-media!)
213-
214-(defn call-in [channel] (get @channel-calls channel))
215-
216-(defn in-call?
217- ([] (some? @local-call))
218- ([channel] (= channel (:channel @local-call))))
219-
220-(defn apply-state!
221- "Fold an `+freeq.at/av-state` broadcast into what we know about `channel`.
222-
223- The server is the authority on who is in a call, so this only writes what it
224- was told. Ending clears the room; anything else updates the tally in place,
225- because a `left` that omits the count should not reset it to nothing."
226- [channel st]
227- (let [{:keys [action session-id actor participants title]} st]
228- (if (= :ended action)
229- (swap! channel-calls dissoc channel)
230- (swap! channel-calls update channel
231- (fn [c]
232- (cond-> (or c {:participants 0})
233- true (assoc :session-id session-id)
234- participants (assoc :participants participants)
235- ;; A `started` with no count is one person: whoever started it.
236- (and (nil? participants)
237- (= :started action)
238- (zero? (:participants (or c {:participants 0}))))
239- (assoc :participants 1)
240- actor (assoc :last-actor actor)
241- title (assoc :title title)))))
242- ;; A call we are in that has ended is one we are no longer in, whoever
243- ;; ended it. Leaving the local call set would leave the controls up over
244- ;; a session the SFU has already forgotten.
245- (when (and (= :ended action) (in-call? channel))
246- (stop-media!))
247- ;; The server agreeing we are in the call is the other moment worth
248- ;; dialling on. A join we opened optimistically has no session id until
249- ;; this arrives — and a *re*join often brings no token with it, because the
250- ;; server already minted one for this session and does not mint a second.
251- (when (and (not= :ended action) (in-call? channel))
252- (when (and (seq session-id) (str/blank? (:session-id @local-call)))
253- (swap! local-call assoc :session-id session-id))
254- true)))
255-
256-;; --- the media plane, as this client uses it ---------------------------------
257-
258-(defn- drop-feeds!
259- "Stop painting every feed we have been pushing."
260- []
261- (doseq [k @painted-feeds] (platform/frame-drop! k))
262- (reset! painted-feeds #{})
263- (reset! feeds []))
264-
265-(defn stop-media!
266- "Leave the media plane and forget the call, telling nobody.
267-
268- For the two cases where the server already knows: it ended the call itself,
269- or the caller is about to send an `av-leave` of its own. Anything else wants
270- `dropped!`, or freeq goes on counting a participant who is not there."
271- []
272- (plane/stop!)
273- (drop-feeds!)
274- (reset! feeds [])
275- (reset! local-call nil)
276- (reset! media-error nil))
277-
278-(defn dropped!
279- "We are out of the call and did not ask to be — the media plane failed, or
280- the transport went away under it.
281-
282- Tells whoever registered `on-dropped` first, while the session id and
283- instance it needs are still here to be read, and only then forgets them."
284- []
285- (when-let [announce @on-dropped]
286- (when-let [call @local-call]
287- (try (announce call) (catch Exception _ nil))))
288- (stop-media!))
289-
290-(declare try-start-media!)
291-
292-(defn- start-media!
293- "Dial the SFU for the call we have already joined over IRC.
294-
295- Called once the server has minted a token for us, not when we asked to join:
296- a remote SFU refuses a connection without one and the MoQ client then retries
297- in a loop that looks exactly like a hang."
298- [server]
299- (when-let [{:keys [session-id instance token muted? speaker-muted? camera?
300- camera-id mic-id speaker-id nick]} @local-call]
301- (if-let [url (sfu-url server token instance)]
302- (do
303- (swap! local-call assoc :media :dialling)
304- (reset! media-error nil)
305- ;; Fire and forget: the connect is a QUIC handshake and waiting for
306- ;; it here would freeze the window for as long as it took — thirty
307- ;; seconds when the SFU is not there. `pump!` finishes it and
308- ;; `pump-status!` below reports what happened, which is exactly
309- ;; what this did when the waiting was joltmoq's to do.
310- (try
311- (plane/dial! {:url url
312- :path (str "/" instance)
313- :camera-device (when camera? camera-id)
314- :mic-device (when-not muted? mic-id)
315- :speaker-device (when-not speaker-muted? speaker-id)
316- :camera? camera?
317- :muted? muted?})
318- (catch Exception e
319- (swap! local-call assoc :media :failed)
320- (reset! media-error (or (ex-message e)
321- "could not start the media plane")))))
322- (do (swap! local-call assoc :media :failed)
323- (reset! media-error (str "no SFU for " server))))))
324-
325-(defn try-start-media!
326- "Dial the SFU if there is a call to dial for, and we are not already on it.
327-
328- Called from every signal that might mean the call is ready — the server
329- agreeing we joined, a token arriving, a rejoin — because none of them is
330- reliably the one that comes last. What makes that safe is the two guards:
331- `can-dial?` refuses a remote SFU with no token rather than retrying in a
332- loop that looks like a hang, and a call already up or on its way is left
333- alone rather than re-dialled."
334- [server]
335- (when-let [{:keys [session-id token media]} @local-call]
336- (when (and (available?)
337- (seq session-id)
338- (not (contains? #{:dialling :live} media))
339- (can-dial? server token))
340- (start-media! server))))
341-
342-(defn apply-token!
343- "The server minted us an SFU token — remember it, and dial.
344-
345- This arrives as a TAGMSG directed at our own nick rather than at the channel,
346- so the buffer it came in on says nothing about which call it is for; the
347- session id in the tag does. One naming a different session than ours is not
348- ours.
349-
350- Remembered past the end of the call, because the server does not always mint
351- a second one when you rejoin the same session."
352- [server session-id token]
353- (when-let [lc @local-call]
354- (when (or (str/blank? session-id)
355- (str/blank? (:session-id lc))
356- (= session-id (:session-id lc)))
357- (let [sid (if (seq session-id) session-id (:session-id lc))]
358- (when (seq sid)
359- (swap! session-tokens assoc sid token))
360- (swap! local-call
361- #(-> %
362- (assoc :token token :awaiting-start? false)
363- (cond-> (seq sid) (assoc :session-id sid)))))
364- (try-start-media! server))))
365-
366-(defn begin!
367- "Record that this device is joining `channel`, before the server has agreed.
368-
369- Optimistic on purpose: the controls appear on the press rather than a round
370- trip later. `:awaiting-start?` is what a start that has not been answered yet
371- looks like, and is how a collision is recognised as ours."
372- [{:keys [channel session-id nick muted? speaker-muted? camera?
373- camera-id mic-id speaker-id]}]
374- (let [instance (new-instance)]
375- (reset! media-error nil)
376- (reset! local-call
377- {:channel channel
378- :session-id (or session-id "")
379- :instance instance
380- :nick nick
381- ;; What we were given last time we were in this call, if
382- ;; anything. A rejoin the server answers with no new token
383- ;; still has something to dial with.
384- :token (get @session-tokens (or session-id ""))
385- :awaiting-start? (str/blank? (or session-id ""))
386- :muted? (boolean muted?)
387- :speaker-muted? (boolean speaker-muted?)
388- :camera? (boolean camera?)
389- :camera-id camera-id
390- :mic-id mic-id
391- :speaker-id speaker-id
392- :media :waiting})
393- instance))
394-
395-;; --- controls ----------------------------------------------------------------
396-;; Each writes the cell the UI reads *and* tells the media plane, so a control
397-;; answers on the press rather than a frame later. Muting the microphone and
398-;; muting the speaker are deliberately separate: deafening yourself still lets
399-;; peers hear you.
400-
401-(defn set-muted! [muted?]
402- (swap! local-call assoc :muted? muted?)
403- (plane/set-muted! muted?))
404-
405-(defn set-speaker-muted! [muted?]
406- (swap! local-call assoc :speaker-muted? muted?)
407- (plane/set-speaker-muted! muted?))
408-
409-(defn set-camera! [on?]
410- (swap! local-call assoc :camera? on?)
411- (plane/set-camera! on?)
412- ;; The tile goes when the camera does: the plane stops publishing, so no
413- ;; frame arrives to replace the last one.
414- (when-not on?
415- (platform/frame-drop! local-feed)
416- (swap! painted-feeds disj local-feed)))
417-
418-;; Switching a device mid-call reopens it, which the plane can only do by
419-;; going round again — there is no V4L2 ioctl for "become a different
420-;; camera". The id is recorded either way, so a call started afterwards
421-;; uses it even where a live switch is not offered yet.
422-
423-(defn set-camera-device! [id]
424- (swap! local-call assoc :camera-id id)
425- (plane/set-camera-device! id))
426-
427-(defn set-mic-device! [id]
428- (swap! local-call assoc :mic-id id)
429- (plane/set-mic-device! id))
430-
431-(defn set-speaker-device! [id]
432- (swap! local-call assoc :speaker-id id)
433- (plane/set-speaker-device! id))
434-
435-;; --- the pump ----------------------------------------------------------------
436-
437-(defn- pump-status!
438- "Drain what the media plane has learned since the last frame.
439-
440- A drained QUEUE rather than a code per call, which is what the plane
441- answers — but the loop is the same shape and for the same reason: a call
442- can fail and end between two pumps, and reading only the latest state
443- would show the wrong one."
444- []
445- (doseq [{:keys [code text has-camera? has-mic?]} (plane/poll-status!)]
446- (cond
447- (= code status-live)
448- (swap! local-call #(when % (assoc % :media :live
449- :has-camera? (boolean has-camera?)
450- :has-mic? (boolean has-mic?))))
451-
452- ;; Both of these are the call ending underneath us rather than at
453- ;; our request, so both have to be announced. A failure keeps the
454- ;; local call up afterwards so the reason stays on screen — but the
455- ;; server is told either way, because we are no longer in the call
456- ;; whether or not the person has read why yet.
457- (= code status-ended)
458- (dropped!)
459-
460- (= code status-failed)
461- (let [call @local-call]
462- (when-let [announce @on-dropped]
463- (when call (try (announce call) (catch Exception _ nil))))
464- (reset! media-error text)
465- (swap! local-call #(when % (assoc % :media :failed)))))))
466-
467-(defn- pump-frames!
468- "Hand every new frame straight to Vidya.
469-
470- The pointer is borrowed until the next poll, so it is used and dropped inside
471- this loop and never held. Nothing is copied on this side: the pixels go from
472- the decoder's own buffer to a texture without becoming a jolt value at all,
473- which is the only way a call at thirty frames a second is affordable here.
474-
475- Several frames at once now, where joltmoq answered one per poll. That is
476- safe because the plane keeps a decoder PER PEER: one shared between them
477- would make every pointer here alias the last picture decoded."
478- []
479- (doseq [{:keys [key w h rgba]} (plane/poll-frames!)]
480- (when (and (seq key) (pos? w) (pos? h) rgba)
481- (platform/frame-rgba! key w h rgba)
482- (swap! painted-feeds conj key))))
483-
484-(defn- order-feeds
485- "Everyone with a picture, the self-view last.
486-
487- Last because it is the one whose subject the person can already see, so it
488- belongs where it will not push a face they are talking to off the row."
489- [keys]
490- (let [mine (filter #(= local-feed %) keys)
491- others (sort (remove #(= local-feed %) keys))]
492- (vec (concat others mine))))
493-
494-(defn- pump-feeds!
495- "Reconcile who has a picture: drop the tiles of anyone who has stopped, and
496- publish the list for the view to render from.
497-
498- The cell is only written when the set has actually changed. Writing it every
499- frame would re-render the call wall sixty times a second to say the same
500- thing, and every `:image` node under it would be rebuilt around a texture
501- that was fine where it was."
502- []
503- (let [live (plane/feed-keys)]
504- (doseq [k (remove live @painted-feeds)]
505- (platform/frame-drop! k)
506- (swap! painted-feeds disj k))
507- (let [ordered (order-feeds live)]
508- (when-not (= ordered @feeds)
509- (reset! feeds ordered)))))
510-
511-(defn- pump-window!
512- "Follow the window's width, so a call wall can divide it.
513-
514- Outside the `live?` guard: the width is wanted the frame a call *starts*, and
515- a cell first written at that moment would lay the wall out against a zero.
516-
517- Written only when it changes, and only in whole points. A window being
518- dragged produces a fractional width every frame, and a cell that took each
519- one would re-render the wall for a third of a point of difference nobody can
520- see."
521- []
522- (let [[w h] (platform/screen-size)
523- w (long w)
524- h (long h)]
525- (when-not (= w @window-width) (reset! window-width w))
526- (when-not (= h @window-height) (reset! window-height h))))
527-
528-(defn pump!
529- "One frame's worth of the media plane. Cheap when no call is up.
530-
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."
533- []
534- (pump-window!)
535- (when (live?)
536- (pump-status!)
537- (pump-frames!)
538- (pump-feeds!)))
539-
540-(defn init-logging!
541- "Ask, once, whether calls can happen here.
542-
543- It used to do two things: turn on the Rust media plane's logging and, as
544- a side effect of the call succeeding, discover that the plane existed.
545- There is no Rust plane now and nothing to switch on — `frq.av.plane`
546- raises where it fails and `poll-status!` carries the reason, both of
547- which a jolt-level trace can already see.
548-
549- The name stays because `frq.app` calls it at startup and the answer it
550- wants is unchanged: is there a media plane here at all."
551- []
552- (available?))
553-
554-(defn install-pump!
555- "Start pumping the media plane every frame. Returns a timer id, or nil where
556- there is no media plane to pump.
557-
558- Sixteen milliseconds rather than a longer gap because this is where video
559- arrives: polling slower than the window paints would show every other frame."
560- []
561- (when (available?)
562- (platform/every! 16 pump!)))
563-
564-(defn tiles
565- "Everyone with a picture in the current call, the self-view last.
566-
567- Reads the cell the pump writes, so a component calling this re-renders when
568- someone turns their camera on."
569- []
570- @feeds)
571-
572-;; What the wall cannot use: the window's own edges, the card the tiles sit in,
573-;; and the gaps between them. Measured against the chat screen's margins rather
574-;; than guessed — 12 a side outside the card, 8 a side within it.
575-(def ^:private wall-chrome 44)
576-(def ^:private tile-gap 8)
577-
578-;; The size a tile wants to be. Columns are chosen to keep tiles near this
579-;; rather than to fit as many across as will physically go: four faces at a
580-;; hundred points each is worse than two rows of two at twice that, and a call
581-;; is people looking at each other, not a contact sheet.
582-(def ^:private want-tile 160)
583-
584-;; A tile narrower than this is not a face, it is a thumbnail of one.
585-(def ^:private min-tile 96)
586-
587-;; And an upper bound, so one person alone does not become a wall-sized
588-;; portrait. Generous rather than tight — what actually stops tiles growing is
589-;; the height budget below, and this is only here so there is an answer on a
590-;; screen tall enough that it never binds.
591-(def ^:private max-tile 720)
592-
593-;; What a tile costs in height beyond its picture: the name under it, and the
594-;; gap to the row below.
595-(def ^:private tile-label 22)
596-
597-;; The wall's share of the window height.
598-;;
599-;; A third was the first guess and it was wrong: on a 1920x1060 screen it held
600-;; one person to a 440-point tile with fourteen hundred points of empty width
601-;; beside them, which reads as video that will not scale — because in every way
602-;; the eye can tell, it does not. Half leaves the conversation legible under it
603-;; while letting a maximised window actually be worth maximising.
604-(def ^:private wall-share 0.5)
605-
606-(defn- rows-for [n cols] (max 1 (quot (+ n (dec cols)) cols)))
607-
608-(defn tile-width
609- "How wide a tile is with `n` across and `rows` down, in a window of `width`
610- by `height` points.
611-
612- Bounded by both axes, and the height is usually the one that binds. That is
613- deliberate: a fixed ceiling meant a window dragged from half the screen to
614- all of it changed the tiles by six points, because they were already against
615- it. Deriving the ceiling from the wall's share of the height means a taller
616- window really does make the faces bigger."
617- [width height n rows]
618- (let [n (max 1 n)
619- rows (max 1 rows)
620- across (max 0 (- (or width 0) wall-chrome (* tile-gap (dec n))))
621- by-width (quot across n)
622- ;; What the row height allows, once the name and the gap are paid for.
623- down (- (quot (long (* (or height 0) wall-share)) rows) tile-gap tile-label)
624- by-height (long (/ (max 0 down) 0.75))]
625- (-> (min by-width by-height)
626- (max min-tile)
627- (min max-tile))))
628-
629-(defn columns
630- "How many tiles to put across, for the biggest tiles the window allows.
631-
632- Every arrangement from one row to one column is tried and the roomiest wins,
633- because neither axis alone decides it: more columns buy height by spending
634- width, and which is worth more depends on the shape of the window. Ties go to
635- fewer columns, which is the arrangement with fewer rows."
636- [width height n]
637- (let [n (max 1 n)]
638- (reduce (fn [best cols]
639- (if (> (tile-width width height cols (rows-for n cols))
640- (tile-width width height best (rows-for n best)))
641- cols
642- best))
643- 1
644- (range 2 (inc n)))))
645-
646-(defn tile-rows
647- "The tiles as `[width [[key ...] ...]]` — one width, and the rows to draw.
648-
649- One width for every tile, not one per row: a last row holding a single person
650- would otherwise draw them at twice the size of everybody above, which reads
651- as though something had gone wrong rather than as a layout."
652- ([] (tile-rows @window-width @window-height (tiles)))
653- ([width height keys]
654- (let [n (count keys)
655- cols (columns width height n)]
656- [(tile-width width height cols (rows-for n cols))
657- (mapv vec (partition-all cols keys))])))
deleted file mode 100644
@@ -1,657 +0,0 @@
1-(ns frq.av
2- "Calls: the signaling, in jolt, and a handle on the media plane, which is not.
3-
4- A freeq call has two halves. The one that is written here is *signaling* —
5- `+freeq.at/av-start`, `av-join` and `av-leave` go out as TAGMSGs and the
6- server broadcasts `+freeq.at/av-state` back, which is IRC and nothing more,
7- so it is written in the language the rest of the client is written in.
8-
9- The other half is audio and video over MoQ, and it used to be `libjoltmoq`
10- — three thousand lines of Rust behind a C ABI. It is `frq.av.plane` now:
11- MoQ over QUIC from `libmoq_ffi`, Opus from libopus, H.264 from openh264,
12- and the camera and sound devices from V4L2 and ALSA, all bound directly.
13- This namespace is the whole of what frq says to it, and what it says has
14- barely changed — the plane was built to joltmoq's shape on purpose.
15-
16- Two rules come from that side and shape everything here:
17-
18- * **Nothing calls back.** Status and video are *polled* — `pump!` drains both
19- and is called from a timer, which glimmer runs on the loop thread.
20- * **A video frame is borrowed**, valid only until the next poll. `pump!`
21- hands each one to Vidya as it arrives and never keeps one, which is also
22- why a frame never becomes a jolt value: it goes from the decoder's buffer
23- to the texture as a pointer, and is never copied on this side at all.
24-
25- Both still hold, and for the same reasons — the plane is pumped because a
26- blocking foreign call would pin glimmer's loop thread, and it hands out
27- borrowed pointers because a frame at thirty a second cannot afford a copy."
28- (:require [clojure.string :as str]
29- [glimmer.ratom :as r :refer [atom]]
30- [frq.platform :as platform]
31- [frq.irc :as irc]
32- [frq.av.dial :as dial]
33- [frq.av.plane :as plane]
34- [frq.capture.alsa :as alsa]
35- [frq.capture.v4l2 :as v4l2]))
36-
37-;; --- the media plane ---------------------------------------------------------
38-;; `frq.av.plane`, where this used to be twenty-five `joltmoq_*` symbols.
39-;; The shape is deliberately the same one — start, stop, poll status, poll
40-;; frames — because that is what let the plane be swapped in underneath this
41-;; file rather than through it.
42-
43-(def ^:private status-live :live)
44-(def ^:private status-ended :ended)
45-(def ^:private status-failed :failed)
46-
47-(defonce ^:private media-plane
48- ;; Whether the media plane can run here at all, asked once. It cannot on
49- ;; the phone: the plane is V4L2 and ALSA, and Android has neither — the
50- ;; camera is Camera2 through JNI and the audio is AAudio. A client that
51- ;; cannot make calls is still a client, so the answer gates the call
52- ;; surface rather than ending the run.
53- ;;
54- ;; Asked by looking for a sound device rather than by loading anything:
55- ;; every library the plane needs is declared in deps.edn and already
56- ;; resolved by the time this runs, so the question is not "is the code
57- ;; here" but "is there anything for it to talk to".
58- (delay
59- (try (boolean (seq (alsa/devices :playback)))
60- (catch Exception _ false))))
61-
62-(defn available?
63- "Whether calls can happen here at all."
64- []
65- @media-plane)
66-
67-(defn- pref [s] (or s ""))
68-
69-(defn live? [] (plane/live?))
70-
71-(defn can-dial?
72- "Whether dialling this server is worth attempting. A remote SFU with no token
73- accepts the connection and closes it, and the MoQ client then retries in a
74- tight loop that looks exactly like a hang."
75- [server jwt]
76- (dial/can-dial? server jwt))
77-
78-(defn sfu-url
79- "The SFU to dial for this server, or nil when the server is not one a URL can
80- be made of."
81- [server jwt instance]
82- (dial/sfu-url server jwt instance))
83-
84-(defn new-instance
85- "A per-device call instance id. Two devices signed in as the same person need
86- different ones, or their broadcast paths collide."
87- []
88- (dial/new-instance))
89-
90-;; --- devices -----------------------------------------------------------------
91-;; Where joltmoq answered a tab-separated string that this file parsed, the
92-;; bindings answer the maps directly. The shape the UI reads is unchanged:
93-;; {:id :name :default?}.
94-
95-(defn cameras [] (try (v4l2/devices) (catch Exception _ [])))
96-(defn microphones [] (try (alsa/devices :capture) (catch Exception _ [])))
97-(defn speakers [] (try (alsa/devices :playback) (catch Exception _ [])))
98-
99-;; --- the signaling tags ------------------------------------------------------
100-;; Every one of these is a TAGMSG to the channel. The server answers with an
101-;; `+freeq.at/av-state` broadcast, which is what actually moves this client's
102-;; state — nothing below assumes a request succeeded.
103-
104-(defn start-tags
105- "Open a call on this channel."
106- [instance title]
107- (cond-> {"+freeq.at/av-start" ""
108- "+freeq.at/av-instance" instance}
109- (seq title) (assoc "+freeq.at/av-title" title)))
110-
111-(defn join-tags
112- "Join the call already open on this channel."
113- [session-id instance]
114- {"+freeq.at/av-join" ""
115- "+freeq.at/av-id" session-id
116- "+freeq.at/av-instance" instance})
117-
118-(defn leave-tags
119- [session-id instance]
120- {"+freeq.at/av-leave" ""
121- "+freeq.at/av-id" session-id
122- "+freeq.at/av-instance" instance})
123-
124-(defn parse-state
125- "An `+freeq.at/av-state` broadcast, or nil for any other TAGMSG.
126-
127- Safe to apply to every TAGMSG that arrives: a reaction answers nil."
128- [tags]
129- (let [action (irc/tag-value tags "+freeq.at/av-state")]
130- (when (contains? #{"started" "joined" "left" "ended"} action)
131- {:action (keyword action)
132- :session-id (or (irc/tag-value tags "+freeq.at/av-id") "")
133- :actor (irc/tag-value tags "+freeq.at/av-actor")
134- :participants (when-let [p (irc/tag-value tags "+freeq.at/av-participants")]
135- (try (Integer/parseInt p) (catch Exception _ nil)))
136- :title (let [t (irc/tag-value tags "+freeq.at/av-title")]
137- (when (seq t) t))})))
138-
139-(defn state-message
140- "The system line a state change is worth showing as."
141- [{:keys [action actor participants title]}]
142- (let [who (or actor "someone")
143- n (if participants (str " · " participants " in call") "")]
144- (case action
145- :started (str "Call started by " who
146- (if (seq title) (str " “" title "”") "")
147- n)
148- :joined (str who " joined the call" n)
149- :left (str who " left the call" n)
150- :ended (str "Call ended" n)
151- "")))
152-
153-;; --- what the UI reads -------------------------------------------------------
154-
155-;; channel -> {:session-id :title :participants :last-actor}. What the server
156-;; says is happening in a room, whether or not we are in it: this is what puts
157-;; a "join the call" banner above a channel nobody here has joined.
158-(defonce channel-calls (atom {}))
159-
160-;; nil, or the call this device is in. `:media` is how far the other half has
161-;; got: :dialling until the plane says otherwise, then :live or :failed.
162-(defonce local-call (atom nil))
163-
164-;; The last thing the media plane failed with, for the line under the controls.
165-(defonce media-error (atom nil))
166-
167-;; What to do when this device stops being in a call without having asked to.
168-;;
169-;; The media plane failing is not the server hearing about it: freeq counts a
170-;; participant until an `av-leave` says otherwise. Dropping out quietly leaves
171-;; a ghost in the room, and pressing Join again adds a second one — which is
172-;; how a channel ends up reporting seven people in a call with two.
173-;;
174-;; `frq.state` puts the TAGMSG here. This namespace cannot send one itself
175-;; without depending on the client that owns the connection.
176-(defonce on-dropped (atom nil))
177-
178-;; SFU tokens, by session id.
179-;;
180-;; The server mints one when you join, and does not necessarily mint another
181-;; when you join the same call again — so a client that forgets it on the way
182-;; out has nothing to dial with on the way back in, and rejoining signals
183-;; correctly and then sits there with no video. Kept past the call for exactly
184-;; that, and overwritten whenever a fresh one arrives.
185-(defonce ^:private session-tokens (atom {}))
186-
187-;; Feeds pushed to Vidya, so the ones that stop can be dropped again. Without
188-;; this the last frame of someone who left hangs on the wall for the rest of
189-;; the call.
190-(defonce ^:private painted-feeds (atom #{}))
191-
192-;; Who has a picture, in the order they should be drawn.
193-;;
194-;; A cell rather than a question asked at render time, and that is the whole
195-;; point: glimmer re-renders a component when a ratom it read has changed, so a
196-;; view that asked the media plane directly would render once with nobody on
197-;; screen and never be told that someone had appeared. The frames would arrive,
198-;; be handed to Vidya, and paint into no node at all.
199-(defonce feeds (atom []))
200-
201-;; The window's width in points, so a tile can be a share of it rather than a
202-;; number somebody picked. A cell for the same reason `feeds` is one: a
203-;; component that asked the backend at render time would lay itself out once,
204-;; on the first frame, and keep that shape however the window was dragged.
205-(defonce window-width (atom 0))
206-(defonce window-height (atom 0))
207-
208-;; The self-view is keyed this way by the media plane; the UI wants to know
209-;; which tile is its own, to label it and to mirror nothing else.
210-(def local-feed "__local__")
211-
212-(declare stop-media!)
213-
214-(defn call-in [channel] (get @channel-calls channel))
215-
216-(defn in-call?
217- ([] (some? @local-call))
218- ([channel] (= channel (:channel @local-call))))
219-
220-(defn apply-state!
221- "Fold an `+freeq.at/av-state` broadcast into what we know about `channel`.
222-
223- The server is the authority on who is in a call, so this only writes what it
224- was told. Ending clears the room; anything else updates the tally in place,
225- because a `left` that omits the count should not reset it to nothing."
226- [channel st]
227- (let [{:keys [action session-id actor participants title]} st]
228- (if (= :ended action)
229- (swap! channel-calls dissoc channel)
230- (swap! channel-calls update channel
231- (fn [c]
232- (cond-> (or c {:participants 0})
233- true (assoc :session-id session-id)
234- participants (assoc :participants participants)
235- ;; A `started` with no count is one person: whoever started it.
236- (and (nil? participants)
237- (= :started action)
238- (zero? (:participants (or c {:participants 0}))))
239- (assoc :participants 1)
240- actor (assoc :last-actor actor)
241- title (assoc :title title)))))
242- ;; A call we are in that has ended is one we are no longer in, whoever
243- ;; ended it. Leaving the local call set would leave the controls up over
244- ;; a session the SFU has already forgotten.
245- (when (and (= :ended action) (in-call? channel))
246- (stop-media!))
247- ;; The server agreeing we are in the call is the other moment worth
248- ;; dialling on. A join we opened optimistically has no session id until
249- ;; this arrives — and a *re*join often brings no token with it, because the
250- ;; server already minted one for this session and does not mint a second.
251- (when (and (not= :ended action) (in-call? channel))
252- (when (and (seq session-id) (str/blank? (:session-id @local-call)))
253- (swap! local-call assoc :session-id session-id))
254- true)))
255-
256-;; --- the media plane, as this client uses it ---------------------------------
257-
258-(defn- drop-feeds!
259- "Stop painting every feed we have been pushing."
260- []
261- (doseq [k @painted-feeds] (platform/frame-drop! k))
262- (reset! painted-feeds #{})
263- (reset! feeds []))
264-
265-(defn stop-media!
266- "Leave the media plane and forget the call, telling nobody.
267-
268- For the two cases where the server already knows: it ended the call itself,
269- or the caller is about to send an `av-leave` of its own. Anything else wants
270- `dropped!`, or freeq goes on counting a participant who is not there."
271- []
272- (plane/stop!)
273- (drop-feeds!)
274- (reset! feeds [])
275- (reset! local-call nil)
276- (reset! media-error nil))
277-
278-(defn dropped!
279- "We are out of the call and did not ask to be — the media plane failed, or
280- the transport went away under it.
281-
282- Tells whoever registered `on-dropped` first, while the session id and
283- instance it needs are still here to be read, and only then forgets them."
284- []
285- (when-let [announce @on-dropped]
286- (when-let [call @local-call]
287- (try (announce call) (catch Exception _ nil))))
288- (stop-media!))
289-
290-(declare try-start-media!)
291-
292-(defn- start-media!
293- "Dial the SFU for the call we have already joined over IRC.
294-
295- Called once the server has minted a token for us, not when we asked to join:
296- a remote SFU refuses a connection without one and the MoQ client then retries
297- in a loop that looks exactly like a hang."
298- [server]
299- (when-let [{:keys [session-id instance token muted? speaker-muted? camera?
300- camera-id mic-id speaker-id nick]} @local-call]
301- (if-let [url (sfu-url server token instance)]
302- (do
303- (swap! local-call assoc :media :dialling)
304- (reset! media-error nil)
305- ;; Fire and forget: the connect is a QUIC handshake and waiting for
306- ;; it here would freeze the window for as long as it took — thirty
307- ;; seconds when the SFU is not there. `pump!` finishes it and
308- ;; `pump-status!` below reports what happened, which is exactly
309- ;; what this did when the waiting was joltmoq's to do.
310- (try
311- (plane/dial! {:url url
312- :path (str "/" instance)
313- :camera-device (when camera? camera-id)
314- :mic-device (when-not muted? mic-id)
315- :speaker-device (when-not speaker-muted? speaker-id)
316- :camera? camera?
317- :muted? muted?})
318- (catch Exception e
319- (swap! local-call assoc :media :failed)
320- (reset! media-error (or (ex-message e)
321- "could not start the media plane")))))
322- (do (swap! local-call assoc :media :failed)
323- (reset! media-error (str "no SFU for " server))))))
324-
325-(defn try-start-media!
326- "Dial the SFU if there is a call to dial for, and we are not already on it.
327-
328- Called from every signal that might mean the call is ready — the server
329- agreeing we joined, a token arriving, a rejoin — because none of them is
330- reliably the one that comes last. What makes that safe is the two guards:
331- `can-dial?` refuses a remote SFU with no token rather than retrying in a
332- loop that looks like a hang, and a call already up or on its way is left
333- alone rather than re-dialled."
334- [server]
335- (when-let [{:keys [session-id token media]} @local-call]
336- (when (and (available?)
337- (seq session-id)
338- (not (contains? #{:dialling :live} media))
339- (can-dial? server token))
340- (start-media! server))))
341-
342-(defn apply-token!
343- "The server minted us an SFU token — remember it, and dial.
344-
345- This arrives as a TAGMSG directed at our own nick rather than at the channel,
346- so the buffer it came in on says nothing about which call it is for; the
347- session id in the tag does. One naming a different session than ours is not
348- ours.
349-
350- Remembered past the end of the call, because the server does not always mint
351- a second one when you rejoin the same session."
352- [server session-id token]
353- (when-let [lc @local-call]
354- (when (or (str/blank? session-id)
355- (str/blank? (:session-id lc))
356- (= session-id (:session-id lc)))
357- (let [sid (if (seq session-id) session-id (:session-id lc))]
358- (when (seq sid)
359- (swap! session-tokens assoc sid token))
360- (swap! local-call
361- #(-> %
362- (assoc :token token :awaiting-start? false)
363- (cond-> (seq sid) (assoc :session-id sid)))))
364- (try-start-media! server))))
365-
366-(defn begin!
367- "Record that this device is joining `channel`, before the server has agreed.
368-
369- Optimistic on purpose: the controls appear on the press rather than a round
370- trip later. `:awaiting-start?` is what a start that has not been answered yet
371- looks like, and is how a collision is recognised as ours."
372- [{:keys [channel session-id nick muted? speaker-muted? camera?
373- camera-id mic-id speaker-id]}]
374- (let [instance (new-instance)]
375- (reset! media-error nil)
376- (reset! local-call
377- {:channel channel
378- :session-id (or session-id "")
379- :instance instance
380- :nick nick
381- ;; What we were given last time we were in this call, if
382- ;; anything. A rejoin the server answers with no new token
383- ;; still has something to dial with.
384- :token (get @session-tokens (or session-id ""))
385- :awaiting-start? (str/blank? (or session-id ""))
386- :muted? (boolean muted?)
387- :speaker-muted? (boolean speaker-muted?)
388- :camera? (boolean camera?)
389- :camera-id camera-id
390- :mic-id mic-id
391- :speaker-id speaker-id
392- :media :waiting})
393- instance))
394-
395-;; --- controls ----------------------------------------------------------------
396-;; Each writes the cell the UI reads *and* tells the media plane, so a control
397-;; answers on the press rather than a frame later. Muting the microphone and
398-;; muting the speaker are deliberately separate: deafening yourself still lets
399-;; peers hear you.
400-
401-(defn set-muted! [muted?]
402- (swap! local-call assoc :muted? muted?)
403- (plane/set-muted! muted?))
404-
405-(defn set-speaker-muted! [muted?]
406- (swap! local-call assoc :speaker-muted? muted?)
407- (plane/set-speaker-muted! muted?))
408-
409-(defn set-camera! [on?]
410- (swap! local-call assoc :camera? on?)
411- (plane/set-camera! on?)
412- ;; The tile goes when the camera does: the plane stops publishing, so no
413- ;; frame arrives to replace the last one.
414- (when-not on?
415- (platform/frame-drop! local-feed)
416- (swap! painted-feeds disj local-feed)))
417-
418-;; Switching a device mid-call reopens it, which the plane can only do by
419-;; going round again — there is no V4L2 ioctl for "become a different
420-;; camera". The id is recorded either way, so a call started afterwards
421-;; uses it even where a live switch is not offered yet.
422-
423-(defn set-camera-device! [id]
424- (swap! local-call assoc :camera-id id)
425- (plane/set-camera-device! id))
426-
427-(defn set-mic-device! [id]
428- (swap! local-call assoc :mic-id id)
429- (plane/set-mic-device! id))
430-
431-(defn set-speaker-device! [id]
432- (swap! local-call assoc :speaker-id id)
433- (plane/set-speaker-device! id))
434-
435-;; --- the pump ----------------------------------------------------------------
436-
437-(defn- pump-status!
438- "Drain what the media plane has learned since the last frame.
439-
440- A drained QUEUE rather than a code per call, which is what the plane
441- answers — but the loop is the same shape and for the same reason: a call
442- can fail and end between two pumps, and reading only the latest state
443- would show the wrong one."
444- []
445- (doseq [{:keys [code text has-camera? has-mic?]} (plane/poll-status!)]
446- (cond
447- (= code status-live)
448- (swap! local-call #(when % (assoc % :media :live
449- :has-camera? (boolean has-camera?)
450- :has-mic? (boolean has-mic?))))
451-
452- ;; Both of these are the call ending underneath us rather than at
453- ;; our request, so both have to be announced. A failure keeps the
454- ;; local call up afterwards so the reason stays on screen — but the
455- ;; server is told either way, because we are no longer in the call
456- ;; whether or not the person has read why yet.
457- (= code status-ended)
458- (dropped!)
459-
460- (= code status-failed)
461- (let [call @local-call]
462- (when-let [announce @on-dropped]
463- (when call (try (announce call) (catch Exception _ nil))))
464- (reset! media-error text)
465- (swap! local-call #(when % (assoc % :media :failed)))))))
466-
467-(defn- pump-frames!
468- "Hand every new frame straight to Vidya.
469-
470- The pointer is borrowed until the next poll, so it is used and dropped inside
471- this loop and never held. Nothing is copied on this side: the pixels go from
472- the decoder's own buffer to a texture without becoming a jolt value at all,
473- which is the only way a call at thirty frames a second is affordable here.
474-
475- Several frames at once now, where joltmoq answered one per poll. That is
476- safe because the plane keeps a decoder PER PEER: one shared between them
477- would make every pointer here alias the last picture decoded."
478- []
479- (doseq [{:keys [key w h rgba]} (plane/poll-frames!)]
480- (when (and (seq key) (pos? w) (pos? h) rgba)
481- (platform/frame-rgba! key w h rgba)
482- (swap! painted-feeds conj key))))
483-
484-(defn- order-feeds
485- "Everyone with a picture, the self-view last.
486-
487- Last because it is the one whose subject the person can already see, so it
488- belongs where it will not push a face they are talking to off the row."
489- [keys]
490- (let [mine (filter #(= local-feed %) keys)
491- others (sort (remove #(= local-feed %) keys))]
492- (vec (concat others mine))))
493-
494-(defn- pump-feeds!
495- "Reconcile who has a picture: drop the tiles of anyone who has stopped, and
496- publish the list for the view to render from.
497-
498- The cell is only written when the set has actually changed. Writing it every
499- frame would re-render the call wall sixty times a second to say the same
500- thing, and every `:image` node under it would be rebuilt around a texture
501- that was fine where it was."
502- []
503- (let [live (plane/feed-keys)]
504- (doseq [k (remove live @painted-feeds)]
505- (platform/frame-drop! k)
506- (swap! painted-feeds disj k))
507- (let [ordered (order-feeds live)]
508- (when-not (= ordered @feeds)
509- (reset! feeds ordered)))))
510-
511-(defn- pump-window!
512- "Follow the window's width, so a call wall can divide it.
513-
514- Outside the `live?` guard: the width is wanted the frame a call *starts*, and
515- a cell first written at that moment would lay the wall out against a zero.
516-
517- Written only when it changes, and only in whole points. A window being
518- dragged produces a fractional width every frame, and a cell that took each
519- one would re-render the wall for a third of a point of difference nobody can
520- see."
521- []
522- (let [[w h] (platform/screen-size)
523- w (long w)
524- h (long h)]
525- (when-not (= w @window-width) (reset! window-width w))
526- (when-not (= h @window-height) (reset! window-height h))))
527-
528-(defn pump!
529- "One frame's worth of the media plane. Cheap when no call is up.
530-
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."
533- []
534- (pump-window!)
535- (when (live?)
536- (pump-status!)
537- (pump-frames!)
538- (pump-feeds!)))
539-
540-(defn init-logging!
541- "Ask, once, whether calls can happen here.
542-
543- It used to do two things: turn on the Rust media plane's logging and, as
544- a side effect of the call succeeding, discover that the plane existed.
545- There is no Rust plane now and nothing to switch on — `frq.av.plane`
546- raises where it fails and `poll-status!` carries the reason, both of
547- which a jolt-level trace can already see.
548-
549- The name stays because `frq.app` calls it at startup and the answer it
550- wants is unchanged: is there a media plane here at all."
551- []
552- (available?))
553-
554-(defn install-pump!
555- "Start pumping the media plane every frame. Returns a timer id, or nil where
556- there is no media plane to pump.
557-
558- Sixteen milliseconds rather than a longer gap because this is where video
559- arrives: polling slower than the window paints would show every other frame."
560- []
561- (when (available?)
562- (platform/every! 16 pump!)))
563-
564-(defn tiles
565- "Everyone with a picture in the current call, the self-view last.
566-
567- Reads the cell the pump writes, so a component calling this re-renders when
568- someone turns their camera on."
569- []
570- @feeds)
571-
572-;; What the wall cannot use: the window's own edges, the card the tiles sit in,
573-;; and the gaps between them. Measured against the chat screen's margins rather
574-;; than guessed — 12 a side outside the card, 8 a side within it.
575-(def ^:private wall-chrome 44)
576-(def ^:private tile-gap 8)
577-
578-;; The size a tile wants to be. Columns are chosen to keep tiles near this
579-;; rather than to fit as many across as will physically go: four faces at a
580-;; hundred points each is worse than two rows of two at twice that, and a call
581-;; is people looking at each other, not a contact sheet.
582-(def ^:private want-tile 160)
583-
584-;; A tile narrower than this is not a face, it is a thumbnail of one.
585-(def ^:private min-tile 96)
586-
587-;; And an upper bound, so one person alone does not become a wall-sized
588-;; portrait. Generous rather than tight — what actually stops tiles growing is
589-;; the height budget below, and this is only here so there is an answer on a
590-;; screen tall enough that it never binds.
591-(def ^:private max-tile 720)
592-
593-;; What a tile costs in height beyond its picture: the name under it, and the
594-;; gap to the row below.
595-(def ^:private tile-label 22)
596-
597-;; The wall's share of the window height.
598-;;
599-;; A third was the first guess and it was wrong: on a 1920x1060 screen it held
600-;; one person to a 440-point tile with fourteen hundred points of empty width
601-;; beside them, which reads as video that will not scale — because in every way
602-;; the eye can tell, it does not. Half leaves the conversation legible under it
603-;; while letting a maximised window actually be worth maximising.
604-(def ^:private wall-share 0.5)
605-
606-(defn- rows-for [n cols] (max 1 (quot (+ n (dec cols)) cols)))
607-
608-(defn tile-width
609- "How wide a tile is with `n` across and `rows` down, in a window of `width`
610- by `height` points.
611-
612- Bounded by both axes, and the height is usually the one that binds. That is
613- deliberate: a fixed ceiling meant a window dragged from half the screen to
614- all of it changed the tiles by six points, because they were already against
615- it. Deriving the ceiling from the wall's share of the height means a taller
616- window really does make the faces bigger."
617- [width height n rows]
618- (let [n (max 1 n)
619- rows (max 1 rows)
620- across (max 0 (- (or width 0) wall-chrome (* tile-gap (dec n))))
621- by-width (quot across n)
622- ;; What the row height allows, once the name and the gap are paid for.
623- down (- (quot (long (* (or height 0) wall-share)) rows) tile-gap tile-label)
624- by-height (long (/ (max 0 down) 0.75))]
625- (-> (min by-width by-height)
626- (max min-tile)
627- (min max-tile))))
628-
629-(defn columns
630- "How many tiles to put across, for the biggest tiles the window allows.
631-
632- Every arrangement from one row to one column is tried and the roomiest wins,
633- because neither axis alone decides it: more columns buy height by spending
634- width, and which is worth more depends on the shape of the window. Ties go to
635- fewer columns, which is the arrangement with fewer rows."
636- [width height n]
637- (let [n (max 1 n)]
638- (reduce (fn [best cols]
639- (if (> (tile-width width height cols (rows-for n cols))
640- (tile-width width height best (rows-for n best)))
641- cols
642- best))
643- 1
644- (range 2 (inc n)))))
645-
646-(defn tile-rows
647- "The tiles as `[width [[key ...] ...]]` — one width, and the rows to draw.
648-
649- One width for every tile, not one per row: a last row holding a single person
650- would otherwise draw them at twice the size of everybody above, which reads
651- as though something had gone wrong rather than as a layout."
652- ([] (tile-rows @window-width @window-height (tiles)))
653- ([width height keys]
654- (let [n (count keys)
655- cols (columns width height n)]
656- [(tile-width width height cols (rows-for n cols))
657- (mapv vec (partition-all cols keys))])))
deleted src/frq/av/audio.clj +0 -158
deleted file mode 100644
@@ -1,158 +0,0 @@
1-(ns frq.av.audio
2- "Opus over MoQ, and the jitter buffer that makes it listenable.
3-
4- Video can afford to be simple here: a frame arrives, it is decoded, it is
5- painted, and if one is late the picture holds. Audio cannot. A gap in
6- playback is audible as a click, arriving early is as bad as arriving late,
7- and several peers have to be summed into ONE stream whose clock belongs to
8- the sound card rather than to any of them. That is what this namespace is
9- for, and it is why it is separate from `frq.av.plane` — mixing policy is a
10- thing to be able to read on its own.
11-
12- THE MODEL. Fixed 20ms frames at 48kHz — 960 samples per channel, which is
13- Opus's usual frame and what `frame-samples` is. Every peer decodes into a
14- small ring; the mixer takes one frame from each peer's ring per tick and
15- sums them. A peer whose ring is empty contributes Opus's own concealment
16- rather than silence, because a dropped packet concealed sounds like a
17- smudge where silence sounds like a click.
18-
19- THE THREE THINGS A JITTER BUFFER DECIDES, spelled out because the defaults
20- are the whole design:
21-
22- * DEPTH. `target-depth` frames are accumulated before a peer is played
23- at all. Too shallow and every network hiccup is a gap; too deep and
24- the call gains latency nobody asked for. Two frames — 40ms — is the
25- usual starting point for a conversation.
26- * OVERFLOW. Past `max-depth` the OLDEST frame is dropped, not the
27- newest. A listener wants the most recent audio; keeping the stale end
28- of a backlog just delays everything behind it permanently.
29- * UNDERFLOW. An empty ring conceals rather than stalls. Waiting for the
30- late frame would stall every OTHER peer too, since they share the
31- output clock.
32-
33- This is a deliberately plain buffer: fixed depth, no adaptation to
34- measured jitter, no clock-drift resampling. Those are real and they are
35- missing, and the note at `mix-into!` says what goes wrong without them."
36- (:require [frq.codec.opus :as opus]
37- [jolt.ffi :as ffi]))
38-
39-(def ^:const sample-rate 48000)
40-(def ^:const frame-samples 960) ; 20ms at 48kHz, per channel
41-(def ^:const target-depth 2)
42-(def ^:const max-depth 6)
43-
44-;; --- OpusHead ----------------------------------------------------------------
45-
46-(defn opus-head!
47- "The 19-byte OpusHead an Opus track's catalog entry needs, into `p`.
48-
49- Little-endian, unlike everything else in this port — OpusHead is Ogg's
50- header format and predates any of it. An audio track will not publish
51- without one: video resolves its parameters in band and audio does not."
52- [p channels]
53- (let [magic [0x4f 0x70 0x75 0x73 0x48 0x65 0x61 0x64] ; "OpusHead"
54- pre-skip 3840]
55- (dotimes [i 8] (ffi/write (+ p i) :uint8 (nth magic i)))
56- (ffi/write (+ p 8) :uint8 1) ; version
57- (ffi/write (+ p 9) :uint8 channels)
58- (ffi/write (+ p 10) :uint8 (bit-and pre-skip 255))
59- (ffi/write (+ p 11) :uint8 (bit-and (bit-shift-right pre-skip 8) 255))
60- (dotimes [i 4]
61- (ffi/write (+ p 12 i) :uint8
62- (bit-and (bit-shift-right sample-rate (* 8 i)) 255)))
63- (ffi/write (+ p 16) :uint8 0) ; output gain lo
64- (ffi/write (+ p 17) :uint8 0) ; output gain hi
65- (ffi/write (+ p 18) :uint8 0) ; mapping family
66- [p 19]))
67-
68-;; --- a peer's ring -----------------------------------------------------------
69-
70-(defn ring
71- "A peer's decoded-audio ring: `max-depth` frames of foreign memory.
72-
73- Allocated once and reused. Decoding into fresh memory every 20ms would
74- make the allocator part of the audio path, which is the one place it has
75- no business being."
76- [channels]
77- {:decoder (opus/decoder sample-rate channels)
78- :channels channels
79- :slots (mapv (fn [_] (ffi/alloc (* 2 frame-samples channels)))
80- (range max-depth))
81- :filled (atom []) ; indices holding audio, oldest first
82- :free (atom (vec (range max-depth)))
83- :started? (atom false)})
84-
85-(defn close-ring! [r]
86- (opus/free-decoder! (:decoder r))
87- (doseq [p (:slots r)] (ffi/free p))
88- nil)
89-
90-(defn push-packet!
91- "Decode one Opus packet into the ring.
92-
93- Over `max-depth` the OLDEST frame goes, not this one: a listener wants the
94- most recent audio, and keeping the stale end of a backlog delays
95- everything behind it for the rest of the call."
96- [r ptr len]
97- (let [{:keys [decoder slots filled free channels]} r
98- i (if-let [i (first @free)]
99- (do (swap! free subvec 1) i)
100- (let [oldest (first @filled)]
101- (swap! filled subvec 1)
102- oldest))
103- n (opus/decode! decoder ptr len (nth slots i) frame-samples)]
104- (swap! filled conj i)
105- (when (>= (count @filled) target-depth) (reset! (:started? r) true))
106- n))
107-
108-(defn- take-frame!
109- "The oldest frame in the ring, or nil while it is still filling."
110- [r]
111- (when @(:started? r)
112- (when-let [i (first @(:filled r))]
113- (swap! (:filled r) subvec 1)
114- (swap! (:free r) conj i)
115- (nth (:slots r) i))))
116-
117-;; --- mixing ------------------------------------------------------------------
118-
119-(defn mix-into!
120- "Sum one frame from every ring into `out`; answers the peak written.
121-
122- Summed and CLAMPED, not averaged. Averaging would make every voice quieter
123- as more people joined, which is the wrong behaviour in a meeting; clamping
124- only bites when several people are loud at once, which is already
125- unpleasant for other reasons.
126-
127- A ring with nothing in it conceals — `opus/decode!` with no packet is
128- Opus's own loss concealment — rather than contributing silence, because a
129- gap is a click and a concealed frame is a smudge.
130-
131- WHAT IS NOT HERE, and it will be audible eventually: no resampling for
132- clock drift. The sound card's clock and the sender's are not the same, and
133- over minutes one drifts against the other — the ring slowly fills or
134- slowly empties, and the fix is to resample by a fraction of a percent
135- rather than to keep dropping or concealing. That wants measurement this
136- buffer does not yet take."
137- [rings out channels]
138- (let [n (* frame-samples channels)]
139- (dotimes [i n] (ffi/write (+ out (* 2 i)) :int16 0))
140- (doseq [r rings]
141- (let [src (or (take-frame! r)
142- ;; Conceal: decode nothing, which Opus turns into a
143- ;; plausible continuation of what it last heard.
144- (let [slot (nth (:slots r) 0)]
145- (when @(:started? r)
146- (opus/decode! (:decoder r) nil 0 slot frame-samples)
147- slot)))]
148- (when src
149- (dotimes [i n]
150- (let [a (ffi/read (+ out (* 2 i)) :int16)
151- b (ffi/read (+ src (* 2 i)) :int16)
152- v (+ a b)]
153- (ffi/write (+ out (* 2 i)) :int16
154- (cond (> v 32767) 32767 (< v -32768) -32768 :else v)))))))
155- (loop [i 0 peak 0]
156- (if (= i n)
157- peak
158- (recur (inc i) (max peak (abs (ffi/read (+ out (* 2 i)) :int16))))))))
deleted file mode 100644
@@ -1,158 +0,0 @@
1-(ns frq.av.audio
2- "Opus over MoQ, and the jitter buffer that makes it listenable.
3-
4- Video can afford to be simple here: a frame arrives, it is decoded, it is
5- painted, and if one is late the picture holds. Audio cannot. A gap in
6- playback is audible as a click, arriving early is as bad as arriving late,
7- and several peers have to be summed into ONE stream whose clock belongs to
8- the sound card rather than to any of them. That is what this namespace is
9- for, and it is why it is separate from `frq.av.plane` — mixing policy is a
10- thing to be able to read on its own.
11-
12- THE MODEL. Fixed 20ms frames at 48kHz — 960 samples per channel, which is
13- Opus's usual frame and what `frame-samples` is. Every peer decodes into a
14- small ring; the mixer takes one frame from each peer's ring per tick and
15- sums them. A peer whose ring is empty contributes Opus's own concealment
16- rather than silence, because a dropped packet concealed sounds like a
17- smudge where silence sounds like a click.
18-
19- THE THREE THINGS A JITTER BUFFER DECIDES, spelled out because the defaults
20- are the whole design:
21-
22- * DEPTH. `target-depth` frames are accumulated before a peer is played
23- at all. Too shallow and every network hiccup is a gap; too deep and
24- the call gains latency nobody asked for. Two frames — 40ms — is the
25- usual starting point for a conversation.
26- * OVERFLOW. Past `max-depth` the OLDEST frame is dropped, not the
27- newest. A listener wants the most recent audio; keeping the stale end
28- of a backlog just delays everything behind it permanently.
29- * UNDERFLOW. An empty ring conceals rather than stalls. Waiting for the
30- late frame would stall every OTHER peer too, since they share the
31- output clock.
32-
33- This is a deliberately plain buffer: fixed depth, no adaptation to
34- measured jitter, no clock-drift resampling. Those are real and they are
35- missing, and the note at `mix-into!` says what goes wrong without them."
36- (:require [frq.codec.opus :as opus]
37- [jolt.ffi :as ffi]))
38-
39-(def ^:const sample-rate 48000)
40-(def ^:const frame-samples 960) ; 20ms at 48kHz, per channel
41-(def ^:const target-depth 2)
42-(def ^:const max-depth 6)
43-
44-;; --- OpusHead ----------------------------------------------------------------
45-
46-(defn opus-head!
47- "The 19-byte OpusHead an Opus track's catalog entry needs, into `p`.
48-
49- Little-endian, unlike everything else in this port — OpusHead is Ogg's
50- header format and predates any of it. An audio track will not publish
51- without one: video resolves its parameters in band and audio does not."
52- [p channels]
53- (let [magic [0x4f 0x70 0x75 0x73 0x48 0x65 0x61 0x64] ; "OpusHead"
54- pre-skip 3840]
55- (dotimes [i 8] (ffi/write (+ p i) :uint8 (nth magic i)))
56- (ffi/write (+ p 8) :uint8 1) ; version
57- (ffi/write (+ p 9) :uint8 channels)
58- (ffi/write (+ p 10) :uint8 (bit-and pre-skip 255))
59- (ffi/write (+ p 11) :uint8 (bit-and (bit-shift-right pre-skip 8) 255))
60- (dotimes [i 4]
61- (ffi/write (+ p 12 i) :uint8
62- (bit-and (bit-shift-right sample-rate (* 8 i)) 255)))
63- (ffi/write (+ p 16) :uint8 0) ; output gain lo
64- (ffi/write (+ p 17) :uint8 0) ; output gain hi
65- (ffi/write (+ p 18) :uint8 0) ; mapping family
66- [p 19]))
67-
68-;; --- a peer's ring -----------------------------------------------------------
69-
70-(defn ring
71- "A peer's decoded-audio ring: `max-depth` frames of foreign memory.
72-
73- Allocated once and reused. Decoding into fresh memory every 20ms would
74- make the allocator part of the audio path, which is the one place it has
75- no business being."
76- [channels]
77- {:decoder (opus/decoder sample-rate channels)
78- :channels channels
79- :slots (mapv (fn [_] (ffi/alloc (* 2 frame-samples channels)))
80- (range max-depth))
81- :filled (atom []) ; indices holding audio, oldest first
82- :free (atom (vec (range max-depth)))
83- :started? (atom false)})
84-
85-(defn close-ring! [r]
86- (opus/free-decoder! (:decoder r))
87- (doseq [p (:slots r)] (ffi/free p))
88- nil)
89-
90-(defn push-packet!
91- "Decode one Opus packet into the ring.
92-
93- Over `max-depth` the OLDEST frame goes, not this one: a listener wants the
94- most recent audio, and keeping the stale end of a backlog delays
95- everything behind it for the rest of the call."
96- [r ptr len]
97- (let [{:keys [decoder slots filled free channels]} r
98- i (if-let [i (first @free)]
99- (do (swap! free subvec 1) i)
100- (let [oldest (first @filled)]
101- (swap! filled subvec 1)
102- oldest))
103- n (opus/decode! decoder ptr len (nth slots i) frame-samples)]
104- (swap! filled conj i)
105- (when (>= (count @filled) target-depth) (reset! (:started? r) true))
106- n))
107-
108-(defn- take-frame!
109- "The oldest frame in the ring, or nil while it is still filling."
110- [r]
111- (when @(:started? r)
112- (when-let [i (first @(:filled r))]
113- (swap! (:filled r) subvec 1)
114- (swap! (:free r) conj i)
115- (nth (:slots r) i))))
116-
117-;; --- mixing ------------------------------------------------------------------
118-
119-(defn mix-into!
120- "Sum one frame from every ring into `out`; answers the peak written.
121-
122- Summed and CLAMPED, not averaged. Averaging would make every voice quieter
123- as more people joined, which is the wrong behaviour in a meeting; clamping
124- only bites when several people are loud at once, which is already
125- unpleasant for other reasons.
126-
127- A ring with nothing in it conceals — `opus/decode!` with no packet is
128- Opus's own loss concealment — rather than contributing silence, because a
129- gap is a click and a concealed frame is a smudge.
130-
131- WHAT IS NOT HERE, and it will be audible eventually: no resampling for
132- clock drift. The sound card's clock and the sender's are not the same, and
133- over minutes one drifts against the other — the ring slowly fills or
134- slowly empties, and the fix is to resample by a fraction of a percent
135- rather than to keep dropping or concealing. That wants measurement this
136- buffer does not yet take."
137- [rings out channels]
138- (let [n (* frame-samples channels)]
139- (dotimes [i n] (ffi/write (+ out (* 2 i)) :int16 0))
140- (doseq [r rings]
141- (let [src (or (take-frame! r)
142- ;; Conceal: decode nothing, which Opus turns into a
143- ;; plausible continuation of what it last heard.
144- (let [slot (nth (:slots r) 0)]
145- (when @(:started? r)
146- (opus/decode! (:decoder r) nil 0 slot frame-samples)
147- slot)))]
148- (when src
149- (dotimes [i n]
150- (let [a (ffi/read (+ out (* 2 i)) :int16)
151- b (ffi/read (+ src (* 2 i)) :int16)
152- v (+ a b)]
153- (ffi/write (+ out (* 2 i)) :int16
154- (cond (> v 32767) 32767 (< v -32768) -32768 :else v)))))))
155- (loop [i 0 peak 0]
156- (if (= i n)
157- peak
158- (recur (inc i) (max peak (abs (ffi/read (+ out (* 2 i)) :int16))))))))
deleted src/frq/av/plane.clj +0 -628
deleted file mode 100644
@@ -1,628 +0,0 @@
1-(ns frq.av.plane
2- "The media plane, in jolt — what `libjoltmoq` was.
3-
4- `joltmoq_start` was one call that connected, published a camera, subscribed
5- to every peer, decoded their video and mixed their audio, on its own Rust
6- threads. This is the same job assembled from the pieces `frq.moq.*`,
7- `frq.codec.*` and `frq.capture.*` now provide, and it deliberately keeps
8- joltmoq's SHAPE so that `frq.av` becomes a change of call sites rather than
9- a rewrite:
10-
11- start! stop! live? joltmoq_start / _stop / _is_live
12- poll-status! joltmoq_poll_status + _status_text
13- poll-frames! joltmoq_frame_poll + _frame_rgba
14- poll-audio! (no equivalent; joltmoq played it itself)
15-
16- STATUS IS A QUEUE, drained rather than sampled. joltmoq's poll_status
17- answered one code per call and was looped until it said none, and the
18- reason is worth keeping: a call can fail and end between two pumps, and a
19- caller that only ever sees the latest state would show the wrong one.
20- `frq.av` already loops on it.
21-
22- WHY IT IS PUMPED AND NOT THREADED. jolt has fibers, but a fiber is bound to
23- its carrier for life and a blocking foreign call pins that carrier and
24- strands everything queued behind it — and the two things this has to do
25- most often, V4L2's DQBUF and ALSA's readi, are exactly that. So the plane is
26- driven from `pump!`, which glimmer already calls from a timer for `frq.av`.
27- It also keeps joltmoq's frame contract intact for free: at most one frame is
28- decoded per poll, so the pointer handed out stays valid until the next one,
29- which is precisely what `joltmoq_frame_rgba` promised.
30-
31- WHAT IS HERE. Video, for as many peers as announce themselves. Outbound is
32- capture → H.264 → a MoQ media track. Inbound is DISCOVERED rather than
33- configured: an announcement watch on the origin turns up each peer's
34- broadcast, its catalog names the video track and says which container it is
35- in, and from there it is subscribe → H.264 → RGBA per peer.
36-
37- Each peer keeps its OWN decoder, which is what makes several of them
38- possible at all: a decoder's output buffer is overwritten by its next
39- decode, so one shared between peers would hand out the same pixels for all
40- of them. One decode per peer per pump, and the pointers stay good until
41- that peer's next.
42-
43- What is still not here:
44-
45- * audio — Opus and ALSA are bound, but mixing several peers into one
46- playback stream needs a jitter buffer and a resampler for clock drift,
47- and a bad one is worse than none
48- * Android, where neither V4L2 nor ALSA exists
49-
50- A SOURCE IS A FUNCTION, not a camera. `start!` takes `:source`, a thunk
51- answering [pointer length] for one I420 frame or nil for \"nothing right
52- now\". `frq.capture.v4l2` is one such thunk; a test pattern is another. That
53- is what lets the plane be exercised on a machine with no camera, and it is
54- also how the phone will pass a Camera2 buffer in later without this
55- namespace learning about JNI."
56- (:require [clojure.string :as str]
57- [frq.av.audio :as audio]
58- [frq.moq.client :as client]
59- [frq.moq.media :as media]
60- [frq.moq.uniffi :as uniffi]
61- [frq.codec.h264 :as h264]
62- [frq.codec.opus :as opus]
63- [frq.capture.source :as source]
64- [jolt.ffi :as ffi]))
65-
66-;; --- state -------------------------------------------------------------------
67-;; One plane per process, as joltmoq had: its C API was all globals, and the
68-;; call surface above it assumes a single call at a time.
69-
70-(declare stop! start!)
71-
72-(defonce ^:private plane (atom nil))
73-
74-(defn live?
75- "Whether a call is actually up — not merely dialling."
76- []
77- (boolean (some-> @plane :producer)))
78-
79-(defn dialling? [] (boolean (some-> @plane :dialling)))
80-
81-;; --- starting ----------------------------------------------------------------
82-
83-(defn start!
84- "Bring the plane up and answer true, or false with a reason recorded.
85-
86- `origin` is a MoqOriginProducer — from a session for a real call, or made
87- locally for a test, which is the same object either way. `source` is the
88- frame thunk described above.
89-
90- Everything that can fail does so HERE rather than at the first frame: the
91- encoder validates its size, the decoder opens, and the subscribe settles,
92- so a plane that comes up is one that can carry a picture."
93- [{:keys [origin discover session path source mic speaker
94- camera-device mic-device speaker-device
95- width height fps bitrate camera? muted? channels]
96- :or {path "/frq" width 640 height 480 fps 30 bitrate 800000
97- camera? true muted? false channels 1}}]
98- (stop!)
99- ;; A device NAME builds the thunk; a thunk passed directly wins. That
100- ;; ordering is what lets the same plane run against a camera and against
101- ;; a test pattern without knowing which it has.
102- (let [cam (when (and camera-device (nil? source))
103- (source/camera camera-device {:width width :height height}))
104- width (or (:width cam) width)
105- height (or (:height cam) height)
106- source (or source (:source cam))
107- micdev (when (and mic-device (nil? mic))
108- (source/microphone mic-device {:channels channels}))
109- mic (or mic (:mic micdev))
110- spk (or speaker
111- (when speaker-device
112- (source/speaker speaker-device {:channels channels})))
113- broadcast (media/create-broadcast! origin path)
114- producer (media/publish-media! broadcast "avc3")
115- track (media/producer-name producer)
116- consumer (media/broadcast-consumer broadcast)
117- ;; The audio track rides the same publish_media as video — this
118- ;; object has no publish_audio, that being moq-ffi's `audio`
119- ;; feature. What it needs instead is an OpusHead up front: video
120- ;; resolves its parameters in band and audio does not.
121- [head-p head-n] (audio/opus-head! (ffi/alloc 19) channels)
122- mic-producer (when mic
123- (media/publish-media-bytes! broadcast "opus"
124- head-p head-n))]
125- (ffi/free head-p)
126- (reset! plane
127- {:broadcast broadcast
128- :producer producer
129- :track track
130- :path path
131- :session session
132- ;; A session that closes says so through a future rather than
133- ;; a callback. Completing it is how the REASON for a dropped
134- ;; call is learned — it settles quietly on a clean close and
135- ;; raises the MoqError on a dirty one — where noticing that
136- ;; frames stopped would only ever say "something".
137- :closed (when session (client/watch-closed! session))
138- ;; The announcement watch is the whole of peer discovery. An
139- ;; empty prefix takes everything on the origin, because in a
140- ;; call every participant is a broadcast and none of their
141- ;; paths are known in advance.
142- ;;
143- ;; PUBLISH AND DISCOVER ARE TWO ORIGINS, not one. On a local
144- ;; origin they are the same object and it makes no difference;
145- ;; over a session they are `publisher()` and `consumer()`, and
146- ;; conflating them is how a client publishes into the void or
147- ;; watches an origin nobody announces on. The default keeps the
148- ;; local case a one-liner.
149- :announced (media/announced!
150- (or discover (media/origin-consumer origin)) "")
151- :announce nil
152- :peers {}
153- :encoder (h264/encoder {:width width :height height
154- :fps fps :bitrate bitrate})
155- :mic-producer mic-producer
156- :mic-encoder (when mic (opus/encoder audio/sample-rate channels :voip))
157- :mic mic
158- :speaker spk
159- :closers (into [] (keep :close!) [cam micdev spk])
160- :muted? muted?
161- :channels channels
162- :mix (ffi/alloc (* 2 audio/frame-samples channels))
163- :mixed nil
164- :source source
165- :size [width height]
166- :camera? camera?
167- :frames []
168- :status (atom [{:code :live
169- :has-camera? (some? source)
170- :has-mic? (some? mic)}])
171- :pts (atom 0)
172- :fps fps})
173- true))
174-
175-(defn dial!
176- "Dial `url` and bring the plane up when the session answers. Answers true.
177-
178- FIRE AND FORGET, like `joltmoq_start` was: the connect is a future, and
179- waiting for it here would block glimmer's loop thread for the length of a
180- QUIC handshake — thirty seconds when the relay is not there. `pump!`
181- finishes the job and `poll-status!` reports it, which is the shape
182- `frq.av` is already written around.
183-
184- Everything the plane will need is kept until then, because the tracks
185- cannot be published until there is a session to publish them into."
186- [{:keys [url] :as opts}]
187- (stop!)
188- (let [c (client/new-client)]
189- (reset! plane {:dialling (client/connect! c url)
190- :client c
191- :opts (dissoc opts :url)
192- :status (atom [])
193- :frames []
194- :peers {}})
195- true))
196-
197-(defn- pump-dialling!
198- "Finish a connect that has settled, and start the plane on its session."
199- [p]
200- (if-let [fut (:dialling p)]
201- (when (uniffi/settled? fut)
202- (let [sess (try (uniffi/complete! fut)
203- (catch Exception e
204- (swap! (:status p) conj
205- {:code :failed
206- :text (or (:message (ex-data e)) (ex-message e))})
207- nil))]
208- (if sess
209- (do (start! (assoc (:opts p)
210- :origin (client/session-publisher sess)
211- :discover (client/session-consumer sess)
212- :session sess))
213- ;; start! replaced the plane wholesale; carry over anything
214- ;; queued while we were still dialling.
215- (swap! plane update :status
216- (fn [q] (swap! q into @(:status p)) q))
217- @plane)
218- ;; A failed dial leaves the plane present but not live, so the
219- ;; reason survives to be read.
220- (assoc p :dialling nil :client nil))))
221- p))
222-
223-(defn stop!
224- "Take the plane down and release everything it holds."
225- []
226- (when-let [p @plane]
227- (try (h264/close! (:encoder p)) (catch Exception _ nil))
228- (doseq [[_ peer] (:peers p)]
229- (try (h264/close-decoder! (:decoder peer)) (catch Exception _ nil))
230- (when-let [r (:ring peer)] (try (audio/close-ring! r) (catch Exception _ nil))))
231- (when-let [e (:mic-encoder p)] (try (opus/free-encoder! e) (catch Exception _ nil)))
232- (when-let [m (:mix p)] (try (ffi/free m) (catch Exception _ nil)))
233- ;; The session has to be told, not merely dropped. Freeing its handle
234- ;; without a shutdown panics the process — the drop tries to close the
235- ;; QUIC connection from whatever thread got there, and outside a tokio
236- ;; worker there is no reactor to do it on.
237- (when-let [sess (:session p)]
238- (try (client/shutdown! sess) (catch Exception _ nil)))
239- ;; Devices last: the encoder and the rings may still be reading from
240- ;; buffers these own.
241- (doseq [close! (:closers p)] (try (close!) (catch Exception _ nil)))
242- (reset! plane nil))
243- nil)
244-
245-;; --- controls ----------------------------------------------------------------
246-
247-(defn set-camera!
248- "Publishing on or off. Off stops the encoder being fed; it does not tear the
249- track down, because a subscriber that saw the track vanish and reappear
250- would have to rediscover it."
251- [on?]
252- (swap! plane #(when % (assoc % :camera? (boolean on?))))
253- nil)
254-
255-(defn set-muted!
256- "Stop feeding the encoder. The track stays published — a peer who saw it
257- vanish would have to rediscover it to hear you unmute."
258- [muted?]
259- (swap! plane #(when % (assoc % :muted? (boolean muted?))))
260- nil)
261-
262-(defn set-speaker-muted!
263- "Stop playing what the mix produced. The peers keep being decoded — their
264- rings have to stay current or unmuting would start from forty
265- milliseconds of stale audio."
266- [muted?]
267- (swap! plane #(when % (assoc % :speaker-muted? (boolean muted?))))
268- nil)
269-
270-;; Device switching mid-call is RECORDED, not applied. Reopening a camera
271-;; means tearing down the capture, the encoder and the published track and
272-;; putting them back — there is no V4L2 ioctl for "become a different
273-;; camera" — and doing that underneath a live call is a change worth making
274-;; deliberately rather than as a side effect of a menu. `frq.av` keeps the
275-;; id either way, so the next call uses it.
276-
277-(defn set-camera-device! [id] (swap! plane #(when % (assoc % :camera-device id))) nil)
278-(defn set-mic-device! [id] (swap! plane #(when % (assoc % :mic-device id))) nil)
279-(defn set-speaker-device! [id] (swap! plane #(when % (assoc % :speaker-device id))) nil)
280-
281-(defn force-keyframe!
282- "Make the next published frame an IDR.
283-
284- What a subscriber joining mid-call needs: the second frame out of an
285- encoder is a P-frame, and a decoder handed one first has no SPS or PPS to
286- decode against and says so."
287- []
288- (when-let [p @plane] (h264/force-keyframe! (:encoder p)))
289- nil)
290-
291-;; --- the outbound half -------------------------------------------------------
292-
293-(defn- pump-out!
294- "Take one frame from the source, encode it, publish it."
295- [{:keys [source encoder producer camera? pts fps]}]
296- (when (and camera? source)
297- (when-let [[px _len] (source)]
298- (let [us (swap! pts + (quot 1000000 (max 1 fps)))]
299- (h264/encode!
300- encoder px us
301- (fn [p len _key?]
302- ;; A skipped frame is a decision, not a failure — openh264
303- ;; answers zero length and there is simply nothing to send.
304- (when (pos? len)
305- (media/write-video-frame! producer p len us))))))))
306-
307-;; --- the inbound half -------------------------------------------------------
308-
309-(defn- settle
310- "Answer a settled future's value, or nil while it has not settled.
311-
312- Never blocks: an unsettled future is polled again and nil comes back, which
313- is what lets this be called from the loop thread as often as a timer fires."
314- [fut lift]
315- (when (and fut (uniffi/settled? fut))
316- (if lift (uniffi/complete! fut lift) (uniffi/complete! fut))))
317-
318-(defn- normalise-path
319- "An origin announces `us` for a broadcast created as `/us`.
320-
321- The leading slash is ours, not the origin's: `create-broadcast!` takes the
322- path we hand it and announcements come back relative to the origin root.
323- Comparing the two verbatim is how the self-view stops being recognised as
324- ours — which does not fail loudly, it just puts your own face in the grid
325- under a peer's name."
326- [path]
327- (when path (str/replace path #"^/+" "")))
328-
329-(defn- pump-announce!
330- "Advance the announcement watch; add a peer for anything new.
331-
332- Our own broadcast is announced back to us like anyone else's, and it is
333- taken as the self-view rather than filtered out — `frq.av` already has a
334- key for that and a self-view is a picture the person expects to see."
335- [p]
336- (let [p (if (:announce p) p (assoc p :announce (media/next-announcement! (:announced p))))]
337- (if-let [ann (settle (:announce p) media/lift-announcement)]
338- (let [path (media/announcement-path ann)
339- p (assoc p :announce nil)]
340- (if (contains? (:peers p) path)
341- p
342- (assoc-in p [:peers path]
343- {:broadcast (media/announcement-broadcast ann)
344- :catalog nil
345- :catalog-pending nil
346- :subscribe nil
347- :media nil
348- :pending nil
349- :decoder (h264/decoder)
350- :self? (= (normalise-path path)
351- (normalise-path (:path p)))})))
352- p)))
353-
354-(defn- pump-peer-audio!
355- "Advance one peer's audio: catalog says the track, then subscribe, then
356- decode into that peer's ring.
357-
358- Separate from the video walk because the two are independent — a peer with
359- a camera off still has a voice, and blocking one on the other is how a
360- muted-video participant goes silent too."
361- [peer channels]
362- (cond
363- (nil? (get-in peer [:catalog :audio-track])) peer
364-
365- (nil? (:audio-media peer))
366- (let [peer (if (:audio-subscribe peer)
367- peer
368- (assoc peer :audio-subscribe
369- (media/subscribe-media! (:broadcast peer)
370- (get-in peer [:catalog :audio-track])
371- (get-in peer [:catalog :audio-container]))))]
372- (if-let [mc (settle (:audio-subscribe peer) nil)]
373- (assoc peer :audio-media mc :audio-subscribe nil
374- :ring (audio/ring channels))
375- peer))
376-
377- :else
378- (let [peer (if (:audio-pending peer)
379- peer
380- (assoc peer :audio-pending (media/next-frame! (:audio-media peer))))
381- got (settle (:audio-pending peer)
382- #(media/lift-media-frame
383- %
384- (fn [ptr len]
385- (when (pos? len)
386- (audio/push-packet! (:ring peer) ptr len)
387- true))))]
388- (if got (assoc peer :audio-pending nil) peer))))
389-
390-(defn- pump-peer!
391- "Walk one peer from announced to a decoded picture.
392-
393- Four states, advanced at most one step per pump so that no peer can hold
394- the loop thread: subscribe the catalog, read it for a video track name and
395- its container, subscribe that track, then decode a frame from it."
396- [peer]
397- (cond
398- ;; 1. The catalog: what tracks this peer has, and in which container.
399- (nil? (:catalog peer))
400- (let [peer (if (:catalog-pending peer)
401- peer
402- (assoc peer :catalog-pending
403- {:sub (media/subscribe-catalog! (:broadcast peer))}))
404- sub (:catalog-pending peer)]
405- (cond
406- (:consumer sub)
407- (let [cp (or (:next sub) (media/next-catalog! (:consumer sub)))]
408- (if-let [cat (settle cp media/lift-catalog)]
409- (let [[track video] (first (:video cat))
410- [atrack aud] (first (:audio cat))]
411- ;; EITHER is enough. Requiring video here is how an audio-only
412- ;; peer waits for ever: with no picture coming the catalog is
413- ;; never accepted, so the audio track named in the same
414- ;; catalog is never read either, and someone with their camera
415- ;; off goes silent as well as dark.
416- (if (or track atrack)
417- (assoc peer :catalog {:track track
418- :container (:container video)
419- :audio-track atrack
420- :audio-container (:container aud)}
421- :catalog-pending nil)
422- ;; Nothing published yet. Ask again.
423- (assoc peer :catalog-pending {:consumer (:consumer sub) :next nil})))
424- (assoc peer :catalog-pending {:consumer (:consumer sub) :next cp})))
425-
426- :else
427- (if-let [cc (settle (:sub sub) nil)]
428- (assoc peer :catalog-pending {:consumer cc :next nil})
429- peer)))
430-
431- ;; No picture from this peer — audio only, or camera off. Not a state
432- ;; to advance out of; their audio walks on its own.
433- (nil? (get-in peer [:catalog :track])) peer
434-
435- ;; 2. Subscribe to the track the catalog named.
436- (nil? (:media peer))
437- (let [peer (if (:subscribe peer)
438- peer
439- (assoc peer :subscribe
440- (media/subscribe-media! (:broadcast peer)
441- (get-in peer [:catalog :track])
442- (get-in peer [:catalog :container]))))]
443- (if-let [mc (settle (:subscribe peer) nil)]
444- (assoc peer :media mc :subscribe nil)
445- peer))
446-
447- ;; 3. A frame.
448- :else
449- (let [peer (if (:pending peer) peer (assoc peer :pending (media/next-frame! (:media peer))))
450- ;; The decode happens INSIDE the lift, while the RustBuffer the
451- ;; payload points into is still alive. Lifting the span out and
452- ;; decoding afterwards reads a buffer that has already been freed,
453- ;; and what comes back from that is not a fault but plausible
454- ;; rubbish.
455- ;;
456- ;; The RGBA pointer it answers belongs to this peer's DECODER, not
457- ;; to the RustBuffer, so it outlives the lift and is good until
458- ;; this peer decodes again.
459- decoded (settle (:pending peer)
460- #(media/lift-media-frame
461- %
462- (fn [ptr len]
463- (when (pos? len)
464- (h264/decode!
465- (:decoder peer) ptr len
466- (fn [rgba w h]
467- (when-not (or (ffi/null? rgba) (zero? w))
468- {:w w :h h :rgba rgba})))))))]
469- (if decoded
470- (assoc peer :pending nil :frame (:payload decoded))
471- (assoc peer :frame nil)))))
472-
473-(defn- pump-mic!
474- "One 20ms frame from the microphone, encoded and published."
475- [{:keys [mic mic-encoder mic-producer muted? pts channels]}]
476- (when (and mic mic-encoder mic-producer (not muted?))
477- (when-let [[pcm _] (mic)]
478- (ffi/with-arena [a]
479- (let [out (ffi/alloc a 4000)
480- n (opus/encode! mic-encoder pcm audio/frame-samples out 4000)]
481- ;; DTX is the encoder saying this frame is silence and need not be
482- ;; sent. Sending it anyway would be bytes for nothing.
483- (when-not (opus/dtx? n)
484- (media/write-video-frame! mic-producer out n @pts)))))))
485-
486-(defn- pump-in!
487- "Discover peers, then advance every one of them."
488- [p]
489- (let [p (pump-announce! p)
490- peers (reduce-kv (fn [m path peer]
491- (assoc m path (-> peer
492- pump-peer!
493- (pump-peer-audio! (:channels p)))))
494- {} (:peers p))]
495- (assoc p
496- :peers peers
497- :frames (into []
498- (keep (fn [[path peer]]
499- (when-let [f (:frame peer)]
500- (assoc f :key (if (:self? peer) "__local__" path)))))
501- peers))))
502-
503-;; --- the pump ----------------------------------------------------------------
504-
505-(defn- note!
506- "Queue a status transition for the next `poll-status!`."
507- [p event]
508- (when-let [q (:status p)] (swap! q conj event))
509- nil)
510-
511-(defn- pump-closed!
512- "Notice the session going away, and why.
513-
514- A clean shutdown settles the future with nothing; a dropped connection
515- raises the MoqError that caused it. Both mean the call is over, and both
516- have to be announced — the difference is only what the person is told."
517- [p]
518- (if-let [w (:closed p)]
519- (if (uniffi/settled? w)
520- (do (try
521- (uniffi/complete! w)
522- (note! p {:code :ended})
523- (catch Exception e
524- (note! p {:code :failed
525- :text (or (:message (ex-data e)) (ex-message e))})))
526- (assoc p :closed nil))
527- p)
528- p))
529-
530-(defn pump!
531- "Drive both halves once. Called from the same timer as `frq.av/pump!`.
532-
533- Everything inside is wrapped: a raise from the media plane is a call that
534- failed, not a UI that stops repainting. glimmer calls this from the loop
535- thread, and an exception escaping here would take the window with it."
536- []
537- (when-let [p @plane]
538- (try
539- (if (:dialling p)
540- ;; Still connecting: nothing to pump but the handshake.
541- (when-let [p' (pump-dialling! p)] (reset! plane p'))
542- (let [p (pump-closed! p)]
543- (pump-out! p)
544- (pump-mic! p)
545- (let [p' (pump-in! p)
546- ;; Mix everyone EXCEPT ourselves: hearing your own voice back is
547- ;; the thing headphones exist to prevent.
548- rings (keep (fn [[_ peer]] (when-not (:self? peer) (:ring peer)))
549- (:peers p'))
550- peak (when (seq rings)
551- (audio/mix-into! rings (:mix p') (:channels p')))]
552- (let [mixed (when peak {:ptr (:mix p')
553- :samples audio/frame-samples
554- :peak peak})]
555- ;; Straight out to the speaker if there is one. A caller with its
556- ;; own output — the terminal backend, a test — reads poll-audio!
557- ;; instead and this stays nil.
558- (when (and mixed (:speaker p') (not (:speaker-muted? p')))
559- ((:play! (:speaker p')) mixed))
560- (reset! plane (assoc p' :mixed mixed))))))
561- (catch Exception e
562- (note! p {:code :failed
563- :text (or (:message (ex-data e)) (ex-message e))})
564- ;; The plane stays UP after a failure. joltmoq did the same, and the
565- ;; reason is the person: tearing it down here would take the message
566- ;; saying what went wrong off the screen along with it. `frq.av`
567- ;; decides when to stop.
568- (reset! plane (assoc p :frames [] :mixed nil)))))
569- nil)
570-
571-(defn poll-frames!
572- "Every frame decoded by the last `pump!`, one per peer at most.
573-
574- Each is {:key :w :h :rgba} with `:key` the peer's broadcast path, or
575- \"__local__\" for our own. `:rgba` is BORROWED — it is that peer's decoder
576- buffer and that peer's next decode overwrites it — so hand each to
577- `vidya/frame-rgba!` and let it go. Copying is the one copy this whole path
578- exists to avoid.
579-
580- Several frames at once is safe precisely because the decoders are
581- per-peer: one shared decoder would make every pointer here alias the last
582- picture decoded."
583- []
584- (:frames @plane))
585-
586-(defn poll-audio!
587- "The mixed 20ms frame from the last `pump!`, or nil.
588-
589- {:ptr :samples :peak} — interleaved int16 ready for `alsa/write!`, and
590- BORROWED like everything else here: the next pump mixes over it."
591- []
592- (:mixed @plane))
593-
594-(defn peers
595- "The broadcast paths currently known, self included."
596- []
597- (some-> @plane :peers keys vec))
598-
599-(defn feed-keys
600- "The keys of everyone currently sending a picture.
601-
602- Not everyone announced: a peer with their camera off has a broadcast and
603- a catalog and no video track, and listing them would leave an empty tile
604- in the wall that never fills. This is joltmoq's `video_keys`, and the
605- test is the same one — is there a video track subscribed."
606- []
607- (into #{}
608- (keep (fn [[path peer]]
609- (when (:media peer)
610- (if (:self? peer) "__local__" path))))
611- (some-> @plane :peers)))
612-
613-(defn poll-status!
614- "Drain what the plane has learned since the last call, oldest first.
615-
616- Each event is {:code :live|:ended|:failed} with `:text` on a failure and
617- `:has-camera?`/`:has-mic?` on :live — which is joltmoq's poll_status,
618- status_text and status_has_camera/_mic in one value instead of four
619- calls that had to be made in the right order.
620-
621- Drained, not sampled: a call can fail and end between two pumps, and a
622- caller shown only the latest would show the wrong one."
623- []
624- (when-let [p @plane]
625- (let [q (:status p)
626- v @q]
627- (reset! q [])
628- v)))
deleted file mode 100644
@@ -1,628 +0,0 @@
1-(ns frq.av.plane
2- "The media plane, in jolt — what `libjoltmoq` was.
3-
4- `joltmoq_start` was one call that connected, published a camera, subscribed
5- to every peer, decoded their video and mixed their audio, on its own Rust
6- threads. This is the same job assembled from the pieces `frq.moq.*`,
7- `frq.codec.*` and `frq.capture.*` now provide, and it deliberately keeps
8- joltmoq's SHAPE so that `frq.av` becomes a change of call sites rather than
9- a rewrite:
10-
11- start! stop! live? joltmoq_start / _stop / _is_live
12- poll-status! joltmoq_poll_status + _status_text
13- poll-frames! joltmoq_frame_poll + _frame_rgba
14- poll-audio! (no equivalent; joltmoq played it itself)
15-
16- STATUS IS A QUEUE, drained rather than sampled. joltmoq's poll_status
17- answered one code per call and was looped until it said none, and the
18- reason is worth keeping: a call can fail and end between two pumps, and a
19- caller that only ever sees the latest state would show the wrong one.
20- `frq.av` already loops on it.
21-
22- WHY IT IS PUMPED AND NOT THREADED. jolt has fibers, but a fiber is bound to
23- its carrier for life and a blocking foreign call pins that carrier and
24- strands everything queued behind it — and the two things this has to do
25- most often, V4L2's DQBUF and ALSA's readi, are exactly that. So the plane is
26- driven from `pump!`, which glimmer already calls from a timer for `frq.av`.
27- It also keeps joltmoq's frame contract intact for free: at most one frame is
28- decoded per poll, so the pointer handed out stays valid until the next one,
29- which is precisely what `joltmoq_frame_rgba` promised.
30-
31- WHAT IS HERE. Video, for as many peers as announce themselves. Outbound is
32- capture → H.264 → a MoQ media track. Inbound is DISCOVERED rather than
33- configured: an announcement watch on the origin turns up each peer's
34- broadcast, its catalog names the video track and says which container it is
35- in, and from there it is subscribe → H.264 → RGBA per peer.
36-
37- Each peer keeps its OWN decoder, which is what makes several of them
38- possible at all: a decoder's output buffer is overwritten by its next
39- decode, so one shared between peers would hand out the same pixels for all
40- of them. One decode per peer per pump, and the pointers stay good until
41- that peer's next.
42-
43- What is still not here:
44-
45- * audio — Opus and ALSA are bound, but mixing several peers into one
46- playback stream needs a jitter buffer and a resampler for clock drift,
47- and a bad one is worse than none
48- * Android, where neither V4L2 nor ALSA exists
49-
50- A SOURCE IS A FUNCTION, not a camera. `start!` takes `:source`, a thunk
51- answering [pointer length] for one I420 frame or nil for \"nothing right
52- now\". `frq.capture.v4l2` is one such thunk; a test pattern is another. That
53- is what lets the plane be exercised on a machine with no camera, and it is
54- also how the phone will pass a Camera2 buffer in later without this
55- namespace learning about JNI."
56- (:require [clojure.string :as str]
57- [frq.av.audio :as audio]
58- [frq.moq.client :as client]
59- [frq.moq.media :as media]
60- [frq.moq.uniffi :as uniffi]
61- [frq.codec.h264 :as h264]
62- [frq.codec.opus :as opus]
63- [frq.capture.source :as source]
64- [jolt.ffi :as ffi]))
65-
66-;; --- state -------------------------------------------------------------------
67-;; One plane per process, as joltmoq had: its C API was all globals, and the
68-;; call surface above it assumes a single call at a time.
69-
70-(declare stop! start!)
71-
72-(defonce ^:private plane (atom nil))
73-
74-(defn live?
75- "Whether a call is actually up — not merely dialling."
76- []
77- (boolean (some-> @plane :producer)))
78-
79-(defn dialling? [] (boolean (some-> @plane :dialling)))
80-
81-;; --- starting ----------------------------------------------------------------
82-
83-(defn start!
84- "Bring the plane up and answer true, or false with a reason recorded.
85-
86- `origin` is a MoqOriginProducer — from a session for a real call, or made
87- locally for a test, which is the same object either way. `source` is the
88- frame thunk described above.
89-
90- Everything that can fail does so HERE rather than at the first frame: the
91- encoder validates its size, the decoder opens, and the subscribe settles,
92- so a plane that comes up is one that can carry a picture."
93- [{:keys [origin discover session path source mic speaker
94- camera-device mic-device speaker-device
95- width height fps bitrate camera? muted? channels]
96- :or {path "/frq" width 640 height 480 fps 30 bitrate 800000
97- camera? true muted? false channels 1}}]
98- (stop!)
99- ;; A device NAME builds the thunk; a thunk passed directly wins. That
100- ;; ordering is what lets the same plane run against a camera and against
101- ;; a test pattern without knowing which it has.
102- (let [cam (when (and camera-device (nil? source))
103- (source/camera camera-device {:width width :height height}))
104- width (or (:width cam) width)
105- height (or (:height cam) height)
106- source (or source (:source cam))
107- micdev (when (and mic-device (nil? mic))
108- (source/microphone mic-device {:channels channels}))
109- mic (or mic (:mic micdev))
110- spk (or speaker
111- (when speaker-device
112- (source/speaker speaker-device {:channels channels})))
113- broadcast (media/create-broadcast! origin path)
114- producer (media/publish-media! broadcast "avc3")
115- track (media/producer-name producer)
116- consumer (media/broadcast-consumer broadcast)
117- ;; The audio track rides the same publish_media as video — this
118- ;; object has no publish_audio, that being moq-ffi's `audio`
119- ;; feature. What it needs instead is an OpusHead up front: video
120- ;; resolves its parameters in band and audio does not.
121- [head-p head-n] (audio/opus-head! (ffi/alloc 19) channels)
122- mic-producer (when mic
123- (media/publish-media-bytes! broadcast "opus"
124- head-p head-n))]
125- (ffi/free head-p)
126- (reset! plane
127- {:broadcast broadcast
128- :producer producer
129- :track track
130- :path path
131- :session session
132- ;; A session that closes says so through a future rather than
133- ;; a callback. Completing it is how the REASON for a dropped
134- ;; call is learned — it settles quietly on a clean close and
135- ;; raises the MoqError on a dirty one — where noticing that
136- ;; frames stopped would only ever say "something".
137- :closed (when session (client/watch-closed! session))
138- ;; The announcement watch is the whole of peer discovery. An
139- ;; empty prefix takes everything on the origin, because in a
140- ;; call every participant is a broadcast and none of their
141- ;; paths are known in advance.
142- ;;
143- ;; PUBLISH AND DISCOVER ARE TWO ORIGINS, not one. On a local
144- ;; origin they are the same object and it makes no difference;
145- ;; over a session they are `publisher()` and `consumer()`, and
146- ;; conflating them is how a client publishes into the void or
147- ;; watches an origin nobody announces on. The default keeps the
148- ;; local case a one-liner.
149- :announced (media/announced!
150- (or discover (media/origin-consumer origin)) "")
151- :announce nil
152- :peers {}
153- :encoder (h264/encoder {:width width :height height
154- :fps fps :bitrate bitrate})
155- :mic-producer mic-producer
156- :mic-encoder (when mic (opus/encoder audio/sample-rate channels :voip))
157- :mic mic
158- :speaker spk
159- :closers (into [] (keep :close!) [cam micdev spk])
160- :muted? muted?
161- :channels channels
162- :mix (ffi/alloc (* 2 audio/frame-samples channels))
163- :mixed nil
164- :source source
165- :size [width height]
166- :camera? camera?
167- :frames []
168- :status (atom [{:code :live
169- :has-camera? (some? source)
170- :has-mic? (some? mic)}])
171- :pts (atom 0)
172- :fps fps})
173- true))
174-
175-(defn dial!
176- "Dial `url` and bring the plane up when the session answers. Answers true.
177-
178- FIRE AND FORGET, like `joltmoq_start` was: the connect is a future, and
179- waiting for it here would block glimmer's loop thread for the length of a
180- QUIC handshake — thirty seconds when the relay is not there. `pump!`
181- finishes the job and `poll-status!` reports it, which is the shape
182- `frq.av` is already written around.
183-
184- Everything the plane will need is kept until then, because the tracks
185- cannot be published until there is a session to publish them into."
186- [{:keys [url] :as opts}]
187- (stop!)
188- (let [c (client/new-client)]
189- (reset! plane {:dialling (client/connect! c url)
190- :client c
191- :opts (dissoc opts :url)
192- :status (atom [])
193- :frames []
194- :peers {}})
195- true))
196-
197-(defn- pump-dialling!
198- "Finish a connect that has settled, and start the plane on its session."
199- [p]
200- (if-let [fut (:dialling p)]
201- (when (uniffi/settled? fut)
202- (let [sess (try (uniffi/complete! fut)
203- (catch Exception e
204- (swap! (:status p) conj
205- {:code :failed
206- :text (or (:message (ex-data e)) (ex-message e))})
207- nil))]
208- (if sess
209- (do (start! (assoc (:opts p)
210- :origin (client/session-publisher sess)
211- :discover (client/session-consumer sess)
212- :session sess))
213- ;; start! replaced the plane wholesale; carry over anything
214- ;; queued while we were still dialling.
215- (swap! plane update :status
216- (fn [q] (swap! q into @(:status p)) q))
217- @plane)
218- ;; A failed dial leaves the plane present but not live, so the
219- ;; reason survives to be read.
220- (assoc p :dialling nil :client nil))))
221- p))
222-
223-(defn stop!
224- "Take the plane down and release everything it holds."
225- []
226- (when-let [p @plane]
227- (try (h264/close! (:encoder p)) (catch Exception _ nil))
228- (doseq [[_ peer] (:peers p)]
229- (try (h264/close-decoder! (:decoder peer)) (catch Exception _ nil))
230- (when-let [r (:ring peer)] (try (audio/close-ring! r) (catch Exception _ nil))))
231- (when-let [e (:mic-encoder p)] (try (opus/free-encoder! e) (catch Exception _ nil)))
232- (when-let [m (:mix p)] (try (ffi/free m) (catch Exception _ nil)))
233- ;; The session has to be told, not merely dropped. Freeing its handle
234- ;; without a shutdown panics the process — the drop tries to close the
235- ;; QUIC connection from whatever thread got there, and outside a tokio
236- ;; worker there is no reactor to do it on.
237- (when-let [sess (:session p)]
238- (try (client/shutdown! sess) (catch Exception _ nil)))
239- ;; Devices last: the encoder and the rings may still be reading from
240- ;; buffers these own.
241- (doseq [close! (:closers p)] (try (close!) (catch Exception _ nil)))
242- (reset! plane nil))
243- nil)
244-
245-;; --- controls ----------------------------------------------------------------
246-
247-(defn set-camera!
248- "Publishing on or off. Off stops the encoder being fed; it does not tear the
249- track down, because a subscriber that saw the track vanish and reappear
250- would have to rediscover it."
251- [on?]
252- (swap! plane #(when % (assoc % :camera? (boolean on?))))
253- nil)
254-
255-(defn set-muted!
256- "Stop feeding the encoder. The track stays published — a peer who saw it
257- vanish would have to rediscover it to hear you unmute."
258- [muted?]
259- (swap! plane #(when % (assoc % :muted? (boolean muted?))))
260- nil)
261-
262-(defn set-speaker-muted!
263- "Stop playing what the mix produced. The peers keep being decoded — their
264- rings have to stay current or unmuting would start from forty
265- milliseconds of stale audio."
266- [muted?]
267- (swap! plane #(when % (assoc % :speaker-muted? (boolean muted?))))
268- nil)
269-
270-;; Device switching mid-call is RECORDED, not applied. Reopening a camera
271-;; means tearing down the capture, the encoder and the published track and
272-;; putting them back — there is no V4L2 ioctl for "become a different
273-;; camera" — and doing that underneath a live call is a change worth making
274-;; deliberately rather than as a side effect of a menu. `frq.av` keeps the
275-;; id either way, so the next call uses it.
276-
277-(defn set-camera-device! [id] (swap! plane #(when % (assoc % :camera-device id))) nil)
278-(defn set-mic-device! [id] (swap! plane #(when % (assoc % :mic-device id))) nil)
279-(defn set-speaker-device! [id] (swap! plane #(when % (assoc % :speaker-device id))) nil)
280-
281-(defn force-keyframe!
282- "Make the next published frame an IDR.
283-
284- What a subscriber joining mid-call needs: the second frame out of an
285- encoder is a P-frame, and a decoder handed one first has no SPS or PPS to
286- decode against and says so."
287- []
288- (when-let [p @plane] (h264/force-keyframe! (:encoder p)))
289- nil)
290-
291-;; --- the outbound half -------------------------------------------------------
292-
293-(defn- pump-out!
294- "Take one frame from the source, encode it, publish it."
295- [{:keys [source encoder producer camera? pts fps]}]
296- (when (and camera? source)
297- (when-let [[px _len] (source)]
298- (let [us (swap! pts + (quot 1000000 (max 1 fps)))]
299- (h264/encode!
300- encoder px us
301- (fn [p len _key?]
302- ;; A skipped frame is a decision, not a failure — openh264
303- ;; answers zero length and there is simply nothing to send.
304- (when (pos? len)
305- (media/write-video-frame! producer p len us))))))))
306-
307-;; --- the inbound half -------------------------------------------------------
308-
309-(defn- settle
310- "Answer a settled future's value, or nil while it has not settled.
311-
312- Never blocks: an unsettled future is polled again and nil comes back, which
313- is what lets this be called from the loop thread as often as a timer fires."
314- [fut lift]
315- (when (and fut (uniffi/settled? fut))
316- (if lift (uniffi/complete! fut lift) (uniffi/complete! fut))))
317-
318-(defn- normalise-path
319- "An origin announces `us` for a broadcast created as `/us`.
320-
321- The leading slash is ours, not the origin's: `create-broadcast!` takes the
322- path we hand it and announcements come back relative to the origin root.
323- Comparing the two verbatim is how the self-view stops being recognised as
324- ours — which does not fail loudly, it just puts your own face in the grid
325- under a peer's name."
326- [path]
327- (when path (str/replace path #"^/+" "")))
328-
329-(defn- pump-announce!
330- "Advance the announcement watch; add a peer for anything new.
331-
332- Our own broadcast is announced back to us like anyone else's, and it is
333- taken as the self-view rather than filtered out — `frq.av` already has a
334- key for that and a self-view is a picture the person expects to see."
335- [p]
336- (let [p (if (:announce p) p (assoc p :announce (media/next-announcement! (:announced p))))]
337- (if-let [ann (settle (:announce p) media/lift-announcement)]
338- (let [path (media/announcement-path ann)
339- p (assoc p :announce nil)]
340- (if (contains? (:peers p) path)
341- p
342- (assoc-in p [:peers path]
343- {:broadcast (media/announcement-broadcast ann)
344- :catalog nil
345- :catalog-pending nil
346- :subscribe nil
347- :media nil
348- :pending nil
349- :decoder (h264/decoder)
350- :self? (= (normalise-path path)
351- (normalise-path (:path p)))})))
352- p)))
353-
354-(defn- pump-peer-audio!
355- "Advance one peer's audio: catalog says the track, then subscribe, then
356- decode into that peer's ring.
357-
358- Separate from the video walk because the two are independent — a peer with
359- a camera off still has a voice, and blocking one on the other is how a
360- muted-video participant goes silent too."
361- [peer channels]
362- (cond
363- (nil? (get-in peer [:catalog :audio-track])) peer
364-
365- (nil? (:audio-media peer))
366- (let [peer (if (:audio-subscribe peer)
367- peer
368- (assoc peer :audio-subscribe
369- (media/subscribe-media! (:broadcast peer)
370- (get-in peer [:catalog :audio-track])
371- (get-in peer [:catalog :audio-container]))))]
372- (if-let [mc (settle (:audio-subscribe peer) nil)]
373- (assoc peer :audio-media mc :audio-subscribe nil
374- :ring (audio/ring channels))
375- peer))
376-
377- :else
378- (let [peer (if (:audio-pending peer)
379- peer
380- (assoc peer :audio-pending (media/next-frame! (:audio-media peer))))
381- got (settle (:audio-pending peer)
382- #(media/lift-media-frame
383- %
384- (fn [ptr len]
385- (when (pos? len)
386- (audio/push-packet! (:ring peer) ptr len)
387- true))))]
388- (if got (assoc peer :audio-pending nil) peer))))
389-
390-(defn- pump-peer!
391- "Walk one peer from announced to a decoded picture.
392-
393- Four states, advanced at most one step per pump so that no peer can hold
394- the loop thread: subscribe the catalog, read it for a video track name and
395- its container, subscribe that track, then decode a frame from it."
396- [peer]
397- (cond
398- ;; 1. The catalog: what tracks this peer has, and in which container.
399- (nil? (:catalog peer))
400- (let [peer (if (:catalog-pending peer)
401- peer
402- (assoc peer :catalog-pending
403- {:sub (media/subscribe-catalog! (:broadcast peer))}))
404- sub (:catalog-pending peer)]
405- (cond
406- (:consumer sub)
407- (let [cp (or (:next sub) (media/next-catalog! (:consumer sub)))]
408- (if-let [cat (settle cp media/lift-catalog)]
409- (let [[track video] (first (:video cat))
410- [atrack aud] (first (:audio cat))]
411- ;; EITHER is enough. Requiring video here is how an audio-only
412- ;; peer waits for ever: with no picture coming the catalog is
413- ;; never accepted, so the audio track named in the same
414- ;; catalog is never read either, and someone with their camera
415- ;; off goes silent as well as dark.
416- (if (or track atrack)
417- (assoc peer :catalog {:track track
418- :container (:container video)
419- :audio-track atrack
420- :audio-container (:container aud)}
421- :catalog-pending nil)
422- ;; Nothing published yet. Ask again.
423- (assoc peer :catalog-pending {:consumer (:consumer sub) :next nil})))
424- (assoc peer :catalog-pending {:consumer (:consumer sub) :next cp})))
425-
426- :else
427- (if-let [cc (settle (:sub sub) nil)]
428- (assoc peer :catalog-pending {:consumer cc :next nil})
429- peer)))
430-
431- ;; No picture from this peer — audio only, or camera off. Not a state
432- ;; to advance out of; their audio walks on its own.
433- (nil? (get-in peer [:catalog :track])) peer
434-
435- ;; 2. Subscribe to the track the catalog named.
436- (nil? (:media peer))
437- (let [peer (if (:subscribe peer)
438- peer
439- (assoc peer :subscribe
440- (media/subscribe-media! (:broadcast peer)
441- (get-in peer [:catalog :track])
442- (get-in peer [:catalog :container]))))]
443- (if-let [mc (settle (:subscribe peer) nil)]
444- (assoc peer :media mc :subscribe nil)
445- peer))
446-
447- ;; 3. A frame.
448- :else
449- (let [peer (if (:pending peer) peer (assoc peer :pending (media/next-frame! (:media peer))))
450- ;; The decode happens INSIDE the lift, while the RustBuffer the
451- ;; payload points into is still alive. Lifting the span out and
452- ;; decoding afterwards reads a buffer that has already been freed,
453- ;; and what comes back from that is not a fault but plausible
454- ;; rubbish.
455- ;;
456- ;; The RGBA pointer it answers belongs to this peer's DECODER, not
457- ;; to the RustBuffer, so it outlives the lift and is good until
458- ;; this peer decodes again.
459- decoded (settle (:pending peer)
460- #(media/lift-media-frame
461- %
462- (fn [ptr len]
463- (when (pos? len)
464- (h264/decode!
465- (:decoder peer) ptr len
466- (fn [rgba w h]
467- (when-not (or (ffi/null? rgba) (zero? w))
468- {:w w :h h :rgba rgba})))))))]
469- (if decoded
470- (assoc peer :pending nil :frame (:payload decoded))
471- (assoc peer :frame nil)))))
472-
473-(defn- pump-mic!
474- "One 20ms frame from the microphone, encoded and published."
475- [{:keys [mic mic-encoder mic-producer muted? pts channels]}]
476- (when (and mic mic-encoder mic-producer (not muted?))
477- (when-let [[pcm _] (mic)]
478- (ffi/with-arena [a]
479- (let [out (ffi/alloc a 4000)
480- n (opus/encode! mic-encoder pcm audio/frame-samples out 4000)]
481- ;; DTX is the encoder saying this frame is silence and need not be
482- ;; sent. Sending it anyway would be bytes for nothing.
483- (when-not (opus/dtx? n)
484- (media/write-video-frame! mic-producer out n @pts)))))))
485-
486-(defn- pump-in!
487- "Discover peers, then advance every one of them."
488- [p]
489- (let [p (pump-announce! p)
490- peers (reduce-kv (fn [m path peer]
491- (assoc m path (-> peer
492- pump-peer!
493- (pump-peer-audio! (:channels p)))))
494- {} (:peers p))]
495- (assoc p
496- :peers peers
497- :frames (into []
498- (keep (fn [[path peer]]
499- (when-let [f (:frame peer)]
500- (assoc f :key (if (:self? peer) "__local__" path)))))
501- peers))))
502-
503-;; --- the pump ----------------------------------------------------------------
504-
505-(defn- note!
506- "Queue a status transition for the next `poll-status!`."
507- [p event]
508- (when-let [q (:status p)] (swap! q conj event))
509- nil)
510-
511-(defn- pump-closed!
512- "Notice the session going away, and why.
513-
514- A clean shutdown settles the future with nothing; a dropped connection
515- raises the MoqError that caused it. Both mean the call is over, and both
516- have to be announced — the difference is only what the person is told."
517- [p]
518- (if-let [w (:closed p)]
519- (if (uniffi/settled? w)
520- (do (try
521- (uniffi/complete! w)
522- (note! p {:code :ended})
523- (catch Exception e
524- (note! p {:code :failed
525- :text (or (:message (ex-data e)) (ex-message e))})))
526- (assoc p :closed nil))
527- p)
528- p))
529-
530-(defn pump!
531- "Drive both halves once. Called from the same timer as `frq.av/pump!`.
532-
533- Everything inside is wrapped: a raise from the media plane is a call that
534- failed, not a UI that stops repainting. glimmer calls this from the loop
535- thread, and an exception escaping here would take the window with it."
536- []
537- (when-let [p @plane]
538- (try
539- (if (:dialling p)
540- ;; Still connecting: nothing to pump but the handshake.
541- (when-let [p' (pump-dialling! p)] (reset! plane p'))
542- (let [p (pump-closed! p)]
543- (pump-out! p)
544- (pump-mic! p)
545- (let [p' (pump-in! p)
546- ;; Mix everyone EXCEPT ourselves: hearing your own voice back is
547- ;; the thing headphones exist to prevent.
548- rings (keep (fn [[_ peer]] (when-not (:self? peer) (:ring peer)))
549- (:peers p'))
550- peak (when (seq rings)
551- (audio/mix-into! rings (:mix p') (:channels p')))]
552- (let [mixed (when peak {:ptr (:mix p')
553- :samples audio/frame-samples
554- :peak peak})]
555- ;; Straight out to the speaker if there is one. A caller with its
556- ;; own output — the terminal backend, a test — reads poll-audio!
557- ;; instead and this stays nil.
558- (when (and mixed (:speaker p') (not (:speaker-muted? p')))
559- ((:play! (:speaker p')) mixed))
560- (reset! plane (assoc p' :mixed mixed))))))
561- (catch Exception e
562- (note! p {:code :failed
563- :text (or (:message (ex-data e)) (ex-message e))})
564- ;; The plane stays UP after a failure. joltmoq did the same, and the
565- ;; reason is the person: tearing it down here would take the message
566- ;; saying what went wrong off the screen along with it. `frq.av`
567- ;; decides when to stop.
568- (reset! plane (assoc p :frames [] :mixed nil)))))
569- nil)
570-
571-(defn poll-frames!
572- "Every frame decoded by the last `pump!`, one per peer at most.
573-
574- Each is {:key :w :h :rgba} with `:key` the peer's broadcast path, or
575- \"__local__\" for our own. `:rgba` is BORROWED — it is that peer's decoder
576- buffer and that peer's next decode overwrites it — so hand each to
577- `vidya/frame-rgba!` and let it go. Copying is the one copy this whole path
578- exists to avoid.
579-
580- Several frames at once is safe precisely because the decoders are
581- per-peer: one shared decoder would make every pointer here alias the last
582- picture decoded."
583- []
584- (:frames @plane))
585-
586-(defn poll-audio!
587- "The mixed 20ms frame from the last `pump!`, or nil.
588-
589- {:ptr :samples :peak} — interleaved int16 ready for `alsa/write!`, and
590- BORROWED like everything else here: the next pump mixes over it."
591- []
592- (:mixed @plane))
593-
594-(defn peers
595- "The broadcast paths currently known, self included."
596- []
597- (some-> @plane :peers keys vec))
598-
599-(defn feed-keys
600- "The keys of everyone currently sending a picture.
601-
602- Not everyone announced: a peer with their camera off has a broadcast and
603- a catalog and no video track, and listing them would leave an empty tile
604- in the wall that never fills. This is joltmoq's `video_keys`, and the
605- test is the same one — is there a video track subscribed."
606- []
607- (into #{}
608- (keep (fn [[path peer]]
609- (when (:media peer)
610- (if (:self? peer) "__local__" path))))
611- (some-> @plane :peers)))
612-
613-(defn poll-status!
614- "Drain what the plane has learned since the last call, oldest first.
615-
616- Each event is {:code :live|:ended|:failed} with `:text` on a failure and
617- `:has-camera?`/`:has-mic?` on :live — which is joltmoq's poll_status,
618- status_text and status_has_camera/_mic in one value instead of four
619- calls that had to be made in the right order.
620-
621- Drained, not sampled: a call can fail and end between two pumps, and a
622- caller shown only the latest would show the wrong one."
623- []
624- (when-let [p @plane]
625- (let [q (:status p)
626- v @q]
627- (reset! q [])
628- v)))
deleted src/frq/avatars.clj +0 -72
deleted file mode 100644
@@ -1,72 +0,0 @@
1-(ns frq.avatars
2- "Profile pictures for the people with an AT Protocol identity behind them.
3-
4- freeq gives an authenticated user their handle as their nick — `nandi.uk`
5- rather than `sleek5209` — so the nick is the lookup, and a nick that is not
6- handle-shaped is a guest with no profile to fetch. One lookup per person
7- however many lines they write, kept on disk between runs like any other
8- picture.
9-
10- The thumbnail preset, and `@png` rather than the CDN's default: the tree
11- backend decodes PNG, and 128×128 is what a 24-point avatar needs."
12- (:require [clojure.string :as str]
13- [frq.profile :as profile]
14- [frq.atproto :as atproto]
15- [jolt.host :as host]
16- [jolt.mvn-http :as http]))
17-
18-(def ^:private directory-host "public.api.bsky.app")
19-
20-(def handle?
21- "Moved to `frq.profile`, which is what asks: whether a nick is worth looking
22- a profile up by is the same question under either compiler."
23- profile/handle?)
24-
25-(def actor profile/actor)
26-
27-(defn cache-dir []
28- (let [xdg (host/getenv "XDG_CACHE_HOME")
29- home (host/getenv "HOME")]
30- (str (if (seq xdg) xdg (str home "/.cache")) "/frq/avatars")))
31-
32-(defn cached-path [handle]
33- (str (cache-dir) "/" (str/replace (str/lower-case handle) #"[^a-z0-9._-]" "_") ".png"))
34-
35-;; handle -> :fetching | :ready | :failed
36-(defonce state (atom {}))
37-
38-(defn path-when-ready [handle]
39- (when (= :ready (get @state handle)) (cached-path handle)))
40-
41-(defn- profile-avatar
42- "The avatar URL on someone's profile, or nil if they have none.
43-
44- The rewrite to a thumbnail is `frq.profile`'s, not this file's: the Flutter
45- half reads the same field off the same body and has no jolt under it."
46- [handle]
47- (profile/avatar-url
48- (atproto/request directory-host
49- (str "/xrpc/app.bsky.actor.getProfile?actor=" handle)
50- nil)))
51-
52-(defn fetch!
53- "Ensure this person's avatar is on disk, in the background. Returns without
54- waiting; `path-when-ready` answers for it afterwards, and `on-change` says
55- when that answer has changed."
56- [handle on-change]
57- (when (and (seq handle) (not (contains? @state handle)))
58- (let [path (cached-path handle)]
59- (if (host/file-exists? path)
60- (do (swap! state assoc handle :ready) (on-change))
61- (do
62- (swap! state assoc handle :fetching)
63- (future
64- (let [ok (try
65- (host/mkdirs! (cache-dir))
66- (http/ensure-native!)
67- (when-let [url (profile-avatar handle)]
68- (and (http/fetch url path)
69- (host/file-exists? path)))
70- (catch Exception _ false))]
71- (swap! state assoc handle (if ok :ready :failed))
72- (on-change))))))))
deleted file mode 100644
@@ -1,72 +0,0 @@
1-(ns frq.avatars
2- "Profile pictures for the people with an AT Protocol identity behind them.
3-
4- freeq gives an authenticated user their handle as their nick — `nandi.uk`
5- rather than `sleek5209` — so the nick is the lookup, and a nick that is not
6- handle-shaped is a guest with no profile to fetch. One lookup per person
7- however many lines they write, kept on disk between runs like any other
8- picture.
9-
10- The thumbnail preset, and `@png` rather than the CDN's default: the tree
11- backend decodes PNG, and 128×128 is what a 24-point avatar needs."
12- (:require [clojure.string :as str]
13- [frq.profile :as profile]
14- [frq.atproto :as atproto]
15- [jolt.host :as host]
16- [jolt.mvn-http :as http]))
17-
18-(def ^:private directory-host "public.api.bsky.app")
19-
20-(def handle?
21- "Moved to `frq.profile`, which is what asks: whether a nick is worth looking
22- a profile up by is the same question under either compiler."
23- profile/handle?)
24-
25-(def actor profile/actor)
26-
27-(defn cache-dir []
28- (let [xdg (host/getenv "XDG_CACHE_HOME")
29- home (host/getenv "HOME")]
30- (str (if (seq xdg) xdg (str home "/.cache")) "/frq/avatars")))
31-
32-(defn cached-path [handle]
33- (str (cache-dir) "/" (str/replace (str/lower-case handle) #"[^a-z0-9._-]" "_") ".png"))
34-
35-;; handle -> :fetching | :ready | :failed
36-(defonce state (atom {}))
37-
38-(defn path-when-ready [handle]
39- (when (= :ready (get @state handle)) (cached-path handle)))
40-
41-(defn- profile-avatar
42- "The avatar URL on someone's profile, or nil if they have none.
43-
44- The rewrite to a thumbnail is `frq.profile`'s, not this file's: the Flutter
45- half reads the same field off the same body and has no jolt under it."
46- [handle]
47- (profile/avatar-url
48- (atproto/request directory-host
49- (str "/xrpc/app.bsky.actor.getProfile?actor=" handle)
50- nil)))
51-
52-(defn fetch!
53- "Ensure this person's avatar is on disk, in the background. Returns without
54- waiting; `path-when-ready` answers for it afterwards, and `on-change` says
55- when that answer has changed."
56- [handle on-change]
57- (when (and (seq handle) (not (contains? @state handle)))
58- (let [path (cached-path handle)]
59- (if (host/file-exists? path)
60- (do (swap! state assoc handle :ready) (on-change))
61- (do
62- (swap! state assoc handle :fetching)
63- (future
64- (let [ok (try
65- (host/mkdirs! (cache-dir))
66- (http/ensure-native!)
67- (when-let [url (profile-avatar handle)]
68- (and (http/fetch url path)
69- (host/file-exists? path)))
70- (catch Exception _ false))]
71- (swap! state assoc handle (if ok :ready :failed))
72- (on-change))))))))
deleted src/frq/capture/alsa.clj +0 -147
deleted file mode 100644
@@ -1,147 +0,0 @@
1-(ns frq.capture.alsa
2- "Audio devices, through libasound.
3-
4- The last of the four C libraries the media plane needs, and the least
5- eventful: ALSA has a flat C API, no vtable and no ioctl arithmetic. What it
6- does have is two APIs, and this binds the small one — `snd_pcm_set_params`
7- configures format, access, channels, rate, resampling and latency in a
8- single call, where the general path is a `snd_pcm_hw_params_t` allocated by
9- the library and poked field by field through thirty accessors. The small one
10- is enough for a call: interleaved S16 at a fixed rate is what Opus wants on
11- one side and what a device gives on the other.
12-
13- READS ARE BLOCKING and counted in FRAMES, not bytes and not samples. A
14- frame is one sample per channel, so 960 frames of stereo S16 is 3840 bytes;
15- passing a byte count asks for four times the audio and blocks for four
16- times as long, which looks like a slow device rather than a bug.
17-
18- RECOVERY IS EXPECTED. An overrun on capture is normal on a busy machine and
19- is not a failure — `snd_pcm_recover` puts the stream back and the next read
20- continues. `read!` does that itself and reports the loss rather than
21- raising, because a dropped buffer is a thing a call survives."
22- (:require [clojure.string :as str]
23- [jolt.ffi :as ffi]))
24-
25-(def ^:const format-s16-le 2)
26-(def ^:const access-rw-interleaved 3)
27-(def streams {:playback 0 :capture 1})
28-
29-(ffi/defcfn raw-open "snd_pcm_open" [:pointer :string :int :int] :int)
30-(ffi/defcfn raw-close "snd_pcm_close" [:pointer] :int)
31-(ffi/defcfn raw-set-params "snd_pcm_set_params"
32- [:pointer :int :int :uint :uint :int :uint] :int)
33-(ffi/defcfn raw-readi "snd_pcm_readi" [:pointer :pointer :uint64] :int64)
34-(ffi/defcfn raw-writei "snd_pcm_writei" [:pointer :pointer :uint64] :int64)
35-(ffi/defcfn raw-prepare "snd_pcm_prepare" [:pointer] :int)
36-(ffi/defcfn raw-recover "snd_pcm_recover" [:pointer :int :int] :int)
37-(ffi/defcfn raw-drain "snd_pcm_drain" [:pointer] :int)
38-(ffi/defcfn strerror "snd_strerror" [:int] :string)
39-
40-(defn- check! [rc what]
41- (if (neg? rc)
42- (throw (ex-info (str "alsa: " what ": " (strerror rc)) {:code rc :op what}))
43- rc))
44-
45-(defn open-pcm
46- "Open a PCM by ALSA name — \"default\", \"hw:1,0\", or \"null\" for a device
47- that swallows everything and always exists.
48-
49- `latency-us` is what ALSA is asked to aim for; it picks buffer and period
50- sizes to suit and may not hit it exactly."
51- [name stream {:keys [rate channels latency-us]
52- :or {rate 48000 channels 1 latency-us 20000}}]
53- (ffi/with-arena [a]
54- (let [out (ffi/alloc a 8)
55- dir (or (streams stream)
56- (throw (ex-info "unknown pcm stream" {:got stream})))]
57- (check! (raw-open out name dir 0) (str "open " name))
58- (let [pcm (ffi/read out :pointer)]
59- (check! (raw-set-params pcm format-s16-le access-rw-interleaved
60- channels rate 1 latency-us)
61- "set_params")
62- pcm))))
63-
64-(defn read!
65- "Read up to `frames` frames of interleaved S16 into `buf`.
66-
67- Answers {:frames n} on a normal read, or {:frames n :recovered true} when
68- an overrun was absorbed. Frames, not bytes — see the namespace docstring."
69- [pcm buf frames]
70- (let [n (raw-readi pcm buf frames)]
71- (if (neg? n)
72- (do (check! (raw-recover pcm n 1) "recover")
73- (let [n2 (raw-readi pcm buf frames)]
74- {:frames (max 0 (check! n2 "readi")) :recovered true}))
75- {:frames n})))
76-
77-(defn write!
78- "Write `frames` frames of interleaved S16 from `buf`."
79- [pcm buf frames]
80- (let [n (raw-writei pcm buf frames)]
81- (if (neg? n)
82- (do (check! (raw-recover pcm n 1) "recover")
83- {:frames (max 0 (check! (raw-writei pcm buf frames) "writei"))
84- :recovered true})
85- {:frames n})))
86-
87-;; --- enumeration -------------------------------------------------------------
88-;; snd_device_name_hint answers a NULL-terminated array of opaque hints, and
89-;; each field of a hint is a char* the CALLER frees. Both facts shape this:
90-;; the array is walked a pointer at a time, and every string is read through
91-;; ptr->string and then released, because declaring it :string would hand
92-;; back a jolt string and lose the address that has to be freed.
93-
94-(ffi/defcfn raw-name-hint "snd_device_name_hint" [:int :string :pointer] :int)
95-(ffi/defcfn raw-get-hint "snd_device_name_get_hint" [:pointer :string] :pointer)
96-(ffi/defcfn raw-free-hint "snd_device_name_free_hint" [:pointer] :int)
97-
98-(defn- hint-field [hint id]
99- (let [p (raw-get-hint hint id)]
100- (when-not (ffi/null? p)
101- (let [s (ffi/ptr->string p)]
102- (ffi/free p)
103- s))))
104-
105-(defn devices
106- "PCMs ALSA is willing to name, as {:id :name :default?}.
107-
108- `direction` is :capture or :playback, and it filters on the hint's IOID:
109- a device with no IOID does both, which is most of them, so absence means
110- yes rather than no.
111-
112- The `null` PCM is dropped. It is always present, it swallows everything,
113- and a person picking it from a list of microphones would get silence that
114- looks exactly like a broken device."
115- [direction]
116- (let [want (case direction :capture "Input" :playback "Output")]
117- (ffi/with-arena [a]
118- (let [out (ffi/alloc a 8)]
119- (when (neg? (raw-name-hint -1 "pcm" out))
120- (throw (ex-info "alsa: could not list devices" {})))
121- (let [arr (ffi/read out :pointer)]
122- (if (ffi/null? arr)
123- []
124- (try
125- (loop [i 0 acc []]
126- (let [hint (ffi/read (+ arr (* 8 i)) :pointer)]
127- (if (ffi/null? hint)
128- acc
129- (let [name (hint-field hint "NAME")
130- desc (hint-field hint "DESC")
131- ioid (hint-field hint "IOID")]
132- (recur (inc i)
133- (if (and name
134- (not= "null" name)
135- (or (nil? ioid) (= ioid want)))
136- (conj acc {:id name
137- ;; DESC is multi-line: a friendly
138- ;; name, then the card detail.
139- :name (or (some-> desc str/split-lines first)
140- name)
141- :default? (= "default" name)})
142- acc))))))
143- (finally (raw-free-hint arr)))))))))
144-
145-(defn prepare! [pcm] (check! (raw-prepare pcm) "prepare") nil)
146-(defn drain! [pcm] (check! (raw-drain pcm) "drain") nil)
147-(defn close! [pcm] (raw-close pcm) nil)
deleted file mode 100644
@@ -1,147 +0,0 @@
1-(ns frq.capture.alsa
2- "Audio devices, through libasound.
3-
4- The last of the four C libraries the media plane needs, and the least
5- eventful: ALSA has a flat C API, no vtable and no ioctl arithmetic. What it
6- does have is two APIs, and this binds the small one — `snd_pcm_set_params`
7- configures format, access, channels, rate, resampling and latency in a
8- single call, where the general path is a `snd_pcm_hw_params_t` allocated by
9- the library and poked field by field through thirty accessors. The small one
10- is enough for a call: interleaved S16 at a fixed rate is what Opus wants on
11- one side and what a device gives on the other.
12-
13- READS ARE BLOCKING and counted in FRAMES, not bytes and not samples. A
14- frame is one sample per channel, so 960 frames of stereo S16 is 3840 bytes;
15- passing a byte count asks for four times the audio and blocks for four
16- times as long, which looks like a slow device rather than a bug.
17-
18- RECOVERY IS EXPECTED. An overrun on capture is normal on a busy machine and
19- is not a failure — `snd_pcm_recover` puts the stream back and the next read
20- continues. `read!` does that itself and reports the loss rather than
21- raising, because a dropped buffer is a thing a call survives."
22- (:require [clojure.string :as str]
23- [jolt.ffi :as ffi]))
24-
25-(def ^:const format-s16-le 2)
26-(def ^:const access-rw-interleaved 3)
27-(def streams {:playback 0 :capture 1})
28-
29-(ffi/defcfn raw-open "snd_pcm_open" [:pointer :string :int :int] :int)
30-(ffi/defcfn raw-close "snd_pcm_close" [:pointer] :int)
31-(ffi/defcfn raw-set-params "snd_pcm_set_params"
32- [:pointer :int :int :uint :uint :int :uint] :int)
33-(ffi/defcfn raw-readi "snd_pcm_readi" [:pointer :pointer :uint64] :int64)
34-(ffi/defcfn raw-writei "snd_pcm_writei" [:pointer :pointer :uint64] :int64)
35-(ffi/defcfn raw-prepare "snd_pcm_prepare" [:pointer] :int)
36-(ffi/defcfn raw-recover "snd_pcm_recover" [:pointer :int :int] :int)
37-(ffi/defcfn raw-drain "snd_pcm_drain" [:pointer] :int)
38-(ffi/defcfn strerror "snd_strerror" [:int] :string)
39-
40-(defn- check! [rc what]
41- (if (neg? rc)
42- (throw (ex-info (str "alsa: " what ": " (strerror rc)) {:code rc :op what}))
43- rc))
44-
45-(defn open-pcm
46- "Open a PCM by ALSA name — \"default\", \"hw:1,0\", or \"null\" for a device
47- that swallows everything and always exists.
48-
49- `latency-us` is what ALSA is asked to aim for; it picks buffer and period
50- sizes to suit and may not hit it exactly."
51- [name stream {:keys [rate channels latency-us]
52- :or {rate 48000 channels 1 latency-us 20000}}]
53- (ffi/with-arena [a]
54- (let [out (ffi/alloc a 8)
55- dir (or (streams stream)
56- (throw (ex-info "unknown pcm stream" {:got stream})))]
57- (check! (raw-open out name dir 0) (str "open " name))
58- (let [pcm (ffi/read out :pointer)]
59- (check! (raw-set-params pcm format-s16-le access-rw-interleaved
60- channels rate 1 latency-us)
61- "set_params")
62- pcm))))
63-
64-(defn read!
65- "Read up to `frames` frames of interleaved S16 into `buf`.
66-
67- Answers {:frames n} on a normal read, or {:frames n :recovered true} when
68- an overrun was absorbed. Frames, not bytes — see the namespace docstring."
69- [pcm buf frames]
70- (let [n (raw-readi pcm buf frames)]
71- (if (neg? n)
72- (do (check! (raw-recover pcm n 1) "recover")
73- (let [n2 (raw-readi pcm buf frames)]
74- {:frames (max 0 (check! n2 "readi")) :recovered true}))
75- {:frames n})))
76-
77-(defn write!
78- "Write `frames` frames of interleaved S16 from `buf`."
79- [pcm buf frames]
80- (let [n (raw-writei pcm buf frames)]
81- (if (neg? n)
82- (do (check! (raw-recover pcm n 1) "recover")
83- {:frames (max 0 (check! (raw-writei pcm buf frames) "writei"))
84- :recovered true})
85- {:frames n})))
86-
87-;; --- enumeration -------------------------------------------------------------
88-;; snd_device_name_hint answers a NULL-terminated array of opaque hints, and
89-;; each field of a hint is a char* the CALLER frees. Both facts shape this:
90-;; the array is walked a pointer at a time, and every string is read through
91-;; ptr->string and then released, because declaring it :string would hand
92-;; back a jolt string and lose the address that has to be freed.
93-
94-(ffi/defcfn raw-name-hint "snd_device_name_hint" [:int :string :pointer] :int)
95-(ffi/defcfn raw-get-hint "snd_device_name_get_hint" [:pointer :string] :pointer)
96-(ffi/defcfn raw-free-hint "snd_device_name_free_hint" [:pointer] :int)
97-
98-(defn- hint-field [hint id]
99- (let [p (raw-get-hint hint id)]
100- (when-not (ffi/null? p)
101- (let [s (ffi/ptr->string p)]
102- (ffi/free p)
103- s))))
104-
105-(defn devices
106- "PCMs ALSA is willing to name, as {:id :name :default?}.
107-
108- `direction` is :capture or :playback, and it filters on the hint's IOID:
109- a device with no IOID does both, which is most of them, so absence means
110- yes rather than no.
111-
112- The `null` PCM is dropped. It is always present, it swallows everything,
113- and a person picking it from a list of microphones would get silence that
114- looks exactly like a broken device."
115- [direction]
116- (let [want (case direction :capture "Input" :playback "Output")]
117- (ffi/with-arena [a]
118- (let [out (ffi/alloc a 8)]
119- (when (neg? (raw-name-hint -1 "pcm" out))
120- (throw (ex-info "alsa: could not list devices" {})))
121- (let [arr (ffi/read out :pointer)]
122- (if (ffi/null? arr)
123- []
124- (try
125- (loop [i 0 acc []]
126- (let [hint (ffi/read (+ arr (* 8 i)) :pointer)]
127- (if (ffi/null? hint)
128- acc
129- (let [name (hint-field hint "NAME")
130- desc (hint-field hint "DESC")
131- ioid (hint-field hint "IOID")]
132- (recur (inc i)
133- (if (and name
134- (not= "null" name)
135- (or (nil? ioid) (= ioid want)))
136- (conj acc {:id name
137- ;; DESC is multi-line: a friendly
138- ;; name, then the card detail.
139- :name (or (some-> desc str/split-lines first)
140- name)
141- :default? (= "default" name)})
142- acc))))))
143- (finally (raw-free-hint arr)))))))))
144-
145-(defn prepare! [pcm] (check! (raw-prepare pcm) "prepare") nil)
146-(defn drain! [pcm] (check! (raw-drain pcm) "drain") nil)
147-(defn close! [pcm] (raw-close pcm) nil)
deleted src/frq/capture/source.clj +0 -103
deleted file mode 100644
@@ -1,103 +0,0 @@
1-(ns frq.capture.source
2- "Real devices as the thunks `frq.av.plane` takes.
3-
4- The plane asks for `:source` and `:mic` — functions answering [pointer
5- length] or nil — and does not know or care where the pixels came from.
6- This namespace is where a camera and a microphone become those functions,
7- and keeping it separate is what lets the plane be tested with a synthetic
8- frame and run with a real one without a line of it changing.
9-
10- Both are NON-BLOCKING, because the plane is pumped from glimmer's loop
11- thread. A blocking read here would hold the whole UI for as long as the
12- device felt like taking, and on a device that has stopped producing, for
13- ever. Nothing ready is a nil, not a wait."
14- (:require [frq.capture.alsa :as alsa]
15- [frq.capture.v4l2 :as v4l2]
16- [frq.av.audio :as audio]
17- [jolt.ffi :as ffi]))
18-
19-(ffi/defcfn yuyv->i420 "frq_yuyv_to_i420" [:pointer :pointer :int :int] :void)
20-
21-;; --- the camera --------------------------------------------------------------
22-
23-(defn camera
24- "Open `path` and answer {:source :close! :width :height}.
25-
26- YUYV is asked for because every UVC camera has it and openh264 wants I420,
27- which is one pass away. MJPEG would be smaller on the wire between camera
28- and kernel but needs a JPEG decoder in front of the converter, and this
29- path already has enough moving parts.
30-
31- V4L2 NEGOTIATES: the size that comes back is not necessarily the size
32- asked for, so the answer carries what the driver actually chose and the
33- encoder should be opened from that rather than from the request."
34- [path {:keys [width height buffers] :or {width 640 height 480 buffers 4}}]
35- (let [fd (v4l2/open-device path)
36- caps (v4l2/capabilities fd)]
37- (when-not (:capture? caps)
38- (v4l2/close-device! fd [])
39- (throw (ex-info "v4l2: not a capture device" {:path path :caps caps})))
40- (let [fmt (v4l2/set-format! fd width height :yuyv)
41- w (:width fmt) h (:height fmt)
42- n (v4l2/request-buffers! fd buffers)
43- bufs (v4l2/map-buffers! fd n)
44- ;; One I420 frame, allocated once. The converter writes here and
45- ;; the encoder reads here; a fresh allocation per frame would put
46- ;; the allocator in the capture path.
47- i420 (ffi/alloc (+ (* w h) (* 2 (quot (* w h) 4))))]
48- (doseq [{:keys [index]} bufs] (v4l2/queue! fd index))
49- (v4l2/stream-on! fd)
50- {:width w
51- :height h
52- :source (fn []
53- (v4l2/try-frame
54- fd bufs
55- (fn [ptr _len]
56- (yuyv->i420 ptr i420 w h)
57- [i420 (+ (* w h) (* 2 (quot (* w h) 4)))])))
58- :close! (fn []
59- (try (v4l2/stream-off! fd) (catch Exception _ nil))
60- (v4l2/close-device! fd bufs)
61- (ffi/free i420))})))
62-
63-;; --- the microphone ----------------------------------------------------------
64-
65-(defn microphone
66- "Open an ALSA capture PCM and answer {:mic :close!}.
67-
68- One Opus frame at a time — 20ms, `audio/frame-samples` per channel —
69- because that is the unit the encoder takes and the jitter buffer holds.
70- Reading a different amount would mean carrying a remainder between pumps,
71- which is a buffer this does not need to own.
72-
73- A short read answers nil rather than a partial frame. Opus encodes whole
74- frames, and padding a short one with silence puts a click in the audio
75- every time the device is a little behind."
76- [name {:keys [channels] :or {channels 1}}]
77- (let [pcm (alsa/open-pcm name :capture
78- {:rate audio/sample-rate :channels channels
79- :latency-us 40000})
80- buf (ffi/alloc (* 2 audio/frame-samples channels))]
81- (alsa/prepare! pcm)
82- {:mic (fn []
83- (let [{:keys [frames]} (alsa/read! pcm buf audio/frame-samples)]
84- (when (= frames audio/frame-samples)
85- [buf frames])))
86- :close! (fn [] (alsa/close! pcm) (ffi/free buf))}))
87-
88-;; --- the speaker -------------------------------------------------------------
89-
90-(defn speaker
91- "Open an ALSA playback PCM and answer {:play! :close!}.
92-
93- `play!` takes what `frq.av.plane/poll-audio!` answers and writes it. It is
94- the one place in this port where a short write is silently fine: ALSA
95- accepting fewer frames than offered means the device's buffer is full,
96- which for playback means we are ahead rather than behind."
97- [name {:keys [channels] :or {channels 1}}]
98- (let [pcm (alsa/open-pcm name :playback
99- {:rate audio/sample-rate :channels channels
100- :latency-us 40000})]
101- {:play! (fn [{:keys [ptr samples]}]
102- (when ptr (alsa/write! pcm ptr samples)))
103- :close! (fn [] (alsa/close! pcm))}))
deleted file mode 100644
@@ -1,103 +0,0 @@
1-(ns frq.capture.source
2- "Real devices as the thunks `frq.av.plane` takes.
3-
4- The plane asks for `:source` and `:mic` — functions answering [pointer
5- length] or nil — and does not know or care where the pixels came from.
6- This namespace is where a camera and a microphone become those functions,
7- and keeping it separate is what lets the plane be tested with a synthetic
8- frame and run with a real one without a line of it changing.
9-
10- Both are NON-BLOCKING, because the plane is pumped from glimmer's loop
11- thread. A blocking read here would hold the whole UI for as long as the
12- device felt like taking, and on a device that has stopped producing, for
13- ever. Nothing ready is a nil, not a wait."
14- (:require [frq.capture.alsa :as alsa]
15- [frq.capture.v4l2 :as v4l2]
16- [frq.av.audio :as audio]
17- [jolt.ffi :as ffi]))
18-
19-(ffi/defcfn yuyv->i420 "frq_yuyv_to_i420" [:pointer :pointer :int :int] :void)
20-
21-;; --- the camera --------------------------------------------------------------
22-
23-(defn camera
24- "Open `path` and answer {:source :close! :width :height}.
25-
26- YUYV is asked for because every UVC camera has it and openh264 wants I420,
27- which is one pass away. MJPEG would be smaller on the wire between camera
28- and kernel but needs a JPEG decoder in front of the converter, and this
29- path already has enough moving parts.
30-
31- V4L2 NEGOTIATES: the size that comes back is not necessarily the size
32- asked for, so the answer carries what the driver actually chose and the
33- encoder should be opened from that rather than from the request."
34- [path {:keys [width height buffers] :or {width 640 height 480 buffers 4}}]
35- (let [fd (v4l2/open-device path)
36- caps (v4l2/capabilities fd)]
37- (when-not (:capture? caps)
38- (v4l2/close-device! fd [])
39- (throw (ex-info "v4l2: not a capture device" {:path path :caps caps})))
40- (let [fmt (v4l2/set-format! fd width height :yuyv)
41- w (:width fmt) h (:height fmt)
42- n (v4l2/request-buffers! fd buffers)
43- bufs (v4l2/map-buffers! fd n)
44- ;; One I420 frame, allocated once. The converter writes here and
45- ;; the encoder reads here; a fresh allocation per frame would put
46- ;; the allocator in the capture path.
47- i420 (ffi/alloc (+ (* w h) (* 2 (quot (* w h) 4))))]
48- (doseq [{:keys [index]} bufs] (v4l2/queue! fd index))
49- (v4l2/stream-on! fd)
50- {:width w
51- :height h
52- :source (fn []
53- (v4l2/try-frame
54- fd bufs
55- (fn [ptr _len]
56- (yuyv->i420 ptr i420 w h)
57- [i420 (+ (* w h) (* 2 (quot (* w h) 4)))])))
58- :close! (fn []
59- (try (v4l2/stream-off! fd) (catch Exception _ nil))
60- (v4l2/close-device! fd bufs)
61- (ffi/free i420))})))
62-
63-;; --- the microphone ----------------------------------------------------------
64-
65-(defn microphone
66- "Open an ALSA capture PCM and answer {:mic :close!}.
67-
68- One Opus frame at a time — 20ms, `audio/frame-samples` per channel —
69- because that is the unit the encoder takes and the jitter buffer holds.
70- Reading a different amount would mean carrying a remainder between pumps,
71- which is a buffer this does not need to own.
72-
73- A short read answers nil rather than a partial frame. Opus encodes whole
74- frames, and padding a short one with silence puts a click in the audio
75- every time the device is a little behind."
76- [name {:keys [channels] :or {channels 1}}]
77- (let [pcm (alsa/open-pcm name :capture
78- {:rate audio/sample-rate :channels channels
79- :latency-us 40000})
80- buf (ffi/alloc (* 2 audio/frame-samples channels))]
81- (alsa/prepare! pcm)
82- {:mic (fn []
83- (let [{:keys [frames]} (alsa/read! pcm buf audio/frame-samples)]
84- (when (= frames audio/frame-samples)
85- [buf frames])))
86- :close! (fn [] (alsa/close! pcm) (ffi/free buf))}))
87-
88-;; --- the speaker -------------------------------------------------------------
89-
90-(defn speaker
91- "Open an ALSA playback PCM and answer {:play! :close!}.
92-
93- `play!` takes what `frq.av.plane/poll-audio!` answers and writes it. It is
94- the one place in this port where a short write is silently fine: ALSA
95- accepting fewer frames than offered means the device's buffer is full,
96- which for playback means we are ahead rather than behind."
97- [name {:keys [channels] :or {channels 1}}]
98- (let [pcm (alsa/open-pcm name :playback
99- {:rate audio/sample-rate :channels channels
100- :latency-us 40000})]
101- {:play! (fn [{:keys [ptr samples]}]
102- (when ptr (alsa/write! pcm ptr samples)))
103- :close! (fn [] (alsa/close! pcm))}))
deleted src/frq/capture/v4l2.clj +0 -338
deleted file mode 100644
@@ -1,338 +0,0 @@
1-(ns frq.capture.v4l2
2- "The camera, behind an ioctl.
3-
4- V4L2 is the one part of the media plane that was never going to need a
5- library: it is `open`, `ioctl`, `mmap` and a handful of structs, all of it
6- in libc and the kernel. What it needs instead is EXACTNESS. Every request
7- number below encodes the size of the struct it carries, so a layout that is
8- one byte wrong does not read a wrong field — it makes a request number the
9- kernel has never heard of, and the driver answers ENOTTY for an ioctl that
10- plainly exists.
11-
12- That is why `frq.capture.v4l2-test/check-layouts` compares every size and
13- offset here against what a C compiler says about the running kernel's
14- headers, rather than trusting that they were transcribed correctly.
15-
16- A CAPTURED FRAME IS BORROWED. `with-frame` hands the mmap'd buffer straight
17- to its callback and requeues it afterwards; the pointer is the driver's, it
18- is valid until the buffer goes back, and nothing here copies it. That is
19- `frq.av`'s rule arriving from the other end — capture buffer to encoder as a
20- pointer, the way the decoder's buffer already reaches a texture."
21- (:require [clojure.string :as str]
22- [jolt.ffi :as ffi]))
23-
24-;; --- libc --------------------------------------------------------------------
25-
26-;; Not variadic: open(2)'s third argument exists only for O_CREAT, which a
27-;; device node never wants. Declaring the tail would oblige every call to
28-;; pass a mode that the kernel then ignores.
29-(ffi/defcfn c-open "open" [:string :int] :int)
30-(ffi/defcfn c-close "close" [:int] :int)
31-;; ioctl's third argument is whatever the request says it is; for every
32-;; request here it is a pointer, and the declared tail costs no compile.
33-(ffi/defcfn c-ioctl "ioctl" [:int :uint64 :& :pointer] :int)
34-(ffi/defcfn c-mmap "mmap" [:pointer :uint64 :int :int :int :int64] :pointer)
35-(ffi/defcfn c-munmap "munmap" [:pointer :uint64] :int)
36-
37-(def ^:const o-rdwr 2)
38-;; O_NONBLOCK, because the plane is pumped. A blocking DQBUF would hold
39-;; glimmer's loop thread for up to a frame interval every pump, and on a
40-;; camera that stops producing, for ever.
41-(def ^:const o-nonblock 2048)
42-(def ^:const prot-read 1)
43-(def ^:const prot-write 2)
44-(def ^:const map-shared 1)
45-
46-;; --- the requests ------------------------------------------------------------
47-;; _IOC(dir, type, nr, size) = dir<<30 | size<<16 | 'V'<<8 | nr, with dir 1
48-;; for write, 2 for read and 3 for both. The size in there is the struct's,
49-;; which is why the layouts below are checked rather than assumed.
50-
51-(def ^:const VIDIOC_QUERYCAP 2154321408)
52-(def ^:const VIDIOC_S_FMT 3234878981)
53-(def ^:const VIDIOC_REQBUFS 3222558216)
54-(def ^:const VIDIOC_QUERYBUF 3227014665)
55-(def ^:const VIDIOC_QBUF 3227014671)
56-(def ^:const VIDIOC_DQBUF 3227014673)
57-(def ^:const VIDIOC_STREAMON 1074026002)
58-(def ^:const VIDIOC_STREAMOFF 1074026003)
59-
60-(def ^:const buf-type-video-capture 1)
61-(def ^:const memory-mmap 1)
62-(def ^:const field-none 1)
63-(def ^:const cap-video-capture 1)
64-(def ^:const cap-streaming 67108864)
65-
66-(def pixel-formats
67- "V4L2 fourccs, as the kernel packs them."
68- {:yuyv 1448695129 :mjpeg 1196444237 :yuv420 842093913})
69-
70-;; --- the structs -------------------------------------------------------------
71-;; Padded to the kernel's sizes rather than described field by field: what
72-;; matters is the total size (it is in the request number) and the offsets of
73-;; the fields actually read. A union is spelled as the reserved block it
74-;; occupies, which is what `v4l2_format` mostly is.
75-
76-(def capability
77- (ffi/layout [:struct [[:driver [:array :uint8 16]]
78- [:card [:array :uint8 32]]
79- [:bus-info [:array :uint8 32]]
80- [:version :uint32]
81- [:capabilities :uint32]
82- [:device-caps :uint32]
83- [:reserved [:array :uint32 3]]]]))
84-
85-(def format-pix
86- ;; v4l2_format is 208 bytes: a type, four bytes of padding, then a union
87- ;; whose largest member decides the rest. Only the pix arm is described;
88- ;; the tail is the union's remaining bytes.
89- (ffi/layout [:struct [[:type :uint32]
90- [:pad :uint32]
91- [:width :uint32]
92- [:height :uint32]
93- [:pixelformat :uint32]
94- [:field :uint32]
95- [:bytesperline :uint32]
96- [:sizeimage :uint32]
97- [:colorspace :uint32]
98- [:priv :uint32]
99- [:flags :uint32]
100- [:enc :uint32]
101- [:quantization :uint32]
102- [:xfer-func :uint32]
103- [:rest [:array :uint8 152]]]]))
104-
105-(def requestbuffers
106- (ffi/layout [:struct [[:count :uint32]
107- [:type :uint32]
108- [:memory :uint32]
109- [:capabilities :uint32]
110- [:flags :uint8]
111- [:reserved [:array :uint8 3]]]]))
112-
113-(def buffer
114- ;; 88 bytes. `timestamp` is a struct timeval at 24, `m` is a union at 64
115- ;; whose first member is the mmap offset, and `memory` sits at 60.
116- (ffi/layout [:struct [[:index :uint32]
117- [:type :uint32]
118- [:bytesused :uint32]
119- [:flags :uint32]
120- [:field :uint32]
121- [:pad0 :uint32]
122- [:tv-sec :int64]
123- [:tv-usec :int64]
124- [:timecode [:array :uint8 16]]
125- [:sequence :uint32]
126- [:memory :uint32]
127- [:offset :uint32]
128- [:pad1 :uint32]
129- [:length :uint32]
130- [:reserved2 :uint32]
131- [:request-fd :int32]
132- [:pad2 :uint32]]]))
133-
134-;; --- opening -----------------------------------------------------------------
135-
136-(defn- ioctl! [fd req p what]
137- (let [rc (c-ioctl fd req p)]
138- (when (neg? rc)
139- (throw (ex-info (str "v4l2: " what " failed") {:errno (ffi/errno) :op what})))
140- rc))
141-
142-(defn open-device
143- "Open a camera and answer its fd. Non-blocking: see `o-nonblock`."
144- [path]
145- (let [fd (c-open path (bit-or o-rdwr o-nonblock))]
146- (when (neg? fd)
147- (throw (ex-info (str "v4l2: cannot open " path) {:errno (ffi/errno) :path path})))
148- fd))
149-
150-(defn capabilities
151- "What the device says it can do. `:capture?` and `:streaming?` are the two
152- that decide whether the rest of this namespace applies to it."
153- [fd]
154- (ffi/with-arena [a]
155- (let [p (ffi/alloc a (ffi/layout-size capability))]
156- (ioctl! fd VIDIOC_QUERYCAP p "QUERYCAP")
157- (let [caps (ffi/read-field p capability [:capabilities])
158- dev (ffi/read-field p capability [:device-caps])
159- ;; device_caps describes THIS node; capabilities describes the
160- ;; whole device, which on a multi-node camera is not the same
161- ;; thing and is the usual reason a /dev/video1 refuses to stream.
162- eff (if (zero? dev) caps dev)]
163- {:capabilities caps
164- :device-caps dev
165- :capture? (pos? (bit-and eff cap-video-capture))
166- :streaming? (pos? (bit-and eff cap-streaming))}))))
167-
168-(defn set-format!
169- "Ask for a size and pixel format; answers what the driver actually chose.
170-
171- V4L2 negotiates rather than obeys — a driver may answer a different size or
172- a different format entirely, and the returned map is the truth."
173- [fd width height pixel-format]
174- (ffi/with-arena [a]
175- (let [p (ffi/alloc a (ffi/layout-size format-pix))
176- fourcc (or (pixel-formats pixel-format) pixel-format)]
177- (ffi/write p format-pix {:type buf-type-video-capture :pad 0
178- :width width :height height
179- :pixelformat fourcc :field field-none
180- :bytesperline 0 :sizeimage 0 :colorspace 0
181- :priv 0 :flags 0 :enc 0 :quantization 0
182- :xfer-func 0 :rest (vec (repeat 152 0))})
183- (ioctl! fd VIDIOC_S_FMT p "S_FMT")
184- {:width (ffi/read-field p format-pix [:width])
185- :height (ffi/read-field p format-pix [:height])
186- :pixelformat (ffi/read-field p format-pix [:pixelformat])
187- :bytesperline (ffi/read-field p format-pix [:bytesperline])
188- :sizeimage (ffi/read-field p format-pix [:sizeimage])})))
189-
190-;; --- buffers -----------------------------------------------------------------
191-
192-(defn request-buffers!
193- "Ask the driver for `n` mmap buffers; answers how many it granted."
194- [fd n]
195- (ffi/with-arena [a]
196- (let [p (ffi/alloc a (ffi/layout-size requestbuffers))]
197- (ffi/write p requestbuffers {:count n :type buf-type-video-capture
198- :memory memory-mmap :capabilities 0
199- :flags 0 :reserved [0 0 0]})
200- (ioctl! fd VIDIOC_REQBUFS p "REQBUFS")
201- (ffi/read-field p requestbuffers [:count]))))
202-
203-(defn- blank-buffer [p index]
204- (ffi/write p buffer {:index index :type buf-type-video-capture :bytesused 0
205- :flags 0 :field 0 :pad0 0 :tv-sec 0 :tv-usec 0
206- :timecode (vec (repeat 16 0)) :sequence 0
207- :memory memory-mmap :offset 0 :pad1 0 :length 0
208- :reserved2 0 :request-fd 0 :pad2 0}))
209-
210-(defn map-buffers!
211- "QUERYBUF then mmap each buffer; answers a vector of {:ptr :len :index}."
212- [fd n]
213- (ffi/with-arena [a]
214- (let [p (ffi/alloc a (ffi/layout-size buffer))]
215- (mapv (fn [i]
216- (blank-buffer p i)
217- (ioctl! fd VIDIOC_QUERYBUF p "QUERYBUF")
218- (let [len (ffi/read-field p buffer [:length])
219- off (ffi/read-field p buffer [:offset])
220- ptr (c-mmap ffi/null len (bit-or prot-read prot-write)
221- map-shared fd off)]
222- (when (= ptr -1)
223- (throw (ex-info "v4l2: mmap failed" {:errno (ffi/errno) :index i})))
224- {:index i :ptr ptr :len len}))
225- (range n)))))
226-
227-(defn queue!
228- "Hand a buffer back to the driver."
229- [fd index]
230- (ffi/with-arena [a]
231- (let [p (ffi/alloc a (ffi/layout-size buffer))]
232- (blank-buffer p index)
233- (ioctl! fd VIDIOC_QBUF p "QBUF")))
234- nil)
235-
236-(defn stream-on! [fd]
237- (ffi/with-arena [a]
238- (let [t (ffi/alloc a 4)]
239- (ffi/write t :uint32 buf-type-video-capture)
240- (ioctl! fd VIDIOC_STREAMON t "STREAMON")))
241- nil)
242-
243-(defn stream-off! [fd]
244- (ffi/with-arena [a]
245- (let [t (ffi/alloc a 4)]
246- (ffi/write t :uint32 buf-type-video-capture)
247- (ioctl! fd VIDIOC_STREAMOFF t "STREAMOFF")))
248- nil)
249-
250-(defn- eagain?
251- "EAGAIN (11) — no frame ready. On a non-blocking device that is the normal
252- answer most of the time, not a failure."
253- [errno]
254- (= errno 11))
255-
256-(defn try-frame
257- "Dequeue a frame if one is ready, hand it to `f`, requeue it.
258-
259- Answers what `f` answered, or nil when the camera has nothing yet. Unlike
260- `with-frame` this never raises on an empty queue, which is what a pumped
261- caller needs — on a non-blocking device EAGAIN is the ordinary case."
262- [fd buffers f]
263- (ffi/with-arena [a]
264- (let [p (ffi/alloc a (ffi/layout-size buffer))]
265- (blank-buffer p 0)
266- (let [rc (c-ioctl fd VIDIOC_DQBUF p)]
267- (cond
268- (not (neg? rc))
269- (let [i (ffi/read-field p buffer [:index])
270- n (ffi/read-field p buffer [:bytesused])
271- buf (nth buffers i)]
272- (try (f (:ptr buf) n)
273- (finally (queue! fd i))))
274-
275- (eagain? (ffi/errno)) nil
276-
277- :else
278- (throw (ex-info "v4l2: DQBUF failed" {:errno (ffi/errno)})))))))
279-
280-(defn with-frame
281- "Dequeue a frame, hand it to `f` as [pointer length], and requeue it.
282-
283- The pointer is the driver's mmap'd buffer, valid only until the requeue —
284- which is why the buffer goes back in a `finally` and why `f` is called
285- rather than the span being answered. Copying it here would be the one copy
286- frq.av exists to avoid."
287- [fd buffers f]
288- (ffi/with-arena [a]
289- (let [p (ffi/alloc a (ffi/layout-size buffer))]
290- (blank-buffer p 0)
291- (ioctl! fd VIDIOC_DQBUF p "DQBUF")
292- (let [i (ffi/read-field p buffer [:index])
293- n (ffi/read-field p buffer [:bytesused])
294- buf (nth buffers i)]
295- (try
296- (f (:ptr buf) n)
297- (finally (queue! fd i)))))))
298-
299-(defn devices
300- "Every /dev/videoN that can actually capture, as {:id :name :default?}.
301-
302- Probed rather than listed: reading the directory would mean binding
303- opendir and readdir for a range the kernel keeps small anyway, and a node
304- that cannot be opened is one this process could not have used regardless.
305-
306- The filter matters more than it looks. A single camera usually presents
307- SEVERAL /dev/video nodes — the capture node beside metadata and control
308- nodes — and only one of them streams. Offering the others is how a device
309- list ends up with entries that fail the moment they are picked, so
310- QUERYCAP's device_caps decides rather than the node existing."
311- []
312- (->> (range 64)
313- (keep (fn [i]
314- (let [path (str "/dev/video" i)
315- fd (c-open path o-rdwr)]
316- (when-not (neg? fd)
317- (try
318- (let [caps (capabilities fd)]
319- (when (and (:capture? caps) (:streaming? caps))
320- (ffi/with-arena [a]
321- (let [p (ffi/alloc a (ffi/layout-size capability))]
322- (ioctl! fd VIDIOC_QUERYCAP p "QUERYCAP")
323- (let [card (ffi/read-bytes
324- (+ p (ffi/field-offset capability [:card])) 32)
325- ;; The kernel pads `card` with NULs to 32
326- ;; bytes; a jolt string of the whole field
327- ;; would carry them.
328- name (first (str/split card #"\x00"))]
329- {:id path
330- :name (if (seq name) name path)
331- :default? (zero? i)})))))
332- (finally (c-close fd)))))))
333- vec))
334-
335-(defn close-device! [fd buffers]
336- (doseq [{:keys [ptr len]} buffers] (c-munmap ptr len))
337- (c-close fd)
338- nil)
deleted file mode 100644
@@ -1,338 +0,0 @@
1-(ns frq.capture.v4l2
2- "The camera, behind an ioctl.
3-
4- V4L2 is the one part of the media plane that was never going to need a
5- library: it is `open`, `ioctl`, `mmap` and a handful of structs, all of it
6- in libc and the kernel. What it needs instead is EXACTNESS. Every request
7- number below encodes the size of the struct it carries, so a layout that is
8- one byte wrong does not read a wrong field — it makes a request number the
9- kernel has never heard of, and the driver answers ENOTTY for an ioctl that
10- plainly exists.
11-
12- That is why `frq.capture.v4l2-test/check-layouts` compares every size and
13- offset here against what a C compiler says about the running kernel's
14- headers, rather than trusting that they were transcribed correctly.
15-
16- A CAPTURED FRAME IS BORROWED. `with-frame` hands the mmap'd buffer straight
17- to its callback and requeues it afterwards; the pointer is the driver's, it
18- is valid until the buffer goes back, and nothing here copies it. That is
19- `frq.av`'s rule arriving from the other end — capture buffer to encoder as a
20- pointer, the way the decoder's buffer already reaches a texture."
21- (:require [clojure.string :as str]
22- [jolt.ffi :as ffi]))
23-
24-;; --- libc --------------------------------------------------------------------
25-
26-;; Not variadic: open(2)'s third argument exists only for O_CREAT, which a
27-;; device node never wants. Declaring the tail would oblige every call to
28-;; pass a mode that the kernel then ignores.
29-(ffi/defcfn c-open "open" [:string :int] :int)
30-(ffi/defcfn c-close "close" [:int] :int)
31-;; ioctl's third argument is whatever the request says it is; for every
32-;; request here it is a pointer, and the declared tail costs no compile.
33-(ffi/defcfn c-ioctl "ioctl" [:int :uint64 :& :pointer] :int)
34-(ffi/defcfn c-mmap "mmap" [:pointer :uint64 :int :int :int :int64] :pointer)
35-(ffi/defcfn c-munmap "munmap" [:pointer :uint64] :int)
36-
37-(def ^:const o-rdwr 2)
38-;; O_NONBLOCK, because the plane is pumped. A blocking DQBUF would hold
39-;; glimmer's loop thread for up to a frame interval every pump, and on a
40-;; camera that stops producing, for ever.
41-(def ^:const o-nonblock 2048)
42-(def ^:const prot-read 1)
43-(def ^:const prot-write 2)
44-(def ^:const map-shared 1)
45-
46-;; --- the requests ------------------------------------------------------------
47-;; _IOC(dir, type, nr, size) = dir<<30 | size<<16 | 'V'<<8 | nr, with dir 1
48-;; for write, 2 for read and 3 for both. The size in there is the struct's,
49-;; which is why the layouts below are checked rather than assumed.
50-
51-(def ^:const VIDIOC_QUERYCAP 2154321408)
52-(def ^:const VIDIOC_S_FMT 3234878981)
53-(def ^:const VIDIOC_REQBUFS 3222558216)
54-(def ^:const VIDIOC_QUERYBUF 3227014665)
55-(def ^:const VIDIOC_QBUF 3227014671)
56-(def ^:const VIDIOC_DQBUF 3227014673)
57-(def ^:const VIDIOC_STREAMON 1074026002)
58-(def ^:const VIDIOC_STREAMOFF 1074026003)
59-
60-(def ^:const buf-type-video-capture 1)
61-(def ^:const memory-mmap 1)
62-(def ^:const field-none 1)
63-(def ^:const cap-video-capture 1)
64-(def ^:const cap-streaming 67108864)
65-
66-(def pixel-formats
67- "V4L2 fourccs, as the kernel packs them."
68- {:yuyv 1448695129 :mjpeg 1196444237 :yuv420 842093913})
69-
70-;; --- the structs -------------------------------------------------------------
71-;; Padded to the kernel's sizes rather than described field by field: what
72-;; matters is the total size (it is in the request number) and the offsets of
73-;; the fields actually read. A union is spelled as the reserved block it
74-;; occupies, which is what `v4l2_format` mostly is.
75-
76-(def capability
77- (ffi/layout [:struct [[:driver [:array :uint8 16]]
78- [:card [:array :uint8 32]]
79- [:bus-info [:array :uint8 32]]
80- [:version :uint32]
81- [:capabilities :uint32]
82- [:device-caps :uint32]
83- [:reserved [:array :uint32 3]]]]))
84-
85-(def format-pix
86- ;; v4l2_format is 208 bytes: a type, four bytes of padding, then a union
87- ;; whose largest member decides the rest. Only the pix arm is described;
88- ;; the tail is the union's remaining bytes.
89- (ffi/layout [:struct [[:type :uint32]
90- [:pad :uint32]
91- [:width :uint32]
92- [:height :uint32]
93- [:pixelformat :uint32]
94- [:field :uint32]
95- [:bytesperline :uint32]
96- [:sizeimage :uint32]
97- [:colorspace :uint32]
98- [:priv :uint32]
99- [:flags :uint32]
100- [:enc :uint32]
101- [:quantization :uint32]
102- [:xfer-func :uint32]
103- [:rest [:array :uint8 152]]]]))
104-
105-(def requestbuffers
106- (ffi/layout [:struct [[:count :uint32]
107- [:type :uint32]
108- [:memory :uint32]
109- [:capabilities :uint32]
110- [:flags :uint8]
111- [:reserved [:array :uint8 3]]]]))
112-
113-(def buffer
114- ;; 88 bytes. `timestamp` is a struct timeval at 24, `m` is a union at 64
115- ;; whose first member is the mmap offset, and `memory` sits at 60.
116- (ffi/layout [:struct [[:index :uint32]
117- [:type :uint32]
118- [:bytesused :uint32]
119- [:flags :uint32]
120- [:field :uint32]
121- [:pad0 :uint32]
122- [:tv-sec :int64]
123- [:tv-usec :int64]
124- [:timecode [:array :uint8 16]]
125- [:sequence :uint32]
126- [:memory :uint32]
127- [:offset :uint32]
128- [:pad1 :uint32]
129- [:length :uint32]
130- [:reserved2 :uint32]
131- [:request-fd :int32]
132- [:pad2 :uint32]]]))
133-
134-;; --- opening -----------------------------------------------------------------
135-
136-(defn- ioctl! [fd req p what]
137- (let [rc (c-ioctl fd req p)]
138- (when (neg? rc)
139- (throw (ex-info (str "v4l2: " what " failed") {:errno (ffi/errno) :op what})))
140- rc))
141-
142-(defn open-device
143- "Open a camera and answer its fd. Non-blocking: see `o-nonblock`."
144- [path]
145- (let [fd (c-open path (bit-or o-rdwr o-nonblock))]
146- (when (neg? fd)
147- (throw (ex-info (str "v4l2: cannot open " path) {:errno (ffi/errno) :path path})))
148- fd))
149-
150-(defn capabilities
151- "What the device says it can do. `:capture?` and `:streaming?` are the two
152- that decide whether the rest of this namespace applies to it."
153- [fd]
154- (ffi/with-arena [a]
155- (let [p (ffi/alloc a (ffi/layout-size capability))]
156- (ioctl! fd VIDIOC_QUERYCAP p "QUERYCAP")
157- (let [caps (ffi/read-field p capability [:capabilities])
158- dev (ffi/read-field p capability [:device-caps])
159- ;; device_caps describes THIS node; capabilities describes the
160- ;; whole device, which on a multi-node camera is not the same
161- ;; thing and is the usual reason a /dev/video1 refuses to stream.
162- eff (if (zero? dev) caps dev)]
163- {:capabilities caps
164- :device-caps dev
165- :capture? (pos? (bit-and eff cap-video-capture))
166- :streaming? (pos? (bit-and eff cap-streaming))}))))
167-
168-(defn set-format!
169- "Ask for a size and pixel format; answers what the driver actually chose.
170-
171- V4L2 negotiates rather than obeys — a driver may answer a different size or
172- a different format entirely, and the returned map is the truth."
173- [fd width height pixel-format]
174- (ffi/with-arena [a]
175- (let [p (ffi/alloc a (ffi/layout-size format-pix))
176- fourcc (or (pixel-formats pixel-format) pixel-format)]
177- (ffi/write p format-pix {:type buf-type-video-capture :pad 0
178- :width width :height height
179- :pixelformat fourcc :field field-none
180- :bytesperline 0 :sizeimage 0 :colorspace 0
181- :priv 0 :flags 0 :enc 0 :quantization 0
182- :xfer-func 0 :rest (vec (repeat 152 0))})
183- (ioctl! fd VIDIOC_S_FMT p "S_FMT")
184- {:width (ffi/read-field p format-pix [:width])
185- :height (ffi/read-field p format-pix [:height])
186- :pixelformat (ffi/read-field p format-pix [:pixelformat])
187- :bytesperline (ffi/read-field p format-pix [:bytesperline])
188- :sizeimage (ffi/read-field p format-pix [:sizeimage])})))
189-
190-;; --- buffers -----------------------------------------------------------------
191-
192-(defn request-buffers!
193- "Ask the driver for `n` mmap buffers; answers how many it granted."
194- [fd n]
195- (ffi/with-arena [a]
196- (let [p (ffi/alloc a (ffi/layout-size requestbuffers))]
197- (ffi/write p requestbuffers {:count n :type buf-type-video-capture
198- :memory memory-mmap :capabilities 0
199- :flags 0 :reserved [0 0 0]})
200- (ioctl! fd VIDIOC_REQBUFS p "REQBUFS")
201- (ffi/read-field p requestbuffers [:count]))))
202-
203-(defn- blank-buffer [p index]
204- (ffi/write p buffer {:index index :type buf-type-video-capture :bytesused 0
205- :flags 0 :field 0 :pad0 0 :tv-sec 0 :tv-usec 0
206- :timecode (vec (repeat 16 0)) :sequence 0
207- :memory memory-mmap :offset 0 :pad1 0 :length 0
208- :reserved2 0 :request-fd 0 :pad2 0}))
209-
210-(defn map-buffers!
211- "QUERYBUF then mmap each buffer; answers a vector of {:ptr :len :index}."
212- [fd n]
213- (ffi/with-arena [a]
214- (let [p (ffi/alloc a (ffi/layout-size buffer))]
215- (mapv (fn [i]
216- (blank-buffer p i)
217- (ioctl! fd VIDIOC_QUERYBUF p "QUERYBUF")
218- (let [len (ffi/read-field p buffer [:length])
219- off (ffi/read-field p buffer [:offset])
220- ptr (c-mmap ffi/null len (bit-or prot-read prot-write)
221- map-shared fd off)]
222- (when (= ptr -1)
223- (throw (ex-info "v4l2: mmap failed" {:errno (ffi/errno) :index i})))
224- {:index i :ptr ptr :len len}))
225- (range n)))))
226-
227-(defn queue!
228- "Hand a buffer back to the driver."
229- [fd index]
230- (ffi/with-arena [a]
231- (let [p (ffi/alloc a (ffi/layout-size buffer))]
232- (blank-buffer p index)
233- (ioctl! fd VIDIOC_QBUF p "QBUF")))
234- nil)
235-
236-(defn stream-on! [fd]
237- (ffi/with-arena [a]
238- (let [t (ffi/alloc a 4)]
239- (ffi/write t :uint32 buf-type-video-capture)
240- (ioctl! fd VIDIOC_STREAMON t "STREAMON")))
241- nil)
242-
243-(defn stream-off! [fd]
244- (ffi/with-arena [a]
245- (let [t (ffi/alloc a 4)]
246- (ffi/write t :uint32 buf-type-video-capture)
247- (ioctl! fd VIDIOC_STREAMOFF t "STREAMOFF")))
248- nil)
249-
250-(defn- eagain?
251- "EAGAIN (11) — no frame ready. On a non-blocking device that is the normal
252- answer most of the time, not a failure."
253- [errno]
254- (= errno 11))
255-
256-(defn try-frame
257- "Dequeue a frame if one is ready, hand it to `f`, requeue it.
258-
259- Answers what `f` answered, or nil when the camera has nothing yet. Unlike
260- `with-frame` this never raises on an empty queue, which is what a pumped
261- caller needs — on a non-blocking device EAGAIN is the ordinary case."
262- [fd buffers f]
263- (ffi/with-arena [a]
264- (let [p (ffi/alloc a (ffi/layout-size buffer))]
265- (blank-buffer p 0)
266- (let [rc (c-ioctl fd VIDIOC_DQBUF p)]
267- (cond
268- (not (neg? rc))
269- (let [i (ffi/read-field p buffer [:index])
270- n (ffi/read-field p buffer [:bytesused])
271- buf (nth buffers i)]
272- (try (f (:ptr buf) n)
273- (finally (queue! fd i))))
274-
275- (eagain? (ffi/errno)) nil
276-
277- :else
278- (throw (ex-info "v4l2: DQBUF failed" {:errno (ffi/errno)})))))))
279-
280-(defn with-frame
281- "Dequeue a frame, hand it to `f` as [pointer length], and requeue it.
282-
283- The pointer is the driver's mmap'd buffer, valid only until the requeue —
284- which is why the buffer goes back in a `finally` and why `f` is called
285- rather than the span being answered. Copying it here would be the one copy
286- frq.av exists to avoid."
287- [fd buffers f]
288- (ffi/with-arena [a]
289- (let [p (ffi/alloc a (ffi/layout-size buffer))]
290- (blank-buffer p 0)
291- (ioctl! fd VIDIOC_DQBUF p "DQBUF")
292- (let [i (ffi/read-field p buffer [:index])
293- n (ffi/read-field p buffer [:bytesused])
294- buf (nth buffers i)]
295- (try
296- (f (:ptr buf) n)
297- (finally (queue! fd i)))))))
298-
299-(defn devices
300- "Every /dev/videoN that can actually capture, as {:id :name :default?}.
301-
302- Probed rather than listed: reading the directory would mean binding
303- opendir and readdir for a range the kernel keeps small anyway, and a node
304- that cannot be opened is one this process could not have used regardless.
305-
306- The filter matters more than it looks. A single camera usually presents
307- SEVERAL /dev/video nodes — the capture node beside metadata and control
308- nodes — and only one of them streams. Offering the others is how a device
309- list ends up with entries that fail the moment they are picked, so
310- QUERYCAP's device_caps decides rather than the node existing."
311- []
312- (->> (range 64)
313- (keep (fn [i]
314- (let [path (str "/dev/video" i)
315- fd (c-open path o-rdwr)]
316- (when-not (neg? fd)
317- (try
318- (let [caps (capabilities fd)]
319- (when (and (:capture? caps) (:streaming? caps))
320- (ffi/with-arena [a]
321- (let [p (ffi/alloc a (ffi/layout-size capability))]
322- (ioctl! fd VIDIOC_QUERYCAP p "QUERYCAP")
323- (let [card (ffi/read-bytes
324- (+ p (ffi/field-offset capability [:card])) 32)
325- ;; The kernel pads `card` with NULs to 32
326- ;; bytes; a jolt string of the whole field
327- ;; would carry them.
328- name (first (str/split card #"\x00"))]
329- {:id path
330- :name (if (seq name) name path)
331- :default? (zero? i)})))))
332- (finally (c-close fd)))))))
333- vec))
334-
335-(defn close-device! [fd buffers]
336- (doseq [{:keys [ptr len]} buffers] (c-munmap ptr len))
337- (c-close fd)
338- nil)
deleted src/frq/codec/h264.clj +0 -131
deleted file mode 100644
@@ -1,131 +0,0 @@
1-(ns frq.codec.h264
2- "H.264 encoding, through openh264.
3-
4- Not through openh264 DIRECTLY, and the reason is a calling convention.
5- openh264's C API is not flat: `ISVCEncoder` is `const ISVCEncoderVtbl*`, so
6- every method — Initialize, EncodeFrame, Uninitialize — is a function pointer
7- read out of a table hanging off the object. jolt.ffi cannot call one. Chez
8- fixes a foreign procedure's argument and result types when it COMPILES it,
9- which is also why the target has to be a literal C symbol name rather than
10- an address; `jolt/ffi.clj` says so in as many words.
11-
12- So `c/frq_h264.c` walks the vtable and exports five plain symbols, and this
13- namespace binds those. The shim is a hundred lines that change a calling
14- convention; it holds no policy and makes no decisions that belong here.
15-
16- It does flatten the output, because that part cannot sensibly live in jolt:
17- openh264 answers an `SFrameBSInfo` of layers, each with its own NAL count
18- over a shared buffer, and walking that from here would mean reading nested
19- C structs whose layout is openh264's business rather than ours.
20-
21- A FRAME IS BORROWED, both ways. `encode!` takes a pointer to I420 and
22- answers a span into the encoder's own buffer, valid until the next call on
23- the same encoder. That is `frq.av`'s existing contract for video — decoder
24- buffer to texture as a pointer, never copied on this side — and it is why
25- nothing here turns a picture into a jolt value."
26- (:require [jolt.ffi :as ffi]))
27-
28-(ffi/defcfn raw-open "frq_h264_open" [:int :int :int :int :pointer] :int)
29-(ffi/defcfn raw-encode "frq_h264_encode"
30- [:pointer :pointer :int64 :pointer :pointer :pointer] :int)
31-(ffi/defcfn raw-force-keyframe "frq_h264_force_keyframe" [:pointer] :int)
32-(ffi/defcfn raw-close "frq_h264_close" [:pointer] :void)
33-
34-(ffi/defcfn raw-decoder-open "frq_h264_decoder_open" [:pointer] :int)
35-(ffi/defcfn raw-decode-rgba "frq_h264_decode_rgba"
36- [:pointer :pointer :int :pointer :pointer :pointer] :int)
37-(ffi/defcfn raw-decoder-close "frq_h264_decoder_close" [:pointer] :void)
38-
39-(defn i420-size
40- "Bytes in one I420 frame: a luma plane, then two at quarter resolution."
41- [width height]
42- (+ (* width height) (* 2 (quot (* width height) 4))))
43-
44-(defn encoder
45- "Open an encoder. `bitrate` is bits per second.
46-
47- openh264 validates here rather than at the first frame, so an impossible
48- size or bitrate raises now."
49- [{:keys [width height fps bitrate] :or {fps 30 bitrate 1000000}}]
50- (ffi/with-arena [a]
51- (let [out (ffi/alloc a 8)
52- rc (raw-open width height fps bitrate out)]
53- (when-not (zero? rc)
54- (throw (ex-info "openh264: could not open an encoder"
55- {:code rc :width width :height height
56- :fps fps :bitrate bitrate})))
57- (let [h (ffi/read out :pointer)]
58- (when (ffi/null? h)
59- (throw (ex-info "openh264: encoder handle is NULL" {})))
60- h))))
61-
62-(defn encode!
63- "Encode one I420 frame and hand the result to `use-frame`.
64-
65- `i420` is a pointer to `(i420-size w h)` bytes. `use-frame` is called with
66- [pointer length keyframe?] and its value is answered; the span is the
67- encoder's own buffer and is valid only for the duration of that call.
68-
69- A frame openh264 chose to skip calls `use-frame` with a zero length rather
70- than raising — a skip is a decision, not a failure."
71- [enc i420 pts-us use-frame]
72- (ffi/with-arena [a]
73- (let [out (ffi/alloc a 8)
74- len (ffi/alloc a 4)
75- key (ffi/alloc a 4)
76- rc (raw-encode enc i420 pts-us out len key)]
77- (when-not (zero? rc)
78- (throw (ex-info "openh264: encode failed" {:code rc})))
79- (use-frame (ffi/read out :pointer)
80- (ffi/read len :int32)
81- (not (zero? (ffi/read key :int32)))))))
82-
83-(defn force-keyframe!
84- "Make the next frame an IDR — what a newly arrived subscriber needs."
85- [enc]
86- (raw-force-keyframe enc)
87- nil)
88-
89-(defn close! [enc] (raw-close enc) nil)
90-
91-;; --- decoding ----------------------------------------------------------------
92-
93-(defn decoder
94- "Open a decoder. It answers RGBA, not I420 — see `decode!`."
95- []
96- (ffi/with-arena [a]
97- (let [out (ffi/alloc a 8)
98- rc (raw-decoder-open out)]
99- (when-not (zero? rc)
100- (throw (ex-info "openh264: could not open a decoder" {:code rc})))
101- (ffi/read out :pointer))))
102-
103-(defn decode!
104- "Decode one Annex B frame and hand the picture to `use-frame`.
105-
106- `use-frame` is called with [pointer width height] and its value answered.
107- The pointer is tightly packed RGBA in the decoder's own buffer, valid until
108- the next decode on the same decoder.
109-
110- RGBA rather than I420 on purpose: `vidya/frame-rgba!` is where this is
111- going, so the conversion happens once in C rather than dragging three
112- planes and their strides across into jolt to be rearranged. Those strides
113- are also why it happens there — a decoder pads its rows, and `stride` is
114- not `width`.
115-
116- A decoder with no picture yet — normal for the first packets of a stream —
117- calls `use-frame` with a NULL pointer and zero dimensions rather than
118- raising."
119- [dec annexb len use-frame]
120- (ffi/with-arena [a]
121- (let [out (ffi/alloc a 8)
122- w (ffi/alloc a 4)
123- h (ffi/alloc a 4)
124- rc (raw-decode-rgba dec annexb len out w h)]
125- (when-not (zero? rc)
126- (throw (ex-info "openh264: decode failed" {:code rc})))
127- (use-frame (ffi/read out :pointer)
128- (ffi/read w :int32)
129- (ffi/read h :int32)))))
130-
131-(defn close-decoder! [dec] (raw-decoder-close dec) nil)
deleted file mode 100644
@@ -1,131 +0,0 @@
1-(ns frq.codec.h264
2- "H.264 encoding, through openh264.
3-
4- Not through openh264 DIRECTLY, and the reason is a calling convention.
5- openh264's C API is not flat: `ISVCEncoder` is `const ISVCEncoderVtbl*`, so
6- every method — Initialize, EncodeFrame, Uninitialize — is a function pointer
7- read out of a table hanging off the object. jolt.ffi cannot call one. Chez
8- fixes a foreign procedure's argument and result types when it COMPILES it,
9- which is also why the target has to be a literal C symbol name rather than
10- an address; `jolt/ffi.clj` says so in as many words.
11-
12- So `c/frq_h264.c` walks the vtable and exports five plain symbols, and this
13- namespace binds those. The shim is a hundred lines that change a calling
14- convention; it holds no policy and makes no decisions that belong here.
15-
16- It does flatten the output, because that part cannot sensibly live in jolt:
17- openh264 answers an `SFrameBSInfo` of layers, each with its own NAL count
18- over a shared buffer, and walking that from here would mean reading nested
19- C structs whose layout is openh264's business rather than ours.
20-
21- A FRAME IS BORROWED, both ways. `encode!` takes a pointer to I420 and
22- answers a span into the encoder's own buffer, valid until the next call on
23- the same encoder. That is `frq.av`'s existing contract for video — decoder
24- buffer to texture as a pointer, never copied on this side — and it is why
25- nothing here turns a picture into a jolt value."
26- (:require [jolt.ffi :as ffi]))
27-
28-(ffi/defcfn raw-open "frq_h264_open" [:int :int :int :int :pointer] :int)
29-(ffi/defcfn raw-encode "frq_h264_encode"
30- [:pointer :pointer :int64 :pointer :pointer :pointer] :int)
31-(ffi/defcfn raw-force-keyframe "frq_h264_force_keyframe" [:pointer] :int)
32-(ffi/defcfn raw-close "frq_h264_close" [:pointer] :void)
33-
34-(ffi/defcfn raw-decoder-open "frq_h264_decoder_open" [:pointer] :int)
35-(ffi/defcfn raw-decode-rgba "frq_h264_decode_rgba"
36- [:pointer :pointer :int :pointer :pointer :pointer] :int)
37-(ffi/defcfn raw-decoder-close "frq_h264_decoder_close" [:pointer] :void)
38-
39-(defn i420-size
40- "Bytes in one I420 frame: a luma plane, then two at quarter resolution."
41- [width height]
42- (+ (* width height) (* 2 (quot (* width height) 4))))
43-
44-(defn encoder
45- "Open an encoder. `bitrate` is bits per second.
46-
47- openh264 validates here rather than at the first frame, so an impossible
48- size or bitrate raises now."
49- [{:keys [width height fps bitrate] :or {fps 30 bitrate 1000000}}]
50- (ffi/with-arena [a]
51- (let [out (ffi/alloc a 8)
52- rc (raw-open width height fps bitrate out)]
53- (when-not (zero? rc)
54- (throw (ex-info "openh264: could not open an encoder"
55- {:code rc :width width :height height
56- :fps fps :bitrate bitrate})))
57- (let [h (ffi/read out :pointer)]
58- (when (ffi/null? h)
59- (throw (ex-info "openh264: encoder handle is NULL" {})))
60- h))))
61-
62-(defn encode!
63- "Encode one I420 frame and hand the result to `use-frame`.
64-
65- `i420` is a pointer to `(i420-size w h)` bytes. `use-frame` is called with
66- [pointer length keyframe?] and its value is answered; the span is the
67- encoder's own buffer and is valid only for the duration of that call.
68-
69- A frame openh264 chose to skip calls `use-frame` with a zero length rather
70- than raising — a skip is a decision, not a failure."
71- [enc i420 pts-us use-frame]
72- (ffi/with-arena [a]
73- (let [out (ffi/alloc a 8)
74- len (ffi/alloc a 4)
75- key (ffi/alloc a 4)
76- rc (raw-encode enc i420 pts-us out len key)]
77- (when-not (zero? rc)
78- (throw (ex-info "openh264: encode failed" {:code rc})))
79- (use-frame (ffi/read out :pointer)
80- (ffi/read len :int32)
81- (not (zero? (ffi/read key :int32)))))))
82-
83-(defn force-keyframe!
84- "Make the next frame an IDR — what a newly arrived subscriber needs."
85- [enc]
86- (raw-force-keyframe enc)
87- nil)
88-
89-(defn close! [enc] (raw-close enc) nil)
90-
91-;; --- decoding ----------------------------------------------------------------
92-
93-(defn decoder
94- "Open a decoder. It answers RGBA, not I420 — see `decode!`."
95- []
96- (ffi/with-arena [a]
97- (let [out (ffi/alloc a 8)
98- rc (raw-decoder-open out)]
99- (when-not (zero? rc)
100- (throw (ex-info "openh264: could not open a decoder" {:code rc})))
101- (ffi/read out :pointer))))
102-
103-(defn decode!
104- "Decode one Annex B frame and hand the picture to `use-frame`.
105-
106- `use-frame` is called with [pointer width height] and its value answered.
107- The pointer is tightly packed RGBA in the decoder's own buffer, valid until
108- the next decode on the same decoder.
109-
110- RGBA rather than I420 on purpose: `vidya/frame-rgba!` is where this is
111- going, so the conversion happens once in C rather than dragging three
112- planes and their strides across into jolt to be rearranged. Those strides
113- are also why it happens there — a decoder pads its rows, and `stride` is
114- not `width`.
115-
116- A decoder with no picture yet — normal for the first packets of a stream —
117- calls `use-frame` with a NULL pointer and zero dimensions rather than
118- raising."
119- [dec annexb len use-frame]
120- (ffi/with-arena [a]
121- (let [out (ffi/alloc a 8)
122- w (ffi/alloc a 4)
123- h (ffi/alloc a 4)
124- rc (raw-decode-rgba dec annexb len out w h)]
125- (when-not (zero? rc)
126- (throw (ex-info "openh264: decode failed" {:code rc})))
127- (use-frame (ffi/read out :pointer)
128- (ffi/read w :int32)
129- (ffi/read h :int32)))))
130-
131-(defn close-decoder! [dec] (raw-decoder-close dec) nil)
deleted src/frq/codec/opus.clj +0 -144
deleted file mode 100644
@@ -1,144 +0,0 @@
1-(ns frq.codec.opus
2- "Opus, bound to libopus.
3-
4- This is what the media plane's audio half becomes on this side of the port.
5- libmoq_ffi carries MoQ over QUIC and nothing else — moq-ffi's `audio`
6- feature, which would have brought Opus with it, costs a 1062-crate build —
7- so the codec is linked where it has always lived, in a C library with a flat
8- API and a twenty-year-old ABI.
9-
10- Two things about that API shape the binding.
11-
12- **PCM is int16, interleaved, and never a jolt value.** `encode!` and
13- `decode!` take and answer [pointer length] spans of foreign memory, the same
14- as `frq.moq.media`'s frame path. A 20ms stereo frame at 48kHz is 1920
15- samples; turning that into a jolt vector twice per frame, fifty times a
16- second, is work with nothing to show for it, and `frq.av`'s rule already
17- says audio and video move as pointers.
18-
19- **A frame is not any length you like.** Opus encodes exactly 2.5, 5, 10, 20,
20- 40 or 60ms, and `frame-size` is a count of samples PER CHANNEL, not bytes
21- and not interleaved samples. Handing it the interleaved count is the classic
22- mistake: at stereo it asks for twice the duration, which is a valid frame
23- size, so nothing raises and the audio simply runs fast."
24- (:require [jolt.ffi :as ffi]))
25-
26-;; --- constants ---------------------------------------------------------------
27-
28-(def ^:const ok 0)
29-
30-(def applications
31- "What the encoder is being asked to optimise for. :voip is what a call
32- wants — it favours speech intelligibility over musical fidelity."
33- {:voip 2048 :audio 2049 :low-delay 2051})
34-
35-(def ^:private errors
36- {0 :ok -1 :bad-arg -2 :buffer-too-small -3 :internal-error
37- -4 :invalid-packet -5 :unimplemented -6 :invalid-state -7 :alloc-fail})
38-
39-(def ^:const set-bitrate-request 4002)
40-
41-;; Sample counts PER CHANNEL for one frame at 48kHz, by frame duration.
42-(def frame-samples-48k
43- {2.5 120, 5 240, 10 480, 20 960, 40 1920, 60 2880})
44-
45-;; --- the entry points --------------------------------------------------------
46-
47-(ffi/defcfn raw-encoder-create "opus_encoder_create"
48- [:int32 :int :int :pointer] :pointer)
49-(ffi/defcfn raw-encoder-destroy "opus_encoder_destroy" [:pointer] :void)
50-(ffi/defcfn raw-encode "opus_encode"
51- [:pointer :pointer :int :pointer :int32] :int32)
52-;; Variadic: the CTL request decides the tail. Declared with the one tail
53-;; shape this namespace uses — an int32 — rather than a bare :&, so it costs
54-;; no compile at the first call.
55-(ffi/defcfn raw-encoder-ctl "opus_encoder_ctl" [:pointer :int :& :int32] :int)
56-
57-(ffi/defcfn raw-decoder-create "opus_decoder_create" [:int32 :int :pointer] :pointer)
58-(ffi/defcfn raw-decoder-destroy "opus_decoder_destroy" [:pointer] :void)
59-(ffi/defcfn raw-decode "opus_decode"
60- [:pointer :pointer :int32 :pointer :int :int] :int)
61-
62-(ffi/defcfn raw-strerror "opus_strerror" [:int] :string)
63-
64-;; --- errors ------------------------------------------------------------------
65-
66-(defn- check!
67- "libopus answers a negative int for every failure, in every function that
68- returns one. There is no errno and no out-parameter to consult except on the
69- constructors, so this is the whole error protocol."
70- [n what]
71- (if (neg? n)
72- (throw (ex-info (str "opus: " what ": " (raw-strerror n))
73- {:code n :error (errors n :unknown) :op what}))
74- n))
75-
76-;; --- encoding ----------------------------------------------------------------
77-
78-(defn encoder
79- "An Opus encoder. `sample-rate` is one of 8000, 12000, 16000, 24000, 48000."
80- ([] (encoder 48000 1 :voip))
81- ([sample-rate channels application]
82- (ffi/with-arena [a]
83- (let [err (ffi/alloc a 4)
84- enc (raw-encoder-create sample-rate channels
85- (or (applications application)
86- (throw (ex-info "unknown opus application"
87- {:got application
88- :known (keys applications)})))
89- err)]
90- (check! (ffi/read err :int32) "encoder_create")
91- (when (ffi/null? enc)
92- (throw (ex-info "opus: encoder_create answered NULL" {})))
93- enc))))
94-
95-(defn set-bitrate!
96- "Bits per second across all channels."
97- [enc bps]
98- (check! (raw-encoder-ctl enc set-bitrate-request bps) "set_bitrate")
99- nil)
100-
101-(defn encode!
102- "Encode one frame; answers the number of bytes written into `out`.
103-
104- `pcm` is a pointer to interleaved int16 samples and `samples-per-channel`
105- counts them PER CHANNEL — see the namespace docstring on why that
106- distinction bites silently rather than loudly.
107-
108- A return of 2 bytes or fewer is not an error: it is DTX, the encoder saying
109- this frame is silence and need not be sent at all."
110- [enc pcm samples-per-channel out out-capacity]
111- (check! (raw-encode enc pcm samples-per-channel out out-capacity) "encode"))
112-
113-(defn dtx?
114- "Did `encode!` decide the frame was not worth sending?"
115- [written]
116- (<= written 2))
117-
118-(defn free-encoder! [enc] (raw-encoder-destroy enc) nil)
119-
120-;; --- decoding ----------------------------------------------------------------
121-
122-(defn decoder
123- ([] (decoder 48000 1))
124- ([sample-rate channels]
125- (ffi/with-arena [a]
126- (let [err (ffi/alloc a 4)
127- dec (raw-decoder-create sample-rate channels err)]
128- (check! (ffi/read err :int32) "decoder_create")
129- (when (ffi/null? dec)
130- (throw (ex-info "opus: decoder_create answered NULL" {})))
131- dec))))
132-
133-(defn decode!
134- "Decode one packet into `pcm`; answers samples decoded PER CHANNEL.
135-
136- `capacity-per-channel` is how much room `pcm` has, again per channel. Pass a
137- nil packet to conceal a lost one — that is what Opus's PLC is, and it is why
138- `data` is allowed to be NULL where most C APIs would refuse."
139- [dec data len pcm capacity-per-channel]
140- (check! (raw-decode dec (or data ffi/null) (if data len 0)
141- pcm capacity-per-channel 0)
142- "decode"))
143-
144-(defn free-decoder! [dec] (raw-decoder-destroy dec) nil)
deleted file mode 100644
@@ -1,144 +0,0 @@
1-(ns frq.codec.opus
2- "Opus, bound to libopus.
3-
4- This is what the media plane's audio half becomes on this side of the port.
5- libmoq_ffi carries MoQ over QUIC and nothing else — moq-ffi's `audio`
6- feature, which would have brought Opus with it, costs a 1062-crate build —
7- so the codec is linked where it has always lived, in a C library with a flat
8- API and a twenty-year-old ABI.
9-
10- Two things about that API shape the binding.
11-
12- **PCM is int16, interleaved, and never a jolt value.** `encode!` and
13- `decode!` take and answer [pointer length] spans of foreign memory, the same
14- as `frq.moq.media`'s frame path. A 20ms stereo frame at 48kHz is 1920
15- samples; turning that into a jolt vector twice per frame, fifty times a
16- second, is work with nothing to show for it, and `frq.av`'s rule already
17- says audio and video move as pointers.
18-
19- **A frame is not any length you like.** Opus encodes exactly 2.5, 5, 10, 20,
20- 40 or 60ms, and `frame-size` is a count of samples PER CHANNEL, not bytes
21- and not interleaved samples. Handing it the interleaved count is the classic
22- mistake: at stereo it asks for twice the duration, which is a valid frame
23- size, so nothing raises and the audio simply runs fast."
24- (:require [jolt.ffi :as ffi]))
25-
26-;; --- constants ---------------------------------------------------------------
27-
28-(def ^:const ok 0)
29-
30-(def applications
31- "What the encoder is being asked to optimise for. :voip is what a call
32- wants — it favours speech intelligibility over musical fidelity."
33- {:voip 2048 :audio 2049 :low-delay 2051})
34-
35-(def ^:private errors
36- {0 :ok -1 :bad-arg -2 :buffer-too-small -3 :internal-error
37- -4 :invalid-packet -5 :unimplemented -6 :invalid-state -7 :alloc-fail})
38-
39-(def ^:const set-bitrate-request 4002)
40-
41-;; Sample counts PER CHANNEL for one frame at 48kHz, by frame duration.
42-(def frame-samples-48k
43- {2.5 120, 5 240, 10 480, 20 960, 40 1920, 60 2880})
44-
45-;; --- the entry points --------------------------------------------------------
46-
47-(ffi/defcfn raw-encoder-create "opus_encoder_create"
48- [:int32 :int :int :pointer] :pointer)
49-(ffi/defcfn raw-encoder-destroy "opus_encoder_destroy" [:pointer] :void)
50-(ffi/defcfn raw-encode "opus_encode"
51- [:pointer :pointer :int :pointer :int32] :int32)
52-;; Variadic: the CTL request decides the tail. Declared with the one tail
53-;; shape this namespace uses — an int32 — rather than a bare :&, so it costs
54-;; no compile at the first call.
55-(ffi/defcfn raw-encoder-ctl "opus_encoder_ctl" [:pointer :int :& :int32] :int)
56-
57-(ffi/defcfn raw-decoder-create "opus_decoder_create" [:int32 :int :pointer] :pointer)
58-(ffi/defcfn raw-decoder-destroy "opus_decoder_destroy" [:pointer] :void)
59-(ffi/defcfn raw-decode "opus_decode"
60- [:pointer :pointer :int32 :pointer :int :int] :int)
61-
62-(ffi/defcfn raw-strerror "opus_strerror" [:int] :string)
63-
64-;; --- errors ------------------------------------------------------------------
65-
66-(defn- check!
67- "libopus answers a negative int for every failure, in every function that
68- returns one. There is no errno and no out-parameter to consult except on the
69- constructors, so this is the whole error protocol."
70- [n what]
71- (if (neg? n)
72- (throw (ex-info (str "opus: " what ": " (raw-strerror n))
73- {:code n :error (errors n :unknown) :op what}))
74- n))
75-
76-;; --- encoding ----------------------------------------------------------------
77-
78-(defn encoder
79- "An Opus encoder. `sample-rate` is one of 8000, 12000, 16000, 24000, 48000."
80- ([] (encoder 48000 1 :voip))
81- ([sample-rate channels application]
82- (ffi/with-arena [a]
83- (let [err (ffi/alloc a 4)
84- enc (raw-encoder-create sample-rate channels
85- (or (applications application)
86- (throw (ex-info "unknown opus application"
87- {:got application
88- :known (keys applications)})))
89- err)]
90- (check! (ffi/read err :int32) "encoder_create")
91- (when (ffi/null? enc)
92- (throw (ex-info "opus: encoder_create answered NULL" {})))
93- enc))))
94-
95-(defn set-bitrate!
96- "Bits per second across all channels."
97- [enc bps]
98- (check! (raw-encoder-ctl enc set-bitrate-request bps) "set_bitrate")
99- nil)
100-
101-(defn encode!
102- "Encode one frame; answers the number of bytes written into `out`.
103-
104- `pcm` is a pointer to interleaved int16 samples and `samples-per-channel`
105- counts them PER CHANNEL — see the namespace docstring on why that
106- distinction bites silently rather than loudly.
107-
108- A return of 2 bytes or fewer is not an error: it is DTX, the encoder saying
109- this frame is silence and need not be sent at all."
110- [enc pcm samples-per-channel out out-capacity]
111- (check! (raw-encode enc pcm samples-per-channel out out-capacity) "encode"))
112-
113-(defn dtx?
114- "Did `encode!` decide the frame was not worth sending?"
115- [written]
116- (<= written 2))
117-
118-(defn free-encoder! [enc] (raw-encoder-destroy enc) nil)
119-
120-;; --- decoding ----------------------------------------------------------------
121-
122-(defn decoder
123- ([] (decoder 48000 1))
124- ([sample-rate channels]
125- (ffi/with-arena [a]
126- (let [err (ffi/alloc a 4)
127- dec (raw-decoder-create sample-rate channels err)]
128- (check! (ffi/read err :int32) "decoder_create")
129- (when (ffi/null? dec)
130- (throw (ex-info "opus: decoder_create answered NULL" {})))
131- dec))))
132-
133-(defn decode!
134- "Decode one packet into `pcm`; answers samples decoded PER CHANNEL.
135-
136- `capacity-per-channel` is how much room `pcm` has, again per channel. Pass a
137- nil packet to conceal a lost one — that is what Opus's PLC is, and it is why
138- `data` is allowed to be NULL where most C APIs would refuse."
139- [dec data len pcm capacity-per-channel]
140- (check! (raw-decode dec (or data ffi/null) (if data len 0)
141- pcm capacity-per-channel 0)
142- "decode"))
143-
144-(defn free-decoder! [dec] (raw-decoder-destroy dec) nil)
deleted src/frq/cosmic.clj +0 -91
deleted file mode 100644
@@ -1,91 +0,0 @@
1-(ns frq.cosmic
2- "frq's own screens, painted by libcosmic. The desktop entry point.
3-
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`
6- makes it the backend glimmer renders with. Nothing in `frq.app` changes.
7-
8- glimmer-cosmic is a spike, and it shows. It paints a short list of tags and
9- treats every other one as a column, so most of frq comes out as stacked text
10- and buttons rather than its real layout. The jvui calls frq makes elsewhere
11- (a picture chooser, call frames) have no window of their own to act on and do
12- nothing, the same way they do in the terminal.
13-
14- The one real difference from the other two: libcosmic takes the main thread
15- and keeps it, so glimmer's loop runs on a worker. The timers handed to
16- `start!` are glimmer-cosmic's, which run on that worker.
17-
18- just cosmic run"
19- (:require [frq.app :as app]
20- [frq.platform :as platform]
21- [frq.state :as s]
22- [glimmer.core :as ui]
23- [glimmer-cosmic.ffi :as cosmic-ffi]
24- ;; last, so its install! is the one that stands
25- [glimmer-cosmic.core :as cosmic]))
26-
27-(defn- dump-tree-to!
28- "Write the tree as the reconciler left it to `path` every few seconds.
29-
30- There is no REPL into a libcosmic window — it owns the main thread — so this
31- is how to tell a row that is not in the tree from a row that is in it and not
32- painted. Off unless FRQ_COSMIC_DUMP names a file."
33- [path]
34- (cosmic/every! 3000 #(spit path (cosmic/dump-str))))
35-
36-(defn- open-url!
37- "Hand a URL to the desktop's browser.
38-
39- libcosmic has none of its own, so this is xdg-open — which is what the
40- desktop's answer to \"show me this page\" has always been, and what
41- `frq.platform/open-url!` means by a backend that has a browser to hand it
42- to. Detached and its output thrown away: frq is not waiting on it, and a
43- child whose pipes nobody reads is a child that can block on a full one.
44-
45- http and https only. Every link in a conversation reaches this from
46- somewhere else's message, and xdg-open takes far more than a web page — a
47- `file:` URL is a file manager, and a bare path is whatever is registered for
48- it. A scheme this client did not mean to offer is not opened at all."
49- [url]
50- (boolean
51- (when (re-matches #"(?i)https?://[^\s]+" (str url))
52- (try
53- (-> (ProcessBuilder. (into-array String ["xdg-open" url]))
54- (.redirectOutput java.lang.ProcessBuilder$Redirect/DISCARD)
55- (.redirectError java.lang.ProcessBuilder$Redirect/DISCARD)
56- (.start))
57- true
58- (catch Exception _ false)))))
59-
60-(defn -main [& _]
61- ;; Everything this backend can do. What is missing is missing on purpose:
62- ;; libcosmic has no texture to push call frames into, which is the same
63- ;; thing `:av? false` below says from the other end. It has no browser
64- ;; either, but the desktop it is running on does — `open-url!` above is
65- ;; that, and it is why the connect screen opens a browser here now rather
66- ;; than falling back to printing the URL for someone to copy.
67- (platform/override! {:after! cosmic/after!
68- :every! cosmic/every!
69- :quit! cosmic/quit!
70- :open-url! open-url!
71- :screen-size cosmic/window-size
72- :pick-image! cosmic/pick-image!
73- :picked-image! cosmic/picked-image!
74- :clipboard-image-png! cosmic/clipboard-image-png!})
75- (when-let [path (not-empty (System/getenv "FRQ_COSMIC_DUMP"))]
76- (dump-tree-to! path))
77- (app/start! {:after! cosmic/after!
78- :every! cosmic/every!
79- :title! cosmic-ffi/set-title!
80- ;; The window's size, which frq lays its columns out against —
81- ;; the message list is told how much room the people panel
82- ;; leaves it.
83- :measure! (fn []
84- (let [[w h] (cosmic/window-size)]
85- (when (and (pos? w) (not= w @s/window-width))
86- (reset! s/window-width w))
87- (when (and (pos? h) (not= h @s/window-height))
88- (reset! s/window-height h))))
89- ;; Call frames are painted into a jvui texture, which is not here.
90- :av? false})
91- (ui/run app/app :title "frq" :width 520 :height 860))
deleted file mode 100644
@@ -1,91 +0,0 @@
1-(ns frq.cosmic
2- "frq's own screens, painted by libcosmic. The desktop entry point.
3-
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`
6- makes it the backend glimmer renders with. Nothing in `frq.app` changes.
7-
8- glimmer-cosmic is a spike, and it shows. It paints a short list of tags and
9- treats every other one as a column, so most of frq comes out as stacked text
10- and buttons rather than its real layout. The jvui calls frq makes elsewhere
11- (a picture chooser, call frames) have no window of their own to act on and do
12- nothing, the same way they do in the terminal.
13-
14- The one real difference from the other two: libcosmic takes the main thread
15- and keeps it, so glimmer's loop runs on a worker. The timers handed to
16- `start!` are glimmer-cosmic's, which run on that worker.
17-
18- just cosmic run"
19- (:require [frq.app :as app]
20- [frq.platform :as platform]
21- [frq.state :as s]
22- [glimmer.core :as ui]
23- [glimmer-cosmic.ffi :as cosmic-ffi]
24- ;; last, so its install! is the one that stands
25- [glimmer-cosmic.core :as cosmic]))
26-
27-(defn- dump-tree-to!
28- "Write the tree as the reconciler left it to `path` every few seconds.
29-
30- There is no REPL into a libcosmic window — it owns the main thread — so this
31- is how to tell a row that is not in the tree from a row that is in it and not
32- painted. Off unless FRQ_COSMIC_DUMP names a file."
33- [path]
34- (cosmic/every! 3000 #(spit path (cosmic/dump-str))))
35-
36-(defn- open-url!
37- "Hand a URL to the desktop's browser.
38-
39- libcosmic has none of its own, so this is xdg-open — which is what the
40- desktop's answer to \"show me this page\" has always been, and what
41- `frq.platform/open-url!` means by a backend that has a browser to hand it
42- to. Detached and its output thrown away: frq is not waiting on it, and a
43- child whose pipes nobody reads is a child that can block on a full one.
44-
45- http and https only. Every link in a conversation reaches this from
46- somewhere else's message, and xdg-open takes far more than a web page — a
47- `file:` URL is a file manager, and a bare path is whatever is registered for
48- it. A scheme this client did not mean to offer is not opened at all."
49- [url]
50- (boolean
51- (when (re-matches #"(?i)https?://[^\s]+" (str url))
52- (try
53- (-> (ProcessBuilder. (into-array String ["xdg-open" url]))
54- (.redirectOutput java.lang.ProcessBuilder$Redirect/DISCARD)
55- (.redirectError java.lang.ProcessBuilder$Redirect/DISCARD)
56- (.start))
57- true
58- (catch Exception _ false)))))
59-
60-(defn -main [& _]
61- ;; Everything this backend can do. What is missing is missing on purpose:
62- ;; libcosmic has no texture to push call frames into, which is the same
63- ;; thing `:av? false` below says from the other end. It has no browser
64- ;; either, but the desktop it is running on does — `open-url!` above is
65- ;; that, and it is why the connect screen opens a browser here now rather
66- ;; than falling back to printing the URL for someone to copy.
67- (platform/override! {:after! cosmic/after!
68- :every! cosmic/every!
69- :quit! cosmic/quit!
70- :open-url! open-url!
71- :screen-size cosmic/window-size
72- :pick-image! cosmic/pick-image!
73- :picked-image! cosmic/picked-image!
74- :clipboard-image-png! cosmic/clipboard-image-png!})
75- (when-let [path (not-empty (System/getenv "FRQ_COSMIC_DUMP"))]
76- (dump-tree-to! path))
77- (app/start! {:after! cosmic/after!
78- :every! cosmic/every!
79- :title! cosmic-ffi/set-title!
80- ;; The window's size, which frq lays its columns out against —
81- ;; the message list is told how much room the people panel
82- ;; leaves it.
83- :measure! (fn []
84- (let [[w h] (cosmic/window-size)]
85- (when (and (pos? w) (not= w @s/window-width))
86- (reset! s/window-width w))
87- (when (and (pos? h) (not= h @s/window-height))
88- (reset! s/window-height h))))
89- ;; Call frames are painted into a jvui texture, which is not here.
90- :av? false})
91- (ui/run app/app :title "frq" :width 520 :height 860))
deleted src/frq/crypto/openssl.clj +0 -109
deleted file mode 100644
@@ -1,109 +0,0 @@
1-(ns frq.crypto.openssl
2- "The desktop's answers to `frq.crypto`, over libcrypto.
3-
4- The same OpenSSL jolt already loads for TLS — there is no other crypto here
5- to borrow, and an Ed25519 written by hand is not a thing to put in a chat
6- client. Requiring this installs them.
7-
8- The EVP_PKEY lives here rather than in `frq.msgsig`, which is shared and has
9- nowhere to put a pointer."
10- (:require [frq.crypto :as crypto]
11- [jolt.ffi :as ffi]
12- [jolt.mvn-http :as tls]))
13-
14-
15-(ffi/defcfn c-rand-bytes "RAND_bytes" [:pointer :int] :int)
16-(ffi/defcfn c-new-raw-priv "EVP_PKEY_new_raw_private_key"
17- [:int :pointer :pointer :size_t] :pointer)
18-(ffi/defcfn c-get-raw-pub "EVP_PKEY_get_raw_public_key"
19- [:pointer :pointer :pointer] :int)
20-(ffi/defcfn c-pkey-free "EVP_PKEY_free" [:pointer] :void)
21-(ffi/defcfn c-md-ctx-new "EVP_MD_CTX_new" [] :pointer)
22-(ffi/defcfn c-md-ctx-free "EVP_MD_CTX_free" [:pointer] :void)
23-(ffi/defcfn c-sign-init "EVP_DigestSignInit"
24- [:pointer :pointer :pointer :pointer :pointer] :int)
25-(ffi/defcfn c-sign "EVP_DigestSign" [:pointer :pointer :pointer :pointer :size_t] :int)
26-(ffi/defcfn c-sha256 "SHA256" [:pointer :size_t :pointer] :pointer)
27-
28-;; Ed25519's NID, which openssl/obj_mac.h spells EVP_PKEY_ED25519.
29-(def ^:private nid-ed25519 1087)
30-
31-(defonce ^:private pkey (atom nil))
32-
33-(defn- ->bytes
34- "Whatever the shared side handed over, as the array ffi/write-array wants.
35-
36- `frq.io/utf8-bytes` answers a vector of ints and `frq.msgsig` passes seqs
37- around, because those are the only shapes both compilers agree on. The
38- conversion belongs here, where the pointer does."
39- [bs]
40- (if (bytes? bs) bs (byte-array (map unchecked-byte bs))))
41-
42-(defn- random-bytes [n]
43- (tls/ensure-native!)
44- (let [buf (ffi/alloc n)]
45- (try (when (= 1 (c-rand-bytes buf n)) (ffi/read-array buf n))
46- (finally (ffi/free buf)))))
47-
48-(defn- sha256 [bs]
49- (let [bs (->bytes bs)
50- n (alength bs)
51- in (ffi/alloc (max 1 n))
52- out (ffi/alloc 32)]
53- (try (ffi/write-array in bs)
54- (c-sha256 in n out)
55- (ffi/read-array out 32)
56- (finally (ffi/free in) (ffi/free out)))))
57-
58-(defn- forget! []
59- (when-let [k @pkey]
60- (try (c-pkey-free k) (catch Exception _ nil)))
61- (reset! pkey nil))
62-
63-(defn- generate!
64- "The seed IS the key for Ed25519, so `EVP_PKEY_new_raw_private_key` is the
65- whole of it. Returns the public half as bytes."
66- [seed]
67- (forget!)
68- (try
69- (tls/ensure-native!)
70- (let [seed (->bytes seed)
71- buf (ffi/alloc 32)]
72- (try
73- (ffi/write-array buf seed)
74- (let [k (c-new-raw-priv nid-ed25519 ffi/null buf 32)]
75- (when-not (ffi/null? k)
76- (let [pub (ffi/alloc 32)
77- plen (ffi/alloc (ffi/sizeof :size_t))]
78- (try
79- (ffi/write plen :size_t 32)
80- (when (= 1 (c-get-raw-pub k pub plen))
81- (reset! pkey k)
82- (ffi/read-array pub 32))
83- (finally (ffi/free pub) (ffi/free plen))))))
84- (finally (ffi/free buf))))
85- (catch Exception _ nil)))
86-
87-(defn- sign [bs]
88- (when-let [k @pkey]
89- (let [bs (->bytes bs)
90- n (alength bs)
91- msg (ffi/alloc (max 1 n))
92- sig (ffi/alloc 64)
93- slen (ffi/alloc (ffi/sizeof :size_t))
94- ctx (c-md-ctx-new)]
95- (try
96- (ffi/write-array msg bs)
97- (ffi/write slen :size_t 64)
98- (when (and (= 1 (c-sign-init ctx ffi/null ffi/null ffi/null k))
99- (= 1 (c-sign ctx sig slen msg n)))
100- (ffi/read-array sig (ffi/read slen :size_t)))
101- (catch Exception _ nil)
102- (finally (ffi/free msg) (ffi/free sig) (ffi/free slen) (c-md-ctx-free ctx))))))
103-
104-(crypto/install!
105- {:random-bytes random-bytes
106- :sha256 sha256
107- :ed25519-generate! generate!
108- :ed25519-sign sign
109- :ed25519-forget! forget!})
deleted file mode 100644
@@ -1,109 +0,0 @@
1-(ns frq.crypto.openssl
2- "The desktop's answers to `frq.crypto`, over libcrypto.
3-
4- The same OpenSSL jolt already loads for TLS — there is no other crypto here
5- to borrow, and an Ed25519 written by hand is not a thing to put in a chat
6- client. Requiring this installs them.
7-
8- The EVP_PKEY lives here rather than in `frq.msgsig`, which is shared and has
9- nowhere to put a pointer."
10- (:require [frq.crypto :as crypto]
11- [jolt.ffi :as ffi]
12- [jolt.mvn-http :as tls]))
13-
14-
15-(ffi/defcfn c-rand-bytes "RAND_bytes" [:pointer :int] :int)
16-(ffi/defcfn c-new-raw-priv "EVP_PKEY_new_raw_private_key"
17- [:int :pointer :pointer :size_t] :pointer)
18-(ffi/defcfn c-get-raw-pub "EVP_PKEY_get_raw_public_key"
19- [:pointer :pointer :pointer] :int)
20-(ffi/defcfn c-pkey-free "EVP_PKEY_free" [:pointer] :void)
21-(ffi/defcfn c-md-ctx-new "EVP_MD_CTX_new" [] :pointer)
22-(ffi/defcfn c-md-ctx-free "EVP_MD_CTX_free" [:pointer] :void)
23-(ffi/defcfn c-sign-init "EVP_DigestSignInit"
24- [:pointer :pointer :pointer :pointer :pointer] :int)
25-(ffi/defcfn c-sign "EVP_DigestSign" [:pointer :pointer :pointer :pointer :size_t] :int)
26-(ffi/defcfn c-sha256 "SHA256" [:pointer :size_t :pointer] :pointer)
27-
28-;; Ed25519's NID, which openssl/obj_mac.h spells EVP_PKEY_ED25519.
29-(def ^:private nid-ed25519 1087)
30-
31-(defonce ^:private pkey (atom nil))
32-
33-(defn- ->bytes
34- "Whatever the shared side handed over, as the array ffi/write-array wants.
35-
36- `frq.io/utf8-bytes` answers a vector of ints and `frq.msgsig` passes seqs
37- around, because those are the only shapes both compilers agree on. The
38- conversion belongs here, where the pointer does."
39- [bs]
40- (if (bytes? bs) bs (byte-array (map unchecked-byte bs))))
41-
42-(defn- random-bytes [n]
43- (tls/ensure-native!)
44- (let [buf (ffi/alloc n)]
45- (try (when (= 1 (c-rand-bytes buf n)) (ffi/read-array buf n))
46- (finally (ffi/free buf)))))
47-
48-(defn- sha256 [bs]
49- (let [bs (->bytes bs)
50- n (alength bs)
51- in (ffi/alloc (max 1 n))
52- out (ffi/alloc 32)]
53- (try (ffi/write-array in bs)
54- (c-sha256 in n out)
55- (ffi/read-array out 32)
56- (finally (ffi/free in) (ffi/free out)))))
57-
58-(defn- forget! []
59- (when-let [k @pkey]
60- (try (c-pkey-free k) (catch Exception _ nil)))
61- (reset! pkey nil))
62-
63-(defn- generate!
64- "The seed IS the key for Ed25519, so `EVP_PKEY_new_raw_private_key` is the
65- whole of it. Returns the public half as bytes."
66- [seed]
67- (forget!)
68- (try
69- (tls/ensure-native!)
70- (let [seed (->bytes seed)
71- buf (ffi/alloc 32)]
72- (try
73- (ffi/write-array buf seed)
74- (let [k (c-new-raw-priv nid-ed25519 ffi/null buf 32)]
75- (when-not (ffi/null? k)
76- (let [pub (ffi/alloc 32)
77- plen (ffi/alloc (ffi/sizeof :size_t))]
78- (try
79- (ffi/write plen :size_t 32)
80- (when (= 1 (c-get-raw-pub k pub plen))
81- (reset! pkey k)
82- (ffi/read-array pub 32))
83- (finally (ffi/free pub) (ffi/free plen))))))
84- (finally (ffi/free buf))))
85- (catch Exception _ nil)))
86-
87-(defn- sign [bs]
88- (when-let [k @pkey]
89- (let [bs (->bytes bs)
90- n (alength bs)
91- msg (ffi/alloc (max 1 n))
92- sig (ffi/alloc 64)
93- slen (ffi/alloc (ffi/sizeof :size_t))
94- ctx (c-md-ctx-new)]
95- (try
96- (ffi/write-array msg bs)
97- (ffi/write slen :size_t 64)
98- (when (and (= 1 (c-sign-init ctx ffi/null ffi/null ffi/null k))
99- (= 1 (c-sign ctx sig slen msg n)))
100- (ffi/read-array sig (ffi/read slen :size_t)))
101- (catch Exception _ nil)
102- (finally (ffi/free msg) (ffi/free sig) (ffi/free slen) (c-md-ctx-free ctx))))))
103-
104-(crypto/install!
105- {:random-bytes random-bytes
106- :sha256 sha256
107- :ed25519-generate! generate!
108- :ed25519-sign sign
109- :ed25519-forget! forget!})
deleted src/frq/icons/insert-image.png +0 -0
deleted file mode 100644
Binary files a/src/frq/icons/insert-image.png and /dev/null differ
deleted file mode 100644
Binary files a/src/frq/icons/insert-image.png and /dev/null differBinary files a/src/frq/icons/insert-image.png and /dev/null differ
deleted src/frq/icons/insert-image.svg +0 -18
deleted file mode 100644
@@ -1,18 +0,0 @@
1-<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" version="1.1">
2- <!-- Delight 2 tile, geometry from delight-icons (gitlab.com/nandithebull/delight-icons); cream picture glyph -->
3- <defs>
4- <filter id="blur" style="color-interpolation-filters:sRGB" x="-0.0913" y="-0.0913" width="1.1827" height="1.1827">
5- <feGaussianBlur stdDeviation="1.3 1.3"/>
6- </filter>
7- </defs>
8- <g transform="matrix(1.1,0,0,1.1,-3.1845928,-3.2065454)">
9- <rect style="fill:none;stroke:#000000;stroke-width:2.72727;filter:url(#blur);opacity:0.5" width="49.090908" height="49.090908" x="7.4405394" y="7.4604964" rx="7.4768682" ry="7.477273"/>
10- <rect style="fill:#c4c2c5;stroke:#1e2430;stroke-width:2.72727273" width="49.090908" height="49.090908" x="7.4405394" y="7.4604964" rx="7.4768682" ry="7.477273"/>
11- <rect style="fill:#1e2430;stroke:none" width="42.089317" height="42.089317" x="10.941335" y="10.961291" rx="5.5927272" ry="5.5927272"/>
12- <g fill="#f4e3cf">
13- <path fill-rule="evenodd" d="M20.5 19.5 h23 a4 4 0 0 1 4 4 v17 a4 4 0 0 1 -4 4 h-23 a4 4 0 0 1 -4 -4 v-17 a4 4 0 0 1 4 -4 Z M20.8 22.4 a1.3 1.3 0 0 0 -1.3 1.3 v16.6 a1.3 1.3 0 0 0 1.3 1.3 h22.4 a1.3 1.3 0 0 0 1.3 -1.3 v-16.6 a1.3 1.3 0 0 0 -1.3 -1.3 Z"/>
14- <circle cx="37.8" cy="28.2" r="2.8"/>
15- <path d="M21.5 39.6 L28.3 30.4 Q29.2 29.3 30.1 30.4 L34.4 35.8 L36.6 33.2 Q37.4 32.3 38.2 33.2 L42.5 39.6 Z"/>
16- </g>
17- </g>
18-</svg>
deleted file mode 100644
@@ -1,18 +0,0 @@
1-<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" version="1.1">
2- <!-- Delight 2 tile, geometry from delight-icons (gitlab.com/nandithebull/delight-icons); cream picture glyph -->
3- <defs>
4- <filter id="blur" style="color-interpolation-filters:sRGB" x="-0.0913" y="-0.0913" width="1.1827" height="1.1827">
5- <feGaussianBlur stdDeviation="1.3 1.3"/>
6- </filter>
7- </defs>
8- <g transform="matrix(1.1,0,0,1.1,-3.1845928,-3.2065454)">
9- <rect style="fill:none;stroke:#000000;stroke-width:2.72727;filter:url(#blur);opacity:0.5" width="49.090908" height="49.090908" x="7.4405394" y="7.4604964" rx="7.4768682" ry="7.477273"/>
10- <rect style="fill:#c4c2c5;stroke:#1e2430;stroke-width:2.72727273" width="49.090908" height="49.090908" x="7.4405394" y="7.4604964" rx="7.4768682" ry="7.477273"/>
11- <rect style="fill:#1e2430;stroke:none" width="42.089317" height="42.089317" x="10.941335" y="10.961291" rx="5.5927272" ry="5.5927272"/>
12- <g fill="#f4e3cf">
13- <path fill-rule="evenodd" d="M20.5 19.5 h23 a4 4 0 0 1 4 4 v17 a4 4 0 0 1 -4 4 h-23 a4 4 0 0 1 -4 -4 v-17 a4 4 0 0 1 4 -4 Z M20.8 22.4 a1.3 1.3 0 0 0 -1.3 1.3 v16.6 a1.3 1.3 0 0 0 1.3 1.3 h22.4 a1.3 1.3 0 0 0 1.3 -1.3 v-16.6 a1.3 1.3 0 0 0 -1.3 -1.3 Z"/>
14- <circle cx="37.8" cy="28.2" r="2.8"/>
15- <path d="M21.5 39.6 L28.3 30.4 Q29.2 29.3 30.1 30.4 L34.4 35.8 L36.6 33.2 Q37.4 32.3 38.2 33.2 L42.5 39.6 Z"/>
16- </g>
17- </g>
18-</svg>
deleted src/frq/io/jolt.clj +0 -140
deleted file mode 100644
@@ -1,140 +0,0 @@
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.atproto :as atproto]
9- [frq.io :as io]
10- [frq.platform :as platform]
11- [jolt.host :as host]))
12-
13-(def ^:private zone
14- ;; TZ if it is set, otherwise whatever /etc/localtime points at, otherwise
15- ;; UTC — which is wrong by hours but never wrong by a day's worth of parsing.
16- ;;
17- ;; /etc/localtime is not always a symlink. In a container it is often the
18- ;; bytes themselves — distrobox's Arch has it as an overlay-mounted regular
19- ;; file — and then `readlink -f` answers with the path it was given and there
20- ;; is no zone name to read out of it at all. That fell through to UTC, so a
21- ;; channel read seven hours ahead of the clock on the same screen.
22- ;;
23- ;; The colon form is what libc has for exactly this: `TZ=:/etc/localtime`
24- ;; names the tzfile by path rather than by zone, and tzset reads it with the
25- ;; transitions intact — the offset it gives is DST-correct per instant, not a
26- ;; fixed one. Nothing downstream cares that this is a path: the zone is only
27- ;; ever handed to tz-offset-seconds, never shown.
28- ;;
29- ;; The `getprop` branch is Android's, and it stays here rather than moving to
30- ;; `frq.io.dart`: this is the code the APK runs today, and it runs it through
31- ;; jolt. When the Flutter APK is the only APK, this branch is dead and goes.
32- (delay
33- (or (let [tz (host/getenv "TZ")] (when (seq tz) tz))
34- (try
35- (second (re-find #"/zoneinfo/(.+)$"
36- (str/trim (str (host/sh-out "readlink -f /etc/localtime")))))
37- (catch Exception _ nil))
38- (when (host/file-exists? "/etc/localtime") ":/etc/localtime")
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- config-dir []
46- (let [xdg (host/getenv "XDG_CONFIG_HOME")
47- home (host/getenv "HOME")]
48- (str (if (seq xdg) xdg (str home "/.config")) "/frq")))
49-
50-(defn- slurp* [path]
51- (try (slurp path) (catch Exception _ nil)))
52-
53-(defn- spit* [path s]
54- (try (spit path s) true (catch Exception _ false)))
55-
56-(defn- downloads-dir
57- "Where this desktop keeps saved files: XDG's answer if the user-dirs file
58- has one, otherwise ~/Downloads, otherwise home. Created if it is only
59- missing — a reader with no ~/Downloads still means \"save it\"."
60- []
61- (let [home (host/getenv "HOME")
62- xdg (let [v (host/getenv "XDG_DOWNLOAD_DIR")] (when (seq v) v))
63- dir (or xdg (when (seq home) (str home "/Downloads")) home)]
64- (when (seq dir)
65- (when-not (host/directory? dir) (host/mkdirs! dir))
66- (when (host/directory? dir) dir))))
67-
68-(defn- free-path
69- "`dir/name`, or the same with `-1`, `-2`… before the extension until it
70- names nothing. Nothing saved is ever written over."
71- [dir name]
72- (let [[_ stem ext] (re-find #"^(.+)(\.[A-Za-z0-9]+)$" name)
73- stem (or stem name)
74- ext (or ext "")]
75- (loop [n 0]
76- (let [p (str dir "/" stem (when (pos? n) (str "-" n)) ext)]
77- (cond
78- (not (host/file-exists? p)) p
79- (> n 99) nil
80- :else (recur (inc n)))))))
81-
82-(defn- save-to-downloads!
83- "`cp` and not a read-then-write: what is being copied is a picture, the
84- seam's `slurp` is a string, and bytes through a string is how a PNG comes
85- out the other side broken."
86- [path filename]
87- (try
88- (when-let [dir (downloads-dir)]
89- (when-let [dest (free-path dir (or filename "picture.png"))]
90- (host/sh (str "cp '" path "' '" dest "'"))
91- (when (host/file-exists? dest) dest)))
92- (catch Exception _ nil)))
93-
94-(defn- write-private-file!
95- "Created before it is written, so the token is never on disk world-readable
96- even for an instant."
97- [path s]
98- (try
99- (host/sh (str "install -m 600 /dev/null '" path "'"))
100- (spit path s)
101- (host/sh (str "chmod 600 '" path "'"))
102- true
103- (catch Exception _ false)))
104-
105-(defn- fetch-text!
106- "`frq.io/fetch-text!` over the same hand-rolled HTTPS `frq.atproto` uses.
107-
108- On a future, because the caller is a UI thread and this is a socket: the
109- answer arrives at `on-done` whenever it arrives, and a request that throws
110- answers nil rather than taking the thread down with it. https only — every
111- URL this is asked for is one frq built out of the host the reader signed in
112- to."
113- [url headers on-done]
114- (future
115- (on-done
116- (try
117- (let [[_ host path] (re-matches #"https://([^/]+)(/.*)?" (str url))]
118- (when host
119- (atproto/request host (or path "/") nil headers)))
120- (catch Exception _ nil)))))
121-
122-(io/install!
123- {:getenv host/getenv
124- :open-url! platform/open-url!
125- :fetch-text! fetch-text!
126- :config-dir config-dir
127- :file-exists? host/file-exists?
128- :directory? host/directory?
129- :list-dir host/list-dir
130- :mkdirs! host/mkdirs!
131- :delete-file! host/delete-file!
132- :slurp slurp*
133- :spit spit*
134- :write-private-file! write-private-file!
135- :save-to-downloads! save-to-downloads!
136- :utf8-bytes (fn [s] (mapv #(bit-and (int %) 0xff) (.getBytes (str s))))
137- :utf8-string (fn [bs] (String. (byte-array (map unchecked-byte bs))))
138- :wall-nanos host/wall-nanos
139- :mono-nanos host/mono-nanos
140- :local-offset-seconds (fn [secs] (host/tz-offset-seconds @zone secs))})
deleted file mode 100644
@@ -1,140 +0,0 @@
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.atproto :as atproto]
9- [frq.io :as io]
10- [frq.platform :as platform]
11- [jolt.host :as host]))
12-
13-(def ^:private zone
14- ;; TZ if it is set, otherwise whatever /etc/localtime points at, otherwise
15- ;; UTC — which is wrong by hours but never wrong by a day's worth of parsing.
16- ;;
17- ;; /etc/localtime is not always a symlink. In a container it is often the
18- ;; bytes themselves — distrobox's Arch has it as an overlay-mounted regular
19- ;; file — and then `readlink -f` answers with the path it was given and there
20- ;; is no zone name to read out of it at all. That fell through to UTC, so a
21- ;; channel read seven hours ahead of the clock on the same screen.
22- ;;
23- ;; The colon form is what libc has for exactly this: `TZ=:/etc/localtime`
24- ;; names the tzfile by path rather than by zone, and tzset reads it with the
25- ;; transitions intact — the offset it gives is DST-correct per instant, not a
26- ;; fixed one. Nothing downstream cares that this is a path: the zone is only
27- ;; ever handed to tz-offset-seconds, never shown.
28- ;;
29- ;; The `getprop` branch is Android's, and it stays here rather than moving to
30- ;; `frq.io.dart`: this is the code the APK runs today, and it runs it through
31- ;; jolt. When the Flutter APK is the only APK, this branch is dead and goes.
32- (delay
33- (or (let [tz (host/getenv "TZ")] (when (seq tz) tz))
34- (try
35- (second (re-find #"/zoneinfo/(.+)$"
36- (str/trim (str (host/sh-out "readlink -f /etc/localtime")))))
37- (catch Exception _ nil))
38- (when (host/file-exists? "/etc/localtime") ":/etc/localtime")
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- config-dir []
46- (let [xdg (host/getenv "XDG_CONFIG_HOME")
47- home (host/getenv "HOME")]
48- (str (if (seq xdg) xdg (str home "/.config")) "/frq")))
49-
50-(defn- slurp* [path]
51- (try (slurp path) (catch Exception _ nil)))
52-
53-(defn- spit* [path s]
54- (try (spit path s) true (catch Exception _ false)))
55-
56-(defn- downloads-dir
57- "Where this desktop keeps saved files: XDG's answer if the user-dirs file
58- has one, otherwise ~/Downloads, otherwise home. Created if it is only
59- missing — a reader with no ~/Downloads still means \"save it\"."
60- []
61- (let [home (host/getenv "HOME")
62- xdg (let [v (host/getenv "XDG_DOWNLOAD_DIR")] (when (seq v) v))
63- dir (or xdg (when (seq home) (str home "/Downloads")) home)]
64- (when (seq dir)
65- (when-not (host/directory? dir) (host/mkdirs! dir))
66- (when (host/directory? dir) dir))))
67-
68-(defn- free-path
69- "`dir/name`, or the same with `-1`, `-2`… before the extension until it
70- names nothing. Nothing saved is ever written over."
71- [dir name]
72- (let [[_ stem ext] (re-find #"^(.+)(\.[A-Za-z0-9]+)$" name)
73- stem (or stem name)
74- ext (or ext "")]
75- (loop [n 0]
76- (let [p (str dir "/" stem (when (pos? n) (str "-" n)) ext)]
77- (cond
78- (not (host/file-exists? p)) p
79- (> n 99) nil
80- :else (recur (inc n)))))))
81-
82-(defn- save-to-downloads!
83- "`cp` and not a read-then-write: what is being copied is a picture, the
84- seam's `slurp` is a string, and bytes through a string is how a PNG comes
85- out the other side broken."
86- [path filename]
87- (try
88- (when-let [dir (downloads-dir)]
89- (when-let [dest (free-path dir (or filename "picture.png"))]
90- (host/sh (str "cp '" path "' '" dest "'"))
91- (when (host/file-exists? dest) dest)))
92- (catch Exception _ nil)))
93-
94-(defn- write-private-file!
95- "Created before it is written, so the token is never on disk world-readable
96- even for an instant."
97- [path s]
98- (try
99- (host/sh (str "install -m 600 /dev/null '" path "'"))
100- (spit path s)
101- (host/sh (str "chmod 600 '" path "'"))
102- true
103- (catch Exception _ false)))
104-
105-(defn- fetch-text!
106- "`frq.io/fetch-text!` over the same hand-rolled HTTPS `frq.atproto` uses.
107-
108- On a future, because the caller is a UI thread and this is a socket: the
109- answer arrives at `on-done` whenever it arrives, and a request that throws
110- answers nil rather than taking the thread down with it. https only — every
111- URL this is asked for is one frq built out of the host the reader signed in
112- to."
113- [url headers on-done]
114- (future
115- (on-done
116- (try
117- (let [[_ host path] (re-matches #"https://([^/]+)(/.*)?" (str url))]
118- (when host
119- (atproto/request host (or path "/") nil headers)))
120- (catch Exception _ nil)))))
121-
122-(io/install!
123- {:getenv host/getenv
124- :open-url! platform/open-url!
125- :fetch-text! fetch-text!
126- :config-dir config-dir
127- :file-exists? host/file-exists?
128- :directory? host/directory?
129- :list-dir host/list-dir
130- :mkdirs! host/mkdirs!
131- :delete-file! host/delete-file!
132- :slurp slurp*
133- :spit spit*
134- :write-private-file! write-private-file!
135- :save-to-downloads! save-to-downloads!
136- :utf8-bytes (fn [s] (mapv #(bit-and (int %) 0xff) (.getBytes (str s))))
137- :utf8-string (fn [bs] (String. (byte-array (map unchecked-byte bs))))
138- :wall-nanos host/wall-nanos
139- :mono-nanos host/mono-nanos
140- :local-offset-seconds (fn [secs] (host/tz-offset-seconds @zone secs))})
deleted src/frq/irc.clj +0 -295
deleted file mode 100644
@@ -1,295 +0,0 @@
1-(ns frq.irc
2- "A small IRC client for freeq servers, over plain TCP.
3-
4- Two transports, behind one `read-chunk!`/`write!` pair. TLS is the default —
5- jolt.mvn-http carries OpenSSL bindings for its own HTTPS fetching, and they
6- are just as good for an IRC socket, so `:6697` works. Plain TCP is the raw
7- BSD calls: socket, connect, send, recv.
8-
9- The raw calls rather than the java.net.Socket surface jolt.socket registers,
10- because that surface does not work on Android — nor does mvn-http's
11- getaddrinfo path, which is why TLS is desktop-only there. `socket()` and
12- `connect()` themselves are fine on both, so this is what the phone gets.
13-
14- One future reads, the UI thread writes; nothing here knows about the UI.
15- `connect!` takes an `on-msg` fn and returns a connection map that
16- `send-line!` and `close!` accept."
17- (:require [clojure.string :as str]
18- [frq.atproto :as atproto]
19- [frq.irc.parse :as parse]
20- [frq.irc.handshake :as handshake]
21- [frq.irc.mutate :as mutate]
22- [frq.msgsig :as msgsig]
23- [frq.wire :as wire]
24- [jolt.ffi :as ffi]
25- [jolt.host :as host]
26- [jolt.mvn-http :as tls]
27- [jolt.socket :as socket]))
28-
29-(def ^:private af-inet 2)
30-(def ^:private sock-stream 1)
31-(def ^:private buffer-size 8192)
32-
33-;; jolt's TLS sockets carry a 30-second receive timeout, so a quiet connection
34-;; reads nothing without being closed. These decide how long that is allowed to
35-;; go on: past `idle-ping-secs` we ask the server whether it is still there,
36-;; and past `idle-dead-secs` with no answer we conclude it is not.
37-(def ^:private idle-ping-secs 45)
38-(def ^:private idle-dead-secs 90)
39-
40-;; How long a TLS read waits before giving the thread back. It is also how long
41-;; an outgoing line can sit in the outbox, so it wants to be short: the reader
42-;; owns the connection, and this is how often it looks at what there is to send.
43-(def ^:private tls-poll-ms 200)
44-
45-(defn- secs-since [t] (quot (- (host/mono-nanos) t) 1000000000))
46-
47-;; ---------------------------------------------------------------- parsing
48-;;
49-;; Moved to `frq.irc.parse` under common/, so ClojureDart compiles it too —
50-;; the wire format is the same on a phone, and only the socket under it is
51-;; not. Re-exported here rather than left to the callers: `irc/tag-value` and
52-;; `irc/nick-of` are read in twenty-three places across frq.state and frq.av,
53-;; and none of them care which file it lives in.
54-
55-(def parse-line parse/parse-line)
56-(def unescape-tag parse/unescape-tag)
57-(def escape-tag-value parse/escape-tag-value)
58-(def tag-value parse/tag-value)
59-(def nick-of parse/nick-of)
60-
61-;; ---------------------------------------------------------------- transport
62-
63-(defn- write!
64- "Bytes out, whichever transport this is. TLS callers go through the outbox
65- instead — see `send-line!`."
66- [conn text]
67- (if (= :tls (:kind conn))
68- (tls/tls-write (:tls conn) (.getBytes text))
69- (wire/send-all! (:fd conn) text)))
70-
71-(defn- read-chunk!
72- "Block for the next chunk as a string, or nil at end of stream."
73- [conn]
74- (if (= :tls (:kind conn))
75- (let [b (try (tls/tls-read (:tls conn)) (catch Exception _ nil))]
76- (when (and b (pos? (count b))) (String. b)))
77- (let [buf (:buf conn)
78- n (try (wire/recv! (:fd conn) buf buffer-size) (catch Exception _ -1))]
79- (when (and n (pos? n)) (String. (ffi/read-bytes buf n))))))
80-
81-(defn send-line!
82- "Send a raw IRC line. Safe from any thread.
83-
84- On TLS the line is queued rather than written: OpenSSL is driven here through
85- a pair of memory BIOs, and a write issued while the reader thread is parked
86- inside SSL_read is simply lost — the call reports success and the server
87- never sees the line. So the reader thread owns the connection in both
88- directions and drains this queue between reads. A raw socket has no such
89- problem, and writes straight through."
90- [conn line]
91- (let [text (str line "\r\n")]
92- (if (= :tls (:kind conn))
93- (locking (:lock conn) (swap! (:outbox conn) conj text))
94- (locking (:lock conn) (write! conn text)))))
95-
96-(declare flush-outbox-tls!)
97-
98-(defn- flush-outbox!
99- "Write whatever has been queued. Only ever called on the reader thread.
100-
101- Only a TLS connection has a queue: a raw socket is written straight from
102- whichever thread is sending, so there is nothing here to drain."
103- [conn]
104- (when (:outbox conn)
105- (flush-outbox-tls! conn)))
106-
107-(defn- trace!
108- "One line of the conversation with the server, under FRQ_TRACE.
109-
110- Whole, not the first hundred characters: the tags come first and a line
111- carries a signature long enough to push every other tag past that cut, so
112- the truncated form could not answer what a `+reply` said — or whether there
113- was one — which is the question the trace exists for.
114-
115- Except an `AUTHENTICATE` payload, which is the credential itself. A trace is
116- something you paste into a bug report, and a session token is not."
117- [dir line]
118- (when (System/getenv "FRQ_TRACE")
119- (let [line (str/trimr line)
120- line (if (str/starts-with? line "AUTHENTICATE ") "AUTHENTICATE <redacted>" line)]
121- (binding [*out* *err*] (println (str "frq/irc: " dir " " line))))))
122-
123-(defn- flush-outbox-tls! [conn]
124- (let [pending (locking (:lock conn)
125- (let [q @(:outbox conn)]
126- (reset! (:outbox conn) [])
127- q))]
128- (doseq [text pending]
129- (trace! ">>" text)
130- (try (write! conn text)
131- (catch Exception e
132- (binding [*out* *err*] (println "frq/irc: write failed:" (or (ex-message e) (str e))))
133- ;; Put it back: a write that failed for a transient reason is
134- ;; worth another turn of the loop.
135- (locking (:lock conn) (swap! (:outbox conn) conj text)))))))
136-
137-(defn- reader-loop!
138- "Read until the connection ends, splitting on CRLF and dispatching each
139- complete line. PING is answered here so a busy UI never times the link out;
140- everything else goes to `on-msg`.
141-
142- Nothing to read is not the end of the connection. On TLS it usually means the
143- 30-second receive timeout elapsed on a quiet channel — reading that as EOF is
144- what used to leave the app connected in appearance only: sends went nowhere
145- while the buffer still filled in with what the user typed. So a quiet stretch
146- gets a PING, and only silence after that counts as gone."
147- [conn on-msg]
148- (loop [acc "" last-data (host/mono-nanos) pinged? false]
149- (flush-outbox! conn)
150- (let [chunk (read-chunk! conn)]
151- (cond
152- ;; A plain socket has no timeout, so nothing to read really is the end.
153- (and (nil? chunk) (not= :tls (:kind conn)))
154- (on-msg {:command "*DISCONNECTED*" :params []})
155-
156- (nil? chunk)
157- (let [idle (secs-since last-data)]
158- (cond
159- (and pinged? (> idle idle-dead-secs))
160- (on-msg {:command "*DISCONNECTED*" :params []})
161-
162- (and (not pinged?) (> idle idle-ping-secs))
163- (do (try (send-line! conn "PING :frq") (catch Exception _ nil))
164- (recur acc last-data true))
165-
166- :else (recur acc last-data pinged?)))
167-
168- :else
169- (let [acc (str acc chunk)
170- lines (str/split acc #"\r?\n" -1)
171- complete (butlast lines)]
172- (doseq [line complete :when (seq (str/trim line))]
173- (trace! "<<" line)
174- (let [msg (parse-line line)]
175- (when (= "PING" (:command msg))
176- (send-line! conn (str "PONG :" (first (:params msg)))))
177- (on-msg (assoc msg :raw line))))
178- (recur (last lines) (host/mono-nanos) false))))))
179-
180-(defn- open
181- "Dial `host`:`port`, over TLS unless `tls?` is false."
182- [host port tls? nick]
183- (if tls?
184- (do (tls/ensure-native!)
185- (let [t (tls/tls-connect host (int port))]
186- ;; Without a short timeout the reader parks for 30 seconds at a time,
187- ;; which is 30 seconds of nothing being sent.
188- (try (#'tls/set-timeouts! (:sock t) tls-poll-ms) (catch Exception _ nil))
189- {:kind :tls :tls t :outbox (atom [])
190- :lock (Object.) :nick nick :caps (atom #{})}))
191- (let [ip (#'socket/ip->str (socket/resolve-host host))
192- fd (socket/c-socket af-inet sock-stream 0)]
193- (when (neg? fd) (throw (ex-info "socket() failed" {:host host})))
194- (let [rc (wire/connect! fd (#'socket/make-sockaddr-in ip (int port)) 16)]
195- (when (neg? rc)
196- (socket/c-close fd)
197- (throw (ex-info "connect() failed" {:host host :ip ip :port port}))))
198- {:kind :plain :fd fd :buf (ffi/alloc buffer-size)
199- :lock (Object.) :nick nick :caps (atom #{})})))
200-
201-(def cap-acked?
202- "Whether the server agreed to `cap` on this connection."
203- (fn [conn cap]
204- (handshake/acked? (when-let [caps (:caps conn)] @caps) cap)))
205-
206-(defn- cap-step!
207- "Drive capability negotiation, and the SASL exchange inside it when there is
208- a session to authenticate with. Returns the message unchanged, so the caller
209- can go on handling it.
210-
211- All of the deciding is `frq.irc.handshake`, which is shared: it answers with
212- the lines to send and this writes them. What is left here is the writing and
213- the atom the acked set lives in."
214- [conn session msg]
215- (let [caps (:caps conn)
216- {:keys [send] next-caps :caps}
217- (handshake/step {:session session :caps (when caps @caps)} msg)]
218- (when caps (reset! caps next-caps))
219- (doseq [line send] (send-line! conn line)))
220- msg)
221-
222-(defn connect!
223- "Open a connection, register `nick`, and start the reader.
224-
225- With a `session` from `frq.atproto/create-session` the registration runs the
226- SASL exchange first and the connection is bound to that DID; without one it
227- is an ordinary guest. `tls?` defaults to true — freeq's TLS listener is
228- :6697, plain is :6667."
229- ([host port nick on-msg] (connect! host port nick on-msg true nil))
230- ([host port nick on-msg tls?] (connect! host port nick on-msg tls? nil))
231- ([host port nick on-msg tls? session]
232- (let [conn (open host port tls? nick)
233- on-msg (fn [msg] (on-msg (cap-step! conn session msg)))]
234- (future
235- (try (reader-loop! conn on-msg)
236- (catch Exception e
237- (on-msg {:command "*ERROR*" :params [(str e)]}))))
238- ;; CAP first: registration waits for CAP END, which negotiation sends once
239- ;; it has an answer — after the SASL exchange, when there is one.
240- (send-line! conn "CAP LS 302")
241- (send-line! conn (str "NICK " nick))
242- (send-line! conn (str "USER " nick " 0 * :" nick))
243- conn)))
244-
245-(defn join! [conn channel] (send-line! conn (str "JOIN " channel)))
246-(defn part! [conn channel] (send-line! conn (str "PART " channel)))
247-
248-(defn privmsg!
249- "Say something. With `reply-to`, say it as an answer to that message: the
250- `+draft/reply` tag is what every other freeq client reads to thread it, and
251- what this one draws its chips from. The line is `frq.irc.mutate`'s, so both
252- halves tag a reply the same way; this writes it."
253- ([conn target text] (privmsg! conn target text nil))
254- ([conn target text reply-to]
255- (send-line! conn (mutate/say-line target text reply-to))))
256-
257-(defn edit!
258- "Rewrite something already said. The line is `frq.irc.mutate`'s; this writes
259- it."
260- ([conn target msgid text] (edit! conn target msgid text nil))
261- ([conn target msgid text peer-did]
262- (send-line! conn (mutate/edit-line target msgid text peer-did))))
263-
264-(defn tagmsg!
265- "A message that is only tags: how freeq carries a reaction, a typing hint or
266- a delete. `tags` is a map of name to value, sent in no particular order — the
267- server reads them by name."
268- [conn target tags]
269- (let [pairs (for [[k v] tags] (str k "=" (escape-tag-value v)))]
270- (send-line! conn (str "@" (str/join ";" pairs) " TAGMSG " target))))
271-
272-(defn react!
273- "Put `emoji` on the message `msgid`, for everyone in `target` to see.
274-
275- The line is `frq.irc.mutate`'s; this writes it."
276- ([conn target msgid emoji] (react! conn target msgid emoji nil))
277- ([conn target msgid emoji peer-did]
278- (send-line! conn (mutate/react-line target msgid emoji peer-did))))
279-
280-(defn unreact!
281- "Take it off again."
282- ([conn target msgid emoji] (unreact! conn target msgid emoji nil))
283- ([conn target msgid emoji peer-did]
284- (send-line! conn (mutate/unreact-line target msgid emoji peer-did))))
285-
286-
287-(defn close! [conn]
288- ;; Written straight out rather than queued: the reader may already be gone,
289- ;; and there is nothing left to lose if this one is.
290- (try (locking (:lock conn) (write! conn "QUIT :frq\r\n")) (catch Exception _ nil))
291- (try (if (= :tls (:kind conn))
292- (tls/tls-close (:tls conn))
293- (do (socket/c-close (:fd conn))
294- (ffi/free (:buf conn))))
295- (catch Exception _ nil)))
deleted file mode 100644
@@ -1,295 +0,0 @@
1-(ns frq.irc
2- "A small IRC client for freeq servers, over plain TCP.
3-
4- Two transports, behind one `read-chunk!`/`write!` pair. TLS is the default —
5- jolt.mvn-http carries OpenSSL bindings for its own HTTPS fetching, and they
6- are just as good for an IRC socket, so `:6697` works. Plain TCP is the raw
7- BSD calls: socket, connect, send, recv.
8-
9- The raw calls rather than the java.net.Socket surface jolt.socket registers,
10- because that surface does not work on Android — nor does mvn-http's
11- getaddrinfo path, which is why TLS is desktop-only there. `socket()` and
12- `connect()` themselves are fine on both, so this is what the phone gets.
13-
14- One future reads, the UI thread writes; nothing here knows about the UI.
15- `connect!` takes an `on-msg` fn and returns a connection map that
16- `send-line!` and `close!` accept."
17- (:require [clojure.string :as str]
18- [frq.atproto :as atproto]
19- [frq.irc.parse :as parse]
20- [frq.irc.handshake :as handshake]
21- [frq.irc.mutate :as mutate]
22- [frq.msgsig :as msgsig]
23- [frq.wire :as wire]
24- [jolt.ffi :as ffi]
25- [jolt.host :as host]
26- [jolt.mvn-http :as tls]
27- [jolt.socket :as socket]))
28-
29-(def ^:private af-inet 2)
30-(def ^:private sock-stream 1)
31-(def ^:private buffer-size 8192)
32-
33-;; jolt's TLS sockets carry a 30-second receive timeout, so a quiet connection
34-;; reads nothing without being closed. These decide how long that is allowed to
35-;; go on: past `idle-ping-secs` we ask the server whether it is still there,
36-;; and past `idle-dead-secs` with no answer we conclude it is not.
37-(def ^:private idle-ping-secs 45)
38-(def ^:private idle-dead-secs 90)
39-
40-;; How long a TLS read waits before giving the thread back. It is also how long
41-;; an outgoing line can sit in the outbox, so it wants to be short: the reader
42-;; owns the connection, and this is how often it looks at what there is to send.
43-(def ^:private tls-poll-ms 200)
44-
45-(defn- secs-since [t] (quot (- (host/mono-nanos) t) 1000000000))
46-
47-;; ---------------------------------------------------------------- parsing
48-;;
49-;; Moved to `frq.irc.parse` under common/, so ClojureDart compiles it too —
50-;; the wire format is the same on a phone, and only the socket under it is
51-;; not. Re-exported here rather than left to the callers: `irc/tag-value` and
52-;; `irc/nick-of` are read in twenty-three places across frq.state and frq.av,
53-;; and none of them care which file it lives in.
54-
55-(def parse-line parse/parse-line)
56-(def unescape-tag parse/unescape-tag)
57-(def escape-tag-value parse/escape-tag-value)
58-(def tag-value parse/tag-value)
59-(def nick-of parse/nick-of)
60-
61-;; ---------------------------------------------------------------- transport
62-
63-(defn- write!
64- "Bytes out, whichever transport this is. TLS callers go through the outbox
65- instead — see `send-line!`."
66- [conn text]
67- (if (= :tls (:kind conn))
68- (tls/tls-write (:tls conn) (.getBytes text))
69- (wire/send-all! (:fd conn) text)))
70-
71-(defn- read-chunk!
72- "Block for the next chunk as a string, or nil at end of stream."
73- [conn]
74- (if (= :tls (:kind conn))
75- (let [b (try (tls/tls-read (:tls conn)) (catch Exception _ nil))]
76- (when (and b (pos? (count b))) (String. b)))
77- (let [buf (:buf conn)
78- n (try (wire/recv! (:fd conn) buf buffer-size) (catch Exception _ -1))]
79- (when (and n (pos? n)) (String. (ffi/read-bytes buf n))))))
80-
81-(defn send-line!
82- "Send a raw IRC line. Safe from any thread.
83-
84- On TLS the line is queued rather than written: OpenSSL is driven here through
85- a pair of memory BIOs, and a write issued while the reader thread is parked
86- inside SSL_read is simply lost — the call reports success and the server
87- never sees the line. So the reader thread owns the connection in both
88- directions and drains this queue between reads. A raw socket has no such
89- problem, and writes straight through."
90- [conn line]
91- (let [text (str line "\r\n")]
92- (if (= :tls (:kind conn))
93- (locking (:lock conn) (swap! (:outbox conn) conj text))
94- (locking (:lock conn) (write! conn text)))))
95-
96-(declare flush-outbox-tls!)
97-
98-(defn- flush-outbox!
99- "Write whatever has been queued. Only ever called on the reader thread.
100-
101- Only a TLS connection has a queue: a raw socket is written straight from
102- whichever thread is sending, so there is nothing here to drain."
103- [conn]
104- (when (:outbox conn)
105- (flush-outbox-tls! conn)))
106-
107-(defn- trace!
108- "One line of the conversation with the server, under FRQ_TRACE.
109-
110- Whole, not the first hundred characters: the tags come first and a line
111- carries a signature long enough to push every other tag past that cut, so
112- the truncated form could not answer what a `+reply` said — or whether there
113- was one — which is the question the trace exists for.
114-
115- Except an `AUTHENTICATE` payload, which is the credential itself. A trace is
116- something you paste into a bug report, and a session token is not."
117- [dir line]
118- (when (System/getenv "FRQ_TRACE")
119- (let [line (str/trimr line)
120- line (if (str/starts-with? line "AUTHENTICATE ") "AUTHENTICATE <redacted>" line)]
121- (binding [*out* *err*] (println (str "frq/irc: " dir " " line))))))
122-
123-(defn- flush-outbox-tls! [conn]
124- (let [pending (locking (:lock conn)
125- (let [q @(:outbox conn)]
126- (reset! (:outbox conn) [])
127- q))]
128- (doseq [text pending]
129- (trace! ">>" text)
130- (try (write! conn text)
131- (catch Exception e
132- (binding [*out* *err*] (println "frq/irc: write failed:" (or (ex-message e) (str e))))
133- ;; Put it back: a write that failed for a transient reason is
134- ;; worth another turn of the loop.
135- (locking (:lock conn) (swap! (:outbox conn) conj text)))))))
136-
137-(defn- reader-loop!
138- "Read until the connection ends, splitting on CRLF and dispatching each
139- complete line. PING is answered here so a busy UI never times the link out;
140- everything else goes to `on-msg`.
141-
142- Nothing to read is not the end of the connection. On TLS it usually means the
143- 30-second receive timeout elapsed on a quiet channel — reading that as EOF is
144- what used to leave the app connected in appearance only: sends went nowhere
145- while the buffer still filled in with what the user typed. So a quiet stretch
146- gets a PING, and only silence after that counts as gone."
147- [conn on-msg]
148- (loop [acc "" last-data (host/mono-nanos) pinged? false]
149- (flush-outbox! conn)
150- (let [chunk (read-chunk! conn)]
151- (cond
152- ;; A plain socket has no timeout, so nothing to read really is the end.
153- (and (nil? chunk) (not= :tls (:kind conn)))
154- (on-msg {:command "*DISCONNECTED*" :params []})
155-
156- (nil? chunk)
157- (let [idle (secs-since last-data)]
158- (cond
159- (and pinged? (> idle idle-dead-secs))
160- (on-msg {:command "*DISCONNECTED*" :params []})
161-
162- (and (not pinged?) (> idle idle-ping-secs))
163- (do (try (send-line! conn "PING :frq") (catch Exception _ nil))
164- (recur acc last-data true))
165-
166- :else (recur acc last-data pinged?)))
167-
168- :else
169- (let [acc (str acc chunk)
170- lines (str/split acc #"\r?\n" -1)
171- complete (butlast lines)]
172- (doseq [line complete :when (seq (str/trim line))]
173- (trace! "<<" line)
174- (let [msg (parse-line line)]
175- (when (= "PING" (:command msg))
176- (send-line! conn (str "PONG :" (first (:params msg)))))
177- (on-msg (assoc msg :raw line))))
178- (recur (last lines) (host/mono-nanos) false))))))
179-
180-(defn- open
181- "Dial `host`:`port`, over TLS unless `tls?` is false."
182- [host port tls? nick]
183- (if tls?
184- (do (tls/ensure-native!)
185- (let [t (tls/tls-connect host (int port))]
186- ;; Without a short timeout the reader parks for 30 seconds at a time,
187- ;; which is 30 seconds of nothing being sent.
188- (try (#'tls/set-timeouts! (:sock t) tls-poll-ms) (catch Exception _ nil))
189- {:kind :tls :tls t :outbox (atom [])
190- :lock (Object.) :nick nick :caps (atom #{})}))
191- (let [ip (#'socket/ip->str (socket/resolve-host host))
192- fd (socket/c-socket af-inet sock-stream 0)]
193- (when (neg? fd) (throw (ex-info "socket() failed" {:host host})))
194- (let [rc (wire/connect! fd (#'socket/make-sockaddr-in ip (int port)) 16)]
195- (when (neg? rc)
196- (socket/c-close fd)
197- (throw (ex-info "connect() failed" {:host host :ip ip :port port}))))
198- {:kind :plain :fd fd :buf (ffi/alloc buffer-size)
199- :lock (Object.) :nick nick :caps (atom #{})})))
200-
201-(def cap-acked?
202- "Whether the server agreed to `cap` on this connection."
203- (fn [conn cap]
204- (handshake/acked? (when-let [caps (:caps conn)] @caps) cap)))
205-
206-(defn- cap-step!
207- "Drive capability negotiation, and the SASL exchange inside it when there is
208- a session to authenticate with. Returns the message unchanged, so the caller
209- can go on handling it.
210-
211- All of the deciding is `frq.irc.handshake`, which is shared: it answers with
212- the lines to send and this writes them. What is left here is the writing and
213- the atom the acked set lives in."
214- [conn session msg]
215- (let [caps (:caps conn)
216- {:keys [send] next-caps :caps}
217- (handshake/step {:session session :caps (when caps @caps)} msg)]
218- (when caps (reset! caps next-caps))
219- (doseq [line send] (send-line! conn line)))
220- msg)
221-
222-(defn connect!
223- "Open a connection, register `nick`, and start the reader.
224-
225- With a `session` from `frq.atproto/create-session` the registration runs the
226- SASL exchange first and the connection is bound to that DID; without one it
227- is an ordinary guest. `tls?` defaults to true — freeq's TLS listener is
228- :6697, plain is :6667."
229- ([host port nick on-msg] (connect! host port nick on-msg true nil))
230- ([host port nick on-msg tls?] (connect! host port nick on-msg tls? nil))
231- ([host port nick on-msg tls? session]
232- (let [conn (open host port tls? nick)
233- on-msg (fn [msg] (on-msg (cap-step! conn session msg)))]
234- (future
235- (try (reader-loop! conn on-msg)
236- (catch Exception e
237- (on-msg {:command "*ERROR*" :params [(str e)]}))))
238- ;; CAP first: registration waits for CAP END, which negotiation sends once
239- ;; it has an answer — after the SASL exchange, when there is one.
240- (send-line! conn "CAP LS 302")
241- (send-line! conn (str "NICK " nick))
242- (send-line! conn (str "USER " nick " 0 * :" nick))
243- conn)))
244-
245-(defn join! [conn channel] (send-line! conn (str "JOIN " channel)))
246-(defn part! [conn channel] (send-line! conn (str "PART " channel)))
247-
248-(defn privmsg!
249- "Say something. With `reply-to`, say it as an answer to that message: the
250- `+draft/reply` tag is what every other freeq client reads to thread it, and
251- what this one draws its chips from. The line is `frq.irc.mutate`'s, so both
252- halves tag a reply the same way; this writes it."
253- ([conn target text] (privmsg! conn target text nil))
254- ([conn target text reply-to]
255- (send-line! conn (mutate/say-line target text reply-to))))
256-
257-(defn edit!
258- "Rewrite something already said. The line is `frq.irc.mutate`'s; this writes
259- it."
260- ([conn target msgid text] (edit! conn target msgid text nil))
261- ([conn target msgid text peer-did]
262- (send-line! conn (mutate/edit-line target msgid text peer-did))))
263-
264-(defn tagmsg!
265- "A message that is only tags: how freeq carries a reaction, a typing hint or
266- a delete. `tags` is a map of name to value, sent in no particular order — the
267- server reads them by name."
268- [conn target tags]
269- (let [pairs (for [[k v] tags] (str k "=" (escape-tag-value v)))]
270- (send-line! conn (str "@" (str/join ";" pairs) " TAGMSG " target))))
271-
272-(defn react!
273- "Put `emoji` on the message `msgid`, for everyone in `target` to see.
274-
275- The line is `frq.irc.mutate`'s; this writes it."
276- ([conn target msgid emoji] (react! conn target msgid emoji nil))
277- ([conn target msgid emoji peer-did]
278- (send-line! conn (mutate/react-line target msgid emoji peer-did))))
279-
280-(defn unreact!
281- "Take it off again."
282- ([conn target msgid emoji] (unreact! conn target msgid emoji nil))
283- ([conn target msgid emoji peer-did]
284- (send-line! conn (mutate/unreact-line target msgid emoji peer-did))))
285-
286-
287-(defn close! [conn]
288- ;; Written straight out rather than queued: the reader may already be gone,
289- ;; and there is nothing left to lose if this one is.
290- (try (locking (:lock conn) (write! conn "QUIT :frq\r\n")) (catch Exception _ nil))
291- (try (if (= :tls (:kind conn))
292- (tls/tls-close (:tls conn))
293- (do (socket/c-close (:fd conn))
294- (ffi/free (:buf conn))))
295- (catch Exception _ nil)))
deleted src/frq/media.clj +0 -52
deleted file mode 100644
@@ -1,52 +0,0 @@
1-(ns frq.media
2- "Images in messages: spot the links, fetch them once, keep them on disk.
3-
4- The picture itself is painted by Vidya's `:image` node from a file, so all
5- this has to do is turn a URL into a path — off the UI thread, one fetch per
6- URL however many messages carry it, and never twice across runs.
7-
8- The naming half of it is not here: which links are pictures and what the
9- file behind one is called are things the Flutter half has to agree with, so
10- they live in `frq.media.core` under ../common and this is the jolt fetching
11- around them."
12- (:require [frq.media.core :as core]
13- [jolt.host :as host]
14- [jolt.mvn-http :as http]))
15-
16-(def image-urls core/image-urls)
17-
18-(defn cache-dir []
19- (let [xdg (host/getenv "XDG_CACHE_HOME")
20- home (host/getenv "HOME")]
21- (str (if (seq xdg) xdg (str home "/.cache")) "/frq/media")))
22-
23-(defn cached-path [url] (core/cached-path (cache-dir) url))
24-
25-;; url -> :fetching | :ready | :failed
26-(defonce state (atom {}))
27-
28-(defn status [url] (get @state url))
29-
30-(defn path-when-ready [url]
31- (when (= :ready (get @state url)) (cached-path url)))
32-
33-(defn fetch!
34- "Ensure the image behind `url` is on disk, in the background. Returns without
35- waiting; `path-when-ready` answers for it afterwards. `on-change` is called
36- when the answer changes, so a UI can repaint."
37- [url on-change]
38- (when-not (contains? @state url)
39- (let [path (cached-path url)]
40- (if (host/file-exists? path)
41- (do (swap! state assoc url :ready) (on-change))
42- (do
43- (swap! state assoc url :fetching)
44- (future
45- (let [ok (try
46- (host/mkdirs! (cache-dir))
47- (http/ensure-native!)
48- (and (http/fetch url path)
49- (host/file-exists? path))
50- (catch Exception _ false))]
51- (swap! state assoc url (if ok :ready :failed))
52- (on-change))))))))
deleted file mode 100644
@@ -1,52 +0,0 @@
1-(ns frq.media
2- "Images in messages: spot the links, fetch them once, keep them on disk.
3-
4- The picture itself is painted by Vidya's `:image` node from a file, so all
5- this has to do is turn a URL into a path — off the UI thread, one fetch per
6- URL however many messages carry it, and never twice across runs.
7-
8- The naming half of it is not here: which links are pictures and what the
9- file behind one is called are things the Flutter half has to agree with, so
10- they live in `frq.media.core` under ../common and this is the jolt fetching
11- around them."
12- (:require [frq.media.core :as core]
13- [jolt.host :as host]
14- [jolt.mvn-http :as http]))
15-
16-(def image-urls core/image-urls)
17-
18-(defn cache-dir []
19- (let [xdg (host/getenv "XDG_CACHE_HOME")
20- home (host/getenv "HOME")]
21- (str (if (seq xdg) xdg (str home "/.cache")) "/frq/media")))
22-
23-(defn cached-path [url] (core/cached-path (cache-dir) url))
24-
25-;; url -> :fetching | :ready | :failed
26-(defonce state (atom {}))
27-
28-(defn status [url] (get @state url))
29-
30-(defn path-when-ready [url]
31- (when (= :ready (get @state url)) (cached-path url)))
32-
33-(defn fetch!
34- "Ensure the image behind `url` is on disk, in the background. Returns without
35- waiting; `path-when-ready` answers for it afterwards. `on-change` is called
36- when the answer changes, so a UI can repaint."
37- [url on-change]
38- (when-not (contains? @state url)
39- (let [path (cached-path url)]
40- (if (host/file-exists? path)
41- (do (swap! state assoc url :ready) (on-change))
42- (do
43- (swap! state assoc url :fetching)
44- (future
45- (let [ok (try
46- (host/mkdirs! (cache-dir))
47- (http/ensure-native!)
48- (and (http/fetch url path)
49- (host/file-exists? path))
50- (catch Exception _ false))]
51- (swap! state assoc url (if ok :ready :failed))
52- (on-change))))))))
deleted src/frq/moq/client.clj +0 -245
deleted file mode 100644
@@ -1,245 +0,0 @@
1-(ns frq.moq.client
2- "A MoQ client and the session a connect answers.
3-
4- This is the first thing in `frq.moq.*` that does transport rather than
5- describe it, and two rules from UniFFI's object model shape all of it.
6-
7- **A method consumes a clone of the handle.** UniFFI's generated bindings
8- clone before every call and let the callee own that clone — so `call` here
9- does the same. Passing the handle itself would hand ownership away and leave
10- the next call reading a freed object, which is the kind of bug that surfaces
11- minutes later in an allocator rather than at the call that caused it.
12-
13- **A connect is a future, not a call.** `connect!` starts one and answers
14- immediately; `poll-connect!` is what a caller on the loop thread asks, and
15- it answers nil until the far side is ready. That is the same shape
16- `frq.av/pump!` already has, and it is deliberate — glimmer runs the loop
17- thread, and a blocking connect on it is a frozen window for as long as a
18- QUIC handshake takes, or for the full timeout when a relay is unreachable."
19- (:require [frq.moq.uniffi :as uniffi]
20- [frq.moq.raw :as raw]
21- [jolt.ffi :as ffi]))
22-
23-;; --- handles -----------------------------------------------------------------
24-
25-(defn- clone-client [h] (uniffi/with-out-status #(raw/clone-moqclient h %)))
26-(defn- clone-session [h] (uniffi/with-out-status #(raw/clone-moqsession h %)))
27-
28-(defn free-client!
29- "Release a client handle. The sessions it opened outlive it."
30- [h]
31- (uniffi/with-out-status #(raw/free-moqclient h %))
32- nil)
33-
34-(defn free-session!
35- "Release a session handle.
36-
37- Not the same as closing the session — `shutdown!` ends the conversation,
38- this only drops our reference to it."
39- [h]
40- (uniffi/with-out-status #(raw/free-moqsession h %))
41- nil)
42-
43-;; --- the client --------------------------------------------------------------
44-
45-(defn new-client
46- "A MoqClient with the object's own defaults: binds `[::]:0`, verifies against
47- the system roots."
48- []
49- (uniffi/with-out-status #(raw/constructor-moqclient-new %)))
50-
51-(defn set-bind!
52- "Set the local UDP bind address, e.g. \"0.0.0.0:0\"."
53- [client addr]
54- (ffi/with-arena [a]
55- (let [buf (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
56- (uniffi/lower-string buf addr)
57- (uniffi/with-out-status
58- #(raw/method-moqclient-set-bind (clone-client client) buf %))))
59- nil)
60-
61-(defn connect!
62- "Begin connecting to `url`; answers a future to poll, not a session.
63-
64- The lowered URL is NOT freed here. UniFFI's convention is that a lowered
65- argument is handed over with its ownership — the callee frees it — so
66- freeing it on this side would be the second free.
67-
68- The future is a :u64 one because a MoqSession crosses as a handle, and a
69- handle is a u64 whatever it points at."
70- [client url]
71- (ffi/with-arena [a]
72- (let [buf (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
73- (uniffi/lower-string buf url)
74- (-> (raw/method-moqclient-connect (clone-client client) buf)
75- (uniffi/start-future :u64)))))
76-
77-(defn poll-connect!
78- "Answer the session handle once the connect has settled, or nil while it has
79- not. Raises what the far side raised if the connect failed.
80-
81- Safe to call from the loop thread as often as a timer fires: when the future
82- is not ready this issues another poll and returns, and nothing here blocks."
83- [fut]
84- (when (uniffi/settled? fut)
85- (uniffi/complete! fut)))
86-
87-;; --- the session -------------------------------------------------------------
88-
89-;; --- sessions ---------------------------------------------------------------
90-
91-(defn session-publisher
92- "The origin this session publishes INTO. Broadcasts created here go out
93- over the wire."
94- [session]
95- (uniffi/with-out-status
96- #(raw/method-moqsession-publisher (clone-session session) %)))
97-
98-(defn session-consumer
99- "The origin this session receives FROM. Peers' broadcasts are announced
100- here."
101- [session]
102- (uniffi/with-out-status
103- #(raw/method-moqsession-consumer (clone-session session) %)))
104-
105-(defn- lower-strings
106- "A Sequence<String>: an i32 count, then each string length-prefixed."
107- [dest xs]
108- (uniffi/lower-buffer dest (into [[:i32 (count xs)]]
109- (map (fn [x] [:string x])) xs)))
110-
111-(defn set-tls-fingerprints!
112- "Trust exactly these certificate fingerprints.
113-
114- What a self-signed relay needs: the certificate is not in any root store,
115- and the alternative — disabling verification altogether — trusts whatever
116- answers the address."
117- [client fingerprints]
118- (ffi/with-arena [a]
119- (let [buf (lower-strings (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))
120- fingerprints)]
121- (uniffi/with-out-status
122- #(raw/method-moqclient-set-tls-fingerprints (clone-client client) buf %))))
123- nil)
124-
125-;; --- a relay ----------------------------------------------------------------
126-;; Not for production — for having something real to connect TO. A MoQ call
127-;; needs a relay in the middle, and standing one up in-process is what lets
128-;; the session path be exercised over actual QUIC rather than mocked.
129-
130-(defn- clone-server [h] (uniffi/with-out-status #(raw/clone-moqserver h %)))
131-(defn- clone-request [h] (uniffi/with-out-status #(raw/clone-moqrequest h %)))
132-
133-(defn new-server [] (uniffi/with-out-status #(raw/constructor-moqserver-new %)))
134-
135-(defn server-bind! [server addr]
136- (ffi/with-arena [a]
137- (let [buf (uniffi/lower-string (ffi/alloc a (ffi/layout-size uniffi/rust-buffer)) addr)]
138- (uniffi/with-out-status #(raw/method-moqserver-set-bind (clone-server server) buf %))))
139- nil)
140-
141-(defn server-tls-generate!
142- "Make a self-signed certificate for these hostnames."
143- [server hostnames]
144- (ffi/with-arena [a]
145- (let [buf (lower-strings (ffi/alloc a (ffi/layout-size uniffi/rust-buffer)) hostnames)]
146- (uniffi/with-out-status
147- #(raw/method-moqserver-set-tls-generate (clone-server server) buf %))))
148- nil)
149-
150-(defn server-origin!
151- "Wire one origin as both what the relay publishes and what it consumes.
152-
153- That is what makes it a relay rather than two unrelated halves: a
154- broadcast arriving from one session is announced to every other."
155- [server origin]
156- (ffi/with-arena [a]
157- (let [cell #(ffi/alloc a (ffi/layout-size uniffi/rust-buffer))
158- ;; Optional<MoqOriginProducer>: present, then the handle.
159- ops [[:u8 1] [:u64 origin]]]
160- (uniffi/with-out-status
161- #(raw/method-moqserver-set-publish
162- (clone-server server) (uniffi/lower-buffer (cell) ops) %))
163- (uniffi/with-out-status
164- #(raw/method-moqserver-set-consume
165- (clone-server server) (uniffi/lower-buffer (cell) ops) %))))
166- nil)
167-
168-(defn server-listen!
169- "Bind the socket; answers a future settling to the bound address.
170-
171- Async, which is easy to miss — `listen` reads like a synchronous bind and
172- answering it without awaiting leaves the server not listening, which the
173- next call reports as `bind: not listening; call listen() first`."
174- [server]
175- (-> (raw/method-moqserver-listen (clone-server server))
176- (uniffi/start-future :rb)))
177-
178-(defn server-fingerprints
179- [server]
180- (ffi/with-arena [a]
181- (let [out (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
182- (uniffi/with-out-status
183- #(raw/method-moqserver-cert-fingerprints out (clone-server server) %))
184- (let [len (ffi/read-field out uniffi/rust-buffer [:len])
185- data (ffi/read-field out uniffi/rust-buffer [:data])
186- v (if (pos? len)
187- (uniffi/r-list! (uniffi/reader data len) uniffi/r-string!)
188- [])]
189- (uniffi/with-out-status #(raw/rustbuffer-free out %))
190- v))))
191-
192-(defn server-accept!
193- "Wait for the next incoming session; answers an :rb future settling to an
194- Optional<MoqRequest> handle."
195- [server]
196- (-> (raw/method-moqserver-accept (clone-server server))
197- (uniffi/start-future :rb)))
198-
199-(defn accept-request!
200- "Accept an incoming session; answers a future settling to a MoqSession."
201- [request]
202- (-> (raw/method-moqrequest-accept (clone-request request))
203- (uniffi/start-future :u64)))
204-
205-(defn server-cancel! [server]
206- (uniffi/with-out-status #(raw/method-moqserver-cancel (clone-server server) %))
207- nil)
208-
209-(defn watch-closed!
210- "A :void future that settles when the session closes.
211-
212- Settles QUIETLY on a clean close and RAISES on a dirty one, with the
213- MoqError saying why — so completing it is how the reason for a dropped
214- call is learned, rather than by noticing that frames stopped."
215- [session]
216- (-> (raw/method-moqsession-closed (clone-session session))
217- (uniffi/start-future :void)))
218-
219-(defn shutdown!
220- "Graceful shutdown — equivalent to `(cancel! session 0)`.
221-
222- NOT OPTIONAL BEFORE THE HANDLE GOES. A session dropped without one panics
223- the process — `there is no reactor running, must be called from the
224- context of a Tokio 1.x runtime` — because the drop tries to close the
225- QUIC connection from whatever thread happened to free it. Either this or
226- `cancel!` has to run first, and `frq.av.plane` does it in a finally.
227-
228- Named as upstream names it: UniFFI's Kotlin generator already emits a
229- `close()` that releases the FFI handle, so `close` would mean two different
230- things depending on which side of the binding you were reading."
231- [session]
232- (uniffi/with-out-status
233- #(raw/method-moqsession-shutdown (clone-session session) %))
234- nil)
235-
236-(defn cancel!
237- "Close the session with an error code.
238-
239- Code 0 is \"no error\", which is what `shutdown!` sends — upstream documents
240- it that way so a caller ending a call normally does not have to invent one."
241- ([session] (cancel! session 0))
242- ([session code]
243- (uniffi/with-out-status
244- #(raw/method-moqsession-cancel (clone-session session) code %))
245- nil))
deleted file mode 100644
@@ -1,245 +0,0 @@
1-(ns frq.moq.client
2- "A MoQ client and the session a connect answers.
3-
4- This is the first thing in `frq.moq.*` that does transport rather than
5- describe it, and two rules from UniFFI's object model shape all of it.
6-
7- **A method consumes a clone of the handle.** UniFFI's generated bindings
8- clone before every call and let the callee own that clone — so `call` here
9- does the same. Passing the handle itself would hand ownership away and leave
10- the next call reading a freed object, which is the kind of bug that surfaces
11- minutes later in an allocator rather than at the call that caused it.
12-
13- **A connect is a future, not a call.** `connect!` starts one and answers
14- immediately; `poll-connect!` is what a caller on the loop thread asks, and
15- it answers nil until the far side is ready. That is the same shape
16- `frq.av/pump!` already has, and it is deliberate — glimmer runs the loop
17- thread, and a blocking connect on it is a frozen window for as long as a
18- QUIC handshake takes, or for the full timeout when a relay is unreachable."
19- (:require [frq.moq.uniffi :as uniffi]
20- [frq.moq.raw :as raw]
21- [jolt.ffi :as ffi]))
22-
23-;; --- handles -----------------------------------------------------------------
24-
25-(defn- clone-client [h] (uniffi/with-out-status #(raw/clone-moqclient h %)))
26-(defn- clone-session [h] (uniffi/with-out-status #(raw/clone-moqsession h %)))
27-
28-(defn free-client!
29- "Release a client handle. The sessions it opened outlive it."
30- [h]
31- (uniffi/with-out-status #(raw/free-moqclient h %))
32- nil)
33-
34-(defn free-session!
35- "Release a session handle.
36-
37- Not the same as closing the session — `shutdown!` ends the conversation,
38- this only drops our reference to it."
39- [h]
40- (uniffi/with-out-status #(raw/free-moqsession h %))
41- nil)
42-
43-;; --- the client --------------------------------------------------------------
44-
45-(defn new-client
46- "A MoqClient with the object's own defaults: binds `[::]:0`, verifies against
47- the system roots."
48- []
49- (uniffi/with-out-status #(raw/constructor-moqclient-new %)))
50-
51-(defn set-bind!
52- "Set the local UDP bind address, e.g. \"0.0.0.0:0\"."
53- [client addr]
54- (ffi/with-arena [a]
55- (let [buf (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
56- (uniffi/lower-string buf addr)
57- (uniffi/with-out-status
58- #(raw/method-moqclient-set-bind (clone-client client) buf %))))
59- nil)
60-
61-(defn connect!
62- "Begin connecting to `url`; answers a future to poll, not a session.
63-
64- The lowered URL is NOT freed here. UniFFI's convention is that a lowered
65- argument is handed over with its ownership — the callee frees it — so
66- freeing it on this side would be the second free.
67-
68- The future is a :u64 one because a MoqSession crosses as a handle, and a
69- handle is a u64 whatever it points at."
70- [client url]
71- (ffi/with-arena [a]
72- (let [buf (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
73- (uniffi/lower-string buf url)
74- (-> (raw/method-moqclient-connect (clone-client client) buf)
75- (uniffi/start-future :u64)))))
76-
77-(defn poll-connect!
78- "Answer the session handle once the connect has settled, or nil while it has
79- not. Raises what the far side raised if the connect failed.
80-
81- Safe to call from the loop thread as often as a timer fires: when the future
82- is not ready this issues another poll and returns, and nothing here blocks."
83- [fut]
84- (when (uniffi/settled? fut)
85- (uniffi/complete! fut)))
86-
87-;; --- the session -------------------------------------------------------------
88-
89-;; --- sessions ---------------------------------------------------------------
90-
91-(defn session-publisher
92- "The origin this session publishes INTO. Broadcasts created here go out
93- over the wire."
94- [session]
95- (uniffi/with-out-status
96- #(raw/method-moqsession-publisher (clone-session session) %)))
97-
98-(defn session-consumer
99- "The origin this session receives FROM. Peers' broadcasts are announced
100- here."
101- [session]
102- (uniffi/with-out-status
103- #(raw/method-moqsession-consumer (clone-session session) %)))
104-
105-(defn- lower-strings
106- "A Sequence<String>: an i32 count, then each string length-prefixed."
107- [dest xs]
108- (uniffi/lower-buffer dest (into [[:i32 (count xs)]]
109- (map (fn [x] [:string x])) xs)))
110-
111-(defn set-tls-fingerprints!
112- "Trust exactly these certificate fingerprints.
113-
114- What a self-signed relay needs: the certificate is not in any root store,
115- and the alternative — disabling verification altogether — trusts whatever
116- answers the address."
117- [client fingerprints]
118- (ffi/with-arena [a]
119- (let [buf (lower-strings (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))
120- fingerprints)]
121- (uniffi/with-out-status
122- #(raw/method-moqclient-set-tls-fingerprints (clone-client client) buf %))))
123- nil)
124-
125-;; --- a relay ----------------------------------------------------------------
126-;; Not for production — for having something real to connect TO. A MoQ call
127-;; needs a relay in the middle, and standing one up in-process is what lets
128-;; the session path be exercised over actual QUIC rather than mocked.
129-
130-(defn- clone-server [h] (uniffi/with-out-status #(raw/clone-moqserver h %)))
131-(defn- clone-request [h] (uniffi/with-out-status #(raw/clone-moqrequest h %)))
132-
133-(defn new-server [] (uniffi/with-out-status #(raw/constructor-moqserver-new %)))
134-
135-(defn server-bind! [server addr]
136- (ffi/with-arena [a]
137- (let [buf (uniffi/lower-string (ffi/alloc a (ffi/layout-size uniffi/rust-buffer)) addr)]
138- (uniffi/with-out-status #(raw/method-moqserver-set-bind (clone-server server) buf %))))
139- nil)
140-
141-(defn server-tls-generate!
142- "Make a self-signed certificate for these hostnames."
143- [server hostnames]
144- (ffi/with-arena [a]
145- (let [buf (lower-strings (ffi/alloc a (ffi/layout-size uniffi/rust-buffer)) hostnames)]
146- (uniffi/with-out-status
147- #(raw/method-moqserver-set-tls-generate (clone-server server) buf %))))
148- nil)
149-
150-(defn server-origin!
151- "Wire one origin as both what the relay publishes and what it consumes.
152-
153- That is what makes it a relay rather than two unrelated halves: a
154- broadcast arriving from one session is announced to every other."
155- [server origin]
156- (ffi/with-arena [a]
157- (let [cell #(ffi/alloc a (ffi/layout-size uniffi/rust-buffer))
158- ;; Optional<MoqOriginProducer>: present, then the handle.
159- ops [[:u8 1] [:u64 origin]]]
160- (uniffi/with-out-status
161- #(raw/method-moqserver-set-publish
162- (clone-server server) (uniffi/lower-buffer (cell) ops) %))
163- (uniffi/with-out-status
164- #(raw/method-moqserver-set-consume
165- (clone-server server) (uniffi/lower-buffer (cell) ops) %))))
166- nil)
167-
168-(defn server-listen!
169- "Bind the socket; answers a future settling to the bound address.
170-
171- Async, which is easy to miss — `listen` reads like a synchronous bind and
172- answering it without awaiting leaves the server not listening, which the
173- next call reports as `bind: not listening; call listen() first`."
174- [server]
175- (-> (raw/method-moqserver-listen (clone-server server))
176- (uniffi/start-future :rb)))
177-
178-(defn server-fingerprints
179- [server]
180- (ffi/with-arena [a]
181- (let [out (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
182- (uniffi/with-out-status
183- #(raw/method-moqserver-cert-fingerprints out (clone-server server) %))
184- (let [len (ffi/read-field out uniffi/rust-buffer [:len])
185- data (ffi/read-field out uniffi/rust-buffer [:data])
186- v (if (pos? len)
187- (uniffi/r-list! (uniffi/reader data len) uniffi/r-string!)
188- [])]
189- (uniffi/with-out-status #(raw/rustbuffer-free out %))
190- v))))
191-
192-(defn server-accept!
193- "Wait for the next incoming session; answers an :rb future settling to an
194- Optional<MoqRequest> handle."
195- [server]
196- (-> (raw/method-moqserver-accept (clone-server server))
197- (uniffi/start-future :rb)))
198-
199-(defn accept-request!
200- "Accept an incoming session; answers a future settling to a MoqSession."
201- [request]
202- (-> (raw/method-moqrequest-accept (clone-request request))
203- (uniffi/start-future :u64)))
204-
205-(defn server-cancel! [server]
206- (uniffi/with-out-status #(raw/method-moqserver-cancel (clone-server server) %))
207- nil)
208-
209-(defn watch-closed!
210- "A :void future that settles when the session closes.
211-
212- Settles QUIETLY on a clean close and RAISES on a dirty one, with the
213- MoqError saying why — so completing it is how the reason for a dropped
214- call is learned, rather than by noticing that frames stopped."
215- [session]
216- (-> (raw/method-moqsession-closed (clone-session session))
217- (uniffi/start-future :void)))
218-
219-(defn shutdown!
220- "Graceful shutdown — equivalent to `(cancel! session 0)`.
221-
222- NOT OPTIONAL BEFORE THE HANDLE GOES. A session dropped without one panics
223- the process — `there is no reactor running, must be called from the
224- context of a Tokio 1.x runtime` — because the drop tries to close the
225- QUIC connection from whatever thread happened to free it. Either this or
226- `cancel!` has to run first, and `frq.av.plane` does it in a finally.
227-
228- Named as upstream names it: UniFFI's Kotlin generator already emits a
229- `close()` that releases the FFI handle, so `close` would mean two different
230- things depending on which side of the binding you were reading."
231- [session]
232- (uniffi/with-out-status
233- #(raw/method-moqsession-shutdown (clone-session session) %))
234- nil)
235-
236-(defn cancel!
237- "Close the session with an error code.
238-
239- Code 0 is \"no error\", which is what `shutdown!` sends — upstream documents
240- it that way so a caller ending a call normally does not have to invent one."
241- ([session] (cancel! session 0))
242- ([session code]
243- (uniffi/with-out-status
244- #(raw/method-moqsession-cancel (clone-session session) code %))
245- nil))
deleted src/frq/moq/media.clj +0 -461
deleted file mode 100644
@@ -1,461 +0,0 @@
1-(ns frq.moq.media
2- "Broadcasts, media tracks, and the frames that cross them.
3-
4- An origin is the piece that makes this testable without a network. A
5- `MoqOriginProducer` is a plain constructor, not something a session hands
6- out: create one, create a broadcast under it, publish a media track, and
7- `consume` gives you the subscriber's side of the very same broadcast. No
8- QUIC, no relay, no second process — which is how `frq.moq.smoke` can put a
9- frame in and take the same frame out.
10-
11- Over a session the shapes are identical; only where the origin comes from
12- changes, so what is exercised locally is what runs over the wire.
13-
14- ON FRAME PAYLOADS. `frame-payload` answers a jolt string, and that is a
15- DEBUGGING affordance, not the path a real frame should take. UniFFI gives
16- Bytes and String the same wire shape — an i32 length and that many bytes —
17- so a payload survives the trip only while it happens to be text. H.264 is
18- not text. `frq.av`'s rule is that a video frame goes from the decoder's
19- buffer to the texture as a pointer and never becomes a jolt value at all,
20- and honouring that here means reading the payload's address out of the
21- buffer and handing it on — which is what the real subscribe path will do,
22- and what this namespace does not do yet."
23- (:require [frq.moq.uniffi :as uniffi]
24- [frq.moq.raw :as raw]
25- [jolt.ffi :as ffi]))
26-
27-(defn- lowered
28- "Run `f` with a RustBuffer holding `ops`."
29- [ops f]
30- (ffi/with-arena [a]
31- (let [buf (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
32- (uniffi/lower-buffer buf ops)
33- (f buf))))
34-
35-;; --- containers --------------------------------------------------------------
36-
37-(def containers
38- "MoqContainer, as UniFFI numbers it. LEGACY and LOC carry nothing; CMAF
39- carries its init segment, which `container-ops` does not build yet."
40- {:legacy 1 :cmaf 2 :loc 3})
41-
42-(defn container-ops [kind]
43- (let [v (or (containers kind)
44- (throw (ex-info "unknown MoqContainer" {:kind kind
45- :known (keys containers)})))]
46- (when (= kind :cmaf)
47- (throw (ex-info "CMAF needs its init segment, which is not built here" {})))
48- [[:i32 v]]))
49-
50-;; --- origins and broadcasts --------------------------------------------------
51-
52-(defn new-origin
53- "A MoqOriginProducer with default options (no cache cap)."
54- []
55- (lowered [[:u8 0]] ; MoqOriginOptions{cache_capacity_bytes: None}
56- (fn [buf]
57- (uniffi/with-out-status
58- #(raw/constructor-moqoriginproducer-new buf %)))))
59-
60-(defn create-broadcast!
61- "A MoqBroadcastProducer at `path` under this origin.
62-
63- `path` goes through `lower-string`, not the record encoder: see the note on
64- `subscribe-media!` about which strings carry a length and which do not."
65- [origin path]
66- (let [h (uniffi/with-out-status #(raw/clone-moqoriginproducer origin %))]
67- (ffi/with-arena [a]
68- (let [buf (uniffi/lower-string
69- (ffi/alloc a (ffi/layout-size uniffi/rust-buffer)) path)]
70- (uniffi/with-out-status
71- #(raw/method-moqoriginproducer-create-broadcast h buf %))))))
72-
73-(defn broadcast-consumer
74- "The subscriber's side of a broadcast we produce."
75- [broadcast]
76- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastproducer broadcast %))]
77- (uniffi/with-out-status #(raw/method-moqbroadcastproducer-consume h %))))
78-
79-;; --- discovery --------------------------------------------------------------
80-;; How a peer is FOUND, rather than known. `subscribe-media!` needs a path and
81-;; a track name; announcements are where those come from when the other side
82-;; of the call is somebody else's client rather than a broadcast we made.
83-
84-(defn origin-consumer
85- "The subscriber's side of an origin."
86- [origin]
87- (let [h (uniffi/with-out-status #(raw/clone-moqoriginproducer origin %))]
88- (uniffi/with-out-status #(raw/method-moqoriginproducer-consume h %))))
89-
90-(defn announced!
91- "Watch for broadcasts whose path starts with `prefix`; answers a MoqAnnounced.
92-
93- An empty prefix watches everything on the origin, which in a call is what
94- you want: every participant is a broadcast and none of their paths are
95- known in advance."
96- [origin-consumer prefix]
97- (let [h (uniffi/with-out-status #(raw/clone-moqoriginconsumer origin-consumer %))]
98- (ffi/with-arena [a]
99- (let [buf (uniffi/lower-string
100- (ffi/alloc a (ffi/layout-size uniffi/rust-buffer)) prefix)]
101- (uniffi/with-out-status
102- #(raw/method-moqoriginconsumer-announced h buf %))))))
103-
104-(defn next-announcement!
105- "Ask for the next announcement; answers an :rb future.
106-
107- It settles to an Optional<MoqAnnouncement> — absent when the origin has
108- closed, which is the end of the watch rather than an error."
109- [announced]
110- (let [h (uniffi/with-out-status #(raw/clone-moqannounced announced %))]
111- (-> (raw/method-moqannounced-next h)
112- (uniffi/start-future :rb))))
113-
114-(defn lift-optional-handle
115- "Read an Optional<interface> from a settled :rb buffer as a handle.
116-
117- An interface crosses as a u64 the far side has already cloned for us, so
118- freeing the buffer it arrived in does not touch it. Announcements,
119- requests and anything else optional-and-opaque come back this way."
120- [rb-ptr]
121- (let [len (ffi/read-field rb-ptr uniffi/rust-buffer [:len])
122- data (ffi/read-field rb-ptr uniffi/rust-buffer [:data])
123- v (when (and (pos? len) (not (ffi/null? data)))
124- (uniffi/r-optional! (uniffi/reader data len) uniffi/r-u64!))]
125- (uniffi/with-out-status #(raw/rustbuffer-free rb-ptr %))
126- v))
127-
128-(defn lift-announcement
129- "Read an Optional<MoqAnnouncement> from a settled :rb buffer.
130-
131- Answers the announcement's HANDLE, which outlives the buffer — an interface
132- crosses as a u64 the far side has already cloned for us, so freeing the
133- buffer it arrived in does not touch it."
134- [rb-ptr]
135- (let [len (ffi/read-field rb-ptr uniffi/rust-buffer [:len])
136- data (ffi/read-field rb-ptr uniffi/rust-buffer [:data])
137- v (when (and (pos? len) (not (ffi/null? data)))
138- (let [c (uniffi/reader data len)]
139- (uniffi/r-optional! c uniffi/r-u64!)))]
140- (uniffi/with-out-status #(raw/rustbuffer-free rb-ptr %))
141- v))
142-
143-(defn announcement-path
144- [ann]
145- (let [h (uniffi/with-out-status #(raw/clone-moqannouncement ann %))]
146- (ffi/with-arena [a]
147- (let [out (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
148- (uniffi/with-out-status #(raw/method-moqannouncement-path out h %))
149- (uniffi/lift-string out)))))
150-
151-(defn announcement-broadcast
152- "The MoqBroadcastConsumer this announcement is for."
153- [ann]
154- (let [h (uniffi/with-out-status #(raw/clone-moqannouncement ann %))]
155- (uniffi/with-out-status #(raw/method-moqannouncement-broadcast h %))))
156-
157-;; --- the catalog ------------------------------------------------------------
158-;; What a peer's broadcast says it contains. Two things come out of it that
159-;; cannot be guessed from the outside: the track NAMES, and each track's
160-;; CONTAINER — which is the LOC-versus-LEGACY question that answers
161-;; `mux: loc: malformed loc properties` when got wrong.
162-
163-(def ^:private container-names
164- (into {} (map (fn [[k v]] [v k])) containers))
165-
166-(defn- r-dimensions [c]
167- {:width (uniffi/r-i32! c) :height (uniffi/r-i32! c)})
168-
169-(defn- r-container [c]
170- (let [v (uniffi/r-i32! c)]
171- (when (= v (containers :cmaf)) (uniffi/r-bytes-span! c)) ; init blob, skipped
172- (container-names v :unknown)))
173-
174-(defn- r-video [c]
175- {:codec (uniffi/r-string! c)
176- :description (uniffi/r-optional! c uniffi/r-bytes-span!)
177- :coded (uniffi/r-optional! c r-dimensions)
178- :aspect (uniffi/r-optional! c r-dimensions)
179- :bitrate (uniffi/r-optional! c uniffi/r-u64!)
180- :stalled (uniffi/r-bool! c)
181- :framerate (uniffi/r-optional! c uniffi/r-f64!)
182- :container (r-container c)})
183-
184-(defn- r-audio [c]
185- {:codec (uniffi/r-string! c)
186- :description (uniffi/r-optional! c uniffi/r-bytes-span!)
187- :sample-rate (uniffi/r-i32! c)
188- :channels (uniffi/r-i32! c)
189- :bitrate (uniffi/r-optional! c uniffi/r-u64!)
190- :container (r-container c)})
191-
192-(defn next-catalog!
193- "Ask for the next catalog update; answers an :rb future."
194- [catalog-consumer]
195- (let [h (uniffi/with-out-status #(raw/clone-moqcatalogconsumer catalog-consumer %))]
196- (-> (raw/method-moqcatalogconsumer-next h)
197- (uniffi/start-future :rb))))
198-
199-(defn lift-catalog
200- "Read an Optional<MoqCatalog> from a settled :rb buffer.
201-
202- Every field is read even though only `:video` is used yet: the fields are
203- positional in the buffer, so skipping one means parsing it anyway, and
204- half-parsing a record is how the next field comes out as nonsense."
205- [rb-ptr]
206- (let [len (ffi/read-field rb-ptr uniffi/rust-buffer [:len])
207- data (ffi/read-field rb-ptr uniffi/rust-buffer [:data])
208- v (when (and (pos? len) (not (ffi/null? data)))
209- (let [c (uniffi/reader data len)]
210- (uniffi/r-optional!
211- c (fn [c]
212- {:video (uniffi/r-map! c r-video)
213- :audio (uniffi/r-map! c r-audio)
214- :display (uniffi/r-optional! c r-dimensions)
215- :rotation (uniffi/r-optional! c uniffi/r-f64!)
216- :flip (uniffi/r-optional! c uniffi/r-bool!)
217- :sections (uniffi/r-map! c uniffi/r-string!)}))))]
218- (uniffi/with-out-status #(raw/rustbuffer-free rb-ptr %))
219- v))
220-
221-(defn subscribe-catalog!
222- "Watch a broadcast's catalog; answers a future settling to a
223- MoqCatalogConsumer.
224-
225- What names a peer's tracks. `subscribe-media!` wants a track name, and on a
226- broadcast we did not publish there is nothing else to learn it from."
227- [broadcast-consumer]
228- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastconsumer broadcast-consumer %))]
229- (-> (raw/method-moqbroadcastconsumer-subscribe-catalog h)
230- (uniffi/start-future :u64))))
231-
232-;; --- publishing --------------------------------------------------------------
233-
234-(defn publish-media!
235- "Publish a media track and answer its MoqMediaProducer.
236-
237- `init` is a MoqInit: a format string, an init blob, and an optional video
238- hint. The blob goes out through the :string op because UniFFI lowers Bytes
239- and String identically — an i32 length and that many bytes."
240- ([broadcast format] (publish-media! broadcast format ""))
241- ([broadcast format init-data]
242- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastproducer broadcast %))]
243- (lowered [[:string format] [:string init-data] [:u8 0]]
244- (fn [buf]
245- (uniffi/with-out-status
246- #(raw/method-moqbroadcastproducer-publish-media h buf %)))))))
247-
248-(defn publish-media-bytes!
249- "Publish a media track whose init blob is RAW bytes.
250-
251- `publish-media!` above takes the blob as a string, which is fine for the
252- empty one a video track uses and wrong for an OpusHead — that is 19 bytes
253- of little-endian header with interior zeros, and routing it through a jolt
254- string would not survive."
255- [broadcast format ptr len]
256- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastproducer broadcast %))]
257- (lowered [[:string format] [:bytes [ptr len]] [:u8 0]]
258- (fn [buf]
259- (uniffi/with-out-status
260- #(raw/method-moqbroadcastproducer-publish-media h buf %))))))
261-
262-(defn producer-name
263- "The track name the object chose for this producer — what a subscriber asks
264- for by name."
265- [producer]
266- (let [h (uniffi/with-out-status #(raw/clone-moqmediaproducer producer %))]
267- (ffi/with-arena [a]
268- (let [out (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
269- (uniffi/with-out-status #(raw/method-moqmediaproducer-name out h %))
270- (uniffi/lift-string out)))))
271-
272-(defn write-frame!
273- "Write one MoqFrame to a media producer: a payload and a microsecond stamp.
274-
275- MoqFrame, NOT MoqMediaFrame. The two names differ by one word and by one
276- field — what a consumer hands back carries a `keyframe` flag, and what a
277- producer takes does not, because the container works that out from the
278- bitstream. Sending the extra byte is not an ABI error: the object lifts the
279- record, finds a byte left over, and panics with `junk data left in buffer`."
280- [producer payload timestamp-us]
281- (let [h (uniffi/with-out-status #(raw/clone-moqmediaproducer producer %))]
282- (lowered [[:string payload] [:u64 timestamp-us]]
283- (fn [buf]
284- (uniffi/with-out-status
285- #(raw/method-moqmediaproducer-write-frame h buf %)))))
286- nil)
287-
288-;; --- opaque tracks -----------------------------------------------------------
289-;; The same broadcast, without a codec in the way. `publish_track` and
290-;; `subscribe_track` move plain byte payloads and parse nothing, which is what
291-;; a round trip can actually be checked against: a media track declares a
292-;; format, and `avc3` means the container really does try to read Annex B out
293-;; of whatever is handed to it.
294-
295-(defn publish-track!
296- "Publish an opaque track by name; answers a MoqTrackProducer."
297- [broadcast name]
298- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastproducer broadcast %))]
299- (ffi/with-arena [a]
300- (let [cell #(ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
301- (uniffi/with-out-status
302- #(raw/method-moqbroadcastproducer-publish-track
303- h
304- (uniffi/lower-string (cell) name)
305- (uniffi/lower-buffer (cell) [[:u8 0]]) ; Optional<MoqTrackInfo>
306- %))))))
307-
308-(defn write-track-frame!
309- "Write one MoqFrame to an opaque track producer."
310- [producer payload timestamp-us]
311- (let [h (uniffi/with-out-status #(raw/clone-moqtrackproducer producer %))]
312- (lowered [[:string payload] [:u64 timestamp-us]]
313- (fn [buf]
314- (uniffi/with-out-status
315- #(raw/method-moqtrackproducer-write-frame h buf %)))))
316- nil)
317-
318-(defn subscribe-track!
319- "Subscribe to an opaque track; answers a future settling to a
320- MoqTrackConsumer."
321- [broadcast-consumer name]
322- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastconsumer broadcast-consumer %))]
323- (ffi/with-arena [a]
324- (let [cell #(ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
325- (-> (raw/method-moqbroadcastconsumer-subscribe-track
326- h
327- (uniffi/lower-string (cell) name)
328- (uniffi/lower-buffer (cell) [[:u8 0]])) ; Optional<MoqSubscription>
329- (uniffi/start-future :u64))))))
330-
331-(defn read-frame!
332- "Ask an opaque track for its next frame; answers an :rb future."
333- [consumer]
334- (let [h (uniffi/with-out-status #(raw/clone-moqtrackconsumer consumer %))]
335- (-> (raw/method-moqtrackconsumer-read-frame h)
336- (uniffi/start-future :rb))))
337-
338-(defn lift-plain-frame
339- "Read an Optional<MoqFrame> out of a settled :rb buffer, and free it."
340- [rb-ptr]
341- (let [len (ffi/read-field rb-ptr uniffi/rust-buffer [:len])
342- data (ffi/read-field rb-ptr uniffi/rust-buffer [:data])
343- v (when (and (pos? len) (not (ffi/null? data)))
344- (let [c (uniffi/reader data len)]
345- (uniffi/r-optional!
346- c (fn [c]
347- {:payload (uniffi/r-string! c)
348- :timestamp-us (uniffi/r-u64! c)}))))]
349- (uniffi/with-out-status #(raw/rustbuffer-free rb-ptr %))
350- v))
351-
352-;; --- subscribing -------------------------------------------------------------
353-
354-(defn subscribe-media!
355- "Subscribe to `name`; answers a future that settles to a MoqMediaConsumer.
356-
357- THREE buffers, not one. Each argument of a UniFFI method is lowered into a
358- RustBuffer of its own — the concatenation that a record's fields go through
359- is a shape that stops at the record boundary.
360-
361- And the name is lowered by `lower-string`, NOT as [[:string name]]. Where a
362- string sits decides whether it carries its own length:
363-
364- * a TOP-LEVEL string argument is bare UTF-8, and the RustBuffer's own
365- `len` is the length;
366- * a string INSIDE a record, enum or optional is prefixed with an i32 byte
367- count, because the buffer's length no longer delimits it.
368-
369- Both are `String` on the Rust side and the difference is invisible in the
370- signature. Getting it backwards does not fail at the ABI — the four prefix
371- bytes simply become part of the name, and the object answers `not found`
372- for a track that is plainly there."
373- [broadcast-consumer name container]
374- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastconsumer broadcast-consumer %))]
375- (ffi/with-arena [a]
376- (let [cell #(ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
377- (-> (raw/method-moqbroadcastconsumer-subscribe-media
378- h
379- (uniffi/lower-string (cell) name)
380- (uniffi/lower-buffer (cell) (container-ops container))
381- (uniffi/lower-buffer (cell) [[:u8 0]])) ; Optional::None
382- (uniffi/start-future :u64))))))
383-
384-(defn next-frame!
385- "Ask for the next frame; answers a future.
386-
387- It settles to a RustBuffer holding an Optional<MoqMediaFrame> — absent when
388- the track has ended — so it is an :rb future, not a :u64 one."
389- [consumer]
390- (let [h (uniffi/with-out-status #(raw/clone-moqmediaconsumer consumer %))]
391- (-> (raw/method-moqmediaconsumer-next h)
392- (uniffi/start-future :rb))))
393-
394-(defn lift-frame
395- "Read an Optional<MoqMediaFrame> out of a settled :rb future's buffer, and
396- free the buffer. nil means the track ended."
397- [rb-ptr]
398- (let [len (ffi/read-field rb-ptr uniffi/rust-buffer [:len])
399- data (ffi/read-field rb-ptr uniffi/rust-buffer [:data])
400- v (when (and (pos? len) (not (ffi/null? data)))
401- (let [c (uniffi/reader data len)]
402- (uniffi/r-optional!
403- c (fn [c]
404- {:payload (uniffi/r-string! c)
405- :timestamp-us (uniffi/r-u64! c)
406- :keyframe (uniffi/r-bool! c)}))))]
407- (uniffi/with-out-status #(raw/rustbuffer-free rb-ptr %))
408- v))
409-
410-;; --- encoded frames --------------------------------------------------------
411-;; No publish_video or publish_audio here: those are moq-ffi's `video` and
412-;; `audio` features, which the release object is built without. Encoding
413-;; happens on this side, against libopus and openh264 directly, and what
414-;; crosses is an already-encoded frame.
415-;;
416-;; A video track publishes as LEGACY. Subscribing to one with LOC answers
417-;; `mux: loc: malformed loc properties` — a message about properties, from a
418-;; mismatch about containers — so it is written down here rather than
419-;; rediscovered. Reading the catalog is the honest way to learn it.
420-
421-(def video-container :legacy)
422-
423-(defn write-video-frame!
424- "Write an ALREADY-ENCODED frame to a media producer, from foreign memory.
425-
426- The `write-frame!` above takes a jolt string, which is fine for a test
427- payload and wrong for H.264. This takes [pointer length] and copies the
428- bytes straight into the buffer, so an encoded frame goes from the
429- encoder's output to the wire without becoming a jolt value."
430- [producer ptr len timestamp-us]
431- (let [h (uniffi/with-out-status #(raw/clone-moqmediaproducer producer %))]
432- (lowered [[:bytes [ptr len]] [:u64 timestamp-us]]
433- (fn [buf]
434- (uniffi/with-out-status
435- #(raw/method-moqmediaproducer-write-frame h buf %)))))
436- nil)
437-
438-(defn lift-media-frame
439- "Read an Optional<MoqMediaFrame>, handing the payload to `use-payload` as a
440- BORROWED [pointer length] span.
441-
442- The span is valid only inside this call: the buffer is freed on the way out.
443- That is the shape on purpose — it is what lets an encoded frame go straight
444- on to a texture, the way frq.av already moves one, instead of being copied
445- into a jolt value first."
446- [rb-ptr use-payload]
447- (let [len (ffi/read-field rb-ptr uniffi/rust-buffer [:len])
448- data (ffi/read-field rb-ptr uniffi/rust-buffer [:data])
449- v (when (and (pos? len) (not (ffi/null? data)))
450- (let [c (uniffi/reader data len)]
451- (uniffi/r-optional!
452- c (fn [c]
453- (let [span (uniffi/r-bytes-span! c)
454- ts (uniffi/r-u64! c)
455- kf (uniffi/r-bool! c)]
456- {:payload (use-payload (:ptr span) (:len span))
457- :bytes (:len span)
458- :timestamp-us ts
459- :keyframe kf})))))]
460- (uniffi/with-out-status #(raw/rustbuffer-free rb-ptr %))
461- v))
deleted file mode 100644
@@ -1,461 +0,0 @@
1-(ns frq.moq.media
2- "Broadcasts, media tracks, and the frames that cross them.
3-
4- An origin is the piece that makes this testable without a network. A
5- `MoqOriginProducer` is a plain constructor, not something a session hands
6- out: create one, create a broadcast under it, publish a media track, and
7- `consume` gives you the subscriber's side of the very same broadcast. No
8- QUIC, no relay, no second process — which is how `frq.moq.smoke` can put a
9- frame in and take the same frame out.
10-
11- Over a session the shapes are identical; only where the origin comes from
12- changes, so what is exercised locally is what runs over the wire.
13-
14- ON FRAME PAYLOADS. `frame-payload` answers a jolt string, and that is a
15- DEBUGGING affordance, not the path a real frame should take. UniFFI gives
16- Bytes and String the same wire shape — an i32 length and that many bytes —
17- so a payload survives the trip only while it happens to be text. H.264 is
18- not text. `frq.av`'s rule is that a video frame goes from the decoder's
19- buffer to the texture as a pointer and never becomes a jolt value at all,
20- and honouring that here means reading the payload's address out of the
21- buffer and handing it on — which is what the real subscribe path will do,
22- and what this namespace does not do yet."
23- (:require [frq.moq.uniffi :as uniffi]
24- [frq.moq.raw :as raw]
25- [jolt.ffi :as ffi]))
26-
27-(defn- lowered
28- "Run `f` with a RustBuffer holding `ops`."
29- [ops f]
30- (ffi/with-arena [a]
31- (let [buf (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
32- (uniffi/lower-buffer buf ops)
33- (f buf))))
34-
35-;; --- containers --------------------------------------------------------------
36-
37-(def containers
38- "MoqContainer, as UniFFI numbers it. LEGACY and LOC carry nothing; CMAF
39- carries its init segment, which `container-ops` does not build yet."
40- {:legacy 1 :cmaf 2 :loc 3})
41-
42-(defn container-ops [kind]
43- (let [v (or (containers kind)
44- (throw (ex-info "unknown MoqContainer" {:kind kind
45- :known (keys containers)})))]
46- (when (= kind :cmaf)
47- (throw (ex-info "CMAF needs its init segment, which is not built here" {})))
48- [[:i32 v]]))
49-
50-;; --- origins and broadcasts --------------------------------------------------
51-
52-(defn new-origin
53- "A MoqOriginProducer with default options (no cache cap)."
54- []
55- (lowered [[:u8 0]] ; MoqOriginOptions{cache_capacity_bytes: None}
56- (fn [buf]
57- (uniffi/with-out-status
58- #(raw/constructor-moqoriginproducer-new buf %)))))
59-
60-(defn create-broadcast!
61- "A MoqBroadcastProducer at `path` under this origin.
62-
63- `path` goes through `lower-string`, not the record encoder: see the note on
64- `subscribe-media!` about which strings carry a length and which do not."
65- [origin path]
66- (let [h (uniffi/with-out-status #(raw/clone-moqoriginproducer origin %))]
67- (ffi/with-arena [a]
68- (let [buf (uniffi/lower-string
69- (ffi/alloc a (ffi/layout-size uniffi/rust-buffer)) path)]
70- (uniffi/with-out-status
71- #(raw/method-moqoriginproducer-create-broadcast h buf %))))))
72-
73-(defn broadcast-consumer
74- "The subscriber's side of a broadcast we produce."
75- [broadcast]
76- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastproducer broadcast %))]
77- (uniffi/with-out-status #(raw/method-moqbroadcastproducer-consume h %))))
78-
79-;; --- discovery --------------------------------------------------------------
80-;; How a peer is FOUND, rather than known. `subscribe-media!` needs a path and
81-;; a track name; announcements are where those come from when the other side
82-;; of the call is somebody else's client rather than a broadcast we made.
83-
84-(defn origin-consumer
85- "The subscriber's side of an origin."
86- [origin]
87- (let [h (uniffi/with-out-status #(raw/clone-moqoriginproducer origin %))]
88- (uniffi/with-out-status #(raw/method-moqoriginproducer-consume h %))))
89-
90-(defn announced!
91- "Watch for broadcasts whose path starts with `prefix`; answers a MoqAnnounced.
92-
93- An empty prefix watches everything on the origin, which in a call is what
94- you want: every participant is a broadcast and none of their paths are
95- known in advance."
96- [origin-consumer prefix]
97- (let [h (uniffi/with-out-status #(raw/clone-moqoriginconsumer origin-consumer %))]
98- (ffi/with-arena [a]
99- (let [buf (uniffi/lower-string
100- (ffi/alloc a (ffi/layout-size uniffi/rust-buffer)) prefix)]
101- (uniffi/with-out-status
102- #(raw/method-moqoriginconsumer-announced h buf %))))))
103-
104-(defn next-announcement!
105- "Ask for the next announcement; answers an :rb future.
106-
107- It settles to an Optional<MoqAnnouncement> — absent when the origin has
108- closed, which is the end of the watch rather than an error."
109- [announced]
110- (let [h (uniffi/with-out-status #(raw/clone-moqannounced announced %))]
111- (-> (raw/method-moqannounced-next h)
112- (uniffi/start-future :rb))))
113-
114-(defn lift-optional-handle
115- "Read an Optional<interface> from a settled :rb buffer as a handle.
116-
117- An interface crosses as a u64 the far side has already cloned for us, so
118- freeing the buffer it arrived in does not touch it. Announcements,
119- requests and anything else optional-and-opaque come back this way."
120- [rb-ptr]
121- (let [len (ffi/read-field rb-ptr uniffi/rust-buffer [:len])
122- data (ffi/read-field rb-ptr uniffi/rust-buffer [:data])
123- v (when (and (pos? len) (not (ffi/null? data)))
124- (uniffi/r-optional! (uniffi/reader data len) uniffi/r-u64!))]
125- (uniffi/with-out-status #(raw/rustbuffer-free rb-ptr %))
126- v))
127-
128-(defn lift-announcement
129- "Read an Optional<MoqAnnouncement> from a settled :rb buffer.
130-
131- Answers the announcement's HANDLE, which outlives the buffer — an interface
132- crosses as a u64 the far side has already cloned for us, so freeing the
133- buffer it arrived in does not touch it."
134- [rb-ptr]
135- (let [len (ffi/read-field rb-ptr uniffi/rust-buffer [:len])
136- data (ffi/read-field rb-ptr uniffi/rust-buffer [:data])
137- v (when (and (pos? len) (not (ffi/null? data)))
138- (let [c (uniffi/reader data len)]
139- (uniffi/r-optional! c uniffi/r-u64!)))]
140- (uniffi/with-out-status #(raw/rustbuffer-free rb-ptr %))
141- v))
142-
143-(defn announcement-path
144- [ann]
145- (let [h (uniffi/with-out-status #(raw/clone-moqannouncement ann %))]
146- (ffi/with-arena [a]
147- (let [out (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
148- (uniffi/with-out-status #(raw/method-moqannouncement-path out h %))
149- (uniffi/lift-string out)))))
150-
151-(defn announcement-broadcast
152- "The MoqBroadcastConsumer this announcement is for."
153- [ann]
154- (let [h (uniffi/with-out-status #(raw/clone-moqannouncement ann %))]
155- (uniffi/with-out-status #(raw/method-moqannouncement-broadcast h %))))
156-
157-;; --- the catalog ------------------------------------------------------------
158-;; What a peer's broadcast says it contains. Two things come out of it that
159-;; cannot be guessed from the outside: the track NAMES, and each track's
160-;; CONTAINER — which is the LOC-versus-LEGACY question that answers
161-;; `mux: loc: malformed loc properties` when got wrong.
162-
163-(def ^:private container-names
164- (into {} (map (fn [[k v]] [v k])) containers))
165-
166-(defn- r-dimensions [c]
167- {:width (uniffi/r-i32! c) :height (uniffi/r-i32! c)})
168-
169-(defn- r-container [c]
170- (let [v (uniffi/r-i32! c)]
171- (when (= v (containers :cmaf)) (uniffi/r-bytes-span! c)) ; init blob, skipped
172- (container-names v :unknown)))
173-
174-(defn- r-video [c]
175- {:codec (uniffi/r-string! c)
176- :description (uniffi/r-optional! c uniffi/r-bytes-span!)
177- :coded (uniffi/r-optional! c r-dimensions)
178- :aspect (uniffi/r-optional! c r-dimensions)
179- :bitrate (uniffi/r-optional! c uniffi/r-u64!)
180- :stalled (uniffi/r-bool! c)
181- :framerate (uniffi/r-optional! c uniffi/r-f64!)
182- :container (r-container c)})
183-
184-(defn- r-audio [c]
185- {:codec (uniffi/r-string! c)
186- :description (uniffi/r-optional! c uniffi/r-bytes-span!)
187- :sample-rate (uniffi/r-i32! c)
188- :channels (uniffi/r-i32! c)
189- :bitrate (uniffi/r-optional! c uniffi/r-u64!)
190- :container (r-container c)})
191-
192-(defn next-catalog!
193- "Ask for the next catalog update; answers an :rb future."
194- [catalog-consumer]
195- (let [h (uniffi/with-out-status #(raw/clone-moqcatalogconsumer catalog-consumer %))]
196- (-> (raw/method-moqcatalogconsumer-next h)
197- (uniffi/start-future :rb))))
198-
199-(defn lift-catalog
200- "Read an Optional<MoqCatalog> from a settled :rb buffer.
201-
202- Every field is read even though only `:video` is used yet: the fields are
203- positional in the buffer, so skipping one means parsing it anyway, and
204- half-parsing a record is how the next field comes out as nonsense."
205- [rb-ptr]
206- (let [len (ffi/read-field rb-ptr uniffi/rust-buffer [:len])
207- data (ffi/read-field rb-ptr uniffi/rust-buffer [:data])
208- v (when (and (pos? len) (not (ffi/null? data)))
209- (let [c (uniffi/reader data len)]
210- (uniffi/r-optional!
211- c (fn [c]
212- {:video (uniffi/r-map! c r-video)
213- :audio (uniffi/r-map! c r-audio)
214- :display (uniffi/r-optional! c r-dimensions)
215- :rotation (uniffi/r-optional! c uniffi/r-f64!)
216- :flip (uniffi/r-optional! c uniffi/r-bool!)
217- :sections (uniffi/r-map! c uniffi/r-string!)}))))]
218- (uniffi/with-out-status #(raw/rustbuffer-free rb-ptr %))
219- v))
220-
221-(defn subscribe-catalog!
222- "Watch a broadcast's catalog; answers a future settling to a
223- MoqCatalogConsumer.
224-
225- What names a peer's tracks. `subscribe-media!` wants a track name, and on a
226- broadcast we did not publish there is nothing else to learn it from."
227- [broadcast-consumer]
228- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastconsumer broadcast-consumer %))]
229- (-> (raw/method-moqbroadcastconsumer-subscribe-catalog h)
230- (uniffi/start-future :u64))))
231-
232-;; --- publishing --------------------------------------------------------------
233-
234-(defn publish-media!
235- "Publish a media track and answer its MoqMediaProducer.
236-
237- `init` is a MoqInit: a format string, an init blob, and an optional video
238- hint. The blob goes out through the :string op because UniFFI lowers Bytes
239- and String identically — an i32 length and that many bytes."
240- ([broadcast format] (publish-media! broadcast format ""))
241- ([broadcast format init-data]
242- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastproducer broadcast %))]
243- (lowered [[:string format] [:string init-data] [:u8 0]]
244- (fn [buf]
245- (uniffi/with-out-status
246- #(raw/method-moqbroadcastproducer-publish-media h buf %)))))))
247-
248-(defn publish-media-bytes!
249- "Publish a media track whose init blob is RAW bytes.
250-
251- `publish-media!` above takes the blob as a string, which is fine for the
252- empty one a video track uses and wrong for an OpusHead — that is 19 bytes
253- of little-endian header with interior zeros, and routing it through a jolt
254- string would not survive."
255- [broadcast format ptr len]
256- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastproducer broadcast %))]
257- (lowered [[:string format] [:bytes [ptr len]] [:u8 0]]
258- (fn [buf]
259- (uniffi/with-out-status
260- #(raw/method-moqbroadcastproducer-publish-media h buf %))))))
261-
262-(defn producer-name
263- "The track name the object chose for this producer — what a subscriber asks
264- for by name."
265- [producer]
266- (let [h (uniffi/with-out-status #(raw/clone-moqmediaproducer producer %))]
267- (ffi/with-arena [a]
268- (let [out (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
269- (uniffi/with-out-status #(raw/method-moqmediaproducer-name out h %))
270- (uniffi/lift-string out)))))
271-
272-(defn write-frame!
273- "Write one MoqFrame to a media producer: a payload and a microsecond stamp.
274-
275- MoqFrame, NOT MoqMediaFrame. The two names differ by one word and by one
276- field — what a consumer hands back carries a `keyframe` flag, and what a
277- producer takes does not, because the container works that out from the
278- bitstream. Sending the extra byte is not an ABI error: the object lifts the
279- record, finds a byte left over, and panics with `junk data left in buffer`."
280- [producer payload timestamp-us]
281- (let [h (uniffi/with-out-status #(raw/clone-moqmediaproducer producer %))]
282- (lowered [[:string payload] [:u64 timestamp-us]]
283- (fn [buf]
284- (uniffi/with-out-status
285- #(raw/method-moqmediaproducer-write-frame h buf %)))))
286- nil)
287-
288-;; --- opaque tracks -----------------------------------------------------------
289-;; The same broadcast, without a codec in the way. `publish_track` and
290-;; `subscribe_track` move plain byte payloads and parse nothing, which is what
291-;; a round trip can actually be checked against: a media track declares a
292-;; format, and `avc3` means the container really does try to read Annex B out
293-;; of whatever is handed to it.
294-
295-(defn publish-track!
296- "Publish an opaque track by name; answers a MoqTrackProducer."
297- [broadcast name]
298- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastproducer broadcast %))]
299- (ffi/with-arena [a]
300- (let [cell #(ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
301- (uniffi/with-out-status
302- #(raw/method-moqbroadcastproducer-publish-track
303- h
304- (uniffi/lower-string (cell) name)
305- (uniffi/lower-buffer (cell) [[:u8 0]]) ; Optional<MoqTrackInfo>
306- %))))))
307-
308-(defn write-track-frame!
309- "Write one MoqFrame to an opaque track producer."
310- [producer payload timestamp-us]
311- (let [h (uniffi/with-out-status #(raw/clone-moqtrackproducer producer %))]
312- (lowered [[:string payload] [:u64 timestamp-us]]
313- (fn [buf]
314- (uniffi/with-out-status
315- #(raw/method-moqtrackproducer-write-frame h buf %)))))
316- nil)
317-
318-(defn subscribe-track!
319- "Subscribe to an opaque track; answers a future settling to a
320- MoqTrackConsumer."
321- [broadcast-consumer name]
322- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastconsumer broadcast-consumer %))]
323- (ffi/with-arena [a]
324- (let [cell #(ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
325- (-> (raw/method-moqbroadcastconsumer-subscribe-track
326- h
327- (uniffi/lower-string (cell) name)
328- (uniffi/lower-buffer (cell) [[:u8 0]])) ; Optional<MoqSubscription>
329- (uniffi/start-future :u64))))))
330-
331-(defn read-frame!
332- "Ask an opaque track for its next frame; answers an :rb future."
333- [consumer]
334- (let [h (uniffi/with-out-status #(raw/clone-moqtrackconsumer consumer %))]
335- (-> (raw/method-moqtrackconsumer-read-frame h)
336- (uniffi/start-future :rb))))
337-
338-(defn lift-plain-frame
339- "Read an Optional<MoqFrame> out of a settled :rb buffer, and free it."
340- [rb-ptr]
341- (let [len (ffi/read-field rb-ptr uniffi/rust-buffer [:len])
342- data (ffi/read-field rb-ptr uniffi/rust-buffer [:data])
343- v (when (and (pos? len) (not (ffi/null? data)))
344- (let [c (uniffi/reader data len)]
345- (uniffi/r-optional!
346- c (fn [c]
347- {:payload (uniffi/r-string! c)
348- :timestamp-us (uniffi/r-u64! c)}))))]
349- (uniffi/with-out-status #(raw/rustbuffer-free rb-ptr %))
350- v))
351-
352-;; --- subscribing -------------------------------------------------------------
353-
354-(defn subscribe-media!
355- "Subscribe to `name`; answers a future that settles to a MoqMediaConsumer.
356-
357- THREE buffers, not one. Each argument of a UniFFI method is lowered into a
358- RustBuffer of its own — the concatenation that a record's fields go through
359- is a shape that stops at the record boundary.
360-
361- And the name is lowered by `lower-string`, NOT as [[:string name]]. Where a
362- string sits decides whether it carries its own length:
363-
364- * a TOP-LEVEL string argument is bare UTF-8, and the RustBuffer's own
365- `len` is the length;
366- * a string INSIDE a record, enum or optional is prefixed with an i32 byte
367- count, because the buffer's length no longer delimits it.
368-
369- Both are `String` on the Rust side and the difference is invisible in the
370- signature. Getting it backwards does not fail at the ABI — the four prefix
371- bytes simply become part of the name, and the object answers `not found`
372- for a track that is plainly there."
373- [broadcast-consumer name container]
374- (let [h (uniffi/with-out-status #(raw/clone-moqbroadcastconsumer broadcast-consumer %))]
375- (ffi/with-arena [a]
376- (let [cell #(ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
377- (-> (raw/method-moqbroadcastconsumer-subscribe-media
378- h
379- (uniffi/lower-string (cell) name)
380- (uniffi/lower-buffer (cell) (container-ops container))
381- (uniffi/lower-buffer (cell) [[:u8 0]])) ; Optional::None
382- (uniffi/start-future :u64))))))
383-
384-(defn next-frame!
385- "Ask for the next frame; answers a future.
386-
387- It settles to a RustBuffer holding an Optional<MoqMediaFrame> — absent when
388- the track has ended — so it is an :rb future, not a :u64 one."
389- [consumer]
390- (let [h (uniffi/with-out-status #(raw/clone-moqmediaconsumer consumer %))]
391- (-> (raw/method-moqmediaconsumer-next h)
392- (uniffi/start-future :rb))))
393-
394-(defn lift-frame
395- "Read an Optional<MoqMediaFrame> out of a settled :rb future's buffer, and
396- free the buffer. nil means the track ended."
397- [rb-ptr]
398- (let [len (ffi/read-field rb-ptr uniffi/rust-buffer [:len])
399- data (ffi/read-field rb-ptr uniffi/rust-buffer [:data])
400- v (when (and (pos? len) (not (ffi/null? data)))
401- (let [c (uniffi/reader data len)]
402- (uniffi/r-optional!
403- c (fn [c]
404- {:payload (uniffi/r-string! c)
405- :timestamp-us (uniffi/r-u64! c)
406- :keyframe (uniffi/r-bool! c)}))))]
407- (uniffi/with-out-status #(raw/rustbuffer-free rb-ptr %))
408- v))
409-
410-;; --- encoded frames --------------------------------------------------------
411-;; No publish_video or publish_audio here: those are moq-ffi's `video` and
412-;; `audio` features, which the release object is built without. Encoding
413-;; happens on this side, against libopus and openh264 directly, and what
414-;; crosses is an already-encoded frame.
415-;;
416-;; A video track publishes as LEGACY. Subscribing to one with LOC answers
417-;; `mux: loc: malformed loc properties` — a message about properties, from a
418-;; mismatch about containers — so it is written down here rather than
419-;; rediscovered. Reading the catalog is the honest way to learn it.
420-
421-(def video-container :legacy)
422-
423-(defn write-video-frame!
424- "Write an ALREADY-ENCODED frame to a media producer, from foreign memory.
425-
426- The `write-frame!` above takes a jolt string, which is fine for a test
427- payload and wrong for H.264. This takes [pointer length] and copies the
428- bytes straight into the buffer, so an encoded frame goes from the
429- encoder's output to the wire without becoming a jolt value."
430- [producer ptr len timestamp-us]
431- (let [h (uniffi/with-out-status #(raw/clone-moqmediaproducer producer %))]
432- (lowered [[:bytes [ptr len]] [:u64 timestamp-us]]
433- (fn [buf]
434- (uniffi/with-out-status
435- #(raw/method-moqmediaproducer-write-frame h buf %)))))
436- nil)
437-
438-(defn lift-media-frame
439- "Read an Optional<MoqMediaFrame>, handing the payload to `use-payload` as a
440- BORROWED [pointer length] span.
441-
442- The span is valid only inside this call: the buffer is freed on the way out.
443- That is the shape on purpose — it is what lets an encoded frame go straight
444- on to a texture, the way frq.av already moves one, instead of being copied
445- into a jolt value first."
446- [rb-ptr use-payload]
447- (let [len (ffi/read-field rb-ptr uniffi/rust-buffer [:len])
448- data (ffi/read-field rb-ptr uniffi/rust-buffer [:data])
449- v (when (and (pos? len) (not (ffi/null? data)))
450- (let [c (uniffi/reader data len)]
451- (uniffi/r-optional!
452- c (fn [c]
453- (let [span (uniffi/r-bytes-span! c)
454- ts (uniffi/r-u64! c)
455- kf (uniffi/r-bool! c)]
456- {:payload (use-payload (:ptr span) (:len span))
457- :bytes (:len span)
458- :timestamp-us ts
459- :keyframe kf})))))]
460- (uniffi/with-out-status #(raw/rustbuffer-free rb-ptr %))
461- v))
deleted src/frq/moq/raw.clj +0 -474
deleted file mode 100644
@@ -1,474 +0,0 @@
1-(ns frq.moq.raw
2- "Every entry point in libmoq_ffi, declared. GENERATED — do not edit.
3-
4- just gen-moq
5-
6- These are not transcribed from a header. UniFFI embeds its interface
7- metadata in the object itself, and `uniffi-bindgen --library` reads it back
8- out, so what is declared here is what the .so we load actually exports.
9-
10- WHAT THIS OBJECT IS. The release artifact, fetched, not built. moq-ffi has
11- `audio` and `video` features that are on by default upstream and OFF in
12- every Linux and Android artifact it publishes — so there is no
13- publish_audio, no publish_video, and no moqaudio*/moqvideo* here: 206
14- functions where the Apple build (and the C header shipped beside it) has
15- 230. Binding that header would have linked on a Mac and failed on both
16- platforms frq ships to, which is why these come from the object.
17-
18- The codecs are deliberately absent rather than missing. Building moq-ffi
19- with its defaults on would put Opus and H.264 behind this same ABI at the
20- cost of compiling a 1062-crate workspace — the whole dependency graph this
21- binding exists to avoid. The codecs come from their own C libraries
22- instead; this object is the transport, which is the one piece with no C
23- implementation anywhere.
24-
25- Nothing here is hand-written and nothing is inferred. The generator maps a
26- ctype it does not recognise to nothing at all and reports it, rather than
27- guessing a width — a wrong one would be a silent memory bug rather than a
28- compile error.
29-
30- Two shapes recur, and both are spelled out in full at every call site rather
31- than named: `defcfn` is a macro whose types Chez fixes when it COMPILES the
32- foreign procedure, so a `def`'d descriptor in type position has nothing to
33- compile. Hence the literal [:by-value [:struct ...]] repeated throughout —
34- it is the RustBuffer {capacity, len, data} triple, or the ForeignBytes pair.
35-
36- A trailing :pointer on a non-async function is its `RustCallStatus *`
37- out-parameter; `frq.moq.uniffi/with-out-status` is what supplies one. An
38- async function has no status and answers a future handle instead — poll it
39- through `frq.moq.uniffi/start-future`.
40-
41- Generated from the moq-ffi 0.3.17 release object, UniFFI contract 30."
42- (:require [jolt.ffi :as ffi]))
43-
44-;; MoqError, as UniFFI numbers it in THIS object. Generated with the
45-;; entry points above, and for the same reason: the variants are
46-;; inserted into rather than appended to, so a table written by hand
47-;; against one build names the wrong error in the next.
48-(def moq-error-variants
49- {1 :protocol
50- 2 :media
51- 3 :mux
52- 4 :json-track
53- 5 :url
54- 6 :time-overflow
55- 7 :log-level
56- 8 :task
57- 9 :json
58- 10 :cancelled
59- 11 :closed
60- 12 :connect
61- 13 :bind
62- 14 :reject
63- 15 :already-responded
64- 16 :codec
65- 17 :unauthorized
66- 18 :forbidden
67- 19 :not-found
68- 20 :unsupported
69- 21 :invalid-route
70- 22 :log})
71-
72-(ffi/defcfn rust-future-cancel-f32 "ffi_moq_ffi_rust_future_cancel_f32" [:uint64] :void)
73-(ffi/defcfn rust-future-cancel-f64 "ffi_moq_ffi_rust_future_cancel_f64" [:uint64] :void)
74-(ffi/defcfn rust-future-cancel-i16 "ffi_moq_ffi_rust_future_cancel_i16" [:uint64] :void)
75-(ffi/defcfn rust-future-cancel-i32 "ffi_moq_ffi_rust_future_cancel_i32" [:uint64] :void)
76-(ffi/defcfn rust-future-cancel-i64 "ffi_moq_ffi_rust_future_cancel_i64" [:uint64] :void)
77-(ffi/defcfn rust-future-cancel-i8 "ffi_moq_ffi_rust_future_cancel_i8" [:uint64] :void)
78-(ffi/defcfn rust-future-cancel-rust-buffer "ffi_moq_ffi_rust_future_cancel_rust_buffer" [:uint64] :void)
79-(ffi/defcfn rust-future-cancel-u16 "ffi_moq_ffi_rust_future_cancel_u16" [:uint64] :void)
80-(ffi/defcfn rust-future-cancel-u32 "ffi_moq_ffi_rust_future_cancel_u32" [:uint64] :void)
81-(ffi/defcfn rust-future-cancel-u64 "ffi_moq_ffi_rust_future_cancel_u64" [:uint64] :void)
82-(ffi/defcfn rust-future-cancel-u8 "ffi_moq_ffi_rust_future_cancel_u8" [:uint64] :void)
83-(ffi/defcfn rust-future-cancel-void "ffi_moq_ffi_rust_future_cancel_void" [:uint64] :void)
84-(ffi/defcfn rust-future-complete-f32 "ffi_moq_ffi_rust_future_complete_f32" [:uint64 :pointer] :float)
85-(ffi/defcfn rust-future-complete-f64 "ffi_moq_ffi_rust_future_complete_f64" [:uint64 :pointer] :double)
86-(ffi/defcfn rust-future-complete-i16 "ffi_moq_ffi_rust_future_complete_i16" [:uint64 :pointer] :int16)
87-(ffi/defcfn rust-future-complete-i32 "ffi_moq_ffi_rust_future_complete_i32" [:uint64 :pointer] :int32)
88-(ffi/defcfn rust-future-complete-i64 "ffi_moq_ffi_rust_future_complete_i64" [:uint64 :pointer] :int64)
89-(ffi/defcfn rust-future-complete-i8 "ffi_moq_ffi_rust_future_complete_i8" [:uint64 :pointer] :int8)
90-(ffi/defcfn rust-future-complete-rust-buffer "ffi_moq_ffi_rust_future_complete_rust_buffer" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
91-(ffi/defcfn rust-future-complete-u16 "ffi_moq_ffi_rust_future_complete_u16" [:uint64 :pointer] :uint16)
92-(ffi/defcfn rust-future-complete-u32 "ffi_moq_ffi_rust_future_complete_u32" [:uint64 :pointer] :uint32)
93-(ffi/defcfn rust-future-complete-u64 "ffi_moq_ffi_rust_future_complete_u64" [:uint64 :pointer] :uint64)
94-(ffi/defcfn rust-future-complete-u8 "ffi_moq_ffi_rust_future_complete_u8" [:uint64 :pointer] :uint8)
95-(ffi/defcfn rust-future-complete-void "ffi_moq_ffi_rust_future_complete_void" [:uint64 :pointer] :void)
96-(ffi/defcfn rust-future-free-f32 "ffi_moq_ffi_rust_future_free_f32" [:uint64] :void)
97-(ffi/defcfn rust-future-free-f64 "ffi_moq_ffi_rust_future_free_f64" [:uint64] :void)
98-(ffi/defcfn rust-future-free-i16 "ffi_moq_ffi_rust_future_free_i16" [:uint64] :void)
99-(ffi/defcfn rust-future-free-i32 "ffi_moq_ffi_rust_future_free_i32" [:uint64] :void)
100-(ffi/defcfn rust-future-free-i64 "ffi_moq_ffi_rust_future_free_i64" [:uint64] :void)
101-(ffi/defcfn rust-future-free-i8 "ffi_moq_ffi_rust_future_free_i8" [:uint64] :void)
102-(ffi/defcfn rust-future-free-rust-buffer "ffi_moq_ffi_rust_future_free_rust_buffer" [:uint64] :void)
103-(ffi/defcfn rust-future-free-u16 "ffi_moq_ffi_rust_future_free_u16" [:uint64] :void)
104-(ffi/defcfn rust-future-free-u32 "ffi_moq_ffi_rust_future_free_u32" [:uint64] :void)
105-(ffi/defcfn rust-future-free-u64 "ffi_moq_ffi_rust_future_free_u64" [:uint64] :void)
106-(ffi/defcfn rust-future-free-u8 "ffi_moq_ffi_rust_future_free_u8" [:uint64] :void)
107-(ffi/defcfn rust-future-free-void "ffi_moq_ffi_rust_future_free_void" [:uint64] :void)
108-(ffi/defcfn rust-future-poll-f32 "ffi_moq_ffi_rust_future_poll_f32" [:uint64 :pointer :uint64] :void)
109-(ffi/defcfn rust-future-poll-f64 "ffi_moq_ffi_rust_future_poll_f64" [:uint64 :pointer :uint64] :void)
110-(ffi/defcfn rust-future-poll-i16 "ffi_moq_ffi_rust_future_poll_i16" [:uint64 :pointer :uint64] :void)
111-(ffi/defcfn rust-future-poll-i32 "ffi_moq_ffi_rust_future_poll_i32" [:uint64 :pointer :uint64] :void)
112-(ffi/defcfn rust-future-poll-i64 "ffi_moq_ffi_rust_future_poll_i64" [:uint64 :pointer :uint64] :void)
113-(ffi/defcfn rust-future-poll-i8 "ffi_moq_ffi_rust_future_poll_i8" [:uint64 :pointer :uint64] :void)
114-(ffi/defcfn rust-future-poll-rust-buffer "ffi_moq_ffi_rust_future_poll_rust_buffer" [:uint64 :pointer :uint64] :void)
115-(ffi/defcfn rust-future-poll-u16 "ffi_moq_ffi_rust_future_poll_u16" [:uint64 :pointer :uint64] :void)
116-(ffi/defcfn rust-future-poll-u32 "ffi_moq_ffi_rust_future_poll_u32" [:uint64 :pointer :uint64] :void)
117-(ffi/defcfn rust-future-poll-u64 "ffi_moq_ffi_rust_future_poll_u64" [:uint64 :pointer :uint64] :void)
118-(ffi/defcfn rust-future-poll-u8 "ffi_moq_ffi_rust_future_poll_u8" [:uint64 :pointer :uint64] :void)
119-(ffi/defcfn rust-future-poll-void "ffi_moq_ffi_rust_future_poll_void" [:uint64 :pointer :uint64] :void)
120-(ffi/defcfn rustbuffer-alloc "ffi_moq_ffi_rustbuffer_alloc" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
121-(ffi/defcfn rustbuffer-free "ffi_moq_ffi_rustbuffer_free" [[:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
122-(ffi/defcfn rustbuffer-from-bytes "ffi_moq_ffi_rustbuffer_from_bytes" [[:by-value [:struct [[:len :int32] [:data :pointer]]]] :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
123-(ffi/defcfn rustbuffer-reserve "ffi_moq_ffi_rustbuffer_reserve" [[:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
124-(ffi/defcfn uniffi-contract-version "ffi_moq_ffi_uniffi_contract_version" [] :uint32)
125-(ffi/defcfn checksum-constructor-moqbroadcastproducer-new "uniffi_moq_ffi_checksum_constructor_moqbroadcastproducer_new" [] :uint16)
126-(ffi/defcfn checksum-constructor-moqclient-new "uniffi_moq_ffi_checksum_constructor_moqclient_new" [] :uint16)
127-(ffi/defcfn checksum-constructor-moqoriginproducer-new "uniffi_moq_ffi_checksum_constructor_moqoriginproducer_new" [] :uint16)
128-(ffi/defcfn checksum-constructor-moqserver-new "uniffi_moq_ffi_checksum_constructor_moqserver_new" [] :uint16)
129-(ffi/defcfn checksum-func-moq-log-level "uniffi_moq_ffi_checksum_func_moq_log_level" [] :uint16)
130-(ffi/defcfn checksum-method-moqannounced-cancel "uniffi_moq_ffi_checksum_method_moqannounced_cancel" [] :uint16)
131-(ffi/defcfn checksum-method-moqannounced-next "uniffi_moq_ffi_checksum_method_moqannounced_next" [] :uint16)
132-(ffi/defcfn checksum-method-moqannouncedbroadcast-available "uniffi_moq_ffi_checksum_method_moqannouncedbroadcast_available" [] :uint16)
133-(ffi/defcfn checksum-method-moqannouncedbroadcast-cancel "uniffi_moq_ffi_checksum_method_moqannouncedbroadcast_cancel" [] :uint16)
134-(ffi/defcfn checksum-method-moqannouncement-broadcast "uniffi_moq_ffi_checksum_method_moqannouncement_broadcast" [] :uint16)
135-(ffi/defcfn checksum-method-moqannouncement-path "uniffi_moq_ffi_checksum_method_moqannouncement_path" [] :uint16)
136-(ffi/defcfn checksum-method-moqbroadcastconsumer-fetch-group "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_fetch_group" [] :uint16)
137-(ffi/defcfn checksum-method-moqbroadcastconsumer-fetch-media-group "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_fetch_media_group" [] :uint16)
138-(ffi/defcfn checksum-method-moqbroadcastconsumer-route "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_route" [] :uint16)
139-(ffi/defcfn checksum-method-moqbroadcastconsumer-route-updates "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_route_updates" [] :uint16)
140-(ffi/defcfn checksum-method-moqbroadcastconsumer-subscribe-catalog "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_catalog" [] :uint16)
141-(ffi/defcfn checksum-method-moqbroadcastconsumer-subscribe-json-snapshot "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_json_snapshot" [] :uint16)
142-(ffi/defcfn checksum-method-moqbroadcastconsumer-subscribe-json-stream "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_json_stream" [] :uint16)
143-(ffi/defcfn checksum-method-moqbroadcastconsumer-subscribe-media "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_media" [] :uint16)
144-(ffi/defcfn checksum-method-moqbroadcastconsumer-subscribe-track "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_track" [] :uint16)
145-(ffi/defcfn checksum-method-moqbroadcastdynamic-cancel "uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_cancel" [] :uint16)
146-(ffi/defcfn checksum-method-moqbroadcastdynamic-requested-track "uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_requested_track" [] :uint16)
147-(ffi/defcfn checksum-method-moqbroadcastproducer-consume "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_consume" [] :uint16)
148-(ffi/defcfn checksum-method-moqbroadcastproducer-dynamic "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_dynamic" [] :uint16)
149-(ffi/defcfn checksum-method-moqbroadcastproducer-finish "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_finish" [] :uint16)
150-(ffi/defcfn checksum-method-moqbroadcastproducer-publish-json-snapshot "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_snapshot" [] :uint16)
151-(ffi/defcfn checksum-method-moqbroadcastproducer-publish-json-stream "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_stream" [] :uint16)
152-(ffi/defcfn checksum-method-moqbroadcastproducer-publish-media "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_media" [] :uint16)
153-(ffi/defcfn checksum-method-moqbroadcastproducer-publish-media-on-track "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_media_on_track" [] :uint16)
154-(ffi/defcfn checksum-method-moqbroadcastproducer-publish-media-stream "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_media_stream" [] :uint16)
155-(ffi/defcfn checksum-method-moqbroadcastproducer-publish-track "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_track" [] :uint16)
156-(ffi/defcfn checksum-method-moqbroadcastproducer-remove-catalog-section "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_remove_catalog_section" [] :uint16)
157-(ffi/defcfn checksum-method-moqbroadcastproducer-set-announce "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_set_announce" [] :uint16)
158-(ffi/defcfn checksum-method-moqbroadcastproducer-set-catalog-section "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_set_catalog_section" [] :uint16)
159-(ffi/defcfn checksum-method-moqbroadcastproducer-set-route "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_set_route" [] :uint16)
160-(ffi/defcfn checksum-method-moqbroadcastproducer-set-video-properties "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_set_video_properties" [] :uint16)
161-(ffi/defcfn checksum-method-moqbroadcastrequest-abort "uniffi_moq_ffi_checksum_method_moqbroadcastrequest_abort" [] :uint16)
162-(ffi/defcfn checksum-method-moqbroadcastrequest-accept "uniffi_moq_ffi_checksum_method_moqbroadcastrequest_accept" [] :uint16)
163-(ffi/defcfn checksum-method-moqbroadcastrequest-path "uniffi_moq_ffi_checksum_method_moqbroadcastrequest_path" [] :uint16)
164-(ffi/defcfn checksum-method-moqcatalogconsumer-cancel "uniffi_moq_ffi_checksum_method_moqcatalogconsumer_cancel" [] :uint16)
165-(ffi/defcfn checksum-method-moqcatalogconsumer-next "uniffi_moq_ffi_checksum_method_moqcatalogconsumer_next" [] :uint16)
166-(ffi/defcfn checksum-method-moqclient-cancel "uniffi_moq_ffi_checksum_method_moqclient_cancel" [] :uint16)
167-(ffi/defcfn checksum-method-moqclient-connect "uniffi_moq_ffi_checksum_method_moqclient_connect" [] :uint16)
168-(ffi/defcfn checksum-method-moqclient-set-bind "uniffi_moq_ffi_checksum_method_moqclient_set_bind" [] :uint16)
169-(ffi/defcfn checksum-method-moqclient-set-consume "uniffi_moq_ffi_checksum_method_moqclient_set_consume" [] :uint16)
170-(ffi/defcfn checksum-method-moqclient-set-publish "uniffi_moq_ffi_checksum_method_moqclient_set_publish" [] :uint16)
171-(ffi/defcfn checksum-method-moqclient-set-tls-cert "uniffi_moq_ffi_checksum_method_moqclient_set_tls_cert" [] :uint16)
172-(ffi/defcfn checksum-method-moqclient-set-tls-disable-verify "uniffi_moq_ffi_checksum_method_moqclient_set_tls_disable_verify" [] :uint16)
173-(ffi/defcfn checksum-method-moqclient-set-tls-fingerprints "uniffi_moq_ffi_checksum_method_moqclient_set_tls_fingerprints" [] :uint16)
174-(ffi/defcfn checksum-method-moqclient-set-tls-key "uniffi_moq_ffi_checksum_method_moqclient_set_tls_key" [] :uint16)
175-(ffi/defcfn checksum-method-moqclient-set-tls-roots "uniffi_moq_ffi_checksum_method_moqclient_set_tls_roots" [] :uint16)
176-(ffi/defcfn checksum-method-moqclient-set-tls-system-roots "uniffi_moq_ffi_checksum_method_moqclient_set_tls_system_roots" [] :uint16)
177-(ffi/defcfn checksum-method-moqgroupconsumer-cancel "uniffi_moq_ffi_checksum_method_moqgroupconsumer_cancel" [] :uint16)
178-(ffi/defcfn checksum-method-moqgroupconsumer-read-frame "uniffi_moq_ffi_checksum_method_moqgroupconsumer_read_frame" [] :uint16)
179-(ffi/defcfn checksum-method-moqgroupconsumer-sequence "uniffi_moq_ffi_checksum_method_moqgroupconsumer_sequence" [] :uint16)
180-(ffi/defcfn checksum-method-moqgroupproducer-abort "uniffi_moq_ffi_checksum_method_moqgroupproducer_abort" [] :uint16)
181-(ffi/defcfn checksum-method-moqgroupproducer-consume "uniffi_moq_ffi_checksum_method_moqgroupproducer_consume" [] :uint16)
182-(ffi/defcfn checksum-method-moqgroupproducer-finish "uniffi_moq_ffi_checksum_method_moqgroupproducer_finish" [] :uint16)
183-(ffi/defcfn checksum-method-moqgroupproducer-sequence "uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence" [] :uint16)
184-(ffi/defcfn checksum-method-moqgroupproducer-write-frame "uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame" [] :uint16)
185-(ffi/defcfn checksum-method-moqgrouprequest-abort "uniffi_moq_ffi_checksum_method_moqgrouprequest_abort" [] :uint16)
186-(ffi/defcfn checksum-method-moqgrouprequest-accept "uniffi_moq_ffi_checksum_method_moqgrouprequest_accept" [] :uint16)
187-(ffi/defcfn checksum-method-moqgrouprequest-priority "uniffi_moq_ffi_checksum_method_moqgrouprequest_priority" [] :uint16)
188-(ffi/defcfn checksum-method-moqgrouprequest-sequence "uniffi_moq_ffi_checksum_method_moqgrouprequest_sequence" [] :uint16)
189-(ffi/defcfn checksum-method-moqjsonsnapshotconsumer-cancel "uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_cancel" [] :uint16)
190-(ffi/defcfn checksum-method-moqjsonsnapshotconsumer-next "uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_next" [] :uint16)
191-(ffi/defcfn checksum-method-moqjsonsnapshotproducer-finish "uniffi_moq_ffi_checksum_method_moqjsonsnapshotproducer_finish" [] :uint16)
192-(ffi/defcfn checksum-method-moqjsonsnapshotproducer-update "uniffi_moq_ffi_checksum_method_moqjsonsnapshotproducer_update" [] :uint16)
193-(ffi/defcfn checksum-method-moqjsonstreamconsumer-cancel "uniffi_moq_ffi_checksum_method_moqjsonstreamconsumer_cancel" [] :uint16)
194-(ffi/defcfn checksum-method-moqjsonstreamconsumer-next "uniffi_moq_ffi_checksum_method_moqjsonstreamconsumer_next" [] :uint16)
195-(ffi/defcfn checksum-method-moqjsonstreamproducer-append "uniffi_moq_ffi_checksum_method_moqjsonstreamproducer_append" [] :uint16)
196-(ffi/defcfn checksum-method-moqjsonstreamproducer-finish "uniffi_moq_ffi_checksum_method_moqjsonstreamproducer_finish" [] :uint16)
197-(ffi/defcfn checksum-method-moqmediaconsumer-cancel "uniffi_moq_ffi_checksum_method_moqmediaconsumer_cancel" [] :uint16)
198-(ffi/defcfn checksum-method-moqmediaconsumer-next "uniffi_moq_ffi_checksum_method_moqmediaconsumer_next" [] :uint16)
199-(ffi/defcfn checksum-method-moqmediagroupconsumer-cancel "uniffi_moq_ffi_checksum_method_moqmediagroupconsumer_cancel" [] :uint16)
200-(ffi/defcfn checksum-method-moqmediagroupconsumer-next "uniffi_moq_ffi_checksum_method_moqmediagroupconsumer_next" [] :uint16)
201-(ffi/defcfn checksum-method-moqmediagroupconsumer-sequence "uniffi_moq_ffi_checksum_method_moqmediagroupconsumer_sequence" [] :uint16)
202-(ffi/defcfn checksum-method-moqmediaproducer-finish "uniffi_moq_ffi_checksum_method_moqmediaproducer_finish" [] :uint16)
203-(ffi/defcfn checksum-method-moqmediaproducer-name "uniffi_moq_ffi_checksum_method_moqmediaproducer_name" [] :uint16)
204-(ffi/defcfn checksum-method-moqmediaproducer-unused "uniffi_moq_ffi_checksum_method_moqmediaproducer_unused" [] :uint16)
205-(ffi/defcfn checksum-method-moqmediaproducer-used "uniffi_moq_ffi_checksum_method_moqmediaproducer_used" [] :uint16)
206-(ffi/defcfn checksum-method-moqmediaproducer-write-frame "uniffi_moq_ffi_checksum_method_moqmediaproducer_write_frame" [] :uint16)
207-(ffi/defcfn checksum-method-moqmediastreamproducer-finish "uniffi_moq_ffi_checksum_method_moqmediastreamproducer_finish" [] :uint16)
208-(ffi/defcfn checksum-method-moqmediastreamproducer-write "uniffi_moq_ffi_checksum_method_moqmediastreamproducer_write" [] :uint16)
209-(ffi/defcfn checksum-method-moqoriginconsumer-announced "uniffi_moq_ffi_checksum_method_moqoriginconsumer_announced" [] :uint16)
210-(ffi/defcfn checksum-method-moqoriginconsumer-announced-broadcast "uniffi_moq_ffi_checksum_method_moqoriginconsumer_announced_broadcast" [] :uint16)
211-(ffi/defcfn checksum-method-moqoriginconsumer-request-broadcast "uniffi_moq_ffi_checksum_method_moqoriginconsumer_request_broadcast" [] :uint16)
212-(ffi/defcfn checksum-method-moqorigindynamic-cancel "uniffi_moq_ffi_checksum_method_moqorigindynamic_cancel" [] :uint16)
213-(ffi/defcfn checksum-method-moqorigindynamic-requested-broadcast "uniffi_moq_ffi_checksum_method_moqorigindynamic_requested_broadcast" [] :uint16)
214-(ffi/defcfn checksum-method-moqoriginproducer-consume "uniffi_moq_ffi_checksum_method_moqoriginproducer_consume" [] :uint16)
215-(ffi/defcfn checksum-method-moqoriginproducer-create-broadcast "uniffi_moq_ffi_checksum_method_moqoriginproducer_create_broadcast" [] :uint16)
216-(ffi/defcfn checksum-method-moqoriginproducer-dynamic "uniffi_moq_ffi_checksum_method_moqoriginproducer_dynamic" [] :uint16)
217-(ffi/defcfn checksum-method-moqrequest-accept "uniffi_moq_ffi_checksum_method_moqrequest_accept" [] :uint16)
218-(ffi/defcfn checksum-method-moqrequest-cancel "uniffi_moq_ffi_checksum_method_moqrequest_cancel" [] :uint16)
219-(ffi/defcfn checksum-method-moqrequest-path "uniffi_moq_ffi_checksum_method_moqrequest_path" [] :uint16)
220-(ffi/defcfn checksum-method-moqrequest-query "uniffi_moq_ffi_checksum_method_moqrequest_query" [] :uint16)
221-(ffi/defcfn checksum-method-moqrequest-reject "uniffi_moq_ffi_checksum_method_moqrequest_reject" [] :uint16)
222-(ffi/defcfn checksum-method-moqrequest-set-consume "uniffi_moq_ffi_checksum_method_moqrequest_set_consume" [] :uint16)
223-(ffi/defcfn checksum-method-moqrequest-set-publish "uniffi_moq_ffi_checksum_method_moqrequest_set_publish" [] :uint16)
224-(ffi/defcfn checksum-method-moqrequest-transport "uniffi_moq_ffi_checksum_method_moqrequest_transport" [] :uint16)
225-(ffi/defcfn checksum-method-moqrequest-url "uniffi_moq_ffi_checksum_method_moqrequest_url" [] :uint16)
226-(ffi/defcfn checksum-method-moqroutewatch-cancel "uniffi_moq_ffi_checksum_method_moqroutewatch_cancel" [] :uint16)
227-(ffi/defcfn checksum-method-moqroutewatch-next "uniffi_moq_ffi_checksum_method_moqroutewatch_next" [] :uint16)
228-(ffi/defcfn checksum-method-moqserver-accept "uniffi_moq_ffi_checksum_method_moqserver_accept" [] :uint16)
229-(ffi/defcfn checksum-method-moqserver-cancel "uniffi_moq_ffi_checksum_method_moqserver_cancel" [] :uint16)
230-(ffi/defcfn checksum-method-moqserver-cert-fingerprints "uniffi_moq_ffi_checksum_method_moqserver_cert_fingerprints" [] :uint16)
231-(ffi/defcfn checksum-method-moqserver-listen "uniffi_moq_ffi_checksum_method_moqserver_listen" [] :uint16)
232-(ffi/defcfn checksum-method-moqserver-set-bind "uniffi_moq_ffi_checksum_method_moqserver_set_bind" [] :uint16)
233-(ffi/defcfn checksum-method-moqserver-set-consume "uniffi_moq_ffi_checksum_method_moqserver_set_consume" [] :uint16)
234-(ffi/defcfn checksum-method-moqserver-set-publish "uniffi_moq_ffi_checksum_method_moqserver_set_publish" [] :uint16)
235-(ffi/defcfn checksum-method-moqserver-set-tls-cert "uniffi_moq_ffi_checksum_method_moqserver_set_tls_cert" [] :uint16)
236-(ffi/defcfn checksum-method-moqserver-set-tls-generate "uniffi_moq_ffi_checksum_method_moqserver_set_tls_generate" [] :uint16)
237-(ffi/defcfn checksum-method-moqserver-set-tls-key "uniffi_moq_ffi_checksum_method_moqserver_set_tls_key" [] :uint16)
238-(ffi/defcfn checksum-method-moqsession-cancel "uniffi_moq_ffi_checksum_method_moqsession_cancel" [] :uint16)
239-(ffi/defcfn checksum-method-moqsession-closed "uniffi_moq_ffi_checksum_method_moqsession_closed" [] :uint16)
240-(ffi/defcfn checksum-method-moqsession-consumer "uniffi_moq_ffi_checksum_method_moqsession_consumer" [] :uint16)
241-(ffi/defcfn checksum-method-moqsession-publisher "uniffi_moq_ffi_checksum_method_moqsession_publisher" [] :uint16)
242-(ffi/defcfn checksum-method-moqsession-shutdown "uniffi_moq_ffi_checksum_method_moqsession_shutdown" [] :uint16)
243-(ffi/defcfn checksum-method-moqsession-stats "uniffi_moq_ffi_checksum_method_moqsession_stats" [] :uint16)
244-(ffi/defcfn checksum-method-moqtrackconsumer-cancel "uniffi_moq_ffi_checksum_method_moqtrackconsumer_cancel" [] :uint16)
245-(ffi/defcfn checksum-method-moqtrackconsumer-info "uniffi_moq_ffi_checksum_method_moqtrackconsumer_info" [] :uint16)
246-(ffi/defcfn checksum-method-moqtrackconsumer-next-group "uniffi_moq_ffi_checksum_method_moqtrackconsumer_next_group" [] :uint16)
247-(ffi/defcfn checksum-method-moqtrackconsumer-read-frame "uniffi_moq_ffi_checksum_method_moqtrackconsumer_read_frame" [] :uint16)
248-(ffi/defcfn checksum-method-moqtrackconsumer-recv-datagram "uniffi_moq_ffi_checksum_method_moqtrackconsumer_recv_datagram" [] :uint16)
249-(ffi/defcfn checksum-method-moqtrackconsumer-recv-group "uniffi_moq_ffi_checksum_method_moqtrackconsumer_recv_group" [] :uint16)
250-(ffi/defcfn checksum-method-moqtrackconsumer-update "uniffi_moq_ffi_checksum_method_moqtrackconsumer_update" [] :uint16)
251-(ffi/defcfn checksum-method-moqtrackdynamic-cancel "uniffi_moq_ffi_checksum_method_moqtrackdynamic_cancel" [] :uint16)
252-(ffi/defcfn checksum-method-moqtrackdynamic-requested-group "uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_group" [] :uint16)
253-(ffi/defcfn checksum-method-moqtrackproducer-abort "uniffi_moq_ffi_checksum_method_moqtrackproducer_abort" [] :uint16)
254-(ffi/defcfn checksum-method-moqtrackproducer-append-datagram "uniffi_moq_ffi_checksum_method_moqtrackproducer_append_datagram" [] :uint16)
255-(ffi/defcfn checksum-method-moqtrackproducer-append-group "uniffi_moq_ffi_checksum_method_moqtrackproducer_append_group" [] :uint16)
256-(ffi/defcfn checksum-method-moqtrackproducer-consume "uniffi_moq_ffi_checksum_method_moqtrackproducer_consume" [] :uint16)
257-(ffi/defcfn checksum-method-moqtrackproducer-create-group "uniffi_moq_ffi_checksum_method_moqtrackproducer_create_group" [] :uint16)
258-(ffi/defcfn checksum-method-moqtrackproducer-dynamic "uniffi_moq_ffi_checksum_method_moqtrackproducer_dynamic" [] :uint16)
259-(ffi/defcfn checksum-method-moqtrackproducer-finish "uniffi_moq_ffi_checksum_method_moqtrackproducer_finish" [] :uint16)
260-(ffi/defcfn checksum-method-moqtrackproducer-finish-at "uniffi_moq_ffi_checksum_method_moqtrackproducer_finish_at" [] :uint16)
261-(ffi/defcfn checksum-method-moqtrackproducer-name "uniffi_moq_ffi_checksum_method_moqtrackproducer_name" [] :uint16)
262-(ffi/defcfn checksum-method-moqtrackproducer-unused "uniffi_moq_ffi_checksum_method_moqtrackproducer_unused" [] :uint16)
263-(ffi/defcfn checksum-method-moqtrackproducer-used "uniffi_moq_ffi_checksum_method_moqtrackproducer_used" [] :uint16)
264-(ffi/defcfn checksum-method-moqtrackproducer-write-frame "uniffi_moq_ffi_checksum_method_moqtrackproducer_write_frame" [] :uint16)
265-(ffi/defcfn checksum-method-moqtrackrequest-abort "uniffi_moq_ffi_checksum_method_moqtrackrequest_abort" [] :uint16)
266-(ffi/defcfn checksum-method-moqtrackrequest-accept "uniffi_moq_ffi_checksum_method_moqtrackrequest_accept" [] :uint16)
267-(ffi/defcfn checksum-method-moqtrackrequest-dynamic "uniffi_moq_ffi_checksum_method_moqtrackrequest_dynamic" [] :uint16)
268-(ffi/defcfn checksum-method-moqtrackrequest-name "uniffi_moq_ffi_checksum_method_moqtrackrequest_name" [] :uint16)
269-(ffi/defcfn clone-moqannounced "uniffi_moq_ffi_fn_clone_moqannounced" [:uint64 :pointer] :uint64)
270-(ffi/defcfn clone-moqannouncedbroadcast "uniffi_moq_ffi_fn_clone_moqannouncedbroadcast" [:uint64 :pointer] :uint64)
271-(ffi/defcfn clone-moqannouncement "uniffi_moq_ffi_fn_clone_moqannouncement" [:uint64 :pointer] :uint64)
272-(ffi/defcfn clone-moqbroadcastconsumer "uniffi_moq_ffi_fn_clone_moqbroadcastconsumer" [:uint64 :pointer] :uint64)
273-(ffi/defcfn clone-moqbroadcastdynamic "uniffi_moq_ffi_fn_clone_moqbroadcastdynamic" [:uint64 :pointer] :uint64)
274-(ffi/defcfn clone-moqbroadcastproducer "uniffi_moq_ffi_fn_clone_moqbroadcastproducer" [:uint64 :pointer] :uint64)
275-(ffi/defcfn clone-moqbroadcastrequest "uniffi_moq_ffi_fn_clone_moqbroadcastrequest" [:uint64 :pointer] :uint64)
276-(ffi/defcfn clone-moqcatalogconsumer "uniffi_moq_ffi_fn_clone_moqcatalogconsumer" [:uint64 :pointer] :uint64)
277-(ffi/defcfn clone-moqclient "uniffi_moq_ffi_fn_clone_moqclient" [:uint64 :pointer] :uint64)
278-(ffi/defcfn clone-moqgroupconsumer "uniffi_moq_ffi_fn_clone_moqgroupconsumer" [:uint64 :pointer] :uint64)
279-(ffi/defcfn clone-moqgroupproducer "uniffi_moq_ffi_fn_clone_moqgroupproducer" [:uint64 :pointer] :uint64)
280-(ffi/defcfn clone-moqgrouprequest "uniffi_moq_ffi_fn_clone_moqgrouprequest" [:uint64 :pointer] :uint64)
281-(ffi/defcfn clone-moqjsonsnapshotconsumer "uniffi_moq_ffi_fn_clone_moqjsonsnapshotconsumer" [:uint64 :pointer] :uint64)
282-(ffi/defcfn clone-moqjsonsnapshotproducer "uniffi_moq_ffi_fn_clone_moqjsonsnapshotproducer" [:uint64 :pointer] :uint64)
283-(ffi/defcfn clone-moqjsonstreamconsumer "uniffi_moq_ffi_fn_clone_moqjsonstreamconsumer" [:uint64 :pointer] :uint64)
284-(ffi/defcfn clone-moqjsonstreamproducer "uniffi_moq_ffi_fn_clone_moqjsonstreamproducer" [:uint64 :pointer] :uint64)
285-(ffi/defcfn clone-moqmediaconsumer "uniffi_moq_ffi_fn_clone_moqmediaconsumer" [:uint64 :pointer] :uint64)
286-(ffi/defcfn clone-moqmediagroupconsumer "uniffi_moq_ffi_fn_clone_moqmediagroupconsumer" [:uint64 :pointer] :uint64)
287-(ffi/defcfn clone-moqmediaproducer "uniffi_moq_ffi_fn_clone_moqmediaproducer" [:uint64 :pointer] :uint64)
288-(ffi/defcfn clone-moqmediastreamproducer "uniffi_moq_ffi_fn_clone_moqmediastreamproducer" [:uint64 :pointer] :uint64)
289-(ffi/defcfn clone-moqoriginconsumer "uniffi_moq_ffi_fn_clone_moqoriginconsumer" [:uint64 :pointer] :uint64)
290-(ffi/defcfn clone-moqorigindynamic "uniffi_moq_ffi_fn_clone_moqorigindynamic" [:uint64 :pointer] :uint64)
291-(ffi/defcfn clone-moqoriginproducer "uniffi_moq_ffi_fn_clone_moqoriginproducer" [:uint64 :pointer] :uint64)
292-(ffi/defcfn clone-moqrequest "uniffi_moq_ffi_fn_clone_moqrequest" [:uint64 :pointer] :uint64)
293-(ffi/defcfn clone-moqroutewatch "uniffi_moq_ffi_fn_clone_moqroutewatch" [:uint64 :pointer] :uint64)
294-(ffi/defcfn clone-moqserver "uniffi_moq_ffi_fn_clone_moqserver" [:uint64 :pointer] :uint64)
295-(ffi/defcfn clone-moqsession "uniffi_moq_ffi_fn_clone_moqsession" [:uint64 :pointer] :uint64)
296-(ffi/defcfn clone-moqtrackconsumer "uniffi_moq_ffi_fn_clone_moqtrackconsumer" [:uint64 :pointer] :uint64)
297-(ffi/defcfn clone-moqtrackdynamic "uniffi_moq_ffi_fn_clone_moqtrackdynamic" [:uint64 :pointer] :uint64)
298-(ffi/defcfn clone-moqtrackproducer "uniffi_moq_ffi_fn_clone_moqtrackproducer" [:uint64 :pointer] :uint64)
299-(ffi/defcfn clone-moqtrackrequest "uniffi_moq_ffi_fn_clone_moqtrackrequest" [:uint64 :pointer] :uint64)
300-(ffi/defcfn constructor-moqbroadcastproducer-new "uniffi_moq_ffi_fn_constructor_moqbroadcastproducer_new" [:pointer] :uint64)
301-(ffi/defcfn constructor-moqclient-new "uniffi_moq_ffi_fn_constructor_moqclient_new" [:pointer] :uint64)
302-(ffi/defcfn constructor-moqoriginproducer-new "uniffi_moq_ffi_fn_constructor_moqoriginproducer_new" [[:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
303-(ffi/defcfn constructor-moqserver-new "uniffi_moq_ffi_fn_constructor_moqserver_new" [:pointer] :uint64)
304-(ffi/defcfn free-moqannounced "uniffi_moq_ffi_fn_free_moqannounced" [:uint64 :pointer] :void)
305-(ffi/defcfn free-moqannouncedbroadcast "uniffi_moq_ffi_fn_free_moqannouncedbroadcast" [:uint64 :pointer] :void)
306-(ffi/defcfn free-moqannouncement "uniffi_moq_ffi_fn_free_moqannouncement" [:uint64 :pointer] :void)
307-(ffi/defcfn free-moqbroadcastconsumer "uniffi_moq_ffi_fn_free_moqbroadcastconsumer" [:uint64 :pointer] :void)
308-(ffi/defcfn free-moqbroadcastdynamic "uniffi_moq_ffi_fn_free_moqbroadcastdynamic" [:uint64 :pointer] :void)
309-(ffi/defcfn free-moqbroadcastproducer "uniffi_moq_ffi_fn_free_moqbroadcastproducer" [:uint64 :pointer] :void)
310-(ffi/defcfn free-moqbroadcastrequest "uniffi_moq_ffi_fn_free_moqbroadcastrequest" [:uint64 :pointer] :void)
311-(ffi/defcfn free-moqcatalogconsumer "uniffi_moq_ffi_fn_free_moqcatalogconsumer" [:uint64 :pointer] :void)
312-(ffi/defcfn free-moqclient "uniffi_moq_ffi_fn_free_moqclient" [:uint64 :pointer] :void)
313-(ffi/defcfn free-moqgroupconsumer "uniffi_moq_ffi_fn_free_moqgroupconsumer" [:uint64 :pointer] :void)
314-(ffi/defcfn free-moqgroupproducer "uniffi_moq_ffi_fn_free_moqgroupproducer" [:uint64 :pointer] :void)
315-(ffi/defcfn free-moqgrouprequest "uniffi_moq_ffi_fn_free_moqgrouprequest" [:uint64 :pointer] :void)
316-(ffi/defcfn free-moqjsonsnapshotconsumer "uniffi_moq_ffi_fn_free_moqjsonsnapshotconsumer" [:uint64 :pointer] :void)
317-(ffi/defcfn free-moqjsonsnapshotproducer "uniffi_moq_ffi_fn_free_moqjsonsnapshotproducer" [:uint64 :pointer] :void)
318-(ffi/defcfn free-moqjsonstreamconsumer "uniffi_moq_ffi_fn_free_moqjsonstreamconsumer" [:uint64 :pointer] :void)
319-(ffi/defcfn free-moqjsonstreamproducer "uniffi_moq_ffi_fn_free_moqjsonstreamproducer" [:uint64 :pointer] :void)
320-(ffi/defcfn free-moqmediaconsumer "uniffi_moq_ffi_fn_free_moqmediaconsumer" [:uint64 :pointer] :void)
321-(ffi/defcfn free-moqmediagroupconsumer "uniffi_moq_ffi_fn_free_moqmediagroupconsumer" [:uint64 :pointer] :void)
322-(ffi/defcfn free-moqmediaproducer "uniffi_moq_ffi_fn_free_moqmediaproducer" [:uint64 :pointer] :void)
323-(ffi/defcfn free-moqmediastreamproducer "uniffi_moq_ffi_fn_free_moqmediastreamproducer" [:uint64 :pointer] :void)
324-(ffi/defcfn free-moqoriginconsumer "uniffi_moq_ffi_fn_free_moqoriginconsumer" [:uint64 :pointer] :void)
325-(ffi/defcfn free-moqorigindynamic "uniffi_moq_ffi_fn_free_moqorigindynamic" [:uint64 :pointer] :void)
326-(ffi/defcfn free-moqoriginproducer "uniffi_moq_ffi_fn_free_moqoriginproducer" [:uint64 :pointer] :void)
327-(ffi/defcfn free-moqrequest "uniffi_moq_ffi_fn_free_moqrequest" [:uint64 :pointer] :void)
328-(ffi/defcfn free-moqroutewatch "uniffi_moq_ffi_fn_free_moqroutewatch" [:uint64 :pointer] :void)
329-(ffi/defcfn free-moqserver "uniffi_moq_ffi_fn_free_moqserver" [:uint64 :pointer] :void)
330-(ffi/defcfn free-moqsession "uniffi_moq_ffi_fn_free_moqsession" [:uint64 :pointer] :void)
331-(ffi/defcfn free-moqtrackconsumer "uniffi_moq_ffi_fn_free_moqtrackconsumer" [:uint64 :pointer] :void)
332-(ffi/defcfn free-moqtrackdynamic "uniffi_moq_ffi_fn_free_moqtrackdynamic" [:uint64 :pointer] :void)
333-(ffi/defcfn free-moqtrackproducer "uniffi_moq_ffi_fn_free_moqtrackproducer" [:uint64 :pointer] :void)
334-(ffi/defcfn free-moqtrackrequest "uniffi_moq_ffi_fn_free_moqtrackrequest" [:uint64 :pointer] :void)
335-(ffi/defcfn func-moq-log-level "uniffi_moq_ffi_fn_func_moq_log_level" [[:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
336-(ffi/defcfn method-moqannounced-cancel "uniffi_moq_ffi_fn_method_moqannounced_cancel" [:uint64 :pointer] :void)
337-(ffi/defcfn method-moqannounced-next "uniffi_moq_ffi_fn_method_moqannounced_next" [:uint64] :uint64)
338-(ffi/defcfn method-moqannouncedbroadcast-available "uniffi_moq_ffi_fn_method_moqannouncedbroadcast_available" [:uint64] :uint64)
339-(ffi/defcfn method-moqannouncedbroadcast-cancel "uniffi_moq_ffi_fn_method_moqannouncedbroadcast_cancel" [:uint64 :pointer] :void)
340-(ffi/defcfn method-moqannouncement-broadcast "uniffi_moq_ffi_fn_method_moqannouncement_broadcast" [:uint64 :pointer] :uint64)
341-(ffi/defcfn method-moqannouncement-path "uniffi_moq_ffi_fn_method_moqannouncement_path" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
342-(ffi/defcfn method-moqbroadcastconsumer-fetch-group "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_fetch_group" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
343-(ffi/defcfn method-moqbroadcastconsumer-fetch-media-group "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_fetch_media_group" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
344-(ffi/defcfn method-moqbroadcastconsumer-route "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_route" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
345-(ffi/defcfn method-moqbroadcastconsumer-route-updates "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_route_updates" [:uint64 :pointer] :uint64)
346-(ffi/defcfn method-moqbroadcastconsumer-subscribe-catalog "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_catalog" [:uint64] :uint64)
347-(ffi/defcfn method-moqbroadcastconsumer-subscribe-json-snapshot "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_json_snapshot" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
348-(ffi/defcfn method-moqbroadcastconsumer-subscribe-json-stream "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_json_stream" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
349-(ffi/defcfn method-moqbroadcastconsumer-subscribe-media "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_media" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
350-(ffi/defcfn method-moqbroadcastconsumer-subscribe-track "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_track" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
351-(ffi/defcfn method-moqbroadcastdynamic-cancel "uniffi_moq_ffi_fn_method_moqbroadcastdynamic_cancel" [:uint64 :pointer] :void)
352-(ffi/defcfn method-moqbroadcastdynamic-requested-track "uniffi_moq_ffi_fn_method_moqbroadcastdynamic_requested_track" [:uint64] :uint64)
353-(ffi/defcfn method-moqbroadcastproducer-consume "uniffi_moq_ffi_fn_method_moqbroadcastproducer_consume" [:uint64 :pointer] :uint64)
354-(ffi/defcfn method-moqbroadcastproducer-dynamic "uniffi_moq_ffi_fn_method_moqbroadcastproducer_dynamic" [:uint64 :pointer] :uint64)
355-(ffi/defcfn method-moqbroadcastproducer-finish "uniffi_moq_ffi_fn_method_moqbroadcastproducer_finish" [:uint64 :pointer] :void)
356-(ffi/defcfn method-moqbroadcastproducer-publish-json-snapshot "uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_json_snapshot" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
357-(ffi/defcfn method-moqbroadcastproducer-publish-json-stream "uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_json_stream" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
358-(ffi/defcfn method-moqbroadcastproducer-publish-media "uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_media" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
359-(ffi/defcfn method-moqbroadcastproducer-publish-media-on-track "uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_media_on_track" [:uint64 :uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
360-(ffi/defcfn method-moqbroadcastproducer-publish-media-stream "uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_media_stream" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
361-(ffi/defcfn method-moqbroadcastproducer-publish-track "uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_track" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
362-(ffi/defcfn method-moqbroadcastproducer-remove-catalog-section "uniffi_moq_ffi_fn_method_moqbroadcastproducer_remove_catalog_section" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
363-(ffi/defcfn method-moqbroadcastproducer-set-announce "uniffi_moq_ffi_fn_method_moqbroadcastproducer_set_announce" [:uint64 :int8 :pointer] :void)
364-(ffi/defcfn method-moqbroadcastproducer-set-catalog-section "uniffi_moq_ffi_fn_method_moqbroadcastproducer_set_catalog_section" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
365-(ffi/defcfn method-moqbroadcastproducer-set-route "uniffi_moq_ffi_fn_method_moqbroadcastproducer_set_route" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
366-(ffi/defcfn method-moqbroadcastproducer-set-video-properties "uniffi_moq_ffi_fn_method_moqbroadcastproducer_set_video_properties" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
367-(ffi/defcfn method-moqbroadcastrequest-abort "uniffi_moq_ffi_fn_method_moqbroadcastrequest_abort" [:uint64 :uint16 :pointer] :void)
368-(ffi/defcfn method-moqbroadcastrequest-accept "uniffi_moq_ffi_fn_method_moqbroadcastrequest_accept" [:uint64 :uint64 :pointer] :void)
369-(ffi/defcfn method-moqbroadcastrequest-path "uniffi_moq_ffi_fn_method_moqbroadcastrequest_path" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
370-(ffi/defcfn method-moqcatalogconsumer-cancel "uniffi_moq_ffi_fn_method_moqcatalogconsumer_cancel" [:uint64 :pointer] :void)
371-(ffi/defcfn method-moqcatalogconsumer-next "uniffi_moq_ffi_fn_method_moqcatalogconsumer_next" [:uint64] :uint64)
372-(ffi/defcfn method-moqclient-cancel "uniffi_moq_ffi_fn_method_moqclient_cancel" [:uint64 :pointer] :void)
373-(ffi/defcfn method-moqclient-connect "uniffi_moq_ffi_fn_method_moqclient_connect" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
374-(ffi/defcfn method-moqclient-set-bind "uniffi_moq_ffi_fn_method_moqclient_set_bind" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
375-(ffi/defcfn method-moqclient-set-consume "uniffi_moq_ffi_fn_method_moqclient_set_consume" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
376-(ffi/defcfn method-moqclient-set-publish "uniffi_moq_ffi_fn_method_moqclient_set_publish" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
377-(ffi/defcfn method-moqclient-set-tls-cert "uniffi_moq_ffi_fn_method_moqclient_set_tls_cert" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
378-(ffi/defcfn method-moqclient-set-tls-disable-verify "uniffi_moq_ffi_fn_method_moqclient_set_tls_disable_verify" [:uint64 :int8 :pointer] :void)
379-(ffi/defcfn method-moqclient-set-tls-fingerprints "uniffi_moq_ffi_fn_method_moqclient_set_tls_fingerprints" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
380-(ffi/defcfn method-moqclient-set-tls-key "uniffi_moq_ffi_fn_method_moqclient_set_tls_key" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
381-(ffi/defcfn method-moqclient-set-tls-roots "uniffi_moq_ffi_fn_method_moqclient_set_tls_roots" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
382-(ffi/defcfn method-moqclient-set-tls-system-roots "uniffi_moq_ffi_fn_method_moqclient_set_tls_system_roots" [:uint64 :int8 :pointer] :void)
383-(ffi/defcfn method-moqgroupconsumer-cancel "uniffi_moq_ffi_fn_method_moqgroupconsumer_cancel" [:uint64 :pointer] :void)
384-(ffi/defcfn method-moqgroupconsumer-read-frame "uniffi_moq_ffi_fn_method_moqgroupconsumer_read_frame" [:uint64] :uint64)
385-(ffi/defcfn method-moqgroupconsumer-sequence "uniffi_moq_ffi_fn_method_moqgroupconsumer_sequence" [:uint64 :pointer] :uint64)
386-(ffi/defcfn method-moqgroupproducer-abort "uniffi_moq_ffi_fn_method_moqgroupproducer_abort" [:uint64 :uint16 :pointer] :void)
387-(ffi/defcfn method-moqgroupproducer-consume "uniffi_moq_ffi_fn_method_moqgroupproducer_consume" [:uint64 :pointer] :uint64)
388-(ffi/defcfn method-moqgroupproducer-finish "uniffi_moq_ffi_fn_method_moqgroupproducer_finish" [:uint64 :pointer] :void)
389-(ffi/defcfn method-moqgroupproducer-sequence "uniffi_moq_ffi_fn_method_moqgroupproducer_sequence" [:uint64 :pointer] :uint64)
390-(ffi/defcfn method-moqgroupproducer-write-frame "uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
391-(ffi/defcfn method-moqgrouprequest-abort "uniffi_moq_ffi_fn_method_moqgrouprequest_abort" [:uint64 :uint16 :pointer] :void)
392-(ffi/defcfn method-moqgrouprequest-accept "uniffi_moq_ffi_fn_method_moqgrouprequest_accept" [:uint64 :pointer] :uint64)
393-(ffi/defcfn method-moqgrouprequest-priority "uniffi_moq_ffi_fn_method_moqgrouprequest_priority" [:uint64 :pointer] :uint8)
394-(ffi/defcfn method-moqgrouprequest-sequence "uniffi_moq_ffi_fn_method_moqgrouprequest_sequence" [:uint64 :pointer] :uint64)
395-(ffi/defcfn method-moqjsonsnapshotconsumer-cancel "uniffi_moq_ffi_fn_method_moqjsonsnapshotconsumer_cancel" [:uint64 :pointer] :void)
396-(ffi/defcfn method-moqjsonsnapshotconsumer-next "uniffi_moq_ffi_fn_method_moqjsonsnapshotconsumer_next" [:uint64] :uint64)
397-(ffi/defcfn method-moqjsonsnapshotproducer-finish "uniffi_moq_ffi_fn_method_moqjsonsnapshotproducer_finish" [:uint64 :pointer] :void)
398-(ffi/defcfn method-moqjsonsnapshotproducer-update "uniffi_moq_ffi_fn_method_moqjsonsnapshotproducer_update" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
399-(ffi/defcfn method-moqjsonstreamconsumer-cancel "uniffi_moq_ffi_fn_method_moqjsonstreamconsumer_cancel" [:uint64 :pointer] :void)
400-(ffi/defcfn method-moqjsonstreamconsumer-next "uniffi_moq_ffi_fn_method_moqjsonstreamconsumer_next" [:uint64] :uint64)
401-(ffi/defcfn method-moqjsonstreamproducer-append "uniffi_moq_ffi_fn_method_moqjsonstreamproducer_append" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
402-(ffi/defcfn method-moqjsonstreamproducer-finish "uniffi_moq_ffi_fn_method_moqjsonstreamproducer_finish" [:uint64 :pointer] :void)
403-(ffi/defcfn method-moqmediaconsumer-cancel "uniffi_moq_ffi_fn_method_moqmediaconsumer_cancel" [:uint64 :pointer] :void)
404-(ffi/defcfn method-moqmediaconsumer-next "uniffi_moq_ffi_fn_method_moqmediaconsumer_next" [:uint64] :uint64)
405-(ffi/defcfn method-moqmediagroupconsumer-cancel "uniffi_moq_ffi_fn_method_moqmediagroupconsumer_cancel" [:uint64 :pointer] :void)
406-(ffi/defcfn method-moqmediagroupconsumer-next "uniffi_moq_ffi_fn_method_moqmediagroupconsumer_next" [:uint64] :uint64)
407-(ffi/defcfn method-moqmediagroupconsumer-sequence "uniffi_moq_ffi_fn_method_moqmediagroupconsumer_sequence" [:uint64 :pointer] :uint64)
408-(ffi/defcfn method-moqmediaproducer-finish "uniffi_moq_ffi_fn_method_moqmediaproducer_finish" [:uint64 :pointer] :void)
409-(ffi/defcfn method-moqmediaproducer-name "uniffi_moq_ffi_fn_method_moqmediaproducer_name" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
410-(ffi/defcfn method-moqmediaproducer-unused "uniffi_moq_ffi_fn_method_moqmediaproducer_unused" [:uint64] :uint64)
411-(ffi/defcfn method-moqmediaproducer-used "uniffi_moq_ffi_fn_method_moqmediaproducer_used" [:uint64] :uint64)
412-(ffi/defcfn method-moqmediaproducer-write-frame "uniffi_moq_ffi_fn_method_moqmediaproducer_write_frame" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
413-(ffi/defcfn method-moqmediastreamproducer-finish "uniffi_moq_ffi_fn_method_moqmediastreamproducer_finish" [:uint64 :pointer] :void)
414-(ffi/defcfn method-moqmediastreamproducer-write "uniffi_moq_ffi_fn_method_moqmediastreamproducer_write" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
415-(ffi/defcfn method-moqoriginconsumer-announced "uniffi_moq_ffi_fn_method_moqoriginconsumer_announced" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
416-(ffi/defcfn method-moqoriginconsumer-announced-broadcast "uniffi_moq_ffi_fn_method_moqoriginconsumer_announced_broadcast" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
417-(ffi/defcfn method-moqoriginconsumer-request-broadcast "uniffi_moq_ffi_fn_method_moqoriginconsumer_request_broadcast" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
418-(ffi/defcfn method-moqorigindynamic-cancel "uniffi_moq_ffi_fn_method_moqorigindynamic_cancel" [:uint64 :pointer] :void)
419-(ffi/defcfn method-moqorigindynamic-requested-broadcast "uniffi_moq_ffi_fn_method_moqorigindynamic_requested_broadcast" [:uint64] :uint64)
420-(ffi/defcfn method-moqoriginproducer-consume "uniffi_moq_ffi_fn_method_moqoriginproducer_consume" [:uint64 :pointer] :uint64)
421-(ffi/defcfn method-moqoriginproducer-create-broadcast "uniffi_moq_ffi_fn_method_moqoriginproducer_create_broadcast" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
422-(ffi/defcfn method-moqoriginproducer-dynamic "uniffi_moq_ffi_fn_method_moqoriginproducer_dynamic" [:uint64 :pointer] :uint64)
423-(ffi/defcfn method-moqrequest-accept "uniffi_moq_ffi_fn_method_moqrequest_accept" [:uint64] :uint64)
424-(ffi/defcfn method-moqrequest-cancel "uniffi_moq_ffi_fn_method_moqrequest_cancel" [:uint64 :pointer] :void)
425-(ffi/defcfn method-moqrequest-path "uniffi_moq_ffi_fn_method_moqrequest_path" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
426-(ffi/defcfn method-moqrequest-query "uniffi_moq_ffi_fn_method_moqrequest_query" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
427-(ffi/defcfn method-moqrequest-reject "uniffi_moq_ffi_fn_method_moqrequest_reject" [:uint64 :uint16] :uint64)
428-(ffi/defcfn method-moqrequest-set-consume "uniffi_moq_ffi_fn_method_moqrequest_set_consume" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
429-(ffi/defcfn method-moqrequest-set-publish "uniffi_moq_ffi_fn_method_moqrequest_set_publish" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
430-(ffi/defcfn method-moqrequest-transport "uniffi_moq_ffi_fn_method_moqrequest_transport" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
431-(ffi/defcfn method-moqrequest-url "uniffi_moq_ffi_fn_method_moqrequest_url" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
432-(ffi/defcfn method-moqroutewatch-cancel "uniffi_moq_ffi_fn_method_moqroutewatch_cancel" [:uint64 :pointer] :void)
433-(ffi/defcfn method-moqroutewatch-next "uniffi_moq_ffi_fn_method_moqroutewatch_next" [:uint64] :uint64)
434-(ffi/defcfn method-moqserver-accept "uniffi_moq_ffi_fn_method_moqserver_accept" [:uint64] :uint64)
435-(ffi/defcfn method-moqserver-cancel "uniffi_moq_ffi_fn_method_moqserver_cancel" [:uint64 :pointer] :void)
436-(ffi/defcfn method-moqserver-cert-fingerprints "uniffi_moq_ffi_fn_method_moqserver_cert_fingerprints" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
437-(ffi/defcfn method-moqserver-listen "uniffi_moq_ffi_fn_method_moqserver_listen" [:uint64] :uint64)
438-(ffi/defcfn method-moqserver-set-bind "uniffi_moq_ffi_fn_method_moqserver_set_bind" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
439-(ffi/defcfn method-moqserver-set-consume "uniffi_moq_ffi_fn_method_moqserver_set_consume" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
440-(ffi/defcfn method-moqserver-set-publish "uniffi_moq_ffi_fn_method_moqserver_set_publish" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
441-(ffi/defcfn method-moqserver-set-tls-cert "uniffi_moq_ffi_fn_method_moqserver_set_tls_cert" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
442-(ffi/defcfn method-moqserver-set-tls-generate "uniffi_moq_ffi_fn_method_moqserver_set_tls_generate" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
443-(ffi/defcfn method-moqserver-set-tls-key "uniffi_moq_ffi_fn_method_moqserver_set_tls_key" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
444-(ffi/defcfn method-moqsession-cancel "uniffi_moq_ffi_fn_method_moqsession_cancel" [:uint64 :uint32 :pointer] :void)
445-(ffi/defcfn method-moqsession-closed "uniffi_moq_ffi_fn_method_moqsession_closed" [:uint64] :uint64)
446-(ffi/defcfn method-moqsession-consumer "uniffi_moq_ffi_fn_method_moqsession_consumer" [:uint64 :pointer] :uint64)
447-(ffi/defcfn method-moqsession-publisher "uniffi_moq_ffi_fn_method_moqsession_publisher" [:uint64 :pointer] :uint64)
448-(ffi/defcfn method-moqsession-shutdown "uniffi_moq_ffi_fn_method_moqsession_shutdown" [:uint64 :pointer] :void)
449-(ffi/defcfn method-moqsession-stats "uniffi_moq_ffi_fn_method_moqsession_stats" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
450-(ffi/defcfn method-moqtrackconsumer-cancel "uniffi_moq_ffi_fn_method_moqtrackconsumer_cancel" [:uint64 :pointer] :void)
451-(ffi/defcfn method-moqtrackconsumer-info "uniffi_moq_ffi_fn_method_moqtrackconsumer_info" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
452-(ffi/defcfn method-moqtrackconsumer-next-group "uniffi_moq_ffi_fn_method_moqtrackconsumer_next_group" [:uint64] :uint64)
453-(ffi/defcfn method-moqtrackconsumer-read-frame "uniffi_moq_ffi_fn_method_moqtrackconsumer_read_frame" [:uint64] :uint64)
454-(ffi/defcfn method-moqtrackconsumer-recv-datagram "uniffi_moq_ffi_fn_method_moqtrackconsumer_recv_datagram" [:uint64] :uint64)
455-(ffi/defcfn method-moqtrackconsumer-recv-group "uniffi_moq_ffi_fn_method_moqtrackconsumer_recv_group" [:uint64] :uint64)
456-(ffi/defcfn method-moqtrackconsumer-update "uniffi_moq_ffi_fn_method_moqtrackconsumer_update" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
457-(ffi/defcfn method-moqtrackdynamic-cancel "uniffi_moq_ffi_fn_method_moqtrackdynamic_cancel" [:uint64 :pointer] :void)
458-(ffi/defcfn method-moqtrackdynamic-requested-group "uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_group" [:uint64] :uint64)
459-(ffi/defcfn method-moqtrackproducer-abort "uniffi_moq_ffi_fn_method_moqtrackproducer_abort" [:uint64 :uint16 :pointer] :void)
460-(ffi/defcfn method-moqtrackproducer-append-datagram "uniffi_moq_ffi_fn_method_moqtrackproducer_append_datagram" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
461-(ffi/defcfn method-moqtrackproducer-append-group "uniffi_moq_ffi_fn_method_moqtrackproducer_append_group" [:uint64 :pointer] :uint64)
462-(ffi/defcfn method-moqtrackproducer-consume "uniffi_moq_ffi_fn_method_moqtrackproducer_consume" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
463-(ffi/defcfn method-moqtrackproducer-create-group "uniffi_moq_ffi_fn_method_moqtrackproducer_create_group" [:uint64 :uint64 :pointer] :uint64)
464-(ffi/defcfn method-moqtrackproducer-dynamic "uniffi_moq_ffi_fn_method_moqtrackproducer_dynamic" [:uint64 :pointer] :uint64)
465-(ffi/defcfn method-moqtrackproducer-finish "uniffi_moq_ffi_fn_method_moqtrackproducer_finish" [:uint64 :pointer] :void)
466-(ffi/defcfn method-moqtrackproducer-finish-at "uniffi_moq_ffi_fn_method_moqtrackproducer_finish_at" [:uint64 :uint64 :pointer] :void)
467-(ffi/defcfn method-moqtrackproducer-name "uniffi_moq_ffi_fn_method_moqtrackproducer_name" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
468-(ffi/defcfn method-moqtrackproducer-unused "uniffi_moq_ffi_fn_method_moqtrackproducer_unused" [:uint64] :uint64)
469-(ffi/defcfn method-moqtrackproducer-used "uniffi_moq_ffi_fn_method_moqtrackproducer_used" [:uint64] :uint64)
470-(ffi/defcfn method-moqtrackproducer-write-frame "uniffi_moq_ffi_fn_method_moqtrackproducer_write_frame" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
471-(ffi/defcfn method-moqtrackrequest-abort "uniffi_moq_ffi_fn_method_moqtrackrequest_abort" [:uint64 :uint16 :pointer] :void)
472-(ffi/defcfn method-moqtrackrequest-accept "uniffi_moq_ffi_fn_method_moqtrackrequest_accept" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
473-(ffi/defcfn method-moqtrackrequest-dynamic "uniffi_moq_ffi_fn_method_moqtrackrequest_dynamic" [:uint64 :pointer] :uint64)
474-(ffi/defcfn method-moqtrackrequest-name "uniffi_moq_ffi_fn_method_moqtrackrequest_name" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
deleted file mode 100644
@@ -1,474 +0,0 @@
1-(ns frq.moq.raw
2- "Every entry point in libmoq_ffi, declared. GENERATED — do not edit.
3-
4- just gen-moq
5-
6- These are not transcribed from a header. UniFFI embeds its interface
7- metadata in the object itself, and `uniffi-bindgen --library` reads it back
8- out, so what is declared here is what the .so we load actually exports.
9-
10- WHAT THIS OBJECT IS. The release artifact, fetched, not built. moq-ffi has
11- `audio` and `video` features that are on by default upstream and OFF in
12- every Linux and Android artifact it publishes — so there is no
13- publish_audio, no publish_video, and no moqaudio*/moqvideo* here: 206
14- functions where the Apple build (and the C header shipped beside it) has
15- 230. Binding that header would have linked on a Mac and failed on both
16- platforms frq ships to, which is why these come from the object.
17-
18- The codecs are deliberately absent rather than missing. Building moq-ffi
19- with its defaults on would put Opus and H.264 behind this same ABI at the
20- cost of compiling a 1062-crate workspace — the whole dependency graph this
21- binding exists to avoid. The codecs come from their own C libraries
22- instead; this object is the transport, which is the one piece with no C
23- implementation anywhere.
24-
25- Nothing here is hand-written and nothing is inferred. The generator maps a
26- ctype it does not recognise to nothing at all and reports it, rather than
27- guessing a width — a wrong one would be a silent memory bug rather than a
28- compile error.
29-
30- Two shapes recur, and both are spelled out in full at every call site rather
31- than named: `defcfn` is a macro whose types Chez fixes when it COMPILES the
32- foreign procedure, so a `def`'d descriptor in type position has nothing to
33- compile. Hence the literal [:by-value [:struct ...]] repeated throughout —
34- it is the RustBuffer {capacity, len, data} triple, or the ForeignBytes pair.
35-
36- A trailing :pointer on a non-async function is its `RustCallStatus *`
37- out-parameter; `frq.moq.uniffi/with-out-status` is what supplies one. An
38- async function has no status and answers a future handle instead — poll it
39- through `frq.moq.uniffi/start-future`.
40-
41- Generated from the moq-ffi 0.3.17 release object, UniFFI contract 30."
42- (:require [jolt.ffi :as ffi]))
43-
44-;; MoqError, as UniFFI numbers it in THIS object. Generated with the
45-;; entry points above, and for the same reason: the variants are
46-;; inserted into rather than appended to, so a table written by hand
47-;; against one build names the wrong error in the next.
48-(def moq-error-variants
49- {1 :protocol
50- 2 :media
51- 3 :mux
52- 4 :json-track
53- 5 :url
54- 6 :time-overflow
55- 7 :log-level
56- 8 :task
57- 9 :json
58- 10 :cancelled
59- 11 :closed
60- 12 :connect
61- 13 :bind
62- 14 :reject
63- 15 :already-responded
64- 16 :codec
65- 17 :unauthorized
66- 18 :forbidden
67- 19 :not-found
68- 20 :unsupported
69- 21 :invalid-route
70- 22 :log})
71-
72-(ffi/defcfn rust-future-cancel-f32 "ffi_moq_ffi_rust_future_cancel_f32" [:uint64] :void)
73-(ffi/defcfn rust-future-cancel-f64 "ffi_moq_ffi_rust_future_cancel_f64" [:uint64] :void)
74-(ffi/defcfn rust-future-cancel-i16 "ffi_moq_ffi_rust_future_cancel_i16" [:uint64] :void)
75-(ffi/defcfn rust-future-cancel-i32 "ffi_moq_ffi_rust_future_cancel_i32" [:uint64] :void)
76-(ffi/defcfn rust-future-cancel-i64 "ffi_moq_ffi_rust_future_cancel_i64" [:uint64] :void)
77-(ffi/defcfn rust-future-cancel-i8 "ffi_moq_ffi_rust_future_cancel_i8" [:uint64] :void)
78-(ffi/defcfn rust-future-cancel-rust-buffer "ffi_moq_ffi_rust_future_cancel_rust_buffer" [:uint64] :void)
79-(ffi/defcfn rust-future-cancel-u16 "ffi_moq_ffi_rust_future_cancel_u16" [:uint64] :void)
80-(ffi/defcfn rust-future-cancel-u32 "ffi_moq_ffi_rust_future_cancel_u32" [:uint64] :void)
81-(ffi/defcfn rust-future-cancel-u64 "ffi_moq_ffi_rust_future_cancel_u64" [:uint64] :void)
82-(ffi/defcfn rust-future-cancel-u8 "ffi_moq_ffi_rust_future_cancel_u8" [:uint64] :void)
83-(ffi/defcfn rust-future-cancel-void "ffi_moq_ffi_rust_future_cancel_void" [:uint64] :void)
84-(ffi/defcfn rust-future-complete-f32 "ffi_moq_ffi_rust_future_complete_f32" [:uint64 :pointer] :float)
85-(ffi/defcfn rust-future-complete-f64 "ffi_moq_ffi_rust_future_complete_f64" [:uint64 :pointer] :double)
86-(ffi/defcfn rust-future-complete-i16 "ffi_moq_ffi_rust_future_complete_i16" [:uint64 :pointer] :int16)
87-(ffi/defcfn rust-future-complete-i32 "ffi_moq_ffi_rust_future_complete_i32" [:uint64 :pointer] :int32)
88-(ffi/defcfn rust-future-complete-i64 "ffi_moq_ffi_rust_future_complete_i64" [:uint64 :pointer] :int64)
89-(ffi/defcfn rust-future-complete-i8 "ffi_moq_ffi_rust_future_complete_i8" [:uint64 :pointer] :int8)
90-(ffi/defcfn rust-future-complete-rust-buffer "ffi_moq_ffi_rust_future_complete_rust_buffer" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
91-(ffi/defcfn rust-future-complete-u16 "ffi_moq_ffi_rust_future_complete_u16" [:uint64 :pointer] :uint16)
92-(ffi/defcfn rust-future-complete-u32 "ffi_moq_ffi_rust_future_complete_u32" [:uint64 :pointer] :uint32)
93-(ffi/defcfn rust-future-complete-u64 "ffi_moq_ffi_rust_future_complete_u64" [:uint64 :pointer] :uint64)
94-(ffi/defcfn rust-future-complete-u8 "ffi_moq_ffi_rust_future_complete_u8" [:uint64 :pointer] :uint8)
95-(ffi/defcfn rust-future-complete-void "ffi_moq_ffi_rust_future_complete_void" [:uint64 :pointer] :void)
96-(ffi/defcfn rust-future-free-f32 "ffi_moq_ffi_rust_future_free_f32" [:uint64] :void)
97-(ffi/defcfn rust-future-free-f64 "ffi_moq_ffi_rust_future_free_f64" [:uint64] :void)
98-(ffi/defcfn rust-future-free-i16 "ffi_moq_ffi_rust_future_free_i16" [:uint64] :void)
99-(ffi/defcfn rust-future-free-i32 "ffi_moq_ffi_rust_future_free_i32" [:uint64] :void)
100-(ffi/defcfn rust-future-free-i64 "ffi_moq_ffi_rust_future_free_i64" [:uint64] :void)
101-(ffi/defcfn rust-future-free-i8 "ffi_moq_ffi_rust_future_free_i8" [:uint64] :void)
102-(ffi/defcfn rust-future-free-rust-buffer "ffi_moq_ffi_rust_future_free_rust_buffer" [:uint64] :void)
103-(ffi/defcfn rust-future-free-u16 "ffi_moq_ffi_rust_future_free_u16" [:uint64] :void)
104-(ffi/defcfn rust-future-free-u32 "ffi_moq_ffi_rust_future_free_u32" [:uint64] :void)
105-(ffi/defcfn rust-future-free-u64 "ffi_moq_ffi_rust_future_free_u64" [:uint64] :void)
106-(ffi/defcfn rust-future-free-u8 "ffi_moq_ffi_rust_future_free_u8" [:uint64] :void)
107-(ffi/defcfn rust-future-free-void "ffi_moq_ffi_rust_future_free_void" [:uint64] :void)
108-(ffi/defcfn rust-future-poll-f32 "ffi_moq_ffi_rust_future_poll_f32" [:uint64 :pointer :uint64] :void)
109-(ffi/defcfn rust-future-poll-f64 "ffi_moq_ffi_rust_future_poll_f64" [:uint64 :pointer :uint64] :void)
110-(ffi/defcfn rust-future-poll-i16 "ffi_moq_ffi_rust_future_poll_i16" [:uint64 :pointer :uint64] :void)
111-(ffi/defcfn rust-future-poll-i32 "ffi_moq_ffi_rust_future_poll_i32" [:uint64 :pointer :uint64] :void)
112-(ffi/defcfn rust-future-poll-i64 "ffi_moq_ffi_rust_future_poll_i64" [:uint64 :pointer :uint64] :void)
113-(ffi/defcfn rust-future-poll-i8 "ffi_moq_ffi_rust_future_poll_i8" [:uint64 :pointer :uint64] :void)
114-(ffi/defcfn rust-future-poll-rust-buffer "ffi_moq_ffi_rust_future_poll_rust_buffer" [:uint64 :pointer :uint64] :void)
115-(ffi/defcfn rust-future-poll-u16 "ffi_moq_ffi_rust_future_poll_u16" [:uint64 :pointer :uint64] :void)
116-(ffi/defcfn rust-future-poll-u32 "ffi_moq_ffi_rust_future_poll_u32" [:uint64 :pointer :uint64] :void)
117-(ffi/defcfn rust-future-poll-u64 "ffi_moq_ffi_rust_future_poll_u64" [:uint64 :pointer :uint64] :void)
118-(ffi/defcfn rust-future-poll-u8 "ffi_moq_ffi_rust_future_poll_u8" [:uint64 :pointer :uint64] :void)
119-(ffi/defcfn rust-future-poll-void "ffi_moq_ffi_rust_future_poll_void" [:uint64 :pointer :uint64] :void)
120-(ffi/defcfn rustbuffer-alloc "ffi_moq_ffi_rustbuffer_alloc" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
121-(ffi/defcfn rustbuffer-free "ffi_moq_ffi_rustbuffer_free" [[:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
122-(ffi/defcfn rustbuffer-from-bytes "ffi_moq_ffi_rustbuffer_from_bytes" [[:by-value [:struct [[:len :int32] [:data :pointer]]]] :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
123-(ffi/defcfn rustbuffer-reserve "ffi_moq_ffi_rustbuffer_reserve" [[:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
124-(ffi/defcfn uniffi-contract-version "ffi_moq_ffi_uniffi_contract_version" [] :uint32)
125-(ffi/defcfn checksum-constructor-moqbroadcastproducer-new "uniffi_moq_ffi_checksum_constructor_moqbroadcastproducer_new" [] :uint16)
126-(ffi/defcfn checksum-constructor-moqclient-new "uniffi_moq_ffi_checksum_constructor_moqclient_new" [] :uint16)
127-(ffi/defcfn checksum-constructor-moqoriginproducer-new "uniffi_moq_ffi_checksum_constructor_moqoriginproducer_new" [] :uint16)
128-(ffi/defcfn checksum-constructor-moqserver-new "uniffi_moq_ffi_checksum_constructor_moqserver_new" [] :uint16)
129-(ffi/defcfn checksum-func-moq-log-level "uniffi_moq_ffi_checksum_func_moq_log_level" [] :uint16)
130-(ffi/defcfn checksum-method-moqannounced-cancel "uniffi_moq_ffi_checksum_method_moqannounced_cancel" [] :uint16)
131-(ffi/defcfn checksum-method-moqannounced-next "uniffi_moq_ffi_checksum_method_moqannounced_next" [] :uint16)
132-(ffi/defcfn checksum-method-moqannouncedbroadcast-available "uniffi_moq_ffi_checksum_method_moqannouncedbroadcast_available" [] :uint16)
133-(ffi/defcfn checksum-method-moqannouncedbroadcast-cancel "uniffi_moq_ffi_checksum_method_moqannouncedbroadcast_cancel" [] :uint16)
134-(ffi/defcfn checksum-method-moqannouncement-broadcast "uniffi_moq_ffi_checksum_method_moqannouncement_broadcast" [] :uint16)
135-(ffi/defcfn checksum-method-moqannouncement-path "uniffi_moq_ffi_checksum_method_moqannouncement_path" [] :uint16)
136-(ffi/defcfn checksum-method-moqbroadcastconsumer-fetch-group "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_fetch_group" [] :uint16)
137-(ffi/defcfn checksum-method-moqbroadcastconsumer-fetch-media-group "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_fetch_media_group" [] :uint16)
138-(ffi/defcfn checksum-method-moqbroadcastconsumer-route "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_route" [] :uint16)
139-(ffi/defcfn checksum-method-moqbroadcastconsumer-route-updates "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_route_updates" [] :uint16)
140-(ffi/defcfn checksum-method-moqbroadcastconsumer-subscribe-catalog "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_catalog" [] :uint16)
141-(ffi/defcfn checksum-method-moqbroadcastconsumer-subscribe-json-snapshot "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_json_snapshot" [] :uint16)
142-(ffi/defcfn checksum-method-moqbroadcastconsumer-subscribe-json-stream "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_json_stream" [] :uint16)
143-(ffi/defcfn checksum-method-moqbroadcastconsumer-subscribe-media "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_media" [] :uint16)
144-(ffi/defcfn checksum-method-moqbroadcastconsumer-subscribe-track "uniffi_moq_ffi_checksum_method_moqbroadcastconsumer_subscribe_track" [] :uint16)
145-(ffi/defcfn checksum-method-moqbroadcastdynamic-cancel "uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_cancel" [] :uint16)
146-(ffi/defcfn checksum-method-moqbroadcastdynamic-requested-track "uniffi_moq_ffi_checksum_method_moqbroadcastdynamic_requested_track" [] :uint16)
147-(ffi/defcfn checksum-method-moqbroadcastproducer-consume "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_consume" [] :uint16)
148-(ffi/defcfn checksum-method-moqbroadcastproducer-dynamic "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_dynamic" [] :uint16)
149-(ffi/defcfn checksum-method-moqbroadcastproducer-finish "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_finish" [] :uint16)
150-(ffi/defcfn checksum-method-moqbroadcastproducer-publish-json-snapshot "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_snapshot" [] :uint16)
151-(ffi/defcfn checksum-method-moqbroadcastproducer-publish-json-stream "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_json_stream" [] :uint16)
152-(ffi/defcfn checksum-method-moqbroadcastproducer-publish-media "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_media" [] :uint16)
153-(ffi/defcfn checksum-method-moqbroadcastproducer-publish-media-on-track "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_media_on_track" [] :uint16)
154-(ffi/defcfn checksum-method-moqbroadcastproducer-publish-media-stream "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_media_stream" [] :uint16)
155-(ffi/defcfn checksum-method-moqbroadcastproducer-publish-track "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_publish_track" [] :uint16)
156-(ffi/defcfn checksum-method-moqbroadcastproducer-remove-catalog-section "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_remove_catalog_section" [] :uint16)
157-(ffi/defcfn checksum-method-moqbroadcastproducer-set-announce "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_set_announce" [] :uint16)
158-(ffi/defcfn checksum-method-moqbroadcastproducer-set-catalog-section "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_set_catalog_section" [] :uint16)
159-(ffi/defcfn checksum-method-moqbroadcastproducer-set-route "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_set_route" [] :uint16)
160-(ffi/defcfn checksum-method-moqbroadcastproducer-set-video-properties "uniffi_moq_ffi_checksum_method_moqbroadcastproducer_set_video_properties" [] :uint16)
161-(ffi/defcfn checksum-method-moqbroadcastrequest-abort "uniffi_moq_ffi_checksum_method_moqbroadcastrequest_abort" [] :uint16)
162-(ffi/defcfn checksum-method-moqbroadcastrequest-accept "uniffi_moq_ffi_checksum_method_moqbroadcastrequest_accept" [] :uint16)
163-(ffi/defcfn checksum-method-moqbroadcastrequest-path "uniffi_moq_ffi_checksum_method_moqbroadcastrequest_path" [] :uint16)
164-(ffi/defcfn checksum-method-moqcatalogconsumer-cancel "uniffi_moq_ffi_checksum_method_moqcatalogconsumer_cancel" [] :uint16)
165-(ffi/defcfn checksum-method-moqcatalogconsumer-next "uniffi_moq_ffi_checksum_method_moqcatalogconsumer_next" [] :uint16)
166-(ffi/defcfn checksum-method-moqclient-cancel "uniffi_moq_ffi_checksum_method_moqclient_cancel" [] :uint16)
167-(ffi/defcfn checksum-method-moqclient-connect "uniffi_moq_ffi_checksum_method_moqclient_connect" [] :uint16)
168-(ffi/defcfn checksum-method-moqclient-set-bind "uniffi_moq_ffi_checksum_method_moqclient_set_bind" [] :uint16)
169-(ffi/defcfn checksum-method-moqclient-set-consume "uniffi_moq_ffi_checksum_method_moqclient_set_consume" [] :uint16)
170-(ffi/defcfn checksum-method-moqclient-set-publish "uniffi_moq_ffi_checksum_method_moqclient_set_publish" [] :uint16)
171-(ffi/defcfn checksum-method-moqclient-set-tls-cert "uniffi_moq_ffi_checksum_method_moqclient_set_tls_cert" [] :uint16)
172-(ffi/defcfn checksum-method-moqclient-set-tls-disable-verify "uniffi_moq_ffi_checksum_method_moqclient_set_tls_disable_verify" [] :uint16)
173-(ffi/defcfn checksum-method-moqclient-set-tls-fingerprints "uniffi_moq_ffi_checksum_method_moqclient_set_tls_fingerprints" [] :uint16)
174-(ffi/defcfn checksum-method-moqclient-set-tls-key "uniffi_moq_ffi_checksum_method_moqclient_set_tls_key" [] :uint16)
175-(ffi/defcfn checksum-method-moqclient-set-tls-roots "uniffi_moq_ffi_checksum_method_moqclient_set_tls_roots" [] :uint16)
176-(ffi/defcfn checksum-method-moqclient-set-tls-system-roots "uniffi_moq_ffi_checksum_method_moqclient_set_tls_system_roots" [] :uint16)
177-(ffi/defcfn checksum-method-moqgroupconsumer-cancel "uniffi_moq_ffi_checksum_method_moqgroupconsumer_cancel" [] :uint16)
178-(ffi/defcfn checksum-method-moqgroupconsumer-read-frame "uniffi_moq_ffi_checksum_method_moqgroupconsumer_read_frame" [] :uint16)
179-(ffi/defcfn checksum-method-moqgroupconsumer-sequence "uniffi_moq_ffi_checksum_method_moqgroupconsumer_sequence" [] :uint16)
180-(ffi/defcfn checksum-method-moqgroupproducer-abort "uniffi_moq_ffi_checksum_method_moqgroupproducer_abort" [] :uint16)
181-(ffi/defcfn checksum-method-moqgroupproducer-consume "uniffi_moq_ffi_checksum_method_moqgroupproducer_consume" [] :uint16)
182-(ffi/defcfn checksum-method-moqgroupproducer-finish "uniffi_moq_ffi_checksum_method_moqgroupproducer_finish" [] :uint16)
183-(ffi/defcfn checksum-method-moqgroupproducer-sequence "uniffi_moq_ffi_checksum_method_moqgroupproducer_sequence" [] :uint16)
184-(ffi/defcfn checksum-method-moqgroupproducer-write-frame "uniffi_moq_ffi_checksum_method_moqgroupproducer_write_frame" [] :uint16)
185-(ffi/defcfn checksum-method-moqgrouprequest-abort "uniffi_moq_ffi_checksum_method_moqgrouprequest_abort" [] :uint16)
186-(ffi/defcfn checksum-method-moqgrouprequest-accept "uniffi_moq_ffi_checksum_method_moqgrouprequest_accept" [] :uint16)
187-(ffi/defcfn checksum-method-moqgrouprequest-priority "uniffi_moq_ffi_checksum_method_moqgrouprequest_priority" [] :uint16)
188-(ffi/defcfn checksum-method-moqgrouprequest-sequence "uniffi_moq_ffi_checksum_method_moqgrouprequest_sequence" [] :uint16)
189-(ffi/defcfn checksum-method-moqjsonsnapshotconsumer-cancel "uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_cancel" [] :uint16)
190-(ffi/defcfn checksum-method-moqjsonsnapshotconsumer-next "uniffi_moq_ffi_checksum_method_moqjsonsnapshotconsumer_next" [] :uint16)
191-(ffi/defcfn checksum-method-moqjsonsnapshotproducer-finish "uniffi_moq_ffi_checksum_method_moqjsonsnapshotproducer_finish" [] :uint16)
192-(ffi/defcfn checksum-method-moqjsonsnapshotproducer-update "uniffi_moq_ffi_checksum_method_moqjsonsnapshotproducer_update" [] :uint16)
193-(ffi/defcfn checksum-method-moqjsonstreamconsumer-cancel "uniffi_moq_ffi_checksum_method_moqjsonstreamconsumer_cancel" [] :uint16)
194-(ffi/defcfn checksum-method-moqjsonstreamconsumer-next "uniffi_moq_ffi_checksum_method_moqjsonstreamconsumer_next" [] :uint16)
195-(ffi/defcfn checksum-method-moqjsonstreamproducer-append "uniffi_moq_ffi_checksum_method_moqjsonstreamproducer_append" [] :uint16)
196-(ffi/defcfn checksum-method-moqjsonstreamproducer-finish "uniffi_moq_ffi_checksum_method_moqjsonstreamproducer_finish" [] :uint16)
197-(ffi/defcfn checksum-method-moqmediaconsumer-cancel "uniffi_moq_ffi_checksum_method_moqmediaconsumer_cancel" [] :uint16)
198-(ffi/defcfn checksum-method-moqmediaconsumer-next "uniffi_moq_ffi_checksum_method_moqmediaconsumer_next" [] :uint16)
199-(ffi/defcfn checksum-method-moqmediagroupconsumer-cancel "uniffi_moq_ffi_checksum_method_moqmediagroupconsumer_cancel" [] :uint16)
200-(ffi/defcfn checksum-method-moqmediagroupconsumer-next "uniffi_moq_ffi_checksum_method_moqmediagroupconsumer_next" [] :uint16)
201-(ffi/defcfn checksum-method-moqmediagroupconsumer-sequence "uniffi_moq_ffi_checksum_method_moqmediagroupconsumer_sequence" [] :uint16)
202-(ffi/defcfn checksum-method-moqmediaproducer-finish "uniffi_moq_ffi_checksum_method_moqmediaproducer_finish" [] :uint16)
203-(ffi/defcfn checksum-method-moqmediaproducer-name "uniffi_moq_ffi_checksum_method_moqmediaproducer_name" [] :uint16)
204-(ffi/defcfn checksum-method-moqmediaproducer-unused "uniffi_moq_ffi_checksum_method_moqmediaproducer_unused" [] :uint16)
205-(ffi/defcfn checksum-method-moqmediaproducer-used "uniffi_moq_ffi_checksum_method_moqmediaproducer_used" [] :uint16)
206-(ffi/defcfn checksum-method-moqmediaproducer-write-frame "uniffi_moq_ffi_checksum_method_moqmediaproducer_write_frame" [] :uint16)
207-(ffi/defcfn checksum-method-moqmediastreamproducer-finish "uniffi_moq_ffi_checksum_method_moqmediastreamproducer_finish" [] :uint16)
208-(ffi/defcfn checksum-method-moqmediastreamproducer-write "uniffi_moq_ffi_checksum_method_moqmediastreamproducer_write" [] :uint16)
209-(ffi/defcfn checksum-method-moqoriginconsumer-announced "uniffi_moq_ffi_checksum_method_moqoriginconsumer_announced" [] :uint16)
210-(ffi/defcfn checksum-method-moqoriginconsumer-announced-broadcast "uniffi_moq_ffi_checksum_method_moqoriginconsumer_announced_broadcast" [] :uint16)
211-(ffi/defcfn checksum-method-moqoriginconsumer-request-broadcast "uniffi_moq_ffi_checksum_method_moqoriginconsumer_request_broadcast" [] :uint16)
212-(ffi/defcfn checksum-method-moqorigindynamic-cancel "uniffi_moq_ffi_checksum_method_moqorigindynamic_cancel" [] :uint16)
213-(ffi/defcfn checksum-method-moqorigindynamic-requested-broadcast "uniffi_moq_ffi_checksum_method_moqorigindynamic_requested_broadcast" [] :uint16)
214-(ffi/defcfn checksum-method-moqoriginproducer-consume "uniffi_moq_ffi_checksum_method_moqoriginproducer_consume" [] :uint16)
215-(ffi/defcfn checksum-method-moqoriginproducer-create-broadcast "uniffi_moq_ffi_checksum_method_moqoriginproducer_create_broadcast" [] :uint16)
216-(ffi/defcfn checksum-method-moqoriginproducer-dynamic "uniffi_moq_ffi_checksum_method_moqoriginproducer_dynamic" [] :uint16)
217-(ffi/defcfn checksum-method-moqrequest-accept "uniffi_moq_ffi_checksum_method_moqrequest_accept" [] :uint16)
218-(ffi/defcfn checksum-method-moqrequest-cancel "uniffi_moq_ffi_checksum_method_moqrequest_cancel" [] :uint16)
219-(ffi/defcfn checksum-method-moqrequest-path "uniffi_moq_ffi_checksum_method_moqrequest_path" [] :uint16)
220-(ffi/defcfn checksum-method-moqrequest-query "uniffi_moq_ffi_checksum_method_moqrequest_query" [] :uint16)
221-(ffi/defcfn checksum-method-moqrequest-reject "uniffi_moq_ffi_checksum_method_moqrequest_reject" [] :uint16)
222-(ffi/defcfn checksum-method-moqrequest-set-consume "uniffi_moq_ffi_checksum_method_moqrequest_set_consume" [] :uint16)
223-(ffi/defcfn checksum-method-moqrequest-set-publish "uniffi_moq_ffi_checksum_method_moqrequest_set_publish" [] :uint16)
224-(ffi/defcfn checksum-method-moqrequest-transport "uniffi_moq_ffi_checksum_method_moqrequest_transport" [] :uint16)
225-(ffi/defcfn checksum-method-moqrequest-url "uniffi_moq_ffi_checksum_method_moqrequest_url" [] :uint16)
226-(ffi/defcfn checksum-method-moqroutewatch-cancel "uniffi_moq_ffi_checksum_method_moqroutewatch_cancel" [] :uint16)
227-(ffi/defcfn checksum-method-moqroutewatch-next "uniffi_moq_ffi_checksum_method_moqroutewatch_next" [] :uint16)
228-(ffi/defcfn checksum-method-moqserver-accept "uniffi_moq_ffi_checksum_method_moqserver_accept" [] :uint16)
229-(ffi/defcfn checksum-method-moqserver-cancel "uniffi_moq_ffi_checksum_method_moqserver_cancel" [] :uint16)
230-(ffi/defcfn checksum-method-moqserver-cert-fingerprints "uniffi_moq_ffi_checksum_method_moqserver_cert_fingerprints" [] :uint16)
231-(ffi/defcfn checksum-method-moqserver-listen "uniffi_moq_ffi_checksum_method_moqserver_listen" [] :uint16)
232-(ffi/defcfn checksum-method-moqserver-set-bind "uniffi_moq_ffi_checksum_method_moqserver_set_bind" [] :uint16)
233-(ffi/defcfn checksum-method-moqserver-set-consume "uniffi_moq_ffi_checksum_method_moqserver_set_consume" [] :uint16)
234-(ffi/defcfn checksum-method-moqserver-set-publish "uniffi_moq_ffi_checksum_method_moqserver_set_publish" [] :uint16)
235-(ffi/defcfn checksum-method-moqserver-set-tls-cert "uniffi_moq_ffi_checksum_method_moqserver_set_tls_cert" [] :uint16)
236-(ffi/defcfn checksum-method-moqserver-set-tls-generate "uniffi_moq_ffi_checksum_method_moqserver_set_tls_generate" [] :uint16)
237-(ffi/defcfn checksum-method-moqserver-set-tls-key "uniffi_moq_ffi_checksum_method_moqserver_set_tls_key" [] :uint16)
238-(ffi/defcfn checksum-method-moqsession-cancel "uniffi_moq_ffi_checksum_method_moqsession_cancel" [] :uint16)
239-(ffi/defcfn checksum-method-moqsession-closed "uniffi_moq_ffi_checksum_method_moqsession_closed" [] :uint16)
240-(ffi/defcfn checksum-method-moqsession-consumer "uniffi_moq_ffi_checksum_method_moqsession_consumer" [] :uint16)
241-(ffi/defcfn checksum-method-moqsession-publisher "uniffi_moq_ffi_checksum_method_moqsession_publisher" [] :uint16)
242-(ffi/defcfn checksum-method-moqsession-shutdown "uniffi_moq_ffi_checksum_method_moqsession_shutdown" [] :uint16)
243-(ffi/defcfn checksum-method-moqsession-stats "uniffi_moq_ffi_checksum_method_moqsession_stats" [] :uint16)
244-(ffi/defcfn checksum-method-moqtrackconsumer-cancel "uniffi_moq_ffi_checksum_method_moqtrackconsumer_cancel" [] :uint16)
245-(ffi/defcfn checksum-method-moqtrackconsumer-info "uniffi_moq_ffi_checksum_method_moqtrackconsumer_info" [] :uint16)
246-(ffi/defcfn checksum-method-moqtrackconsumer-next-group "uniffi_moq_ffi_checksum_method_moqtrackconsumer_next_group" [] :uint16)
247-(ffi/defcfn checksum-method-moqtrackconsumer-read-frame "uniffi_moq_ffi_checksum_method_moqtrackconsumer_read_frame" [] :uint16)
248-(ffi/defcfn checksum-method-moqtrackconsumer-recv-datagram "uniffi_moq_ffi_checksum_method_moqtrackconsumer_recv_datagram" [] :uint16)
249-(ffi/defcfn checksum-method-moqtrackconsumer-recv-group "uniffi_moq_ffi_checksum_method_moqtrackconsumer_recv_group" [] :uint16)
250-(ffi/defcfn checksum-method-moqtrackconsumer-update "uniffi_moq_ffi_checksum_method_moqtrackconsumer_update" [] :uint16)
251-(ffi/defcfn checksum-method-moqtrackdynamic-cancel "uniffi_moq_ffi_checksum_method_moqtrackdynamic_cancel" [] :uint16)
252-(ffi/defcfn checksum-method-moqtrackdynamic-requested-group "uniffi_moq_ffi_checksum_method_moqtrackdynamic_requested_group" [] :uint16)
253-(ffi/defcfn checksum-method-moqtrackproducer-abort "uniffi_moq_ffi_checksum_method_moqtrackproducer_abort" [] :uint16)
254-(ffi/defcfn checksum-method-moqtrackproducer-append-datagram "uniffi_moq_ffi_checksum_method_moqtrackproducer_append_datagram" [] :uint16)
255-(ffi/defcfn checksum-method-moqtrackproducer-append-group "uniffi_moq_ffi_checksum_method_moqtrackproducer_append_group" [] :uint16)
256-(ffi/defcfn checksum-method-moqtrackproducer-consume "uniffi_moq_ffi_checksum_method_moqtrackproducer_consume" [] :uint16)
257-(ffi/defcfn checksum-method-moqtrackproducer-create-group "uniffi_moq_ffi_checksum_method_moqtrackproducer_create_group" [] :uint16)
258-(ffi/defcfn checksum-method-moqtrackproducer-dynamic "uniffi_moq_ffi_checksum_method_moqtrackproducer_dynamic" [] :uint16)
259-(ffi/defcfn checksum-method-moqtrackproducer-finish "uniffi_moq_ffi_checksum_method_moqtrackproducer_finish" [] :uint16)
260-(ffi/defcfn checksum-method-moqtrackproducer-finish-at "uniffi_moq_ffi_checksum_method_moqtrackproducer_finish_at" [] :uint16)
261-(ffi/defcfn checksum-method-moqtrackproducer-name "uniffi_moq_ffi_checksum_method_moqtrackproducer_name" [] :uint16)
262-(ffi/defcfn checksum-method-moqtrackproducer-unused "uniffi_moq_ffi_checksum_method_moqtrackproducer_unused" [] :uint16)
263-(ffi/defcfn checksum-method-moqtrackproducer-used "uniffi_moq_ffi_checksum_method_moqtrackproducer_used" [] :uint16)
264-(ffi/defcfn checksum-method-moqtrackproducer-write-frame "uniffi_moq_ffi_checksum_method_moqtrackproducer_write_frame" [] :uint16)
265-(ffi/defcfn checksum-method-moqtrackrequest-abort "uniffi_moq_ffi_checksum_method_moqtrackrequest_abort" [] :uint16)
266-(ffi/defcfn checksum-method-moqtrackrequest-accept "uniffi_moq_ffi_checksum_method_moqtrackrequest_accept" [] :uint16)
267-(ffi/defcfn checksum-method-moqtrackrequest-dynamic "uniffi_moq_ffi_checksum_method_moqtrackrequest_dynamic" [] :uint16)
268-(ffi/defcfn checksum-method-moqtrackrequest-name "uniffi_moq_ffi_checksum_method_moqtrackrequest_name" [] :uint16)
269-(ffi/defcfn clone-moqannounced "uniffi_moq_ffi_fn_clone_moqannounced" [:uint64 :pointer] :uint64)
270-(ffi/defcfn clone-moqannouncedbroadcast "uniffi_moq_ffi_fn_clone_moqannouncedbroadcast" [:uint64 :pointer] :uint64)
271-(ffi/defcfn clone-moqannouncement "uniffi_moq_ffi_fn_clone_moqannouncement" [:uint64 :pointer] :uint64)
272-(ffi/defcfn clone-moqbroadcastconsumer "uniffi_moq_ffi_fn_clone_moqbroadcastconsumer" [:uint64 :pointer] :uint64)
273-(ffi/defcfn clone-moqbroadcastdynamic "uniffi_moq_ffi_fn_clone_moqbroadcastdynamic" [:uint64 :pointer] :uint64)
274-(ffi/defcfn clone-moqbroadcastproducer "uniffi_moq_ffi_fn_clone_moqbroadcastproducer" [:uint64 :pointer] :uint64)
275-(ffi/defcfn clone-moqbroadcastrequest "uniffi_moq_ffi_fn_clone_moqbroadcastrequest" [:uint64 :pointer] :uint64)
276-(ffi/defcfn clone-moqcatalogconsumer "uniffi_moq_ffi_fn_clone_moqcatalogconsumer" [:uint64 :pointer] :uint64)
277-(ffi/defcfn clone-moqclient "uniffi_moq_ffi_fn_clone_moqclient" [:uint64 :pointer] :uint64)
278-(ffi/defcfn clone-moqgroupconsumer "uniffi_moq_ffi_fn_clone_moqgroupconsumer" [:uint64 :pointer] :uint64)
279-(ffi/defcfn clone-moqgroupproducer "uniffi_moq_ffi_fn_clone_moqgroupproducer" [:uint64 :pointer] :uint64)
280-(ffi/defcfn clone-moqgrouprequest "uniffi_moq_ffi_fn_clone_moqgrouprequest" [:uint64 :pointer] :uint64)
281-(ffi/defcfn clone-moqjsonsnapshotconsumer "uniffi_moq_ffi_fn_clone_moqjsonsnapshotconsumer" [:uint64 :pointer] :uint64)
282-(ffi/defcfn clone-moqjsonsnapshotproducer "uniffi_moq_ffi_fn_clone_moqjsonsnapshotproducer" [:uint64 :pointer] :uint64)
283-(ffi/defcfn clone-moqjsonstreamconsumer "uniffi_moq_ffi_fn_clone_moqjsonstreamconsumer" [:uint64 :pointer] :uint64)
284-(ffi/defcfn clone-moqjsonstreamproducer "uniffi_moq_ffi_fn_clone_moqjsonstreamproducer" [:uint64 :pointer] :uint64)
285-(ffi/defcfn clone-moqmediaconsumer "uniffi_moq_ffi_fn_clone_moqmediaconsumer" [:uint64 :pointer] :uint64)
286-(ffi/defcfn clone-moqmediagroupconsumer "uniffi_moq_ffi_fn_clone_moqmediagroupconsumer" [:uint64 :pointer] :uint64)
287-(ffi/defcfn clone-moqmediaproducer "uniffi_moq_ffi_fn_clone_moqmediaproducer" [:uint64 :pointer] :uint64)
288-(ffi/defcfn clone-moqmediastreamproducer "uniffi_moq_ffi_fn_clone_moqmediastreamproducer" [:uint64 :pointer] :uint64)
289-(ffi/defcfn clone-moqoriginconsumer "uniffi_moq_ffi_fn_clone_moqoriginconsumer" [:uint64 :pointer] :uint64)
290-(ffi/defcfn clone-moqorigindynamic "uniffi_moq_ffi_fn_clone_moqorigindynamic" [:uint64 :pointer] :uint64)
291-(ffi/defcfn clone-moqoriginproducer "uniffi_moq_ffi_fn_clone_moqoriginproducer" [:uint64 :pointer] :uint64)
292-(ffi/defcfn clone-moqrequest "uniffi_moq_ffi_fn_clone_moqrequest" [:uint64 :pointer] :uint64)
293-(ffi/defcfn clone-moqroutewatch "uniffi_moq_ffi_fn_clone_moqroutewatch" [:uint64 :pointer] :uint64)
294-(ffi/defcfn clone-moqserver "uniffi_moq_ffi_fn_clone_moqserver" [:uint64 :pointer] :uint64)
295-(ffi/defcfn clone-moqsession "uniffi_moq_ffi_fn_clone_moqsession" [:uint64 :pointer] :uint64)
296-(ffi/defcfn clone-moqtrackconsumer "uniffi_moq_ffi_fn_clone_moqtrackconsumer" [:uint64 :pointer] :uint64)
297-(ffi/defcfn clone-moqtrackdynamic "uniffi_moq_ffi_fn_clone_moqtrackdynamic" [:uint64 :pointer] :uint64)
298-(ffi/defcfn clone-moqtrackproducer "uniffi_moq_ffi_fn_clone_moqtrackproducer" [:uint64 :pointer] :uint64)
299-(ffi/defcfn clone-moqtrackrequest "uniffi_moq_ffi_fn_clone_moqtrackrequest" [:uint64 :pointer] :uint64)
300-(ffi/defcfn constructor-moqbroadcastproducer-new "uniffi_moq_ffi_fn_constructor_moqbroadcastproducer_new" [:pointer] :uint64)
301-(ffi/defcfn constructor-moqclient-new "uniffi_moq_ffi_fn_constructor_moqclient_new" [:pointer] :uint64)
302-(ffi/defcfn constructor-moqoriginproducer-new "uniffi_moq_ffi_fn_constructor_moqoriginproducer_new" [[:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
303-(ffi/defcfn constructor-moqserver-new "uniffi_moq_ffi_fn_constructor_moqserver_new" [:pointer] :uint64)
304-(ffi/defcfn free-moqannounced "uniffi_moq_ffi_fn_free_moqannounced" [:uint64 :pointer] :void)
305-(ffi/defcfn free-moqannouncedbroadcast "uniffi_moq_ffi_fn_free_moqannouncedbroadcast" [:uint64 :pointer] :void)
306-(ffi/defcfn free-moqannouncement "uniffi_moq_ffi_fn_free_moqannouncement" [:uint64 :pointer] :void)
307-(ffi/defcfn free-moqbroadcastconsumer "uniffi_moq_ffi_fn_free_moqbroadcastconsumer" [:uint64 :pointer] :void)
308-(ffi/defcfn free-moqbroadcastdynamic "uniffi_moq_ffi_fn_free_moqbroadcastdynamic" [:uint64 :pointer] :void)
309-(ffi/defcfn free-moqbroadcastproducer "uniffi_moq_ffi_fn_free_moqbroadcastproducer" [:uint64 :pointer] :void)
310-(ffi/defcfn free-moqbroadcastrequest "uniffi_moq_ffi_fn_free_moqbroadcastrequest" [:uint64 :pointer] :void)
311-(ffi/defcfn free-moqcatalogconsumer "uniffi_moq_ffi_fn_free_moqcatalogconsumer" [:uint64 :pointer] :void)
312-(ffi/defcfn free-moqclient "uniffi_moq_ffi_fn_free_moqclient" [:uint64 :pointer] :void)
313-(ffi/defcfn free-moqgroupconsumer "uniffi_moq_ffi_fn_free_moqgroupconsumer" [:uint64 :pointer] :void)
314-(ffi/defcfn free-moqgroupproducer "uniffi_moq_ffi_fn_free_moqgroupproducer" [:uint64 :pointer] :void)
315-(ffi/defcfn free-moqgrouprequest "uniffi_moq_ffi_fn_free_moqgrouprequest" [:uint64 :pointer] :void)
316-(ffi/defcfn free-moqjsonsnapshotconsumer "uniffi_moq_ffi_fn_free_moqjsonsnapshotconsumer" [:uint64 :pointer] :void)
317-(ffi/defcfn free-moqjsonsnapshotproducer "uniffi_moq_ffi_fn_free_moqjsonsnapshotproducer" [:uint64 :pointer] :void)
318-(ffi/defcfn free-moqjsonstreamconsumer "uniffi_moq_ffi_fn_free_moqjsonstreamconsumer" [:uint64 :pointer] :void)
319-(ffi/defcfn free-moqjsonstreamproducer "uniffi_moq_ffi_fn_free_moqjsonstreamproducer" [:uint64 :pointer] :void)
320-(ffi/defcfn free-moqmediaconsumer "uniffi_moq_ffi_fn_free_moqmediaconsumer" [:uint64 :pointer] :void)
321-(ffi/defcfn free-moqmediagroupconsumer "uniffi_moq_ffi_fn_free_moqmediagroupconsumer" [:uint64 :pointer] :void)
322-(ffi/defcfn free-moqmediaproducer "uniffi_moq_ffi_fn_free_moqmediaproducer" [:uint64 :pointer] :void)
323-(ffi/defcfn free-moqmediastreamproducer "uniffi_moq_ffi_fn_free_moqmediastreamproducer" [:uint64 :pointer] :void)
324-(ffi/defcfn free-moqoriginconsumer "uniffi_moq_ffi_fn_free_moqoriginconsumer" [:uint64 :pointer] :void)
325-(ffi/defcfn free-moqorigindynamic "uniffi_moq_ffi_fn_free_moqorigindynamic" [:uint64 :pointer] :void)
326-(ffi/defcfn free-moqoriginproducer "uniffi_moq_ffi_fn_free_moqoriginproducer" [:uint64 :pointer] :void)
327-(ffi/defcfn free-moqrequest "uniffi_moq_ffi_fn_free_moqrequest" [:uint64 :pointer] :void)
328-(ffi/defcfn free-moqroutewatch "uniffi_moq_ffi_fn_free_moqroutewatch" [:uint64 :pointer] :void)
329-(ffi/defcfn free-moqserver "uniffi_moq_ffi_fn_free_moqserver" [:uint64 :pointer] :void)
330-(ffi/defcfn free-moqsession "uniffi_moq_ffi_fn_free_moqsession" [:uint64 :pointer] :void)
331-(ffi/defcfn free-moqtrackconsumer "uniffi_moq_ffi_fn_free_moqtrackconsumer" [:uint64 :pointer] :void)
332-(ffi/defcfn free-moqtrackdynamic "uniffi_moq_ffi_fn_free_moqtrackdynamic" [:uint64 :pointer] :void)
333-(ffi/defcfn free-moqtrackproducer "uniffi_moq_ffi_fn_free_moqtrackproducer" [:uint64 :pointer] :void)
334-(ffi/defcfn free-moqtrackrequest "uniffi_moq_ffi_fn_free_moqtrackrequest" [:uint64 :pointer] :void)
335-(ffi/defcfn func-moq-log-level "uniffi_moq_ffi_fn_func_moq_log_level" [[:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
336-(ffi/defcfn method-moqannounced-cancel "uniffi_moq_ffi_fn_method_moqannounced_cancel" [:uint64 :pointer] :void)
337-(ffi/defcfn method-moqannounced-next "uniffi_moq_ffi_fn_method_moqannounced_next" [:uint64] :uint64)
338-(ffi/defcfn method-moqannouncedbroadcast-available "uniffi_moq_ffi_fn_method_moqannouncedbroadcast_available" [:uint64] :uint64)
339-(ffi/defcfn method-moqannouncedbroadcast-cancel "uniffi_moq_ffi_fn_method_moqannouncedbroadcast_cancel" [:uint64 :pointer] :void)
340-(ffi/defcfn method-moqannouncement-broadcast "uniffi_moq_ffi_fn_method_moqannouncement_broadcast" [:uint64 :pointer] :uint64)
341-(ffi/defcfn method-moqannouncement-path "uniffi_moq_ffi_fn_method_moqannouncement_path" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
342-(ffi/defcfn method-moqbroadcastconsumer-fetch-group "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_fetch_group" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
343-(ffi/defcfn method-moqbroadcastconsumer-fetch-media-group "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_fetch_media_group" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
344-(ffi/defcfn method-moqbroadcastconsumer-route "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_route" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
345-(ffi/defcfn method-moqbroadcastconsumer-route-updates "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_route_updates" [:uint64 :pointer] :uint64)
346-(ffi/defcfn method-moqbroadcastconsumer-subscribe-catalog "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_catalog" [:uint64] :uint64)
347-(ffi/defcfn method-moqbroadcastconsumer-subscribe-json-snapshot "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_json_snapshot" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
348-(ffi/defcfn method-moqbroadcastconsumer-subscribe-json-stream "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_json_stream" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
349-(ffi/defcfn method-moqbroadcastconsumer-subscribe-media "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_media" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
350-(ffi/defcfn method-moqbroadcastconsumer-subscribe-track "uniffi_moq_ffi_fn_method_moqbroadcastconsumer_subscribe_track" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
351-(ffi/defcfn method-moqbroadcastdynamic-cancel "uniffi_moq_ffi_fn_method_moqbroadcastdynamic_cancel" [:uint64 :pointer] :void)
352-(ffi/defcfn method-moqbroadcastdynamic-requested-track "uniffi_moq_ffi_fn_method_moqbroadcastdynamic_requested_track" [:uint64] :uint64)
353-(ffi/defcfn method-moqbroadcastproducer-consume "uniffi_moq_ffi_fn_method_moqbroadcastproducer_consume" [:uint64 :pointer] :uint64)
354-(ffi/defcfn method-moqbroadcastproducer-dynamic "uniffi_moq_ffi_fn_method_moqbroadcastproducer_dynamic" [:uint64 :pointer] :uint64)
355-(ffi/defcfn method-moqbroadcastproducer-finish "uniffi_moq_ffi_fn_method_moqbroadcastproducer_finish" [:uint64 :pointer] :void)
356-(ffi/defcfn method-moqbroadcastproducer-publish-json-snapshot "uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_json_snapshot" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
357-(ffi/defcfn method-moqbroadcastproducer-publish-json-stream "uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_json_stream" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
358-(ffi/defcfn method-moqbroadcastproducer-publish-media "uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_media" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
359-(ffi/defcfn method-moqbroadcastproducer-publish-media-on-track "uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_media_on_track" [:uint64 :uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
360-(ffi/defcfn method-moqbroadcastproducer-publish-media-stream "uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_media_stream" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
361-(ffi/defcfn method-moqbroadcastproducer-publish-track "uniffi_moq_ffi_fn_method_moqbroadcastproducer_publish_track" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
362-(ffi/defcfn method-moqbroadcastproducer-remove-catalog-section "uniffi_moq_ffi_fn_method_moqbroadcastproducer_remove_catalog_section" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
363-(ffi/defcfn method-moqbroadcastproducer-set-announce "uniffi_moq_ffi_fn_method_moqbroadcastproducer_set_announce" [:uint64 :int8 :pointer] :void)
364-(ffi/defcfn method-moqbroadcastproducer-set-catalog-section "uniffi_moq_ffi_fn_method_moqbroadcastproducer_set_catalog_section" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
365-(ffi/defcfn method-moqbroadcastproducer-set-route "uniffi_moq_ffi_fn_method_moqbroadcastproducer_set_route" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
366-(ffi/defcfn method-moqbroadcastproducer-set-video-properties "uniffi_moq_ffi_fn_method_moqbroadcastproducer_set_video_properties" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
367-(ffi/defcfn method-moqbroadcastrequest-abort "uniffi_moq_ffi_fn_method_moqbroadcastrequest_abort" [:uint64 :uint16 :pointer] :void)
368-(ffi/defcfn method-moqbroadcastrequest-accept "uniffi_moq_ffi_fn_method_moqbroadcastrequest_accept" [:uint64 :uint64 :pointer] :void)
369-(ffi/defcfn method-moqbroadcastrequest-path "uniffi_moq_ffi_fn_method_moqbroadcastrequest_path" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
370-(ffi/defcfn method-moqcatalogconsumer-cancel "uniffi_moq_ffi_fn_method_moqcatalogconsumer_cancel" [:uint64 :pointer] :void)
371-(ffi/defcfn method-moqcatalogconsumer-next "uniffi_moq_ffi_fn_method_moqcatalogconsumer_next" [:uint64] :uint64)
372-(ffi/defcfn method-moqclient-cancel "uniffi_moq_ffi_fn_method_moqclient_cancel" [:uint64 :pointer] :void)
373-(ffi/defcfn method-moqclient-connect "uniffi_moq_ffi_fn_method_moqclient_connect" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
374-(ffi/defcfn method-moqclient-set-bind "uniffi_moq_ffi_fn_method_moqclient_set_bind" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
375-(ffi/defcfn method-moqclient-set-consume "uniffi_moq_ffi_fn_method_moqclient_set_consume" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
376-(ffi/defcfn method-moqclient-set-publish "uniffi_moq_ffi_fn_method_moqclient_set_publish" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
377-(ffi/defcfn method-moqclient-set-tls-cert "uniffi_moq_ffi_fn_method_moqclient_set_tls_cert" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
378-(ffi/defcfn method-moqclient-set-tls-disable-verify "uniffi_moq_ffi_fn_method_moqclient_set_tls_disable_verify" [:uint64 :int8 :pointer] :void)
379-(ffi/defcfn method-moqclient-set-tls-fingerprints "uniffi_moq_ffi_fn_method_moqclient_set_tls_fingerprints" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
380-(ffi/defcfn method-moqclient-set-tls-key "uniffi_moq_ffi_fn_method_moqclient_set_tls_key" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
381-(ffi/defcfn method-moqclient-set-tls-roots "uniffi_moq_ffi_fn_method_moqclient_set_tls_roots" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
382-(ffi/defcfn method-moqclient-set-tls-system-roots "uniffi_moq_ffi_fn_method_moqclient_set_tls_system_roots" [:uint64 :int8 :pointer] :void)
383-(ffi/defcfn method-moqgroupconsumer-cancel "uniffi_moq_ffi_fn_method_moqgroupconsumer_cancel" [:uint64 :pointer] :void)
384-(ffi/defcfn method-moqgroupconsumer-read-frame "uniffi_moq_ffi_fn_method_moqgroupconsumer_read_frame" [:uint64] :uint64)
385-(ffi/defcfn method-moqgroupconsumer-sequence "uniffi_moq_ffi_fn_method_moqgroupconsumer_sequence" [:uint64 :pointer] :uint64)
386-(ffi/defcfn method-moqgroupproducer-abort "uniffi_moq_ffi_fn_method_moqgroupproducer_abort" [:uint64 :uint16 :pointer] :void)
387-(ffi/defcfn method-moqgroupproducer-consume "uniffi_moq_ffi_fn_method_moqgroupproducer_consume" [:uint64 :pointer] :uint64)
388-(ffi/defcfn method-moqgroupproducer-finish "uniffi_moq_ffi_fn_method_moqgroupproducer_finish" [:uint64 :pointer] :void)
389-(ffi/defcfn method-moqgroupproducer-sequence "uniffi_moq_ffi_fn_method_moqgroupproducer_sequence" [:uint64 :pointer] :uint64)
390-(ffi/defcfn method-moqgroupproducer-write-frame "uniffi_moq_ffi_fn_method_moqgroupproducer_write_frame" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
391-(ffi/defcfn method-moqgrouprequest-abort "uniffi_moq_ffi_fn_method_moqgrouprequest_abort" [:uint64 :uint16 :pointer] :void)
392-(ffi/defcfn method-moqgrouprequest-accept "uniffi_moq_ffi_fn_method_moqgrouprequest_accept" [:uint64 :pointer] :uint64)
393-(ffi/defcfn method-moqgrouprequest-priority "uniffi_moq_ffi_fn_method_moqgrouprequest_priority" [:uint64 :pointer] :uint8)
394-(ffi/defcfn method-moqgrouprequest-sequence "uniffi_moq_ffi_fn_method_moqgrouprequest_sequence" [:uint64 :pointer] :uint64)
395-(ffi/defcfn method-moqjsonsnapshotconsumer-cancel "uniffi_moq_ffi_fn_method_moqjsonsnapshotconsumer_cancel" [:uint64 :pointer] :void)
396-(ffi/defcfn method-moqjsonsnapshotconsumer-next "uniffi_moq_ffi_fn_method_moqjsonsnapshotconsumer_next" [:uint64] :uint64)
397-(ffi/defcfn method-moqjsonsnapshotproducer-finish "uniffi_moq_ffi_fn_method_moqjsonsnapshotproducer_finish" [:uint64 :pointer] :void)
398-(ffi/defcfn method-moqjsonsnapshotproducer-update "uniffi_moq_ffi_fn_method_moqjsonsnapshotproducer_update" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
399-(ffi/defcfn method-moqjsonstreamconsumer-cancel "uniffi_moq_ffi_fn_method_moqjsonstreamconsumer_cancel" [:uint64 :pointer] :void)
400-(ffi/defcfn method-moqjsonstreamconsumer-next "uniffi_moq_ffi_fn_method_moqjsonstreamconsumer_next" [:uint64] :uint64)
401-(ffi/defcfn method-moqjsonstreamproducer-append "uniffi_moq_ffi_fn_method_moqjsonstreamproducer_append" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
402-(ffi/defcfn method-moqjsonstreamproducer-finish "uniffi_moq_ffi_fn_method_moqjsonstreamproducer_finish" [:uint64 :pointer] :void)
403-(ffi/defcfn method-moqmediaconsumer-cancel "uniffi_moq_ffi_fn_method_moqmediaconsumer_cancel" [:uint64 :pointer] :void)
404-(ffi/defcfn method-moqmediaconsumer-next "uniffi_moq_ffi_fn_method_moqmediaconsumer_next" [:uint64] :uint64)
405-(ffi/defcfn method-moqmediagroupconsumer-cancel "uniffi_moq_ffi_fn_method_moqmediagroupconsumer_cancel" [:uint64 :pointer] :void)
406-(ffi/defcfn method-moqmediagroupconsumer-next "uniffi_moq_ffi_fn_method_moqmediagroupconsumer_next" [:uint64] :uint64)
407-(ffi/defcfn method-moqmediagroupconsumer-sequence "uniffi_moq_ffi_fn_method_moqmediagroupconsumer_sequence" [:uint64 :pointer] :uint64)
408-(ffi/defcfn method-moqmediaproducer-finish "uniffi_moq_ffi_fn_method_moqmediaproducer_finish" [:uint64 :pointer] :void)
409-(ffi/defcfn method-moqmediaproducer-name "uniffi_moq_ffi_fn_method_moqmediaproducer_name" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
410-(ffi/defcfn method-moqmediaproducer-unused "uniffi_moq_ffi_fn_method_moqmediaproducer_unused" [:uint64] :uint64)
411-(ffi/defcfn method-moqmediaproducer-used "uniffi_moq_ffi_fn_method_moqmediaproducer_used" [:uint64] :uint64)
412-(ffi/defcfn method-moqmediaproducer-write-frame "uniffi_moq_ffi_fn_method_moqmediaproducer_write_frame" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
413-(ffi/defcfn method-moqmediastreamproducer-finish "uniffi_moq_ffi_fn_method_moqmediastreamproducer_finish" [:uint64 :pointer] :void)
414-(ffi/defcfn method-moqmediastreamproducer-write "uniffi_moq_ffi_fn_method_moqmediastreamproducer_write" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
415-(ffi/defcfn method-moqoriginconsumer-announced "uniffi_moq_ffi_fn_method_moqoriginconsumer_announced" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
416-(ffi/defcfn method-moqoriginconsumer-announced-broadcast "uniffi_moq_ffi_fn_method_moqoriginconsumer_announced_broadcast" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
417-(ffi/defcfn method-moqoriginconsumer-request-broadcast "uniffi_moq_ffi_fn_method_moqoriginconsumer_request_broadcast" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]] :uint64)
418-(ffi/defcfn method-moqorigindynamic-cancel "uniffi_moq_ffi_fn_method_moqorigindynamic_cancel" [:uint64 :pointer] :void)
419-(ffi/defcfn method-moqorigindynamic-requested-broadcast "uniffi_moq_ffi_fn_method_moqorigindynamic_requested_broadcast" [:uint64] :uint64)
420-(ffi/defcfn method-moqoriginproducer-consume "uniffi_moq_ffi_fn_method_moqoriginproducer_consume" [:uint64 :pointer] :uint64)
421-(ffi/defcfn method-moqoriginproducer-create-broadcast "uniffi_moq_ffi_fn_method_moqoriginproducer_create_broadcast" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
422-(ffi/defcfn method-moqoriginproducer-dynamic "uniffi_moq_ffi_fn_method_moqoriginproducer_dynamic" [:uint64 :pointer] :uint64)
423-(ffi/defcfn method-moqrequest-accept "uniffi_moq_ffi_fn_method_moqrequest_accept" [:uint64] :uint64)
424-(ffi/defcfn method-moqrequest-cancel "uniffi_moq_ffi_fn_method_moqrequest_cancel" [:uint64 :pointer] :void)
425-(ffi/defcfn method-moqrequest-path "uniffi_moq_ffi_fn_method_moqrequest_path" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
426-(ffi/defcfn method-moqrequest-query "uniffi_moq_ffi_fn_method_moqrequest_query" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
427-(ffi/defcfn method-moqrequest-reject "uniffi_moq_ffi_fn_method_moqrequest_reject" [:uint64 :uint16] :uint64)
428-(ffi/defcfn method-moqrequest-set-consume "uniffi_moq_ffi_fn_method_moqrequest_set_consume" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
429-(ffi/defcfn method-moqrequest-set-publish "uniffi_moq_ffi_fn_method_moqrequest_set_publish" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
430-(ffi/defcfn method-moqrequest-transport "uniffi_moq_ffi_fn_method_moqrequest_transport" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
431-(ffi/defcfn method-moqrequest-url "uniffi_moq_ffi_fn_method_moqrequest_url" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
432-(ffi/defcfn method-moqroutewatch-cancel "uniffi_moq_ffi_fn_method_moqroutewatch_cancel" [:uint64 :pointer] :void)
433-(ffi/defcfn method-moqroutewatch-next "uniffi_moq_ffi_fn_method_moqroutewatch_next" [:uint64] :uint64)
434-(ffi/defcfn method-moqserver-accept "uniffi_moq_ffi_fn_method_moqserver_accept" [:uint64] :uint64)
435-(ffi/defcfn method-moqserver-cancel "uniffi_moq_ffi_fn_method_moqserver_cancel" [:uint64 :pointer] :void)
436-(ffi/defcfn method-moqserver-cert-fingerprints "uniffi_moq_ffi_fn_method_moqserver_cert_fingerprints" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
437-(ffi/defcfn method-moqserver-listen "uniffi_moq_ffi_fn_method_moqserver_listen" [:uint64] :uint64)
438-(ffi/defcfn method-moqserver-set-bind "uniffi_moq_ffi_fn_method_moqserver_set_bind" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
439-(ffi/defcfn method-moqserver-set-consume "uniffi_moq_ffi_fn_method_moqserver_set_consume" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
440-(ffi/defcfn method-moqserver-set-publish "uniffi_moq_ffi_fn_method_moqserver_set_publish" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
441-(ffi/defcfn method-moqserver-set-tls-cert "uniffi_moq_ffi_fn_method_moqserver_set_tls_cert" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
442-(ffi/defcfn method-moqserver-set-tls-generate "uniffi_moq_ffi_fn_method_moqserver_set_tls_generate" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
443-(ffi/defcfn method-moqserver-set-tls-key "uniffi_moq_ffi_fn_method_moqserver_set_tls_key" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
444-(ffi/defcfn method-moqsession-cancel "uniffi_moq_ffi_fn_method_moqsession_cancel" [:uint64 :uint32 :pointer] :void)
445-(ffi/defcfn method-moqsession-closed "uniffi_moq_ffi_fn_method_moqsession_closed" [:uint64] :uint64)
446-(ffi/defcfn method-moqsession-consumer "uniffi_moq_ffi_fn_method_moqsession_consumer" [:uint64 :pointer] :uint64)
447-(ffi/defcfn method-moqsession-publisher "uniffi_moq_ffi_fn_method_moqsession_publisher" [:uint64 :pointer] :uint64)
448-(ffi/defcfn method-moqsession-shutdown "uniffi_moq_ffi_fn_method_moqsession_shutdown" [:uint64 :pointer] :void)
449-(ffi/defcfn method-moqsession-stats "uniffi_moq_ffi_fn_method_moqsession_stats" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
450-(ffi/defcfn method-moqtrackconsumer-cancel "uniffi_moq_ffi_fn_method_moqtrackconsumer_cancel" [:uint64 :pointer] :void)
451-(ffi/defcfn method-moqtrackconsumer-info "uniffi_moq_ffi_fn_method_moqtrackconsumer_info" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
452-(ffi/defcfn method-moqtrackconsumer-next-group "uniffi_moq_ffi_fn_method_moqtrackconsumer_next_group" [:uint64] :uint64)
453-(ffi/defcfn method-moqtrackconsumer-read-frame "uniffi_moq_ffi_fn_method_moqtrackconsumer_read_frame" [:uint64] :uint64)
454-(ffi/defcfn method-moqtrackconsumer-recv-datagram "uniffi_moq_ffi_fn_method_moqtrackconsumer_recv_datagram" [:uint64] :uint64)
455-(ffi/defcfn method-moqtrackconsumer-recv-group "uniffi_moq_ffi_fn_method_moqtrackconsumer_recv_group" [:uint64] :uint64)
456-(ffi/defcfn method-moqtrackconsumer-update "uniffi_moq_ffi_fn_method_moqtrackconsumer_update" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
457-(ffi/defcfn method-moqtrackdynamic-cancel "uniffi_moq_ffi_fn_method_moqtrackdynamic_cancel" [:uint64 :pointer] :void)
458-(ffi/defcfn method-moqtrackdynamic-requested-group "uniffi_moq_ffi_fn_method_moqtrackdynamic_requested_group" [:uint64] :uint64)
459-(ffi/defcfn method-moqtrackproducer-abort "uniffi_moq_ffi_fn_method_moqtrackproducer_abort" [:uint64 :uint16 :pointer] :void)
460-(ffi/defcfn method-moqtrackproducer-append-datagram "uniffi_moq_ffi_fn_method_moqtrackproducer_append_datagram" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
461-(ffi/defcfn method-moqtrackproducer-append-group "uniffi_moq_ffi_fn_method_moqtrackproducer_append_group" [:uint64 :pointer] :uint64)
462-(ffi/defcfn method-moqtrackproducer-consume "uniffi_moq_ffi_fn_method_moqtrackproducer_consume" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
463-(ffi/defcfn method-moqtrackproducer-create-group "uniffi_moq_ffi_fn_method_moqtrackproducer_create_group" [:uint64 :uint64 :pointer] :uint64)
464-(ffi/defcfn method-moqtrackproducer-dynamic "uniffi_moq_ffi_fn_method_moqtrackproducer_dynamic" [:uint64 :pointer] :uint64)
465-(ffi/defcfn method-moqtrackproducer-finish "uniffi_moq_ffi_fn_method_moqtrackproducer_finish" [:uint64 :pointer] :void)
466-(ffi/defcfn method-moqtrackproducer-finish-at "uniffi_moq_ffi_fn_method_moqtrackproducer_finish_at" [:uint64 :uint64 :pointer] :void)
467-(ffi/defcfn method-moqtrackproducer-name "uniffi_moq_ffi_fn_method_moqtrackproducer_name" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
468-(ffi/defcfn method-moqtrackproducer-unused "uniffi_moq_ffi_fn_method_moqtrackproducer_unused" [:uint64] :uint64)
469-(ffi/defcfn method-moqtrackproducer-used "uniffi_moq_ffi_fn_method_moqtrackproducer_used" [:uint64] :uint64)
470-(ffi/defcfn method-moqtrackproducer-write-frame "uniffi_moq_ffi_fn_method_moqtrackproducer_write_frame" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :void)
471-(ffi/defcfn method-moqtrackrequest-abort "uniffi_moq_ffi_fn_method_moqtrackrequest_abort" [:uint64 :uint16 :pointer] :void)
472-(ffi/defcfn method-moqtrackrequest-accept "uniffi_moq_ffi_fn_method_moqtrackrequest_accept" [:uint64 [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]] :pointer] :uint64)
473-(ffi/defcfn method-moqtrackrequest-dynamic "uniffi_moq_ffi_fn_method_moqtrackrequest_dynamic" [:uint64 :pointer] :uint64)
474-(ffi/defcfn method-moqtrackrequest-name "uniffi_moq_ffi_fn_method_moqtrackrequest_name" [:uint64 :pointer] [:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]])
deleted src/frq/moq/smoke.clj +0 -896
deleted file mode 100644
@@ -1,896 +0,0 @@
1-(ns frq.moq.smoke
2- "The smallest round trip that proves the generated bindings are real.
3-
4- Nothing in `frq.moq.*` has crossed the ABI until something does, and the
5- failure modes here are not the kind that announce themselves: a RustBuffer
6- whose fields are the wrong width reads as plausible garbage, and a handle
7- freed twice corrupts an allocator some minutes later rather than at the call.
8- So this checks, in order, the three things everything else assumes —
9-
10- 1. the contract version, which is the object saying it is the one the
11- bindings were generated from;
12- 2. a handle's whole life — construct a MoqClient, then free it — which is
13- the RustCallStatus out-parameter working in both directions;
14- 3. a string out and back, which is the RustBuffer layout and the
15- alloc/free ownership rule.
16-
17- 4. a connect, which is the only one of these that involves a tokio worker
18- and therefore the only one that exercises the continuation callback —
19- the piece with the most ways to be quietly wrong, since it runs on a
20- thread jolt never started.
21-
22- The connect is aimed at a port nothing is listening on. That is on purpose:
23- what is under test is the future protocol and the error path, and both of
24- those are the same whether the far side refuses or is simply not there —
25- where a real relay would make this test depend on somebody else's uptime.
26-
27- It takes THIRTY SECONDS, and that is not this code being slow. QUIC runs
28- over UDP, so a closed port produces no connection-refused to notice: there
29- is only the handshake timeout, which moq-native sets to 30s. The deadline
30- below has to sit above it, and a test that gave up at 15s reported a
31- continuation that had never fired when what had happened was that nothing
32- had gone wrong yet.
33-
34- just repl -m frq.moq.smoke"
35- (:require [clojure.string :as str]
36- [frq.moq.uniffi :as uniffi]
37- [frq.moq.raw :as raw]
38- [frq.moq.client :as client]
39- [frq.moq.media :as media]
40- [frq.codec.opus :as opus]
41- [frq.codec.h264 :as h264]
42- [frq.capture.v4l2 :as v4l2]
43- [frq.capture.alsa :as alsa]
44- [frq.capture.source :as source]
45- [frq.av.plane :as plane]
46- [frq.av.audio :as audio]
47- [frq.av.dial :as dial]
48- [jolt.ffi :as ffi]))
49-
50-(defn- check-contract []
51- (let [v (uniffi/check-abi!)]
52- (println " contract version:" v "(expected" uniffi/expected-contract ")")
53- true))
54-
55-(defn- check-handle
56- "Construct and free a MoqClient.
57-
58- `moqclient_new` is the one constructor that takes nothing but a status, so
59- it isolates the status protocol from any argument lowering. The free is not
60- a courtesy: it is the other half of the handle contract, and a binding that
61- cannot free is a binding that leaks a QUIC endpoint per call."
62- []
63- (let [h (uniffi/with-out-status #(raw/constructor-moqclient-new %))]
64- (println " moqclient_new ->" h)
65- (when (zero? h)
66- (throw (ex-info "moqclient_new answered a null handle" {})))
67- (uniffi/with-out-status #(raw/free-moqclient h %))
68- (println " free_moqclient ok")
69- true))
70-
71-(defn- check-string
72- "Send a string into a RustBuffer and read it back out.
73-
74- The value is chosen to catch the two mistakes a length-counted buffer
75- invites: multi-byte UTF-8 (a length in characters rather than bytes reads
76- short) and a trailing character (a NUL-terminated read runs past the end)."
77- []
78- (let [s "moq://relay.example/ünïcode/✓"]
79- (ffi/with-arena [a]
80- (let [buf (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
81- (uniffi/lower-string buf s)
82- (let [len (ffi/read-field buf uniffi/rust-buffer [:len])
83- got (uniffi/lift-string buf)]
84- (println " lowered" (count s) "chars ->" len "bytes in the buffer")
85- (println " lifted " (pr-str got))
86- (when-not (= s got)
87- (throw (ex-info "string did not round trip"
88- {:sent s :got got})))
89- true)))))
90-
91-(defn- check-connect
92- "Connect to a closed port and watch the future fail.
93-
94- Everything interesting is in the loop: `poll-connect!` answers nil while the
95- far side has not settled, and each nil has issued another poll. That the
96- answer eventually changes at all is the continuation callback firing from a
97- tokio thread and this thread seeing it — which is the whole reason for the
98- atom in `frq.moq.uniffi/continuation`.
99-
100- A raise is the PASS here. What would be a failure is a hang (the
101- continuation never fires) or a success (a connect to a closed port cannot
102- succeed, so a session handle would mean the future protocol handed us
103- something that is not a session)."
104- []
105- (let [c (client/new-client)]
106- (try
107- (let [fut (client/connect! c "https://127.0.0.1:1/smoke")
108- deadline (+ (System/currentTimeMillis) 45000)]
109- (println " connect started, polling")
110- (loop [polls 0]
111- (cond
112- (> (System/currentTimeMillis) deadline)
113- (throw (ex-info "connect future never settled — the continuation did not fire"
114- {:polls polls}))
115-
116- :else
117- (let [r (try
118- {:ok (client/poll-connect! fut)}
119- (catch clojure.lang.ExceptionInfo e {:err e}))]
120- (cond
121- (:err r)
122- (let [d (ex-data (:err r))]
123- (println " settled after" polls "polls")
124- (println " variant:" (:variant d))
125- (println " message:" (pr-str (:message d)))
126- (when-not (:variant d)
127- (throw (ex-info "error carried no MoqError variant — the buffer did not decode"
128- {:data d})))
129- true)
130-
131- (:ok r)
132- (throw (ex-info "connect to a closed port answered a session"
133- {:handle (:ok r)}))
134-
135- :else
136- (do (Thread/sleep 20) (recur (inc polls))))))))
137- (finally
138- (client/free-client! c)
139- (println " free_moqclient ok")))))
140-
141-(defn- settle!
142- "Poll `fut` until it settles, or give up. Returns what it settled to.
143-
144- The polling is the point: every nil here has issued another poll, and this
145- is what a caller on the loop thread would be doing from a timer instead of
146- from a loop like this one."
147- ([fut what ms] (settle! fut what ms nil))
148- ([fut what ms lift]
149- ;; ms 0 means one look and no waiting — for a caller with its own loop.
150- (let [deadline (+ (System/currentTimeMillis) ms)]
151- (loop []
152- (cond
153- (uniffi/settled? fut) (if lift
154- (uniffi/complete! fut lift)
155- (uniffi/complete! fut))
156- (zero? ms) nil
157- (> (System/currentTimeMillis) deadline)
158- (throw (ex-info (str what ": future never settled") {:after-ms ms}))
159- :else (do (Thread/sleep 10) (recur)))))))
160-
161-(defn- check-media
162- "Put a payload into a broadcast and take the same payload out of it.
163-
164- Entirely in-process: an origin producer is a plain constructor, so the
165- broadcast a subscriber consumes here is the one the producer writes to, with
166- no QUIC in between. That exercises argument lowering (a top-level string, an
167- enum, an optional, a record), both subscribe paths, and the frame decode —
168- everything except the wire itself.
169-
170- TWO tracks, because they prove different things. `subscribe_media` is the
171- one frq.av will use, and it is checked as far as it can be checked here: a
172- media track declares a codec, and avc3 means the container really does try
173- to read Annex B out of whatever arrives, so a frame carrying the word hello
174- is never going to come back out of it. The opaque track beside it parses
175- nothing, which is what lets the payload round trip be an actual assertion
176- rather than a hope."
177- []
178- (let [origin (media/new-origin)
179- broadcast (media/create-broadcast! origin "/smoke")
180- producer (media/publish-media! broadcast "avc3")
181- track (media/producer-name producer)
182- consumer (media/broadcast-consumer broadcast)]
183- (println " origin, broadcast, media track:" (pr-str track))
184-
185- ;; The media path: subscribing is the assertion.
186- (let [mc (settle! (media/subscribe-media! consumer track :loc) "subscribe_media" 10000)]
187- (println " subscribe_media ->" mc)
188- (when (zero? mc)
189- (throw (ex-info "subscribe_media answered a null handle" {}))))
190-
191- ;; The opaque path: the payload is the assertion.
192- (let [tp (media/publish-track! broadcast "data")
193- tc (settle! (media/subscribe-track! consumer "data") "subscribe_track" 10000)]
194- (println " subscribe_track ->" tc)
195- (media/write-track-frame! tp "hello-from-a-frame" 1234567)
196- (println " wrote a frame")
197- (let [frame (settle! (media/read-frame! tc) "read_frame" 10000
198- media/lift-plain-frame)]
199- (println " got frame:" (pr-str frame))
200- (when-not frame
201- (throw (ex-info "track ended instead of delivering a frame" {})))
202- (when-not (= "hello-from-a-frame" (:payload frame))
203- (throw (ex-info "payload did not survive" {:frame frame})))
204- (when-not (= 1234567 (:timestamp-us frame))
205- (throw (ex-info "timestamp did not survive" {:frame frame})))
206- true))))
207-
208-(defn- triangle-pcm
209- "20ms of a triangle wave at 48kHz mono, as int16 in FOREIGN memory.
210-
211- A triangle rather than a sine because it needs no transcendental and no
212- Math namespace, and rather than silence because silence is the one input
213- Opus is entitled to throw away: DTX would answer two bytes and the round
214- trip would prove nothing.
215-
216- Answers [pointer samples-per-channel]."
217- [a]
218- (let [n 960 ; 48000 * 0.020
219- p (ffi/alloc a (* 2 n))]
220- (dotimes [i n]
221- ;; A 100-sample period ramp, +/- 8000 — loud enough that the decoder
222- ;; cannot answer silence and have it look like success.
223- (let [phase (mod i 100)
224- v (if (< phase 50) (- (* phase 320) 8000) (- 8000 (* (- phase 50) 320)))]
225- (ffi/write (+ p (* 2 i)) :int16 v)))
226- [p n]))
227-
228-(defn- check-opus
229- "Encode a frame with libopus and decode it back.
230-
231- This is the first piece of the port that is a C library rather than
232- somebody's binding to one, and the assertions are chosen to fail if it is
233- only pretending to work. The encoded frame must be more than DTX's two
234- bytes; the decode must answer exactly the frame size it was given; and the
235- decoded audio must carry real amplitude, because a decoder that returned
236- the right COUNT of zeroes would otherwise pass."
237- []
238- (ffi/with-arena [a]
239- (let [enc (opus/encoder 48000 1 :voip)
240- dec (opus/decoder 48000 1)]
241- (try
242- (opus/set-bitrate! enc 24000)
243- (let [[pcm n] (triangle-pcm a)
244- cap 4000
245- out (ffi/alloc a cap)
246- written (opus/encode! enc pcm n out cap)]
247- (println " encoded" n "samples ->" written "bytes")
248- (when (opus/dtx? written)
249- (throw (ex-info "encoder answered DTX for a loud frame"
250- {:written written})))
251- (let [back (ffi/alloc a (* 2 n))
252- decoded (opus/decode! dec out written back n)
253- peak (reduce (fn [m i]
254- (max m (abs (ffi/read (+ back (* 2 i)) :int16))))
255- 0 (range decoded))]
256- (println " decoded" decoded "samples, peak" peak)
257- (when-not (= n decoded)
258- (throw (ex-info "decoder answered a different frame size"
259- {:sent n :got decoded})))
260- (when (< peak 1000)
261- (throw (ex-info "decoded audio is silent — a lossy codec is not this lossy"
262- {:peak peak})))
263- true))
264- (finally
265- (opus/free-encoder! enc)
266- (opus/free-decoder! dec))))))
267-
268-(defn- check-h264
269- "Encode an I420 frame to H.264 through the openh264 shim.
270-
271- The assertion is the Annex B start code. A frame that came back with a
272- plausible length and the wrong bytes would be a vtable walked incorrectly —
273- the shim reading the wrong slot, or flattening the layers wrong — and a
274- length check alone would not catch it. 00 00 00 01 at the front, and an
275- IDR for the first frame, is openh264 having actually encoded something.
276-
277- The picture is flat gray, which compresses to almost nothing; what is under
278- test is the calling convention, not the encoder."
279- []
280- (ffi/with-arena [a]
281- (let [w 64 h 64
282- n (h264/i420-size w h)
283- px (ffi/alloc a n)
284- enc (h264/encoder {:width w :height h :fps 30 :bitrate 200000})]
285- (dotimes [i n] (ffi/write (+ px i) :uint8 0x80))
286- (try
287- (let [got (h264/encode! enc px 0
288- (fn [p len key?]
289- ;; The span is the encoder's buffer and dies
290- ;; at the next encode: read what is needed
291- ;; here and let it go.
292- {:len len
293- :keyframe key?
294- :first-4 (mapv #(ffi/read (+ p %) :uint8)
295- (range (min 4 len)))}))]
296- (println " encoded" n "bytes of I420 ->" (:len got)
297- "bytes, keyframe" (:keyframe got)
298- "starts" (pr-str (:first-4 got)))
299- (when (zero? (:len got))
300- (throw (ex-info "encoder skipped the first frame" {})))
301- (when-not (= [0 0 0 1] (:first-4 got))
302- (throw (ex-info "not Annex B — no start code" {:first-4 (:first-4 got)})))
303- (when-not (:keyframe got)
304- (throw (ex-info "first frame is not an IDR" {})))
305-
306- ;; And back again. A decode that answered the right SIZE full of
307- ;; the wrong pixels would pass a length check, so the assertion is
308- ;; the picture: flat gray in, flat gray out, opaque.
309- (let [dec (h264/decoder)]
310- (try
311- ;; The frame above was the IDR; this one would be a P-frame
312- ;; referencing it, and a decoder handed that first answers
313- ;; dsNoParamSets (16) — no SPS or PPS to decode against. A
314- ;; subscriber joining mid-call is in exactly that position,
315- ;; which is what force-keyframe! is for.
316- (h264/force-keyframe! enc)
317- (h264/encode! enc px 33333
318- (fn [p len _]
319- (h264/decode! dec p len
320- (fn [rgba dw dh]
321- (println " decoded ->" dw "x" dh "RGBA")
322- (when (or (zero? dw) (ffi/null? rgba))
323- (throw (ex-info "decoder produced no picture" {})))
324- (when-not (and (= w dw) (= h dh))
325- (throw (ex-info "decoded size does not match"
326- {:want [w h] :got [dw dh]})))
327- (let [px0 (mapv #(ffi/read (+ rgba %) :uint8) (range 4))
328- mid (* 4 (+ (* (quot dh 2) dw) (quot dw 2)))
329- pxm (mapv #(ffi/read (+ rgba mid %) :uint8) (range 4))]
330- (println " first pixel" (pr-str px0) "centre" (pr-str pxm))
331- (when-not (= 255 (nth px0 3))
332- (throw (ex-info "alpha is not opaque" {:pixel px0})))
333- ;; 0x80 luma with neutral chroma is mid gray; the
334- ;; BT.601 maths lands near 125, not exactly 128.
335- (doseq [c (take 3 pxm)]
336- (when-not (< 100 c 150)
337- (throw (ex-info "centre pixel is not gray"
338- {:pixel pxm})))))
339- true))))
340- (finally (h264/close-decoder! dec))))
341- true)
342- (finally (h264/close! enc))))))
343-
344-(defn- check-v4l2-layouts
345- "Check every V4L2 struct against what a C compiler says.
346-
347- There is no camera in this container, so the capture path itself cannot be
348- exercised here. What CAN be checked is the part most likely to be wrong and
349- least likely to announce it: every VIDIOC_ request number encodes the size
350- of the struct it carries, so a layout one byte off does not misread a field
351- — it produces a request the kernel has never heard of, and the driver
352- answers ENOTTY for an ioctl that plainly exists.
353-
354- The numbers on the right came from a C program compiled against this
355- kernel's own headers (scratch/v4l2probe.c). They are the ground truth this
356- namespace is transcribed from, so checking against them catches a typo
357- rather than a misunderstanding — the misunderstanding needs a device."
358- []
359- (let [checks [["v4l2_capability size" (ffi/layout-size v4l2/capability) 104]
360- [" capabilities@" (ffi/field-offset v4l2/capability [:capabilities]) 84]
361- [" device-caps@" (ffi/field-offset v4l2/capability [:device-caps]) 88]
362- ["v4l2_format size" (ffi/layout-size v4l2/format-pix) 208]
363- [" width@" (ffi/field-offset v4l2/format-pix [:width]) 8]
364- [" height@" (ffi/field-offset v4l2/format-pix [:height]) 12]
365- [" pixelformat@" (ffi/field-offset v4l2/format-pix [:pixelformat]) 16]
366- [" sizeimage@" (ffi/field-offset v4l2/format-pix [:sizeimage]) 28]
367- ["v4l2_requestbuffers size" (ffi/layout-size v4l2/requestbuffers) 20]
368- ["v4l2_buffer size" (ffi/layout-size v4l2/buffer) 88]
369- [" bytesused@" (ffi/field-offset v4l2/buffer [:bytesused]) 8]
370- [" timestamp@" (ffi/field-offset v4l2/buffer [:tv-sec]) 24]
371- [" memory@" (ffi/field-offset v4l2/buffer [:memory]) 60]
372- [" m.offset@" (ffi/field-offset v4l2/buffer [:offset]) 64]
373- [" length@" (ffi/field-offset v4l2/buffer [:length]) 72]]
374- bad (remove (fn [[_ got want]] (= got want)) checks)]
375- (doseq [[what got want] checks]
376- (println (str " " what " " got (when-not (= got want) (str " WANT " want)))))
377- (when (seq bad)
378- (throw (ex-info "V4L2 layouts do not match the kernel headers"
379- {:mismatched (mapv (fn [[w g want]] {:what w :got g :want want}) bad)})))
380- (println " (no camera here — the capture path itself is unexercised)")
381- true))
382-
383-(defn- check-alsa
384- "Open a PCM and push frames through it.
385-
386- The device is ALSA's `null` PCM, which swallows everything and is always
387- present — no hardware, no permissions, and nothing that depends on what
388- this container happens to have plugged in. What that proves is the binding:
389- the library loads, the handle out-parameter comes back, set_params accepts
390- the format, and writei answers in FRAMES. What it cannot prove is that a
391- real card behaves, which needs a real card.
392-
393- The frame count is the assertion. 960 frames of mono S16 is 1920 bytes, and
394- a writei that answered 1920 would be this code having confused the two —
395- the mistake the namespace docstring warns about, and the one that looks
396- like a slow device rather than a bug."
397- []
398- (ffi/with-arena [a]
399- (let [frames 960
400- buf (ffi/alloc a (* 2 frames))
401- pcm (alsa/open-pcm "null" :playback {:rate 48000 :channels 1})]
402- (try
403- (dotimes [i frames] (ffi/write (+ buf (* 2 i)) :int16 0))
404- (let [{:keys [frames written recovered]} (alsa/write! pcm buf frames)
405- n (or frames written)]
406- (println (str " null pcm: wrote " n " frames"
407- (when recovered " (recovered from an overrun)")))
408- (when-not (= 960 n)
409- (throw (ex-info "writei answered something other than the frame count"
410- {:asked 960 :got n})))
411- true)
412- (finally (alsa/close! pcm))))))
413-
414-(defn- check-enumeration
415- "List the devices, which is what av.clj's cameras/microphones/speakers are.
416-
417- There is no assertion on the CONTENTS: this container has no camera, and
418- which PCMs ALSA offers is a property of the machine rather than of this
419- code. What is asserted is that enumeration returns without leaking or
420- faulting and that every entry is shaped the way av.clj's `parse-devices`
421- produced them — an :id to pass back, a :name to show, a :default? flag —
422- because that shape is the contract the UI already reads."
423- []
424- (let [shaped? (fn [d] (and (string? (:id d)) (string? (:name d))
425- (contains? d :default?)))
426- cams (v4l2/devices)
427- mics (alsa/devices :capture)
428- outs (alsa/devices :playback)]
429- (println " cameras:" (count cams) (pr-str (mapv :name (take 2 cams))))
430- (println " capture:" (count mics) (pr-str (mapv :name (take 2 mics))))
431- (println " playback:" (count outs) (pr-str (mapv :name (take 2 outs))))
432- (doseq [[what ds] [["camera" cams] ["capture" mics] ["playback" outs]]]
433- (when-let [bad (first (remove shaped? ds))]
434- (throw (ex-info (str what " device is not shaped like av.clj expects")
435- {:device bad}))))
436- true))
437-
438-(defn- i420-halves
439- "An I420 frame whose left half is `lo` and right half `hi`.
440-
441- Flat frames are no use as a test: a decode that produced a uniform picture
442- would pass every check a flat frame can make. Two halves also give each
443- peer a distinguishable picture, which is what makes multi-peer testable —
444- if the frames were identical there would be no way to tell a second peer
445- from the first one counted twice."
446- [a w h lo hi]
447- (let [n (h264/i420-size w h)
448- p (ffi/alloc a n)]
449- (dotimes [y h]
450- (dotimes [x w]
451- (ffi/write (+ p (* y w) x) :uint8 (if (< x (quot w 2)) lo hi))))
452- (dotimes [i (* 2 (quot (* w h) 4))]
453- (ffi/write (+ p (* w h) i) :uint8 0x80))
454- [p n]))
455-
456-(defn- check-plane
457- "Two peers' video, end to end through frq.av.plane.
458-
459- Nothing is configured: the plane watches the origin for ANNOUNCEMENTS,
460- reads each peer's catalog for its video track name and container, and
461- subscribes. Our own broadcast is announced back like anyone else's and
462- becomes the self-view.
463-
464- The second broadcast stands in for another participant. It is published
465- the same way a remote client would publish it and discovered the same way
466- — the only thing it does not cross is the wire, which the connect check
467- already covers.
468-
469- Each picture has a different contrast so a peer cannot be confused for the
470- other, and the assertion is per peer: the right key, the right size, and
471- the halves the right way round."
472- []
473- (ffi/with-arena [a]
474- (let [w 64 h 64
475- [ours _] (i420-halves a w h 0x40 0xC0)
476- [theirs _] (i420-halves a w h 0xC0 0x40)
477- origin (media/new-origin)]
478- (plane/start! {:origin origin :path "/us" :source (fn [] [ours nil])
479- :width w :height h :fps 30 :bitrate 200000})
480- ;; A second participant on the same origin, published exactly as a
481- ;; remote one would be.
482- (let [b2 (media/create-broadcast! origin "/them")
483- p2 (media/publish-media! b2 "avc3")
484- enc (h264/encoder {:width w :height h :fps 30 :bitrate 200000})]
485- (try
486- (let [deadline (+ (System/currentTimeMillis) 25000)]
487- (loop [pumps 0 seen {}]
488- ;; Keep the other peer publishing: a subscriber that arrives
489- ;; after the first keyframe needs another one.
490- (h264/force-keyframe! enc)
491- (h264/encode! enc theirs (* pumps 33333)
492- (fn [p len _]
493- (when (pos? len)
494- (media/write-video-frame! p2 p len (* pumps 33333)))))
495- (plane/pump!)
496- (let [seen (reduce (fn [m f]
497- (if (contains? m (:key f))
498- m
499- (let [at #(ffi/read (+ (:rgba f) (* 4 (+ (* 32 (:w f)) %))) :uint8)]
500- (assoc m (:key f)
501- {:w (:w f) :h (:h f)
502- :left (at 8) :right (at 56)}))))
503- seen (plane/poll-frames!))]
504- (cond
505- (>= (count seen) 2)
506- (do
507- (doseq [[k v] seen]
508- (println " " k (str (:w v) "x" (:h v))
509- "left" (:left v) "right" (:right v)))
510- (when-not (contains? seen "__local__")
511- (throw (ex-info "no self-view" {:keys (keys seen)})))
512- (let [local (get seen "__local__")
513- other (val (first (dissoc seen "__local__")))]
514- (when-not (< (:left local) (:right local))
515- (throw (ex-info "self-view halves are the wrong way round"
516- {:frame local})))
517- (when-not (> (:left other) (:right other))
518- (throw (ex-info "peer halves are the wrong way round — the feeds are crossed"
519- {:frame other}))))
520- true)
521-
522- (> (System/currentTimeMillis) deadline)
523- (throw (ex-info "did not see two peers" {:pumps pumps :seen (keys seen)}))
524-
525- :else (do (Thread/sleep 10) (recur (inc pumps) seen))))))
526- (finally
527- (h264/close! enc)
528- (plane/stop!)))))))
529-
530-(defn- tone
531- "20ms of a triangle at `amp`, int16 mono, in foreign memory."
532- [a amp]
533- (let [n audio/frame-samples
534- p (ffi/alloc a (* 2 n))]
535- (dotimes [i n]
536- (let [phase (mod i 100)
537- v (if (< phase 50)
538- (- (* phase (quot (* 2 amp) 50)) amp)
539- (- amp (* (- phase 50) (quot (* 2 amp) 50))))]
540- (ffi/write (+ p (* 2 i)) :int16 v)))
541- [p n]))
542-
543-(defn- check-audio
544- "A voice through the plane: Opus -> MoQ -> jitter buffer -> mix.
545-
546- The voice belongs to a SECOND peer, and it has to: the mixer excludes our
547- own ring, because hearing your own microphone back is the thing headphones
548- exist to prevent. A test that published only its own audio would be
549- asserting on silence and calling it a bug.
550-
551- The assertion is amplitude. Opus is lossy and a triangle comes back
552- rounded, but silence is unmistakable — and silence is exactly what a
553- broken jitter buffer produces, whether it fills and never drains or drains
554- before it fills.
555-
556- The pump count matters too: the ring holds `target-depth` frames before it
557- plays anything, so a mix appearing on the first pump would mean the depth
558- was not being honoured."
559- []
560- (ffi/with-arena [a]
561- (let [[pcm _] (tone a 8000)
562- origin (media/new-origin)
563- head-p (ffi/alloc a 19)]
564- (audio/opus-head! head-p 1)
565- (plane/start! {:origin origin :path "/us"
566- :source (fn [] nil) ; no camera in this check
567- :mic (fn [] [pcm audio/frame-samples])
568- :width 64 :height 64 :channels 1})
569- (let [b2 (media/create-broadcast! origin "/them")
570- p2 (media/publish-media-bytes! b2 "opus" head-p 19)
571- enc (opus/encoder audio/sample-rate 1 :voip)
572- out (ffi/alloc a 4000)]
573- (try
574- (let [deadline (+ (System/currentTimeMillis) 25000)]
575- (loop [pumps 0]
576- ;; The other participant keeps talking.
577- (let [n (opus/encode! enc pcm audio/frame-samples out 4000)]
578- (when-not (opus/dtx? n)
579- (media/write-video-frame! p2 out n (* pumps 20000))))
580- (plane/pump!)
581- (let [mixed (plane/poll-audio!)]
582- (cond
583- (and mixed (pos? (:peak mixed)))
584- (do (println " mixed audio after" pumps "pumps, peak" (:peak mixed)
585- "over" (:samples mixed) "samples")
586- (when (< (:peak mixed) 500)
587- (throw (ex-info "the mix is effectively silent"
588- {:peak (:peak mixed)})))
589- (when (< pumps 2)
590- (throw (ex-info "played before the jitter buffer filled"
591- {:pumps pumps})))
592- true)
593-
594- (> (System/currentTimeMillis) deadline)
595- (throw (ex-info "no audio came back through the plane" {:pumps pumps}))
596-
597- :else (do (Thread/sleep 5) (recur (inc pumps)))))))
598- (finally
599- (opus/free-encoder! enc)
600- (plane/stop!)))))))
601-
602-(defn- check-session
603- "The plane over a real QUIC session, not a local origin.
604-
605- A relay is stood up in-process — MoqServer with a self-signed certificate
606- and one origin wired as both what it publishes and what it consumes, which
607- is what makes it a relay rather than two unrelated halves. A client dials
608- it over actual QUIC, and the plane runs on that session's publisher() and
609- consumer() rather than on an origin it made itself.
610-
611- What this proves that the loopback checks cannot: that publish and
612- discover being two DIFFERENT origins works, that a broadcast survives the
613- wire, and that the announcement comes back through the relay rather than
614- from an object we already had.
615-
616- Everything is polled, the relay included. Its accept has to be driven from
617- the same loop as the client's connect, because the client cannot finish
618- connecting until the relay accepts and there is no other thread to do it
619- on — which is a fair model of the real thing, where glimmer's timer is
620- the only clock this code gets."
621- []
622- (ffi/with-arena [a]
623- (let [w 64 h 64
624- [px _] (i420-halves a w h 0x40 0xC0)
625- relay (media/new-origin)
626- server (client/new-server)]
627- (client/server-bind! server "127.0.0.1:0")
628- (client/server-tls-generate! server ["localhost"])
629- (client/server-origin! server relay)
630- (let [addr (settle! (client/server-listen! server) "listen" 10000
631- uniffi/lift-string)
632- port (last (str/split addr #":"))
633- fps (client/server-fingerprints server)
634- c (client/new-client)]
635- (println " relay on" addr "fingerprint" (subs (first fps) 0 16))
636- (client/set-tls-fingerprints! c fps)
637- (let [connect (client/connect! c (str "https://localhost:" port "/room"))
638- incoming (client/server-accept! server)
639- deadline (+ (System/currentTimeMillis) 25000)]
640- (loop [req nil accepted nil sess nil]
641- (let [;; The relay side: an incoming request, then accept it.
642- req (or req (settle! incoming "accept" 0 media/lift-optional-handle))
643- accepted (or accepted (when req (client/accept-request! req)))
644- _ (when accepted (settle! accepted "request accept" 0 nil))
645- ;; The client side.
646- sess (or sess (settle! connect "connect" 0 nil))]
647- (cond
648- sess
649- (do
650- (println " connected over QUIC")
651- (try
652- (plane/start! {:origin (client/session-publisher sess)
653- :discover (client/session-consumer sess)
654- :session sess
655- :path "/us" :source (fn [] [px nil])
656- :width w :height h :fps 30 :bitrate 200000})
657- (let [d2 (+ (System/currentTimeMillis) 20000)]
658- (loop [pumps 0]
659- (plane/pump!)
660- (if-let [f (first (plane/poll-frames!))]
661- (do (println " frame back through the relay after" pumps
662- "pumps:" (:w f) "x" (:h f) (pr-str (:key f)))
663- (when-not (and (= w (:w f)) (= h (:h f)))
664- (throw (ex-info "wrong size over the wire" {:frame f})))
665- true)
666- (if (> (System/currentTimeMillis) d2)
667- (throw (ex-info "no frame came back over the session"
668- {:pumps pumps}))
669- (do (Thread/sleep 10) (recur (inc pumps)))))))
670- (finally
671- (plane/stop!)
672- (client/server-cancel! server))))
673-
674- (> (System/currentTimeMillis) deadline)
675- (throw (ex-info "the session never came up"
676- {:request req :accepted (some? accepted)}))
677-
678- :else (do (Thread/sleep 10) (recur req accepted sess))))))))))
679-
680-(defn- check-wired-devices
681- "The plane driven by real device objects rather than test thunks.
682-
683- ALSA's `null` on both ends, and that is a limit of this machine rather
684- than a choice: the sound hardware is held by PipeWire on the host, and
685- PipeWire's own socket is not reachable from inside this container, so
686- `default` and `hw:1,0` both refuse. `null` is a real PCM opened through
687- the real binding — it proves the wiring, the frame arithmetic and the
688- teardown, and it cannot prove that a microphone sounds like anything.
689-
690- The camera is not here at all: there is no /dev/video* and no privilege to
691- load the kernel's virtual one. `frq.capture.source/camera` is written and
692- unexercised, and this test says so rather than implying otherwise.
693-
694- What IS asserted: that a device-shaped mic drives the outbound half, that
695- a speaker sink is written to without raising, and that stop! releases
696- both. A leak here would show up as `Device or resource busy` on the second
697- run, which is why the check runs the whole cycle twice."
698- []
699- (dotimes [round 2]
700- (let [origin (media/new-origin)]
701- (plane/start! {:origin origin :path "/us"
702- :source (fn [] nil)
703- :mic-device "null"
704- :speaker-device "null"
705- :width 64 :height 64 :channels 1})
706- (try
707- (dotimes [_ 5] (plane/pump!))
708- (println (str " round " (inc round) ": mic and speaker opened, pumped, closed"))
709- (finally (plane/stop!)))))
710- ;; And the camera path as far as it goes on a machine with no camera:
711- ;; enumeration is empty and opening one raises rather than pretending.
712- (let [cams (v4l2/devices)]
713- (println " cameras available:" (count cams))
714- (when (seq cams)
715- (let [c (source/camera (:id (first cams)) {:width 640 :height 480})]
716- (println " opened" (:id (first cams)) (:width c) "x" (:height c))
717- ((:close! c)))))
718- true)
719-
720-(defn- check-status
721- "The three transitions frq.av reads: live, failed, ended.
722-
723- :live is asserted on a plain local plane — it carries has-camera? and
724- has-mic?, which is what the UI shows before a single frame arrives.
725-
726- :ended and :failed need a session to lose, so the relay from the session
727- check is stood up again and then CANCELLED underneath a running plane.
728- That is the case that matters: not a call the person hung up, but one
729- that went away, which is the whole reason poll-status! exists rather than
730- frq.av inferring things from frames stopping.
731-
732- Whether the drop reads as :ended or :failed depends on how the far side
733- goes — a relay cancelled mid-session may close cleanly or not — so both
734- are accepted here. What is NOT accepted is silence: a call that ends with
735- no transition at all is one where the person is left looking at a frozen
736- picture."
737- []
738- (ffi/with-arena [a]
739- ;; 1. :live, with the flags.
740- (let [origin (media/new-origin)]
741- (plane/start! {:origin origin :path "/us"
742- :source (fn [] nil) :mic (fn [] nil)
743- :width 64 :height 64 :channels 1})
744- (let [[ev] (plane/poll-status!)]
745- (println " live event:" (pr-str ev))
746- (when-not (= :live (:code ev))
747- (throw (ex-info "no :live on start" {:event ev})))
748- (when-not (and (:has-camera? ev) (:has-mic? ev))
749- (throw (ex-info "flags do not reflect the sources given" {:event ev})))
750- (when (seq (plane/poll-status!))
751- (throw (ex-info "poll-status! did not drain" {}))))
752- (plane/stop!))
753-
754- ;; 2. a session lost underneath us.
755- (let [[px _] (i420-halves a 64 64 0x40 0xC0)
756- relay (media/new-origin)
757- server (client/new-server)]
758- (client/server-bind! server "127.0.0.1:0")
759- (client/server-tls-generate! server ["localhost"])
760- (client/server-origin! server relay)
761- (let [addr (settle! (client/server-listen! server) "listen" 10000
762- uniffi/lift-string)
763- port (last (str/split addr #":"))
764- fps (client/server-fingerprints server)
765- c (client/new-client)]
766- (client/set-tls-fingerprints! c fps)
767- (let [connect (client/connect! c (str "https://localhost:" port "/room"))
768- incoming (client/server-accept! server)
769- deadline (+ (System/currentTimeMillis) 25000)]
770- (loop [req nil accepted nil srv nil sess nil]
771- (let [req (or req (settle! incoming "accept" 0 media/lift-optional-handle))
772- accepted (or accepted (when req (client/accept-request! req)))
773- ;; The relay's OWN side of the session, kept rather than
774- ;; dropped: cancelling the server only stops it accepting
775- ;; new connections, and an established QUIC session then
776- ;; sits there until its idle timeout — half a minute of
777- ;; a frozen picture. What a peer hanging up actually
778- ;; looks like is this session being cancelled.
779- srv (or srv (when accepted
780- (settle! accepted "request accept" 0 nil)))
781- sess (or sess (settle! connect "connect" 0 nil))]
782- (cond
783- (and sess srv)
784- (do
785- (plane/start! {:origin (client/session-publisher sess)
786- :discover (client/session-consumer sess)
787- :session sess
788- :path "/us" :source (fn [] [px nil])
789- :width 64 :height 64 :fps 30 :bitrate 200000})
790- (plane/poll-status!) ; drain the :live
791- (dotimes [_ 10] (plane/pump!) (Thread/sleep 10))
792- (println " dropping the far side under a live plane")
793- (client/cancel! srv 0)
794- (client/server-cancel! server)
795- (let [d2 (+ (System/currentTimeMillis) 20000)]
796- (loop [pumps 0]
797- (plane/pump!)
798- (let [evs (plane/poll-status!)]
799- (cond
800- (seq evs)
801- (do (println " after the drop:" (pr-str evs))
802- (when-not (some #{:ended :failed} (map :code evs))
803- (throw (ex-info "the drop produced no ending"
804- {:events evs})))
805- (plane/stop!)
806- true)
807-
808- (> (System/currentTimeMillis) d2)
809- (do (plane/stop!)
810- (throw (ex-info "the session went away silently"
811- {:pumps pumps})))
812-
813- :else (do (Thread/sleep 10) (recur (inc pumps))))))))
814-
815- (> (System/currentTimeMillis) deadline)
816- (throw (ex-info "the session never came up" {}))
817-
818- :else (do (Thread/sleep 10) (recur req accepted srv sess))))))))))
819-
820-(defn- check-dial
821- "The URL rules, transcribed out of Rust and worth checking case by case.
822-
823- These were `joltmoq_sfu_url` and `joltmoq_can_dial`, and they are the one
824- part of the port that is pure logic rather than a binding — which means
825- they are also the one part where a wrong answer is silent. A URL built
826- slightly wrong does not fail to compile; it dials somewhere that is not
827- there, and the person is told the call timed out.
828-
829- The port dropping is the case worth staring at: `irc.freeq.at:6697`
830- becomes `https://irc.freeq.at/av/moq` with no port, because 6697 is the
831- IRC port and the SFU is not on it — but an absolute URL keeps its
832- authority as written, since someone who put a port in a URL meant it."
833- []
834- (let [cases
835- [["irc.freeq.at:6697" nil nil "https://irc.freeq.at/av/moq"]
836- ["irc.freeq.at" nil nil "https://irc.freeq.at/av/moq"]
837- ["wss://irc.freeq.at/irc" nil nil "https://irc.freeq.at/av/moq"]
838- ["ws://localhost:6667" nil nil "http://localhost:6667/av/moq"]
839- ["localhost:6667" nil nil "http://localhost:6667/av/moq"]
840- ["127.0.0.1:6667" nil nil "http://127.0.0.1:6667/av/moq"]
841- ["irc.freeq.at:6697" "tok" "ab12" "https://irc.freeq.at/av/moq?inst=ab12&jwt=tok"]
842- ["irc.freeq.at" nil "ab12" "https://irc.freeq.at/av/moq?inst=ab12"]
843- ["" nil nil nil]
844- [" " nil nil nil]]]
845- (doseq [[server jwt inst want] cases]
846- (let [got (dial/sfu-url server jwt inst)]
847- (when-not (= want got)
848- (throw (ex-info "sfu-url disagrees with the Rust it came from"
849- {:server server :jwt jwt :instance inst
850- :want want :got got})))))
851- (println " sfu-url:" (count cases) "cases agree"))
852-
853- (let [cases [["localhost:6667" nil true]
854- ["127.0.0.1:6667" nil true]
855- ["irc.freeq.at" nil false]
856- ["irc.freeq.at" "tok" true]
857- ["wss://irc.freeq.at/irc" nil false]
858- ["http://localhost:6667" nil true]]]
859- (doseq [[server jwt want] cases]
860- (let [got (dial/can-dial? server jwt)]
861- (when-not (= want got)
862- (throw (ex-info "can-dial? disagrees with the Rust it came from"
863- {:server server :jwt jwt :want want :got got})))))
864- (println " can-dial?:" (count cases) "cases agree"))
865-
866- (let [ids (repeatedly 200 dial/new-instance)]
867- (when-not (every? #(re-matches #"[0-9a-f]{8}" %) ids)
868- (throw (ex-info "instance ids are not eight hex characters"
869- {:sample (take 3 (remove #(re-matches #"[0-9a-f]{8}" %) ids))})))
870- ;; Two devices with the same id unpublish each other, so collisions are
871- ;; not a cosmetic concern.
872- (println " instances:" (count (distinct ids)) "distinct of" (count ids)))
873- true)
874-
875-(defn -main [& _]
876- (println "libmoq_ffi smoke test")
877- (let [steps [["contract" check-contract]
878- ["handle" check-handle]
879- ["string" check-string]
880- ["connect" check-connect]
881- ["media" check-media]
882- ["opus" check-opus]
883- ["h264" check-h264]
884- ["v4l2" check-v4l2-layouts]
885- ["alsa" check-alsa]
886- ["devices" check-enumeration]
887- ["plane" check-plane]
888- ["audio" check-audio]
889- ["session" check-session]
890- ["wired" check-wired-devices]
891- ["status" check-status]
892- ["dial" check-dial]]]
893- (doseq [[name f] steps]
894- (println (str name ":"))
895- (f))
896- (println "all ok")))
deleted file mode 100644
@@ -1,896 +0,0 @@
1-(ns frq.moq.smoke
2- "The smallest round trip that proves the generated bindings are real.
3-
4- Nothing in `frq.moq.*` has crossed the ABI until something does, and the
5- failure modes here are not the kind that announce themselves: a RustBuffer
6- whose fields are the wrong width reads as plausible garbage, and a handle
7- freed twice corrupts an allocator some minutes later rather than at the call.
8- So this checks, in order, the three things everything else assumes —
9-
10- 1. the contract version, which is the object saying it is the one the
11- bindings were generated from;
12- 2. a handle's whole life — construct a MoqClient, then free it — which is
13- the RustCallStatus out-parameter working in both directions;
14- 3. a string out and back, which is the RustBuffer layout and the
15- alloc/free ownership rule.
16-
17- 4. a connect, which is the only one of these that involves a tokio worker
18- and therefore the only one that exercises the continuation callback —
19- the piece with the most ways to be quietly wrong, since it runs on a
20- thread jolt never started.
21-
22- The connect is aimed at a port nothing is listening on. That is on purpose:
23- what is under test is the future protocol and the error path, and both of
24- those are the same whether the far side refuses or is simply not there —
25- where a real relay would make this test depend on somebody else's uptime.
26-
27- It takes THIRTY SECONDS, and that is not this code being slow. QUIC runs
28- over UDP, so a closed port produces no connection-refused to notice: there
29- is only the handshake timeout, which moq-native sets to 30s. The deadline
30- below has to sit above it, and a test that gave up at 15s reported a
31- continuation that had never fired when what had happened was that nothing
32- had gone wrong yet.
33-
34- just repl -m frq.moq.smoke"
35- (:require [clojure.string :as str]
36- [frq.moq.uniffi :as uniffi]
37- [frq.moq.raw :as raw]
38- [frq.moq.client :as client]
39- [frq.moq.media :as media]
40- [frq.codec.opus :as opus]
41- [frq.codec.h264 :as h264]
42- [frq.capture.v4l2 :as v4l2]
43- [frq.capture.alsa :as alsa]
44- [frq.capture.source :as source]
45- [frq.av.plane :as plane]
46- [frq.av.audio :as audio]
47- [frq.av.dial :as dial]
48- [jolt.ffi :as ffi]))
49-
50-(defn- check-contract []
51- (let [v (uniffi/check-abi!)]
52- (println " contract version:" v "(expected" uniffi/expected-contract ")")
53- true))
54-
55-(defn- check-handle
56- "Construct and free a MoqClient.
57-
58- `moqclient_new` is the one constructor that takes nothing but a status, so
59- it isolates the status protocol from any argument lowering. The free is not
60- a courtesy: it is the other half of the handle contract, and a binding that
61- cannot free is a binding that leaks a QUIC endpoint per call."
62- []
63- (let [h (uniffi/with-out-status #(raw/constructor-moqclient-new %))]
64- (println " moqclient_new ->" h)
65- (when (zero? h)
66- (throw (ex-info "moqclient_new answered a null handle" {})))
67- (uniffi/with-out-status #(raw/free-moqclient h %))
68- (println " free_moqclient ok")
69- true))
70-
71-(defn- check-string
72- "Send a string into a RustBuffer and read it back out.
73-
74- The value is chosen to catch the two mistakes a length-counted buffer
75- invites: multi-byte UTF-8 (a length in characters rather than bytes reads
76- short) and a trailing character (a NUL-terminated read runs past the end)."
77- []
78- (let [s "moq://relay.example/ünïcode/✓"]
79- (ffi/with-arena [a]
80- (let [buf (ffi/alloc a (ffi/layout-size uniffi/rust-buffer))]
81- (uniffi/lower-string buf s)
82- (let [len (ffi/read-field buf uniffi/rust-buffer [:len])
83- got (uniffi/lift-string buf)]
84- (println " lowered" (count s) "chars ->" len "bytes in the buffer")
85- (println " lifted " (pr-str got))
86- (when-not (= s got)
87- (throw (ex-info "string did not round trip"
88- {:sent s :got got})))
89- true)))))
90-
91-(defn- check-connect
92- "Connect to a closed port and watch the future fail.
93-
94- Everything interesting is in the loop: `poll-connect!` answers nil while the
95- far side has not settled, and each nil has issued another poll. That the
96- answer eventually changes at all is the continuation callback firing from a
97- tokio thread and this thread seeing it — which is the whole reason for the
98- atom in `frq.moq.uniffi/continuation`.
99-
100- A raise is the PASS here. What would be a failure is a hang (the
101- continuation never fires) or a success (a connect to a closed port cannot
102- succeed, so a session handle would mean the future protocol handed us
103- something that is not a session)."
104- []
105- (let [c (client/new-client)]
106- (try
107- (let [fut (client/connect! c "https://127.0.0.1:1/smoke")
108- deadline (+ (System/currentTimeMillis) 45000)]
109- (println " connect started, polling")
110- (loop [polls 0]
111- (cond
112- (> (System/currentTimeMillis) deadline)
113- (throw (ex-info "connect future never settled — the continuation did not fire"
114- {:polls polls}))
115-
116- :else
117- (let [r (try
118- {:ok (client/poll-connect! fut)}
119- (catch clojure.lang.ExceptionInfo e {:err e}))]
120- (cond
121- (:err r)
122- (let [d (ex-data (:err r))]
123- (println " settled after" polls "polls")
124- (println " variant:" (:variant d))
125- (println " message:" (pr-str (:message d)))
126- (when-not (:variant d)
127- (throw (ex-info "error carried no MoqError variant — the buffer did not decode"
128- {:data d})))
129- true)
130-
131- (:ok r)
132- (throw (ex-info "connect to a closed port answered a session"
133- {:handle (:ok r)}))
134-
135- :else
136- (do (Thread/sleep 20) (recur (inc polls))))))))
137- (finally
138- (client/free-client! c)
139- (println " free_moqclient ok")))))
140-
141-(defn- settle!
142- "Poll `fut` until it settles, or give up. Returns what it settled to.
143-
144- The polling is the point: every nil here has issued another poll, and this
145- is what a caller on the loop thread would be doing from a timer instead of
146- from a loop like this one."
147- ([fut what ms] (settle! fut what ms nil))
148- ([fut what ms lift]
149- ;; ms 0 means one look and no waiting — for a caller with its own loop.
150- (let [deadline (+ (System/currentTimeMillis) ms)]
151- (loop []
152- (cond
153- (uniffi/settled? fut) (if lift
154- (uniffi/complete! fut lift)
155- (uniffi/complete! fut))
156- (zero? ms) nil
157- (> (System/currentTimeMillis) deadline)
158- (throw (ex-info (str what ": future never settled") {:after-ms ms}))
159- :else (do (Thread/sleep 10) (recur)))))))
160-
161-(defn- check-media
162- "Put a payload into a broadcast and take the same payload out of it.
163-
164- Entirely in-process: an origin producer is a plain constructor, so the
165- broadcast a subscriber consumes here is the one the producer writes to, with
166- no QUIC in between. That exercises argument lowering (a top-level string, an
167- enum, an optional, a record), both subscribe paths, and the frame decode —
168- everything except the wire itself.
169-
170- TWO tracks, because they prove different things. `subscribe_media` is the
171- one frq.av will use, and it is checked as far as it can be checked here: a
172- media track declares a codec, and avc3 means the container really does try
173- to read Annex B out of whatever arrives, so a frame carrying the word hello
174- is never going to come back out of it. The opaque track beside it parses
175- nothing, which is what lets the payload round trip be an actual assertion
176- rather than a hope."
177- []
178- (let [origin (media/new-origin)
179- broadcast (media/create-broadcast! origin "/smoke")
180- producer (media/publish-media! broadcast "avc3")
181- track (media/producer-name producer)
182- consumer (media/broadcast-consumer broadcast)]
183- (println " origin, broadcast, media track:" (pr-str track))
184-
185- ;; The media path: subscribing is the assertion.
186- (let [mc (settle! (media/subscribe-media! consumer track :loc) "subscribe_media" 10000)]
187- (println " subscribe_media ->" mc)
188- (when (zero? mc)
189- (throw (ex-info "subscribe_media answered a null handle" {}))))
190-
191- ;; The opaque path: the payload is the assertion.
192- (let [tp (media/publish-track! broadcast "data")
193- tc (settle! (media/subscribe-track! consumer "data") "subscribe_track" 10000)]
194- (println " subscribe_track ->" tc)
195- (media/write-track-frame! tp "hello-from-a-frame" 1234567)
196- (println " wrote a frame")
197- (let [frame (settle! (media/read-frame! tc) "read_frame" 10000
198- media/lift-plain-frame)]
199- (println " got frame:" (pr-str frame))
200- (when-not frame
201- (throw (ex-info "track ended instead of delivering a frame" {})))
202- (when-not (= "hello-from-a-frame" (:payload frame))
203- (throw (ex-info "payload did not survive" {:frame frame})))
204- (when-not (= 1234567 (:timestamp-us frame))
205- (throw (ex-info "timestamp did not survive" {:frame frame})))
206- true))))
207-
208-(defn- triangle-pcm
209- "20ms of a triangle wave at 48kHz mono, as int16 in FOREIGN memory.
210-
211- A triangle rather than a sine because it needs no transcendental and no
212- Math namespace, and rather than silence because silence is the one input
213- Opus is entitled to throw away: DTX would answer two bytes and the round
214- trip would prove nothing.
215-
216- Answers [pointer samples-per-channel]."
217- [a]
218- (let [n 960 ; 48000 * 0.020
219- p (ffi/alloc a (* 2 n))]
220- (dotimes [i n]
221- ;; A 100-sample period ramp, +/- 8000 — loud enough that the decoder
222- ;; cannot answer silence and have it look like success.
223- (let [phase (mod i 100)
224- v (if (< phase 50) (- (* phase 320) 8000) (- 8000 (* (- phase 50) 320)))]
225- (ffi/write (+ p (* 2 i)) :int16 v)))
226- [p n]))
227-
228-(defn- check-opus
229- "Encode a frame with libopus and decode it back.
230-
231- This is the first piece of the port that is a C library rather than
232- somebody's binding to one, and the assertions are chosen to fail if it is
233- only pretending to work. The encoded frame must be more than DTX's two
234- bytes; the decode must answer exactly the frame size it was given; and the
235- decoded audio must carry real amplitude, because a decoder that returned
236- the right COUNT of zeroes would otherwise pass."
237- []
238- (ffi/with-arena [a]
239- (let [enc (opus/encoder 48000 1 :voip)
240- dec (opus/decoder 48000 1)]
241- (try
242- (opus/set-bitrate! enc 24000)
243- (let [[pcm n] (triangle-pcm a)
244- cap 4000
245- out (ffi/alloc a cap)
246- written (opus/encode! enc pcm n out cap)]
247- (println " encoded" n "samples ->" written "bytes")
248- (when (opus/dtx? written)
249- (throw (ex-info "encoder answered DTX for a loud frame"
250- {:written written})))
251- (let [back (ffi/alloc a (* 2 n))
252- decoded (opus/decode! dec out written back n)
253- peak (reduce (fn [m i]
254- (max m (abs (ffi/read (+ back (* 2 i)) :int16))))
255- 0 (range decoded))]
256- (println " decoded" decoded "samples, peak" peak)
257- (when-not (= n decoded)
258- (throw (ex-info "decoder answered a different frame size"
259- {:sent n :got decoded})))
260- (when (< peak 1000)
261- (throw (ex-info "decoded audio is silent — a lossy codec is not this lossy"
262- {:peak peak})))
263- true))
264- (finally
265- (opus/free-encoder! enc)
266- (opus/free-decoder! dec))))))
267-
268-(defn- check-h264
269- "Encode an I420 frame to H.264 through the openh264 shim.
270-
271- The assertion is the Annex B start code. A frame that came back with a
272- plausible length and the wrong bytes would be a vtable walked incorrectly —
273- the shim reading the wrong slot, or flattening the layers wrong — and a
274- length check alone would not catch it. 00 00 00 01 at the front, and an
275- IDR for the first frame, is openh264 having actually encoded something.
276-
277- The picture is flat gray, which compresses to almost nothing; what is under
278- test is the calling convention, not the encoder."
279- []
280- (ffi/with-arena [a]
281- (let [w 64 h 64
282- n (h264/i420-size w h)
283- px (ffi/alloc a n)
284- enc (h264/encoder {:width w :height h :fps 30 :bitrate 200000})]
285- (dotimes [i n] (ffi/write (+ px i) :uint8 0x80))
286- (try
287- (let [got (h264/encode! enc px 0
288- (fn [p len key?]
289- ;; The span is the encoder's buffer and dies
290- ;; at the next encode: read what is needed
291- ;; here and let it go.
292- {:len len
293- :keyframe key?
294- :first-4 (mapv #(ffi/read (+ p %) :uint8)
295- (range (min 4 len)))}))]
296- (println " encoded" n "bytes of I420 ->" (:len got)
297- "bytes, keyframe" (:keyframe got)
298- "starts" (pr-str (:first-4 got)))
299- (when (zero? (:len got))
300- (throw (ex-info "encoder skipped the first frame" {})))
301- (when-not (= [0 0 0 1] (:first-4 got))
302- (throw (ex-info "not Annex B — no start code" {:first-4 (:first-4 got)})))
303- (when-not (:keyframe got)
304- (throw (ex-info "first frame is not an IDR" {})))
305-
306- ;; And back again. A decode that answered the right SIZE full of
307- ;; the wrong pixels would pass a length check, so the assertion is
308- ;; the picture: flat gray in, flat gray out, opaque.
309- (let [dec (h264/decoder)]
310- (try
311- ;; The frame above was the IDR; this one would be a P-frame
312- ;; referencing it, and a decoder handed that first answers
313- ;; dsNoParamSets (16) — no SPS or PPS to decode against. A
314- ;; subscriber joining mid-call is in exactly that position,
315- ;; which is what force-keyframe! is for.
316- (h264/force-keyframe! enc)
317- (h264/encode! enc px 33333
318- (fn [p len _]
319- (h264/decode! dec p len
320- (fn [rgba dw dh]
321- (println " decoded ->" dw "x" dh "RGBA")
322- (when (or (zero? dw) (ffi/null? rgba))
323- (throw (ex-info "decoder produced no picture" {})))
324- (when-not (and (= w dw) (= h dh))
325- (throw (ex-info "decoded size does not match"
326- {:want [w h] :got [dw dh]})))
327- (let [px0 (mapv #(ffi/read (+ rgba %) :uint8) (range 4))
328- mid (* 4 (+ (* (quot dh 2) dw) (quot dw 2)))
329- pxm (mapv #(ffi/read (+ rgba mid %) :uint8) (range 4))]
330- (println " first pixel" (pr-str px0) "centre" (pr-str pxm))
331- (when-not (= 255 (nth px0 3))
332- (throw (ex-info "alpha is not opaque" {:pixel px0})))
333- ;; 0x80 luma with neutral chroma is mid gray; the
334- ;; BT.601 maths lands near 125, not exactly 128.
335- (doseq [c (take 3 pxm)]
336- (when-not (< 100 c 150)
337- (throw (ex-info "centre pixel is not gray"
338- {:pixel pxm})))))
339- true))))
340- (finally (h264/close-decoder! dec))))
341- true)
342- (finally (h264/close! enc))))))
343-
344-(defn- check-v4l2-layouts
345- "Check every V4L2 struct against what a C compiler says.
346-
347- There is no camera in this container, so the capture path itself cannot be
348- exercised here. What CAN be checked is the part most likely to be wrong and
349- least likely to announce it: every VIDIOC_ request number encodes the size
350- of the struct it carries, so a layout one byte off does not misread a field
351- — it produces a request the kernel has never heard of, and the driver
352- answers ENOTTY for an ioctl that plainly exists.
353-
354- The numbers on the right came from a C program compiled against this
355- kernel's own headers (scratch/v4l2probe.c). They are the ground truth this
356- namespace is transcribed from, so checking against them catches a typo
357- rather than a misunderstanding — the misunderstanding needs a device."
358- []
359- (let [checks [["v4l2_capability size" (ffi/layout-size v4l2/capability) 104]
360- [" capabilities@" (ffi/field-offset v4l2/capability [:capabilities]) 84]
361- [" device-caps@" (ffi/field-offset v4l2/capability [:device-caps]) 88]
362- ["v4l2_format size" (ffi/layout-size v4l2/format-pix) 208]
363- [" width@" (ffi/field-offset v4l2/format-pix [:width]) 8]
364- [" height@" (ffi/field-offset v4l2/format-pix [:height]) 12]
365- [" pixelformat@" (ffi/field-offset v4l2/format-pix [:pixelformat]) 16]
366- [" sizeimage@" (ffi/field-offset v4l2/format-pix [:sizeimage]) 28]
367- ["v4l2_requestbuffers size" (ffi/layout-size v4l2/requestbuffers) 20]
368- ["v4l2_buffer size" (ffi/layout-size v4l2/buffer) 88]
369- [" bytesused@" (ffi/field-offset v4l2/buffer [:bytesused]) 8]
370- [" timestamp@" (ffi/field-offset v4l2/buffer [:tv-sec]) 24]
371- [" memory@" (ffi/field-offset v4l2/buffer [:memory]) 60]
372- [" m.offset@" (ffi/field-offset v4l2/buffer [:offset]) 64]
373- [" length@" (ffi/field-offset v4l2/buffer [:length]) 72]]
374- bad (remove (fn [[_ got want]] (= got want)) checks)]
375- (doseq [[what got want] checks]
376- (println (str " " what " " got (when-not (= got want) (str " WANT " want)))))
377- (when (seq bad)
378- (throw (ex-info "V4L2 layouts do not match the kernel headers"
379- {:mismatched (mapv (fn [[w g want]] {:what w :got g :want want}) bad)})))
380- (println " (no camera here — the capture path itself is unexercised)")
381- true))
382-
383-(defn- check-alsa
384- "Open a PCM and push frames through it.
385-
386- The device is ALSA's `null` PCM, which swallows everything and is always
387- present — no hardware, no permissions, and nothing that depends on what
388- this container happens to have plugged in. What that proves is the binding:
389- the library loads, the handle out-parameter comes back, set_params accepts
390- the format, and writei answers in FRAMES. What it cannot prove is that a
391- real card behaves, which needs a real card.
392-
393- The frame count is the assertion. 960 frames of mono S16 is 1920 bytes, and
394- a writei that answered 1920 would be this code having confused the two —
395- the mistake the namespace docstring warns about, and the one that looks
396- like a slow device rather than a bug."
397- []
398- (ffi/with-arena [a]
399- (let [frames 960
400- buf (ffi/alloc a (* 2 frames))
401- pcm (alsa/open-pcm "null" :playback {:rate 48000 :channels 1})]
402- (try
403- (dotimes [i frames] (ffi/write (+ buf (* 2 i)) :int16 0))
404- (let [{:keys [frames written recovered]} (alsa/write! pcm buf frames)
405- n (or frames written)]
406- (println (str " null pcm: wrote " n " frames"
407- (when recovered " (recovered from an overrun)")))
408- (when-not (= 960 n)
409- (throw (ex-info "writei answered something other than the frame count"
410- {:asked 960 :got n})))
411- true)
412- (finally (alsa/close! pcm))))))
413-
414-(defn- check-enumeration
415- "List the devices, which is what av.clj's cameras/microphones/speakers are.
416-
417- There is no assertion on the CONTENTS: this container has no camera, and
418- which PCMs ALSA offers is a property of the machine rather than of this
419- code. What is asserted is that enumeration returns without leaking or
420- faulting and that every entry is shaped the way av.clj's `parse-devices`
421- produced them — an :id to pass back, a :name to show, a :default? flag —
422- because that shape is the contract the UI already reads."
423- []
424- (let [shaped? (fn [d] (and (string? (:id d)) (string? (:name d))
425- (contains? d :default?)))
426- cams (v4l2/devices)
427- mics (alsa/devices :capture)
428- outs (alsa/devices :playback)]
429- (println " cameras:" (count cams) (pr-str (mapv :name (take 2 cams))))
430- (println " capture:" (count mics) (pr-str (mapv :name (take 2 mics))))
431- (println " playback:" (count outs) (pr-str (mapv :name (take 2 outs))))
432- (doseq [[what ds] [["camera" cams] ["capture" mics] ["playback" outs]]]
433- (when-let [bad (first (remove shaped? ds))]
434- (throw (ex-info (str what " device is not shaped like av.clj expects")
435- {:device bad}))))
436- true))
437-
438-(defn- i420-halves
439- "An I420 frame whose left half is `lo` and right half `hi`.
440-
441- Flat frames are no use as a test: a decode that produced a uniform picture
442- would pass every check a flat frame can make. Two halves also give each
443- peer a distinguishable picture, which is what makes multi-peer testable —
444- if the frames were identical there would be no way to tell a second peer
445- from the first one counted twice."
446- [a w h lo hi]
447- (let [n (h264/i420-size w h)
448- p (ffi/alloc a n)]
449- (dotimes [y h]
450- (dotimes [x w]
451- (ffi/write (+ p (* y w) x) :uint8 (if (< x (quot w 2)) lo hi))))
452- (dotimes [i (* 2 (quot (* w h) 4))]
453- (ffi/write (+ p (* w h) i) :uint8 0x80))
454- [p n]))
455-
456-(defn- check-plane
457- "Two peers' video, end to end through frq.av.plane.
458-
459- Nothing is configured: the plane watches the origin for ANNOUNCEMENTS,
460- reads each peer's catalog for its video track name and container, and
461- subscribes. Our own broadcast is announced back like anyone else's and
462- becomes the self-view.
463-
464- The second broadcast stands in for another participant. It is published
465- the same way a remote client would publish it and discovered the same way
466- — the only thing it does not cross is the wire, which the connect check
467- already covers.
468-
469- Each picture has a different contrast so a peer cannot be confused for the
470- other, and the assertion is per peer: the right key, the right size, and
471- the halves the right way round."
472- []
473- (ffi/with-arena [a]
474- (let [w 64 h 64
475- [ours _] (i420-halves a w h 0x40 0xC0)
476- [theirs _] (i420-halves a w h 0xC0 0x40)
477- origin (media/new-origin)]
478- (plane/start! {:origin origin :path "/us" :source (fn [] [ours nil])
479- :width w :height h :fps 30 :bitrate 200000})
480- ;; A second participant on the same origin, published exactly as a
481- ;; remote one would be.
482- (let [b2 (media/create-broadcast! origin "/them")
483- p2 (media/publish-media! b2 "avc3")
484- enc (h264/encoder {:width w :height h :fps 30 :bitrate 200000})]
485- (try
486- (let [deadline (+ (System/currentTimeMillis) 25000)]
487- (loop [pumps 0 seen {}]
488- ;; Keep the other peer publishing: a subscriber that arrives
489- ;; after the first keyframe needs another one.
490- (h264/force-keyframe! enc)
491- (h264/encode! enc theirs (* pumps 33333)
492- (fn [p len _]
493- (when (pos? len)
494- (media/write-video-frame! p2 p len (* pumps 33333)))))
495- (plane/pump!)
496- (let [seen (reduce (fn [m f]
497- (if (contains? m (:key f))
498- m
499- (let [at #(ffi/read (+ (:rgba f) (* 4 (+ (* 32 (:w f)) %))) :uint8)]
500- (assoc m (:key f)
501- {:w (:w f) :h (:h f)
502- :left (at 8) :right (at 56)}))))
503- seen (plane/poll-frames!))]
504- (cond
505- (>= (count seen) 2)
506- (do
507- (doseq [[k v] seen]
508- (println " " k (str (:w v) "x" (:h v))
509- "left" (:left v) "right" (:right v)))
510- (when-not (contains? seen "__local__")
511- (throw (ex-info "no self-view" {:keys (keys seen)})))
512- (let [local (get seen "__local__")
513- other (val (first (dissoc seen "__local__")))]
514- (when-not (< (:left local) (:right local))
515- (throw (ex-info "self-view halves are the wrong way round"
516- {:frame local})))
517- (when-not (> (:left other) (:right other))
518- (throw (ex-info "peer halves are the wrong way round — the feeds are crossed"
519- {:frame other}))))
520- true)
521-
522- (> (System/currentTimeMillis) deadline)
523- (throw (ex-info "did not see two peers" {:pumps pumps :seen (keys seen)}))
524-
525- :else (do (Thread/sleep 10) (recur (inc pumps) seen))))))
526- (finally
527- (h264/close! enc)
528- (plane/stop!)))))))
529-
530-(defn- tone
531- "20ms of a triangle at `amp`, int16 mono, in foreign memory."
532- [a amp]
533- (let [n audio/frame-samples
534- p (ffi/alloc a (* 2 n))]
535- (dotimes [i n]
536- (let [phase (mod i 100)
537- v (if (< phase 50)
538- (- (* phase (quot (* 2 amp) 50)) amp)
539- (- amp (* (- phase 50) (quot (* 2 amp) 50))))]
540- (ffi/write (+ p (* 2 i)) :int16 v)))
541- [p n]))
542-
543-(defn- check-audio
544- "A voice through the plane: Opus -> MoQ -> jitter buffer -> mix.
545-
546- The voice belongs to a SECOND peer, and it has to: the mixer excludes our
547- own ring, because hearing your own microphone back is the thing headphones
548- exist to prevent. A test that published only its own audio would be
549- asserting on silence and calling it a bug.
550-
551- The assertion is amplitude. Opus is lossy and a triangle comes back
552- rounded, but silence is unmistakable — and silence is exactly what a
553- broken jitter buffer produces, whether it fills and never drains or drains
554- before it fills.
555-
556- The pump count matters too: the ring holds `target-depth` frames before it
557- plays anything, so a mix appearing on the first pump would mean the depth
558- was not being honoured."
559- []
560- (ffi/with-arena [a]
561- (let [[pcm _] (tone a 8000)
562- origin (media/new-origin)
563- head-p (ffi/alloc a 19)]
564- (audio/opus-head! head-p 1)
565- (plane/start! {:origin origin :path "/us"
566- :source (fn [] nil) ; no camera in this check
567- :mic (fn [] [pcm audio/frame-samples])
568- :width 64 :height 64 :channels 1})
569- (let [b2 (media/create-broadcast! origin "/them")
570- p2 (media/publish-media-bytes! b2 "opus" head-p 19)
571- enc (opus/encoder audio/sample-rate 1 :voip)
572- out (ffi/alloc a 4000)]
573- (try
574- (let [deadline (+ (System/currentTimeMillis) 25000)]
575- (loop [pumps 0]
576- ;; The other participant keeps talking.
577- (let [n (opus/encode! enc pcm audio/frame-samples out 4000)]
578- (when-not (opus/dtx? n)
579- (media/write-video-frame! p2 out n (* pumps 20000))))
580- (plane/pump!)
581- (let [mixed (plane/poll-audio!)]
582- (cond
583- (and mixed (pos? (:peak mixed)))
584- (do (println " mixed audio after" pumps "pumps, peak" (:peak mixed)
585- "over" (:samples mixed) "samples")
586- (when (< (:peak mixed) 500)
587- (throw (ex-info "the mix is effectively silent"
588- {:peak (:peak mixed)})))
589- (when (< pumps 2)
590- (throw (ex-info "played before the jitter buffer filled"
591- {:pumps pumps})))
592- true)
593-
594- (> (System/currentTimeMillis) deadline)
595- (throw (ex-info "no audio came back through the plane" {:pumps pumps}))
596-
597- :else (do (Thread/sleep 5) (recur (inc pumps)))))))
598- (finally
599- (opus/free-encoder! enc)
600- (plane/stop!)))))))
601-
602-(defn- check-session
603- "The plane over a real QUIC session, not a local origin.
604-
605- A relay is stood up in-process — MoqServer with a self-signed certificate
606- and one origin wired as both what it publishes and what it consumes, which
607- is what makes it a relay rather than two unrelated halves. A client dials
608- it over actual QUIC, and the plane runs on that session's publisher() and
609- consumer() rather than on an origin it made itself.
610-
611- What this proves that the loopback checks cannot: that publish and
612- discover being two DIFFERENT origins works, that a broadcast survives the
613- wire, and that the announcement comes back through the relay rather than
614- from an object we already had.
615-
616- Everything is polled, the relay included. Its accept has to be driven from
617- the same loop as the client's connect, because the client cannot finish
618- connecting until the relay accepts and there is no other thread to do it
619- on — which is a fair model of the real thing, where glimmer's timer is
620- the only clock this code gets."
621- []
622- (ffi/with-arena [a]
623- (let [w 64 h 64
624- [px _] (i420-halves a w h 0x40 0xC0)
625- relay (media/new-origin)
626- server (client/new-server)]
627- (client/server-bind! server "127.0.0.1:0")
628- (client/server-tls-generate! server ["localhost"])
629- (client/server-origin! server relay)
630- (let [addr (settle! (client/server-listen! server) "listen" 10000
631- uniffi/lift-string)
632- port (last (str/split addr #":"))
633- fps (client/server-fingerprints server)
634- c (client/new-client)]
635- (println " relay on" addr "fingerprint" (subs (first fps) 0 16))
636- (client/set-tls-fingerprints! c fps)
637- (let [connect (client/connect! c (str "https://localhost:" port "/room"))
638- incoming (client/server-accept! server)
639- deadline (+ (System/currentTimeMillis) 25000)]
640- (loop [req nil accepted nil sess nil]
641- (let [;; The relay side: an incoming request, then accept it.
642- req (or req (settle! incoming "accept" 0 media/lift-optional-handle))
643- accepted (or accepted (when req (client/accept-request! req)))
644- _ (when accepted (settle! accepted "request accept" 0 nil))
645- ;; The client side.
646- sess (or sess (settle! connect "connect" 0 nil))]
647- (cond
648- sess
649- (do
650- (println " connected over QUIC")
651- (try
652- (plane/start! {:origin (client/session-publisher sess)
653- :discover (client/session-consumer sess)
654- :session sess
655- :path "/us" :source (fn [] [px nil])
656- :width w :height h :fps 30 :bitrate 200000})
657- (let [d2 (+ (System/currentTimeMillis) 20000)]
658- (loop [pumps 0]
659- (plane/pump!)
660- (if-let [f (first (plane/poll-frames!))]
661- (do (println " frame back through the relay after" pumps
662- "pumps:" (:w f) "x" (:h f) (pr-str (:key f)))
663- (when-not (and (= w (:w f)) (= h (:h f)))
664- (throw (ex-info "wrong size over the wire" {:frame f})))
665- true)
666- (if (> (System/currentTimeMillis) d2)
667- (throw (ex-info "no frame came back over the session"
668- {:pumps pumps}))
669- (do (Thread/sleep 10) (recur (inc pumps)))))))
670- (finally
671- (plane/stop!)
672- (client/server-cancel! server))))
673-
674- (> (System/currentTimeMillis) deadline)
675- (throw (ex-info "the session never came up"
676- {:request req :accepted (some? accepted)}))
677-
678- :else (do (Thread/sleep 10) (recur req accepted sess))))))))))
679-
680-(defn- check-wired-devices
681- "The plane driven by real device objects rather than test thunks.
682-
683- ALSA's `null` on both ends, and that is a limit of this machine rather
684- than a choice: the sound hardware is held by PipeWire on the host, and
685- PipeWire's own socket is not reachable from inside this container, so
686- `default` and `hw:1,0` both refuse. `null` is a real PCM opened through
687- the real binding — it proves the wiring, the frame arithmetic and the
688- teardown, and it cannot prove that a microphone sounds like anything.
689-
690- The camera is not here at all: there is no /dev/video* and no privilege to
691- load the kernel's virtual one. `frq.capture.source/camera` is written and
692- unexercised, and this test says so rather than implying otherwise.
693-
694- What IS asserted: that a device-shaped mic drives the outbound half, that
695- a speaker sink is written to without raising, and that stop! releases
696- both. A leak here would show up as `Device or resource busy` on the second
697- run, which is why the check runs the whole cycle twice."
698- []
699- (dotimes [round 2]
700- (let [origin (media/new-origin)]
701- (plane/start! {:origin origin :path "/us"
702- :source (fn [] nil)
703- :mic-device "null"
704- :speaker-device "null"
705- :width 64 :height 64 :channels 1})
706- (try
707- (dotimes [_ 5] (plane/pump!))
708- (println (str " round " (inc round) ": mic and speaker opened, pumped, closed"))
709- (finally (plane/stop!)))))
710- ;; And the camera path as far as it goes on a machine with no camera:
711- ;; enumeration is empty and opening one raises rather than pretending.
712- (let [cams (v4l2/devices)]
713- (println " cameras available:" (count cams))
714- (when (seq cams)
715- (let [c (source/camera (:id (first cams)) {:width 640 :height 480})]
716- (println " opened" (:id (first cams)) (:width c) "x" (:height c))
717- ((:close! c)))))
718- true)
719-
720-(defn- check-status
721- "The three transitions frq.av reads: live, failed, ended.
722-
723- :live is asserted on a plain local plane — it carries has-camera? and
724- has-mic?, which is what the UI shows before a single frame arrives.
725-
726- :ended and :failed need a session to lose, so the relay from the session
727- check is stood up again and then CANCELLED underneath a running plane.
728- That is the case that matters: not a call the person hung up, but one
729- that went away, which is the whole reason poll-status! exists rather than
730- frq.av inferring things from frames stopping.
731-
732- Whether the drop reads as :ended or :failed depends on how the far side
733- goes — a relay cancelled mid-session may close cleanly or not — so both
734- are accepted here. What is NOT accepted is silence: a call that ends with
735- no transition at all is one where the person is left looking at a frozen
736- picture."
737- []
738- (ffi/with-arena [a]
739- ;; 1. :live, with the flags.
740- (let [origin (media/new-origin)]
741- (plane/start! {:origin origin :path "/us"
742- :source (fn [] nil) :mic (fn [] nil)
743- :width 64 :height 64 :channels 1})
744- (let [[ev] (plane/poll-status!)]
745- (println " live event:" (pr-str ev))
746- (when-not (= :live (:code ev))
747- (throw (ex-info "no :live on start" {:event ev})))
748- (when-not (and (:has-camera? ev) (:has-mic? ev))
749- (throw (ex-info "flags do not reflect the sources given" {:event ev})))
750- (when (seq (plane/poll-status!))
751- (throw (ex-info "poll-status! did not drain" {}))))
752- (plane/stop!))
753-
754- ;; 2. a session lost underneath us.
755- (let [[px _] (i420-halves a 64 64 0x40 0xC0)
756- relay (media/new-origin)
757- server (client/new-server)]
758- (client/server-bind! server "127.0.0.1:0")
759- (client/server-tls-generate! server ["localhost"])
760- (client/server-origin! server relay)
761- (let [addr (settle! (client/server-listen! server) "listen" 10000
762- uniffi/lift-string)
763- port (last (str/split addr #":"))
764- fps (client/server-fingerprints server)
765- c (client/new-client)]
766- (client/set-tls-fingerprints! c fps)
767- (let [connect (client/connect! c (str "https://localhost:" port "/room"))
768- incoming (client/server-accept! server)
769- deadline (+ (System/currentTimeMillis) 25000)]
770- (loop [req nil accepted nil srv nil sess nil]
771- (let [req (or req (settle! incoming "accept" 0 media/lift-optional-handle))
772- accepted (or accepted (when req (client/accept-request! req)))
773- ;; The relay's OWN side of the session, kept rather than
774- ;; dropped: cancelling the server only stops it accepting
775- ;; new connections, and an established QUIC session then
776- ;; sits there until its idle timeout — half a minute of
777- ;; a frozen picture. What a peer hanging up actually
778- ;; looks like is this session being cancelled.
779- srv (or srv (when accepted
780- (settle! accepted "request accept" 0 nil)))
781- sess (or sess (settle! connect "connect" 0 nil))]
782- (cond
783- (and sess srv)
784- (do
785- (plane/start! {:origin (client/session-publisher sess)
786- :discover (client/session-consumer sess)
787- :session sess
788- :path "/us" :source (fn [] [px nil])
789- :width 64 :height 64 :fps 30 :bitrate 200000})
790- (plane/poll-status!) ; drain the :live
791- (dotimes [_ 10] (plane/pump!) (Thread/sleep 10))
792- (println " dropping the far side under a live plane")
793- (client/cancel! srv 0)
794- (client/server-cancel! server)
795- (let [d2 (+ (System/currentTimeMillis) 20000)]
796- (loop [pumps 0]
797- (plane/pump!)
798- (let [evs (plane/poll-status!)]
799- (cond
800- (seq evs)
801- (do (println " after the drop:" (pr-str evs))
802- (when-not (some #{:ended :failed} (map :code evs))
803- (throw (ex-info "the drop produced no ending"
804- {:events evs})))
805- (plane/stop!)
806- true)
807-
808- (> (System/currentTimeMillis) d2)
809- (do (plane/stop!)
810- (throw (ex-info "the session went away silently"
811- {:pumps pumps})))
812-
813- :else (do (Thread/sleep 10) (recur (inc pumps))))))))
814-
815- (> (System/currentTimeMillis) deadline)
816- (throw (ex-info "the session never came up" {}))
817-
818- :else (do (Thread/sleep 10) (recur req accepted srv sess))))))))))
819-
820-(defn- check-dial
821- "The URL rules, transcribed out of Rust and worth checking case by case.
822-
823- These were `joltmoq_sfu_url` and `joltmoq_can_dial`, and they are the one
824- part of the port that is pure logic rather than a binding — which means
825- they are also the one part where a wrong answer is silent. A URL built
826- slightly wrong does not fail to compile; it dials somewhere that is not
827- there, and the person is told the call timed out.
828-
829- The port dropping is the case worth staring at: `irc.freeq.at:6697`
830- becomes `https://irc.freeq.at/av/moq` with no port, because 6697 is the
831- IRC port and the SFU is not on it — but an absolute URL keeps its
832- authority as written, since someone who put a port in a URL meant it."
833- []
834- (let [cases
835- [["irc.freeq.at:6697" nil nil "https://irc.freeq.at/av/moq"]
836- ["irc.freeq.at" nil nil "https://irc.freeq.at/av/moq"]
837- ["wss://irc.freeq.at/irc" nil nil "https://irc.freeq.at/av/moq"]
838- ["ws://localhost:6667" nil nil "http://localhost:6667/av/moq"]
839- ["localhost:6667" nil nil "http://localhost:6667/av/moq"]
840- ["127.0.0.1:6667" nil nil "http://127.0.0.1:6667/av/moq"]
841- ["irc.freeq.at:6697" "tok" "ab12" "https://irc.freeq.at/av/moq?inst=ab12&jwt=tok"]
842- ["irc.freeq.at" nil "ab12" "https://irc.freeq.at/av/moq?inst=ab12"]
843- ["" nil nil nil]
844- [" " nil nil nil]]]
845- (doseq [[server jwt inst want] cases]
846- (let [got (dial/sfu-url server jwt inst)]
847- (when-not (= want got)
848- (throw (ex-info "sfu-url disagrees with the Rust it came from"
849- {:server server :jwt jwt :instance inst
850- :want want :got got})))))
851- (println " sfu-url:" (count cases) "cases agree"))
852-
853- (let [cases [["localhost:6667" nil true]
854- ["127.0.0.1:6667" nil true]
855- ["irc.freeq.at" nil false]
856- ["irc.freeq.at" "tok" true]
857- ["wss://irc.freeq.at/irc" nil false]
858- ["http://localhost:6667" nil true]]]
859- (doseq [[server jwt want] cases]
860- (let [got (dial/can-dial? server jwt)]
861- (when-not (= want got)
862- (throw (ex-info "can-dial? disagrees with the Rust it came from"
863- {:server server :jwt jwt :want want :got got})))))
864- (println " can-dial?:" (count cases) "cases agree"))
865-
866- (let [ids (repeatedly 200 dial/new-instance)]
867- (when-not (every? #(re-matches #"[0-9a-f]{8}" %) ids)
868- (throw (ex-info "instance ids are not eight hex characters"
869- {:sample (take 3 (remove #(re-matches #"[0-9a-f]{8}" %) ids))})))
870- ;; Two devices with the same id unpublish each other, so collisions are
871- ;; not a cosmetic concern.
872- (println " instances:" (count (distinct ids)) "distinct of" (count ids)))
873- true)
874-
875-(defn -main [& _]
876- (println "libmoq_ffi smoke test")
877- (let [steps [["contract" check-contract]
878- ["handle" check-handle]
879- ["string" check-string]
880- ["connect" check-connect]
881- ["media" check-media]
882- ["opus" check-opus]
883- ["h264" check-h264]
884- ["v4l2" check-v4l2-layouts]
885- ["alsa" check-alsa]
886- ["devices" check-enumeration]
887- ["plane" check-plane]
888- ["audio" check-audio]
889- ["session" check-session]
890- ["wired" check-wired-devices]
891- ["status" check-status]
892- ["dial" check-dial]]]
893- (doseq [[name f] steps]
894- (println (str name ":"))
895- (f))
896- (println "all ok")))
deleted src/frq/moq/uniffi.clj +0 -473
deleted file mode 100644
@@ -1,473 +0,0 @@
1-(ns frq.moq.uniffi
2- "The UniFFI ABI, in jolt — the substrate every `frq.moq.*` binding sits on.
3-
4- `libmoq_ffi` is not a hand-written C library. It is what UniFFI generates,
5- and its shape is the same one UniFFI's Swift and Kotlin backends consume:
6-
7- * **Values cross as `RustBuffer`** — a {capacity, len, data} triple, passed
8- and returned BY VALUE. A string is its UTF-8 bytes in one; so is a lowered
9- error. The buffer is Rust-owned and must go back through `rustbuffer_free`.
10- * **Errors are an out-parameter.** Every fallible entry point takes a
11- trailing `RustCallStatus *`, and the `code` byte in it says what happened:
12- 0 succeeded, 1 threw (the lowered error is in `errorBuf`), 2 panicked (a
13- message is in `errorBuf`).
14- * **Objects are handles**, not pointers — a `uint64_t` with `clone_*` and
15- `free_*` around it. A handle is cloned before each method call and freed
16- once, which is what UniFFI's generated code does and why doing it by hand
17- is the part to get right.
18- * **Async is a future to be polled.** `connect` and friends return a future
19- handle rather than a value: poll it with a continuation callback, and when
20- the callback reports READY, `complete` it for the result and `free` it.
21-
22- Two facts about the far side shape the async half here. The continuation is
23- invoked from a tokio worker — a thread jolt did not start — so it is built in
24- an `auto-arena` and marked `:collect-safe`, which is exactly the case that
25- arena documents. And `frq.av`'s contract is that nothing calls back into the
26- UI: the continuation therefore does the smallest possible thing, flipping a
27- flag, and `settled?` is what a caller on the loop thread asks.
28-
29- The version guard is not optional. UniFFI writes a contract version and a
30- per-method checksum into the object, and a `libmoq_ffi` built from a
31- different revision will have the same symbol names with different layouts
32- behind them. `check-abi!` is called once at load; a mismatch is a hard error
33- rather than a corrupted read some minutes into a call."
34- (:require [jolt.ffi :as ffi]
35- [frq.moq.raw :as raw]))
36-
37-;; --- the primitives ----------------------------------------------------------
38-;; UNIFFI_SHARED_HEADER_V4. These three are stable across uniffied crates —
39-;; the header carries a guard that refuses to mix versions, and these layouts
40-;; are that version's.
41-
42-(def rust-buffer
43- (ffi/layout [:struct [[:capacity :uint64]
44- [:len :uint64]
45- [:data :pointer]]]))
46-
47-(def foreign-bytes
48- (ffi/layout [:struct [[:len :int32]
49- [:data :pointer]]]))
50-
51-(def rust-call-status
52- (ffi/layout [:struct [[:code :int8]
53- [:error-buf [:struct [[:capacity :uint64]
54- [:len :uint64]
55- [:data :pointer]]]]]]))
56-
57-;; `code` in a RustCallStatus.
58-(def ^:const status-ok 0)
59-(def ^:const status-error 1)
60-(def ^:const status-panic 2)
61-
62-;; What a continuation is handed. READY means `complete` will not block;
63-;; MAYBE-READY means poll again.
64-(def ^:const poll-ready 0)
65-(def ^:const poll-maybe-ready 1)
66-
67-;; --- the entry points -------------------------------------------------------
68-;; Not redeclared here. `frq.moq.raw` is generated from the object's own
69-;; embedded metadata and already declares all 403 of them, including these —
70-;; so this namespace binds behaviour to them rather than restating signatures
71-;; that a regeneration could move underneath it.
72-
73-;; --- errors ------------------------------------------------------------------
74-
75-;; Forward: `with-out-status` expands to a call to this, and jolt resolves the
76-;; expansion when it ANALYSES it rather than when it runs.
77-(declare throw-status!)
78-
79-(defmacro with-out-status
80- "Run `f` with a zeroed RustCallStatus, then raise if it came back non-ok.
81-
82- Every fallible entry point in the object takes one of these as its last
83- argument, so this is the shape nearly every call in `frq.moq.*` wears. The
84- status is confined: it lives exactly as long as the call."
85- [f]
86- `(ffi/with-arena [a#]
87- (let [s# (ffi/alloc a# (ffi/layout-size rust-call-status))]
88- (ffi/write s# rust-call-status {:code status-ok
89- :error-buf {:capacity 0 :len 0
90- :data ffi/null}})
91- (let [v# (~f s#)]
92- (if (= status-ok (ffi/read-field s# rust-call-status [:code]))
93- v#
94- (throw-status! s#))))))
95-
96-;; The variant table lives in `frq.moq.raw`, generated beside the entry points.
97-;; It was hand-written here once and that was a mistake worth recording: the
98-;; variants are INSERTED into, not appended to — turning moq-ffi's audio and
99-;; video features on adds Audio and Video at 5 and 6 and shifts every later
100-;; variant down two. A stale table does not answer "unknown variant"; it
101-;; answers a confident, wrong name for a real one, and the message beside it
102-;; still reads correctly, so nothing looks broken.
103-
104-;; UniFFI serialises into a RustBuffer BIG-ENDIAN, which is not this machine's
105-;; order — so these read byte by byte rather than through ffi/read, whose
106-;; integer types are native. Getting this wrong does not fail loudly: a
107-;; little-endian read of variant 1 is 16777216, which is simply no variant at
108-;; all, and the message after it would be read at the wrong offset.
109-(defn- be-u32 [p off]
110- (let [b #(ffi/read (+ p off %) :uint8)]
111- (+ (* (b 0) 16777216) (* (b 1) 65536) (* (b 2) 256) (b 3))))
112-
113-(defn- decode-error
114- "Lift a lowered MoqError out of an error buffer.
115-
116- The shape is UniFFI's for a flat error: an i32 variant index, then the
117- message as an i32 byte length and that many UTF-8 bytes. Every MoqError
118- variant carries exactly one string, which is what makes this one shape
119- rather than a table of them.
120-
121- A buffer too short to hold even the header is reported as-is rather than
122- read past — an error path is the worst place to add a second fault."
123- [data len]
124- (if (< len 8)
125- {:variant :moq/malformed :message nil}
126- (let [variant (be-u32 data 0)
127- n (be-u32 data 4)
128- n (min n (- len 8))]
129- {:variant (get raw/moq-error-variants variant :moq/unknown)
130- :message (when (pos? n) (ffi/read-bytes (+ data 8) n))})))
131-
132-(defn- status-message
133- "Read and RELEASE the lowered error sitting in a non-ok status.
134-
135- Both `error` and `panic` put a RustBuffer in `errorBuf`, and both are the
136- caller's to free — the difference is only what is inside. A panic's buffer is
137- a bare UTF-8 message; an error's is a lowered `MoqError`, and `decode-error`
138- above lifts that into a variant keyword and its message. MoqError is the one
139- error type every entry point in this object raises — UniFFI says so in as
140- many words — so knowing its shape here costs no generality.
141-
142- The free goes through a status of its own rather than `with-out-status`: this
143- is already the error path, and a raise from freeing an error buffer would
144- lose the error that got us here."
145- [status-ptr]
146- (let [code (ffi/read-field status-ptr rust-call-status [:code])
147- len (ffi/read-field status-ptr rust-call-status [:error-buf :len])
148- data (ffi/read-field status-ptr rust-call-status [:error-buf :data])
149- text (when (and (pos? len) (not (ffi/null? data)))
150- (if (= code status-panic)
151- ;; A panic's buffer is a bare message, not a lowered value.
152- ;; Not ptr->string: it is length-counted and may hold an
153- ;; interior zero.
154- {:variant :moq/panic :message (ffi/read-bytes data len)}
155- (decode-error data len)))]
156- (ffi/with-arena [a]
157- (let [free-status (ffi/alloc a (ffi/layout-size rust-call-status))]
158- (ffi/write free-status rust-call-status
159- {:code status-ok
160- :error-buf {:capacity 0 :len 0 :data ffi/null}})
161- ;; The address of the errorBuf member, not a `place`: a by-value
162- ;; aggregate argument is a POINTER to the struct bytes, and `place`
163- ;; resolves a member's type for read/write rather than its address.
164- (raw/rustbuffer-free (+ status-ptr
165- (ffi/field-offset rust-call-status [:error-buf]))
166- free-status)))
167- text))
168-
169-(defn- throw-status! [status-ptr]
170- (let [code (ffi/read-field status-ptr rust-call-status [:code])
171- msg (status-message status-ptr)]
172- (throw (ex-info (if (= code status-panic)
173- (str "libmoq_ffi panicked: " (:message msg))
174- (str "libmoq_ffi: " (name (:variant msg :moq/none))
175- (when-let [m (:message msg)] (str " — " m))))
176- {:code code
177- :variant (:variant msg)
178- :message (:message msg)}))))
179-
180-;; --- strings -----------------------------------------------------------------
181-
182-(defn lower-string
183- "Copy `s` into a Rust-owned RustBuffer, written into `dest`.
184-
185- UniFFI lowers a bare String as its UTF-8 bytes with no length prefix — the
186- buffer's own `len` is the length. `from_bytes` copies, so the ForeignBytes
187- jolt hands over may live in a confined arena and die with this call.
188-
189- The bytes come from `string->ptr`, which encodes UTF-8 and records the size
190- it allocated; that size counts the NUL it appends, which is why the length
191- handed to Rust is one less. A NUL is not part of the value here — a
192- RustBuffer is length-counted, and including the terminator would append a
193- zero byte to every string that crossed."
194- [dest s]
195- (ffi/with-arena [a]
196- (let [p (ffi/string->ptr a s)
197- n (max 0 (dec (ffi/size p)))
198- fbs (ffi/alloc a (ffi/layout-size foreign-bytes))]
199- (ffi/write fbs foreign-bytes {:len n :data p})
200- (with-out-status #(raw/rustbuffer-from-bytes dest fbs %))
201- dest)))
202-
203-(defn lift-string
204- "Read a returned RustBuffer as a string and free it.
205-
206- `read-bytes` decodes UTF-8 itself, so the length-counted bytes become a jolt
207- string in one block move — no NUL is looked for, which is what a buffer that
208- may hold an interior zero needs.
209-
210- The buffer is Rust-owned, so it goes back through `rustbuffer_free` whether
211- or not it held anything: a zero-length buffer still carries a capacity."
212- [rb-ptr]
213- (let [len (ffi/read-field rb-ptr rust-buffer [:len])
214- data (ffi/read-field rb-ptr rust-buffer [:data])
215- s (if (and (pos? len) (not (ffi/null? data)))
216- (ffi/read-bytes data len)
217- "")]
218- (with-out-status #(raw/rustbuffer-free rb-ptr %))
219- s))
220-
221-;; --- the buffer codec -------------------------------------------------------
222-;; Anything that is not a scalar or a handle crosses as a RustBuffer holding
223-;; UniFFI's own serialisation: BIG-ENDIAN fixed-width integers, a bool as one
224-;; byte, bytes and strings as an i32 length followed by that many bytes, and an
225-;; Optional as a 0/1 flag byte followed by the value when present.
226-;;
227-;; Both directions are written out by hand here rather than reusing `read`
228-;; and `write`: jolt's integer types are NATIVE-endian, and this format is not.
229-
230-(defn- be-u64 [p off]
231- (let [b #(ffi/read (+ p off %) :uint8)]
232- (loop [i 0 acc 0]
233- (if (= i 8) acc (recur (inc i) (+ (* acc 256) (b i)))))))
234-
235-(defn reader
236- "A cursor over a RustBuffer's bytes."
237- [data len]
238- (atom {:data data :len len :off 0}))
239-
240-(defn- take! [c n]
241- (let [{:keys [off len]} @c]
242- (when (> (+ off n) len)
243- (throw (ex-info "read past the end of a RustBuffer"
244- {:off off :want n :len len})))
245- (swap! c update :off + n)
246- (+ (:data @c) off)))
247-
248-(defn r-u8! [c] (ffi/read (take! c 1) :uint8))
249-(defn r-bool! [c] (not (zero? (r-u8! c))))
250-(defn r-i32! [c] (let [p (take! c 4)] (be-u32 p 0)))
251-(defn r-u64! [c] (let [p (take! c 8)] (be-u64 p 0)))
252-
253-(defn r-string!
254- "An i32 byte length, then that many UTF-8 bytes."
255- [c]
256- (let [n (r-i32! c)]
257- (if (zero? n) "" (ffi/read-bytes (take! c n) n))))
258-
259-(defn r-bytes-span!
260- "An i32 byte length, then that many RAW bytes — answered as a BORROWED span,
261- {:ptr :len}, not copied.
262-
263- `r-string!` is the wrong tool for a payload: it decodes UTF-8, and H.264 is
264- not text. It is also the wrong SHAPE. frq.av's rule is that a video frame
265- goes from the decoder's buffer to the texture as a pointer and never becomes
266- a jolt value, so what belongs here is the address, valid exactly as long as
267- the RustBuffer it points into."
268- [c]
269- (let [n (r-i32! c)]
270- {:ptr (take! c n) :len n}))
271-
272-(defn r-f64!
273- "An IEEE 754 double, big-endian like everything else in a RustBuffer."
274- [c]
275- (let [bits (r-u64! c)]
276- (Double/longBitsToDouble (if (>= bits 9223372036854775808)
277- (- bits 18446744073709551616)
278- bits))))
279-
280-(defn r-map!
281- "An i32 count, then that many key/value pairs. Keys are strings."
282- [c read-value]
283- (let [n (r-i32! c)]
284- (loop [i 0 acc {}]
285- (if (= i n)
286- acc
287- (let [k (r-string! c)]
288- (recur (inc i) (assoc acc k (read-value c))))))))
289-
290-(defn r-list!
291- "An i32 count, then that many items."
292- [c read-item]
293- (let [n (r-i32! c)]
294- (mapv (fn [_] (read-item c)) (range n))))
295-
296-(defn r-optional!
297- "A flag byte, then `f` when it is set."
298- [c f]
299- (when (= 1 (r-u8! c)) (f c)))
300-
301-;; -- writing --
302-
303-(defn lower-buffer
304- "Serialise `ops` and hand the bytes to Rust as a RustBuffer in `dest`.
305-
306- `ops` is a sequence of [type value] pairs in wire order, e.g.
307-
308- [[:i32 3] [:u8 0]] ; MoqContainer::LOC, then Optional::None
309-
310- A :bytes op takes [pointer length] and copies those bytes as they are, which
311- is how a raw frame crosses without becoming a jolt string on the way.
312-
313- Strings are materialised FIRST, in the arena, because their wire length is a
314- UTF-8 byte count and jolt will only tell us one by encoding the string — so
315- the total size is not known until every string has been. `string->ptr`
316- records the size it allocated, which counts the NUL it appends; the wire
317- length is one less, since a length-counted string carries no terminator.
318-
319- `from_bytes` copies, so the scratch this builds in may die with the call."
320- [dest ops]
321- (ffi/with-arena [a]
322- (let [;; [op value encoded-pointer byte-count]
323- prepared (mapv (fn [[op v]]
324- (case op
325- :string (let [p (ffi/string->ptr a v)]
326- [op v p (max 0 (dec (ffi/size p)))])
327- ;; [pointer length], copied straight out of
328- ;; foreign memory — never through a jolt value.
329- :bytes [op v (first v) (second v)]
330- [op v nil nil]))
331- ops)
332- n (reduce (fn [n [op _ _ k]]
333- (+ n (case op
334- (:u8 :bool) 1
335- (:i32 :u32) 4
336- :u64 8
337- ;; Both carry an i32 length and then k bytes.
338- (:string :bytes) (+ 4 k))))
339- 0 prepared)
340- buf (ffi/alloc a (max n 1))
341- fbs (ffi/alloc a (ffi/layout-size foreign-bytes))
342- put-int! (fn [off width v]
343- (dotimes [i width]
344- (ffi/write (+ buf off i) :uint8
345- (bit-and (bit-shift-right v (* 8 (- width 1 i)))
346- 255))))]
347- (loop [off 0 todo (seq prepared)]
348- (when todo
349- (let [[op v p k] (first todo)]
350- (recur
351- (case op
352- (:u8 :bool)
353- (do (ffi/write (+ buf off) :uint8
354- (if (= op :bool) (if v 1 0) v))
355- (inc off))
356-
357- (:i32 :u32) (do (put-int! off 4 v) (+ off 4))
358- :u64 (do (put-int! off 8 v) (+ off 8))
359- (:string :bytes)
360- (do (put-int! off 4 k)
361- (when (pos? k) (ffi/copy p (+ buf off 4) k))
362- (+ off 4 k)))
363- (next todo)))))
364- (ffi/write fbs foreign-bytes {:len n :data buf})
365- (with-out-status #(raw/rustbuffer-from-bytes dest fbs %))
366- dest)))
367-
368-;; --- futures -----------------------------------------------------------------
369-
370-(defn- continuation
371- "A `void (uint64_t data, int8_t poll_result)` for the far side to invoke.
372-
373- Built in the auto-arena and `:collect-safe` on purpose: tokio calls this from
374- a worker thread jolt never started, and no lexical scope on this side is the
375- callback's lifetime — which is the case `ffi/auto-arena` exists for.
376-
377- It does as little as a callback can. Recording the poll result into an atom
378- is the whole body; nothing here touches glimmer, allocates a jolt value that
379- outlives the call, or re-enters the object. `frq.av`'s rule that nothing
380- calls back into the UI survives because the UI is never on this path — a
381- caller on the loop thread reads the atom instead."
382- [state]
383- (ffi/callback (ffi/auto-arena)
384- (fn [_data result] (reset! state (long result)))
385- [:uint64 :int8] :void
386- :collect-safe))
387-
388-(defn start-future
389- "Begin polling `handle`, and answer a map the loop thread can interrogate.
390-
391- `kind` picks the width family — :u64 for an object or integer result, :void
392- for one that resolves to nothing, :rb for a value that arrives as a buffer.
393- The future is NOT awaited here: `settled?` says whether it is ready and
394- `complete!` takes the result, so a caller drives this from the same timer
395- that drives `frq.av/pump!` rather than blocking the loop."
396- [handle kind]
397- (let [state (atom nil)
398- cb (continuation state)
399- poll (case kind
400- :u64 raw/rust-future-poll-u64
401- :void raw/rust-future-poll-void
402- :rb raw/rust-future-poll-rust-buffer)]
403- (poll handle cb 0)
404- {:handle handle :kind kind :state state :callback cb :poll poll}))
405-
406-(defn settled?
407- "Has the continuation reported READY?
408-
409- MAYBE-READY means the far side wants another poll, which this issues and
410- then answers false — so calling `settled?` from a timer is what advances a
411- future, and there is no thread on this side waiting on one."
412- [{:keys [handle state poll callback]}]
413- (let [r @state]
414- (cond
415- (nil? r) false
416- (= r poll-ready) true
417- (= r poll-maybe-ready) (do (reset! state nil)
418- (poll handle callback 0)
419- false)
420- :else false)))
421-
422-(defn complete!
423- "Take a settled future's result and release it.
424-
425- Only valid once `settled?` has answered true — completing early is what
426- blocks the calling thread, which on the loop thread is the freeze this whole
427- polling shape exists to avoid. The future handle is freed either way, so a
428- raising `complete` still does not leak one.
429-
430- An :rb future REQUIRES a `lift` function, and it is called while the buffer
431- cell is still alive. The cell is arena memory whose lifetime is this call:
432- handing the pointer back to a caller to read afterwards reads memory that
433- has already been released, and what comes back is not a fault but a
434- RustBuffer whose length no longer matches its capacity — which the object
435- then panics on, some way from the mistake."
436- ([fut]
437- (let [{:keys [kind]} fut]
438- (when (= kind :rb)
439- (throw (ex-info "an :rb future needs a lift function — see complete!"
440- {:kind kind})))
441- (complete! fut nil)))
442- ([{:keys [handle kind]} lift]
443- (try
444- (case kind
445- :u64 (with-out-status #(raw/rust-future-complete-u64 handle %))
446- :void (do (with-out-status #(raw/rust-future-complete-void handle %)) nil)
447- :rb (ffi/with-arena [a]
448- (let [out (ffi/alloc a (ffi/layout-size rust-buffer))]
449- (with-out-status #(raw/rust-future-complete-rust-buffer out handle %))
450- (lift out))))
451- (finally
452- (case kind
453- :u64 (raw/rust-future-free-u64 handle)
454- :void (raw/rust-future-free-void handle)
455- :rb (raw/rust-future-free-rust-buffer handle))))))
456-
457-;; --- the version guard -------------------------------------------------------
458-
459-;; What this file was written against: moq-ffi 0.3.17, UniFFI contract 30.
460-;; Both are asserted rather than assumed — see the namespace docstring on why a
461-;; silent mismatch is the failure mode worth spending a startup check on.
462-(def ^:const expected-contract 30)
463-
464-(defn check-abi!
465- "Refuse a `libmoq_ffi` this file was not written against."
466- []
467- (let [v (raw/uniffi-contract-version)]
468- (when-not (= v expected-contract)
469- (throw (ex-info (str "libmoq_ffi UniFFI contract " v
470- ", expected " expected-contract
471- " — frq.moq.* was generated against moq-ffi 0.3.17")
472- {:found v :expected expected-contract})))
473- v))
deleted file mode 100644
@@ -1,473 +0,0 @@
1-(ns frq.moq.uniffi
2- "The UniFFI ABI, in jolt — the substrate every `frq.moq.*` binding sits on.
3-
4- `libmoq_ffi` is not a hand-written C library. It is what UniFFI generates,
5- and its shape is the same one UniFFI's Swift and Kotlin backends consume:
6-
7- * **Values cross as `RustBuffer`** — a {capacity, len, data} triple, passed
8- and returned BY VALUE. A string is its UTF-8 bytes in one; so is a lowered
9- error. The buffer is Rust-owned and must go back through `rustbuffer_free`.
10- * **Errors are an out-parameter.** Every fallible entry point takes a
11- trailing `RustCallStatus *`, and the `code` byte in it says what happened:
12- 0 succeeded, 1 threw (the lowered error is in `errorBuf`), 2 panicked (a
13- message is in `errorBuf`).
14- * **Objects are handles**, not pointers — a `uint64_t` with `clone_*` and
15- `free_*` around it. A handle is cloned before each method call and freed
16- once, which is what UniFFI's generated code does and why doing it by hand
17- is the part to get right.
18- * **Async is a future to be polled.** `connect` and friends return a future
19- handle rather than a value: poll it with a continuation callback, and when
20- the callback reports READY, `complete` it for the result and `free` it.
21-
22- Two facts about the far side shape the async half here. The continuation is
23- invoked from a tokio worker — a thread jolt did not start — so it is built in
24- an `auto-arena` and marked `:collect-safe`, which is exactly the case that
25- arena documents. And `frq.av`'s contract is that nothing calls back into the
26- UI: the continuation therefore does the smallest possible thing, flipping a
27- flag, and `settled?` is what a caller on the loop thread asks.
28-
29- The version guard is not optional. UniFFI writes a contract version and a
30- per-method checksum into the object, and a `libmoq_ffi` built from a
31- different revision will have the same symbol names with different layouts
32- behind them. `check-abi!` is called once at load; a mismatch is a hard error
33- rather than a corrupted read some minutes into a call."
34- (:require [jolt.ffi :as ffi]
35- [frq.moq.raw :as raw]))
36-
37-;; --- the primitives ----------------------------------------------------------
38-;; UNIFFI_SHARED_HEADER_V4. These three are stable across uniffied crates —
39-;; the header carries a guard that refuses to mix versions, and these layouts
40-;; are that version's.
41-
42-(def rust-buffer
43- (ffi/layout [:struct [[:capacity :uint64]
44- [:len :uint64]
45- [:data :pointer]]]))
46-
47-(def foreign-bytes
48- (ffi/layout [:struct [[:len :int32]
49- [:data :pointer]]]))
50-
51-(def rust-call-status
52- (ffi/layout [:struct [[:code :int8]
53- [:error-buf [:struct [[:capacity :uint64]
54- [:len :uint64]
55- [:data :pointer]]]]]]))
56-
57-;; `code` in a RustCallStatus.
58-(def ^:const status-ok 0)
59-(def ^:const status-error 1)
60-(def ^:const status-panic 2)
61-
62-;; What a continuation is handed. READY means `complete` will not block;
63-;; MAYBE-READY means poll again.
64-(def ^:const poll-ready 0)
65-(def ^:const poll-maybe-ready 1)
66-
67-;; --- the entry points -------------------------------------------------------
68-;; Not redeclared here. `frq.moq.raw` is generated from the object's own
69-;; embedded metadata and already declares all 403 of them, including these —
70-;; so this namespace binds behaviour to them rather than restating signatures
71-;; that a regeneration could move underneath it.
72-
73-;; --- errors ------------------------------------------------------------------
74-
75-;; Forward: `with-out-status` expands to a call to this, and jolt resolves the
76-;; expansion when it ANALYSES it rather than when it runs.
77-(declare throw-status!)
78-
79-(defmacro with-out-status
80- "Run `f` with a zeroed RustCallStatus, then raise if it came back non-ok.
81-
82- Every fallible entry point in the object takes one of these as its last
83- argument, so this is the shape nearly every call in `frq.moq.*` wears. The
84- status is confined: it lives exactly as long as the call."
85- [f]
86- `(ffi/with-arena [a#]
87- (let [s# (ffi/alloc a# (ffi/layout-size rust-call-status))]
88- (ffi/write s# rust-call-status {:code status-ok
89- :error-buf {:capacity 0 :len 0
90- :data ffi/null}})
91- (let [v# (~f s#)]
92- (if (= status-ok (ffi/read-field s# rust-call-status [:code]))
93- v#
94- (throw-status! s#))))))
95-
96-;; The variant table lives in `frq.moq.raw`, generated beside the entry points.
97-;; It was hand-written here once and that was a mistake worth recording: the
98-;; variants are INSERTED into, not appended to — turning moq-ffi's audio and
99-;; video features on adds Audio and Video at 5 and 6 and shifts every later
100-;; variant down two. A stale table does not answer "unknown variant"; it
101-;; answers a confident, wrong name for a real one, and the message beside it
102-;; still reads correctly, so nothing looks broken.
103-
104-;; UniFFI serialises into a RustBuffer BIG-ENDIAN, which is not this machine's
105-;; order — so these read byte by byte rather than through ffi/read, whose
106-;; integer types are native. Getting this wrong does not fail loudly: a
107-;; little-endian read of variant 1 is 16777216, which is simply no variant at
108-;; all, and the message after it would be read at the wrong offset.
109-(defn- be-u32 [p off]
110- (let [b #(ffi/read (+ p off %) :uint8)]
111- (+ (* (b 0) 16777216) (* (b 1) 65536) (* (b 2) 256) (b 3))))
112-
113-(defn- decode-error
114- "Lift a lowered MoqError out of an error buffer.
115-
116- The shape is UniFFI's for a flat error: an i32 variant index, then the
117- message as an i32 byte length and that many UTF-8 bytes. Every MoqError
118- variant carries exactly one string, which is what makes this one shape
119- rather than a table of them.
120-
121- A buffer too short to hold even the header is reported as-is rather than
122- read past — an error path is the worst place to add a second fault."
123- [data len]
124- (if (< len 8)
125- {:variant :moq/malformed :message nil}
126- (let [variant (be-u32 data 0)
127- n (be-u32 data 4)
128- n (min n (- len 8))]
129- {:variant (get raw/moq-error-variants variant :moq/unknown)
130- :message (when (pos? n) (ffi/read-bytes (+ data 8) n))})))
131-
132-(defn- status-message
133- "Read and RELEASE the lowered error sitting in a non-ok status.
134-
135- Both `error` and `panic` put a RustBuffer in `errorBuf`, and both are the
136- caller's to free — the difference is only what is inside. A panic's buffer is
137- a bare UTF-8 message; an error's is a lowered `MoqError`, and `decode-error`
138- above lifts that into a variant keyword and its message. MoqError is the one
139- error type every entry point in this object raises — UniFFI says so in as
140- many words — so knowing its shape here costs no generality.
141-
142- The free goes through a status of its own rather than `with-out-status`: this
143- is already the error path, and a raise from freeing an error buffer would
144- lose the error that got us here."
145- [status-ptr]
146- (let [code (ffi/read-field status-ptr rust-call-status [:code])
147- len (ffi/read-field status-ptr rust-call-status [:error-buf :len])
148- data (ffi/read-field status-ptr rust-call-status [:error-buf :data])
149- text (when (and (pos? len) (not (ffi/null? data)))
150- (if (= code status-panic)
151- ;; A panic's buffer is a bare message, not a lowered value.
152- ;; Not ptr->string: it is length-counted and may hold an
153- ;; interior zero.
154- {:variant :moq/panic :message (ffi/read-bytes data len)}
155- (decode-error data len)))]
156- (ffi/with-arena [a]
157- (let [free-status (ffi/alloc a (ffi/layout-size rust-call-status))]
158- (ffi/write free-status rust-call-status
159- {:code status-ok
160- :error-buf {:capacity 0 :len 0 :data ffi/null}})
161- ;; The address of the errorBuf member, not a `place`: a by-value
162- ;; aggregate argument is a POINTER to the struct bytes, and `place`
163- ;; resolves a member's type for read/write rather than its address.
164- (raw/rustbuffer-free (+ status-ptr
165- (ffi/field-offset rust-call-status [:error-buf]))
166- free-status)))
167- text))
168-
169-(defn- throw-status! [status-ptr]
170- (let [code (ffi/read-field status-ptr rust-call-status [:code])
171- msg (status-message status-ptr)]
172- (throw (ex-info (if (= code status-panic)
173- (str "libmoq_ffi panicked: " (:message msg))
174- (str "libmoq_ffi: " (name (:variant msg :moq/none))
175- (when-let [m (:message msg)] (str " — " m))))
176- {:code code
177- :variant (:variant msg)
178- :message (:message msg)}))))
179-
180-;; --- strings -----------------------------------------------------------------
181-
182-(defn lower-string
183- "Copy `s` into a Rust-owned RustBuffer, written into `dest`.
184-
185- UniFFI lowers a bare String as its UTF-8 bytes with no length prefix — the
186- buffer's own `len` is the length. `from_bytes` copies, so the ForeignBytes
187- jolt hands over may live in a confined arena and die with this call.
188-
189- The bytes come from `string->ptr`, which encodes UTF-8 and records the size
190- it allocated; that size counts the NUL it appends, which is why the length
191- handed to Rust is one less. A NUL is not part of the value here — a
192- RustBuffer is length-counted, and including the terminator would append a
193- zero byte to every string that crossed."
194- [dest s]
195- (ffi/with-arena [a]
196- (let [p (ffi/string->ptr a s)
197- n (max 0 (dec (ffi/size p)))
198- fbs (ffi/alloc a (ffi/layout-size foreign-bytes))]
199- (ffi/write fbs foreign-bytes {:len n :data p})
200- (with-out-status #(raw/rustbuffer-from-bytes dest fbs %))
201- dest)))
202-
203-(defn lift-string
204- "Read a returned RustBuffer as a string and free it.
205-
206- `read-bytes` decodes UTF-8 itself, so the length-counted bytes become a jolt
207- string in one block move — no NUL is looked for, which is what a buffer that
208- may hold an interior zero needs.
209-
210- The buffer is Rust-owned, so it goes back through `rustbuffer_free` whether
211- or not it held anything: a zero-length buffer still carries a capacity."
212- [rb-ptr]
213- (let [len (ffi/read-field rb-ptr rust-buffer [:len])
214- data (ffi/read-field rb-ptr rust-buffer [:data])
215- s (if (and (pos? len) (not (ffi/null? data)))
216- (ffi/read-bytes data len)
217- "")]
218- (with-out-status #(raw/rustbuffer-free rb-ptr %))
219- s))
220-
221-;; --- the buffer codec -------------------------------------------------------
222-;; Anything that is not a scalar or a handle crosses as a RustBuffer holding
223-;; UniFFI's own serialisation: BIG-ENDIAN fixed-width integers, a bool as one
224-;; byte, bytes and strings as an i32 length followed by that many bytes, and an
225-;; Optional as a 0/1 flag byte followed by the value when present.
226-;;
227-;; Both directions are written out by hand here rather than reusing `read`
228-;; and `write`: jolt's integer types are NATIVE-endian, and this format is not.
229-
230-(defn- be-u64 [p off]
231- (let [b #(ffi/read (+ p off %) :uint8)]
232- (loop [i 0 acc 0]
233- (if (= i 8) acc (recur (inc i) (+ (* acc 256) (b i)))))))
234-
235-(defn reader
236- "A cursor over a RustBuffer's bytes."
237- [data len]
238- (atom {:data data :len len :off 0}))
239-
240-(defn- take! [c n]
241- (let [{:keys [off len]} @c]
242- (when (> (+ off n) len)
243- (throw (ex-info "read past the end of a RustBuffer"
244- {:off off :want n :len len})))
245- (swap! c update :off + n)
246- (+ (:data @c) off)))
247-
248-(defn r-u8! [c] (ffi/read (take! c 1) :uint8))
249-(defn r-bool! [c] (not (zero? (r-u8! c))))
250-(defn r-i32! [c] (let [p (take! c 4)] (be-u32 p 0)))
251-(defn r-u64! [c] (let [p (take! c 8)] (be-u64 p 0)))
252-
253-(defn r-string!
254- "An i32 byte length, then that many UTF-8 bytes."
255- [c]
256- (let [n (r-i32! c)]
257- (if (zero? n) "" (ffi/read-bytes (take! c n) n))))
258-
259-(defn r-bytes-span!
260- "An i32 byte length, then that many RAW bytes — answered as a BORROWED span,
261- {:ptr :len}, not copied.
262-
263- `r-string!` is the wrong tool for a payload: it decodes UTF-8, and H.264 is
264- not text. It is also the wrong SHAPE. frq.av's rule is that a video frame
265- goes from the decoder's buffer to the texture as a pointer and never becomes
266- a jolt value, so what belongs here is the address, valid exactly as long as
267- the RustBuffer it points into."
268- [c]
269- (let [n (r-i32! c)]
270- {:ptr (take! c n) :len n}))
271-
272-(defn r-f64!
273- "An IEEE 754 double, big-endian like everything else in a RustBuffer."
274- [c]
275- (let [bits (r-u64! c)]
276- (Double/longBitsToDouble (if (>= bits 9223372036854775808)
277- (- bits 18446744073709551616)
278- bits))))
279-
280-(defn r-map!
281- "An i32 count, then that many key/value pairs. Keys are strings."
282- [c read-value]
283- (let [n (r-i32! c)]
284- (loop [i 0 acc {}]
285- (if (= i n)
286- acc
287- (let [k (r-string! c)]
288- (recur (inc i) (assoc acc k (read-value c))))))))
289-
290-(defn r-list!
291- "An i32 count, then that many items."
292- [c read-item]
293- (let [n (r-i32! c)]
294- (mapv (fn [_] (read-item c)) (range n))))
295-
296-(defn r-optional!
297- "A flag byte, then `f` when it is set."
298- [c f]
299- (when (= 1 (r-u8! c)) (f c)))
300-
301-;; -- writing --
302-
303-(defn lower-buffer
304- "Serialise `ops` and hand the bytes to Rust as a RustBuffer in `dest`.
305-
306- `ops` is a sequence of [type value] pairs in wire order, e.g.
307-
308- [[:i32 3] [:u8 0]] ; MoqContainer::LOC, then Optional::None
309-
310- A :bytes op takes [pointer length] and copies those bytes as they are, which
311- is how a raw frame crosses without becoming a jolt string on the way.
312-
313- Strings are materialised FIRST, in the arena, because their wire length is a
314- UTF-8 byte count and jolt will only tell us one by encoding the string — so
315- the total size is not known until every string has been. `string->ptr`
316- records the size it allocated, which counts the NUL it appends; the wire
317- length is one less, since a length-counted string carries no terminator.
318-
319- `from_bytes` copies, so the scratch this builds in may die with the call."
320- [dest ops]
321- (ffi/with-arena [a]
322- (let [;; [op value encoded-pointer byte-count]
323- prepared (mapv (fn [[op v]]
324- (case op
325- :string (let [p (ffi/string->ptr a v)]
326- [op v p (max 0 (dec (ffi/size p)))])
327- ;; [pointer length], copied straight out of
328- ;; foreign memory — never through a jolt value.
329- :bytes [op v (first v) (second v)]
330- [op v nil nil]))
331- ops)
332- n (reduce (fn [n [op _ _ k]]
333- (+ n (case op
334- (:u8 :bool) 1
335- (:i32 :u32) 4
336- :u64 8
337- ;; Both carry an i32 length and then k bytes.
338- (:string :bytes) (+ 4 k))))
339- 0 prepared)
340- buf (ffi/alloc a (max n 1))
341- fbs (ffi/alloc a (ffi/layout-size foreign-bytes))
342- put-int! (fn [off width v]
343- (dotimes [i width]
344- (ffi/write (+ buf off i) :uint8
345- (bit-and (bit-shift-right v (* 8 (- width 1 i)))
346- 255))))]
347- (loop [off 0 todo (seq prepared)]
348- (when todo
349- (let [[op v p k] (first todo)]
350- (recur
351- (case op
352- (:u8 :bool)
353- (do (ffi/write (+ buf off) :uint8
354- (if (= op :bool) (if v 1 0) v))
355- (inc off))
356-
357- (:i32 :u32) (do (put-int! off 4 v) (+ off 4))
358- :u64 (do (put-int! off 8 v) (+ off 8))
359- (:string :bytes)
360- (do (put-int! off 4 k)
361- (when (pos? k) (ffi/copy p (+ buf off 4) k))
362- (+ off 4 k)))
363- (next todo)))))
364- (ffi/write fbs foreign-bytes {:len n :data buf})
365- (with-out-status #(raw/rustbuffer-from-bytes dest fbs %))
366- dest)))
367-
368-;; --- futures -----------------------------------------------------------------
369-
370-(defn- continuation
371- "A `void (uint64_t data, int8_t poll_result)` for the far side to invoke.
372-
373- Built in the auto-arena and `:collect-safe` on purpose: tokio calls this from
374- a worker thread jolt never started, and no lexical scope on this side is the
375- callback's lifetime — which is the case `ffi/auto-arena` exists for.
376-
377- It does as little as a callback can. Recording the poll result into an atom
378- is the whole body; nothing here touches glimmer, allocates a jolt value that
379- outlives the call, or re-enters the object. `frq.av`'s rule that nothing
380- calls back into the UI survives because the UI is never on this path — a
381- caller on the loop thread reads the atom instead."
382- [state]
383- (ffi/callback (ffi/auto-arena)
384- (fn [_data result] (reset! state (long result)))
385- [:uint64 :int8] :void
386- :collect-safe))
387-
388-(defn start-future
389- "Begin polling `handle`, and answer a map the loop thread can interrogate.
390-
391- `kind` picks the width family — :u64 for an object or integer result, :void
392- for one that resolves to nothing, :rb for a value that arrives as a buffer.
393- The future is NOT awaited here: `settled?` says whether it is ready and
394- `complete!` takes the result, so a caller drives this from the same timer
395- that drives `frq.av/pump!` rather than blocking the loop."
396- [handle kind]
397- (let [state (atom nil)
398- cb (continuation state)
399- poll (case kind
400- :u64 raw/rust-future-poll-u64
401- :void raw/rust-future-poll-void
402- :rb raw/rust-future-poll-rust-buffer)]
403- (poll handle cb 0)
404- {:handle handle :kind kind :state state :callback cb :poll poll}))
405-
406-(defn settled?
407- "Has the continuation reported READY?
408-
409- MAYBE-READY means the far side wants another poll, which this issues and
410- then answers false — so calling `settled?` from a timer is what advances a
411- future, and there is no thread on this side waiting on one."
412- [{:keys [handle state poll callback]}]
413- (let [r @state]
414- (cond
415- (nil? r) false
416- (= r poll-ready) true
417- (= r poll-maybe-ready) (do (reset! state nil)
418- (poll handle callback 0)
419- false)
420- :else false)))
421-
422-(defn complete!
423- "Take a settled future's result and release it.
424-
425- Only valid once `settled?` has answered true — completing early is what
426- blocks the calling thread, which on the loop thread is the freeze this whole
427- polling shape exists to avoid. The future handle is freed either way, so a
428- raising `complete` still does not leak one.
429-
430- An :rb future REQUIRES a `lift` function, and it is called while the buffer
431- cell is still alive. The cell is arena memory whose lifetime is this call:
432- handing the pointer back to a caller to read afterwards reads memory that
433- has already been released, and what comes back is not a fault but a
434- RustBuffer whose length no longer matches its capacity — which the object
435- then panics on, some way from the mistake."
436- ([fut]
437- (let [{:keys [kind]} fut]
438- (when (= kind :rb)
439- (throw (ex-info "an :rb future needs a lift function — see complete!"
440- {:kind kind})))
441- (complete! fut nil)))
442- ([{:keys [handle kind]} lift]
443- (try
444- (case kind
445- :u64 (with-out-status #(raw/rust-future-complete-u64 handle %))
446- :void (do (with-out-status #(raw/rust-future-complete-void handle %)) nil)
447- :rb (ffi/with-arena [a]
448- (let [out (ffi/alloc a (ffi/layout-size rust-buffer))]
449- (with-out-status #(raw/rust-future-complete-rust-buffer out handle %))
450- (lift out))))
451- (finally
452- (case kind
453- :u64 (raw/rust-future-free-u64 handle)
454- :void (raw/rust-future-free-void handle)
455- :rb (raw/rust-future-free-rust-buffer handle))))))
456-
457-;; --- the version guard -------------------------------------------------------
458-
459-;; What this file was written against: moq-ffi 0.3.17, UniFFI contract 30.
460-;; Both are asserted rather than assumed — see the namespace docstring on why a
461-;; silent mismatch is the failure mode worth spending a startup check on.
462-(def ^:const expected-contract 30)
463-
464-(defn check-abi!
465- "Refuse a `libmoq_ffi` this file was not written against."
466- []
467- (let [v (raw/uniffi-contract-version)]
468- (when-not (= v expected-contract)
469- (throw (ex-info (str "libmoq_ffi UniFFI contract " v
470- ", expected " expected-contract
471- " — frq.moq.* was generated against moq-ffi 0.3.17")
472- {:found v :expected expected-contract})))
473- v))
deleted src/frq/oauth.clj +0 -115
deleted file mode 100644
@@ -1,115 +0,0 @@
1-(ns frq.oauth
2- "Bluesky OAuth through freeq's auth broker, the way sleek does it.
3-
4- The broker owns the OAuth dance with the user's PDS; this client only has to
5- get the handoff back. On desktop that is a loopback capture: bind a port, put
6- it in `return_to`, open the browser, and serve a page whose only job is to
7- POST the `#oauth=` fragment back — the fragment never leaves the browser as a
8- query string, so it stays out of logs and history.
9-
10- What comes back is a short-lived SASL `web-token` and a durable
11- `broker_token`. The web-token is single-use: `/session` mints a fresh one
12- from the broker token on every later connection."
13- (:require [clojure.string :as str]
14- [frq.oauth.core :as core]
15- [frq.atproto :as atproto]
16- [frq.platform :as platform]
17- [frq.wire :as wire]
18- [jolt.ffi :as ffi]
19- [jolt.host :as host]
20- [jolt.socket :as socket]))
21-
22-;; ------------------------------------------------------------------ urls
23-;;
24-;; Moved to `frq.oauth.core` under common/, which is everything about this
25-;; flow that is not the waiting: the URL, the payload, the session refresh.
26-;; Re-exported so callers did not move.
27-
28-(def default-broker core/default-broker)
29-(def url-encode core/url-encode)
30-(def login-url core/login-url)
31-(def tokens-of core/tokens-of)
32-
33-(defn open-browser!
34- "Hand the URL to the desktop. A failure here is not fatal — the caller shows
35- the URL so it can be opened by hand."
36- [url]
37- (platform/open-url! url))
38-
39-;; ------------------------------------------------------------------ capture
40-
41-(def capture-html
42- "Moved to `frq.oauth.core`: it is a string, and the phone serves the same one
43- from a `dart:io` HttpServer. Re-exported so callers did not move."
44- core/capture-html)
45-
46-(defn- respond! [fd body content-type]
47- (let [head (str "HTTP/1.1 200 OK\r\nContent-Type: " content-type
48- "\r\nConnection: close\r\nContent-Length: "
49- (count (.getBytes ^String body "UTF-8")) "\r\n\r\n")
50- text (str head body)]
51- ;; A closed peer is ordinary here, so a failed write is not an error. A
52- ;; SHORT write is not ordinary: one c-send used to be the whole of this,
53- ;; and the browser was promised a Content-Length the socket had not
54- ;; finished delivering — a hung tab on the one page the user is watching
55- ;; for the sign-in to land.
56- (try (wire/send-all! fd text)
57- (catch Exception _ -1))))
58-
59-(defn- read-request [fd]
60- (let [buf (ffi/alloc 16384)
61- n (try (wire/recv! fd buf 16384) (catch Exception _ -1))]
62- (if (and n (pos? n)) (String. (ffi/read-bytes buf n)) "")))
63-
64-(defn- bind-loopback!
65- "A listening socket on some free loopback port. Returns [fd port]."
66- []
67- (loop [port 7390]
68- (when (> port 7420)
69- (throw (ex-info "No free loopback port for the OAuth handoff" {})))
70- (let [fd (socket/c-socket 2 1 0)]
71- (if (and (>= fd 0)
72- (zero? (socket/c-bind fd (#'socket/make-sockaddr-in "127.0.0.1" port) 16))
73- (zero? (socket/c-listen fd 4)))
74- [fd port]
75- (do (socket/c-close fd) (recur (inc port)))))))
76-
77-(defn await-callback!
78- "Serve the loopback capture until the browser posts the handoff back.
79-
80- Blocking, so run it off the UI thread. `on-url` is called with the login URL
81- once the port is known — that is what the caller shows and opens."
82- [broker handle on-url]
83- (let [[server port] (bind-loopback!)
84- url (login-url broker handle (str "http://127.0.0.1:" port))]
85- (try
86- (on-url url)
87- (loop []
88- (let [fd (wire/accept! server)]
89- (if (neg? fd)
90- (throw (ex-info "Loopback accept failed" {:port port}))
91- (let [req (read-request fd)
92- line (first (str/split-lines req))]
93- (if (str/starts-with? (or line "") "POST /capture")
94- (let [body (str/trim (or (second (str/split req #"\r\n\r\n" 2)) ""))
95- tokens (try (tokens-of body) (catch Exception _ nil))]
96- (respond! fd (if tokens "ok" "bad payload") "text/plain")
97- (socket/c-close fd)
98- ;; A POST that carried nothing usable is not the end of the
99- ;; wait — keep serving, the real handoff may still arrive.
100- (or tokens (recur)))
101- (do (respond! fd (capture-html (platform/return-url))
102- "text/html; charset=utf-8")
103- (socket/c-close fd)
104- (recur)))))))
105- (finally (socket/c-close server)))))
106-
107-;; ------------------------------------------------------------------ session
108-
109-(defn refresh-session
110- "Mint a fresh single-use web-token from the durable broker token."
111- [broker broker-token]
112- (core/refresh-session-parse
113- broker-token
114- (let [{:keys [host path body]} (core/refresh-session-req broker broker-token)]
115- (atproto/request host path body))))
deleted file mode 100644
@@ -1,115 +0,0 @@
1-(ns frq.oauth
2- "Bluesky OAuth through freeq's auth broker, the way sleek does it.
3-
4- The broker owns the OAuth dance with the user's PDS; this client only has to
5- get the handoff back. On desktop that is a loopback capture: bind a port, put
6- it in `return_to`, open the browser, and serve a page whose only job is to
7- POST the `#oauth=` fragment back — the fragment never leaves the browser as a
8- query string, so it stays out of logs and history.
9-
10- What comes back is a short-lived SASL `web-token` and a durable
11- `broker_token`. The web-token is single-use: `/session` mints a fresh one
12- from the broker token on every later connection."
13- (:require [clojure.string :as str]
14- [frq.oauth.core :as core]
15- [frq.atproto :as atproto]
16- [frq.platform :as platform]
17- [frq.wire :as wire]
18- [jolt.ffi :as ffi]
19- [jolt.host :as host]
20- [jolt.socket :as socket]))
21-
22-;; ------------------------------------------------------------------ urls
23-;;
24-;; Moved to `frq.oauth.core` under common/, which is everything about this
25-;; flow that is not the waiting: the URL, the payload, the session refresh.
26-;; Re-exported so callers did not move.
27-
28-(def default-broker core/default-broker)
29-(def url-encode core/url-encode)
30-(def login-url core/login-url)
31-(def tokens-of core/tokens-of)
32-
33-(defn open-browser!
34- "Hand the URL to the desktop. A failure here is not fatal — the caller shows
35- the URL so it can be opened by hand."
36- [url]
37- (platform/open-url! url))
38-
39-;; ------------------------------------------------------------------ capture
40-
41-(def capture-html
42- "Moved to `frq.oauth.core`: it is a string, and the phone serves the same one
43- from a `dart:io` HttpServer. Re-exported so callers did not move."
44- core/capture-html)
45-
46-(defn- respond! [fd body content-type]
47- (let [head (str "HTTP/1.1 200 OK\r\nContent-Type: " content-type
48- "\r\nConnection: close\r\nContent-Length: "
49- (count (.getBytes ^String body "UTF-8")) "\r\n\r\n")
50- text (str head body)]
51- ;; A closed peer is ordinary here, so a failed write is not an error. A
52- ;; SHORT write is not ordinary: one c-send used to be the whole of this,
53- ;; and the browser was promised a Content-Length the socket had not
54- ;; finished delivering — a hung tab on the one page the user is watching
55- ;; for the sign-in to land.
56- (try (wire/send-all! fd text)
57- (catch Exception _ -1))))
58-
59-(defn- read-request [fd]
60- (let [buf (ffi/alloc 16384)
61- n (try (wire/recv! fd buf 16384) (catch Exception _ -1))]
62- (if (and n (pos? n)) (String. (ffi/read-bytes buf n)) "")))
63-
64-(defn- bind-loopback!
65- "A listening socket on some free loopback port. Returns [fd port]."
66- []
67- (loop [port 7390]
68- (when (> port 7420)
69- (throw (ex-info "No free loopback port for the OAuth handoff" {})))
70- (let [fd (socket/c-socket 2 1 0)]
71- (if (and (>= fd 0)
72- (zero? (socket/c-bind fd (#'socket/make-sockaddr-in "127.0.0.1" port) 16))
73- (zero? (socket/c-listen fd 4)))
74- [fd port]
75- (do (socket/c-close fd) (recur (inc port)))))))
76-
77-(defn await-callback!
78- "Serve the loopback capture until the browser posts the handoff back.
79-
80- Blocking, so run it off the UI thread. `on-url` is called with the login URL
81- once the port is known — that is what the caller shows and opens."
82- [broker handle on-url]
83- (let [[server port] (bind-loopback!)
84- url (login-url broker handle (str "http://127.0.0.1:" port))]
85- (try
86- (on-url url)
87- (loop []
88- (let [fd (wire/accept! server)]
89- (if (neg? fd)
90- (throw (ex-info "Loopback accept failed" {:port port}))
91- (let [req (read-request fd)
92- line (first (str/split-lines req))]
93- (if (str/starts-with? (or line "") "POST /capture")
94- (let [body (str/trim (or (second (str/split req #"\r\n\r\n" 2)) ""))
95- tokens (try (tokens-of body) (catch Exception _ nil))]
96- (respond! fd (if tokens "ok" "bad payload") "text/plain")
97- (socket/c-close fd)
98- ;; A POST that carried nothing usable is not the end of the
99- ;; wait — keep serving, the real handoff may still arrive.
100- (or tokens (recur)))
101- (do (respond! fd (capture-html (platform/return-url))
102- "text/html; charset=utf-8")
103- (socket/c-close fd)
104- (recur)))))))
105- (finally (socket/c-close server)))))
106-
107-;; ------------------------------------------------------------------ session
108-
109-(defn refresh-session
110- "Mint a fresh single-use web-token from the durable broker token."
111- [broker broker-token]
112- (core/refresh-session-parse
113- broker-token
114- (let [{:keys [host path body]} (core/refresh-session-req broker broker-token)]
115- (atproto/request host path body))))
deleted src/frq/platform.clj +0 -121
deleted file mode 100644
@@ -1,121 +0,0 @@
1-(ns frq.platform
2- "The few things that are the platform's job rather than the app's."
3- (:require [jolt.host :as host]))
4-
5-(defn android?
6- "Android, told from a desktop by a binary only it has. What hangs on this is
7- which way the app and the browser sit: side by side, or one behind the other."
8- []
9- (host/file-exists? "/system/bin/am"))
10-
11-(defonce ^:private desktop (delay (not (android?))))
12-
13-(defn desktop?
14- "True where there is a pointer to hover with, rather than a finger. Asked
15- once per render of every row that has a face on it, so the answer is worked
16- out once and kept — the question is a file that either exists or does not,
17- and nothing about it changes while the app runs."
18- []
19- @desktop)
20-
21-(defonce ^:private overrides
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.
31- (atom {}))
32-
33-(defn override!
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."
37- [m]
38- (swap! overrides merge m)
39- nil)
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-
57-(defn after!
58- "Run `f` on the UI thread in about `ms` milliseconds. jvui's timers only run
59- inside jvui's loop, so a backend with a loop of its own has to lend its own."
60- [ms f]
61- ((op :after! (fn [_ _] nil)) ms f))
62-
63-(defn quit!
64- "Close the window."
65- []
66- ((op :quit! (fn [] nil))))
67-
68-(defn pick-image!
69- "Open the platform's picture chooser; true when there is one and it opened."
70- []
71- ((op :pick-image! (fn [] false))))
72-
73-(defn picked-image!
74- "Write the chosen picture to `path`; true once, when one has been chosen."
75- [path]
76- ((op :picked-image! (fn [_] nil)) path))
77-
78-(defn clipboard-image-png!
79- "Write the picture on the clipboard to `path` as PNG; true when there was one.
80- jvui reads it through its own window, so another backend lends its own."
81- [path]
82- ((op :clipboard-image-png! (fn [_] false)) path))
83-
84-(defn return-url
85- "The link that brings the app back to the front once the browser is done, or
86- nil where the browser never covered it. `frq://auth` is the manifest's own
87- scheme; the activity is `singleTask`, so it is the running app that comes
88- forward rather than a second copy of it."
89- []
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))
deleted file mode 100644
@@ -1,121 +0,0 @@
1-(ns frq.platform
2- "The few things that are the platform's job rather than the app's."
3- (:require [jolt.host :as host]))
4-
5-(defn android?
6- "Android, told from a desktop by a binary only it has. What hangs on this is
7- which way the app and the browser sit: side by side, or one behind the other."
8- []
9- (host/file-exists? "/system/bin/am"))
10-
11-(defonce ^:private desktop (delay (not (android?))))
12-
13-(defn desktop?
14- "True where there is a pointer to hover with, rather than a finger. Asked
15- once per render of every row that has a face on it, so the answer is worked
16- out once and kept — the question is a file that either exists or does not,
17- and nothing about it changes while the app runs."
18- []
19- @desktop)
20-
21-(defonce ^:private overrides
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.
31- (atom {}))
32-
33-(defn override!
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."
37- [m]
38- (swap! overrides merge m)
39- nil)
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-
57-(defn after!
58- "Run `f` on the UI thread in about `ms` milliseconds. jvui's timers only run
59- inside jvui's loop, so a backend with a loop of its own has to lend its own."
60- [ms f]
61- ((op :after! (fn [_ _] nil)) ms f))
62-
63-(defn quit!
64- "Close the window."
65- []
66- ((op :quit! (fn [] nil))))
67-
68-(defn pick-image!
69- "Open the platform's picture chooser; true when there is one and it opened."
70- []
71- ((op :pick-image! (fn [] false))))
72-
73-(defn picked-image!
74- "Write the chosen picture to `path`; true once, when one has been chosen."
75- [path]
76- ((op :picked-image! (fn [_] nil)) path))
77-
78-(defn clipboard-image-png!
79- "Write the picture on the clipboard to `path` as PNG; true when there was one.
80- jvui reads it through its own window, so another backend lends its own."
81- [path]
82- ((op :clipboard-image-png! (fn [_] false)) path))
83-
84-(defn return-url
85- "The link that brings the app back to the front once the browser is done, or
86- nil where the browser never covered it. `frq://auth` is the manifest's own
87- scheme; the activity is `singleTask`, so it is the running app that comes
88- forward rather than a second copy of it."
89- []
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))
deleted src/frq/profile/host.clj +0 -45
deleted file mode 100644
@@ -1,45 +0,0 @@
1-(ns frq.profile.host
2- "How this half fetches a profile, and the picture that goes with one.
3-
4- `frq.profile` under common/ is what a profile *is* — the cache, the fields,
5- the URL, the counts, and the press that opens one. What is left here is the
6- two things that genuinely differ by host. The fetch, because a `future` and a
7- blocking request is this backend's answer to a question the other one answers
8- by awaiting. And the avatar, because the desktop downloads a file where the
9- phone hands over a CDN URL.
10-
11- The shared names are re-exported so callers did not have to move."
12- (:require [frq.atproto :as atproto]
13- [frq.avatars :as avatars]
14- [frq.profile :as profile]))
15-
16-(def viewing profile/viewing)
17-(def tick profile/tick)
18-(def entry profile/entry)
19-(def close! profile/close!)
20-(def web-url profile/web-url)
21-(def stats-line profile/stats-line)
22-(def truncate profile/truncate)
23-
24-(profile/install-fetch!
25- (fn [actor]
26- (future
27- (profile/deliver-profile!
28- actor
29- (try
30- (let [{:keys [host path]} (profile/profile-req actor)]
31- (atproto/request host path nil))
32- (catch Exception _ nil))))))
33-
34-(defn- with-avatar!
35- "The picture too. The chat asks for one when a line arrives, but a profile
36- can be opened on someone whose avatar never landed, and it paints a bigger
37- one than the column it was fetched for."
38- [actor]
39- (when (seq actor) (avatars/fetch! actor #(swap! tick inc))))
40-
41-(defn open!
42- "Look at someone. `actor` is their DID or handle, or nil for a guest."
43- [nick actor]
44- (with-avatar! actor)
45- (profile/open! nick actor))
deleted file mode 100644
@@ -1,45 +0,0 @@
1-(ns frq.profile.host
2- "How this half fetches a profile, and the picture that goes with one.
3-
4- `frq.profile` under common/ is what a profile *is* — the cache, the fields,
5- the URL, the counts, and the press that opens one. What is left here is the
6- two things that genuinely differ by host. The fetch, because a `future` and a
7- blocking request is this backend's answer to a question the other one answers
8- by awaiting. And the avatar, because the desktop downloads a file where the
9- phone hands over a CDN URL.
10-
11- The shared names are re-exported so callers did not have to move."
12- (:require [frq.atproto :as atproto]
13- [frq.avatars :as avatars]
14- [frq.profile :as profile]))
15-
16-(def viewing profile/viewing)
17-(def tick profile/tick)
18-(def entry profile/entry)
19-(def close! profile/close!)
20-(def web-url profile/web-url)
21-(def stats-line profile/stats-line)
22-(def truncate profile/truncate)
23-
24-(profile/install-fetch!
25- (fn [actor]
26- (future
27- (profile/deliver-profile!
28- actor
29- (try
30- (let [{:keys [host path]} (profile/profile-req actor)]
31- (atproto/request host path nil))
32- (catch Exception _ nil))))))
33-
34-(defn- with-avatar!
35- "The picture too. The chat asks for one when a line arrives, but a profile
36- can be opened on someone whose avatar never landed, and it paints a bigger
37- one than the column it was fetched for."
38- [actor]
39- (when (seq actor) (avatars/fetch! actor #(swap! tick inc))))
40-
41-(defn open!
42- "Look at someone. `actor` is their DID or handle, or nil for a guest."
43- [nick actor]
44- (with-avatar! actor)
45- (profile/open! nick actor))
deleted src/frq/state.clj +0 -1670
deleted file mode 100644
@@ -1,1670 +0,0 @@
1-(ns frq.state
2- "Every cell the UI reads, and the reducers that write them.
3-
4- glimmer components re-render from ratoms, so the whole app state is a handful
5- of `atom`s here; the IRC reader thread pushes into the same ones. `apply-msg!`
6- is the only place a wire message turns into UI state."
7- (:require [clojure.string :as str]
8- [frq.rooms :as rooms]
9- [frq.members :as members]
10- [frq.reactions :as reactions]
11- [frq.edits :as edits]
12- [glimmer.ratom :as r :refer [atom]]
13- [frq.actions :as actions]
14- [frq.cells :as cells]
15- [frq.replies :as replies]
16- [jolt.host :as host]
17- [frq.atproto :as atproto]
18- [frq.av :as av]
19- [frq.clock :as clock]
20- [frq.emoji :as emoji]
21- [frq.irc :as irc]
22- ;; For the side effect: it installs the desktop crypto behind
23- ;; `frq.crypto`, which the shared `frq.msgsig` signs through.
24- [frq.crypto.openssl]
25- [frq.msgsig :as msgsig]
26- [frq.avatars :as avatars]
27- [frq.media :as media]
28- [frq.oauth :as oauth]
29- [frq.platform :as platform]
30- [frq.store :as store]
31- [frq.upload :as upload]))
32-
33-(def default-host cells/default-host)
34-(def default-port cells/default-port)
35-
36-;; The cells the connect screen reads live in `frq.cells` now, so that screen
37-;; could move to common/ and be the same file on the phone. Re-defined here
38-;; rather than left to the callers: a thousand lines below this say
39-;; `@form-nick` and `@connecting?`, and none of them care which namespace the
40-;; atom was made in.
41-(def screen cells/screen)
42-;; Not in frq.cells: this holds the live IRC connection, which is jolt's
43-;; socket and a reader thread. The phone's equivalent is a dart:io Socket and
44-;; nothing shared could hold either.
45-(defonce conn (atom nil))
46-(def status cells/status)
47-(def error cells/error)
48-(def connecting? cells/connecting?)
49-(def form-host cells/form-host)
50-(def form-port cells/form-port)
51-(def form-tls? cells/form-tls?)
52-(def form-nick cells/form-nick)
53-(def auth-mode cells/auth-mode)
54-(def form-handle cells/form-handle)
55-(def form-app-password cells/form-app-password)
56-(def session cells/session)
57-(def broker-token cells/broker-token)
58-(def channels cells/channels)
59-(def current cells/current)
60-(def join-input cells/join-input)
61-(def search cells/search)
62-(def login-url cells/login-url)
63-
64-(def popular-channels cells/popular-channels)
65-
66-(def auto-join "#test")
67-
68-;; How much backlog to ask for when the server did not volunteer any.
69-(def history-limit 100)
70-
71-(def draft cells/draft)
72-(def replying-to cells/replying-to)
73-
74-(defn reply-to! [m] (reset! replying-to (select-keys m [:id :from :text])))
75-(defn cancel-reply! [] (reset! replying-to nil))
76-
77-(def editing cells/editing)
78-
79-(def reacting cells/reacting)
80-
81-(def emoji-search cells/emoji-search)
82-(def emoji-group cells/emoji-group)
83-
84-(def lightbox cells/lightbox)
85-
86-(def show-users? cells/show-users?)
87-
88-(defn toggle-users! [] (swap! show-users? not))
89-
90-(def hide-chat-list? cells/hide-chat-list?)
91-
92-(declare save-prefs!)
93-
94-(defn toggle-chat-list! []
95- (swap! hide-chat-list? not)
96- (save-prefs!))
97-
98-(def overview? cells/overview?)
99-
100-(defn toggle-overview! []
101- (swap! overview? not)
102- (save-prefs!))
103-
104-(def overview-return cells/overview-return)
105-
106-(declare open-channel!)
107-
108-(defn leaving-for-overview!
109- "Remember where we are, because a line in the strip is about to take us out
110- of it. Nothing to remember if there is nowhere to go back to."
111- []
112- (reset! overview-return @current))
113-
114-(defn overview-back!
115- "Back to the room the strip took you out of.
116-
117- The room, and with it the place in it: the backlog's scroll is remembered
118- under a name per conversation — see `messages-scroll-key` — so a backend
119- that keeps a position per name lands back where the strip found you. One
120- that only knows whether a viewport is new to it, as the Flutter side does,
121- brings you back to the newest line instead; both beat the middle of the
122- last room you were in, which is what one name for every room gave."
123- []
124- (when-let [room @overview-return]
125- (reset! overview-return nil)
126- (open-channel! room)))
127-
128-(def window-width cells/window-width)
129-
130-(def window-height cells/window-height)
131-
132-;; Where the second pane starts paying for itself, shared with the other
133-;; backends — see `frq.cells/wide-width`.
134-(def wide-width cells/wide-width)
135-
136-(defn wide?
137- "True while the window has room for the list and a conversation at once."
138- []
139- (>= @window-width wide-width))
140-
141-(defn chat-visible?
142- "Whether the conversation in `current` is on screen.
143-
144- On a narrow window that is the chat screen alone. On a wide one the chats
145- screen shows it too, in the pane beside the list — so this, and not the
146- screen, is what decides whether an arriving line counts as unread."
147- []
148- (or (= :chat @screen)
149- (and (wide?) (= :chats @screen))))
150-
151-(def at-present? cells/at-present?)
152-(def jump-tick cells/jump-tick)
153-
154-;; How a backend answers "did that scroll end at the end?".
155-;;
156-;; The window's scroll area says so itself — `:on-change` arrives with "end" —
157-;; and the terminal's does not: it reports the offset it was asked for and
158-;; never how far down the bottom is. What it does do is clamp, so a request
159-;; that came back smaller than it went out is a request that ran into the end.
160-;; Only a backend that can see that installs this; `scrolled!` is written for
161-;; both, and asks.
162-(defonce at-end-probe (atom nil))
163-
164-(defn scrolled!
165- "A viewport moved under the reader, to the offset `to`.
166-
167- For the backends whose scroll areas report a position rather than a place:
168- where that leaves the reader is what `at-end-probe` is asked, and with
169- nobody to ask, any scroll at all is a scroll away from the newest line."
170- [to]
171- (reset! at-present? (boolean (when-let [probe @at-end-probe] (probe to))))
172- nil)
173-
174-(defn jump-to-present!
175- "Go back to the newest line.
176-
177- `at-present?` is set here rather than left to the view because not every
178- backend can tell us: the window's scroll area reports where it ended up and
179- corrects this on the next frame, and the terminal's does not report the
180- bottom at all — so what a jump means for the button that asked for it is
181- said here, once, for both."
182- []
183- (reset! at-present? true)
184- (swap! jump-tick inc))
185-;; A counter rather than a clock: the list only needs their order, and a
186-;; monotonic tick cannot be surprised by the system time moving.
187-(def access-tick rooms/access-tick)
188-
189-(def hide-join-part? cells/hide-join-part?)
190-
191-;; Whether rooms.edn is the authority yet.
192-;;
193-;; It is not, the first time this version runs: freeq re-joins an authenticated
194-;; user's channels at registration, so on connect the server announces every
195-;; room it has you in — and a client that parted everything not already in its
196-;; file would walk out of all of them before the file had ever been told they
197-;; existed. So the first connect adopts what the server says and writes it
198-;; down, and every connect after that is the strict one.
199-(defonce room-list-owned? (atom false))
200-
201-;; And whether this session is the adopting one, decided at 001.
202-(defonce ^:private adopting-rooms? (atom false))
203-
204-(defn- save-prefs! []
205- (future (store/save-prefs! (assoc (store/load-prefs)
206- :hide-join-part? @hide-join-part?
207- :hide-chat-list? @hide-chat-list?
208- :overview? @overview?
209- :room-list-owned? @room-list-owned?))))
210-
211-(defn toggle-hide-join-part! []
212- (swap! hide-join-part? not)
213- (save-prefs!))
214-
215-(defn restore-prefs!
216- "Bring back the saved settings at startup."
217- []
218- (let [prefs (store/load-prefs)]
219- (reset! hide-join-part? (boolean (:hide-join-part? prefs)))
220- (reset! hide-chat-list? (boolean (:hide-chat-list? prefs)))
221- (reset! overview? (boolean (:overview? prefs)))
222- (reset! room-list-owned? (boolean (:room-list-owned? prefs)))
223- prefs))
224-
225-(defn connected? [] (some? @conn))
226-
227-(declare channel-order room-records request-names!)
228-
229-(defonce ^:private rooms-saved-at (atom 0))
230-
231-(defn- remember-rooms!
232- "Write the room records out: what rooms there are, in the order they were
233- last used, and how much of each has been read.
234-
235- Off the caller's thread, because opening a room should not wait on a file.
236- Throttled, because the marker moves on every line that arrives while a room
237- is on screen and a busy channel would otherwise write the file per message —
238- `force?` is for the moments worth paying for, which is a room being opened.
239-
240- A late write costs at most the handful of lines that arrived since the last
241- one, shown unread again on the next run. That is the right way round: the
242- marker never claims to have read more than it has."
243- ([] (remember-rooms! false))
244- ([force?]
245- (let [now (clock/now-ms)]
246- (when (or force? (> (- now @rooms-saved-at) 5000))
247- (reset! rooms-saved-at now)
248- (future (store/save-rooms! (room-records)))))))
249-
250-(def dm? rooms/dm?)
251-
252-(defn normalize-channel [s]
253- (let [s (str/trim (or s ""))]
254- (cond (str/blank? s) ""
255- (str/starts-with? s "#") s
256- :else (str "#" s))))
257-
258-(def ^:private after-marker rooms/after-marker)
259-
260-(def ^:private recount rooms/recount)
261-
262-(def ^:private mark-read rooms/mark-read)
263-
264-(def ^:private ensure-channel rooms/ensure-channel)
265-
266-(defonce ^{:doc "Bumped whenever a fetched image becomes available, so the
267- chat view re-renders without every message row watching the media cache."}
268- media-tick (atom 0))
269-
270-(defn local-id
271- "A name for a line the server did not name.
272-
273- freeq tags a message with a `msgid` and that is a line's identity everywhere
274- it matters — a reply points at one, an edit rewrites one, a reaction lands on
275- one. But not every line arrives with one: a replayed backlog can come over
276- with no tags at all, and a line this client has just sent has none until the
277- server echoes it back.
278-
279- Those lines are not nameless to the reader, though. They are on the screen,
280- they are in the overview, and pressing one should go to it. So they get a
281- name made out of what they are: who said it, what it said, when, and where.
282- Two lines identical in all four are the same line as far as anything this
283- client does with one is concerned.
284-
285- `local-` because it is this client's alone, and it is never sent: the
286- server knows only the names it gave out."
287- [channel from text at]
288- (str "local-" (hash [channel from text at])))
289-
290-(defn push-message!
291- "Append a line to a buffer, creating it if needed, and bump the unread count
292- unless that buffer is the one on screen. Any image it links to is fetched in
293- the background, as is the sender's avatar.
294-
295- The extras are what the message carried beyond its text: `:at` when it was
296- said, from the server's own `time` tag where there is one, and `:did` who
297- said it, from the `account` tag — an identity that outlasts whatever nick
298- they are using today. `:id` names this message so a reply can point at it,
299- and `:reply-to` is the one it answers. `:reactions` is what people have put
300- on it already, which on a replayed backlog the server hands over in full."
301- ([channel from text] (push-message! channel from text {}))
302- ([channel from text {:keys [at did id reply-to reactions edited? edit-ids]}]
303- (let [at (or at (clock/now-ms))
304- ;; A name of our own where the server gave none. See `local-id`.
305- mine (when-not id (local-id channel from text at))
306- who (avatars/actor did from)
307- ;; A room reaching the store matters more than the throttle does: a
308- ;; connection joins every channel at once, and the writes for all but
309- ;; the first would be five seconds away — long enough that quitting
310- ;; straight after signing in is how a client forgets the rooms it just
311- ;; joined. Read before the swap, so this is the arrival that made it.
312- new-room? (not (contains? @channels channel))]
313- (doseq [url (media/image-urls text)]
314- (media/fetch! url #(swap! media-tick inc)))
315- ;; The same tick: an avatar arriving is a picture arriving, and the chat
316- ;; view already repaints on it.
317- (when who (avatars/fetch! who #(swap! media-tick inc)))
318- (swap! channels
319- (fn [m]
320- (let [m (ensure-channel m channel)
321- viewing? (and (chat-visible?) (= channel @current))
322- ;; And not a second copy of one we already hold: the
323- ;; server hands the same message over more than once, and
324- ;; `frq.rooms/seen-message?` is the whole of that rule —
325- ;; shared, because the Flutter half was appending every
326- ;; replay this drops.
327- seen? (rooms/seen-message? (get-in m [channel :messages])
328- id from text @form-nick)]
329- (cond
330- ;; The copy we already hold is the pre-edit one, and this is
331- ;; the server's collapsed row saying so. Same message, later
332- ;; word: take the text rather than the arrival order.
333- (and seen? edited?)
334- (assoc-in m [channel :messages]
335- (mapv (fn [msg]
336- (if (= id (:id msg))
337- (assoc msg :text text :edited? true
338- :images (media/image-urls text))
339- msg))
340- (get-in m [channel :messages])))
341-
342- seen? m
343-
344- :else
345- (-> m
346- (update-in [channel :messages] conj
347- {:from from :text text :system? (= "*" from)
348- :actor who
349- :images (media/image-urls text)
350- :at at
351- ;; `:id` is what a reply points at, and
352- ;; `:reply-to` is what this one points at.
353- :id id :local-id mine :reply-to reply-to
354- ;; Any other msgid this same line answers to
355- ;; — a revision's. See `frq.rooms/answers-to?`.
356- :edit-ids (set (remove nil? edit-ids))
357- ;; The sender has since rewritten this line.
358- ;; Replay says so with a tag rather than by
359- ;; sending the revision, so a message can
360- ;; arrive already edited.
361- :edited? (boolean edited?)
362- ;; emoji -> the nicks who put it there
363- :reactions (or reactions {})})
364- (assoc-in [channel :last-activity] at)
365- ;; A DM is a room named after whoever is in it, and a
366- ;; nick is not a name that lasts. The DID is, so the
367- ;; record keeps it the first time the other end says
368- ;; anything — ours would name the wrong side.
369- (cond-> (and (dm? channel) did (not= from @form-nick))
370- (assoc-in [channel :peer-did] did))
371- ;; Reading a room *is* marking it read: a line that
372- ;; arrives while it is on screen moves the marker past
373- ;; itself. Everything else re-derives, so a line arriving
374- ;; in a room nobody is looking at costs a recount of that
375- ;; room and nothing more.
376- (update channel (if viewing? mark-read recount)))))))
377- (remember-rooms! new-room?))))
378-
379-(defn open-channel!
380- "Show a buffer. A channel we are not in is joined on the way — a row can
381- outlive the membership behind it (a disconnect drops every channel, the
382- buffer stays), and opening one is a request to be in it."
383- [name]
384- (reset! current name)
385- ;; On a wide window the conversation lives in the chats screen's second
386- ;; pane, beside the list; :chat is the narrow window's way of showing it
387- ;; instead of the list, and there is nothing there to trade it for.
388- (reset! screen (if (wide?) :chats :chat))
389- ;; A picker belongs to the message it was opened on; carrying it into another
390- ;; buffer would offer to react to something that is no longer on screen.
391- (reset! reacting nil)
392- ;; And an edit belongs to a line in the buffer being left: carried across, the
393- ;; next Send would rewrite a message nobody in this room can see.
394- (when (and @editing (not= name (:channel @editing)))
395- (reset! editing nil)
396- (reset! draft ""))
397- (swap! channels #(-> (ensure-channel % name)
398- (update name mark-read)
399- (assoc-in [name :accessed] (swap! access-tick inc))))
400- (remember-rooms! true)
401- ;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a
402- ;; second JOIN sent in the meantime is what makes the server replay nothing.
403- (let [buffer (get @channels name)]
404- (when (and @conn
405- (str/starts-with? name "#")
406- (not (:joined? buffer))
407- (not (:joining? buffer)))
408- (swap! channels #(assoc-in % [name :joining?] true))
409- (irc/join! @conn name)))
410- ;; Already in it, and nobody listed: the membership survived a restart the
411- ;; NAMES that came with it did not.
412- (when (:joined? (get @channels name))
413- (request-names! name)))
414-
415-(defn join-saved-rooms!
416- "Ask to be in every channel `rooms.edn` says we are in.
417-
418- The server re-joins an authenticated user's channels itself, and gets it
419- wrong in both directions — it forgets rooms and announces ones that are not
420- ours. This is the half that answers the forgetting: the file says what we are
421- in, so on arrival we say it too. A JOIN for a channel the server has already
422- put us in is answered with the membership we already have, so asking twice
423- costs nothing.
424-
425- DMs are not asked for. There is nothing to join in a conversation with a
426- person; the buffer is the whole of it."
427- []
428- (when-let [conn @conn]
429- (doseq [[name buffer] @channels
430- :when (and (str/starts-with? name "#")
431- (not (:joined? buffer))
432- (not (:joining? buffer)))]
433- (swap! channels #(assoc-in % [name :joining?] true))
434- (irc/join! conn name))))
435-
436-(defn leave-channel!
437- "Leave a room and forget it: PART on the wire, gone from the list, gone from
438- `rooms.edn`.
439-
440- The only way a room leaves the file. Everything else adds — the server
441- announcing one, a message arriving in one — so without this the list is a
442- thing that only grows, and the strictness above would have nothing to be
443- strict about."
444- [name]
445- (when (and @conn (str/starts-with? name "#"))
446- (irc/part! @conn name))
447- (swap! channels dissoc name)
448- (when (= name @current)
449- (reset! current nil)
450- (reset! screen :chats))
451- (remember-rooms! true))
452-
453-(def ^:private parse-reactions reactions/parse-tally)
454-(def ^:private with-reaction reactions/with-reaction)
455-
456-(defn update-reaction!
457- "One reaction folded into the buffer it belongs to. `frq.reactions` says what
458- that means; this is the atom it means it to."
459- [channel msgid emoji nick on?]
460- (swap! channels reactions/update-reaction channel msgid emoji nick on?))
461-
462-(defn edit-message!
463- "Rewrite a message in place, and say so.
464-
465- `frq.edits` is the fold and what it answers; the atom and the picture links
466- are this half's. A message keeps the id it was born with across every
467- revision, which is what keeps its reactions, replies and pins attached to it
468- — and `revision`, the msgid of the edit itself, is kept on it too, because a
469- reply to an already-rewritten line names that one."
470- ([channel msgid from text] (edit-message! channel msgid from text nil))
471- ([channel msgid from text revision]
472- (let [out (edits/apply-edit @channels channel msgid from text
473- {:decorate #(assoc % :images (media/image-urls text))
474- :revision revision})]
475- (reset! channels (:channels out))
476- (:result out))))
477-
478-(defn- names-line [channel names]
479- (swap! channels #(members/with-names (ensure-channel % channel) channel names)))
480-
481-(defn- names-end! [channel]
482- (swap! channels members/names-done channel))
483-
484-(defn- add-user! [channel nick]
485- (when (and channel nick)
486- (swap! channels #(members/add-user (ensure-channel % channel) channel nick))))
487-
488-(defn- remove-user! [channel nick]
489- (swap! channels members/remove-user channel nick))
490-
491-(defn- remove-user-everywhere! [nick]
492- (swap! channels members/remove-everywhere nick))
493-
494-(defn- rename-user! [old new]
495- (swap! channels members/rename-user old new))
496-
497-(defn- apply-mode! [channel modes args]
498- (swap! channels members/with-mode channel modes args))
499-
500-(defn member-list [channel]
501- (members/member-list @channels channel))
502-
503-(defn member-count [channel]
504- (members/member-count @channels channel))
505-
506-(defn request-names!
507- "Ask who is in a channel we are already in. freeq re-joins an authenticated
508- user's channels at registration, which happens without a JOIN reaching this
509- client — and so without the NAMES that follows one."
510- [channel]
511- (when (and @conn channel (str/starts-with? channel "#")
512- (empty? (get-in @channels [channel :users])))
513- (irc/send-line! @conn (str "NAMES " channel))))
514-
515-(declare join! join-call!)
516-
517-;; --- calls -------------------------------------------------------------------
518-;; Signaling only. The audio and video themselves are `frq.av`'s, and behind it
519-;; libjoltmoq's; what happens here is that the server's broadcasts become state
520-;; the screens can read, and a press becomes a TAGMSG.
521-
522-(defn apply-call-state!
523- "A `+freeq.at/av-state` broadcast: fold it in, and say so in the buffer.
524-
525- The system line is worth the space — a call is the one thing that happens in
526- a channel while nobody types, and without a line saying so the only trace of
527- someone joining is a number quietly changing in a banner."
528- [channel st]
529- (av/apply-state! channel st)
530- ;; And try to dial. The token may already be in hand — from this join, or
531- ;; from the last time we were in this same session — in which case the
532- ;; server's agreement that we are in the call is the last thing we were
533- ;; waiting for. `try-start-media!` refuses if there is nothing to dial with
534- ;; or a call is already up, so calling it on every state change is safe.
535- (when (av/in-call? channel)
536- (av/try-start-media! @form-host))
537- (let [line (av/state-message st)]
538- (when (seq line)
539- (push-message! channel "*" line))))
540-
541-(defn apply-call-error!
542- "A `+freeq.at/av-error`. Most say the call failed; one says we lost a race.
543-
544- `start-collision` means our `av-start` and someone else's crossed and theirs
545- won. The server names the winning session, so the answer is to join that one
546- rather than to report an error for something the person asked for and can
547- have — they wanted to be in a call in this room, and there is one."
548- [tags code]
549- (let [reason (or (irc/tag-value tags "+freeq.at/av-reason") code)
550- session-id (irc/tag-value tags "+freeq.at/av-id")
551- lc @av/local-call
552- channel (:channel lc)]
553- (if (and (= "start-collision" code) (seq session-id) channel
554- (or (:awaiting-start? lc) (str/blank? (:session-id lc))))
555- (do
556- (push-message! channel "*" "Call already open — joining it instead")
557- (av/stop-media!)
558- (join-call! channel session-id))
559- (do
560- (when channel
561- (push-message! channel "*" (str "Call error: " reason)))
562- ;; Only tear down a call the error is actually about. A `join-failed`
563- ;; naming someone else's session is not ours to act on.
564- (when (and lc
565- (or (str/blank? (or session-id ""))
566- (str/blank? (:session-id lc))
567- (= session-id (:session-id lc))))
568- (av/stop-media!))))))
569-
570-;; Which channel's POLICY reply is outstanding, or nil. The server answers
571-;; POLICY with a run of NOTICEs addressed to our nick and naming no channel;
572-;; without this they land in the status banner one at a time, and the rules
573-;; the reader is being asked to accept are never readable.
574-;;
575-;; ponytail: the run ends at the first line that is not a NOTICE, so a PING
576-;; landing mid-answer truncates the rules. A reply-tag or a POLICY numeric
577-;; from the server would end it properly.
578-(defonce ^:private policy-asking (atom nil))
579-
580-(defn ask-policy!
581- "Ask the server what this channel's policy says, so the reader can read what
582- they are being asked to accept.
583-
584- The answer comes back as plain NOTICEs to our nick, naming no channel — so
585- the question is remembered here, and the lines that follow it are filed
586- under the channel that asked."
587- [ch]
588- (swap! channels #(assoc-in (ensure-channel % ch) [ch :policy-text] []))
589- (reset! policy-asking ch)
590- (when-let [c @conn]
591- (irc/send-line! c (str "POLICY " ch " RULES"))
592- (irc/send-line! c (str "POLICY " ch " INFO"))))
593-
594-(defn accept-policy!
595- "Accept the channel's policy and go back in. The JOIN follows immediately:
596- accepting is only ever done in order to be in the room, and the server takes
597- the two in the order they are sent."
598- [ch]
599- (when-let [c @conn]
600- (irc/send-line! c (str "POLICY " ch " ACCEPT"))
601- (swap! channels #(-> (ensure-channel % ch)
602- (assoc-in [ch :policy-required?] false)
603- (assoc-in [ch :joining?] true)))
604- (irc/join! c ch)))
605-
606-(defn apply-msg!
607- "Fold one parsed IRC message into the state."
608- [msg]
609- (let [{:keys [command params prefix]} msg
610- from (irc/nick-of prefix)]
611- (when (and @policy-asking (not= command "NOTICE"))
612- (reset! policy-asking nil))
613- (case command
614- "001" (do (reset! status (if @session
615- (str "Connected as " (:handle @session))
616- (str "Connected as " @form-nick)))
617- (reset! connecting? false)
618- (reset! screen :chats)
619- ;; This session decides once whether it is the one that
620- ;; takes the room list over from the server. Before the flag
621- ;; is set the file has never been told what we are in, so the
622- ;; server's answer is adopted rather than argued with.
623- (reset! adopting-rooms? (not @room-list-owned?))
624- (when-not @room-list-owned?
625- (reset! room-list-owned? true)
626- (save-prefs!))
627- ;; What the file says we are in, we ask to be in. The server
628- ;; forgets rooms, and a room it has forgotten is one that
629- ;; would otherwise quietly stop existing.
630- (join-saved-rooms!)
631- ;; Back where the reader left off. The list is still what a
632- ;; connect lands on underneath, so Back from the reopened
633- ;; channel goes to the chats rather than out of the app.
634- (if-let [last-ch (first (channel-order))]
635- (open-channel! last-ch)
636- (join! auto-join)))
637- "PRIVMSG" (let [[target text] params
638- ;; The server's clock when it offers one: a replayed
639- ;; backlog is hours or weeks old, and stamping it with
640- ;; the moment it arrived would say it all happened now.
641- at (or (clock/parse-time-tag (:tags msg)) (clock/now-ms))
642- tags (:tags msg)
643- ;; a DM addressed to us belongs in a buffer named for the
644- ;; sender, not for our own nick — except when the sender
645- ;; is us: `echo-message` sends our own DM back, and the
646- ;; buffer it belongs to is the one we sent it to.
647- buffer (cond
648- (str/starts-with? (or target "") "#") target
649- (= from @form-nick) target
650- :else from)
651- ;; What this message rewrites, when it is a rewrite. The
652- ;; server canonicalises the name to `+draft/edit`.
653- edit-of (irc/tag-value tags "+draft/edit")
654- ;; And what the server says about a line it has already
655- ;; collapsed: replay sends one row per message, carrying
656- ;; the current text and no `+draft/edit` to hint that it
657- ;; is not the original. This tag is the only trace.
658- replayed-edit? (= "1" (irc/tag-value tags "+freeq.at/edited"))]
659- (if edit-of
660- ;; A revision is not a new line: it replaces the one it
661- ;; names, under that line's own id rather than its own
662- ;; wire msgid. That msgid is not nothing, though — an
663- ;; answer to a line already rewritten names the revision,
664- ;; because the revision is the wording being answered — so
665- ;; it rides along on the message as a name it also
666- ;; answers to.
667- (when (= :absent (edit-message! buffer edit-of from text
668- (irc/tag-value tags "msgid")))
669- ;; The original is outside the backlog we hold, so show
670- ;; the current text rather than dropping what was said.
671- (push-message! buffer from text
672- {:at at
673- :did (:account msg)
674- :id edit-of
675- ;; Same two names, for the line we are
676- ;; showing in place of the original.
677- :edit-ids #{(irc/tag-value tags "msgid")}
678- :edited? true
679- :reply-to (or (irc/tag-value tags "+reply")
680- (irc/tag-value tags "+draft/reply"))}))
681- (push-message! buffer from text
682- {:at at
683- :did (:account msg)
684- :id (irc/tag-value tags "msgid")
685- :edited? replayed-edit?
686- ;; The server canonicalises +draft/reply to
687- ;; +reply; a client that sent the draft
688- ;; name may still reach us before it does.
689- :reply-to (or (irc/tag-value tags "+reply")
690- (irc/tag-value tags "+draft/reply"))
691- :reactions (parse-reactions
692- (irc/tag-value tags "+freeq.at/reactions"))})))
693- ;; A message that is only tags. A reaction is the one this client reads:
694- ;; `+react` puts an emoji on the message `+reply` names, and the server's
695- ;; own `+freeq.at/unreact` takes it off again.
696- "TAGMSG" (let [tags (:tags msg)
697- target (first params)
698- buffer (if (str/starts-with? (or target "") "#") target from)
699- msgid (or (irc/tag-value tags "+reply")
700- (irc/tag-value tags "+draft/reply"))
701- add (or (irc/tag-value tags "+react")
702- (irc/tag-value tags "+draft/react"))
703- remove-it (irc/tag-value tags "+freeq.at/unreact")
704- call-state (av/parse-state tags)
705- ;; The token is directed at our own nick rather than at
706- ;; the channel, so `buffer` is a DM key here and says
707- ;; nothing about which call it is for. The session id in
708- ;; the tag is what does.
709- token (irc/tag-value tags "+freeq.at/av-token")
710- call-error (irc/tag-value tags "+freeq.at/av-error")]
711- (cond
712- add (update-reaction! buffer msgid add from true)
713- remove-it (update-reaction! buffer msgid remove-it from false)
714- call-state (apply-call-state! buffer call-state)
715- token (av/apply-token! @form-host
716- (irc/tag-value tags "+freeq.at/av-id")
717- token)
718- call-error (apply-call-error! tags call-error)
719- :else nil))
720- "JOIN" (let [ch (first params)]
721- (if (= from @form-nick)
722- ;; Ours if the file says so — restored from rooms.edn, or
723- ;; asked for since. Anything else is the server putting us
724- ;; somewhere we did not ask to be, which it does: it
725- ;; announces memberships that are not real, and adding them
726- ;; is how a list nobody chose fills up with rooms.
727- ;;
728- ;; So we leave again, unless this is the session that is
729- ;; still adopting — on the first connect the file has not
730- ;; been told anything yet, and parting then would be leaving
731- ;; every room we are actually in.
732- (if (and (not (contains? @channels ch))
733- (not @adopting-rooms?))
734- (when @conn (irc/part! @conn ch))
735- (let [fresh? (empty? (get-in @channels [ch :messages]))]
736- (swap! channels #(-> (ensure-channel % ch)
737- (assoc-in [ch :joined?] true)
738- (assoc-in [ch :joining?] false)
739- ;; In the room: whatever it was asking
740- ;; for, it is not asking any more.
741- (assoc-in [ch :policy-required?] false)))
742- ;; Only on the way in to an empty buffer. A reconnect joins
743- ;; every channel again, and saying so on top of the backlog
744- ;; already there is just a second line of noise.
745- (when fresh? (push-message! ch "*" (str "Joined " ch)))
746- ;; Adopted or asked for, it is ours now and the file should
747- ;; say so before the next connect judges it.
748- (remember-rooms! true)))
749- ;; Somebody else arriving in a room we do not hold is not a
750- ;; reason to start holding it: `add-user!` and `push-message!`
751- ;; both build the buffer they are given, so either one would
752- ;; put the refused room back in the list.
753- (when (contains? @channels ch)
754- (add-user! ch from)
755- (when-not @hide-join-part?
756- (push-message! ch "*" (str from " joined"))))))
757- ;; NAMES, a line at a time. The channel is the parameter that names one:
758- ;; the reply is `<us> <symbol> <channel> :<names>`, and a server that
759- ;; leaves the symbol out shifts everything before the list along by one.
760- "353" (let [ch (first (filter #(str/starts-with? (or % "") "#") params))]
761- (when ch (names-line ch (last params))))
762- ;; End of NAMES. A plain JOIN is replayed history before this arrives, so
763- ;; a channel that reaches here with nothing in it was restored rather
764- ;; than joined — freeq re-joins an authenticated user's channels at
765- ;; registration and leaves the backlog for the client to ask for.
766- "366" (let [ch (second params)
767- said (remove :system? (get-in @channels [ch :messages]))]
768- (names-end! ch)
769- (when (and ch @conn (empty? said))
770- (irc/send-line! @conn
771- (str "CHATHISTORY LATEST " ch " * " history-limit))))
772-
773- ;; `assoc-in` on a channel that is not there does not fail, it invents
774- ;; one — a buffer with a `:joined?` and nothing else, no name and no
775- ;; unread, which the room list then tries to draw. That is not
776- ;; hypothetical now: refusing a room the file does not claim sends PART,
777- ;; and the server echoes it straight back at us. A membership changing in
778- ;; a room we do not hold is nothing to record.
779- "PART" (let [ch (first params)]
780- (if (= from @form-nick)
781- (swap! channels #(if (contains? % ch)
782- (-> % (assoc-in [ch :joined?] false)
783- (assoc-in [ch :joining?] false)
784- (assoc-in [ch :users] {}))
785- %))
786- (when (contains? @channels ch)
787- (remove-user! ch from)
788- (when-not @hide-join-part?
789- (push-message! ch "*" (str from " left"))))))
790- "KICK" (let [[ch who] params]
791- (if (= who @form-nick)
792- (swap! channels #(if (contains? % ch)
793- (-> % (assoc-in [ch :joined?] false)
794- (assoc-in [ch :joining?] false)
795- (assoc-in [ch :users] {}))
796- %))
797- (remove-user! ch who))
798- (when (contains? @channels ch)
799- (push-message! ch "*" (str who " was kicked by " from))))
800- ;; A QUIT and a NICK name no channel, so both are folded into every
801- ;; buffer the person was listed in — and said out loud only where they
802- ;; were, which is what keeps a stranger's rename out of a quiet room.
803- "QUIT" (let [rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)]
804- (when-not @hide-join-part?
805- (doseq [ch rooms]
806- (push-message! ch "*" (str from " quit"))))
807- (remove-user-everywhere! from))
808- "NICK" (let [new-nick (last params)
809- rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)]
810- (when (= from @form-nick) (reset! form-nick new-nick))
811- (doseq [ch rooms]
812- (push-message! ch "*" (str from " is now " new-nick)))
813- (rename-user! from new-nick))
814- "MODE" (let [[target modes & args] params]
815- (when (str/starts-with? (or target "") "#")
816- (apply-mode! target modes args)))
817- "NOTICE"
818- (let [text (str/trimr (or (last params) ""))]
819- (cond
820- ;; The server's half of freeq that is REST rather than IRC: sent
821- ;; once, straight after SASL succeeds, and the only way to get one.
822- ;; Kept rather than shown — see `frq.replies`, which spends it
823- ;; asking what a msgid was.
824- (str/starts-with? text "API-BEARER ")
825- (reset! cells/api-bearer (str/trim (subs text (count "API-BEARER "))))
826-
827- @policy-asking
828- (swap! channels #(update-in (ensure-channel % @policy-asking)
829- [@policy-asking :policy-text]
830- (fnil conj []) text))
831-
832- :else (reset! status (or (last params) @status))))
833- ("372" "375" "376" "002" "003" "004")
834- (reset! status (or (last params) @status))
835- ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration,
836- ;; 471 full, 403 no such channel. The channel is params[1]; clearing its
837- ;; flags is what lets a later attempt send a JOIN at all.
838- ("473" "474" "475" "477" "403" "471")
839- (let [ch (second params)
840- why (last params)]
841- (when ch
842- (swap! channels #(-> (ensure-channel % ch)
843- (assoc-in [ch :joined?] false)
844- (assoc-in [ch :joining?] false)))
845- (push-message! ch "*" (str "Could not join " ch " — " why))
846- ;; The one refusal the reader can answer themselves: the room is not
847- ;; shut to them, it is waiting on them to say yes to something. The
848- ;; flag is what puts the Accept button in the channel, and the rules
849- ;; are asked for so it is not a yes to an unread page.
850- (when (str/includes? (str/lower-case (or why "")) "policy")
851- (swap! channels #(assoc-in % [ch :policy-required?] true))
852- (ask-policy! ch)))
853- ;; Deliberately not the global banner: it outlives the screen it was
854- ;; about, and the reason is in the channel's own buffer where it
855- ;; belongs. The banner is for what stops the whole app — a failed
856- ;; connection or a refused sign-in.
857- (when-not ch (reset! error (str "Cannot join: " why))))
858- ;; What the server refused and why, in the reader's words. An edit or a
859- ;; reaction it will not take is otherwise silent: the line on screen
860- ;; simply never changes, which reads as the app having lost it.
861- "FAIL" (let [[what _code] params]
862- (reset! error (str (or what "Request") " refused — "
863- (or (last params) "no reason given"))))
864- "903" (reset! status (str "Signed in as " (:handle @session)))
865- ("904" "905" "906") (do (reset! session nil)
866- ;; The broker token may still be good — but a
867- ;; refusal is as likely to mean it is not, and a
868- ;; stale one would fail the same way every time,
869- ;; including across restarts if it were kept.
870- (reset! broker-token nil)
871- (store/clear-session!)
872- (reset! error (str "Bluesky sign-in refused: "
873- (or (last params) "no reason given"))))
874- ;; The signing key belonged to that connection: the server forgets it
875- ;; when the session ends, and signing with it afterwards would be
876- ;; signing with a key nobody can check.
877- "*DISCONNECTED*" (do (msgsig/forget!)
878- ;; The bearer belongs to the session that is over,
879- ;; and what was asked under it deserves asking
880- ;; again under the next one.
881- (reset! cells/api-bearer nil)
882- (replies/forget-asks!)
883- (reset! conn nil)
884- (reset! connecting? false)
885- (swap! channels
886- #(reduce-kv (fn [m k v]
887- (assoc m k (assoc v :joined? false :joining? false :users {})))
888- {} %))
889- (reset! status "Disconnected"))
890- "*ERROR*" (do (reset! error (first params))
891- (reset! connecting? false))
892- nil)))
893-
894-(def plain-port 6667)
895-
896-(defn- describe
897- "What went wrong, in words. A jolt condition prints as #object[:object], so
898- the message and the ex-data are what has to be dug out by hand."
899- [e]
900- (let [msg (ex-message e)]
901- (if (seq msg)
902- msg
903- ;; A raw host condition prints as #object[:object] and says nothing, so
904- ;; its type is the only thing left worth showing.
905- (str (type e) ": " (str e)))))
906-
907-(defn- dial! [host port nick tls? sess]
908- ;; stderr is the only console on Android — this line lands in logcat.
909- (binding [*out* *err*]
910- (println "frq: dialing" host port (if tls? "tls" "plain")))
911- (reset! conn (irc/connect! host port nick apply-msg! tls? sess)))
912-
913-(defn- connect-blocking!
914- "Sign in if asked to, then dial. Blocking throughout — a browser handoff can
915- take a minute, and the TLS handshake is not instant either."
916- []
917- (reset! error nil)
918- (reset! connecting? true)
919- (reset! status (str "Connecting to " @form-host ":" @form-port "…"))
920- (let [host @form-host
921- port (parse-long (str/trim @form-port))
922- mode @auth-mode
923- sess (case mode
924- ;; OAuth: the browser does the talking, we wait on loopback. A
925- ;; broker token in hand skips the browser entirely.
926- :bluesky
927- (let [handle (str/trim @form-handle)
928- browser! (fn []
929- (reset! status "Opening your browser to sign in…")
930- (oauth/await-callback!
931- oauth/default-broker handle
932- (fn [url]
933- (reset! login-url url)
934- (oauth/open-browser! url)
935- (reset! status "Waiting for the browser…"))))
936- tokens (if-let [bt @broker-token]
937- ;; A saved token that the broker no longer honours
938- ;; is worth exactly one attempt: drop it and go
939- ;; through the browser, rather than failing the
940- ;; same way on every future Connect.
941- (try (reset! status "Resuming your session…")
942- (oauth/refresh-session oauth/default-broker bt)
943- (catch Exception _
944- (reset! broker-token nil)
945- (store/clear-session!)
946- (reset! status "Saved session expired — signing in again…")
947- (browser!)))
948- (browser!))
949- s (assoc tokens :kind :web-token)]
950- (reset! login-url nil)
951- (reset! broker-token (:broker-token tokens))
952- ;; Saved on every sign-in, not only the first: /session can
953- ;; hand back a rotated broker token, and the old one may stop
954- ;; working the moment it does.
955- (store/save-session! tokens)
956- (reset! session s)
957- (when (seq (:handle tokens)) (reset! form-handle (:handle tokens)))
958- s)
959-
960- :app-password
961- (do (reset! status (str "Signing in as " (str/trim @form-handle) "…"))
962- (let [s (assoc (atproto/create-session (str/trim @form-handle)
963- @form-app-password)
964- :kind :pds-session)]
965- (reset! session s)
966- ;; The password did its work at the PDS; do not keep it.
967- (reset! form-app-password "")
968- s))
969-
970- nil)
971- ;; An authenticated connection still needs a nick — the DID is the
972- ;; identity, the nick is only what the channel calls you.
973- nick (if sess
974- (or (:nick sess)
975- (-> (or (:handle sess) "") (str/split #"\\.") first)
976- (str/trim @form-nick))
977- (str/trim @form-nick))]
978- (when sess (reset! form-nick nick))
979- (try
980- (dial! host port nick @form-tls? sess)
981- (catch Exception e
982- (binding [*out* *err*] (println "frq: dial failed:" (describe e)))
983- (if @form-tls?
984- (do (reset! status (str "TLS unavailable — trying " host ":" plain-port "…"))
985- (try
986- (dial! host plain-port nick false sess)
987- (reset! form-tls? false)
988- (reset! form-port (str plain-port))
989- (catch Exception e2
990- (binding [*out* *err*] (println "frq: plain dial failed:" (describe e2)))
991- (reset! connecting? false)
992- (reset! conn nil)
993- (reset! status "Not connected")
994- (reset! error (str "Could not connect: " (describe e2))))))
995- (do (reset! connecting? false)
996- (reset! conn nil)
997- (reset! status "Not connected")
998- (reset! error (str "Could not connect: " (describe e)))))))))
999-
1000-(defn connect!
1001- "Start connecting. The work happens on another thread: the OAuth wait sits on
1002- a loopback accept until the browser comes back, and the UI has frames to
1003- paint in the meantime.
1004-
1005- A second call while one is in flight is ignored. Dialling twice does not just
1006- waste a socket: the server treats the second session as a reconnect of the
1007- first, and a reconnect is not replayed the channel history a fresh join gets,
1008- so the second connection — the one the UI ends up holding — shows an empty
1009- channel."
1010- []
1011- (when-not (or @connecting? @conn)
1012- (reset! error nil)
1013- (reset! connecting? true)
1014- (future
1015- (try (connect-blocking!)
1016- (catch Exception e
1017- (reset! connecting? false)
1018- (reset! conn nil)
1019- (reset! status "Not connected")
1020- (reset! error (str "Could not connect: " (describe e))))))))
1021-
1022-(defn restore-session!
1023- "Pick up a saved sign-in at startup. Only the durable broker token comes
1024- back; the connection still mints a fresh web-token from it."
1025- []
1026- (when-let [saved (store/load-session)]
1027- (reset! broker-token (:broker-token saved))
1028- (when (seq (:handle saved)) (reset! form-handle (:handle saved)))
1029- (when (seq (:nick saved)) (reset! form-nick (:nick saved)))
1030- (reset! auth-mode :bluesky)
1031- (reset! status (str "Signed in as " (:handle saved) " — Connect to resume"))
1032- saved))
1033-
1034-(defn forget-session!
1035- "Drop the saved sign-in, on disk and in memory."
1036- []
1037- (store/clear-session!)
1038- (reset! broker-token nil)
1039- (reset! cells/api-bearer nil)
1040- (replies/forget-asks!)
1041- (reset! session nil)
1042- (reset! auth-mode :guest)
1043- (reset! status "Not connected"))
1044-
1045-(defn disconnect! []
1046- (when-let [c @conn] (irc/close! c))
1047- (reset! conn nil)
1048- (reset! session nil)
1049- ;; The buffers survive, the memberships do not — leaving `joined?` set would
1050- ;; have the next Open show a channel nobody is in.
1051- (swap! channels #(reduce-kv (fn [m k v]
1052- (assoc m k (assoc v :joined? false :joining? false :users {})))
1053- {} %))
1054- (reset! status "Not connected")
1055- (reset! screen :connect))
1056-
1057-(defn open-dm!
1058- "Open a conversation with one person. There is nothing to join — a DM buffer
1059- is a place to type at somebody, and it exists as soon as it is asked for.
1060-
1061- Our own nick is not one of them: a buffer talking to yourself would take the
1062- place in the list of one that could answer."
1063- [nick]
1064- (let [nick (str/trim (or nick ""))]
1065- (when (and (seq nick) (not= nick @form-nick))
1066- (open-channel! nick))))
1067-
1068-(defn join! [name]
1069- ;; Deliberately not clearing `error` here: joining is what follows a
1070- ;; successful registration, and a SASL refusal that arrived moments earlier
1071- ;; is the one thing the user most needs to still be on screen.
1072- ;;
1073- ;; `@nick` opens a DM instead. One box for both: what the reader wants is to
1074- ;; be somewhere, and the sigil says where — the same way it does on the wire.
1075- (let [name (str/trim (or name ""))]
1076- (if (str/starts-with? name "@")
1077- (open-dm! (subs name 1))
1078- (let [ch (normalize-channel name)]
1079- (when (seq ch)
1080- (open-channel! ch))))))
1081-
1082-;; ------------------------------------------------------------------ pasting
1083-
1084-(def attachment cells/attachment)
1085-
1086-;; Each paste gets a file of its own rather than overwriting the last: the
1087-;; preview is painted from the file, and an upload may still be reading it.
1088-(defonce ^:private paste-count (atom 0))
1089-
1090-(defn- paste-path []
1091- (let [n (swap! paste-count inc)]
1092- (str (media/cache-dir) "/outgoing/paste-" n ".png")))
1093-
1094-(defn- discard-file!
1095- "Drop a paste's copy on disk. Nothing else keeps it: the picture that matters
1096- after sending is the one the server serves back, which the media cache
1097- fetches like any other."
1098- [path]
1099- (when path (try (host/delete-file! path) (catch Exception _ nil))))
1100-
1101-(defn clear-attachment!
1102- "Drop the pasted picture without sending it."
1103- []
1104- (when-let [a @attachment]
1105- (reset! attachment nil)
1106- (discard-file! (:path a))))
1107-
1108-(defn- attach!
1109- "Hold the picture already written to `path` — a copy of ours under
1110- `outgoing/` — against the next line, and start its upload.
1111-
1112- The upload runs off the UI thread and starts at once rather than at send, so
1113- by the time a line is written the picture is usually already up. A failure
1114- lands in `error` like any other, and takes the attachment with it — there is
1115- nothing to send and nothing to show.
1116-
1117- `filename` is what the server files it under; it says which gesture the
1118- picture came in by, and nothing else depends on it."
1119- [path filename]
1120- (let [did (:did @session)
1121- host-name @form-host
1122- channel @current]
1123- (reset! error nil)
1124- (clear-attachment!)
1125- (reset! attachment {:path path :status :uploading})
1126- (future
1127- (try
1128- (let [url (upload/upload! host-name did channel path filename)]
1129- ;; Only if this is still the picture on screen: a reader who attached
1130- ;; another, or cleared it, has said what they want, and an upload
1131- ;; landing afterwards does not get to undo that.
1132- (swap! attachment #(if (= (:path %) path)
1133- (assoc % :url url :status :ready)
1134- %))
1135- (when-not (= (:path @attachment) path) (discard-file! path)))
1136- (catch Exception e
1137- (swap! attachment #(if (= (:path %) path) nil %))
1138- (discard-file! path)
1139- (reset! error (or (ex-message e) (str e))))))))
1140-
1141-(defn paste-image!
1142- "Take the picture on the clipboard and hold it against the next line.
1143-
1144- IRC has nowhere to put an image, so a link is the whole of what sending one
1145- means — but that is a fact about the wire, not something the reader should
1146- have to type around. The picture is attached: shown under the draft while
1147- they write whatever they are sending it with, and turned into a link only on
1148- the way out."
1149- []
1150- (let [path (paste-path)]
1151- (host/mkdirs! (str (media/cache-dir) "/outgoing"))
1152- (if-not (platform/clipboard-image-png! path)
1153- ;; Android has no clipboard of pictures to read at all, which is the
1154- ;; other half of why the picker below exists.
1155- (reset! error "No picture on the clipboard.")
1156- (attach! path "paste.png"))))
1157-
1158-;; ------------------------------------------------------------------ picking
1159-
1160-(def image-picker cells/image-picker)
1161-
1162-(defn- readable-dir? [path]
1163- (try (and (host/file-exists? path) (host/directory? path))
1164- (catch Exception _ false)))
1165-
1166-(defn picker-roots
1167- "The places worth opening the picker on, on whichever platform this is.
1168-
1169- Only the ones that are actually there: a phone has no ~/Pictures and a
1170- desktop no /sdcard, and a list of directories that are not there is a list of
1171- dead ends. On Android everything outside the app's own storage is behind a
1172- runtime permission this activity has no code to ask for, so what survives
1173- this filter there is usually the app's own files — which is the honest
1174- answer, not a bug to paper over."
1175- []
1176- (let [home (or (host/getenv "HOME") "")
1177- under (fn [base] (when (seq base)
1178- (map #(str base "/" %)
1179- ["Pictures" "Downloads" "Download" "DCIM"])))]
1180- (vec (distinct (filter readable-dir?
1181- (concat (under home)
1182- (under "/sdcard")
1183- (under "/storage/emulated/0")
1184- [(media/cache-dir) home]))))))
1185-
1186-(defn- png? [name]
1187- (str/ends-with? (str/lower-case (str name)) ".png"))
1188-
1189-(defn picker-entries
1190- "What `dir` holds, as `{:dirs [...] :files [...]}` of full paths.
1191-
1192- PNG only, for the same reason the media cache reads PNG only: it is what the
1193- tree backend paints and what the upload sends. An unreadable directory —
1194- which on Android is most of them — answers empty rather than throwing.
1195-
1196- Hidden entries are left out: nothing a reader means to send lives in one, and
1197- a home directory is unusable as a list with them in it."
1198- [dir]
1199- (let [names (try (sort (host/list-dir dir)) (catch Exception _ nil))
1200- keep (remove #(str/starts-with? (str %) ".") names)
1201- path (fn [n] (str dir "/" n))]
1202- {:dirs (vec (filter readable-dir? (map path keep)))
1203- :files (vec (map path (filter png? keep)))}))
1204-
1205-(defn parent-dir
1206- "The directory above `dir`, or nil at the top."
1207- [dir]
1208- (let [up (str/join "/" (butlast (str/split (str dir) #"/")))]
1209- (when (and (seq up) (not= up dir) (readable-dir? up)) up)))
1210-
1211-;; ------------------------------------------- the platform's own chooser
1212-
1213-;; Polling, because a chooser is another app's screen: it takes the reader away
1214-;; and gives nothing back through a handler here. `choosing` is what the poll
1215-;; runs on, and the count is what ends it — a reader who backs out without
1216-;; choosing tells us nothing at all, so the alternative is a poll that outlives
1217-;; the app's interest in the answer.
1218-(defonce ^:private choosing (atom nil))
1219-
1220-(def ^:private choose-poll-ms 300)
1221-
1222-(def ^:private choose-poll-limit
1223- "Five minutes of asking. Long enough for someone who wandered off mid-choice,
1224- short enough that a cancelled chooser is not still being polled for at
1225- bedtime."
1226- 1000)
1227-
1228-(defn- take-chosen!
1229- "Attach the picture the chooser has written, if it has written one yet."
1230- []
1231- (let [path (paste-path)]
1232- (host/mkdirs! (str (media/cache-dir) "/outgoing"))
1233- (when (platform/picked-image! path)
1234- (reset! choosing nil)
1235- (attach! path "picture.png")
1236- true)))
1237-
1238-(defn- poll-chosen! []
1239- (when-let [left @choosing]
1240- (when-not (take-chosen!)
1241- (if (pos? left)
1242- (do (reset! choosing (dec left))
1243- (platform/after! choose-poll-ms poll-chosen!))
1244- (reset! choosing nil)))))
1245-
1246-(defn choose-image!
1247- "Open the platform's own picture chooser, where there is one; true when it
1248- opened.
1249-
1250- Preferred to browsing on a phone, and not only for the taste of it: what the
1251- chooser hands back is a grant for the one picture the reader chose, so the
1252- app needs no permission over their pictures at all — and without such a
1253- permission, browsing finds almost nothing to show. False where there is no
1254- chooser, which is every desktop, and there browsing is the answer."
1255- []
1256- (when (platform/pick-image!)
1257- (reset! error nil)
1258- (reset! choosing choose-poll-limit)
1259- (platform/after! choose-poll-ms poll-chosen!)
1260- true))
1261-
1262-(defn open-image-picker!
1263- "Ask for a picture, whichever way this platform has of choosing one.
1264-
1265- The platform's own chooser where there is one — it needs no permission and
1266- knows where the reader's pictures actually are — and otherwise this app's
1267- own browsing screen, which is what a desktop gets."
1268- []
1269- (when-not (choose-image!)
1270- (reset! error nil)
1271- (reset! image-picker (or (first (picker-roots)) "/"))))
1272-
1273-(defn close-image-picker! [] (reset! image-picker nil))
1274-
1275-(defn browse! [dir] (when (readable-dir? dir) (reset! image-picker dir)))
1276-
1277-(defn- copy-file!
1278- "Copy `from` to `to`, byte for byte."
1279- [from to]
1280- (let [in (java.io.FileInputStream. from)]
1281- (try
1282- (let [out (java.io.FileOutputStream. to)]
1283- (try (.write out (.readAllBytes in))
1284- (finally (.close out))))
1285- (finally (try (.close in) (catch Exception _ nil))))))
1286-
1287-(defn pick-image!
1288- "Attach the picture at `path` and close the picker.
1289-
1290- Copied into `outgoing/` first rather than attached where it lies: the send
1291- drops the attachment's file when it is done with it, and what it drops has to
1292- be ours — not the reader's own picture, sitting in their pictures folder."
1293- [path]
1294- (let [copy (paste-path)]
1295- (try
1296- (host/mkdirs! (str (media/cache-dir) "/outgoing"))
1297- (copy-file! path copy)
1298- (close-image-picker!)
1299- (attach! copy (or (last (str/split (str path) #"/")) "picture.png"))
1300- (catch Exception e
1301- (discard-file! copy)
1302- (reset! error (str "Could not read that picture: " (or (ex-message e) e)))))))
1303-
1304-(defn- dm-peer-did
1305- "Who this DM is with. `frq.reactions` reads it out of the buffer."
1306- [channel]
1307- (reactions/peer-did @channels channel @form-nick))
1308-
1309-(defn mine?
1310- "Whether we are the one who said this. Nick against nick, which is what the
1311- server itself falls back to for an account with no DID — and an edit it would
1312- refuse is one not worth offering."
1313- [m]
1314- (rooms/mine? m @form-nick))
1315-
1316-(defn start-edit!
1317- "Put a message back in the box to be rewritten.
1318-
1319- The old text is the starting point rather than an empty line: an edit is
1320- usually a word, and retyping the sentence around it is not what was asked
1321- for. Whatever was half-typed is dropped — a draft and an edit are two things
1322- to say, and the box holds one."
1323- [channel m]
1324- (when (and (:id m) (mine? m))
1325- (reset! replying-to nil)
1326- (reset! editing {:channel channel :id (:id m)})
1327- (reset! draft (or (:text m) ""))))
1328-
1329-(defn cancel-edit!
1330- "Leave the message as it was said. The box empties with it: what is in it is
1331- a copy of the line on screen, and leaving that behind would look like a draft
1332- the reader wrote."
1333- []
1334- (reset! editing nil)
1335- (reset! draft ""))
1336-
1337-(defn send-draft!
1338- "Send the draft, with whatever picture is attached to it.
1339-
1340- The picture becomes its link, at the end of the line: what goes on the wire
1341- is the text the reader wrote and a URL after it, which is what every other
1342- client in the channel knows how to show. A line that is only a picture is
1343- only the link.
1344-
1345- A picture still on its way up holds the send rather than losing it: the line
1346- is left in the box, said so, and the reader presses send again a moment
1347- later. Sending the text without its picture would be the one outcome nobody
1348- asked for.
1349-
1350- A draft with a break in it is several messages. There is no newline on the
1351- wire — a PRIVMSG is one line and a line ends where the protocol says it does
1352- — so the box that lets a reader write a paragraph has to be the thing that
1353- takes it apart again: one message a line, in order, blank lines dropped. Only
1354- the last one carries the picture, and only the first one answers the message
1355- being replied to; the rest are the same thought continuing."
1356- []
1357- (let [lines (->> (str/split-lines @draft)
1358- (map str/trim)
1359- (remove str/blank?))
1360- ;; What the branches below that are about one line read: a command and
1361- ;; a rewrite are single-line things whatever the box holds.
1362- text (str/join " " lines)
1363- target @current
1364- reply-to @replying-to
1365- edit @editing
1366- {:keys [url status path] :as att} @attachment]
1367- (cond
1368- (not target) nil
1369- ;; A line beginning with "/" is said to the server, not to the room:
1370- ;; POLICY, MODE, whatever the server asks for by name. Without it a
1371- ;; channel that answers a JOIN with "use POLICY <channel> ACCEPT" is one
1372- ;; the reader can see the instructions for and has no way to follow.
1373- ;; "//" is how you say a line that really does start with a slash.
1374- (and (str/starts-with? text "/") (not (str/starts-with? text "//")))
1375- (if-let [c @conn]
1376- (let [line (str/trim (subs text 1))]
1377- (when (seq line)
1378- (irc/send-line! c line)
1379- (push-message! target "*" (str "> " line)))
1380- (reset! draft ""))
1381- (reset! error "Not connected."))
1382- (= :uploading status) (reset! error "The picture is still uploading.")
1383- ;; A rewrite replaces what was said, and what was said is a line of text:
1384- ;; there is no wire form for adding a picture to a message already sent,
1385- ;; so the attachment is held back rather than silently dropped.
1386- (and edit att) (reset! error "Finish the edit before sending a picture.")
1387- (and edit (str/blank? text)) nil
1388- edit
1389- (do (when-let [c @conn]
1390- (irc/edit! c (:channel edit) (:id edit) text
1391- (dm-peer-did (:channel edit))))
1392- ;; Same reason as a new message: the server's echo is the copy that
1393- ;; every other client sees, and folding this one in as well would
1394- ;; rewrite the line twice. Without echo-message nothing comes back,
1395- ;; so the rewrite has to be applied here or it never shows.
1396- (when-not (some-> @conn (irc/cap-acked? "echo-message"))
1397- (edit-message! (:channel edit) (:id edit) @form-nick text))
1398- (reset! editing nil)
1399- (reset! draft ""))
1400- (and (str/blank? text) (not url)) nil
1401- :else
1402- (let [lines (map #(if (str/starts-with? % "//") (subs % 1) %) lines)
1403- ;; The picture rides the last line, so a message that is only a
1404- ;; picture is the link on its own.
1405- lines (if (seq lines) (vec lines) [""])
1406- last-i (dec (count lines))
1407- lines (map-indexed (fn [i line]
1408- (str/trim (str line (when (and url (= i last-i))
1409- (str " " url)))))
1410- lines)]
1411- ;; Saying something is a way of asking to see it.
1412- (jump-to-present!)
1413- (doseq [[i line] (map-indexed vector lines)]
1414- (when-let [c @conn]
1415- (irc/privmsg! c target line (when (zero? i) (:id reply-to))))
1416- ;; Only when the server will not send the line back itself. Its copy
1417- ;; carries the msgid, and a message with no id is one nobody can react
1418- ;; or reply to; echoing locally as well would put the line up twice.
1419- (when-not (some-> @conn (irc/cap-acked? "echo-message"))
1420- (push-message! target @form-nick line
1421- {:reply-to (when (zero? i) (:id reply-to))})))
1422- (reset! replying-to nil)
1423- (reset! draft "")
1424- (when att
1425- (reset! attachment nil)
1426- ;; The picture on screen from here on is the one fetched back from
1427- ;; the link, like everyone else's.
1428- (discard-file! path))))))
1429-
1430-
1431-(defn open-picker!
1432- "Choose an emoji for this message. Opening it fresh — no leftover search from
1433- the last time, which would be a screen of somebody else's question."
1434- [channel m]
1435- (when (:id m)
1436- (reset! emoji-search "")
1437- (reset! emoji-group nil)
1438- (reset! reacting {:channel channel :id (:id m)})))
1439-
1440-(defn close-picker! [] (reset! reacting nil))
1441-
1442-(def picker-emoji
1443- "Moved to `frq.reactions`: it is the cells and the catalog, both of which
1444- are shared, and the phone shows the same picker."
1445- reactions/picker-emoji)
1446-
1447-(defn my-reaction?
1448- "Whether this nick is already on that emoji — which is what makes a second
1449- click take it off rather than send the same reaction twice."
1450- [m emoji]
1451- (reactions/mine? m emoji @form-nick))
1452-
1453-(def reaction-hover cells/reaction-hover)
1454-
1455-(defn hover-reaction!
1456- "The pointer has come to rest on a pill."
1457- [msgid emoji]
1458- (reset! reaction-hover {:id msgid :emoji emoji}))
1459-
1460-(defn unhover-reaction!
1461- "The pointer has left that pill. Guarded by which one is being left, so
1462- crossing straight from one pill to the next — both edges in a frame — cannot
1463- take down the card that has just been raised."
1464- [msgid emoji]
1465- (swap! reaction-hover #(when-not (= {:id msgid :emoji emoji} %) %)))
1466-
1467-(defn hovering-reaction?
1468- "Whether this is the pill the card belongs to."
1469- [msgid emoji]
1470- (= {:id msgid :emoji emoji} @reaction-hover))
1471-
1472-(defn toggle-reaction!
1473- "Put my emoji on a message, or take it off if it is already mine.
1474-
1475- Applied here as well as sent: the server relays a TAGMSG to everyone in the
1476- channel *except* the client that sent it, so without this the pill would only
1477- appear once someone else reacted too."
1478- [channel m emoji]
1479- (when-let [msgid (:id m)]
1480- (let [on? (not (my-reaction? m emoji))
1481- ;; Who the DM is with, for the signature: freeq names a DM by both
1482- ;; DIDs rather than by a nick, and nothing else in a buffer says
1483- ;; which account the other side is. nil in a channel, which is named
1484- ;; by itself.
1485- peer (dm-peer-did channel)]
1486- (when-let [c @conn]
1487- (if on?
1488- (irc/react! c channel msgid emoji peer)
1489- (irc/unreact! c channel msgid emoji peer)))
1490- (update-reaction! channel msgid emoji @form-nick on?))))
1491-
1492-(defn start-call!
1493- "Open a call on this channel.
1494-
1495- Optimistic: the controls appear on the press. What comes back settles it —
1496- an `av-state` says the room has a call, an `av-token` starts the media, and
1497- a `start-collision` means someone beat us to it and we join theirs instead."
1498- [channel]
1499- (when-let [c @conn]
1500- (let [nick (or (:nick @session) @form-nick)
1501- instance (av/begin! {:channel channel
1502- :nick nick
1503- :muted? false
1504- :speaker-muted? false
1505- ;; Audio first, always. A call that opened with
1506- ;; the camera on would be a call that showed
1507- ;; someone's room before they had agreed to.
1508- :camera? false})]
1509- (irc/tagmsg! c channel (av/start-tags instance nil)))))
1510-
1511-(defn join-call!
1512- "Join the call already open on this channel."
1513- [channel session-id]
1514- (when-let [c @conn]
1515- (let [nick (or (:nick @session) @form-nick)
1516- instance (av/begin! {:channel channel
1517- :session-id session-id
1518- :nick nick
1519- :muted? false
1520- :speaker-muted? false
1521- :camera? false})]
1522- (irc/tagmsg! c channel (av/join-tags session-id instance)))))
1523-
1524-(defn announce-leave!
1525- "Tell the room this device is out of a call it did not choose to leave.
1526-
1527- freeq counts a participant until an `av-leave` says otherwise, so a media
1528- plane that fails silently leaves a ghost behind — and the next Join adds
1529- another beside it. Registered with `frq.av` at startup, because that
1530- namespace has no connection to send on."
1531- [{:keys [channel session-id instance]}]
1532- (when (and @conn (seq (or session-id "")))
1533- (irc/tagmsg! @conn channel (av/leave-tags session-id instance))))
1534-
1535-(defn leave-call!
1536- "Leave the call, telling the room and the SFU both.
1537-
1538- The media plane goes down first and on its own account: the person pressed
1539- leave, so the microphone should be shut whether or not the TAGMSG gets out."
1540- []
1541- (when-let [{:keys [channel session-id instance]} @av/local-call]
1542- (av/stop-media!)
1543- (when-let [c @conn]
1544- (when (seq session-id)
1545- (irc/tagmsg! c channel (av/leave-tags session-id instance))))))
1546-
1547-(def channel-list rooms/channel-list)
1548-
1549-(defn channel-order
1550- "The buffer names, most recently opened first — what gets written to disk.
1551- Buffers never opened are left out: a DM that arrived once and was never read
1552- is not a place this client has been, and neither is a channel someone
1553- mentioned. One that was opened is, whether it is a room or a person."
1554- []
1555- (->> (vals @channels)
1556- (filter #(pos? (:accessed % 0)))
1557- (sort-by #(- (:accessed % 0)))
1558- (mapv :name)))
1559-
1560-(def room-records rooms/room-records)
1561-
1562-(def restore-channels! rooms/restore-channels!)
1563-
1564-(defn message-by-id
1565- "The message a reply points at, if this buffer still holds it.
1566-
1567- By any name it has had — a `:local-id` for a line the server never named,
1568- and the msgid of any revision of it. `frq.rooms/answers-to?` is that rule,
1569- shared so both halves resolve a reply the same way."
1570- [channel id]
1571- (rooms/message-by-id @channels channel id))
1572-
1573-(defn react-from-picker!
1574- "Put the chosen emoji on the message the picker was opened for, and close it.
1575- One choice and back to the conversation: a picker left open would be asking a
1576- question that has been answered."
1577- [emoji]
1578- (when-let [{:keys [channel id]} @reacting]
1579- (when-let [m (message-by-id channel id)]
1580- (toggle-reaction! channel m emoji))
1581- (close-picker!)))
1582-
1583-
1584-(def jump-to cells/jump-to)
1585-
1586-(def highlight cells/highlight)
1587-
1588-
1589-(def overview-limit rooms/overview-limit)
1590-
1591-(def recent-everywhere rooms/recent-everywhere)
1592-
1593-(def last-preview rooms/last-preview)
1594-
1595-;; What the shared screens call. Installed here rather than in an entry point
1596-;; because these are this namespace's own reducers, and the screens that call
1597-;; them are no longer in a position to name them.
1598-(actions/install!
1599- {:connect! connect!
1600- :disconnect! disconnect!
1601- :forget-session! forget-session!
1602- :connected? connected?
1603- :join! join!
1604- :open-channel! open-channel!
1605- :leave-channel! leave-channel!
1606- :toggle-hide-join-part! toggle-hide-join-part!
1607- :browse! browse!
1608- :close-image-picker! close-image-picker!
1609- :pick-image! pick-image!
1610- :parent-dir parent-dir
1611- :picker-entries picker-entries
1612- :picker-roots picker-roots
1613- :media-tick (fn [] @media-tick)
1614- :avatar-ready (fn [actor] (avatars/path-when-ready actor))
1615- :send-draft! send-draft!
1616- :cancel-edit! cancel-edit!
1617- :cancel-reply! cancel-reply!
1618- :clear-attachment! clear-attachment!
1619- :open-image-picker! open-image-picker!
1620- :paste-image! paste-image!
1621- :jump-to-present! jump-to-present!
1622- :scrolled! scrolled!
1623- :toggle-users! toggle-users!
1624- :toggle-chat-list! toggle-chat-list!
1625- :toggle-overview! toggle-overview!
1626- :wide? wide?
1627- :member-count member-count
1628- :start-call! start-call!
1629- :in-call? av/in-call?
1630- :call-in av/call-in
1631- :call-available? av/available?
1632- :desktop? platform/desktop?
1633- :quit! platform/quit!
1634- :avatar-path nil
1635- :image-path nil
1636- :local-call (fn [] @av/local-call)
1637- :local-feed (fn [] av/local-feed)
1638- :media-error (fn [] @av/media-error)
1639- :tiles av/tiles
1640- :tile-rows av/tile-rows
1641- :set-muted! av/set-muted!
1642- :set-speaker-muted! av/set-speaker-muted!
1643- :set-camera! av/set-camera!
1644- :after! platform/after!
1645- :open-url! platform/open-url!
1646- :accept-policy! accept-policy!
1647- :close-picker! close-picker!
1648- :hover-reaction! hover-reaction!
1649- :join-call! join-call!
1650- :leave-call! leave-call!
1651- :leaving-for-overview! leaving-for-overview!
1652- :member-list member-list
1653- :message-by-id message-by-id
1654- :mine? mine?
1655- :my-reaction? my-reaction?
1656- :open-dm! open-dm!
1657- :open-picker! open-picker!
1658- :overview-back! overview-back!
1659- :picker-emoji picker-emoji
1660- :react-from-picker! react-from-picker!
1661- :recent-everywhere recent-everywhere
1662- :reply-to! reply-to!
1663- ;; A reply chip that found nothing asks what that msgid was; the repaint is
1664- ;; the same tick a picture or a face arriving uses, because it is the same
1665- ;; shape of answer — something a row read, arriving after the row was drawn.
1666- :resolve-reply! (fn [channel id]
1667- (replies/resolve! channel id #(swap! media-tick inc)))
1668- :start-edit! start-edit!
1669- :toggle-reaction! toggle-reaction!
1670- :unhover-reaction! unhover-reaction!})
deleted file mode 100644
@@ -1,1670 +0,0 @@
1-(ns frq.state
2- "Every cell the UI reads, and the reducers that write them.
3-
4- glimmer components re-render from ratoms, so the whole app state is a handful
5- of `atom`s here; the IRC reader thread pushes into the same ones. `apply-msg!`
6- is the only place a wire message turns into UI state."
7- (:require [clojure.string :as str]
8- [frq.rooms :as rooms]
9- [frq.members :as members]
10- [frq.reactions :as reactions]
11- [frq.edits :as edits]
12- [glimmer.ratom :as r :refer [atom]]
13- [frq.actions :as actions]
14- [frq.cells :as cells]
15- [frq.replies :as replies]
16- [jolt.host :as host]
17- [frq.atproto :as atproto]
18- [frq.av :as av]
19- [frq.clock :as clock]
20- [frq.emoji :as emoji]
21- [frq.irc :as irc]
22- ;; For the side effect: it installs the desktop crypto behind
23- ;; `frq.crypto`, which the shared `frq.msgsig` signs through.
24- [frq.crypto.openssl]
25- [frq.msgsig :as msgsig]
26- [frq.avatars :as avatars]
27- [frq.media :as media]
28- [frq.oauth :as oauth]
29- [frq.platform :as platform]
30- [frq.store :as store]
31- [frq.upload :as upload]))
32-
33-(def default-host cells/default-host)
34-(def default-port cells/default-port)
35-
36-;; The cells the connect screen reads live in `frq.cells` now, so that screen
37-;; could move to common/ and be the same file on the phone. Re-defined here
38-;; rather than left to the callers: a thousand lines below this say
39-;; `@form-nick` and `@connecting?`, and none of them care which namespace the
40-;; atom was made in.
41-(def screen cells/screen)
42-;; Not in frq.cells: this holds the live IRC connection, which is jolt's
43-;; socket and a reader thread. The phone's equivalent is a dart:io Socket and
44-;; nothing shared could hold either.
45-(defonce conn (atom nil))
46-(def status cells/status)
47-(def error cells/error)
48-(def connecting? cells/connecting?)
49-(def form-host cells/form-host)
50-(def form-port cells/form-port)
51-(def form-tls? cells/form-tls?)
52-(def form-nick cells/form-nick)
53-(def auth-mode cells/auth-mode)
54-(def form-handle cells/form-handle)
55-(def form-app-password cells/form-app-password)
56-(def session cells/session)
57-(def broker-token cells/broker-token)
58-(def channels cells/channels)
59-(def current cells/current)
60-(def join-input cells/join-input)
61-(def search cells/search)
62-(def login-url cells/login-url)
63-
64-(def popular-channels cells/popular-channels)
65-
66-(def auto-join "#test")
67-
68-;; How much backlog to ask for when the server did not volunteer any.
69-(def history-limit 100)
70-
71-(def draft cells/draft)
72-(def replying-to cells/replying-to)
73-
74-(defn reply-to! [m] (reset! replying-to (select-keys m [:id :from :text])))
75-(defn cancel-reply! [] (reset! replying-to nil))
76-
77-(def editing cells/editing)
78-
79-(def reacting cells/reacting)
80-
81-(def emoji-search cells/emoji-search)
82-(def emoji-group cells/emoji-group)
83-
84-(def lightbox cells/lightbox)
85-
86-(def show-users? cells/show-users?)
87-
88-(defn toggle-users! [] (swap! show-users? not))
89-
90-(def hide-chat-list? cells/hide-chat-list?)
91-
92-(declare save-prefs!)
93-
94-(defn toggle-chat-list! []
95- (swap! hide-chat-list? not)
96- (save-prefs!))
97-
98-(def overview? cells/overview?)
99-
100-(defn toggle-overview! []
101- (swap! overview? not)
102- (save-prefs!))
103-
104-(def overview-return cells/overview-return)
105-
106-(declare open-channel!)
107-
108-(defn leaving-for-overview!
109- "Remember where we are, because a line in the strip is about to take us out
110- of it. Nothing to remember if there is nowhere to go back to."
111- []
112- (reset! overview-return @current))
113-
114-(defn overview-back!
115- "Back to the room the strip took you out of.
116-
117- The room, and with it the place in it: the backlog's scroll is remembered
118- under a name per conversation — see `messages-scroll-key` — so a backend
119- that keeps a position per name lands back where the strip found you. One
120- that only knows whether a viewport is new to it, as the Flutter side does,
121- brings you back to the newest line instead; both beat the middle of the
122- last room you were in, which is what one name for every room gave."
123- []
124- (when-let [room @overview-return]
125- (reset! overview-return nil)
126- (open-channel! room)))
127-
128-(def window-width cells/window-width)
129-
130-(def window-height cells/window-height)
131-
132-;; Where the second pane starts paying for itself, shared with the other
133-;; backends — see `frq.cells/wide-width`.
134-(def wide-width cells/wide-width)
135-
136-(defn wide?
137- "True while the window has room for the list and a conversation at once."
138- []
139- (>= @window-width wide-width))
140-
141-(defn chat-visible?
142- "Whether the conversation in `current` is on screen.
143-
144- On a narrow window that is the chat screen alone. On a wide one the chats
145- screen shows it too, in the pane beside the list — so this, and not the
146- screen, is what decides whether an arriving line counts as unread."
147- []
148- (or (= :chat @screen)
149- (and (wide?) (= :chats @screen))))
150-
151-(def at-present? cells/at-present?)
152-(def jump-tick cells/jump-tick)
153-
154-;; How a backend answers "did that scroll end at the end?".
155-;;
156-;; The window's scroll area says so itself — `:on-change` arrives with "end" —
157-;; and the terminal's does not: it reports the offset it was asked for and
158-;; never how far down the bottom is. What it does do is clamp, so a request
159-;; that came back smaller than it went out is a request that ran into the end.
160-;; Only a backend that can see that installs this; `scrolled!` is written for
161-;; both, and asks.
162-(defonce at-end-probe (atom nil))
163-
164-(defn scrolled!
165- "A viewport moved under the reader, to the offset `to`.
166-
167- For the backends whose scroll areas report a position rather than a place:
168- where that leaves the reader is what `at-end-probe` is asked, and with
169- nobody to ask, any scroll at all is a scroll away from the newest line."
170- [to]
171- (reset! at-present? (boolean (when-let [probe @at-end-probe] (probe to))))
172- nil)
173-
174-(defn jump-to-present!
175- "Go back to the newest line.
176-
177- `at-present?` is set here rather than left to the view because not every
178- backend can tell us: the window's scroll area reports where it ended up and
179- corrects this on the next frame, and the terminal's does not report the
180- bottom at all — so what a jump means for the button that asked for it is
181- said here, once, for both."
182- []
183- (reset! at-present? true)
184- (swap! jump-tick inc))
185-;; A counter rather than a clock: the list only needs their order, and a
186-;; monotonic tick cannot be surprised by the system time moving.
187-(def access-tick rooms/access-tick)
188-
189-(def hide-join-part? cells/hide-join-part?)
190-
191-;; Whether rooms.edn is the authority yet.
192-;;
193-;; It is not, the first time this version runs: freeq re-joins an authenticated
194-;; user's channels at registration, so on connect the server announces every
195-;; room it has you in — and a client that parted everything not already in its
196-;; file would walk out of all of them before the file had ever been told they
197-;; existed. So the first connect adopts what the server says and writes it
198-;; down, and every connect after that is the strict one.
199-(defonce room-list-owned? (atom false))
200-
201-;; And whether this session is the adopting one, decided at 001.
202-(defonce ^:private adopting-rooms? (atom false))
203-
204-(defn- save-prefs! []
205- (future (store/save-prefs! (assoc (store/load-prefs)
206- :hide-join-part? @hide-join-part?
207- :hide-chat-list? @hide-chat-list?
208- :overview? @overview?
209- :room-list-owned? @room-list-owned?))))
210-
211-(defn toggle-hide-join-part! []
212- (swap! hide-join-part? not)
213- (save-prefs!))
214-
215-(defn restore-prefs!
216- "Bring back the saved settings at startup."
217- []
218- (let [prefs (store/load-prefs)]
219- (reset! hide-join-part? (boolean (:hide-join-part? prefs)))
220- (reset! hide-chat-list? (boolean (:hide-chat-list? prefs)))
221- (reset! overview? (boolean (:overview? prefs)))
222- (reset! room-list-owned? (boolean (:room-list-owned? prefs)))
223- prefs))
224-
225-(defn connected? [] (some? @conn))
226-
227-(declare channel-order room-records request-names!)
228-
229-(defonce ^:private rooms-saved-at (atom 0))
230-
231-(defn- remember-rooms!
232- "Write the room records out: what rooms there are, in the order they were
233- last used, and how much of each has been read.
234-
235- Off the caller's thread, because opening a room should not wait on a file.
236- Throttled, because the marker moves on every line that arrives while a room
237- is on screen and a busy channel would otherwise write the file per message —
238- `force?` is for the moments worth paying for, which is a room being opened.
239-
240- A late write costs at most the handful of lines that arrived since the last
241- one, shown unread again on the next run. That is the right way round: the
242- marker never claims to have read more than it has."
243- ([] (remember-rooms! false))
244- ([force?]
245- (let [now (clock/now-ms)]
246- (when (or force? (> (- now @rooms-saved-at) 5000))
247- (reset! rooms-saved-at now)
248- (future (store/save-rooms! (room-records)))))))
249-
250-(def dm? rooms/dm?)
251-
252-(defn normalize-channel [s]
253- (let [s (str/trim (or s ""))]
254- (cond (str/blank? s) ""
255- (str/starts-with? s "#") s
256- :else (str "#" s))))
257-
258-(def ^:private after-marker rooms/after-marker)
259-
260-(def ^:private recount rooms/recount)
261-
262-(def ^:private mark-read rooms/mark-read)
263-
264-(def ^:private ensure-channel rooms/ensure-channel)
265-
266-(defonce ^{:doc "Bumped whenever a fetched image becomes available, so the
267- chat view re-renders without every message row watching the media cache."}
268- media-tick (atom 0))
269-
270-(defn local-id
271- "A name for a line the server did not name.
272-
273- freeq tags a message with a `msgid` and that is a line's identity everywhere
274- it matters — a reply points at one, an edit rewrites one, a reaction lands on
275- one. But not every line arrives with one: a replayed backlog can come over
276- with no tags at all, and a line this client has just sent has none until the
277- server echoes it back.
278-
279- Those lines are not nameless to the reader, though. They are on the screen,
280- they are in the overview, and pressing one should go to it. So they get a
281- name made out of what they are: who said it, what it said, when, and where.
282- Two lines identical in all four are the same line as far as anything this
283- client does with one is concerned.
284-
285- `local-` because it is this client's alone, and it is never sent: the
286- server knows only the names it gave out."
287- [channel from text at]
288- (str "local-" (hash [channel from text at])))
289-
290-(defn push-message!
291- "Append a line to a buffer, creating it if needed, and bump the unread count
292- unless that buffer is the one on screen. Any image it links to is fetched in
293- the background, as is the sender's avatar.
294-
295- The extras are what the message carried beyond its text: `:at` when it was
296- said, from the server's own `time` tag where there is one, and `:did` who
297- said it, from the `account` tag — an identity that outlasts whatever nick
298- they are using today. `:id` names this message so a reply can point at it,
299- and `:reply-to` is the one it answers. `:reactions` is what people have put
300- on it already, which on a replayed backlog the server hands over in full."
301- ([channel from text] (push-message! channel from text {}))
302- ([channel from text {:keys [at did id reply-to reactions edited? edit-ids]}]
303- (let [at (or at (clock/now-ms))
304- ;; A name of our own where the server gave none. See `local-id`.
305- mine (when-not id (local-id channel from text at))
306- who (avatars/actor did from)
307- ;; A room reaching the store matters more than the throttle does: a
308- ;; connection joins every channel at once, and the writes for all but
309- ;; the first would be five seconds away — long enough that quitting
310- ;; straight after signing in is how a client forgets the rooms it just
311- ;; joined. Read before the swap, so this is the arrival that made it.
312- new-room? (not (contains? @channels channel))]
313- (doseq [url (media/image-urls text)]
314- (media/fetch! url #(swap! media-tick inc)))
315- ;; The same tick: an avatar arriving is a picture arriving, and the chat
316- ;; view already repaints on it.
317- (when who (avatars/fetch! who #(swap! media-tick inc)))
318- (swap! channels
319- (fn [m]
320- (let [m (ensure-channel m channel)
321- viewing? (and (chat-visible?) (= channel @current))
322- ;; And not a second copy of one we already hold: the
323- ;; server hands the same message over more than once, and
324- ;; `frq.rooms/seen-message?` is the whole of that rule —
325- ;; shared, because the Flutter half was appending every
326- ;; replay this drops.
327- seen? (rooms/seen-message? (get-in m [channel :messages])
328- id from text @form-nick)]
329- (cond
330- ;; The copy we already hold is the pre-edit one, and this is
331- ;; the server's collapsed row saying so. Same message, later
332- ;; word: take the text rather than the arrival order.
333- (and seen? edited?)
334- (assoc-in m [channel :messages]
335- (mapv (fn [msg]
336- (if (= id (:id msg))
337- (assoc msg :text text :edited? true
338- :images (media/image-urls text))
339- msg))
340- (get-in m [channel :messages])))
341-
342- seen? m
343-
344- :else
345- (-> m
346- (update-in [channel :messages] conj
347- {:from from :text text :system? (= "*" from)
348- :actor who
349- :images (media/image-urls text)
350- :at at
351- ;; `:id` is what a reply points at, and
352- ;; `:reply-to` is what this one points at.
353- :id id :local-id mine :reply-to reply-to
354- ;; Any other msgid this same line answers to
355- ;; — a revision's. See `frq.rooms/answers-to?`.
356- :edit-ids (set (remove nil? edit-ids))
357- ;; The sender has since rewritten this line.
358- ;; Replay says so with a tag rather than by
359- ;; sending the revision, so a message can
360- ;; arrive already edited.
361- :edited? (boolean edited?)
362- ;; emoji -> the nicks who put it there
363- :reactions (or reactions {})})
364- (assoc-in [channel :last-activity] at)
365- ;; A DM is a room named after whoever is in it, and a
366- ;; nick is not a name that lasts. The DID is, so the
367- ;; record keeps it the first time the other end says
368- ;; anything — ours would name the wrong side.
369- (cond-> (and (dm? channel) did (not= from @form-nick))
370- (assoc-in [channel :peer-did] did))
371- ;; Reading a room *is* marking it read: a line that
372- ;; arrives while it is on screen moves the marker past
373- ;; itself. Everything else re-derives, so a line arriving
374- ;; in a room nobody is looking at costs a recount of that
375- ;; room and nothing more.
376- (update channel (if viewing? mark-read recount)))))))
377- (remember-rooms! new-room?))))
378-
379-(defn open-channel!
380- "Show a buffer. A channel we are not in is joined on the way — a row can
381- outlive the membership behind it (a disconnect drops every channel, the
382- buffer stays), and opening one is a request to be in it."
383- [name]
384- (reset! current name)
385- ;; On a wide window the conversation lives in the chats screen's second
386- ;; pane, beside the list; :chat is the narrow window's way of showing it
387- ;; instead of the list, and there is nothing there to trade it for.
388- (reset! screen (if (wide?) :chats :chat))
389- ;; A picker belongs to the message it was opened on; carrying it into another
390- ;; buffer would offer to react to something that is no longer on screen.
391- (reset! reacting nil)
392- ;; And an edit belongs to a line in the buffer being left: carried across, the
393- ;; next Send would rewrite a message nobody in this room can see.
394- (when (and @editing (not= name (:channel @editing)))
395- (reset! editing nil)
396- (reset! draft ""))
397- (swap! channels #(-> (ensure-channel % name)
398- (update name mark-read)
399- (assoc-in [name :accessed] (swap! access-tick inc))))
400- (remember-rooms! true)
401- ;; `joining?` as well as `joined?`: the JOIN echo takes a round trip, and a
402- ;; second JOIN sent in the meantime is what makes the server replay nothing.
403- (let [buffer (get @channels name)]
404- (when (and @conn
405- (str/starts-with? name "#")
406- (not (:joined? buffer))
407- (not (:joining? buffer)))
408- (swap! channels #(assoc-in % [name :joining?] true))
409- (irc/join! @conn name)))
410- ;; Already in it, and nobody listed: the membership survived a restart the
411- ;; NAMES that came with it did not.
412- (when (:joined? (get @channels name))
413- (request-names! name)))
414-
415-(defn join-saved-rooms!
416- "Ask to be in every channel `rooms.edn` says we are in.
417-
418- The server re-joins an authenticated user's channels itself, and gets it
419- wrong in both directions — it forgets rooms and announces ones that are not
420- ours. This is the half that answers the forgetting: the file says what we are
421- in, so on arrival we say it too. A JOIN for a channel the server has already
422- put us in is answered with the membership we already have, so asking twice
423- costs nothing.
424-
425- DMs are not asked for. There is nothing to join in a conversation with a
426- person; the buffer is the whole of it."
427- []
428- (when-let [conn @conn]
429- (doseq [[name buffer] @channels
430- :when (and (str/starts-with? name "#")
431- (not (:joined? buffer))
432- (not (:joining? buffer)))]
433- (swap! channels #(assoc-in % [name :joining?] true))
434- (irc/join! conn name))))
435-
436-(defn leave-channel!
437- "Leave a room and forget it: PART on the wire, gone from the list, gone from
438- `rooms.edn`.
439-
440- The only way a room leaves the file. Everything else adds — the server
441- announcing one, a message arriving in one — so without this the list is a
442- thing that only grows, and the strictness above would have nothing to be
443- strict about."
444- [name]
445- (when (and @conn (str/starts-with? name "#"))
446- (irc/part! @conn name))
447- (swap! channels dissoc name)
448- (when (= name @current)
449- (reset! current nil)
450- (reset! screen :chats))
451- (remember-rooms! true))
452-
453-(def ^:private parse-reactions reactions/parse-tally)
454-(def ^:private with-reaction reactions/with-reaction)
455-
456-(defn update-reaction!
457- "One reaction folded into the buffer it belongs to. `frq.reactions` says what
458- that means; this is the atom it means it to."
459- [channel msgid emoji nick on?]
460- (swap! channels reactions/update-reaction channel msgid emoji nick on?))
461-
462-(defn edit-message!
463- "Rewrite a message in place, and say so.
464-
465- `frq.edits` is the fold and what it answers; the atom and the picture links
466- are this half's. A message keeps the id it was born with across every
467- revision, which is what keeps its reactions, replies and pins attached to it
468- — and `revision`, the msgid of the edit itself, is kept on it too, because a
469- reply to an already-rewritten line names that one."
470- ([channel msgid from text] (edit-message! channel msgid from text nil))
471- ([channel msgid from text revision]
472- (let [out (edits/apply-edit @channels channel msgid from text
473- {:decorate #(assoc % :images (media/image-urls text))
474- :revision revision})]
475- (reset! channels (:channels out))
476- (:result out))))
477-
478-(defn- names-line [channel names]
479- (swap! channels #(members/with-names (ensure-channel % channel) channel names)))
480-
481-(defn- names-end! [channel]
482- (swap! channels members/names-done channel))
483-
484-(defn- add-user! [channel nick]
485- (when (and channel nick)
486- (swap! channels #(members/add-user (ensure-channel % channel) channel nick))))
487-
488-(defn- remove-user! [channel nick]
489- (swap! channels members/remove-user channel nick))
490-
491-(defn- remove-user-everywhere! [nick]
492- (swap! channels members/remove-everywhere nick))
493-
494-(defn- rename-user! [old new]
495- (swap! channels members/rename-user old new))
496-
497-(defn- apply-mode! [channel modes args]
498- (swap! channels members/with-mode channel modes args))
499-
500-(defn member-list [channel]
501- (members/member-list @channels channel))
502-
503-(defn member-count [channel]
504- (members/member-count @channels channel))
505-
506-(defn request-names!
507- "Ask who is in a channel we are already in. freeq re-joins an authenticated
508- user's channels at registration, which happens without a JOIN reaching this
509- client — and so without the NAMES that follows one."
510- [channel]
511- (when (and @conn channel (str/starts-with? channel "#")
512- (empty? (get-in @channels [channel :users])))
513- (irc/send-line! @conn (str "NAMES " channel))))
514-
515-(declare join! join-call!)
516-
517-;; --- calls -------------------------------------------------------------------
518-;; Signaling only. The audio and video themselves are `frq.av`'s, and behind it
519-;; libjoltmoq's; what happens here is that the server's broadcasts become state
520-;; the screens can read, and a press becomes a TAGMSG.
521-
522-(defn apply-call-state!
523- "A `+freeq.at/av-state` broadcast: fold it in, and say so in the buffer.
524-
525- The system line is worth the space — a call is the one thing that happens in
526- a channel while nobody types, and without a line saying so the only trace of
527- someone joining is a number quietly changing in a banner."
528- [channel st]
529- (av/apply-state! channel st)
530- ;; And try to dial. The token may already be in hand — from this join, or
531- ;; from the last time we were in this same session — in which case the
532- ;; server's agreement that we are in the call is the last thing we were
533- ;; waiting for. `try-start-media!` refuses if there is nothing to dial with
534- ;; or a call is already up, so calling it on every state change is safe.
535- (when (av/in-call? channel)
536- (av/try-start-media! @form-host))
537- (let [line (av/state-message st)]
538- (when (seq line)
539- (push-message! channel "*" line))))
540-
541-(defn apply-call-error!
542- "A `+freeq.at/av-error`. Most say the call failed; one says we lost a race.
543-
544- `start-collision` means our `av-start` and someone else's crossed and theirs
545- won. The server names the winning session, so the answer is to join that one
546- rather than to report an error for something the person asked for and can
547- have — they wanted to be in a call in this room, and there is one."
548- [tags code]
549- (let [reason (or (irc/tag-value tags "+freeq.at/av-reason") code)
550- session-id (irc/tag-value tags "+freeq.at/av-id")
551- lc @av/local-call
552- channel (:channel lc)]
553- (if (and (= "start-collision" code) (seq session-id) channel
554- (or (:awaiting-start? lc) (str/blank? (:session-id lc))))
555- (do
556- (push-message! channel "*" "Call already open — joining it instead")
557- (av/stop-media!)
558- (join-call! channel session-id))
559- (do
560- (when channel
561- (push-message! channel "*" (str "Call error: " reason)))
562- ;; Only tear down a call the error is actually about. A `join-failed`
563- ;; naming someone else's session is not ours to act on.
564- (when (and lc
565- (or (str/blank? (or session-id ""))
566- (str/blank? (:session-id lc))
567- (= session-id (:session-id lc))))
568- (av/stop-media!))))))
569-
570-;; Which channel's POLICY reply is outstanding, or nil. The server answers
571-;; POLICY with a run of NOTICEs addressed to our nick and naming no channel;
572-;; without this they land in the status banner one at a time, and the rules
573-;; the reader is being asked to accept are never readable.
574-;;
575-;; ponytail: the run ends at the first line that is not a NOTICE, so a PING
576-;; landing mid-answer truncates the rules. A reply-tag or a POLICY numeric
577-;; from the server would end it properly.
578-(defonce ^:private policy-asking (atom nil))
579-
580-(defn ask-policy!
581- "Ask the server what this channel's policy says, so the reader can read what
582- they are being asked to accept.
583-
584- The answer comes back as plain NOTICEs to our nick, naming no channel — so
585- the question is remembered here, and the lines that follow it are filed
586- under the channel that asked."
587- [ch]
588- (swap! channels #(assoc-in (ensure-channel % ch) [ch :policy-text] []))
589- (reset! policy-asking ch)
590- (when-let [c @conn]
591- (irc/send-line! c (str "POLICY " ch " RULES"))
592- (irc/send-line! c (str "POLICY " ch " INFO"))))
593-
594-(defn accept-policy!
595- "Accept the channel's policy and go back in. The JOIN follows immediately:
596- accepting is only ever done in order to be in the room, and the server takes
597- the two in the order they are sent."
598- [ch]
599- (when-let [c @conn]
600- (irc/send-line! c (str "POLICY " ch " ACCEPT"))
601- (swap! channels #(-> (ensure-channel % ch)
602- (assoc-in [ch :policy-required?] false)
603- (assoc-in [ch :joining?] true)))
604- (irc/join! c ch)))
605-
606-(defn apply-msg!
607- "Fold one parsed IRC message into the state."
608- [msg]
609- (let [{:keys [command params prefix]} msg
610- from (irc/nick-of prefix)]
611- (when (and @policy-asking (not= command "NOTICE"))
612- (reset! policy-asking nil))
613- (case command
614- "001" (do (reset! status (if @session
615- (str "Connected as " (:handle @session))
616- (str "Connected as " @form-nick)))
617- (reset! connecting? false)
618- (reset! screen :chats)
619- ;; This session decides once whether it is the one that
620- ;; takes the room list over from the server. Before the flag
621- ;; is set the file has never been told what we are in, so the
622- ;; server's answer is adopted rather than argued with.
623- (reset! adopting-rooms? (not @room-list-owned?))
624- (when-not @room-list-owned?
625- (reset! room-list-owned? true)
626- (save-prefs!))
627- ;; What the file says we are in, we ask to be in. The server
628- ;; forgets rooms, and a room it has forgotten is one that
629- ;; would otherwise quietly stop existing.
630- (join-saved-rooms!)
631- ;; Back where the reader left off. The list is still what a
632- ;; connect lands on underneath, so Back from the reopened
633- ;; channel goes to the chats rather than out of the app.
634- (if-let [last-ch (first (channel-order))]
635- (open-channel! last-ch)
636- (join! auto-join)))
637- "PRIVMSG" (let [[target text] params
638- ;; The server's clock when it offers one: a replayed
639- ;; backlog is hours or weeks old, and stamping it with
640- ;; the moment it arrived would say it all happened now.
641- at (or (clock/parse-time-tag (:tags msg)) (clock/now-ms))
642- tags (:tags msg)
643- ;; a DM addressed to us belongs in a buffer named for the
644- ;; sender, not for our own nick — except when the sender
645- ;; is us: `echo-message` sends our own DM back, and the
646- ;; buffer it belongs to is the one we sent it to.
647- buffer (cond
648- (str/starts-with? (or target "") "#") target
649- (= from @form-nick) target
650- :else from)
651- ;; What this message rewrites, when it is a rewrite. The
652- ;; server canonicalises the name to `+draft/edit`.
653- edit-of (irc/tag-value tags "+draft/edit")
654- ;; And what the server says about a line it has already
655- ;; collapsed: replay sends one row per message, carrying
656- ;; the current text and no `+draft/edit` to hint that it
657- ;; is not the original. This tag is the only trace.
658- replayed-edit? (= "1" (irc/tag-value tags "+freeq.at/edited"))]
659- (if edit-of
660- ;; A revision is not a new line: it replaces the one it
661- ;; names, under that line's own id rather than its own
662- ;; wire msgid. That msgid is not nothing, though — an
663- ;; answer to a line already rewritten names the revision,
664- ;; because the revision is the wording being answered — so
665- ;; it rides along on the message as a name it also
666- ;; answers to.
667- (when (= :absent (edit-message! buffer edit-of from text
668- (irc/tag-value tags "msgid")))
669- ;; The original is outside the backlog we hold, so show
670- ;; the current text rather than dropping what was said.
671- (push-message! buffer from text
672- {:at at
673- :did (:account msg)
674- :id edit-of
675- ;; Same two names, for the line we are
676- ;; showing in place of the original.
677- :edit-ids #{(irc/tag-value tags "msgid")}
678- :edited? true
679- :reply-to (or (irc/tag-value tags "+reply")
680- (irc/tag-value tags "+draft/reply"))}))
681- (push-message! buffer from text
682- {:at at
683- :did (:account msg)
684- :id (irc/tag-value tags "msgid")
685- :edited? replayed-edit?
686- ;; The server canonicalises +draft/reply to
687- ;; +reply; a client that sent the draft
688- ;; name may still reach us before it does.
689- :reply-to (or (irc/tag-value tags "+reply")
690- (irc/tag-value tags "+draft/reply"))
691- :reactions (parse-reactions
692- (irc/tag-value tags "+freeq.at/reactions"))})))
693- ;; A message that is only tags. A reaction is the one this client reads:
694- ;; `+react` puts an emoji on the message `+reply` names, and the server's
695- ;; own `+freeq.at/unreact` takes it off again.
696- "TAGMSG" (let [tags (:tags msg)
697- target (first params)
698- buffer (if (str/starts-with? (or target "") "#") target from)
699- msgid (or (irc/tag-value tags "+reply")
700- (irc/tag-value tags "+draft/reply"))
701- add (or (irc/tag-value tags "+react")
702- (irc/tag-value tags "+draft/react"))
703- remove-it (irc/tag-value tags "+freeq.at/unreact")
704- call-state (av/parse-state tags)
705- ;; The token is directed at our own nick rather than at
706- ;; the channel, so `buffer` is a DM key here and says
707- ;; nothing about which call it is for. The session id in
708- ;; the tag is what does.
709- token (irc/tag-value tags "+freeq.at/av-token")
710- call-error (irc/tag-value tags "+freeq.at/av-error")]
711- (cond
712- add (update-reaction! buffer msgid add from true)
713- remove-it (update-reaction! buffer msgid remove-it from false)
714- call-state (apply-call-state! buffer call-state)
715- token (av/apply-token! @form-host
716- (irc/tag-value tags "+freeq.at/av-id")
717- token)
718- call-error (apply-call-error! tags call-error)
719- :else nil))
720- "JOIN" (let [ch (first params)]
721- (if (= from @form-nick)
722- ;; Ours if the file says so — restored from rooms.edn, or
723- ;; asked for since. Anything else is the server putting us
724- ;; somewhere we did not ask to be, which it does: it
725- ;; announces memberships that are not real, and adding them
726- ;; is how a list nobody chose fills up with rooms.
727- ;;
728- ;; So we leave again, unless this is the session that is
729- ;; still adopting — on the first connect the file has not
730- ;; been told anything yet, and parting then would be leaving
731- ;; every room we are actually in.
732- (if (and (not (contains? @channels ch))
733- (not @adopting-rooms?))
734- (when @conn (irc/part! @conn ch))
735- (let [fresh? (empty? (get-in @channels [ch :messages]))]
736- (swap! channels #(-> (ensure-channel % ch)
737- (assoc-in [ch :joined?] true)
738- (assoc-in [ch :joining?] false)
739- ;; In the room: whatever it was asking
740- ;; for, it is not asking any more.
741- (assoc-in [ch :policy-required?] false)))
742- ;; Only on the way in to an empty buffer. A reconnect joins
743- ;; every channel again, and saying so on top of the backlog
744- ;; already there is just a second line of noise.
745- (when fresh? (push-message! ch "*" (str "Joined " ch)))
746- ;; Adopted or asked for, it is ours now and the file should
747- ;; say so before the next connect judges it.
748- (remember-rooms! true)))
749- ;; Somebody else arriving in a room we do not hold is not a
750- ;; reason to start holding it: `add-user!` and `push-message!`
751- ;; both build the buffer they are given, so either one would
752- ;; put the refused room back in the list.
753- (when (contains? @channels ch)
754- (add-user! ch from)
755- (when-not @hide-join-part?
756- (push-message! ch "*" (str from " joined"))))))
757- ;; NAMES, a line at a time. The channel is the parameter that names one:
758- ;; the reply is `<us> <symbol> <channel> :<names>`, and a server that
759- ;; leaves the symbol out shifts everything before the list along by one.
760- "353" (let [ch (first (filter #(str/starts-with? (or % "") "#") params))]
761- (when ch (names-line ch (last params))))
762- ;; End of NAMES. A plain JOIN is replayed history before this arrives, so
763- ;; a channel that reaches here with nothing in it was restored rather
764- ;; than joined — freeq re-joins an authenticated user's channels at
765- ;; registration and leaves the backlog for the client to ask for.
766- "366" (let [ch (second params)
767- said (remove :system? (get-in @channels [ch :messages]))]
768- (names-end! ch)
769- (when (and ch @conn (empty? said))
770- (irc/send-line! @conn
771- (str "CHATHISTORY LATEST " ch " * " history-limit))))
772-
773- ;; `assoc-in` on a channel that is not there does not fail, it invents
774- ;; one — a buffer with a `:joined?` and nothing else, no name and no
775- ;; unread, which the room list then tries to draw. That is not
776- ;; hypothetical now: refusing a room the file does not claim sends PART,
777- ;; and the server echoes it straight back at us. A membership changing in
778- ;; a room we do not hold is nothing to record.
779- "PART" (let [ch (first params)]
780- (if (= from @form-nick)
781- (swap! channels #(if (contains? % ch)
782- (-> % (assoc-in [ch :joined?] false)
783- (assoc-in [ch :joining?] false)
784- (assoc-in [ch :users] {}))
785- %))
786- (when (contains? @channels ch)
787- (remove-user! ch from)
788- (when-not @hide-join-part?
789- (push-message! ch "*" (str from " left"))))))
790- "KICK" (let [[ch who] params]
791- (if (= who @form-nick)
792- (swap! channels #(if (contains? % ch)
793- (-> % (assoc-in [ch :joined?] false)
794- (assoc-in [ch :joining?] false)
795- (assoc-in [ch :users] {}))
796- %))
797- (remove-user! ch who))
798- (when (contains? @channels ch)
799- (push-message! ch "*" (str who " was kicked by " from))))
800- ;; A QUIT and a NICK name no channel, so both are folded into every
801- ;; buffer the person was listed in — and said out loud only where they
802- ;; were, which is what keeps a stranger's rename out of a quiet room.
803- "QUIT" (let [rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)]
804- (when-not @hide-join-part?
805- (doseq [ch rooms]
806- (push-message! ch "*" (str from " quit"))))
807- (remove-user-everywhere! from))
808- "NICK" (let [new-nick (last params)
809- rooms (keep (fn [[k v]] (when (get (:users v) from) k)) @channels)]
810- (when (= from @form-nick) (reset! form-nick new-nick))
811- (doseq [ch rooms]
812- (push-message! ch "*" (str from " is now " new-nick)))
813- (rename-user! from new-nick))
814- "MODE" (let [[target modes & args] params]
815- (when (str/starts-with? (or target "") "#")
816- (apply-mode! target modes args)))
817- "NOTICE"
818- (let [text (str/trimr (or (last params) ""))]
819- (cond
820- ;; The server's half of freeq that is REST rather than IRC: sent
821- ;; once, straight after SASL succeeds, and the only way to get one.
822- ;; Kept rather than shown — see `frq.replies`, which spends it
823- ;; asking what a msgid was.
824- (str/starts-with? text "API-BEARER ")
825- (reset! cells/api-bearer (str/trim (subs text (count "API-BEARER "))))
826-
827- @policy-asking
828- (swap! channels #(update-in (ensure-channel % @policy-asking)
829- [@policy-asking :policy-text]
830- (fnil conj []) text))
831-
832- :else (reset! status (or (last params) @status))))
833- ("372" "375" "376" "002" "003" "004")
834- (reset! status (or (last params) @status))
835- ;; 473 invite-only, 474 banned, 475 keyed, 477 needs registration,
836- ;; 471 full, 403 no such channel. The channel is params[1]; clearing its
837- ;; flags is what lets a later attempt send a JOIN at all.
838- ("473" "474" "475" "477" "403" "471")
839- (let [ch (second params)
840- why (last params)]
841- (when ch
842- (swap! channels #(-> (ensure-channel % ch)
843- (assoc-in [ch :joined?] false)
844- (assoc-in [ch :joining?] false)))
845- (push-message! ch "*" (str "Could not join " ch " — " why))
846- ;; The one refusal the reader can answer themselves: the room is not
847- ;; shut to them, it is waiting on them to say yes to something. The
848- ;; flag is what puts the Accept button in the channel, and the rules
849- ;; are asked for so it is not a yes to an unread page.
850- (when (str/includes? (str/lower-case (or why "")) "policy")
851- (swap! channels #(assoc-in % [ch :policy-required?] true))
852- (ask-policy! ch)))
853- ;; Deliberately not the global banner: it outlives the screen it was
854- ;; about, and the reason is in the channel's own buffer where it
855- ;; belongs. The banner is for what stops the whole app — a failed
856- ;; connection or a refused sign-in.
857- (when-not ch (reset! error (str "Cannot join: " why))))
858- ;; What the server refused and why, in the reader's words. An edit or a
859- ;; reaction it will not take is otherwise silent: the line on screen
860- ;; simply never changes, which reads as the app having lost it.
861- "FAIL" (let [[what _code] params]
862- (reset! error (str (or what "Request") " refused — "
863- (or (last params) "no reason given"))))
864- "903" (reset! status (str "Signed in as " (:handle @session)))
865- ("904" "905" "906") (do (reset! session nil)
866- ;; The broker token may still be good — but a
867- ;; refusal is as likely to mean it is not, and a
868- ;; stale one would fail the same way every time,
869- ;; including across restarts if it were kept.
870- (reset! broker-token nil)
871- (store/clear-session!)
872- (reset! error (str "Bluesky sign-in refused: "
873- (or (last params) "no reason given"))))
874- ;; The signing key belonged to that connection: the server forgets it
875- ;; when the session ends, and signing with it afterwards would be
876- ;; signing with a key nobody can check.
877- "*DISCONNECTED*" (do (msgsig/forget!)
878- ;; The bearer belongs to the session that is over,
879- ;; and what was asked under it deserves asking
880- ;; again under the next one.
881- (reset! cells/api-bearer nil)
882- (replies/forget-asks!)
883- (reset! conn nil)
884- (reset! connecting? false)
885- (swap! channels
886- #(reduce-kv (fn [m k v]
887- (assoc m k (assoc v :joined? false :joining? false :users {})))
888- {} %))
889- (reset! status "Disconnected"))
890- "*ERROR*" (do (reset! error (first params))
891- (reset! connecting? false))
892- nil)))
893-
894-(def plain-port 6667)
895-
896-(defn- describe
897- "What went wrong, in words. A jolt condition prints as #object[:object], so
898- the message and the ex-data are what has to be dug out by hand."
899- [e]
900- (let [msg (ex-message e)]
901- (if (seq msg)
902- msg
903- ;; A raw host condition prints as #object[:object] and says nothing, so
904- ;; its type is the only thing left worth showing.
905- (str (type e) ": " (str e)))))
906-
907-(defn- dial! [host port nick tls? sess]
908- ;; stderr is the only console on Android — this line lands in logcat.
909- (binding [*out* *err*]
910- (println "frq: dialing" host port (if tls? "tls" "plain")))
911- (reset! conn (irc/connect! host port nick apply-msg! tls? sess)))
912-
913-(defn- connect-blocking!
914- "Sign in if asked to, then dial. Blocking throughout — a browser handoff can
915- take a minute, and the TLS handshake is not instant either."
916- []
917- (reset! error nil)
918- (reset! connecting? true)
919- (reset! status (str "Connecting to " @form-host ":" @form-port "…"))
920- (let [host @form-host
921- port (parse-long (str/trim @form-port))
922- mode @auth-mode
923- sess (case mode
924- ;; OAuth: the browser does the talking, we wait on loopback. A
925- ;; broker token in hand skips the browser entirely.
926- :bluesky
927- (let [handle (str/trim @form-handle)
928- browser! (fn []
929- (reset! status "Opening your browser to sign in…")
930- (oauth/await-callback!
931- oauth/default-broker handle
932- (fn [url]
933- (reset! login-url url)
934- (oauth/open-browser! url)
935- (reset! status "Waiting for the browser…"))))
936- tokens (if-let [bt @broker-token]
937- ;; A saved token that the broker no longer honours
938- ;; is worth exactly one attempt: drop it and go
939- ;; through the browser, rather than failing the
940- ;; same way on every future Connect.
941- (try (reset! status "Resuming your session…")
942- (oauth/refresh-session oauth/default-broker bt)
943- (catch Exception _
944- (reset! broker-token nil)
945- (store/clear-session!)
946- (reset! status "Saved session expired — signing in again…")
947- (browser!)))
948- (browser!))
949- s (assoc tokens :kind :web-token)]
950- (reset! login-url nil)
951- (reset! broker-token (:broker-token tokens))
952- ;; Saved on every sign-in, not only the first: /session can
953- ;; hand back a rotated broker token, and the old one may stop
954- ;; working the moment it does.
955- (store/save-session! tokens)
956- (reset! session s)
957- (when (seq (:handle tokens)) (reset! form-handle (:handle tokens)))
958- s)
959-
960- :app-password
961- (do (reset! status (str "Signing in as " (str/trim @form-handle) "…"))
962- (let [s (assoc (atproto/create-session (str/trim @form-handle)
963- @form-app-password)
964- :kind :pds-session)]
965- (reset! session s)
966- ;; The password did its work at the PDS; do not keep it.
967- (reset! form-app-password "")
968- s))
969-
970- nil)
971- ;; An authenticated connection still needs a nick — the DID is the
972- ;; identity, the nick is only what the channel calls you.
973- nick (if sess
974- (or (:nick sess)
975- (-> (or (:handle sess) "") (str/split #"\\.") first)
976- (str/trim @form-nick))
977- (str/trim @form-nick))]
978- (when sess (reset! form-nick nick))
979- (try
980- (dial! host port nick @form-tls? sess)
981- (catch Exception e
982- (binding [*out* *err*] (println "frq: dial failed:" (describe e)))
983- (if @form-tls?
984- (do (reset! status (str "TLS unavailable — trying " host ":" plain-port "…"))
985- (try
986- (dial! host plain-port nick false sess)
987- (reset! form-tls? false)
988- (reset! form-port (str plain-port))
989- (catch Exception e2
990- (binding [*out* *err*] (println "frq: plain dial failed:" (describe e2)))
991- (reset! connecting? false)
992- (reset! conn nil)
993- (reset! status "Not connected")
994- (reset! error (str "Could not connect: " (describe e2))))))
995- (do (reset! connecting? false)
996- (reset! conn nil)
997- (reset! status "Not connected")
998- (reset! error (str "Could not connect: " (describe e)))))))))
999-
1000-(defn connect!
1001- "Start connecting. The work happens on another thread: the OAuth wait sits on
1002- a loopback accept until the browser comes back, and the UI has frames to
1003- paint in the meantime.
1004-
1005- A second call while one is in flight is ignored. Dialling twice does not just
1006- waste a socket: the server treats the second session as a reconnect of the
1007- first, and a reconnect is not replayed the channel history a fresh join gets,
1008- so the second connection — the one the UI ends up holding — shows an empty
1009- channel."
1010- []
1011- (when-not (or @connecting? @conn)
1012- (reset! error nil)
1013- (reset! connecting? true)
1014- (future
1015- (try (connect-blocking!)
1016- (catch Exception e
1017- (reset! connecting? false)
1018- (reset! conn nil)
1019- (reset! status "Not connected")
1020- (reset! error (str "Could not connect: " (describe e))))))))
1021-
1022-(defn restore-session!
1023- "Pick up a saved sign-in at startup. Only the durable broker token comes
1024- back; the connection still mints a fresh web-token from it."
1025- []
1026- (when-let [saved (store/load-session)]
1027- (reset! broker-token (:broker-token saved))
1028- (when (seq (:handle saved)) (reset! form-handle (:handle saved)))
1029- (when (seq (:nick saved)) (reset! form-nick (:nick saved)))
1030- (reset! auth-mode :bluesky)
1031- (reset! status (str "Signed in as " (:handle saved) " — Connect to resume"))
1032- saved))
1033-
1034-(defn forget-session!
1035- "Drop the saved sign-in, on disk and in memory."
1036- []
1037- (store/clear-session!)
1038- (reset! broker-token nil)
1039- (reset! cells/api-bearer nil)
1040- (replies/forget-asks!)
1041- (reset! session nil)
1042- (reset! auth-mode :guest)
1043- (reset! status "Not connected"))
1044-
1045-(defn disconnect! []
1046- (when-let [c @conn] (irc/close! c))
1047- (reset! conn nil)
1048- (reset! session nil)
1049- ;; The buffers survive, the memberships do not — leaving `joined?` set would
1050- ;; have the next Open show a channel nobody is in.
1051- (swap! channels #(reduce-kv (fn [m k v]
1052- (assoc m k (assoc v :joined? false :joining? false :users {})))
1053- {} %))
1054- (reset! status "Not connected")
1055- (reset! screen :connect))
1056-
1057-(defn open-dm!
1058- "Open a conversation with one person. There is nothing to join — a DM buffer
1059- is a place to type at somebody, and it exists as soon as it is asked for.
1060-
1061- Our own nick is not one of them: a buffer talking to yourself would take the
1062- place in the list of one that could answer."
1063- [nick]
1064- (let [nick (str/trim (or nick ""))]
1065- (when (and (seq nick) (not= nick @form-nick))
1066- (open-channel! nick))))
1067-
1068-(defn join! [name]
1069- ;; Deliberately not clearing `error` here: joining is what follows a
1070- ;; successful registration, and a SASL refusal that arrived moments earlier
1071- ;; is the one thing the user most needs to still be on screen.
1072- ;;
1073- ;; `@nick` opens a DM instead. One box for both: what the reader wants is to
1074- ;; be somewhere, and the sigil says where — the same way it does on the wire.
1075- (let [name (str/trim (or name ""))]
1076- (if (str/starts-with? name "@")
1077- (open-dm! (subs name 1))
1078- (let [ch (normalize-channel name)]
1079- (when (seq ch)
1080- (open-channel! ch))))))
1081-
1082-;; ------------------------------------------------------------------ pasting
1083-
1084-(def attachment cells/attachment)
1085-
1086-;; Each paste gets a file of its own rather than overwriting the last: the
1087-;; preview is painted from the file, and an upload may still be reading it.
1088-(defonce ^:private paste-count (atom 0))
1089-
1090-(defn- paste-path []
1091- (let [n (swap! paste-count inc)]
1092- (str (media/cache-dir) "/outgoing/paste-" n ".png")))
1093-
1094-(defn- discard-file!
1095- "Drop a paste's copy on disk. Nothing else keeps it: the picture that matters
1096- after sending is the one the server serves back, which the media cache
1097- fetches like any other."
1098- [path]
1099- (when path (try (host/delete-file! path) (catch Exception _ nil))))
1100-
1101-(defn clear-attachment!
1102- "Drop the pasted picture without sending it."
1103- []
1104- (when-let [a @attachment]
1105- (reset! attachment nil)
1106- (discard-file! (:path a))))
1107-
1108-(defn- attach!
1109- "Hold the picture already written to `path` — a copy of ours under
1110- `outgoing/` — against the next line, and start its upload.
1111-
1112- The upload runs off the UI thread and starts at once rather than at send, so
1113- by the time a line is written the picture is usually already up. A failure
1114- lands in `error` like any other, and takes the attachment with it — there is
1115- nothing to send and nothing to show.
1116-
1117- `filename` is what the server files it under; it says which gesture the
1118- picture came in by, and nothing else depends on it."
1119- [path filename]
1120- (let [did (:did @session)
1121- host-name @form-host
1122- channel @current]
1123- (reset! error nil)
1124- (clear-attachment!)
1125- (reset! attachment {:path path :status :uploading})
1126- (future
1127- (try
1128- (let [url (upload/upload! host-name did channel path filename)]
1129- ;; Only if this is still the picture on screen: a reader who attached
1130- ;; another, or cleared it, has said what they want, and an upload
1131- ;; landing afterwards does not get to undo that.
1132- (swap! attachment #(if (= (:path %) path)
1133- (assoc % :url url :status :ready)
1134- %))
1135- (when-not (= (:path @attachment) path) (discard-file! path)))
1136- (catch Exception e
1137- (swap! attachment #(if (= (:path %) path) nil %))
1138- (discard-file! path)
1139- (reset! error (or (ex-message e) (str e))))))))
1140-
1141-(defn paste-image!
1142- "Take the picture on the clipboard and hold it against the next line.
1143-
1144- IRC has nowhere to put an image, so a link is the whole of what sending one
1145- means — but that is a fact about the wire, not something the reader should
1146- have to type around. The picture is attached: shown under the draft while
1147- they write whatever they are sending it with, and turned into a link only on
1148- the way out."
1149- []
1150- (let [path (paste-path)]
1151- (host/mkdirs! (str (media/cache-dir) "/outgoing"))
1152- (if-not (platform/clipboard-image-png! path)
1153- ;; Android has no clipboard of pictures to read at all, which is the
1154- ;; other half of why the picker below exists.
1155- (reset! error "No picture on the clipboard.")
1156- (attach! path "paste.png"))))
1157-
1158-;; ------------------------------------------------------------------ picking
1159-
1160-(def image-picker cells/image-picker)
1161-
1162-(defn- readable-dir? [path]
1163- (try (and (host/file-exists? path) (host/directory? path))
1164- (catch Exception _ false)))
1165-
1166-(defn picker-roots
1167- "The places worth opening the picker on, on whichever platform this is.
1168-
1169- Only the ones that are actually there: a phone has no ~/Pictures and a
1170- desktop no /sdcard, and a list of directories that are not there is a list of
1171- dead ends. On Android everything outside the app's own storage is behind a
1172- runtime permission this activity has no code to ask for, so what survives
1173- this filter there is usually the app's own files — which is the honest
1174- answer, not a bug to paper over."
1175- []
1176- (let [home (or (host/getenv "HOME") "")
1177- under (fn [base] (when (seq base)
1178- (map #(str base "/" %)
1179- ["Pictures" "Downloads" "Download" "DCIM"])))]
1180- (vec (distinct (filter readable-dir?
1181- (concat (under home)
1182- (under "/sdcard")
1183- (under "/storage/emulated/0")
1184- [(media/cache-dir) home]))))))
1185-
1186-(defn- png? [name]
1187- (str/ends-with? (str/lower-case (str name)) ".png"))
1188-
1189-(defn picker-entries
1190- "What `dir` holds, as `{:dirs [...] :files [...]}` of full paths.
1191-
1192- PNG only, for the same reason the media cache reads PNG only: it is what the
1193- tree backend paints and what the upload sends. An unreadable directory —
1194- which on Android is most of them — answers empty rather than throwing.
1195-
1196- Hidden entries are left out: nothing a reader means to send lives in one, and
1197- a home directory is unusable as a list with them in it."
1198- [dir]
1199- (let [names (try (sort (host/list-dir dir)) (catch Exception _ nil))
1200- keep (remove #(str/starts-with? (str %) ".") names)
1201- path (fn [n] (str dir "/" n))]
1202- {:dirs (vec (filter readable-dir? (map path keep)))
1203- :files (vec (map path (filter png? keep)))}))
1204-
1205-(defn parent-dir
1206- "The directory above `dir`, or nil at the top."
1207- [dir]
1208- (let [up (str/join "/" (butlast (str/split (str dir) #"/")))]
1209- (when (and (seq up) (not= up dir) (readable-dir? up)) up)))
1210-
1211-;; ------------------------------------------- the platform's own chooser
1212-
1213-;; Polling, because a chooser is another app's screen: it takes the reader away
1214-;; and gives nothing back through a handler here. `choosing` is what the poll
1215-;; runs on, and the count is what ends it — a reader who backs out without
1216-;; choosing tells us nothing at all, so the alternative is a poll that outlives
1217-;; the app's interest in the answer.
1218-(defonce ^:private choosing (atom nil))
1219-
1220-(def ^:private choose-poll-ms 300)
1221-
1222-(def ^:private choose-poll-limit
1223- "Five minutes of asking. Long enough for someone who wandered off mid-choice,
1224- short enough that a cancelled chooser is not still being polled for at
1225- bedtime."
1226- 1000)
1227-
1228-(defn- take-chosen!
1229- "Attach the picture the chooser has written, if it has written one yet."
1230- []
1231- (let [path (paste-path)]
1232- (host/mkdirs! (str (media/cache-dir) "/outgoing"))
1233- (when (platform/picked-image! path)
1234- (reset! choosing nil)
1235- (attach! path "picture.png")
1236- true)))
1237-
1238-(defn- poll-chosen! []
1239- (when-let [left @choosing]
1240- (when-not (take-chosen!)
1241- (if (pos? left)
1242- (do (reset! choosing (dec left))
1243- (platform/after! choose-poll-ms poll-chosen!))
1244- (reset! choosing nil)))))
1245-
1246-(defn choose-image!
1247- "Open the platform's own picture chooser, where there is one; true when it
1248- opened.
1249-
1250- Preferred to browsing on a phone, and not only for the taste of it: what the
1251- chooser hands back is a grant for the one picture the reader chose, so the
1252- app needs no permission over their pictures at all — and without such a
1253- permission, browsing finds almost nothing to show. False where there is no
1254- chooser, which is every desktop, and there browsing is the answer."
1255- []
1256- (when (platform/pick-image!)
1257- (reset! error nil)
1258- (reset! choosing choose-poll-limit)
1259- (platform/after! choose-poll-ms poll-chosen!)
1260- true))
1261-
1262-(defn open-image-picker!
1263- "Ask for a picture, whichever way this platform has of choosing one.
1264-
1265- The platform's own chooser where there is one — it needs no permission and
1266- knows where the reader's pictures actually are — and otherwise this app's
1267- own browsing screen, which is what a desktop gets."
1268- []
1269- (when-not (choose-image!)
1270- (reset! error nil)
1271- (reset! image-picker (or (first (picker-roots)) "/"))))
1272-
1273-(defn close-image-picker! [] (reset! image-picker nil))
1274-
1275-(defn browse! [dir] (when (readable-dir? dir) (reset! image-picker dir)))
1276-
1277-(defn- copy-file!
1278- "Copy `from` to `to`, byte for byte."
1279- [from to]
1280- (let [in (java.io.FileInputStream. from)]
1281- (try
1282- (let [out (java.io.FileOutputStream. to)]
1283- (try (.write out (.readAllBytes in))
1284- (finally (.close out))))
1285- (finally (try (.close in) (catch Exception _ nil))))))
1286-
1287-(defn pick-image!
1288- "Attach the picture at `path` and close the picker.
1289-
1290- Copied into `outgoing/` first rather than attached where it lies: the send
1291- drops the attachment's file when it is done with it, and what it drops has to
1292- be ours — not the reader's own picture, sitting in their pictures folder."
1293- [path]
1294- (let [copy (paste-path)]
1295- (try
1296- (host/mkdirs! (str (media/cache-dir) "/outgoing"))
1297- (copy-file! path copy)
1298- (close-image-picker!)
1299- (attach! copy (or (last (str/split (str path) #"/")) "picture.png"))
1300- (catch Exception e
1301- (discard-file! copy)
1302- (reset! error (str "Could not read that picture: " (or (ex-message e) e)))))))
1303-
1304-(defn- dm-peer-did
1305- "Who this DM is with. `frq.reactions` reads it out of the buffer."
1306- [channel]
1307- (reactions/peer-did @channels channel @form-nick))
1308-
1309-(defn mine?
1310- "Whether we are the one who said this. Nick against nick, which is what the
1311- server itself falls back to for an account with no DID — and an edit it would
1312- refuse is one not worth offering."
1313- [m]
1314- (rooms/mine? m @form-nick))
1315-
1316-(defn start-edit!
1317- "Put a message back in the box to be rewritten.
1318-
1319- The old text is the starting point rather than an empty line: an edit is
1320- usually a word, and retyping the sentence around it is not what was asked
1321- for. Whatever was half-typed is dropped — a draft and an edit are two things
1322- to say, and the box holds one."
1323- [channel m]
1324- (when (and (:id m) (mine? m))
1325- (reset! replying-to nil)
1326- (reset! editing {:channel channel :id (:id m)})
1327- (reset! draft (or (:text m) ""))))
1328-
1329-(defn cancel-edit!
1330- "Leave the message as it was said. The box empties with it: what is in it is
1331- a copy of the line on screen, and leaving that behind would look like a draft
1332- the reader wrote."
1333- []
1334- (reset! editing nil)
1335- (reset! draft ""))
1336-
1337-(defn send-draft!
1338- "Send the draft, with whatever picture is attached to it.
1339-
1340- The picture becomes its link, at the end of the line: what goes on the wire
1341- is the text the reader wrote and a URL after it, which is what every other
1342- client in the channel knows how to show. A line that is only a picture is
1343- only the link.
1344-
1345- A picture still on its way up holds the send rather than losing it: the line
1346- is left in the box, said so, and the reader presses send again a moment
1347- later. Sending the text without its picture would be the one outcome nobody
1348- asked for.
1349-
1350- A draft with a break in it is several messages. There is no newline on the
1351- wire — a PRIVMSG is one line and a line ends where the protocol says it does
1352- — so the box that lets a reader write a paragraph has to be the thing that
1353- takes it apart again: one message a line, in order, blank lines dropped. Only
1354- the last one carries the picture, and only the first one answers the message
1355- being replied to; the rest are the same thought continuing."
1356- []
1357- (let [lines (->> (str/split-lines @draft)
1358- (map str/trim)
1359- (remove str/blank?))
1360- ;; What the branches below that are about one line read: a command and
1361- ;; a rewrite are single-line things whatever the box holds.
1362- text (str/join " " lines)
1363- target @current
1364- reply-to @replying-to
1365- edit @editing
1366- {:keys [url status path] :as att} @attachment]
1367- (cond
1368- (not target) nil
1369- ;; A line beginning with "/" is said to the server, not to the room:
1370- ;; POLICY, MODE, whatever the server asks for by name. Without it a
1371- ;; channel that answers a JOIN with "use POLICY <channel> ACCEPT" is one
1372- ;; the reader can see the instructions for and has no way to follow.
1373- ;; "//" is how you say a line that really does start with a slash.
1374- (and (str/starts-with? text "/") (not (str/starts-with? text "//")))
1375- (if-let [c @conn]
1376- (let [line (str/trim (subs text 1))]
1377- (when (seq line)
1378- (irc/send-line! c line)
1379- (push-message! target "*" (str "> " line)))
1380- (reset! draft ""))
1381- (reset! error "Not connected."))
1382- (= :uploading status) (reset! error "The picture is still uploading.")
1383- ;; A rewrite replaces what was said, and what was said is a line of text:
1384- ;; there is no wire form for adding a picture to a message already sent,
1385- ;; so the attachment is held back rather than silently dropped.
1386- (and edit att) (reset! error "Finish the edit before sending a picture.")
1387- (and edit (str/blank? text)) nil
1388- edit
1389- (do (when-let [c @conn]
1390- (irc/edit! c (:channel edit) (:id edit) text
1391- (dm-peer-did (:channel edit))))
1392- ;; Same reason as a new message: the server's echo is the copy that
1393- ;; every other client sees, and folding this one in as well would
1394- ;; rewrite the line twice. Without echo-message nothing comes back,
1395- ;; so the rewrite has to be applied here or it never shows.
1396- (when-not (some-> @conn (irc/cap-acked? "echo-message"))
1397- (edit-message! (:channel edit) (:id edit) @form-nick text))
1398- (reset! editing nil)
1399- (reset! draft ""))
1400- (and (str/blank? text) (not url)) nil
1401- :else
1402- (let [lines (map #(if (str/starts-with? % "//") (subs % 1) %) lines)
1403- ;; The picture rides the last line, so a message that is only a
1404- ;; picture is the link on its own.
1405- lines (if (seq lines) (vec lines) [""])
1406- last-i (dec (count lines))
1407- lines (map-indexed (fn [i line]
1408- (str/trim (str line (when (and url (= i last-i))
1409- (str " " url)))))
1410- lines)]
1411- ;; Saying something is a way of asking to see it.
1412- (jump-to-present!)
1413- (doseq [[i line] (map-indexed vector lines)]
1414- (when-let [c @conn]
1415- (irc/privmsg! c target line (when (zero? i) (:id reply-to))))
1416- ;; Only when the server will not send the line back itself. Its copy
1417- ;; carries the msgid, and a message with no id is one nobody can react
1418- ;; or reply to; echoing locally as well would put the line up twice.
1419- (when-not (some-> @conn (irc/cap-acked? "echo-message"))
1420- (push-message! target @form-nick line
1421- {:reply-to (when (zero? i) (:id reply-to))})))
1422- (reset! replying-to nil)
1423- (reset! draft "")
1424- (when att
1425- (reset! attachment nil)
1426- ;; The picture on screen from here on is the one fetched back from
1427- ;; the link, like everyone else's.
1428- (discard-file! path))))))
1429-
1430-
1431-(defn open-picker!
1432- "Choose an emoji for this message. Opening it fresh — no leftover search from
1433- the last time, which would be a screen of somebody else's question."
1434- [channel m]
1435- (when (:id m)
1436- (reset! emoji-search "")
1437- (reset! emoji-group nil)
1438- (reset! reacting {:channel channel :id (:id m)})))
1439-
1440-(defn close-picker! [] (reset! reacting nil))
1441-
1442-(def picker-emoji
1443- "Moved to `frq.reactions`: it is the cells and the catalog, both of which
1444- are shared, and the phone shows the same picker."
1445- reactions/picker-emoji)
1446-
1447-(defn my-reaction?
1448- "Whether this nick is already on that emoji — which is what makes a second
1449- click take it off rather than send the same reaction twice."
1450- [m emoji]
1451- (reactions/mine? m emoji @form-nick))
1452-
1453-(def reaction-hover cells/reaction-hover)
1454-
1455-(defn hover-reaction!
1456- "The pointer has come to rest on a pill."
1457- [msgid emoji]
1458- (reset! reaction-hover {:id msgid :emoji emoji}))
1459-
1460-(defn unhover-reaction!
1461- "The pointer has left that pill. Guarded by which one is being left, so
1462- crossing straight from one pill to the next — both edges in a frame — cannot
1463- take down the card that has just been raised."
1464- [msgid emoji]
1465- (swap! reaction-hover #(when-not (= {:id msgid :emoji emoji} %) %)))
1466-
1467-(defn hovering-reaction?
1468- "Whether this is the pill the card belongs to."
1469- [msgid emoji]
1470- (= {:id msgid :emoji emoji} @reaction-hover))
1471-
1472-(defn toggle-reaction!
1473- "Put my emoji on a message, or take it off if it is already mine.
1474-
1475- Applied here as well as sent: the server relays a TAGMSG to everyone in the
1476- channel *except* the client that sent it, so without this the pill would only
1477- appear once someone else reacted too."
1478- [channel m emoji]
1479- (when-let [msgid (:id m)]
1480- (let [on? (not (my-reaction? m emoji))
1481- ;; Who the DM is with, for the signature: freeq names a DM by both
1482- ;; DIDs rather than by a nick, and nothing else in a buffer says
1483- ;; which account the other side is. nil in a channel, which is named
1484- ;; by itself.
1485- peer (dm-peer-did channel)]
1486- (when-let [c @conn]
1487- (if on?
1488- (irc/react! c channel msgid emoji peer)
1489- (irc/unreact! c channel msgid emoji peer)))
1490- (update-reaction! channel msgid emoji @form-nick on?))))
1491-
1492-(defn start-call!
1493- "Open a call on this channel.
1494-
1495- Optimistic: the controls appear on the press. What comes back settles it —
1496- an `av-state` says the room has a call, an `av-token` starts the media, and
1497- a `start-collision` means someone beat us to it and we join theirs instead."
1498- [channel]
1499- (when-let [c @conn]
1500- (let [nick (or (:nick @session) @form-nick)
1501- instance (av/begin! {:channel channel
1502- :nick nick
1503- :muted? false
1504- :speaker-muted? false
1505- ;; Audio first, always. A call that opened with
1506- ;; the camera on would be a call that showed
1507- ;; someone's room before they had agreed to.
1508- :camera? false})]
1509- (irc/tagmsg! c channel (av/start-tags instance nil)))))
1510-
1511-(defn join-call!
1512- "Join the call already open on this channel."
1513- [channel session-id]
1514- (when-let [c @conn]
1515- (let [nick (or (:nick @session) @form-nick)
1516- instance (av/begin! {:channel channel
1517- :session-id session-id
1518- :nick nick
1519- :muted? false
1520- :speaker-muted? false
1521- :camera? false})]
1522- (irc/tagmsg! c channel (av/join-tags session-id instance)))))
1523-
1524-(defn announce-leave!
1525- "Tell the room this device is out of a call it did not choose to leave.
1526-
1527- freeq counts a participant until an `av-leave` says otherwise, so a media
1528- plane that fails silently leaves a ghost behind — and the next Join adds
1529- another beside it. Registered with `frq.av` at startup, because that
1530- namespace has no connection to send on."
1531- [{:keys [channel session-id instance]}]
1532- (when (and @conn (seq (or session-id "")))
1533- (irc/tagmsg! @conn channel (av/leave-tags session-id instance))))
1534-
1535-(defn leave-call!
1536- "Leave the call, telling the room and the SFU both.
1537-
1538- The media plane goes down first and on its own account: the person pressed
1539- leave, so the microphone should be shut whether or not the TAGMSG gets out."
1540- []
1541- (when-let [{:keys [channel session-id instance]} @av/local-call]
1542- (av/stop-media!)
1543- (when-let [c @conn]
1544- (when (seq session-id)
1545- (irc/tagmsg! c channel (av/leave-tags session-id instance))))))
1546-
1547-(def channel-list rooms/channel-list)
1548-
1549-(defn channel-order
1550- "The buffer names, most recently opened first — what gets written to disk.
1551- Buffers never opened are left out: a DM that arrived once and was never read
1552- is not a place this client has been, and neither is a channel someone
1553- mentioned. One that was opened is, whether it is a room or a person."
1554- []
1555- (->> (vals @channels)
1556- (filter #(pos? (:accessed % 0)))
1557- (sort-by #(- (:accessed % 0)))
1558- (mapv :name)))
1559-
1560-(def room-records rooms/room-records)
1561-
1562-(def restore-channels! rooms/restore-channels!)
1563-
1564-(defn message-by-id
1565- "The message a reply points at, if this buffer still holds it.
1566-
1567- By any name it has had — a `:local-id` for a line the server never named,
1568- and the msgid of any revision of it. `frq.rooms/answers-to?` is that rule,
1569- shared so both halves resolve a reply the same way."
1570- [channel id]
1571- (rooms/message-by-id @channels channel id))
1572-
1573-(defn react-from-picker!
1574- "Put the chosen emoji on the message the picker was opened for, and close it.
1575- One choice and back to the conversation: a picker left open would be asking a
1576- question that has been answered."
1577- [emoji]
1578- (when-let [{:keys [channel id]} @reacting]
1579- (when-let [m (message-by-id channel id)]
1580- (toggle-reaction! channel m emoji))
1581- (close-picker!)))
1582-
1583-
1584-(def jump-to cells/jump-to)
1585-
1586-(def highlight cells/highlight)
1587-
1588-
1589-(def overview-limit rooms/overview-limit)
1590-
1591-(def recent-everywhere rooms/recent-everywhere)
1592-
1593-(def last-preview rooms/last-preview)
1594-
1595-;; What the shared screens call. Installed here rather than in an entry point
1596-;; because these are this namespace's own reducers, and the screens that call
1597-;; them are no longer in a position to name them.
1598-(actions/install!
1599- {:connect! connect!
1600- :disconnect! disconnect!
1601- :forget-session! forget-session!
1602- :connected? connected?
1603- :join! join!
1604- :open-channel! open-channel!
1605- :leave-channel! leave-channel!
1606- :toggle-hide-join-part! toggle-hide-join-part!
1607- :browse! browse!
1608- :close-image-picker! close-image-picker!
1609- :pick-image! pick-image!
1610- :parent-dir parent-dir
1611- :picker-entries picker-entries
1612- :picker-roots picker-roots
1613- :media-tick (fn [] @media-tick)
1614- :avatar-ready (fn [actor] (avatars/path-when-ready actor))
1615- :send-draft! send-draft!
1616- :cancel-edit! cancel-edit!
1617- :cancel-reply! cancel-reply!
1618- :clear-attachment! clear-attachment!
1619- :open-image-picker! open-image-picker!
1620- :paste-image! paste-image!
1621- :jump-to-present! jump-to-present!
1622- :scrolled! scrolled!
1623- :toggle-users! toggle-users!
1624- :toggle-chat-list! toggle-chat-list!
1625- :toggle-overview! toggle-overview!
1626- :wide? wide?
1627- :member-count member-count
1628- :start-call! start-call!
1629- :in-call? av/in-call?
1630- :call-in av/call-in
1631- :call-available? av/available?
1632- :desktop? platform/desktop?
1633- :quit! platform/quit!
1634- :avatar-path nil
1635- :image-path nil
1636- :local-call (fn [] @av/local-call)
1637- :local-feed (fn [] av/local-feed)
1638- :media-error (fn [] @av/media-error)
1639- :tiles av/tiles
1640- :tile-rows av/tile-rows
1641- :set-muted! av/set-muted!
1642- :set-speaker-muted! av/set-speaker-muted!
1643- :set-camera! av/set-camera!
1644- :after! platform/after!
1645- :open-url! platform/open-url!
1646- :accept-policy! accept-policy!
1647- :close-picker! close-picker!
1648- :hover-reaction! hover-reaction!
1649- :join-call! join-call!
1650- :leave-call! leave-call!
1651- :leaving-for-overview! leaving-for-overview!
1652- :member-list member-list
1653- :message-by-id message-by-id
1654- :mine? mine?
1655- :my-reaction? my-reaction?
1656- :open-dm! open-dm!
1657- :open-picker! open-picker!
1658- :overview-back! overview-back!
1659- :picker-emoji picker-emoji
1660- :react-from-picker! react-from-picker!
1661- :recent-everywhere recent-everywhere
1662- :reply-to! reply-to!
1663- ;; A reply chip that found nothing asks what that msgid was; the repaint is
1664- ;; the same tick a picture or a face arriving uses, because it is the same
1665- ;; shape of answer — something a row read, arriving after the row was drawn.
1666- :resolve-reply! (fn [channel id]
1667- (replies/resolve! channel id #(swap! media-tick inc)))
1668- :start-edit! start-edit!
1669- :toggle-reaction! toggle-reaction!
1670- :unhover-reaction! unhover-reaction!})
deleted src/frq/tui.clj +0 -241
deleted file mode 100644
@@ -1,241 +0,0 @@
1-(ns frq.tui
2- "frq's own screens, painted into a terminal.
3-
4- The components in `frq.app` are hiccup over glimmer's reconciler, and the
5- reconciler does not know what is under it — so the same tree that egui paints
6- as a window goes through `libjolttui` as cells instead. Nothing in `frq.app`
7- changes; this namespace only picks the other backend and gives it a screen.
8-
9- Requiring order is the whole trick. `frq.app` pulls in `glimmer-vidya.core`,
10- which installs itself on load; `glimmer-tui.core` is required after it and
11- installs itself in turn, so the backend glimmer renders with is the terminal.
12- The Vidya library is still loaded and frq still calls into it — for a window
13- title, a window width, a picture chooser — and with no window open every one
14- of those is inert, which is the behaviour those calls already have to have.
15-
16- It is the client, not a preview of it: `frq.app/start!` is what a launch
17- does, and this hands it the terminal's timers instead of the window's. The
18- saved sign-in is restored and connects itself, the rooms this client has been
19- in come back, and `#test` is joined on arrival — the same session as the
20- window, painted somewhere else.
21-
22- Two ways to run it:
23-
24- jolt -M:tui the real terminal, until Ctrl-Q
25- jolt -M:tui --headless one screenshot on stdout, no terminal at all
26-
27- The headless one is `tui_headless` — the same layout and the same painting
28- with the writer taken off the end — and is what a screenshot in a bug report
29- or a CI check should be. It is also the one that has nothing to show: a
30- screenshot taken a moment after launch is of a client that has not finished
31- connecting, so `--demo` fills a buffer of its own rather than waiting on a
32- server for one."
33- (:require [clojure.string :as str]
34- [frq.app :as app]
35- [frq.state :as s]
36- [jolt.host :as host]
37- [glimmer.core :as ui]
38- [frq.platform :as platform]
39- [glimmer-tui.ffi :as tui-ffi]
40- ;; last, so its install! is the one that stands
41- [glimmer-tui.core :as tui]))
42-
43-(def ^:private demo-channel "#tui")
44-
45-;; frq's spacing is written in points, because it was written for a window:
46-;; `:margin 12`, `:width-request 260`, a sidebar of 320. A cell is worth about
47-;; eight of those across, so that is the divisor the backend is handed — and
48-;; the same one `narrow!` multiplies back up, so `wide?` is asked its question
49-;; in the units it was written against.
50-(def ^:private points-per-cell 8)
51-
52-;; And down the page a cell is worth about twice that: a character is roughly
53-;; twice as tall as it is wide, which is what decides how many messages fit.
54-(def ^:private points-per-row (* 2 points-per-cell))
55-
56-(defn- seed!
57- "Put a conversation on screen without a server behind it — `--demo`.
58-
59- A screenshot is taken before a connection has been made, and an empty buffer
60- says nothing about how a buffer looks. These go in through `push-message!`
61- rather than into the atom directly, so what is drawn is a real buffer:
62- timestamps, day headings, the actor lookup, reply chips and reactions all
63- included."
64- []
65- (let [now (System/currentTimeMillis)
66- minute 60000]
67- (s/push-message! demo-channel "*" (str "Now talking in " demo-channel)
68- {:at (- now (* 32 minute))})
69- (s/push-message! demo-channel "nandi" "the tree ABI is the same one libvidya exports"
70- {:at (- now (* 12 minute)) :id "m1"})
71- (s/push-message! demo-channel "vidya" "so the jolt side picks a window or a terminal and changes nothing else"
72- {:at (- now (* 11 minute)) :id "m2" :reply-to "m1"
73- :reactions {"👍" ["nandi"]}})
74- (s/push-message! demo-channel "nandi" "one reconciler, two shared objects"
75- {:at (- now (* 2 minute)) :id "m3"}))
76- (swap! s/channels assoc-in [demo-channel :joined?] true)
77- (swap! s/channels assoc-in [demo-channel :users]
78- {"nandi" "@" "vidya" "" "you" ""})
79- (reset! s/form-nick "you")
80- (reset! s/status (str "Not connected — " demo-channel " is a demo buffer"))
81- (reset! s/current demo-channel)
82- (reset! s/screen :chat)
83- nil)
84-
85-(defn- graphics?
86- "Whether this terminal draws pictures over its cells.
87-
88- The same question `jolt-tui` asks itself before it reserves cells for an
89- `:image`, and asked the same way: the protocol's own query comes back as
90- input, long after the layout has had to decide how many rows a picture takes,
91- so a terminal is identified by name here as it is everywhere else — and
92- `JOLT_TUI_GRAPHICS` is the way in for one neither of us has learned yet.
93-
94- frq asks it too, rather than reading the answer back out of the library,
95- because what hangs on it is a tree: a face on a message is an `:image` node
96- or it is no node at all, and that is decided before the backend sees it."
97- []
98- (let [env (fn [k] (or (host/getenv k) ""))
99- known? (fn [name]
100- (let [name (str/lower-case name)]
101- (boolean (some #(str/includes? name %)
102- ["kitty" "ghostty" "wezterm"]))))]
103- (case (str/lower-case (env "JOLT_TUI_GRAPHICS"))
104- ("1" "true") true
105- ("0" "false") false
106- (or (seq (env "KITTY_WINDOW_ID"))
107- (seq (env "GHOSTTY_RESOURCES_DIR"))
108- (known? (env "TERM"))
109- (known? (env "TERM_PROGRAM"))
110- false))))
111-
112-(defn- at-end?
113- "Whether the scroll that is being reported ran into the end of its content.
114-
115- A terminal's viewport is moved by an offset and reports the offset it was
116- asked for, so `to` on its own says nothing about where the bottom is. What
117- says it is the clamp: painting fits the offset to the content it had and
118- writes the fitted one back, so a request that came out smaller than it went
119- in is a request that asked for more list than there was — which is a reader
120- who has scrolled back down to the newest line.
121-
122- Read here, in the handler, because this is the one moment it is true of this
123- scroll: the loop paints the frame the input caused and only then hands out
124- the events, so the offset on the node is already this request, clamped. The
125- render that follows will clear it.
126-
127- The one it cannot see is a page that lands exactly on the end — nothing was
128- clamped, so it reads as a scroll like any other, and the button stays up for
129- one more press. There is no max on the node to compare against, and asking
130- for one is the jump itself."
131- [to]
132- (< (tui-ffi/node-get-num (tui-ffi/event-node) "offset") to))
133-
134-(defn- narrow!
135- "Tell the layout how much room it has, in the units it expects.
136-
137- `frq.state`'s `wide?` is written against a window's width in points, and the
138- terminal's is in cells — two orders of magnitude smaller, so left alone every
139- session would take the narrow layout by accident rather than on purpose. A
140- cell is about eight points wide and a row about sixteen tall, which is close
141- enough for the one question anything here asks of these numbers."
142- []
143- (let [[cols rows] (tui/screen-size)]
144- (reset! s/window-width (* cols points-per-cell))
145- ;; In the same points: the heights in frq — a picture's, the room kept for
146- ;; the compose bar — are measured against the window's.
147- (reset! s/window-height (* rows points-per-row)))
148- nil)
149-
150-(defn- start!
151- "The launch, with the terminal's timers in place of the window's.
152-
153- No title and no media plane: a terminal has no title bar to write a nick
154- into, and a call paints frames into a texture there is no texture for here.
155- Everything else — the saved settings, the rooms, the sign-in that connects
156- itself — is the same startup the window runs."
157- []
158- ;; A row of chrome here is one cell, not a window's 34 points, so the strip
159- ;; under a scrolling list reserves the three rows it needs rather than the
160- ;; nine the window's count works out to.
161- (reset! app/chrome-row points-per-row)
162- ;; And a message here is a name with its words hanging under it, rather than
163- ;; a face with them beside it: the initial that stands in for a portrait in a
164- ;; window is a letter printed twice here, which only pushed every nick in
165- ;; past its own text.
166- (reset! app/terminal? true)
167- ;; The face itself comes back where the terminal can draw one: this client
168- ;; fetches the picture either way, and Kitty's protocol puts it over the
169- ;; cells the layout reserved for it.
170- (reset! app/terminal-graphics? (boolean (graphics?)))
171- ;; And how a scroll here answers "am I at the newest line?", which the
172- ;; window's scroll area answers for itself.
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})
182- (app/start! {:after! tui/after!
183- :every! tui/every!
184- :title! nil
185- :av? false
186- :measure! narrow!}))
187-
188-(defn- run-headless!
189- "Mount the app in a session of `cols` by `rows` with no terminal, let it
190- settle, and print what was painted."
191- [cols rows demo? wait dump?]
192- ;; The size has to be known before the first render, or the layout reads the
193- ;; zero it was left at. A timer runs on the loop thread, which is where
194- ;; screen-size may be asked — and the first one fires before the first paint.
195- (tui/after! 0 (fn []
196- (narrow!)
197- (start!)
198- (when demo? (seed!))))
199- ;; Long enough for whatever the screenshot is of. A client that restores a
200- ;; sign-in spends the first seconds connecting, so the default is a picture
201- ;; of the connect screen and `--wait=` is how you ask for one of the room.
202- (tui/after! wait (fn []
203- (println (tui/screen-str))
204- ;; The tree as the library holds it, which is what a report
205- ;; about a layout is actually about: props after scaling,
206- ;; and the shape the reconciler left behind.
207- (when dump? (println (tui/dump-str)))
208- (tui/quit!)))
209- (ui/run app/app :headless [cols rows]
210- :points-per-cell points-per-cell
211- :points-per-row points-per-row))
212-
213-(defn- run-terminal! [demo?]
214- ;; On the loop thread, and before the first paint: `start!` sets timers of
215- ;; its own, and a timer added from anywhere else is a node touched from the
216- ;; wrong thread waiting to happen.
217- (tui/after! 0 (fn []
218- (narrow!)
219- (start!)
220- (when demo? (seed!))))
221- (ui/run app/app :mouse true
222- :points-per-cell points-per-cell
223- :points-per-row points-per-row))
224-
225-(defn -main [& args]
226- (let [args (set args)
227- headless? (contains? args "--headless")
228- demo? (contains? args "--demo")
229- dump? (contains? args "--dump")
230- num (fn [flag default]
231- (if-let [n (first (keep #(when (str/starts-with? % flag)
232- (subs % (count flag)))
233- args))]
234- (Integer/parseInt n)
235- default))
236- cols (num "--cols=" 100)
237- rows (num "--rows=" 36)
238- wait (num "--wait=" 400)]
239- (if headless?
240- (run-headless! cols rows demo? wait dump?)
241- (run-terminal! demo?))))
deleted file mode 100644
@@ -1,241 +0,0 @@
1-(ns frq.tui
2- "frq's own screens, painted into a terminal.
3-
4- The components in `frq.app` are hiccup over glimmer's reconciler, and the
5- reconciler does not know what is under it — so the same tree that egui paints
6- as a window goes through `libjolttui` as cells instead. Nothing in `frq.app`
7- changes; this namespace only picks the other backend and gives it a screen.
8-
9- Requiring order is the whole trick. `frq.app` pulls in `glimmer-vidya.core`,
10- which installs itself on load; `glimmer-tui.core` is required after it and
11- installs itself in turn, so the backend glimmer renders with is the terminal.
12- The Vidya library is still loaded and frq still calls into it — for a window
13- title, a window width, a picture chooser — and with no window open every one
14- of those is inert, which is the behaviour those calls already have to have.
15-
16- It is the client, not a preview of it: `frq.app/start!` is what a launch
17- does, and this hands it the terminal's timers instead of the window's. The
18- saved sign-in is restored and connects itself, the rooms this client has been
19- in come back, and `#test` is joined on arrival — the same session as the
20- window, painted somewhere else.
21-
22- Two ways to run it:
23-
24- jolt -M:tui the real terminal, until Ctrl-Q
25- jolt -M:tui --headless one screenshot on stdout, no terminal at all
26-
27- The headless one is `tui_headless` — the same layout and the same painting
28- with the writer taken off the end — and is what a screenshot in a bug report
29- or a CI check should be. It is also the one that has nothing to show: a
30- screenshot taken a moment after launch is of a client that has not finished
31- connecting, so `--demo` fills a buffer of its own rather than waiting on a
32- server for one."
33- (:require [clojure.string :as str]
34- [frq.app :as app]
35- [frq.state :as s]
36- [jolt.host :as host]
37- [glimmer.core :as ui]
38- [frq.platform :as platform]
39- [glimmer-tui.ffi :as tui-ffi]
40- ;; last, so its install! is the one that stands
41- [glimmer-tui.core :as tui]))
42-
43-(def ^:private demo-channel "#tui")
44-
45-;; frq's spacing is written in points, because it was written for a window:
46-;; `:margin 12`, `:width-request 260`, a sidebar of 320. A cell is worth about
47-;; eight of those across, so that is the divisor the backend is handed — and
48-;; the same one `narrow!` multiplies back up, so `wide?` is asked its question
49-;; in the units it was written against.
50-(def ^:private points-per-cell 8)
51-
52-;; And down the page a cell is worth about twice that: a character is roughly
53-;; twice as tall as it is wide, which is what decides how many messages fit.
54-(def ^:private points-per-row (* 2 points-per-cell))
55-
56-(defn- seed!
57- "Put a conversation on screen without a server behind it — `--demo`.
58-
59- A screenshot is taken before a connection has been made, and an empty buffer
60- says nothing about how a buffer looks. These go in through `push-message!`
61- rather than into the atom directly, so what is drawn is a real buffer:
62- timestamps, day headings, the actor lookup, reply chips and reactions all
63- included."
64- []
65- (let [now (System/currentTimeMillis)
66- minute 60000]
67- (s/push-message! demo-channel "*" (str "Now talking in " demo-channel)
68- {:at (- now (* 32 minute))})
69- (s/push-message! demo-channel "nandi" "the tree ABI is the same one libvidya exports"
70- {:at (- now (* 12 minute)) :id "m1"})
71- (s/push-message! demo-channel "vidya" "so the jolt side picks a window or a terminal and changes nothing else"
72- {:at (- now (* 11 minute)) :id "m2" :reply-to "m1"
73- :reactions {"👍" ["nandi"]}})
74- (s/push-message! demo-channel "nandi" "one reconciler, two shared objects"
75- {:at (- now (* 2 minute)) :id "m3"}))
76- (swap! s/channels assoc-in [demo-channel :joined?] true)
77- (swap! s/channels assoc-in [demo-channel :users]
78- {"nandi" "@" "vidya" "" "you" ""})
79- (reset! s/form-nick "you")
80- (reset! s/status (str "Not connected — " demo-channel " is a demo buffer"))
81- (reset! s/current demo-channel)
82- (reset! s/screen :chat)
83- nil)
84-
85-(defn- graphics?
86- "Whether this terminal draws pictures over its cells.
87-
88- The same question `jolt-tui` asks itself before it reserves cells for an
89- `:image`, and asked the same way: the protocol's own query comes back as
90- input, long after the layout has had to decide how many rows a picture takes,
91- so a terminal is identified by name here as it is everywhere else — and
92- `JOLT_TUI_GRAPHICS` is the way in for one neither of us has learned yet.
93-
94- frq asks it too, rather than reading the answer back out of the library,
95- because what hangs on it is a tree: a face on a message is an `:image` node
96- or it is no node at all, and that is decided before the backend sees it."
97- []
98- (let [env (fn [k] (or (host/getenv k) ""))
99- known? (fn [name]
100- (let [name (str/lower-case name)]
101- (boolean (some #(str/includes? name %)
102- ["kitty" "ghostty" "wezterm"]))))]
103- (case (str/lower-case (env "JOLT_TUI_GRAPHICS"))
104- ("1" "true") true
105- ("0" "false") false
106- (or (seq (env "KITTY_WINDOW_ID"))
107- (seq (env "GHOSTTY_RESOURCES_DIR"))
108- (known? (env "TERM"))
109- (known? (env "TERM_PROGRAM"))
110- false))))
111-
112-(defn- at-end?
113- "Whether the scroll that is being reported ran into the end of its content.
114-
115- A terminal's viewport is moved by an offset and reports the offset it was
116- asked for, so `to` on its own says nothing about where the bottom is. What
117- says it is the clamp: painting fits the offset to the content it had and
118- writes the fitted one back, so a request that came out smaller than it went
119- in is a request that asked for more list than there was — which is a reader
120- who has scrolled back down to the newest line.
121-
122- Read here, in the handler, because this is the one moment it is true of this
123- scroll: the loop paints the frame the input caused and only then hands out
124- the events, so the offset on the node is already this request, clamped. The
125- render that follows will clear it.
126-
127- The one it cannot see is a page that lands exactly on the end — nothing was
128- clamped, so it reads as a scroll like any other, and the button stays up for
129- one more press. There is no max on the node to compare against, and asking
130- for one is the jump itself."
131- [to]
132- (< (tui-ffi/node-get-num (tui-ffi/event-node) "offset") to))
133-
134-(defn- narrow!
135- "Tell the layout how much room it has, in the units it expects.
136-
137- `frq.state`'s `wide?` is written against a window's width in points, and the
138- terminal's is in cells — two orders of magnitude smaller, so left alone every
139- session would take the narrow layout by accident rather than on purpose. A
140- cell is about eight points wide and a row about sixteen tall, which is close
141- enough for the one question anything here asks of these numbers."
142- []
143- (let [[cols rows] (tui/screen-size)]
144- (reset! s/window-width (* cols points-per-cell))
145- ;; In the same points: the heights in frq — a picture's, the room kept for
146- ;; the compose bar — are measured against the window's.
147- (reset! s/window-height (* rows points-per-row)))
148- nil)
149-
150-(defn- start!
151- "The launch, with the terminal's timers in place of the window's.
152-
153- No title and no media plane: a terminal has no title bar to write a nick
154- into, and a call paints frames into a texture there is no texture for here.
155- Everything else — the saved settings, the rooms, the sign-in that connects
156- itself — is the same startup the window runs."
157- []
158- ;; A row of chrome here is one cell, not a window's 34 points, so the strip
159- ;; under a scrolling list reserves the three rows it needs rather than the
160- ;; nine the window's count works out to.
161- (reset! app/chrome-row points-per-row)
162- ;; And a message here is a name with its words hanging under it, rather than
163- ;; a face with them beside it: the initial that stands in for a portrait in a
164- ;; window is a letter printed twice here, which only pushed every nick in
165- ;; past its own text.
166- (reset! app/terminal? true)
167- ;; The face itself comes back where the terminal can draw one: this client
168- ;; fetches the picture either way, and Kitty's protocol puts it over the
169- ;; cells the layout reserved for it.
170- (reset! app/terminal-graphics? (boolean (graphics?)))
171- ;; And how a scroll here answers "am I at the newest line?", which the
172- ;; window's scroll area answers for itself.
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})
182- (app/start! {:after! tui/after!
183- :every! tui/every!
184- :title! nil
185- :av? false
186- :measure! narrow!}))
187-
188-(defn- run-headless!
189- "Mount the app in a session of `cols` by `rows` with no terminal, let it
190- settle, and print what was painted."
191- [cols rows demo? wait dump?]
192- ;; The size has to be known before the first render, or the layout reads the
193- ;; zero it was left at. A timer runs on the loop thread, which is where
194- ;; screen-size may be asked — and the first one fires before the first paint.
195- (tui/after! 0 (fn []
196- (narrow!)
197- (start!)
198- (when demo? (seed!))))
199- ;; Long enough for whatever the screenshot is of. A client that restores a
200- ;; sign-in spends the first seconds connecting, so the default is a picture
201- ;; of the connect screen and `--wait=` is how you ask for one of the room.
202- (tui/after! wait (fn []
203- (println (tui/screen-str))
204- ;; The tree as the library holds it, which is what a report
205- ;; about a layout is actually about: props after scaling,
206- ;; and the shape the reconciler left behind.
207- (when dump? (println (tui/dump-str)))
208- (tui/quit!)))
209- (ui/run app/app :headless [cols rows]
210- :points-per-cell points-per-cell
211- :points-per-row points-per-row))
212-
213-(defn- run-terminal! [demo?]
214- ;; On the loop thread, and before the first paint: `start!` sets timers of
215- ;; its own, and a timer added from anywhere else is a node touched from the
216- ;; wrong thread waiting to happen.
217- (tui/after! 0 (fn []
218- (narrow!)
219- (start!)
220- (when demo? (seed!))))
221- (ui/run app/app :mouse true
222- :points-per-cell points-per-cell
223- :points-per-row points-per-row))
224-
225-(defn -main [& args]
226- (let [args (set args)
227- headless? (contains? args "--headless")
228- demo? (contains? args "--demo")
229- dump? (contains? args "--dump")
230- num (fn [flag default]
231- (if-let [n (first (keep #(when (str/starts-with? % flag)
232- (subs % (count flag)))
233- args))]
234- (Integer/parseInt n)
235- default))
236- cols (num "--cols=" 100)
237- rows (num "--rows=" 36)
238- wait (num "--wait=" 400)]
239- (if headless?
240- (run-headless! cols rows demo? wait dump?)
241- (run-terminal! demo?))))
deleted src/frq/upload.clj +0 -79
deleted file mode 100644
@@ -1,79 +0,0 @@
1-(ns frq.upload
2- "Sending a picture: freeq's media endpoint, over the same hand-rolled TLS the
3- rest of the client speaks.
4-
5- IRC carries text, so a picture is shared the way every other client shares
6- one — it is uploaded, and the link goes in the line. freeq's
7- `POST /api/v1/upload` takes a multipart form and answers with a URL under
8- `/api/v1/media/…`, signed and served back to anyone the link reaches.
9-
10- The upload is authorised by the connection itself: the endpoint accepts a DID
11- that has a live session on the server, which a signed-in client already has.
12- A guest has no DID and cannot upload — the same line TLS draws everywhere
13- else in this client.
14-
15- Nothing here is streamed: an image is at most a few megabytes and the body is
16- built in memory, which is what keeps the request one write."
17- (:require [clojure.string :as str]
18- [frq.upload.core :as core]
19- [jolt.mvn-http :as tls]))
20-
21-;; The endpoint's own cap. Refusing here rather than at the server saves a
22-;; multi-megabyte upload that was always going to be rejected.
23-(def max-bytes core/max-bytes)
24-
25-(defn- file-bytes [path]
26- (let [in (java.io.FileInputStream. path)]
27- (try (.readAllBytes in)
28- (finally (try (.close in) (catch Exception _ nil))))))
29-
30-(defn- bytes-of [s] (.getBytes (str s)))
31-
32-(defn- read-all!
33- "Drain a TLS connection into a string. The response is a short JSON body, so
34- it is read whole rather than by Content-Length."
35- [t]
36- (loop [acc ""]
37- (let [b (try (tls/tls-read t) (catch Exception _ nil))]
38- (if (or (nil? b) (zero? (count b)))
39- acc
40- (recur (str acc (String. b)))))))
41-
42-(defn- status-of [resp]
43- (some-> (re-find #"^HTTP/1\.[01] (\d{3})" (or resp "")) second))
44-
45-(defn upload!
46- "Upload `path` as `did`'s, returning the URL freeq serves it back at.
47-
48- `frq.upload.core` builds the request and reads the answer; what is left here
49- is this backend's way of sending one — jolt's own TLS, in a single write.
50-
51- `channel` is passed along when there is one: the server files an upload under
52- the conversation it was meant for. Nothing is shared to the PDS or posted to
53- Bluesky — those are opt-in fields this client does not send."
54- [host did channel path filename]
55- (let [{:keys [path content-type body]}
56- (core/request did channel filename (vec (file-bytes path)))
57- body (byte-array body)]
58- (tls/ensure-native!)
59- (let [head (bytes-of (str "POST " path " HTTP/1.1\r\n"
60- "Host: " host "\r\n"
61- "User-Agent: frq\r\n"
62- "Accept: application/json\r\n"
63- "Content-Type: " content-type "\r\n"
64- "Content-Length: " (alength body) "\r\n"
65- "Connection: close\r\n\r\n"))
66- t (tls/tls-connect host 443)]
67- (try
68- ;; Head and body in one write: the server reads a request, not two.
69- (let [req (java.io.ByteArrayOutputStream.)]
70- (.write req head)
71- (.write req body)
72- (tls/tls-write t (.toByteArray req)))
73- (let [resp (read-all! t)
74- status (status-of resp)
75- [_ payload] (str/split resp #"\r\n\r\n" 2)]
76- (if (and status (str/starts-with? status "2"))
77- (core/url-of payload)
78- (throw (ex-info (core/error-message (or status "no response") payload) {}))))
79- (finally (try (tls/tls-close t) (catch Exception _ nil)))))))
deleted file mode 100644
@@ -1,79 +0,0 @@
1-(ns frq.upload
2- "Sending a picture: freeq's media endpoint, over the same hand-rolled TLS the
3- rest of the client speaks.
4-
5- IRC carries text, so a picture is shared the way every other client shares
6- one — it is uploaded, and the link goes in the line. freeq's
7- `POST /api/v1/upload` takes a multipart form and answers with a URL under
8- `/api/v1/media/…`, signed and served back to anyone the link reaches.
9-
10- The upload is authorised by the connection itself: the endpoint accepts a DID
11- that has a live session on the server, which a signed-in client already has.
12- A guest has no DID and cannot upload — the same line TLS draws everywhere
13- else in this client.
14-
15- Nothing here is streamed: an image is at most a few megabytes and the body is
16- built in memory, which is what keeps the request one write."
17- (:require [clojure.string :as str]
18- [frq.upload.core :as core]
19- [jolt.mvn-http :as tls]))
20-
21-;; The endpoint's own cap. Refusing here rather than at the server saves a
22-;; multi-megabyte upload that was always going to be rejected.
23-(def max-bytes core/max-bytes)
24-
25-(defn- file-bytes [path]
26- (let [in (java.io.FileInputStream. path)]
27- (try (.readAllBytes in)
28- (finally (try (.close in) (catch Exception _ nil))))))
29-
30-(defn- bytes-of [s] (.getBytes (str s)))
31-
32-(defn- read-all!
33- "Drain a TLS connection into a string. The response is a short JSON body, so
34- it is read whole rather than by Content-Length."
35- [t]
36- (loop [acc ""]
37- (let [b (try (tls/tls-read t) (catch Exception _ nil))]
38- (if (or (nil? b) (zero? (count b)))
39- acc
40- (recur (str acc (String. b)))))))
41-
42-(defn- status-of [resp]
43- (some-> (re-find #"^HTTP/1\.[01] (\d{3})" (or resp "")) second))
44-
45-(defn upload!
46- "Upload `path` as `did`'s, returning the URL freeq serves it back at.
47-
48- `frq.upload.core` builds the request and reads the answer; what is left here
49- is this backend's way of sending one — jolt's own TLS, in a single write.
50-
51- `channel` is passed along when there is one: the server files an upload under
52- the conversation it was meant for. Nothing is shared to the PDS or posted to
53- Bluesky — those are opt-in fields this client does not send."
54- [host did channel path filename]
55- (let [{:keys [path content-type body]}
56- (core/request did channel filename (vec (file-bytes path)))
57- body (byte-array body)]
58- (tls/ensure-native!)
59- (let [head (bytes-of (str "POST " path " HTTP/1.1\r\n"
60- "Host: " host "\r\n"
61- "User-Agent: frq\r\n"
62- "Accept: application/json\r\n"
63- "Content-Type: " content-type "\r\n"
64- "Content-Length: " (alength body) "\r\n"
65- "Connection: close\r\n\r\n"))
66- t (tls/tls-connect host 443)]
67- (try
68- ;; Head and body in one write: the server reads a request, not two.
69- (let [req (java.io.ByteArrayOutputStream.)]
70- (.write req head)
71- (.write req body)
72- (tls/tls-write t (.toByteArray req)))
73- (let [resp (read-all! t)
74- status (status-of resp)
75- [_ payload] (str/split resp #"\r\n\r\n" 2)]
76- (if (and status (str/starts-with? status "2"))
77- (core/url-of payload)
78- (throw (ex-info (core/error-message (or status "no response") payload) {}))))
79- (finally (try (tls/tls-close t) (catch Exception _ nil)))))))
deleted src/frq/wire.clj +0 -81
deleted file mode 100644
@@ -1,81 +0,0 @@
1-(ns frq.wire
2- "Bytes onto a raw socket, all of them.
3-
4- `send(2)` is allowed to accept less than it was given and say so, which is
5- not an error and not rare — it is what a full socket buffer looks like. The
6- caller has to resume from where it stopped, and the only way to say that to
7- the kernel is a pointer further along the buffer: shrinking the length while
8- passing the same address re-sends the head of the line and loses the tail.
9- The peer then gets the right number of bytes, the wrong ones, and a stream
10- that no longer frames.
11-
12- Both raw-socket writers in frq had their own version of this loop and both
13- had it wrong, so there is one here instead. TLS does not come through — that
14- transport is `tls/tls-write`, which handles its own record boundaries."
15- (:require [jolt.ffi :as ffi]
16- [jolt.socket :as socket]))
17-
18-(def ^:private no-signal
19- "MSG_NOSIGNAL: a write to a closed peer returns EPIPE rather than killing
20- the process with SIGPIPE. Read from jolt rather than spelled here, as both
21- call sites already did — the value is the platform's, not frq's."
22- @#'socket/msg-nosignal)
23-
24-(def ^:private io-call
25- "jolt's own retry wrapper for one blocking-capable socket syscall.
26-
27- The `accept`/`recv`/`send` bindings are declared `:capture-native-error`, so
28- they answer `[result errno]` rather than a bare number — a pair that reads as
29- a socket error nowhere and as `class clojure.lang.PersistentVector cannot be
30- cast to class java.lang.Number` the moment a caller asks whether it is
31- positive. Taken from jolt rather than unwrapped here, as with `no-signal`
32- above: the errno is what tells EINTR and EAGAIN from a real failure, and
33- jolt is where that classification lives."
34- @#'socket/io-call)
35-
36-(defn recv!
37- "One `recv` into `buf`, answering the byte count — negative or zero at end."
38- [fd buf len]
39- (io-call #(socket/c-recv fd buf len 0) fd :read))
40-
41-(defn connect!
42- "One `connect` to the address at `sa`, answering zero or a negative.
43-
44- `connect` is the fourth of these bindings and was left out of the round
45- that fixed the other three: it is only on the plain-socket path, which the
46- TLS default does not take, so its pair reached `neg?` unnoticed. The wait
47- is for writability — a socket that finishes connecting reports itself
48- writable, which is what jolt's poller is being asked about here."
49- [fd sa len]
50- (io-call #(socket/c-connect fd sa len) fd :write))
51-
52-(defn accept!
53- "One `accept` on a listening fd, answering the connected fd or a negative."
54- [fd]
55- (io-call #(socket/c-accept fd ffi/null ffi/null) fd :read))
56-
57-(defn send-all!
58- "Write `text` to `fd` until none is left. Throws if the socket does.
59-
60- The byte count is taken as UTF-8 explicitly, because that is what
61- `with-c-string` writes: the platform default agrees on every machine frq
62- has run on, but a machine where it did not would send a length measured in
63- one encoding against bytes laid down in another."
64- [fd text]
65- (let [len (count (.getBytes ^String text "UTF-8"))]
66- (ffi/with-c-string [p text]
67- (loop [sent 0]
68- (when (< sent len)
69- ;; The pointer advances with the length. `p` is an address, so this
70- ;; is ordinary arithmetic on it.
71- (let [n (io-call #(socket/c-send fd (+ p sent) (- len sent) no-signal)
72- fd :write)]
73- ;; Anything not positive ends it. Zero especially: recurring on an
74- ;; unchanged `sent` is an infinite loop that sends nothing, which
75- ;; is worse than the failure it is hiding.
76- ;; EINTR and EAGAIN are already gone by here — io-call retries
77- ;; the one and waits out the other — so a non-positive n is the
78- ;; socket's final answer.
79- (when-not (pos? n)
80- (throw (ex-info "send failed" {:fd fd :sent sent :len len :ret n})))
81- (recur (+ sent n))))))))
deleted file mode 100644
@@ -1,81 +0,0 @@
1-(ns frq.wire
2- "Bytes onto a raw socket, all of them.
3-
4- `send(2)` is allowed to accept less than it was given and say so, which is
5- not an error and not rare — it is what a full socket buffer looks like. The
6- caller has to resume from where it stopped, and the only way to say that to
7- the kernel is a pointer further along the buffer: shrinking the length while
8- passing the same address re-sends the head of the line and loses the tail.
9- The peer then gets the right number of bytes, the wrong ones, and a stream
10- that no longer frames.
11-
12- Both raw-socket writers in frq had their own version of this loop and both
13- had it wrong, so there is one here instead. TLS does not come through — that
14- transport is `tls/tls-write`, which handles its own record boundaries."
15- (:require [jolt.ffi :as ffi]
16- [jolt.socket :as socket]))
17-
18-(def ^:private no-signal
19- "MSG_NOSIGNAL: a write to a closed peer returns EPIPE rather than killing
20- the process with SIGPIPE. Read from jolt rather than spelled here, as both
21- call sites already did — the value is the platform's, not frq's."
22- @#'socket/msg-nosignal)
23-
24-(def ^:private io-call
25- "jolt's own retry wrapper for one blocking-capable socket syscall.
26-
27- The `accept`/`recv`/`send` bindings are declared `:capture-native-error`, so
28- they answer `[result errno]` rather than a bare number — a pair that reads as
29- a socket error nowhere and as `class clojure.lang.PersistentVector cannot be
30- cast to class java.lang.Number` the moment a caller asks whether it is
31- positive. Taken from jolt rather than unwrapped here, as with `no-signal`
32- above: the errno is what tells EINTR and EAGAIN from a real failure, and
33- jolt is where that classification lives."
34- @#'socket/io-call)
35-
36-(defn recv!
37- "One `recv` into `buf`, answering the byte count — negative or zero at end."
38- [fd buf len]
39- (io-call #(socket/c-recv fd buf len 0) fd :read))
40-
41-(defn connect!
42- "One `connect` to the address at `sa`, answering zero or a negative.
43-
44- `connect` is the fourth of these bindings and was left out of the round
45- that fixed the other three: it is only on the plain-socket path, which the
46- TLS default does not take, so its pair reached `neg?` unnoticed. The wait
47- is for writability — a socket that finishes connecting reports itself
48- writable, which is what jolt's poller is being asked about here."
49- [fd sa len]
50- (io-call #(socket/c-connect fd sa len) fd :write))
51-
52-(defn accept!
53- "One `accept` on a listening fd, answering the connected fd or a negative."
54- [fd]
55- (io-call #(socket/c-accept fd ffi/null ffi/null) fd :read))
56-
57-(defn send-all!
58- "Write `text` to `fd` until none is left. Throws if the socket does.
59-
60- The byte count is taken as UTF-8 explicitly, because that is what
61- `with-c-string` writes: the platform default agrees on every machine frq
62- has run on, but a machine where it did not would send a length measured in
63- one encoding against bytes laid down in another."
64- [fd text]
65- (let [len (count (.getBytes ^String text "UTF-8"))]
66- (ffi/with-c-string [p text]
67- (loop [sent 0]
68- (when (< sent len)
69- ;; The pointer advances with the length. `p` is an address, so this
70- ;; is ordinary arithmetic on it.
71- (let [n (io-call #(socket/c-send fd (+ p sent) (- len sent) no-signal)
72- fd :write)]
73- ;; Anything not positive ends it. Zero especially: recurring on an
74- ;; unchanged `sent` is an infinite loop that sends nothing, which
75- ;; is worse than the failure it is hiding.
76- ;; EINTR and EAGAIN are already gone by here — io-call retries
77- ;; the one and waits out the other — so a non-positive n is the
78- ;; socket's final answer.
79- (when-not (pos? n)
80- (throw (ex-info "send failed" {:fd fd :sent sent :len len :ret n})))
81- (recur (+ sent n))))))))
deleted tools/build-desktop.sh +0 -145
deleted file mode 100755
@@ -1,145 +0,0 @@
1-#!/usr/bin/env bash
2-# The cosmic desktop build: stage a runtime, its objects and the source next
3-# to each other, and write a launcher that starts them.
4-#
5-# There is no compilation here of anything written in Jolt — jolt reads
6-# deps.edn and the source at startup, which is what `nix build .#frq` was also
7-# doing behind a wrapper script. What that flake output added was a closure:
8-# a Mesa, a nixGL to put the host's driver in front of it, and a store path
9-# per dependency. `.#appimage` then squashed the lot into one file so a
10-# machine without nix could run it.
11-#
12-# This builds the same program without any of that. The pieces arrive pinned
13-# from `tools/desktop-toolchain.sh`, the one thing that IS compiled is a
14-# single .c file, and what comes out is a directory that runs from wherever
15-# it is unpacked.
16-#
17-# tools/build-desktop.sh build build/desktop
18-# tools/build-desktop.sh tar ...and tar it up beside itself
19-# tools/build-desktop.sh run ...and start it
20-#
21-# The container in `.modal/frq/` runs this same file, the way
22-# `.modal/flutter-web/` runs tools/build-web.sh.
23-set -euo pipefail
24-
25-root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
26-action="${1:-build}"
27-
28-case "$action" in build|tar|run) ;; *)
29- echo "usage: build-desktop.sh [build|tar|run]" >&2; exit 1 ;;
30-esac
31-
32-eval "$("$root/tools/desktop-toolchain.sh" env)"
33-
34-out="${FRQ_DESKTOP_OUT:-$root/build/desktop}"
35-rm -rf "$out"
36-mkdir -p "$out/bin" "$out/lib" "$out/src"
37-
38-# The runtime.
39-install -m 0755 "$FRQ_DESKTOP_TOOLCHAIN/jolt/jolt" "$out/bin/jolt"
40-
41-# Every object in one directory, because JOLT_NATIVE_LIB is one directory:
42-# jolt resolves each `:jolt/native` name against it. They arrive from two
43-# places — the portable tarball and the moq-ffi release — which is exactly
44-# what `nativeAll` was a symlinkJoin for.
45-cp -a "$FRQ_DESKTOP_TOOLCHAIN/jolt-native/lib/." "$out/lib/"
46-install -m 0755 "$FRQ_DESKTOP_TOOLCHAIN/moq-ffi/lib/libmoq_ffi.so" "$out/lib/"
47-
48-# The calling-convention adapter, compiled here because it is one translation
49-# unit and because openh264's C API cannot be called from Jolt directly:
50-# `ISVCEncoder` is a `const ISVCEncoderVtbl*`, so `c/frq_h264.c` walks the
51-# vtable and exports five plain symbols. See src/frq/codec/h264.clj.
52-#
53-# This is the one place the build machine's own libraries get in, and the
54-# reason it is acceptable is the reason the portable tarball works at all:
55-# openh264, opus and alsa-lib are plain C libraries against glibc, and the
56-# copy below takes the .so the link actually resolved rather than trusting the
57-# runner to have the same one.
58-echo "desktop: compiling the h264 adapter" >&2
59-cc -O2 -fPIC -shared "$root/c/frq_h264.c" -o "$out/lib/libfrqh264.so" \
60- $(pkg-config --cflags --libs openh264)
61-
62-# openh264 itself, opus, and ALSA's client library, beside it. `ldd` on what
63-# was just linked names the file the loader chose, which is the one to take —
64-# a guess at a soname is a guess at the distro.
65-for soname in libopenh264 libopus libasound; do
66- lib=$(ldd "$out/lib/libfrqh264.so" 2>/dev/null | awk -v n="$soname" '$1 ~ "^"n"\\." {print $3; exit}')
67- # opus and alsa are opened by jolt rather than NEEDED by the adapter, so
68- # they are not in that ldd and have to be looked up.
69- [ -n "${lib:-}" ] || lib=$(ldconfig -p | awk -v n="$soname" '$1 ~ "^"n"\\.so" {print $NF; exit}')
70- if [ -z "${lib:-}" ]; then
71- echo "desktop: no $soname on this machine — the AV plane will not load" >&2
72- continue
73- fi
74- install -m 0755 -T "$lib" "$out/lib/$(basename "$lib")"
75-done
76-
77-# $ORIGIN for everything staged here, for the reason jolt-native's
78-# libsPortable sets it: the consumer decides where this unpacks and only the
79-# loader knows where that turned out to be. The objects out of the portable
80-# tarball already have it; the ones added above do not.
81-if command -v patchelf >/dev/null 2>&1; then
82- for f in "$out/lib"/*.so*; do patchelf --set-rpath '$ORIGIN' "$f" 2>/dev/null || true; done
83-else
84- echo "desktop: no patchelf; the launcher's LD_LIBRARY_PATH covers this" >&2
85-fi
86-
87-# The project as jolt sees it: source, deps.edn, nothing else — the same three
88-# things `frqSource` copied in the flake.
89-cp -a "$root/common" "$root/src" "$root/deps.edn" "$out/src/"
90-
91-# The two Jolt libraries the launcher names in -Sdeps. Copied in rather than
92-# referenced out of the toolchain, so the bundle is self-contained: a tarball
93-# that needs a directory from the machine that made it is not a bundle.
94-cp -a "$FRQ_GLIMMER" "$out/glimmer"
95-cp -a "$FRQ_GLIMMER_COSMIC" "$out/glimmer-cosmic"
96-rm -rf "$out/glimmer/.git" "$out/glimmer-cosmic/.git"
97-
98-# The launcher. `frqScript` from the flake, with the store paths replaced by
99-# $ORIGIN-relative ones and the nixGL branch deleted — off NixOS that existed
100-# to put the host's GL driver ahead of the closure's Mesa, and there is no
101-# Mesa in here to get ahead of. The driver is simply the host's.
102-cat > "$out/bin/frq" <<'LAUNCH'
103-#!/usr/bin/env bash
104-set -euo pipefail
105-here="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
106-
107-export JOLT_NATIVE_LIB="$here/lib"
108-export LD_LIBRARY_PATH="$here/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
109-
110-# ALSA finds its plugins by directory rather than by soname, and `default`
111-# resolves to nothing without the PipeWire one. Unlike the flake, which named
112-# a store path, this defers to the host: every machine frq targets runs
113-# PipeWire and ships that plugin in the usual place.
114-for d in /usr/lib/x86_64-linux-gnu/alsa-lib /usr/lib/alsa-lib /usr/lib64/alsa-lib; do
115- [ -d "$d" ] && export ALSA_PLUGIN_DIR="$d" && break
116-done
117-
118-# jolt resolves deps.edn from the working directory.
119-cd "$here/src"
120-
121-exec "$here/bin/jolt" \
122- -Sdeps "{:deps {jolt-lang/glimmer {:local/root \"$here/glimmer\"}
123- nandi/glimmer-cosmic {:local/root \"$here/glimmer-cosmic\"}}}" \
124- -m frq.cosmic "$@"
125-LAUNCH
126-chmod +x "$out/bin/frq"
127-
128-echo "desktop: built $out" >&2
129-du -sh "$out" >&2
130-
131-case "$action" in
132- tar)
133- tarball="${FRQ_DESKTOP_TAR:-$root/build/frq-desktop-x86_64-linux.tar.gz}"
134- mkdir -p "$(dirname "$tarball")"
135- # Rooted at a directory of its own, because a tarball that unpacks
136- # `bin/` and `lib/` into the current directory is a tarball someone
137- # will one day unpack into their home.
138- tar czf "$tarball" -C "$(dirname "$out")" "$(basename "$out")"
139- echo "desktop: $tarball" >&2
140- ls -la "$tarball" >&2
141- ;;
142- run)
143- exec "$out/bin/frq"
144- ;;
145-esac
deleted file mode 100755
@@ -1,145 +0,0 @@
1-#!/usr/bin/env bash
2-# The cosmic desktop build: stage a runtime, its objects and the source next
3-# to each other, and write a launcher that starts them.
4-#
5-# There is no compilation here of anything written in Jolt — jolt reads
6-# deps.edn and the source at startup, which is what `nix build .#frq` was also
7-# doing behind a wrapper script. What that flake output added was a closure:
8-# a Mesa, a nixGL to put the host's driver in front of it, and a store path
9-# per dependency. `.#appimage` then squashed the lot into one file so a
10-# machine without nix could run it.
11-#
12-# This builds the same program without any of that. The pieces arrive pinned
13-# from `tools/desktop-toolchain.sh`, the one thing that IS compiled is a
14-# single .c file, and what comes out is a directory that runs from wherever
15-# it is unpacked.
16-#
17-# tools/build-desktop.sh build build/desktop
18-# tools/build-desktop.sh tar ...and tar it up beside itself
19-# tools/build-desktop.sh run ...and start it
20-#
21-# The container in `.modal/frq/` runs this same file, the way
22-# `.modal/flutter-web/` runs tools/build-web.sh.
23-set -euo pipefail
24-
25-root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
26-action="${1:-build}"
27-
28-case "$action" in build|tar|run) ;; *)
29- echo "usage: build-desktop.sh [build|tar|run]" >&2; exit 1 ;;
30-esac
31-
32-eval "$("$root/tools/desktop-toolchain.sh" env)"
33-
34-out="${FRQ_DESKTOP_OUT:-$root/build/desktop}"
35-rm -rf "$out"
36-mkdir -p "$out/bin" "$out/lib" "$out/src"
37-
38-# The runtime.
39-install -m 0755 "$FRQ_DESKTOP_TOOLCHAIN/jolt/jolt" "$out/bin/jolt"
40-
41-# Every object in one directory, because JOLT_NATIVE_LIB is one directory:
42-# jolt resolves each `:jolt/native` name against it. They arrive from two
43-# places — the portable tarball and the moq-ffi release — which is exactly
44-# what `nativeAll` was a symlinkJoin for.
45-cp -a "$FRQ_DESKTOP_TOOLCHAIN/jolt-native/lib/." "$out/lib/"
46-install -m 0755 "$FRQ_DESKTOP_TOOLCHAIN/moq-ffi/lib/libmoq_ffi.so" "$out/lib/"
47-
48-# The calling-convention adapter, compiled here because it is one translation
49-# unit and because openh264's C API cannot be called from Jolt directly:
50-# `ISVCEncoder` is a `const ISVCEncoderVtbl*`, so `c/frq_h264.c` walks the
51-# vtable and exports five plain symbols. See src/frq/codec/h264.clj.
52-#
53-# This is the one place the build machine's own libraries get in, and the
54-# reason it is acceptable is the reason the portable tarball works at all:
55-# openh264, opus and alsa-lib are plain C libraries against glibc, and the
56-# copy below takes the .so the link actually resolved rather than trusting the
57-# runner to have the same one.
58-echo "desktop: compiling the h264 adapter" >&2
59-cc -O2 -fPIC -shared "$root/c/frq_h264.c" -o "$out/lib/libfrqh264.so" \
60- $(pkg-config --cflags --libs openh264)
61-
62-# openh264 itself, opus, and ALSA's client library, beside it. `ldd` on what
63-# was just linked names the file the loader chose, which is the one to take —
64-# a guess at a soname is a guess at the distro.
65-for soname in libopenh264 libopus libasound; do
66- lib=$(ldd "$out/lib/libfrqh264.so" 2>/dev/null | awk -v n="$soname" '$1 ~ "^"n"\\." {print $3; exit}')
67- # opus and alsa are opened by jolt rather than NEEDED by the adapter, so
68- # they are not in that ldd and have to be looked up.
69- [ -n "${lib:-}" ] || lib=$(ldconfig -p | awk -v n="$soname" '$1 ~ "^"n"\\.so" {print $NF; exit}')
70- if [ -z "${lib:-}" ]; then
71- echo "desktop: no $soname on this machine — the AV plane will not load" >&2
72- continue
73- fi
74- install -m 0755 -T "$lib" "$out/lib/$(basename "$lib")"
75-done
76-
77-# $ORIGIN for everything staged here, for the reason jolt-native's
78-# libsPortable sets it: the consumer decides where this unpacks and only the
79-# loader knows where that turned out to be. The objects out of the portable
80-# tarball already have it; the ones added above do not.
81-if command -v patchelf >/dev/null 2>&1; then
82- for f in "$out/lib"/*.so*; do patchelf --set-rpath '$ORIGIN' "$f" 2>/dev/null || true; done
83-else
84- echo "desktop: no patchelf; the launcher's LD_LIBRARY_PATH covers this" >&2
85-fi
86-
87-# The project as jolt sees it: source, deps.edn, nothing else — the same three
88-# things `frqSource` copied in the flake.
89-cp -a "$root/common" "$root/src" "$root/deps.edn" "$out/src/"
90-
91-# The two Jolt libraries the launcher names in -Sdeps. Copied in rather than
92-# referenced out of the toolchain, so the bundle is self-contained: a tarball
93-# that needs a directory from the machine that made it is not a bundle.
94-cp -a "$FRQ_GLIMMER" "$out/glimmer"
95-cp -a "$FRQ_GLIMMER_COSMIC" "$out/glimmer-cosmic"
96-rm -rf "$out/glimmer/.git" "$out/glimmer-cosmic/.git"
97-
98-# The launcher. `frqScript` from the flake, with the store paths replaced by
99-# $ORIGIN-relative ones and the nixGL branch deleted — off NixOS that existed
100-# to put the host's GL driver ahead of the closure's Mesa, and there is no
101-# Mesa in here to get ahead of. The driver is simply the host's.
102-cat > "$out/bin/frq" <<'LAUNCH'
103-#!/usr/bin/env bash
104-set -euo pipefail
105-here="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
106-
107-export JOLT_NATIVE_LIB="$here/lib"
108-export LD_LIBRARY_PATH="$here/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
109-
110-# ALSA finds its plugins by directory rather than by soname, and `default`
111-# resolves to nothing without the PipeWire one. Unlike the flake, which named
112-# a store path, this defers to the host: every machine frq targets runs
113-# PipeWire and ships that plugin in the usual place.
114-for d in /usr/lib/x86_64-linux-gnu/alsa-lib /usr/lib/alsa-lib /usr/lib64/alsa-lib; do
115- [ -d "$d" ] && export ALSA_PLUGIN_DIR="$d" && break
116-done
117-
118-# jolt resolves deps.edn from the working directory.
119-cd "$here/src"
120-
121-exec "$here/bin/jolt" \
122- -Sdeps "{:deps {jolt-lang/glimmer {:local/root \"$here/glimmer\"}
123- nandi/glimmer-cosmic {:local/root \"$here/glimmer-cosmic\"}}}" \
124- -m frq.cosmic "$@"
125-LAUNCH
126-chmod +x "$out/bin/frq"
127-
128-echo "desktop: built $out" >&2
129-du -sh "$out" >&2
130-
131-case "$action" in
132- tar)
133- tarball="${FRQ_DESKTOP_TAR:-$root/build/frq-desktop-x86_64-linux.tar.gz}"
134- mkdir -p "$(dirname "$tarball")"
135- # Rooted at a directory of its own, because a tarball that unpacks
136- # `bin/` and `lib/` into the current directory is a tarball someone
137- # will one day unpack into their home.
138- tar czf "$tarball" -C "$(dirname "$out")" "$(basename "$out")"
139- echo "desktop: $tarball" >&2
140- ls -la "$tarball" >&2
141- ;;
142- run)
143- exec "$out/bin/frq"
144- ;;
145-esac
modified tools/check-common.py +22 -18
@@ -1,16 +1,18 @@
11 #!/usr/bin/env python3
22 """What may appear in common/, checked.
33
4-common/ is compiled twice — by jolt and by ClojureDart — and only one of those
5-happens on the way to a desktop build. So the way this breaks is always the
6-same: shared code reaches for something only the JVM has, everything the
7-author ran still works, and the phone stops compiling at a namespace nobody
8-touched. `Math/ceil` in the compose bar was the third time.
9-
10-The rule being enforced is CLAUDE.md's, unchanged: code under common/ may not
11-require jolt.*, glimmer* or a dart: library, and if it needs the host it asks
12-frq.io. This only sees the first half of that — a host call has to be named to
13-be caught, and the list below is the ones that have actually turned up.
4+common/ is compiled for three targets — Android, the Linux desktop and the web
5+— and one of those has no dart:io and no filesystem. So the way this breaks is
6+always the same: shared code reaches for something only one host has,
7+everything the author ran still works, and another target stops compiling at a
8+namespace nobody touched. `Math/ceil` in the compose bar was the third time.
9+
10+The rule being enforced is CLAUDE.md's: code under common/ may not name a
11+dart: library, and if it needs the host it asks frq.io. This only sees the
12+first half of that — a host call has to be named to be caught, and the list
13+below is the ones that have actually turned up. The JVM patterns are kept
14+because the code was written when jolt compiled this tree too, and they still
15+catch the shape of the mistake: a class ClojureDart does not have.
1416
1517 Two things are stripped before anything is matched, and both are the
1618 difference between a check people keep and one they route around.
@@ -19,11 +21,11 @@ Comments and strings, because the two namespaces that got this right explain
1921 themselves by naming the very thing they avoid — a checker that fires on
2022 `frq.clock`'s docstring teaches people to stop reading it.
2123
22-And the reader-conditional branches ClojureDart does not read. A
23-`#?(:jolt [glimmer.ratom ...])` is not a violation, it is the sanctioned way
24-to say "desktop only": the cljd compiler never sees inside it. So this reads
25-the conditionals the way the compiler does — first branch whose feature is on,
26-with :cljd, :clj and :default on — and looks only at what is left.
24+And the reader-conditional branches ClojureDart does not read: the cljd
25+compiler never sees inside a branch whose feature is off, so neither does this.
26+It reads the conditionals the way the compiler does — first branch whose
27+feature is on, with :cljd, :clj and :default on — and looks only at what is
28+left.
2729 """
2830 import re
2931 import sys
@@ -43,7 +45,9 @@ CTOR = re.compile(r"\((?:[A-Z]\w*\.)(?=[\s)])")
4345 JAVA_PKG = re.compile(r"(?<![\w.-])java\.[\w.]+")
4446 # .getBytes and friends: methods on a JVM object, by name.
4547 METHODS = re.compile(r"(?<![\w.-])\.(?:getBytes|toUpperCase|toLowerCase|intValue|longValue|doubleValue|charAt)(?![\w-])")
46-# The requires CLAUDE.md rules out by name.
48+# The requires CLAUDE.md rules out by name. jolt.* and glimmer* are kept
49+# alongside dart: so that a namespace revived from the retired tree is caught
50+# rather than compiled.
4751 BAD_REQUIRE = re.compile(r"(?<![\w.-])(?:jolt\.[\w.]+|glimmer[\w.]*|\"dart:[\w.]+\")")
4852
4953 CHECKS = [
@@ -51,7 +55,7 @@ CHECKS = [
5155 (CTOR, "Java constructor interop"),
5256 (JAVA_PKG, "a java.* package"),
5357 (METHODS, "a method only a JVM object has"),
54- (BAD_REQUIRE, "a backend common/ may not name"),
58+ (BAD_REQUIRE, "a library common/ may not name"),
5559 ]
5660
5761
@@ -256,7 +260,7 @@ def main():
256260 file=sys.stderr,
257261 )
258262 return 1
259- print(f"{root}/ is clean: nothing here that only one backend has.")
263+ print(f"{root}/ is clean: nothing here that only one target has.")
260264 return 0
261265
262266
@@ -1,16 +1,18 @@
1 #!/usr/bin/env python31 #!/usr/bin/env python3
2 """What may appear in common/, checked.2 """What may appear in common/, checked.
3 3
4-common/ is compiled twice — by jolt and by ClojureDart — and only one of those4+common/ is compiled for three targets — Android, the Linux desktop and the web
5-happens on the way to a desktop build. So the way this breaks is always the5+— and one of those has no dart:io and no filesystem. So the way this breaks is
6-same: shared code reaches for something only the JVM has, everything the6+always the same: shared code reaches for something only one host has,
7-author ran still works, and the phone stops compiling at a namespace nobody7+everything the author ran still works, and another target stops compiling at a
8-touched. `Math/ceil` in the compose bar was the third time.8+namespace nobody touched. `Math/ceil` in the compose bar was the third time.
9-9+
10-The rule being enforced is CLAUDE.md's, unchanged: code under common/ may not10+The rule being enforced is CLAUDE.md's: code under common/ may not name a
11-require jolt.*, glimmer* or a dart: library, and if it needs the host it asks11+dart: library, and if it needs the host it asks frq.io. This only sees the
12-frq.io. This only sees the first half of that — a host call has to be named to12+first half of that — a host call has to be named to be caught, and the list
13-be caught, and the list below is the ones that have actually turned up.13+below is the ones that have actually turned up. The JVM patterns are kept
14+because the code was written when jolt compiled this tree too, and they still
15+catch the shape of the mistake: a class ClojureDart does not have.
14 16
15 Two things are stripped before anything is matched, and both are the17 Two things are stripped before anything is matched, and both are the
16 difference between a check people keep and one they route around.18 difference between a check people keep and one they route around.
@@ -19,11 +21,11 @@ Comments and strings, because the two namespaces that got this right explain
19 themselves by naming the very thing they avoid — a checker that fires on21 themselves by naming the very thing they avoid — a checker that fires on
20 `frq.clock`'s docstring teaches people to stop reading it.22 `frq.clock`'s docstring teaches people to stop reading it.
21 23
22-And the reader-conditional branches ClojureDart does not read. A24+And the reader-conditional branches ClojureDart does not read: the cljd
23-`#?(:jolt [glimmer.ratom ...])` is not a violation, it is the sanctioned way25+compiler never sees inside a branch whose feature is off, so neither does this.
24-to say "desktop only": the cljd compiler never sees inside it. So this reads26+It reads the conditionals the way the compiler does — first branch whose
25-the conditionals the way the compiler does — first branch whose feature is on,27+feature is on, with :cljd, :clj and :default on — and looks only at what is
26-with :cljd, :clj and :default on — and looks only at what is left.28+left.
27 """29 """
28 import re30 import re
29 import sys31 import sys
@@ -43,7 +45,9 @@ CTOR = re.compile(r"\((?:[A-Z]\w*\.)(?=[\s)])")
43 JAVA_PKG = re.compile(r"(?<![\w.-])java\.[\w.]+")45 JAVA_PKG = re.compile(r"(?<![\w.-])java\.[\w.]+")
44 # .getBytes and friends: methods on a JVM object, by name.46 # .getBytes and friends: methods on a JVM object, by name.
45 METHODS = re.compile(r"(?<![\w.-])\.(?:getBytes|toUpperCase|toLowerCase|intValue|longValue|doubleValue|charAt)(?![\w-])")47 METHODS = re.compile(r"(?<![\w.-])\.(?:getBytes|toUpperCase|toLowerCase|intValue|longValue|doubleValue|charAt)(?![\w-])")
46-# The requires CLAUDE.md rules out by name.48+# The requires CLAUDE.md rules out by name. jolt.* and glimmer* are kept
49+# alongside dart: so that a namespace revived from the retired tree is caught
50+# rather than compiled.
47 BAD_REQUIRE = re.compile(r"(?<![\w.-])(?:jolt\.[\w.]+|glimmer[\w.]*|\"dart:[\w.]+\")")51 BAD_REQUIRE = re.compile(r"(?<![\w.-])(?:jolt\.[\w.]+|glimmer[\w.]*|\"dart:[\w.]+\")")
48 52
49 CHECKS = [53 CHECKS = [
@@ -51,7 +55,7 @@ CHECKS = [
51 (CTOR, "Java constructor interop"),55 (CTOR, "Java constructor interop"),
52 (JAVA_PKG, "a java.* package"),56 (JAVA_PKG, "a java.* package"),
53 (METHODS, "a method only a JVM object has"),57 (METHODS, "a method only a JVM object has"),
54- (BAD_REQUIRE, "a backend common/ may not name"),58+ (BAD_REQUIRE, "a library common/ may not name"),
55 ]59 ]
56 60
57 61
@@ -256,7 +260,7 @@ def main():
256 file=sys.stderr,260 file=sys.stderr,
257 )261 )
258 return 1262 return 1
259- print(f"{root}/ is clean: nothing here that only one backend has.")263+ print(f"{root}/ is clean: nothing here that only one target has.")
260 return 0264 return 0
261 265
262 266
deleted tools/cosmic2cljd.py +0 -132
deleted file mode 100755
@@ -1,132 +0,0 @@
1-#!/usr/bin/env python3
2-"""Read the COSMIC theme out of cosmic-config and write it as ClojureDart.
3-
4-libcosmic asks cosmic-config for the user's theme at run time, so
5-`just cosmic run` already paints frq in whatever accent and surfaces are set in COSMIC Settings.
6-A phone has no cosmic-config, so the APK cannot ask — the values are read here
7-instead, on the machine that has them, and compiled in.
8-
9-That is a real difference and worth naming: the desktop follows the theme as
10-it changes, and the APK carries the theme as it was when the APK was built.
11-`just theme` moves it.
12-
13-The config is RON. Not parsed as RON: every value this needs is either a
14-`key: "#RRGGBBAA"` line or a `key: N` line inside a named block, and a general
15-RON parser to read colours out of a flat file is a dependency nobody needs.
16-"""
17-
18-import pathlib
19-import re
20-import sys
21-
22-CONFIG = pathlib.Path.home() / ".config" / "cosmic"
23-
24-# What frq asks of a theme, as (token, file, field). `component` fields are one
25-# block deep — see the `component: (` in background/primary/secondary.
26-COLOURS = [
27- ("accent", "accent", "base"),
28- ("on-accent", "accent", "on"),
29- ("bg", "background", "base"),
30- ("on-bg", "background", "component.on"),
31- ("component", "background", "component.base"),
32- ("component-hover", "background", "component.hover"),
33- ("divider", "background", "component.divider"),
34- ("card", "primary", "base"),
35- ("card-component", "primary", "component.base"),
36- ("on-card", "primary", "component.on"),
37- ("destructive", "destructive", "base"),
38- ("on-destructive", "destructive", "on"),
39- ("success", "success", "base"),
40-]
41-
42-
43-def read(mode: str, name: str) -> str:
44- for version in ("v2", "v1"):
45- p = CONFIG / f"com.system76.CosmicTheme.{mode}" / version / name
46- if p.exists():
47- return p.read_text()
48- raise SystemExit(f"cosmic2cljd: no {name} under {CONFIG} — is COSMIC installed?")
49-
50-
51-def field(text: str, path: str) -> str:
52- """`base`, or `component.base` for the one inside the component block."""
53- if "." in path:
54- outer, inner = path.split(".", 1)
55- m = re.search(rf"\b{outer}:\s*\(", text)
56- if not m:
57- raise SystemExit(f"cosmic2cljd: no {outer} block")
58- text = text[m.end():]
59- path = inner
60- m = re.search(rf'^\s*{path}:\s*"(#[0-9A-Fa-f]{{6,8}})"', text, re.M)
61- if not m:
62- raise SystemExit(f"cosmic2cljd: no {path}")
63- return m.group(1)
64-
65-
66-def argb(hex_rgba: str) -> str:
67- """#RRGGBBAA (COSMIC) to 0xAARRGGBB (Flutter's Color)."""
68- h = hex_rgba.lstrip("#")
69- if len(h) == 6:
70- h += "FF"
71- r, g, b, a = h[0:2], h[2:4], h[4:6], h[6:8]
72- return f"0x{a}{r}{g}{b}".upper().replace("0X", "0x")
73-
74-
75-def number(text: str, key: str, default: float) -> float:
76- m = re.search(rf"^\s*{key}:\s*\(?\s*([0-9.]+)", text, re.M)
77- return float(m.group(1)) if m else default
78-
79-
80-def main() -> None:
81- out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1
82- else "flutter/src/frq/theme/cosmic.cljd")
83- dark = (CONFIG / "com.system76.CosmicTheme.Mode" / "v1" / "is_dark")
84- is_dark = dark.exists() and dark.read_text().strip() == "true"
85- mode = "Dark" if is_dark else "Light"
86-
87- radii = read(mode, "corner_radii")
88- spacing = read(mode, "spacing")
89- files = {n: read(mode, n) for n in {f for _, f, _ in COLOURS}}
90-
91- lines = [
92- "(ns frq.theme.cosmic",
93- ' "The COSMIC theme, as it was on the machine that built this.',
94- "",
95- " GENERATED by tools/cosmic2cljd.py — `just theme`. Do not edit.",
96- "",
97- " libcosmic asks cosmic-config for these at run time, so `just cosmic run`",
98- " follows COSMIC Settings as it changes. A phone has no cosmic-config, so",
99- " the APK carries them instead. That is the one real difference between",
100- f' the two, and it is why this file is in git."',
101- ' (:require ["package:flutter/material.dart" :as m]))',
102- "",
103- f";; {mode} theme, from ~/.config/cosmic.",
104- f"(def dark? {str(is_dark).lower()})",
105- "",
106- ]
107- for token, fname, path in COLOURS:
108- val = field(files[fname], path)
109- lines.append(f";; {fname}.{path} = {val}")
110- lines.append(f"(def {token} (m/Color. {argb(val)}))")
111- lines.append("")
112- lines.append(";; corner_radii")
113- for token, key, default in [("radius-xs", "radius_xs", 2.0),
114- ("radius-s", "radius_s", 8.0),
115- ("radius-m", "radius_m", 8.0)]:
116- lines.append(f"(def {token} {number(radii, key, default)})")
117- lines.append("")
118- lines.append(";; spacing")
119- for token, key, default in [("space-xxxs", "space_xxxs", 4.0),
120- ("space-xxs", "space_xxs", 8.0),
121- ("space-xs", "space_xs", 12.0),
122- ("space-s", "space_s", 16.0),
123- ("space-m", "space_m", 24.0)]:
124- lines.append(f"(def {token} {number(spacing, key, default)})")
125-
126- out.parent.mkdir(parents=True, exist_ok=True)
127- out.write_text("\n".join(lines) + "\n")
128- print(f"wrote {out} ({mode}, accent {field(files['accent'], 'base')})")
129-
130-
131-if __name__ == "__main__":
132- main()
deleted file mode 100755
@@ -1,132 +0,0 @@
1-#!/usr/bin/env python3
2-"""Read the COSMIC theme out of cosmic-config and write it as ClojureDart.
3-
4-libcosmic asks cosmic-config for the user's theme at run time, so
5-`just cosmic run` already paints frq in whatever accent and surfaces are set in COSMIC Settings.
6-A phone has no cosmic-config, so the APK cannot ask — the values are read here
7-instead, on the machine that has them, and compiled in.
8-
9-That is a real difference and worth naming: the desktop follows the theme as
10-it changes, and the APK carries the theme as it was when the APK was built.
11-`just theme` moves it.
12-
13-The config is RON. Not parsed as RON: every value this needs is either a
14-`key: "#RRGGBBAA"` line or a `key: N` line inside a named block, and a general
15-RON parser to read colours out of a flat file is a dependency nobody needs.
16-"""
17-
18-import pathlib
19-import re
20-import sys
21-
22-CONFIG = pathlib.Path.home() / ".config" / "cosmic"
23-
24-# What frq asks of a theme, as (token, file, field). `component` fields are one
25-# block deep — see the `component: (` in background/primary/secondary.
26-COLOURS = [
27- ("accent", "accent", "base"),
28- ("on-accent", "accent", "on"),
29- ("bg", "background", "base"),
30- ("on-bg", "background", "component.on"),
31- ("component", "background", "component.base"),
32- ("component-hover", "background", "component.hover"),
33- ("divider", "background", "component.divider"),
34- ("card", "primary", "base"),
35- ("card-component", "primary", "component.base"),
36- ("on-card", "primary", "component.on"),
37- ("destructive", "destructive", "base"),
38- ("on-destructive", "destructive", "on"),
39- ("success", "success", "base"),
40-]
41-
42-
43-def read(mode: str, name: str) -> str:
44- for version in ("v2", "v1"):
45- p = CONFIG / f"com.system76.CosmicTheme.{mode}" / version / name
46- if p.exists():
47- return p.read_text()
48- raise SystemExit(f"cosmic2cljd: no {name} under {CONFIG} — is COSMIC installed?")
49-
50-
51-def field(text: str, path: str) -> str:
52- """`base`, or `component.base` for the one inside the component block."""
53- if "." in path:
54- outer, inner = path.split(".", 1)
55- m = re.search(rf"\b{outer}:\s*\(", text)
56- if not m:
57- raise SystemExit(f"cosmic2cljd: no {outer} block")
58- text = text[m.end():]
59- path = inner
60- m = re.search(rf'^\s*{path}:\s*"(#[0-9A-Fa-f]{{6,8}})"', text, re.M)
61- if not m:
62- raise SystemExit(f"cosmic2cljd: no {path}")
63- return m.group(1)
64-
65-
66-def argb(hex_rgba: str) -> str:
67- """#RRGGBBAA (COSMIC) to 0xAARRGGBB (Flutter's Color)."""
68- h = hex_rgba.lstrip("#")
69- if len(h) == 6:
70- h += "FF"
71- r, g, b, a = h[0:2], h[2:4], h[4:6], h[6:8]
72- return f"0x{a}{r}{g}{b}".upper().replace("0X", "0x")
73-
74-
75-def number(text: str, key: str, default: float) -> float:
76- m = re.search(rf"^\s*{key}:\s*\(?\s*([0-9.]+)", text, re.M)
77- return float(m.group(1)) if m else default
78-
79-
80-def main() -> None:
81- out = pathlib.Path(sys.argv[1] if len(sys.argv) > 1
82- else "flutter/src/frq/theme/cosmic.cljd")
83- dark = (CONFIG / "com.system76.CosmicTheme.Mode" / "v1" / "is_dark")
84- is_dark = dark.exists() and dark.read_text().strip() == "true"
85- mode = "Dark" if is_dark else "Light"
86-
87- radii = read(mode, "corner_radii")
88- spacing = read(mode, "spacing")
89- files = {n: read(mode, n) for n in {f for _, f, _ in COLOURS}}
90-
91- lines = [
92- "(ns frq.theme.cosmic",
93- ' "The COSMIC theme, as it was on the machine that built this.',
94- "",
95- " GENERATED by tools/cosmic2cljd.py — `just theme`. Do not edit.",
96- "",
97- " libcosmic asks cosmic-config for these at run time, so `just cosmic run`",
98- " follows COSMIC Settings as it changes. A phone has no cosmic-config, so",
99- " the APK carries them instead. That is the one real difference between",
100- f' the two, and it is why this file is in git."',
101- ' (:require ["package:flutter/material.dart" :as m]))',
102- "",
103- f";; {mode} theme, from ~/.config/cosmic.",
104- f"(def dark? {str(is_dark).lower()})",
105- "",
106- ]
107- for token, fname, path in COLOURS:
108- val = field(files[fname], path)
109- lines.append(f";; {fname}.{path} = {val}")
110- lines.append(f"(def {token} (m/Color. {argb(val)}))")
111- lines.append("")
112- lines.append(";; corner_radii")
113- for token, key, default in [("radius-xs", "radius_xs", 2.0),
114- ("radius-s", "radius_s", 8.0),
115- ("radius-m", "radius_m", 8.0)]:
116- lines.append(f"(def {token} {number(radii, key, default)})")
117- lines.append("")
118- lines.append(";; spacing")
119- for token, key, default in [("space-xxxs", "space_xxxs", 4.0),
120- ("space-xxs", "space_xxs", 8.0),
121- ("space-xs", "space_xs", 12.0),
122- ("space-s", "space_s", 16.0),
123- ("space-m", "space_m", 24.0)]:
124- lines.append(f"(def {token} {number(spacing, key, default)})")
125-
126- out.parent.mkdir(parents=True, exist_ok=True)
127- out.write_text("\n".join(lines) + "\n")
128- print(f"wrote {out} ({mode}, accent {field(files['accent'], 'base')})")
129-
130-
131-if __name__ == "__main__":
132- main()
deleted tools/desktop-toolchain.sh +0 -217
deleted file mode 100755
@@ -1,217 +0,0 @@
1-#!/usr/bin/env bash
2-# The cosmic desktop build's toolchain: a jolt binary, the native objects, and
3-# the two Jolt libraries the launcher hands to `-Sdeps`. Each pinned, fetched
4-# into a directory, and that is the whole of it. No nix.
5-#
6-# This is `tools/toolchain.sh` for the other target. That one fetches a
7-# Flutter, a JDK and a Clojure CLI because the web build is a compiler run;
8-# this one fetches a runtime and its libraries because the cosmic build is not
9-# a compilation at all — jolt reads source at startup, so "building" frq for
10-# the desktop means putting the right files next to each other.
11-#
12-# What made this possible is that every piece is now published as something a
13-# machine without nix can use:
14-#
15-# jolt one binary, chez linked in statically. Stock /lib64
16-# interpreter, NEEDED libc and libm and nothing else.
17-# jolt-native the `portable` tarball — the backends with their NEEDED
18-# closure beside them and RUNPATH $ORIGIN. The plain
19-# x86_64-linux tarball is NOT this: those objects resolve
20-# through the builder's /nix/store and are for nix consumers.
21-# libmoq_ffi an upstream release object, no RUNPATH, needs libgcc_s.
22-# glimmer Jolt source. Read, not linked.
23-#
24-# What is NOT here, and has to be on the machine that RUNS the result: the GL
25-# driver, and glibc. That is deliberate and it is what replaces the AppImage —
26-# nix-appimage carried a Mesa, which is why the launcher needed a nixGL to put
27-# the host's driver in front of it. Carrying no Mesa needs no nixGL.
28-#
29-# tools/desktop-toolchain.sh fetch whatever is missing
30-# eval "$(tools/desktop-toolchain.sh env)" ...and set this shell up
31-# tools/desktop-toolchain.sh exec -- jolt --version
32-#
33-# `FRQ_DESKTOP_TOOLCHAIN` says where it lives; the default is `.toolchain-
34-# desktop/` at the top of the checkout, and the container points it at a
35-# volume so the fetch happens once across runs rather than once across
36-# containers.
37-set -euo pipefail
38-
39-root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
40-TC="${FRQ_DESKTOP_TOOLCHAIN:-$root/.toolchain-desktop}"
41-
42-# The pins. A URL and a hash, and nothing resolved at run time — the same rule
43-# tools/toolchain.sh states and for the same reason.
44-
45-# The runtime. One binary out of the fork's release, and it really is one
46-# binary: `patchelf --print-needed` on it says libm and libc.
47-#
48-# To move it: tag a release in gitlab.com/nandithebull/jolt and take the URL
49-# and sha256 from its asset. The version string the binary prints is the tag,
50-# so a mismatch between this pin and the flake's `jolt-src` rev is visible in
51-# `jolt --version` rather than silent.
52-JOLT_VERSION="v0.7.28-1-g2b80d68d"
53-JOLT_URL="https://gitlab.com/-/project/85910549/uploads/c67e91d30934c404583c004710c249a6/jolt-${JOLT_VERSION}-x86_64-linux.tar.gz"
54-JOLT_SHA="aabb71f809aebd9d607b7a5036229f933c733ae90785cb29df899f116be9589f"
55-
56-# The native backends, and the Jolt source that binds them, at ONE revision.
57-#
58-# One variable for both on purpose. glimmer-cosmic talks to libjoltcosmic over
59-# a retained-tree ABI that is not versioned, and the flake's comment on the
60-# jolt-native input says what drift costs: the Jolt half sent a reaction
61-# pill's hover card to a backend with no handler for one, and the pill said
62-# nothing. The source and the object are the same commit here by construction.
63-JOLT_NATIVE_REV="65c27be020b52eb87d0c0718c8cfff1869e8d2f7"
64-JOLT_NATIVE_URL="https://gitlab.com/api/v4/projects/85910092/packages/generic/jolt-native/${JOLT_NATIVE_REV}/x86_64-linux-portable.tar.gz"
65-# Filled in from the first pipeline that publishes this revision's tarball:
66-#
67-# curl -fsSL "$JOLT_NATIVE_URL" | sha256sum
68-#
69-# Left as the placeholder deliberately rather than omitted — an unpinned
70-# fetch of a URL under a package registry that serves the NEWEST upload for a
71-# given name is exactly the moving target these pins exist to refuse.
72-JOLT_NATIVE_SHA="${FRQ_JOLT_NATIVE_SHA:-0000000000000000000000000000000000000000000000000000000000000000}"
73-
74-# MoQ over QUIC behind UniFFI's C ABI, from upstream's release rather than
75-# built. Same object and same version the flake fetches.
76-MOQ_FFI_VERSION="0.3.17"
77-# The triple is the Rust one and not the nix system name -- `x86_64-linux`
78-# gets a 404 from this URL, which is how that got noticed.
79-MOQ_FFI_TARGET="x86_64-unknown-linux-gnu"
80-MOQ_FFI_URL="https://github.com/kixelated/moq/releases/download/moq-ffi-v${MOQ_FFI_VERSION}/moq-ffi-${MOQ_FFI_VERSION}-${MOQ_FFI_TARGET}-libmoq_ffi.so"
81-MOQ_FFI_SHA="773417a55e0981db43fa0df7597e7514501075f0945e436cc75c4f6e86cf7d42"
82-
83-# glimmer, at the rev deps.edn pins. Source, so it is cloned rather than
84-# fetched as an archive: a git rev is immutable in a way a forge's generated
85-# tarball is not — those are re-compressed across forge versions, and a
86-# sha256 over one is a pin that breaks without anything having changed.
87-GLIMMER_REPO="https://gitlab.com/nandithebull/glimmer.git"
88-GLIMMER_REV="399df371c790d690fb6e4560c3d4d7f838502857"
89-
90-JOLT_NATIVE_REPO="https://gitlab.com/nandithebull/jolt-native.git"
91-
92-# What the host still has to bring. Small and boring, but a missing one fails
93-# further from here with a worse message.
94-require_host_tools() {
95- local missing=()
96- for t in curl tar git sha256sum; do
97- command -v "$t" >/dev/null 2>&1 || missing+=("$t")
98- done
99- if [ ${#missing[@]} -gt 0 ]; then
100- echo "desktop-toolchain: this needs ${missing[*]} on PATH and cannot fetch them" >&2
101- exit 1
102- fi
103-}
104-
105-# One archive, unpacked once. The stamp holds the hash rather than the
106-# version, and the work happens in `.tmp` so a half-finished unpack is never
107-# mistaken for a finished one — `tools/toolchain.sh` explains at length.
108-install_archive() {
109- local name=$1 url=$2 sha=$3 strip=$4
110- local dest="$TC/$name" stamp="$TC/$name.sha256"
111- if [ -d "$dest" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$sha" ]; then
112- return 0
113- fi
114- echo "desktop-toolchain: fetching $name" >&2
115- local dl="$TC/.download.$name"
116- rm -rf "$dest" "$dest.tmp" "$dl"
117- mkdir -p "$dest.tmp"
118- curl -fsSL --retry 3 -o "$dl" "$url"
119- echo "$sha $dl" | sha256sum -c - >/dev/null
120- tar -xf "$dl" -C "$dest.tmp" --strip-components="$strip"
121- rm -f "$dl"
122- mv "$dest.tmp" "$dest"
123- echo "$sha" > "$stamp"
124-}
125-
126-# The same, for something that is one file rather than an archive.
127-install_file() {
128- local name=$1 url=$2 sha=$3 into=$4
129- local dest="$TC/$name" stamp="$TC/$name.sha256"
130- if [ -d "$dest" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$sha" ]; then
131- return 0
132- fi
133- echo "desktop-toolchain: fetching $name" >&2
134- rm -rf "$dest" "$dest.tmp"
135- mkdir -p "$dest.tmp/$(dirname "$into")"
136- curl -fsSL --retry 3 -o "$dest.tmp/$into" "$url"
137- echo "$sha $dest.tmp/$into" | sha256sum -c - >/dev/null
138- mv "$dest.tmp" "$dest"
139- echo "$sha" > "$stamp"
140-}
141-
142-# Source, by revision. `git -c advice.detachedHead=false` because this is
143-# always a detached checkout and the advice is four lines of it per fetch.
144-install_source() {
145- local name=$1 repo=$2 rev=$3
146- local dest="$TC/$name" stamp="$TC/$name.rev"
147- if [ -d "$dest" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$rev" ]; then
148- return 0
149- fi
150- echo "desktop-toolchain: cloning $name at ${rev:0:8}" >&2
151- rm -rf "$dest" "$dest.tmp"
152- # A rev is not a ref, so this is init-fetch rather than clone --branch:
153- # `git clone --depth 1` cannot take a sha unless the server allows it, and
154- # `fetch --depth 1 <sha>` is the form that works everywhere.
155- mkdir -p "$dest.tmp"
156- git -C "$dest.tmp" init -q
157- git -C "$dest.tmp" remote add origin "$repo"
158- git -C "$dest.tmp" fetch -q --depth 1 origin "$rev"
159- git -C "$dest.tmp" -c advice.detachedHead=false checkout -q FETCH_HEAD
160- mv "$dest.tmp" "$dest"
161- echo "$rev" > "$stamp"
162-}
163-
164-check_pins() {
165- if [ "$JOLT_NATIVE_SHA" = "0000000000000000000000000000000000000000000000000000000000000000" ]; then
166- cat >&2 <<MSG
167-desktop-toolchain: the jolt-native portable tarball is not pinned yet.
168-
169- It is published by the first pipeline to run on jolt-native's main at
170- ${JOLT_NATIVE_REV:0:8}. Once it exists:
171-
172- curl -fsSL "$JOLT_NATIVE_URL" | sha256sum
173-
174- and put that in JOLT_NATIVE_SHA here, or pass it for one run as
175- FRQ_JOLT_NATIVE_SHA=<sha256>.
176-MSG
177- exit 1
178- fi
179-}
180-
181-install_all() {
182- require_host_tools
183- check_pins
184- mkdir -p "$TC"
185- # strip 1: the jolt tarball is rooted at a versioned directory.
186- install_archive jolt "$JOLT_URL" "$JOLT_SHA" 1
187- # strip 0: the portable tarball is rooted at lib/ and include/ already,
188- # which is the shape a consumer is meant to take it in.
189- install_archive jolt-native "$JOLT_NATIVE_URL" "$JOLT_NATIVE_SHA" 0
190- install_file moq-ffi "$MOQ_FFI_URL" "$MOQ_FFI_SHA" lib/libmoq_ffi.so
191- install_source glimmer "$GLIMMER_REPO" "$GLIMMER_REV"
192- install_source jolt-native-src "$JOLT_NATIVE_REPO" "$JOLT_NATIVE_REV"
193-}
194-
195-# The environment, as shell.
196-#
197-# JOLT_NATIVE_LIB is how jolt resolves every `:jolt/native` name, and it wants
198-# ONE directory — the objects come from two places (the portable tarball and
199-# the moq-ffi release), so `tools/build-desktop.sh` stages them into one and
200-# this names where that landed.
201-print_env() {
202- cat <<ENV
203-export FRQ_DESKTOP_TOOLCHAIN="$TC"
204-export JOLT_NATIVE_LIB="$TC/native"
205-export LD_LIBRARY_PATH="$TC/native\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}"
206-export FRQ_GLIMMER="$TC/glimmer"
207-export FRQ_GLIMMER_COSMIC="$TC/jolt-native-src/glimmer-backends/glimmer-cosmic"
208-export PATH="$TC/jolt:\$PATH"
209-ENV
210-}
211-
212-case "${1:-install}" in
213- install) install_all ;;
214- env) install_all; print_env ;;
215- exec) install_all; eval "$(print_env)"; shift; [ "${1:-}" = "--" ] && shift; exec "$@" ;;
216- *) echo "usage: desktop-toolchain.sh [install|env|exec -- cmd...]" >&2; exit 1 ;;
217-esac
deleted file mode 100755
@@ -1,217 +0,0 @@
1-#!/usr/bin/env bash
2-# The cosmic desktop build's toolchain: a jolt binary, the native objects, and
3-# the two Jolt libraries the launcher hands to `-Sdeps`. Each pinned, fetched
4-# into a directory, and that is the whole of it. No nix.
5-#
6-# This is `tools/toolchain.sh` for the other target. That one fetches a
7-# Flutter, a JDK and a Clojure CLI because the web build is a compiler run;
8-# this one fetches a runtime and its libraries because the cosmic build is not
9-# a compilation at all — jolt reads source at startup, so "building" frq for
10-# the desktop means putting the right files next to each other.
11-#
12-# What made this possible is that every piece is now published as something a
13-# machine without nix can use:
14-#
15-# jolt one binary, chez linked in statically. Stock /lib64
16-# interpreter, NEEDED libc and libm and nothing else.
17-# jolt-native the `portable` tarball — the backends with their NEEDED
18-# closure beside them and RUNPATH $ORIGIN. The plain
19-# x86_64-linux tarball is NOT this: those objects resolve
20-# through the builder's /nix/store and are for nix consumers.
21-# libmoq_ffi an upstream release object, no RUNPATH, needs libgcc_s.
22-# glimmer Jolt source. Read, not linked.
23-#
24-# What is NOT here, and has to be on the machine that RUNS the result: the GL
25-# driver, and glibc. That is deliberate and it is what replaces the AppImage —
26-# nix-appimage carried a Mesa, which is why the launcher needed a nixGL to put
27-# the host's driver in front of it. Carrying no Mesa needs no nixGL.
28-#
29-# tools/desktop-toolchain.sh fetch whatever is missing
30-# eval "$(tools/desktop-toolchain.sh env)" ...and set this shell up
31-# tools/desktop-toolchain.sh exec -- jolt --version
32-#
33-# `FRQ_DESKTOP_TOOLCHAIN` says where it lives; the default is `.toolchain-
34-# desktop/` at the top of the checkout, and the container points it at a
35-# volume so the fetch happens once across runs rather than once across
36-# containers.
37-set -euo pipefail
38-
39-root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
40-TC="${FRQ_DESKTOP_TOOLCHAIN:-$root/.toolchain-desktop}"
41-
42-# The pins. A URL and a hash, and nothing resolved at run time — the same rule
43-# tools/toolchain.sh states and for the same reason.
44-
45-# The runtime. One binary out of the fork's release, and it really is one
46-# binary: `patchelf --print-needed` on it says libm and libc.
47-#
48-# To move it: tag a release in gitlab.com/nandithebull/jolt and take the URL
49-# and sha256 from its asset. The version string the binary prints is the tag,
50-# so a mismatch between this pin and the flake's `jolt-src` rev is visible in
51-# `jolt --version` rather than silent.
52-JOLT_VERSION="v0.7.28-1-g2b80d68d"
53-JOLT_URL="https://gitlab.com/-/project/85910549/uploads/c67e91d30934c404583c004710c249a6/jolt-${JOLT_VERSION}-x86_64-linux.tar.gz"
54-JOLT_SHA="aabb71f809aebd9d607b7a5036229f933c733ae90785cb29df899f116be9589f"
55-
56-# The native backends, and the Jolt source that binds them, at ONE revision.
57-#
58-# One variable for both on purpose. glimmer-cosmic talks to libjoltcosmic over
59-# a retained-tree ABI that is not versioned, and the flake's comment on the
60-# jolt-native input says what drift costs: the Jolt half sent a reaction
61-# pill's hover card to a backend with no handler for one, and the pill said
62-# nothing. The source and the object are the same commit here by construction.
63-JOLT_NATIVE_REV="65c27be020b52eb87d0c0718c8cfff1869e8d2f7"
64-JOLT_NATIVE_URL="https://gitlab.com/api/v4/projects/85910092/packages/generic/jolt-native/${JOLT_NATIVE_REV}/x86_64-linux-portable.tar.gz"
65-# Filled in from the first pipeline that publishes this revision's tarball:
66-#
67-# curl -fsSL "$JOLT_NATIVE_URL" | sha256sum
68-#
69-# Left as the placeholder deliberately rather than omitted — an unpinned
70-# fetch of a URL under a package registry that serves the NEWEST upload for a
71-# given name is exactly the moving target these pins exist to refuse.
72-JOLT_NATIVE_SHA="${FRQ_JOLT_NATIVE_SHA:-0000000000000000000000000000000000000000000000000000000000000000}"
73-
74-# MoQ over QUIC behind UniFFI's C ABI, from upstream's release rather than
75-# built. Same object and same version the flake fetches.
76-MOQ_FFI_VERSION="0.3.17"
77-# The triple is the Rust one and not the nix system name -- `x86_64-linux`
78-# gets a 404 from this URL, which is how that got noticed.
79-MOQ_FFI_TARGET="x86_64-unknown-linux-gnu"
80-MOQ_FFI_URL="https://github.com/kixelated/moq/releases/download/moq-ffi-v${MOQ_FFI_VERSION}/moq-ffi-${MOQ_FFI_VERSION}-${MOQ_FFI_TARGET}-libmoq_ffi.so"
81-MOQ_FFI_SHA="773417a55e0981db43fa0df7597e7514501075f0945e436cc75c4f6e86cf7d42"
82-
83-# glimmer, at the rev deps.edn pins. Source, so it is cloned rather than
84-# fetched as an archive: a git rev is immutable in a way a forge's generated
85-# tarball is not — those are re-compressed across forge versions, and a
86-# sha256 over one is a pin that breaks without anything having changed.
87-GLIMMER_REPO="https://gitlab.com/nandithebull/glimmer.git"
88-GLIMMER_REV="399df371c790d690fb6e4560c3d4d7f838502857"
89-
90-JOLT_NATIVE_REPO="https://gitlab.com/nandithebull/jolt-native.git"
91-
92-# What the host still has to bring. Small and boring, but a missing one fails
93-# further from here with a worse message.
94-require_host_tools() {
95- local missing=()
96- for t in curl tar git sha256sum; do
97- command -v "$t" >/dev/null 2>&1 || missing+=("$t")
98- done
99- if [ ${#missing[@]} -gt 0 ]; then
100- echo "desktop-toolchain: this needs ${missing[*]} on PATH and cannot fetch them" >&2
101- exit 1
102- fi
103-}
104-
105-# One archive, unpacked once. The stamp holds the hash rather than the
106-# version, and the work happens in `.tmp` so a half-finished unpack is never
107-# mistaken for a finished one — `tools/toolchain.sh` explains at length.
108-install_archive() {
109- local name=$1 url=$2 sha=$3 strip=$4
110- local dest="$TC/$name" stamp="$TC/$name.sha256"
111- if [ -d "$dest" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$sha" ]; then
112- return 0
113- fi
114- echo "desktop-toolchain: fetching $name" >&2
115- local dl="$TC/.download.$name"
116- rm -rf "$dest" "$dest.tmp" "$dl"
117- mkdir -p "$dest.tmp"
118- curl -fsSL --retry 3 -o "$dl" "$url"
119- echo "$sha $dl" | sha256sum -c - >/dev/null
120- tar -xf "$dl" -C "$dest.tmp" --strip-components="$strip"
121- rm -f "$dl"
122- mv "$dest.tmp" "$dest"
123- echo "$sha" > "$stamp"
124-}
125-
126-# The same, for something that is one file rather than an archive.
127-install_file() {
128- local name=$1 url=$2 sha=$3 into=$4
129- local dest="$TC/$name" stamp="$TC/$name.sha256"
130- if [ -d "$dest" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$sha" ]; then
131- return 0
132- fi
133- echo "desktop-toolchain: fetching $name" >&2
134- rm -rf "$dest" "$dest.tmp"
135- mkdir -p "$dest.tmp/$(dirname "$into")"
136- curl -fsSL --retry 3 -o "$dest.tmp/$into" "$url"
137- echo "$sha $dest.tmp/$into" | sha256sum -c - >/dev/null
138- mv "$dest.tmp" "$dest"
139- echo "$sha" > "$stamp"
140-}
141-
142-# Source, by revision. `git -c advice.detachedHead=false` because this is
143-# always a detached checkout and the advice is four lines of it per fetch.
144-install_source() {
145- local name=$1 repo=$2 rev=$3
146- local dest="$TC/$name" stamp="$TC/$name.rev"
147- if [ -d "$dest" ] && [ "$(cat "$stamp" 2>/dev/null || true)" = "$rev" ]; then
148- return 0
149- fi
150- echo "desktop-toolchain: cloning $name at ${rev:0:8}" >&2
151- rm -rf "$dest" "$dest.tmp"
152- # A rev is not a ref, so this is init-fetch rather than clone --branch:
153- # `git clone --depth 1` cannot take a sha unless the server allows it, and
154- # `fetch --depth 1 <sha>` is the form that works everywhere.
155- mkdir -p "$dest.tmp"
156- git -C "$dest.tmp" init -q
157- git -C "$dest.tmp" remote add origin "$repo"
158- git -C "$dest.tmp" fetch -q --depth 1 origin "$rev"
159- git -C "$dest.tmp" -c advice.detachedHead=false checkout -q FETCH_HEAD
160- mv "$dest.tmp" "$dest"
161- echo "$rev" > "$stamp"
162-}
163-
164-check_pins() {
165- if [ "$JOLT_NATIVE_SHA" = "0000000000000000000000000000000000000000000000000000000000000000" ]; then
166- cat >&2 <<MSG
167-desktop-toolchain: the jolt-native portable tarball is not pinned yet.
168-
169- It is published by the first pipeline to run on jolt-native's main at
170- ${JOLT_NATIVE_REV:0:8}. Once it exists:
171-
172- curl -fsSL "$JOLT_NATIVE_URL" | sha256sum
173-
174- and put that in JOLT_NATIVE_SHA here, or pass it for one run as
175- FRQ_JOLT_NATIVE_SHA=<sha256>.
176-MSG
177- exit 1
178- fi
179-}
180-
181-install_all() {
182- require_host_tools
183- check_pins
184- mkdir -p "$TC"
185- # strip 1: the jolt tarball is rooted at a versioned directory.
186- install_archive jolt "$JOLT_URL" "$JOLT_SHA" 1
187- # strip 0: the portable tarball is rooted at lib/ and include/ already,
188- # which is the shape a consumer is meant to take it in.
189- install_archive jolt-native "$JOLT_NATIVE_URL" "$JOLT_NATIVE_SHA" 0
190- install_file moq-ffi "$MOQ_FFI_URL" "$MOQ_FFI_SHA" lib/libmoq_ffi.so
191- install_source glimmer "$GLIMMER_REPO" "$GLIMMER_REV"
192- install_source jolt-native-src "$JOLT_NATIVE_REPO" "$JOLT_NATIVE_REV"
193-}
194-
195-# The environment, as shell.
196-#
197-# JOLT_NATIVE_LIB is how jolt resolves every `:jolt/native` name, and it wants
198-# ONE directory — the objects come from two places (the portable tarball and
199-# the moq-ffi release), so `tools/build-desktop.sh` stages them into one and
200-# this names where that landed.
201-print_env() {
202- cat <<ENV
203-export FRQ_DESKTOP_TOOLCHAIN="$TC"
204-export JOLT_NATIVE_LIB="$TC/native"
205-export LD_LIBRARY_PATH="$TC/native\${LD_LIBRARY_PATH:+:\$LD_LIBRARY_PATH}"
206-export FRQ_GLIMMER="$TC/glimmer"
207-export FRQ_GLIMMER_COSMIC="$TC/jolt-native-src/glimmer-backends/glimmer-cosmic"
208-export PATH="$TC/jolt:\$PATH"
209-ENV
210-}
211-
212-case "${1:-install}" in
213- install) install_all ;;
214- env) install_all; print_env ;;
215- exec) install_all; eval "$(print_env)"; shift; [ "${1:-}" = "--" ] && shift; exec "$@" ;;
216- *) echo "usage: desktop-toolchain.sh [install|env|exec -- cmd...]" >&2; exit 1 ;;
217-esac
deleted tools/py2jolt.py +0 -133
deleted file mode 100644
@@ -1,133 +0,0 @@
1-#!/usr/bin/env python3
2-"""Turn uniffi-bindgen's Python output into jolt `defcfn` declarations.
3-
4-The Python backend emits, for every entry point in the library, a pair of
5-lines that together are an exact ABI description:
6-
7- _UniffiLib.uniffi_moq_ffi_fn_method_moqclient_connect.argtypes = (
8- ctypes.c_uint64, _UniffiRustBuffer, ctypes.POINTER(_UniffiRustCallStatus),
9- )
10- _UniffiLib.uniffi_moq_ffi_fn_method_moqclient_connect.restype = ctypes.c_uint64
11-
12-That is generated from the metadata embedded in the .so itself, so it matches
13-the object being bound rather than a header shipped alongside it -- which for
14-this release is a different build (the Apple artifact carries audio/ video, the
15-Linux and Android ones do not).
16-"""
17-import re, sys, collections
18-
19-CTYPE = {
20- "ctypes.c_int8": ":int8", "ctypes.c_uint8": ":uint8",
21- "ctypes.c_int16": ":int16", "ctypes.c_uint16": ":uint16",
22- "ctypes.c_int32": ":int32", "ctypes.c_uint32": ":uint32",
23- "ctypes.c_int64": ":int64", "ctypes.c_uint64": ":uint64",
24- "ctypes.c_float": ":float", "ctypes.c_double": ":double",
25- "ctypes.c_void_p": ":pointer", "ctypes.c_size_t": ":uint64",
26- "None": ":void",
27-}
28-# A RustBuffer crosses by value; a status is always a pointer out-param.
29-RB = "[:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]"
30-STATUS = ":pointer"
31-
32-def conv(t):
33- t = t.strip().rstrip(",").strip()
34- if not t:
35- return None
36- if t in CTYPE:
37- return CTYPE[t]
38- if "RustCallStatus" in t:
39- return STATUS
40- if "RustBuffer" in t:
41- return RB
42- if "ForeignBytes" in t:
43- return "[:by-value [:struct [[:len :int32] [:data :pointer]]]]"
44- if t.startswith("ctypes.POINTER"):
45- return ":pointer"
46- # A function-pointer typedef (the future continuation) -- jolt lowers an
47- # ffi/callback to a plain pointer, so that is what the slot takes.
48- if "callback" in t.lower() or "struct" in t.lower():
49- return ":pointer"
50- return None # unknown -- reported, never guessed
51-
52-def split_args(s):
53- out, depth, cur = [], 0, ""
54- for ch in s:
55- if ch == "(":
56- depth += 1
57- elif ch == ")":
58- depth -= 1
59- if ch == "," and depth == 0:
60- out.append(cur); cur = ""
61- else:
62- cur += ch
63- if cur.strip():
64- out.append(cur)
65- return out
66-
67-def main(path):
68- src = open(path).read()
69- args = dict(re.findall(r"_UniffiLib\.(\w+)\.argtypes\s*=\s*\(([^)]*(?:\([^)]*\)[^)]*)*)\)", src))
70- rets = dict(re.findall(r"_UniffiLib\.(\w+)\.restype\s*=\s*(.+)", src))
71- names = sorted(set(args) | set(rets))
72- unknown = collections.Counter()
73- rows = []
74- for n in names:
75- a = [conv(x) for x in split_args(args.get(n, ""))]
76- r = conv(rets.get(n, "None"))
77- if None in a or r is None:
78- for x in split_args(args.get(n, "")) + [rets.get(n, "None")]:
79- if conv(x) is None:
80- unknown[x.strip()] += 1
81- continue
82- rows.append((n, a, r))
83- return rows, unknown, len(names)
84-
85-def error_variants(path):
86- """The MoqError variant table, read from the same generated bindings.
87-
88- Hand-maintaining this is a trap: the variants are not appended to, they are
89- INSERTED into. Turning moq-ffi\'s audio/video features on adds Audio and
90- Video at 5 and 6 and shifts every later variant down two, so a stale table
91- does not report an unknown variant -- it reports a confidently wrong name
92- for a real one.
93- """
94- src = open(path).read()
95- i = src.index("class _UniffiFfiConverterTypeMoqError")
96- blk = src[i:]
97- blk = blk[:blk.index("\nclass ", 10)]
98- pairs = re.findall(r"if variant == (\d+):\s*\n\s*return MoqError\.(\w+)\(", blk)
99- def kebab(n):
100- return re.sub(r"(?<!^)(?=[A-Z])", "-", n).lower()
101- return [(int(n), kebab(name)) for n, name in pairs]
102-
103-
104-if __name__ == "__main__":
105- rows, unknown, total = main(sys.argv[1])
106- print(f";; {len(rows)} of {total} entry points bound", file=sys.stderr)
107- if unknown:
108- print(";; UNMAPPED (left unbound rather than guessed):", file=sys.stderr)
109- for t, c in unknown.most_common():
110- print(f";; {t} x{c}", file=sys.stderr)
111- variants = error_variants(sys.argv[1])
112- print(";; MoqError, as UniFFI numbers it in THIS object. Generated with the")
113- print(";; entry points above, and for the same reason: the variants are")
114- print(";; inserted into rather than appended to, so a table written by hand")
115- print(";; against one build names the wrong error in the next.")
116- print("(def moq-error-variants")
117- print(" {" + "\n ".join("%d :%s" % (n, k) for n, k in variants) + "})")
118- print()
119- for n, a, r in rows:
120- # Anchored, longest-first: "uniffi_moq_ffi_checksum_" must not be
121- # eaten by the "ffi_moq_ffi_" that also matches inside it.
122- for pre in ("uniffi_moq_ffi_fn_", "uniffi_moq_ffi_checksum_",
123- "ffi_moq_ffi_", "uniffi_moq_ffi_"):
124- if n.startswith(pre):
125- stem = n[len(pre):]
126- if pre.endswith("checksum_"):
127- stem = "checksum-" + stem
128- break
129- else:
130- stem = n
131- jolt_name = stem.replace("_", "-")
132- argv = " ".join(a) if a else ""
133- print(f'(ffi/defcfn {jolt_name} "{n}" [{argv}] {r})')
deleted file mode 100644
@@ -1,133 +0,0 @@
1-#!/usr/bin/env python3
2-"""Turn uniffi-bindgen's Python output into jolt `defcfn` declarations.
3-
4-The Python backend emits, for every entry point in the library, a pair of
5-lines that together are an exact ABI description:
6-
7- _UniffiLib.uniffi_moq_ffi_fn_method_moqclient_connect.argtypes = (
8- ctypes.c_uint64, _UniffiRustBuffer, ctypes.POINTER(_UniffiRustCallStatus),
9- )
10- _UniffiLib.uniffi_moq_ffi_fn_method_moqclient_connect.restype = ctypes.c_uint64
11-
12-That is generated from the metadata embedded in the .so itself, so it matches
13-the object being bound rather than a header shipped alongside it -- which for
14-this release is a different build (the Apple artifact carries audio/ video, the
15-Linux and Android ones do not).
16-"""
17-import re, sys, collections
18-
19-CTYPE = {
20- "ctypes.c_int8": ":int8", "ctypes.c_uint8": ":uint8",
21- "ctypes.c_int16": ":int16", "ctypes.c_uint16": ":uint16",
22- "ctypes.c_int32": ":int32", "ctypes.c_uint32": ":uint32",
23- "ctypes.c_int64": ":int64", "ctypes.c_uint64": ":uint64",
24- "ctypes.c_float": ":float", "ctypes.c_double": ":double",
25- "ctypes.c_void_p": ":pointer", "ctypes.c_size_t": ":uint64",
26- "None": ":void",
27-}
28-# A RustBuffer crosses by value; a status is always a pointer out-param.
29-RB = "[:by-value [:struct [[:capacity :uint64] [:len :uint64] [:data :pointer]]]]"
30-STATUS = ":pointer"
31-
32-def conv(t):
33- t = t.strip().rstrip(",").strip()
34- if not t:
35- return None
36- if t in CTYPE:
37- return CTYPE[t]
38- if "RustCallStatus" in t:
39- return STATUS
40- if "RustBuffer" in t:
41- return RB
42- if "ForeignBytes" in t:
43- return "[:by-value [:struct [[:len :int32] [:data :pointer]]]]"
44- if t.startswith("ctypes.POINTER"):
45- return ":pointer"
46- # A function-pointer typedef (the future continuation) -- jolt lowers an
47- # ffi/callback to a plain pointer, so that is what the slot takes.
48- if "callback" in t.lower() or "struct" in t.lower():
49- return ":pointer"
50- return None # unknown -- reported, never guessed
51-
52-def split_args(s):
53- out, depth, cur = [], 0, ""
54- for ch in s:
55- if ch == "(":
56- depth += 1
57- elif ch == ")":
58- depth -= 1
59- if ch == "," and depth == 0:
60- out.append(cur); cur = ""
61- else:
62- cur += ch
63- if cur.strip():
64- out.append(cur)
65- return out
66-
67-def main(path):
68- src = open(path).read()
69- args = dict(re.findall(r"_UniffiLib\.(\w+)\.argtypes\s*=\s*\(([^)]*(?:\([^)]*\)[^)]*)*)\)", src))
70- rets = dict(re.findall(r"_UniffiLib\.(\w+)\.restype\s*=\s*(.+)", src))
71- names = sorted(set(args) | set(rets))
72- unknown = collections.Counter()
73- rows = []
74- for n in names:
75- a = [conv(x) for x in split_args(args.get(n, ""))]
76- r = conv(rets.get(n, "None"))
77- if None in a or r is None:
78- for x in split_args(args.get(n, "")) + [rets.get(n, "None")]:
79- if conv(x) is None:
80- unknown[x.strip()] += 1
81- continue
82- rows.append((n, a, r))
83- return rows, unknown, len(names)
84-
85-def error_variants(path):
86- """The MoqError variant table, read from the same generated bindings.
87-
88- Hand-maintaining this is a trap: the variants are not appended to, they are
89- INSERTED into. Turning moq-ffi\'s audio/video features on adds Audio and
90- Video at 5 and 6 and shifts every later variant down two, so a stale table
91- does not report an unknown variant -- it reports a confidently wrong name
92- for a real one.
93- """
94- src = open(path).read()
95- i = src.index("class _UniffiFfiConverterTypeMoqError")
96- blk = src[i:]
97- blk = blk[:blk.index("\nclass ", 10)]
98- pairs = re.findall(r"if variant == (\d+):\s*\n\s*return MoqError\.(\w+)\(", blk)
99- def kebab(n):
100- return re.sub(r"(?<!^)(?=[A-Z])", "-", n).lower()
101- return [(int(n), kebab(name)) for n, name in pairs]
102-
103-
104-if __name__ == "__main__":
105- rows, unknown, total = main(sys.argv[1])
106- print(f";; {len(rows)} of {total} entry points bound", file=sys.stderr)
107- if unknown:
108- print(";; UNMAPPED (left unbound rather than guessed):", file=sys.stderr)
109- for t, c in unknown.most_common():
110- print(f";; {t} x{c}", file=sys.stderr)
111- variants = error_variants(sys.argv[1])
112- print(";; MoqError, as UniFFI numbers it in THIS object. Generated with the")
113- print(";; entry points above, and for the same reason: the variants are")
114- print(";; inserted into rather than appended to, so a table written by hand")
115- print(";; against one build names the wrong error in the next.")
116- print("(def moq-error-variants")
117- print(" {" + "\n ".join("%d :%s" % (n, k) for n, k in variants) + "})")
118- print()
119- for n, a, r in rows:
120- # Anchored, longest-first: "uniffi_moq_ffi_checksum_" must not be
121- # eaten by the "ffi_moq_ffi_" that also matches inside it.
122- for pre in ("uniffi_moq_ffi_fn_", "uniffi_moq_ffi_checksum_",
123- "ffi_moq_ffi_", "uniffi_moq_ffi_"):
124- if n.startswith(pre):
125- stem = n[len(pre):]
126- if pre.endswith("checksum_"):
127- stem = "checksum-" + stem
128- break
129- else:
130- stem = n
131- jolt_name = stem.replace("_", "-")
132- argv = " ".join(a) if a else ""
133- print(f'(ffi/defcfn {jolt_name} "{n}" [{argv}] {r})')