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>
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. | |
| 2 | 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 | 10 | # The pinned Flutter/JDK/Clojure tarballs `tools/toolchain.sh` fetches, plus |
| 5 | 11 | # the pub cache, gitlibs and maven repo it keeps beside them. A gigabyte of |
| 6 | 12 | # SDK, reproducible from the hashes in that script. |
| @@ -8,7 +14,6 @@ | ||
| 8 | 14 | |
| 9 | 15 | # Tool caches. |
| 10 | 16 | /.cpcache/ |
| 11 | -/.jolt/ | |
| 12 | 17 | /.lsp/ |
| 13 | 18 | /.clj-kondo/ |
| 14 | 19 | |
| @@ -16,12 +21,6 @@ | ||
| 16 | 21 | /result |
| 17 | 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 | 24 | # samizdat writes its journal into whatever project it is pointed at. |
| 26 | 25 | .samizdat/ |
| 27 | 26 | |
| @@ -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, plus | 10 | # 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 of | 11 | # 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 | /result | 21 | /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. | |
| 3 | 2 | # |
| 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] | |
| 17 | 9 | |
| 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. | |
| 24 | 16 | check-common: |
| 25 | 17 | stage: check |
| 26 | 18 | image: python:3-alpine |
| 27 | - rules: | |
| 28 | - - if: $CI_PIPELINE_SOURCE != "schedule" | |
| 29 | 19 | script: |
| 30 | 20 | - python3 tools/check-common.py common |
| 31 | 21 | |
| 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 | |
| 45 | 28 | 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 every | 3 | +# 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 is | 4 | +# 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 something | 5 | +# 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 build | 7 | +# 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 on | 10 | +# 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 the | 11 | +# 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 a | 12 | +# 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 toolchain | 14 | +# 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: check | 17 | stage: check |
| 26 | image: python:3-alpine | 18 | image: python:3-alpine |
| 27 | - rules: | ||
| 28 | - - if: $CI_PIPELINE_SOURCE != "schedule" | ||
| 29 | script: | 19 | script: |
| 30 | - python3 tools/check-common.py common | 20 | - 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: update | 23 | +# it wants a compiler where check-common wants nothing: one can fail without |
| 34 | - image: nixos/nix:latest | 24 | +# 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 merge | 25 | +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" | ||
| 11 | 11 | # levels up and `.` is the whole tree. |
| 12 | 12 | context = "../.." |
| 13 | 13 | 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. | |
| 17 | 18 | # The devShell, baked in rather than entered. `print-dev-env` writes the whole |
| 18 | 19 | # environment out as shell -- PATH, the compiler, every variable mkShell sets |
| 19 | 20 | # -- and realises its inputs on the way, so the closure becomes an image layer |
| @@ -29,11 +30,11 @@ commands = [ | ||
| 29 | 30 | ] |
| 30 | 31 | # The build state a local checkout carries: 395MB of a 441MB repo, uploaded on |
| 31 | 32 | # 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 | 34 | ignore = [ |
| 34 | 35 | "flutter/build", "flutter/.home", "flutter/.dart_tool", |
| 35 | 36 | "flutter/.clojuredart", "flutter/.cpcache", |
| 36 | - ".jolt", ".cpcache", "result", "build", ".git", | |
| 37 | + ".cpcache", "result", "build", ".git", | |
| 37 | 38 | ] |
| 38 | 39 | |
| 39 | 40 | # Two volumes doing two different jobs. `nix-cache` is the binary cache every |
| @@ -139,7 +140,7 @@ fi | ||
| 139 | 140 | """ |
| 140 | 141 | # Nix's own cache, on the volume rather than in the container. Without it |
| 141 | 142 | # 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 | 144 | # because flake.lock pins which revision to fetch, not whether it is already |
| 144 | 145 | # on disk. Set here rather than in the command so an interactive shell into |
| 145 | 146 | # 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 the | 14 | +# `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 whole | 18 | # The devShell, baked in rather than entered. `print-dev-env` writes the whole |
| 18 | # environment out as shell -- PATH, the compiler, every variable mkShell sets | 19 | # 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 layer | 20 | # -- 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 on | 31 | # 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 of | 32 | # 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 every | 40 | # 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 it | 141 | # 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 git | 142 | # 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 already | 144 | # 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 into | 145 | # 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 = [ | ||
| 30 | 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 | 32 | # 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 | |
| 34 | 34 | # are the laptop's. |
| 35 | 35 | ignore = [ |
| 36 | 36 | "flutter/build", "flutter/.home", "flutter/.dart_tool", |
| @@ -47,7 +47,7 @@ ignore = [ | ||
| 47 | 47 | # The toolchain, which is a gigabyte of Flutter SDK and lives on the |
| 48 | 48 | # volume out here. |
| 49 | 49 | ".toolchain", |
| 50 | - ".jolt", ".cpcache", "result", "build", ".git", | |
| 50 | + ".cpcache", "result", "build", ".git", | |
| 51 | 51 | # An editor's linter rewrites this while the upload is reading it, and |
| 52 | 52 | # Modal fails the whole run with "was modified during build process". |
| 53 | 53 | # 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: this | 32 | # 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 |
| 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 the | 47 | # 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, and | 51 | # 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 @@ | ||
| 1 | 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 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. | |
| 4 | 4 | # |
| 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. | |
| 17 | 10 | # |
| 18 | 11 | # Lives in .rickub/workflows/ rather than .github/workflows/ because rickub |
| 19 | 12 | # reads one or the other and never both: with this directory present, a |
| @@ -28,22 +21,30 @@ on: | ||
| 28 | 21 | |
| 29 | 22 | jobs: |
| 30 | 23 | # 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. | |
| 35 | 27 | check-common: |
| 36 | 28 | runs-on: ubuntu-latest |
| 37 | 29 | steps: |
| 38 | 30 | - uses: actions/checkout@v4 |
| 39 | 31 | - 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 | 44 | 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. | |
| 47 | 48 | timeout-minutes: 30 |
| 48 | 49 | steps: |
| 49 | 50 | # The container copies `.` — the whole working tree, uncommitted edits |
| @@ -60,7 +61,7 @@ jobs: | ||
| 60 | 61 | # Two secrets, set under Settings -> Secrets and variables. A Modal |
| 61 | 62 | # token is the whole of this job's configuration: no nix, no builder, |
| 62 | 63 | # no cache of its own. |
| 63 | - - name: Assemble the desktop bundle, on Modal | |
| 64 | + - name: Build the web bundle, on Modal | |
| 64 | 65 | env: |
| 65 | 66 | MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} |
| 66 | 67 | MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} |
| @@ -68,25 +69,22 @@ jobs: | ||
| 68 | 69 | # nowhere else, and `modal app logs` cannot reach an ephemeral run — |
| 69 | 70 | # so this terminal is the only place the build is visible. tee, not |
| 70 | 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.log | |
| 72 | + 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 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. | |
| 77 | 76 | - name: Fetch the bundle out of the volume |
| 78 | 77 | env: |
| 79 | 78 | MODAL_TOKEN_ID: ${{ secrets.MODAL_TOKEN_ID }} |
| 80 | 79 | MODAL_TOKEN_SECRET: ${{ secrets.MODAL_TOKEN_SECRET }} |
| 81 | 80 | run: | |
| 82 | 81 | 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 | |
| 85 | 83 | |
| 86 | 84 | - uses: actions/upload-artifact@v4 |
| 87 | 85 | 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 | |
| 90 | 88 | if-no-files-found: error |
| 91 | 89 | |
| 92 | 90 | # 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 builds | 2 | +# 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 the | 5 | +# 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 does | 6 | +# 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, a | 7 | +# 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 rickub | 11 | # Lives in .rickub/workflows/ rather than .github/workflows/ because rickub |
| 19 | # reads one or the other and never both: with this directory present, a | 12 | # 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/ compiles | 23 | # 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 | 24 | + # 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 on | 26 | + # 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-latest | 28 | runs-on: ubuntu-latest |
| 37 | steps: | 29 | steps: |
| 38 | - uses: actions/checkout@v4 | 30 | - uses: actions/checkout@v4 |
| 39 | - run: python3 tools/check-common.py common | 31 | - 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-latest | 44 | runs-on: ubuntu-latest |
| 43 | - needs: check-common | 45 | + needs: [check-common, nim-test] |
| 44 | - # Minutes now, not an hour. The Modal side compiles one .c file; what it | 46 | + # 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 and | 47 | + # toolchain, fetching the pinned Flutter/JDK/Clojure tarballs. |
| 46 | - # the upload of the tree from here. | ||
| 47 | timeout-minutes: 30 | 48 | timeout-minutes: 30 |
| 48 | steps: | 49 | steps: |
| 49 | # The container copies `.` — the whole working tree, uncommitted edits | 50 | # The container copies `.` — the whole working tree, uncommitted edits |
| @@ -60,7 +61,7 @@ jobs: | |||
| 60 | # Two secrets, set under Settings -> Secrets and variables. A Modal | 61 | # 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 Modal | 64 | + - 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, not | 70 | # 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.log | 72 | + 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 than | 74 | + # 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 a | 75 | + # 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 volume | 76 | - 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@v4 | 84 | - 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.gz | 87 | + path: web |
| 90 | if-no-files-found: error | 88 | if-no-files-found: error |
| 91 | 89 | ||
| 92 | # Kept whether or not the build succeeded: a failed run's log is the | 90 | # Kept whether or not the build succeeded: a failed run's log is the |
modified
.zed/settings.json +1 -1 | @@ -1,5 +1,5 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "file_types": { |
| 3 | - "Clojure": ["*.clj"] | |
| 3 | + "Clojure": ["*.cljc", "*.cljd"] | |
| 4 | 4 | } |
| 5 | 5 | } |
| @@ -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 @@ | ||
| 4 | 4 | |
| 5 | 5 | **STOP BUILDING LOCALLY. Build on Modal.** This machine is for editing and for |
| 6 | 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 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: | |
| 10 | 10 | |
| 11 | 11 | ```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 | |
| 13 | 14 | ``` |
| 14 | 15 | |
| 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. | |
| 17 | 18 | |
| 18 | 19 | `--dry-run` locally to see what *would* be built, then hand the build to Modal. |
| 19 | 20 | The one exception is a derivation you already know is trivial and already |
| @@ -27,15 +28,15 @@ nowhere else. | ||
| 27 | 28 | You are already running inside the Arch distrobox, where `nix` lives, so run |
| 28 | 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 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. | |
| 36 | 37 | |
| 37 | 38 | 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 | |
| 39 | 40 | ssh-ng://eu.nixbuild.net --eval-store auto` rather than a `builders` entry, so |
| 40 | 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 | 56 | afterwards: |
| 56 | 57 | |
| 57 | 58 | ```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 | |
| 59 | 60 | ``` |
| 60 | 61 | |
| 61 | 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 | 64 | output is not a terminal, so pass `--line-buffered` / `fflush()` or watch the |
| 64 | 65 | file instead. |
| 65 | 66 | |
| 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 | |
| 67 | 88 | |
| 68 | 89 | ``` |
| 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 | |
| 72 | 92 | ``` |
| 73 | 93 | |
| 74 | 94 | 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`. | |
| 81 | 100 | |
| 82 | 101 | 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 | |
| 84 | 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 | 116 | `just apk`, out of the flake's own `.#flutter` shell (clojure, jdk17, flutter) |
| 89 | 117 | and its `.#android-sdk` package. Impure on purpose: Gradle fetches its own |
| 90 | 118 | 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. | |
| 94 | 120 | |
| 95 | 121 | `just flutter-desktop`, out of `.#flutter-desktop` (the same clojure and |
| 96 | 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, 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 | |
| 110 | 133 | `frq.io.dart/write-private-file!`, where assuming cost a token its file mode. |
| 111 | 134 | See flutter/README.md. |
| @@ -4,16 +4,17 @@ | |||
| 4 | 4 | ||
| 5 | **STOP BUILDING LOCALLY. Build on Modal.** This machine is for editing and for | 5 | **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 for | 7 | +— 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 are | 8 | +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 | ```bash | 11 | ```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 | ||
| 13 | ``` | 14 | ``` |
| 14 | 15 | ||
| 15 | -The container is `.modal/`, not a fourth source tree: it is CI config that | 16 | +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 already | 20 | 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 run | 28 | 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 makes | 31 | +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 a | 32 | +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 and | 34 | +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 makes | 35 | +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 case | 38 | 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 |
| 39 | ssh-ng://eu.nixbuild.net --eval-store auto` rather than a `builders` entry, so | 40 | 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 | ```bash | 58 | ```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 |
| 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 the | 64 | 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 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 | ||
| 67 | 88 | ||
| 68 | ``` | 89 | ``` |
| 69 | -common/ .cljc jolt AND ClojureDart | 90 | +common/ .cljc portable — every target compiles it |
| 70 | -src/ .clj jolt only | 91 | +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` and | 94 | 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 | 95 | +`.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:` library | 96 | +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 itself | 97 | +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 await | 99 | +`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 to | 101 | 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 |
| 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 own | 117 | 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 SDK | 118 | 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 | 119 | +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 and | 121 | `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, since | 123 | +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 cosmic | 126 | +`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 target | 127 | +the three pinned tarballs it needs, which is what lets `.modal/flutter-web/` |
| 102 | -over | 128 | +run the same script on a plain Debian image. |
| 103 | -`frq.hiccup`. Same screens out of `common/frq/screens/`, two renderers. jvui and | 129 | + |
| 104 | -Vidya were experiments and are gone; libcosmic is a desktop window and does not | 130 | +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 @@ | ||
| 1 | 1 | # frq |
| 2 | 2 | |
| 3 | 3 | 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). | |
| 9 | 7 | |
| 10 | 8 | It is a proof of concept port of [sleek](../sleek), which is the same client in |
| 11 | 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 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. | |
| 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 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 | |
| 22 | 20 | ``` |
| 23 | 21 | |
| 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. | |
| 28 | 30 | |
| 29 | 31 | ``` |
| 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 | |
| 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 | 57 | ## Tracing |
| 48 | 58 | |
| 49 | 59 | `FRQ_TRACE=1` prints every IRC line sent and received to stderr, which on |
| @@ -51,94 +61,35 @@ Android is logcat. | ||
| 51 | 61 | |
| 52 | 62 | ## Running |
| 53 | 63 | |
| 54 | -The app: | |
| 55 | - | |
| 56 | 64 | ```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 | |
| 58 | 68 | ``` |
| 59 | 69 | |
| 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. | |
| 82 | 80 | |
| 83 | 81 | 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: | |
| 86 | 84 | |
| 87 | 85 | ```bash |
| 88 | 86 | cargo run --release --bin freeq-server # in the freeq checkout |
| 89 | 87 | ``` |
| 90 | 88 | |
| 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. | |
| 142 | 93 | |
| 143 | 94 | ## Signing in |
| 144 | 95 | |
| @@ -172,32 +123,26 @@ server's nonce echoed back so the token cannot be replayed elsewhere. | ||
| 172 | 123 | |
| 173 | 124 | A refused sign-in is reported and the connection carries on as a guest. |
| 174 | 125 | |
| 175 | -## Android | |
| 126 | +## Targets | |
| 176 | 127 | |
| 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. | |
| 179 | 130 | |
| 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. | |
| 186 | 135 | |
| 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. | |
| 193 | 139 | |
| 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. | |
| 197 | 142 | |
| 198 | 143 | ## 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 | 146 | * Guest connect (`NICK`/`USER`), `001` welcome, `PING`/`PONG` keepalive |
| 202 | 147 | * Auto-joins `#test` on `irc.freeq.at` |
| 203 | 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 | 171 | * Conversations listed most recently opened first |
| 227 | 172 | * Bluesky avatars beside the sender, resolved from the DID freeq tags each |
| 228 | 173 | 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 | 175 | ## Limits |
| 262 | 176 | |
| 263 | 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 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.** | |
| 267 | 179 | * **Only the broker token is persisted**, and only for OAuth. An app-password |
| 268 | 180 | 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. | |
| 272 | 182 | * **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. | |
| 285 | 192 | * **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 | # frq | 1 | # frq |
| 2 | 2 | ||
| 3 | A **[freeq](https://github.com/codegod100/freeq)** client written in | 3 | A **[freeq](https://github.com/codegod100/freeq)** client written in |
| 4 | -**[jolt](https://github.com/jolt-lang/jolt)**, as | 4 | +**[ClojureDart](https://github.com/tensegritics/ClojureDart)**, painted by |
| 5 | -[glimmer](https://github.com/jolt-lang/glimmer) components painted by | 5 | +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 through | 6 | +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 in | 8 | 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 widget | 10 | +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 instead | 11 | +`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 glimmer | 17 | +common/ .cljc portable: the screens, the state, the protocol — no dart: |
| 20 | -src/ .clj the jolt half: glimmer, jolt.ffi, cosmic + tui backends | 18 | +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.md | 19 | +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 that | 22 | +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 the | 23 | +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, clock | 32 | +common/frq/io.cljc the seam: filesystem, environment, config dir, clock |
| 31 | -common/frq/clock.cljc IRCv3 time tags → the reader's own zone | 33 | +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 | 34 | +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 | 35 | +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 seam | 36 | +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 payloads | 37 | +common/frq/cells.cljc the atoms every screen reads |
| 36 | -src/frq/oauth.clj the broker flow: login URL, loopback capture, /session | 38 | +common/frq/screens/ connect, chats, chat, discover, settings |
| 37 | -src/frq/avatars.clj profile pictures, by DID or handle | 39 | +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 nick | 40 | +common/frq/irc/handshake.cljc SASL, driven from shared code |
| 39 | -src/frq/media.clj image links: spot them, fetch them once, cache on disk | 41 | +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 multipart | 42 | +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 plane | 43 | +common/frq/msgsig.cljc message signatures |
| 42 | -src/frq/irc.clj IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG | 44 | +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 screens | 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 | ||
| 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 | ## Tracing | 57 | ## Tracing |
| 48 | 58 | ||
| 49 | `FRQ_TRACE=1` prints every IRC line sent and received to stderr, which on | 59 | `FRQ_TRACE=1` prints every IRC line sent and received to stderr, which on |
| @@ -51,94 +61,35 @@ Android is logcat. | |||
| 51 | 61 | ||
| 52 | ## Running | 62 | ## Running |
| 53 | 63 | ||
| 54 | -The app: | ||
| 55 | - | ||
| 56 | ```bash | 64 | ```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 | ||
| 58 | ``` | 68 | ``` |
| 59 | 69 | ||
| 60 | -Every recipe lives in the `justfile` itself. Each one that runs frq re-enters | 70 | +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` and | 71 | +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`, with | 75 | +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 build | 76 | +Debian image. |
| 67 | -of [jolt-native](https://gitlab.com/nandithebull/jolt-native), which carries | 77 | + |
| 68 | -the shared objects this client loads: `libjoltcosmic`, the retained-tree ABI | 78 | +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 grid | 79 | +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 on | 81 | 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 | 82 | +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 | ```bash | 85 | ```bash |
| 88 | cargo run --release --bin freeq-server # in the freeq checkout | 86 | cargo run --release --bin freeq-server # in the freeq checkout |
| 89 | ``` | 87 | ``` |
| 90 | 88 | ||
| 91 | -## In a terminal | 89 | +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 not | 91 | +`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 through | 92 | +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 in | 94 | ## 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 | -## Android | 126 | +## Targets |
| 176 | 127 | ||
| 177 | -The APK is **ClojureDart and Flutter**, not jolt — see | 128 | +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 frq | 131 | +**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 are | 132 | +`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 is | 133 | +`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 is | 134 | +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 the | 136 | +**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 no | 137 | +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 to | 138 | +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 and | 141 | +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 covers | 143 | ## 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` keepalive | 146 | * 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 first | 171 | * Conversations listed most recently opened first |
| 227 | * Bluesky avatars beside the sender, resolved from the DID freeq tags each | 172 | * Bluesky avatars beside the sender, resolved from the DID freeq tags each |
| 228 | message with | 173 | 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 | ## Limits | 175 | ## 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 either | 178 | +* **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-password | 179 | * **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 other | 181 | +* **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 Android | 183 | +* **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 there | 184 | + 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, and | 185 | + 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 filed | 189 | + 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 read | 190 | + 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!`, which | 191 | + 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 @@ | ||
| 1 | 1 | (ns frq.cells |
| 2 | 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,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.") | |
| 20 | 10 | |
| 21 | 11 | (def default-host "irc.freeq.at") |
| 22 | 12 | (def default-port "6697") |
| @@ -199,16 +189,12 @@ | ||
| 199 | 189 | reaction is recomputed on each such change, which is a comparison, but it |
| 200 | 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 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)) | |
| 212 | 198 | |
| 213 | 199 | ;; The pill the pointer is resting on, or nil — `{:id msgid :emoji glyph}`. |
| 214 | 200 | ;; One at a time, and named by the message as well as the glyph: the same emoji |
| @@ -1,22 +1,12 @@ | |||
| 1 | (ns frq.cells | 1 | (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,930 | 4 | + 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 under | 5 | + 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 and | 6 | + 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 thousand | 7 | + `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 it | 189 | 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 screen | 192 | + 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 question | 193 | + 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 is | 194 | + 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 emoji | 200 | ;; 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 @@ | ||
| 1 | 1 | (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 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. | |
| 9 | 9 | |
| 10 | - So this is the seam. Nothing here has an implementation; the backend installs | |
| 11 | - one before anything else runs — `frq.io.jolt` on the desktop side, | |
| 12 | - `frq.io.dart` on the phone. A namespace under `common/` requires this and | |
| 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 | |
| 13 | 13 | stays portable. |
| 14 | 14 | |
| 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 | |
| 16 | 16 | happens to call it. `write-private-file!` rather than a chmod, because Dart |
| 17 | - has no chmod and jolt has no `File.setPermissions`; `local-offset-seconds` | |
| 18 | - rather than a zone name, because finding the zone is four platform-specific | |
| 19 | - guesses on Linux and one property read on Android. Anywhere the seam names a | |
| 20 | - mechanism instead of a result, one of the two sides ends up faking it." | |
| 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." | |
| 21 | 21 | (:refer-clojure :exclude [slurp spit])) |
| 22 | 22 | |
| 23 | 23 | (defonce ^:private impl |
| 24 | - ;; Keyword → fn. Empty until a backend installs into it, which is a load-time | |
| 25 | - ;; effect of requiring `frq.io.jolt` or `frq.io.dart`. | |
| 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`. | |
| 26 | 26 | (atom {})) |
| 27 | 27 | |
| 28 | 28 | (defn install! |
| 29 | - "Register the host's answers. Called once, by the backend, before `-main` | |
| 30 | - does anything — see the require list of `frq.app` and of the Flutter entry | |
| 31 | - point. Merges, so a backend may install in pieces." | |
| 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." | |
| 32 | 32 | [m] |
| 33 | 33 | (swap! impl merge m) |
| 34 | 34 | nil) |
| 35 | 35 | |
| 36 | 36 | (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 | |
| 38 | 38 | wrappers below throw on their own." |
| 39 | 39 | [] |
| 40 | 40 | (boolean (seq @impl))) |
| @@ -44,7 +44,7 @@ | ||
| 44 | 44 | (if-let [f (get @impl k)] |
| 45 | 45 | (apply f args) |
| 46 | 46 | (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 | 48 | {:op k})))) |
| 49 | 49 | |
| 50 | 50 | ;; ------------------------------------------------------------ environment |
| @@ -69,8 +69,7 @@ | ||
| 69 | 69 | (defn delete-file! [path] (call :delete-file! [path])) |
| 70 | 70 | |
| 71 | 71 | (defn slurp |
| 72 | - "The file as a string, or nil where it cannot be read. Shadows core's, which | |
| 73 | - wants a JVM reader." | |
| 72 | + "The file as a string, or nil where it cannot be read. Shadows core's." | |
| 74 | 73 | [path] |
| 75 | 74 | (call :slurp [path])) |
| 76 | 75 | |
| @@ -86,7 +85,7 @@ | ||
| 86 | 85 | Named for the result, like the rest of the seam, because \"where downloads |
| 87 | 86 | go\" is a different question on each of the three targets this has to answer |
| 88 | 87 | 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 | |
| 90 | 89 | has a picture out of the media cache and wants it kept somewhere a file |
| 91 | 90 | manager will find it; which directory that is is the host's business. |
| 92 | 91 | |
| @@ -108,9 +107,9 @@ | ||
| 108 | 107 | (defn utf8-bytes |
| 109 | 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 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 | |
| 114 | 113 | character in it encodes to more of them than it has characters." |
| 115 | 114 | [s] |
| 116 | 115 | (call :utf8-bytes [s])) |
| @@ -126,9 +125,10 @@ | ||
| 126 | 125 | "Hand `url` to whatever shows web pages here, and say whether that worked. |
| 127 | 126 | |
| 128 | 127 | 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." | |
| 132 | 132 | [url] |
| 133 | 133 | (boolean (call :open-url! [url]))) |
| 134 | 134 | |
| @@ -136,16 +136,16 @@ | ||
| 136 | 136 | "Ask `url` for its body, as text, and hand it to `on-done` — nil where the |
| 137 | 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 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. | |
| 142 | 142 | |
| 143 | 143 | `headers` is a map of name to value, which is the whole reason this is here |
| 144 | 144 | rather than in `frq.media`: a picture is fetched by URL alone, and asking |
| 145 | 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, 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." | |
| 149 | 149 | [url headers on-done] |
| 150 | 150 | (call :fetch-text! [url headers on-done])) |
| 151 | 151 | |
| @@ -1,40 +1,40 @@ | |||
| 1 | (ns frq.io | 1 | (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 glimmer | 4 | + `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 and | 5 | + the Linux desktop, dart:html in a browser. Everything in `common/` is |
| 6 | - Flutter. Everything in `common/` is compiled by both, so it cannot mention | 6 | + 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 keeps | 7 | + `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 installs | 10 | + 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 and | 12 | + `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 platform | 15 | + 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 Dart | 16 | happens to call it. `write-private-file!` rather than a chmod, because Dart |
| 17 | - has no chmod and jolt has no `File.setPermissions`; `local-offset-seconds` | 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-specific | 18 | + 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 a | 19 | + 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 impl | 23 | (defonce ^:private impl |
| 24 | - ;; Keyword → fn. Empty until a backend installs into it, which is a load-time | 24 | + ;; 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 entry | 30 | + 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; the | 37 | + "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 " k | 46 | (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 | ;; ------------------------------------------------------------ environment | 50 | ;; ------------------------------------------------------------ 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 slurp | 71 | (defn slurp |
| 72 | - "The file as a string, or nil where it cannot be read. Shadows core's, which | 72 | + "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 downloads | 85 | 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 answer | 86 | 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 on | 87 | 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 |
| 90 | has a picture out of the media cache and wants it kept somewhere a file | 89 | 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-bytes | 107 | (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 has | 110 | + 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-ASCII | 112 | + 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: the | 127 | 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 | 128 | + 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 the | 136 | "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 and | 139 | + 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 here | 143 | `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 asking | 144 | 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, and | 147 | + 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 @@ | ||
| 7 | 7 | strip under the list actually needs — which costs a message a row, and a |
| 8 | 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 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. | |
| 13 | 13 | |
| 14 | 14 | 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.") | |
| 19 | 16 | |
| 20 | 17 | (def window-row 34) |
| 21 | 18 | (defonce chrome-row (atom window-row)) |
| @@ -7,15 +7,12 @@ | |||
| 7 | strip under the list actually needs — which costs a message a row, and a | 7 | 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 a | 10 | + 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 first | 11 | + 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` in | 14 | 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 @@ | ||
| 1443 | 1443 | ;; something else on it". |
| 1444 | 1444 | ;; And the same picture chosen rather than pasted, for a phone — which has |
| 1445 | 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, 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!}] | |
| 1472 | 1462 | ;; In a terminal the row is the width of the screen, so the field takes |
| 1473 | 1463 | ;; the surplus rather than scrolling one line sideways — and it is as |
| 1474 | 1464 | ;; 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 has | 1444 | ;; 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, on | 1446 | + ;; 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 that | 1448 | + ;; 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 working | 1450 | + ;; hence `asset:` rather than a path. |
| 1451 | - ;; directory, which is the source tree under `just run` and `just | 1451 | + [:image {:src "asset:assets/insert-image.png" |
| 1452 | - ;; cosmic` and the store copy under the flake's launcher. ClojureDart is | 1452 | + :size [36 36] |
| 1453 | - ;; launched from `flutter/` and bundles no src/, on the phone and the | 1453 | + ;; 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: the | 1461 | + :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 takes | 1462 | ;; 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 as | 1463 | ;; 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 it | 1464 | ;; 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 @@ | ||
| 26 | 26 | |
| 27 | 27 | Sign-in itself is no longer one of those differences: `frq.irc.handshake` |
| 28 | 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." | |
| 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.") | |
| 31 | 30 | |
| 32 | 31 | (defn error-note |
| 33 | 32 | "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-note | 31 | (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 @@ | ||
| 78 | 78 | [:dim-label {:label "Hides other people arriving, leaving and quitting. The people panel still follows who is here."}]] |
| 79 | 79 | [:card {} |
| 80 | 80 | [: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."}] | |
| 86 | 84 | ;; No Quit where there is nothing to quit: closing an app is a window's |
| 87 | 85 | ;; idea, and Android has its own way of leaving one. |
| 88 | 86 | (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 components | 81 | + ;; 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 and | 82 | + ;; 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's | 84 | ;; 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 @@ | ||
| 1 | 1 | { |
| 2 | 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 | 3 | "flake-utils": { |
| 19 | 4 | "inputs": { |
| 20 | 5 | "systems": "systems" |
| @@ -33,114 +18,9 @@ | ||
| 33 | 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 | 21 | "nixgl": { |
| 142 | 22 | "inputs": { |
| 143 | - "flake-utils": "flake-utils_2", | |
| 23 | + "flake-utils": "flake-utils", | |
| 144 | 24 | "nixpkgs": [ |
| 145 | 25 | "nixpkgs" |
| 146 | 26 | ] |
| @@ -177,34 +57,10 @@ | ||
| 177 | 57 | }, |
| 178 | 58 | "root": { |
| 179 | 59 | "inputs": { |
| 180 | - "glimmer": "glimmer", | |
| 181 | - "jolt-native": "jolt-native", | |
| 182 | - "jolt-src": "jolt-src", | |
| 183 | 60 | "nixgl": "nixgl", |
| 184 | 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 | 64 | "systems": { |
| 209 | 65 | "locked": { |
| 210 | 66 | "lastModified": 1681028828, |
| @@ -219,123 +75,6 @@ | ||
| 219 | 75 | "repo": "default", |
| 220 | 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 | 80 | "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 @@ | ||
| 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) | |
| 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 | |
| 13 | 8 | # |
| 14 | 9 | # On a machine that is not NixOS the GL driver is the host's and the loader |
| 15 | 10 | # 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"; | |
| 21 | 15 | |
| 22 | 16 | inputs = { |
| 23 | 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 | 19 | # Only ever used off NixOS, to put the host GL driver on the loader path. |
| 70 | 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 | 27 | let |
| 78 | 28 | systems = [ "x86_64-linux" "aarch64-linux" ]; |
| 79 | 29 | forEachSystem = f: |
| @@ -130,339 +80,26 @@ | ||
| 130 | 80 | includeNDK = false; |
| 131 | 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 | 83 | in |
| 154 | 84 | { |
| 155 | 85 | packages = forEachSystem (pkgs: |
| 156 | 86 | let |
| 157 | 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 | 88 | 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 | 90 | # The Android SDK `just apk` copies into flutter/.home. A package |
| 449 | 91 | # rather than something the recipe evaluates inline, so that |
| 450 | 92 | # `nix build .#android-sdk` is how you pre-warm it and `nix flake |
| 451 | 93 | # show` admits it exists. |
| 452 | 94 | android-sdk = androidSdkFor pkgs.stdenv.hostPlatform.system; |
| 453 | 95 | |
| 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. | |
| 466 | 103 | |
| 467 | 104 | # Everything `clojure -M:cljd compile` would otherwise reach the |
| 468 | 105 | # network for, fetched once and hashed. |
| @@ -659,9 +296,8 @@ | ||
| 659 | 296 | # The Flutter desktop GUI, built rather than run out of the tree. |
| 660 | 297 | # |
| 661 | 298 | # `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 | |
| 665 | 301 | # purely — the APK cannot, because Gradle fetches as it goes. |
| 666 | 302 | # |
| 667 | 303 | # Two stages, because the Dart does not exist until ClojureDart writes |
| @@ -690,7 +326,7 @@ | ||
| 690 | 326 | # is the screens its deps.edn puts on the classpath. The root is |
| 691 | 327 | # still the source root because of that `../common`, but letting |
| 692 | 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 | 330 | # invalidated the entire Dart compile and paid ten minutes for a |
| 695 | 331 | # change the Flutter build cannot even see. |
| 696 | 332 | # |
| @@ -709,7 +345,7 @@ | ||
| 709 | 345 | # and would make every one of them a new store path. |
| 710 | 346 | && !(builtins.elem (baseNameOf path) [ |
| 711 | 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 | 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 | 457 | devShells = forEachSystem (pkgs: |
| 845 | 458 | let |
| 846 | 459 | inherit (pkgs) lib; |
| 847 | - inherit (self.packages.${pkgs.stdenv.hostPlatform.system}) jolt native nativeAll; | |
| 848 | 460 | in |
| 849 | 461 | { |
| 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"; | |
| 922 | 476 | }; |
| 923 | 477 | |
| 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 | |
| 926 | 485 | # closure, and a desktop build has no use for any of it. |
| 927 | 486 | # |
| 928 | 487 | # `just apk` used to name these as `nix shell nixpkgs#clojure |
| @@ -1005,10 +564,8 @@ | ||
| 1005 | 564 | # pure Dart over the XDG directories. |
| 1006 | 565 | buildInputs = [ pkgs.gtk3 pkgs.glib ]; |
| 1007 | 566 | |
| 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. | |
| 1012 | 569 | NIXGL = "${nixGLFor pkgs}/bin/nixGLIntel"; |
| 1013 | 570 | |
| 1014 | 571 | # The `flutter` shell's, deliberately the same one and for the |
| @@ -1020,8 +577,7 @@ | ||
| 1020 | 577 | # for the flake output itself. |
| 1021 | 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 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` | |
| 1025 | 581 | # outside the shell re-enters and lands back on the same recipe — |
| 1026 | 582 | # no flag to forget, and no second code path for someone who runs |
| 1027 | 583 | # `nix develop .#flutter-desktop --command just flutter-desktop` |
| @@ -1029,7 +585,6 @@ | ||
| 1029 | 585 | FRQ_FLUTTER_DESKTOP = "1"; |
| 1030 | 586 | }; |
| 1031 | 587 | |
| 1032 | - # The third frontend, and the first that is not a window: the same | |
| 1033 | 588 | # No `flutter-web` shell here any more. The web target was the one |
| 1034 | 589 | # that needed nothing of the host -- no JDK and no Android SDK as |
| 1035 | 590 | # the APK wants, no GTK and no C++ and no nixGL as the desktop one |
| @@ -1040,19 +595,9 @@ | ||
| 1040 | 595 | # `.modal/flutter-web/` runs that same script on a plain Debian |
| 1041 | 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 | 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 | } |
| @@ -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 loader | 9 | # 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 not | 10 | # 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 | 11 | + # 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 as | 13 | + # 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 | let | 27 | 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 | in | 83 | in |
| 154 | { | 84 | { |
| 155 | packages = forEachSystem (pkgs: | 85 | packages = forEachSystem (pkgs: |
| 156 | let | 86 | 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 | in | 88 | 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 package | 90 | # The Android SDK `just apk` copies into flutter/.home. A package |
| 449 | # rather than something the recipe evaluates inline, so that | 91 | # rather than something the recipe evaluates inline, so that |
| 450 | # `nix build .#android-sdk` is how you pre-warm it and `nix flake | 92 | # `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 the | 97 | + # host without Nix: they squashed the whole closure into one |
| 456 | - # whole closure into one runnable file, Mesa included, and the Mesa | 98 | + # runnable file, Mesa included, and the Mesa was not waste — off |
| 457 | - # was not waste: off NixOS the launcher goes through nixGL, which | 99 | + # 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 without | 102 | + # 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 the | 104 | # 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 its | 298 | # `just flutter-desktop` is the working-tree loop and this is its |
| 662 | - # opposite number, the same way `nix build .#frq` is `just cosmic | 299 | + # 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 is | 300 | + # 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 writes | 303 | # 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 is | 326 | # is the screens its deps.edn puts on the classpath. The root is |
| 691 | # still the source root because of that `../common`, but letting | 327 | # 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 a | 330 | # 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 | let | 458 | let |
| 846 | inherit (pkgs) lib; | 459 | inherit (pkgs) lib; |
| 847 | - inherit (self.packages.${pkgs.stdenv.hostPlatform.system}) jolt native nativeAll; | ||
| 848 | in | 460 | 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's | 465 | + # to cross-compile to every target the Dart side runs on. |
| 854 | - # too. nixGL for the same reason the launcher reaches for it — see | 466 | + # |
| 855 | - # frqScript. just so the recipe runner comes from here too rather | 467 | + # `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 the | 468 | + # 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 frq | 472 | + |
| 861 | - # source `just cosmic run` runs is the working tree, so the | 473 | + # 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 not | 478 | + # 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 of | 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 | ||
| 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#clojure | 487 | # `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 paints | 567 | + # 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 the | 568 | + # 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 the | 571 | # 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 the | 580 | + # 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 runs | 582 | # 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 one | 588 | # 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 as | 589 | # 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 one | 590 | # 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 Debian | 595 | # `.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 | ||
| 4 | 4 | question "can this file go on the phone?" has a filesystem answer. It builds: |
| 5 | 5 | see "Building it" below. |
| 6 | 6 | |
| 7 | -## The three trees | |
| 7 | +## The two trees | |
| 8 | 8 | |
| 9 | 9 | ``` |
| 10 | -common/ .cljc both compilers. No jolt, no glimmer, no dart. | |
| 11 | -src/ .clj jolt: glimmer, jolt.ffi, the cosmic and tui backends. | |
| 12 | -flutter/ .cljd ClojureDart: Flutter widgets, dart:io, dart:ffi. | |
| 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. | |
| 13 | 12 | ``` |
| 14 | 13 | |
| 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. | |
| 20 | 18 | |
| 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 | |
| 24 | 22 | ClojureDart's FAQ: the `:clj` feature is always on under cljd, so `:clj` goes |
| 25 | 23 | **last** in a conditional, and macro code that wants the Clojure path during |
| 26 | 24 | host evaluation asks for `:cljd/clj-host`. |
| 27 | 25 | |
| 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. | |
| 76 | 71 | |
| 77 | 72 | ## Building it |
| 78 | 73 | |
| 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. | |
| 82 | 77 | |
| 83 | 78 | ```bash |
| 84 | 79 | just apk # the debug APK |
| @@ -88,31 +83,32 @@ just apk log # logcat | ||
| 88 | 83 | |
| 89 | 84 | just flutter-desktop # the debug Linux bundle |
| 90 | 85 | 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 | 91 | ### The desktop one |
| 94 | 92 | |
| 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. | |
| 100 | 97 | |
| 101 | 98 | Its toolchain is `devShells.flutter-desktop`, which is the APK shell with the |
| 102 | 99 | Android half swapped out: clojure and Flutter are the same two packages at the |
| 103 | 100 | same pinned rev, and CMake, Ninja, pkg-config and GTK stand where the JDK and |
| 104 | 101 | the SDK do. Kept separate rather than merged into one shell because the halves |
| 105 | 102 | 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. | |
| 108 | 104 | |
| 109 | 105 | Still impure, for one of the two reasons the APK is: pub.dev resolution and |
| 110 | 106 | Flutter's engine artifacts are network. What it does *not* need is the |
| 111 | 107 | writable-`ANDROID_HOME` dance, since nothing here writes into the store — so |
| 112 | 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` 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. | |
| 116 | 112 | |
| 117 | 113 | `linux/` is the Flutter template's GTK runner, renamed — `frq` rather than |
| 118 | 114 | `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 | 121 | * `frq.io.dart/write-private-file!` was a plain write, on the grounds that |
| 126 | 122 | Android storage is already private to the app. On a Linux desktop it is not: |
| 127 | 123 | 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. | |
| 131 | 127 | * `frq.oauth.dart` handed the capture page `frq://auth` unconditionally, to |
| 132 | 128 | raise the app from behind Chrome. Nothing on a desktop claims that scheme, so |
| 133 | 129 | it is now nil there — which `core/capture-html` already documented as the |
| @@ -169,52 +165,35 @@ targets. | ||
| 169 | 165 | It is signed with `~/.android/debug.keystore`, through the template's |
| 170 | 166 | `signingConfig = signingConfigs.getByName("debug")` — which release builds also |
| 171 | 167 | 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. | |
| 176 | 169 | |
| 177 | 170 | ## The screens are not rewritten |
| 178 | 171 | |
| 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. | |
| 182 | 176 | |
| 183 | 177 | 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. | |
| 197 | 181 | |
| 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 | |
| 199 | 183 | from the top and diffs its own element tree, so a cell firing rebuilds the |
| 200 | 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 | 186 | ## 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`, | |
| 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`. | |
| 218 | 197 | |
| 219 | 198 | What `Length::Fill` means took four goes to get right, and the rule it ended |
| 220 | 199 | 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 | 237 | is where they go, and installing that handler in `frq.main` should have been |
| 259 | 238 | the first move rather than the tenth. |
| 260 | 239 | |
| 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. | |
| 298 | 260 | |
| 299 | 261 | ## What a missing tag property looks like |
| 300 | 262 | |
| @@ -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 trees | 7 | +## 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 reads | 14 | +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 for | 15 | +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 Dart | 16 | +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 at | 17 | +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 without | 20 | +`.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 from | 21 | +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` goes | 22 | ClojureDart's FAQ: the `:clj` feature is always on under cljd, so `:clj` goes |
| 25 | **last** in a conditional, and macro code that wants the Clojure path during | 23 | **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 crossed | 26 | +## The seam |
| 29 | - | 27 | + |
| 30 | -`frq.io` is the seam — the host's job named once, with `frq.io.jolt` answering | 28 | +`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, the | 29 | +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 four | 42 | +`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 file | 43 | + |
| 46 | -itself by path, then Android's `persist.sys.timezone` — and then convert days | 44 | +## The port, as it finished |
| 47 | -to a date by printing one with `jolt.time.local` and taking a `subs` of the | 45 | + |
| 48 | -result. Both are gone: the guessing is a libc question and lives in | 46 | +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 is | 47 | +components painted by libcosmic, with `src/` holding the half that could not |
| 50 | -eleven lines of Hinnant's algorithm, checked against `java.time.LocalDate` for | 48 | +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 not | 51 | +| namespace | note | |
| 54 | - | 52 | +|--------------------|-------------------------------------------------------| |
| 55 | -Roughly 4,000 lines are portable in substance and still `.clj` because the seam | 53 | +| `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 in | 57 | +| `frq.irc.parse` | the parser was always pure | |
| 60 | - a `future`, and Dart has no threads. It becomes a `Stream` over | 58 | +| `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 over | 60 | +| `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-only | 61 | +| `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 thing | 62 | +| `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 are | 64 | +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, so | 66 | +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 is | 69 | +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/`, about | 70 | +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 it | 72 | ## Building it |
| 78 | 73 | ||
| 79 | -Two targets out of one tree. The ClojureDart compile is the same command for | 74 | +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 differs | 75 | +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 | ```bash | 78 | ```bash |
| 84 | just apk # the debug APK | 79 | just apk # the debug APK |
| @@ -88,31 +83,32 @@ just apk log # logcat | |||
| 88 | 83 | ||
| 89 | just flutter-desktop # the debug Linux bundle | 84 | just flutter-desktop # the debug Linux bundle |
| 90 | just flutter-desktop run # and the window | 85 | 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 one | 91 | ### 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 this | 94 | +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/`, two | 95 | +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 the | 98 | 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 the | 99 | 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 and | 100 | 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 halves | 101 | 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 of | 102 | 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 | 103 | +Android SDK. |
| 107 | -shell. | ||
| 108 | 104 | ||
| 109 | Still impure, for one of the two reasons the APK is: pub.dev resolution and | 105 | 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 the | 106 | 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 — so | 107 | 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` does | 110 | +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 than | 113 | `linux/` is the Flutter template's GTK runner, renamed — `frq` rather than |
| 118 | `cljd_flutter`, and `uk.nandi.frq` rather than `com.example.cljd_flutter`, so | 114 | `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 that | 121 | * `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 it | 123 | 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 a | 125 | + 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, to | 127 | * `frq.oauth.dart` handed the capture page `frq://auth` unconditionally, to |
| 132 | raise the app from behind Chrome. Nothing on a desktop claims that scheme, so | 128 | raise the app from behind Chrome. Nothing on a desktop claims that scheme, so |
| 133 | it is now nil there — which `core/capture-html` already documented as the | 129 | 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's | 165 | It is signed with `~/.android/debug.keystore`, through the template's |
| 170 | `signingConfig = signingConfigs.getByName("debug")` — which release builds also | 166 | `signingConfig = signingConfigs.getByName("debug")` — which release builds also |
| 171 | use, so `flutter build apk --release` is not shippable until a real | 167 | 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 | 168 | +`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 rewritten | 170 | ## The screens are not rewritten |
| 178 | 171 | ||
| 179 | -`frq.hiccup` is a glimmer backend, the same way glimmer-cosmic and glimmer-tui | 172 | +`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 said | 177 | 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 whole | 180 | +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 rebuilds | 182 | +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 the | 183 | 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 them | 186 | ## 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 the | 189 | +`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 hand | 191 | +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 in | 192 | + |
| 214 | -`frq.rooms`, the backend metrics in `frq.metrics` and everything a screen | 193 | +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 that | 194 | +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 reaction | 195 | +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 ended | 198 | 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`, the | 199 | 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 been | 237 | 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 in | 240 | +## 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. The | 244 | + |
| 266 | - transport is `frq.net.dart`: `SecureSocket`, a `Stream`, no thread and no | 245 | +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 and | 246 | + 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 this | 247 | + 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 in | 248 | + 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 the | 251 | + — 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 → DID | 252 | + 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, the | 255 | + 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 is | 256 | + 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 the | 257 | + 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 a | 258 | + 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, an | 259 | + 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 like | 261 | ## 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 @@ | ||
| 1 | 1 | (ns frq.io.dart |
| 2 | 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: 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. | |
| 14 | 12 | |
| 15 | 13 | Two Flutter targets share this file, not one: `just apk` and |
| 16 | 14 | `just flutter-desktop` compile the same ClojureDart, so \"the phone\" is no |
| @@ -1,16 +1,14 @@ | |||
| 1 | (ns frq.io.dart | 1 | (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: every | 4 | + `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 the | 5 | + 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 the | 6 | + 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 not | 7 | + 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: the | 10 | + 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 the | 11 | + 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` and | 13 | Two Flutter targets share this file, not one: `just apk` and |
| 16 | `just flutter-desktop` compile the same ClojureDart, so \"the phone\" is no | 14 | `just flutter-desktop` compile the same ClojureDart, so \"the phone\" is no |
modified
flutter/src/frq/theme.cljd +5 -11 | @@ -1,18 +1,12 @@ | ||
| 1 | 1 | (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-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, | |
| 13 | 7 | `:label` is body, `:dim-label` is caption." |
| 14 | 8 | (:require ["package:flutter/material.dart" :as m] |
| 15 | - [frq.theme.cosmic :as c])) | |
| 9 | + [frq.theme.tokens :as c])) | |
| 16 | 10 | |
| 17 | 11 | ;; ------------------------------------------------------------------ scale |
| 18 | 12 | |
| @@ -1,18 +1,12 @@ | |||
| 1 | (ns frq.theme | 1 | (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-config | 4 | + 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 in | 5 | + 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 a | 6 | + 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 | ;; ------------------------------------------------------------------ scale | 11 | ;; ------------------------------------------------------------------ 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 @@ | ||
| 3 | 3 | # That indirection bought one thing worth having, a shared way to reach nix on |
| 4 | 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-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. | |
| 11 | 11 | |
| 12 | 12 | set shell := ["bash", "-euo", "pipefail", "-c"] |
| 13 | 13 | |
| 14 | 14 | # Every recipe below is a `#!` script and passes its arguments on with "$@". |
| 15 | 15 | # 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. | |
| 18 | 17 | set positional-arguments |
| 19 | 18 | |
| 20 | 19 | # 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 | 24 | # --max-jobs 0 is what sends the work to the `builders` entry rather than |
| 26 | 25 | # 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 | |
| 28 | 27 | # remote builder has likely built already. FRQ_MAX_JOBS=auto is the way out on |
| 29 | 28 | # a machine with no builder configured. |
| 30 | 29 | jobs := env("FRQ_MAX_JOBS", "0") |
| @@ -32,22 +31,6 @@ jobs := env("FRQ_MAX_JOBS", "0") | ||
| 32 | 31 | default: |
| 33 | 32 | @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 | 34 | # The APK: ClojureDart compiled to Dart, then Flutter's Gradle build. |
| 52 | 35 | # |
| 53 | 36 | # Impure on purpose, and worth saying why rather than leaving it to be |
| @@ -162,183 +145,18 @@ apk action="build": | ||
| 162 | 145 | *) echo "usage: just apk [build|install|run|log]" >&2; exit 1 ;; |
| 163 | 146 | esac |
| 164 | 147 | |
| 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. | |
| 220 | 150 | check-common: |
| 221 | 151 | #!/usr/bin/env bash |
| 222 | 152 | python3 tools/check-common.py common |
| 223 | 153 | |
| 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. | |
| 248 | 155 | # |
| 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, | |
| 342 | 160 | # then Flutter's Linux target rather than its Android one. CMake and Ninja |
| 343 | 161 | # instead of Gradle, `flutter/linux/` as the runner, no SDK and no JDK. |
| 344 | 162 | # |
| @@ -347,8 +165,8 @@ gen-moq lib="": | ||
| 347 | 165 | # writable-ANDROID_HOME dance — nothing here writes into the store — so there |
| 348 | 166 | # is no `flutter/.home` on this path. |
| 349 | 167 | # |
| 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. | |
| 352 | 170 | # |
| 353 | 171 | # just flutter-desktop build the debug bundle |
| 354 | 172 | # just flutter-desktop run build it and open the window |
| @@ -462,46 +280,19 @@ flutter-web action="build" port="8080": | ||
| 462 | 280 | # start would be one dependency away from the point. |
| 463 | 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 | 283 | # The containers in `.modal/`, run on Modal rather than here. This machine |
| 493 | 284 | # evaluates and Modal builds — see CLAUDE.md, which says so rather more |
| 494 | 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` | |
| 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 | |
| 498 | 289 | # 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. | |
| 501 | 292 | # |
| 502 | -# just modal frq assemble the desktop bundle on Modal | |
| 503 | 293 | # 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 | 296 | #!/usr/bin/env bash |
| 506 | 297 | set -euo pipefail |
| 507 | 298 | cd "{{justfile_directory()}}" |
| @@ -557,9 +348,59 @@ web-local port="8080": | ||
| 557 | 348 | # the sandbox bills until you do. |
| 558 | 349 | # |
| 559 | 350 | # 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 | |
| 561 | 352 | modal-shell container="flutter-dev": |
| 562 | 353 | #!/usr/bin/env bash |
| 563 | 354 | set -euo pipefail |
| 564 | 355 | cd "{{justfile_directory()}}" |
| 565 | 356 | 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 on | 3 | # 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-enter | 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, hand jolt the deps overrides and | 7 | +# 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 for | 9 | +# 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 recipe | 15 | # 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 | 16 | +# rather than handing it argv — and a recipe silently ignored its flags. |
| 17 | -# terminal instead. | ||
| 18 | set positional-arguments | 17 | set positional-arguments |
| 19 | 18 | ||
| 20 | # nix is not on every host this runs on: on the machine these recipes were | 19 | # 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 than | 24 | # --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 a | 25 | # 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 |
| 28 | # remote builder has likely built already. FRQ_MAX_JOBS=auto is the way out on | 27 | # 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 --list | 32 | @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 be | 36 | # 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 | esac | 146 | 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-cosmic | 149 | +# 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 bash | 151 | #!/usr/bin/env bash |
| 222 | python3 tools/check-common.py common | 152 | 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 minus | 156 | +# 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 bash | 159 | +# 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 Ninja | 160 | # 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 there | 165 | # 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 paints | 168 | +# 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 bundle | 171 | # just flutter-desktop build the debug bundle |
| 354 | # just flutter-desktop run build it and open the window | 172 | # 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 machine | 283 | # The containers in `.modal/`, run on Modal rather than here. This machine |
| 493 | # evaluates and Modal builds — see CLAUDE.md, which says so rather more | 284 | # 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 nothing | 288 | +# 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 because | 289 | # 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 | 290 | +# 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 loop | 293 | # 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 bash | 296 | #!/usr/bin/env bash |
| 506 | set -euo pipefail | 297 | 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 one | 350 | # 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 |
| 561 | modal-shell container="flutter-dev": | 352 | modal-shell container="flutter-dev": |
| 562 | #!/usr/bin/env bash | 353 | #!/usr/bin/env bash |
| 563 | set -euo pipefail | 354 | set -euo pipefail |
| 564 | cd "{{justfile_directory()}}" | 355 | cd "{{justfile_directory()}}" |
| 565 | exec modal run ".modal/{{container}}/container.py" --shell | 356 | 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 differ | Binary 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 @@ | ||
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | """What may appear in common/, checked. |
| 3 | 3 | |
| 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. | |
| 14 | 16 | |
| 15 | 17 | Two things are stripped before anything is matched, and both are the |
| 16 | 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 | 21 | themselves by naming the very thing they avoid — a checker that fires on |
| 20 | 22 | `frq.clock`'s docstring teaches people to stop reading it. |
| 21 | 23 | |
| 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. | |
| 27 | 29 | """ |
| 28 | 30 | import re |
| 29 | 31 | import sys |
| @@ -43,7 +45,9 @@ CTOR = re.compile(r"\((?:[A-Z]\w*\.)(?=[\s)])") | ||
| 43 | 45 | JAVA_PKG = re.compile(r"(?<![\w.-])java\.[\w.]+") |
| 44 | 46 | # .getBytes and friends: methods on a JVM object, by name. |
| 45 | 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 | 51 | BAD_REQUIRE = re.compile(r"(?<![\w.-])(?:jolt\.[\w.]+|glimmer[\w.]*|\"dart:[\w.]+\")") |
| 48 | 52 | |
| 49 | 53 | CHECKS = [ |
| @@ -51,7 +55,7 @@ CHECKS = [ | ||
| 51 | 55 | (CTOR, "Java constructor interop"), |
| 52 | 56 | (JAVA_PKG, "a java.* package"), |
| 53 | 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 | 260 | file=sys.stderr, |
| 257 | 261 | ) |
| 258 | 262 | 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 | 264 | return 0 |
| 261 | 265 | |
| 262 | 266 | |
| @@ -1,16 +1,18 @@ | |||
| 1 | #!/usr/bin/env python3 | 1 | #!/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 those | 4 | +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 the | 5 | +— 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 the | 6 | +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 nobody | 7 | +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 not | 10 | +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 asks | 11 | +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 to | 12 | +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 the | 17 | 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 on | 21 | 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. A | 24 | +And the reader-conditional branches ClojureDart does not read: the cljd |
| 23 | -`#?(:jolt [glimmer.ratom ...])` is not a violation, it is the sanctioned way | 25 | +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 reads | 26 | +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 re | 30 | import re |
| 29 | import sys | 31 | 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 1 | 262 | 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 0 | 264 | 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})') | ||