A freeq client in jolt, as glimmer components on Vidya
Sleek's screens — connect, chats, chat, discover, settings — written as
hiccup over glimmer's widget tags rather than immediate-mode drawing code,
with the state in ratoms instead of an AppState struct. Painted by Vidya's
egui backend through libvidya's retained-tree ABI.
The transport is IRC over TCP, either half of it in irc.jolt:
* TLS by default, through the OpenSSL bindings jolt.mvn-http carries for
its own HTTPS fetching. That is desktop-only — Android has no public
libssl to load — so the connect screen falls back to the plain listener.
* Plain TCP as the raw BSD calls: socket, connect, send, recv. Not the
java.net.Socket surface jolt.socket registers, which does not work on
Android either, while the syscalls do.
An APK ships the same sources as a Chez boot image beside vidya's
cross-compiled libvidya. Verified on a Pixel 6a against irc.freeq.at:
guest registration, #test, and sending from the phone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>4719d6f added
.gitignore +7 -0 | new file mode 100644 | ||
| @@ -0,0 +1,7 @@ | ||
| 1 | +# Build outputs: the boot image, the staged libraries, the APK. | |
| 2 | +/android/build/ | |
| 3 | + | |
| 4 | +# Tool caches. | |
| 5 | +/.jolt/ | |
| 6 | +/.lsp/ | |
| 7 | +/.clj-kondo/ | |
| new file mode 100644 | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | +# Build outputs: the boot image, the staged libraries, the APK. | ||
| 2 | +/android/build/ | ||
| 3 | + | ||
| 4 | +# Tool caches. | ||
| 5 | +/.jolt/ | ||
| 6 | +/.lsp/ | ||
| 7 | +/.clj-kondo/ | ||
added
README.md +74 -0 | new file mode 100644 | ||
| @@ -0,0 +1,74 @@ | ||
| 1 | +# frq | |
| 2 | + | |
| 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 | +**[Vidya](https://tangled.org/nandi.uk/vidya)**/egui. | |
| 7 | + | |
| 8 | +It is a proof of concept port of [sleek](../sleek), which is the same client in | |
| 9 | +Rust against egui directly. The screens are sleek's — connect, chats, chat, | |
| 10 | +discover, settings, under a tab bar — but each is hiccup over glimmer's widget | |
| 11 | +tags rather than immediate-mode drawing code, and state lives in ratoms instead | |
| 12 | +of an `AppState` struct. | |
| 13 | + | |
| 14 | +``` | |
| 15 | +src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, JOIN/PRIVMSG/PING | |
| 16 | +src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` | |
| 17 | +src/frq/app.jolt the screens | |
| 18 | +``` | |
| 19 | + | |
| 20 | +## Running | |
| 21 | + | |
| 22 | +`libvidya` from Vidya's Rust/egui backend, then the app: | |
| 23 | + | |
| 24 | +```bash | |
| 25 | +just lib | |
| 26 | +just run | |
| 27 | +``` | |
| 28 | + | |
| 29 | +`just run` is `jolt -M:frq` with `LD_LIBRARY_PATH` pointed at the built | |
| 30 | +library. It connects to `irc.freeq.at:6697` over TLS and joins `#test`. Untick | |
| 31 | +TLS on the connect screen (or point it at `127.0.0.1`) for a local server's | |
| 32 | +plain listener: | |
| 33 | + | |
| 34 | +```bash | |
| 35 | +cargo run --release --bin freeq-server # in the freeq checkout | |
| 36 | +``` | |
| 37 | + | |
| 38 | +## Android | |
| 39 | + | |
| 40 | +An APK with two shared libraries and no Java: `libvidya.so` (vidya's Rust/egui | |
| 41 | +C ABI, which owns the event loop as the NativeActivity's own library) and | |
| 42 | +`libjoltapp.so` (frq compiled to a Chez boot image). Both native halves come | |
| 43 | +from the vidya checkout; only the boot image is frq's. | |
| 44 | + | |
| 45 | +```bash | |
| 46 | +./android/build-apk.sh run # build, install, launch on a connected device | |
| 47 | +./android/build-apk.sh log # logcat, filtered | |
| 48 | +``` | |
| 49 | + | |
| 50 | +Needs what vidya's Android build needs — SDK, NDK r29, and a cross-built Chez | |
| 51 | +in `~/.cache/vidya-chez-android`. | |
| 52 | + | |
| 53 | +TLS does not work there: jolt reaches OpenSSL through the dynamic loader, and | |
| 54 | +Android has no public `libssl` to load. The connect screen falls back to the | |
| 55 | +plain `:6667` listener on its own, which is why the plain transport is the raw | |
| 56 | +`socket`/`connect`/`send`/`recv` calls rather than jolt's `java.net.Socket` | |
| 57 | +surface — that surface does not work on Android either, while the syscalls do. | |
| 58 | + | |
| 59 | +## What the PoC covers | |
| 60 | + | |
| 61 | +* TLS (`:6697`, via jolt.mvn-http's OpenSSL bindings) or plain TCP (`:6667`) | |
| 62 | +* Guest connect (`NICK`/`USER`), `001` welcome, `PING`/`PONG` keepalive | |
| 63 | +* Auto-joins `#test` on `irc.freeq.at` | |
| 64 | +* Join channels, channel buffers with unread counts, send and receive `PRIVMSG` | |
| 65 | +* Join/part notices, DMs bucketed under the sender's nick | |
| 66 | +* Discover list, search over buffers, disconnect | |
| 67 | + | |
| 68 | +## Limits | |
| 69 | + | |
| 70 | +* **TLS and plain TCP only** — no WebSocket, no iroh. On Android, plain only. | |
| 71 | +* **Guest identity only.** No AT Protocol SASL, no OAuth, no credential gates, | |
| 72 | + no E2EE — the parts of freeq that need crypto are exactly the parts left out. | |
| 73 | +* **No scrollback trimming, avatars, reactions, threads, or calls.** | |
| 74 | +* Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. | |
| new file mode 100644 | |||
| @@ -0,0 +1,74 @@ | |||
| 1 | +# frq | ||
| 2 | + | ||
| 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 | +**[Vidya](https://tangled.org/nandi.uk/vidya)**/egui. | ||
| 7 | + | ||
| 8 | +It is a proof of concept port of [sleek](../sleek), which is the same client in | ||
| 9 | +Rust against egui directly. The screens are sleek's — connect, chats, chat, | ||
| 10 | +discover, settings, under a tab bar — but each is hiccup over glimmer's widget | ||
| 11 | +tags rather than immediate-mode drawing code, and state lives in ratoms instead | ||
| 12 | +of an `AppState` struct. | ||
| 13 | + | ||
| 14 | +``` | ||
| 15 | +src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, JOIN/PRIVMSG/PING | ||
| 16 | +src/frq/state.jolt the ratoms every screen reads, and `apply-msg!` | ||
| 17 | +src/frq/app.jolt the screens | ||
| 18 | +``` | ||
| 19 | + | ||
| 20 | +## Running | ||
| 21 | + | ||
| 22 | +`libvidya` from Vidya's Rust/egui backend, then the app: | ||
| 23 | + | ||
| 24 | +```bash | ||
| 25 | +just lib | ||
| 26 | +just run | ||
| 27 | +``` | ||
| 28 | + | ||
| 29 | +`just run` is `jolt -M:frq` with `LD_LIBRARY_PATH` pointed at the built | ||
| 30 | +library. It connects to `irc.freeq.at:6697` over TLS and joins `#test`. Untick | ||
| 31 | +TLS on the connect screen (or point it at `127.0.0.1`) for a local server's | ||
| 32 | +plain listener: | ||
| 33 | + | ||
| 34 | +```bash | ||
| 35 | +cargo run --release --bin freeq-server # in the freeq checkout | ||
| 36 | +``` | ||
| 37 | + | ||
| 38 | +## Android | ||
| 39 | + | ||
| 40 | +An APK with two shared libraries and no Java: `libvidya.so` (vidya's Rust/egui | ||
| 41 | +C ABI, which owns the event loop as the NativeActivity's own library) and | ||
| 42 | +`libjoltapp.so` (frq compiled to a Chez boot image). Both native halves come | ||
| 43 | +from the vidya checkout; only the boot image is frq's. | ||
| 44 | + | ||
| 45 | +```bash | ||
| 46 | +./android/build-apk.sh run # build, install, launch on a connected device | ||
| 47 | +./android/build-apk.sh log # logcat, filtered | ||
| 48 | +``` | ||
| 49 | + | ||
| 50 | +Needs what vidya's Android build needs — SDK, NDK r29, and a cross-built Chez | ||
| 51 | +in `~/.cache/vidya-chez-android`. | ||
| 52 | + | ||
| 53 | +TLS does not work there: jolt reaches OpenSSL through the dynamic loader, and | ||
| 54 | +Android has no public `libssl` to load. The connect screen falls back to the | ||
| 55 | +plain `:6667` listener on its own, which is why the plain transport is the raw | ||
| 56 | +`socket`/`connect`/`send`/`recv` calls rather than jolt's `java.net.Socket` | ||
| 57 | +surface — that surface does not work on Android either, while the syscalls do. | ||
| 58 | + | ||
| 59 | +## What the PoC covers | ||
| 60 | + | ||
| 61 | +* TLS (`:6697`, via jolt.mvn-http's OpenSSL bindings) or plain TCP (`:6667`) | ||
| 62 | +* Guest connect (`NICK`/`USER`), `001` welcome, `PING`/`PONG` keepalive | ||
| 63 | +* Auto-joins `#test` on `irc.freeq.at` | ||
| 64 | +* Join channels, channel buffers with unread counts, send and receive `PRIVMSG` | ||
| 65 | +* Join/part notices, DMs bucketed under the sender's nick | ||
| 66 | +* Discover list, search over buffers, disconnect | ||
| 67 | + | ||
| 68 | +## Limits | ||
| 69 | + | ||
| 70 | +* **TLS and plain TCP only** — no WebSocket, no iroh. On Android, plain only. | ||
| 71 | +* **Guest identity only.** No AT Protocol SASL, no OAuth, no credential gates, | ||
| 72 | + no E2EE — the parts of freeq that need crypto are exactly the parts left out. | ||
| 73 | +* **No scrollback trimming, avatars, reactions, threads, or calls.** | ||
| 74 | +* Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet. | ||
added
android/AndroidManifest.xml +44 -0 | new file mode 100644 | ||
| @@ -0,0 +1,44 @@ | ||
| 1 | +<?xml version="1.0" encoding="utf-8"?> | |
| 2 | +<manifest xmlns:android="http://schemas.android.com/apk/res/android" | |
| 3 | + package="uk.nandi.frq"> | |
| 4 | + <uses-permission android:name="android.permission.INTERNET" /> | |
| 5 | + <uses-feature android:glEsVersion="0x00030000" android:required="true" /> | |
| 6 | + | |
| 7 | + <!-- | |
| 8 | + Not the Fullscreen theme below: under it the window owns the whole | |
| 9 | + display, content_rect is the whole display too, and there is nothing for | |
| 10 | + vidya to inset by — the status bar clock sits on top of the app and the | |
| 11 | + compose bar sits under the keyboard. | |
| 12 | + --> | |
| 13 | + <application | |
| 14 | + android:allowBackup="false" | |
| 15 | + android:hasCode="false" | |
| 16 | + android:label="frq" | |
| 17 | + android:theme="@android:style/Theme.Material.NoActionBar"> | |
| 18 | + <!-- | |
| 19 | + adjustResize: without it the activity's content_rect never shrinks for | |
| 20 | + the soft keyboard, so vidya has no IME inset to reserve and the compose | |
| 21 | + bar sits under the keys. | |
| 22 | + --> | |
| 23 | + <activity | |
| 24 | + android:name="android.app.NativeActivity" | |
| 25 | + android:configChanges="orientation|keyboardHidden|screenSize" | |
| 26 | + android:windowSoftInputMode="adjustResize" | |
| 27 | + android:exported="true" | |
| 28 | + android:screenOrientation="portrait"> | |
| 29 | + <!-- | |
| 30 | + The activity's library is libvidya.so, not the Jolt one: whoever | |
| 31 | + holds android-activity's glue owns the event loop, and winit | |
| 32 | + cannot build one without the AndroidApp handle that glue receives. | |
| 33 | + It dlopens libjoltapp.so in turn. See ffi/src/android.rs. | |
| 34 | + --> | |
| 35 | + <meta-data | |
| 36 | + android:name="android.app.lib_name" | |
| 37 | + android:value="vidya" /> | |
| 38 | + <intent-filter> | |
| 39 | + <action android:name="android.intent.action.MAIN" /> | |
| 40 | + <category android:name="android.intent.category.LAUNCHER" /> | |
| 41 | + </intent-filter> | |
| 42 | + </activity> | |
| 43 | + </application> | |
| 44 | +</manifest> | |
| new file mode 100644 | |||
| @@ -0,0 +1,44 @@ | |||
| 1 | +<?xml version="1.0" encoding="utf-8"?> | ||
| 2 | +<manifest xmlns:android="http://schemas.android.com/apk/res/android" | ||
| 3 | + package="uk.nandi.frq"> | ||
| 4 | + <uses-permission android:name="android.permission.INTERNET" /> | ||
| 5 | + <uses-feature android:glEsVersion="0x00030000" android:required="true" /> | ||
| 6 | + | ||
| 7 | + <!-- | ||
| 8 | + Not the Fullscreen theme below: under it the window owns the whole | ||
| 9 | + display, content_rect is the whole display too, and there is nothing for | ||
| 10 | + vidya to inset by — the status bar clock sits on top of the app and the | ||
| 11 | + compose bar sits under the keyboard. | ||
| 12 | + --> | ||
| 13 | + <application | ||
| 14 | + android:allowBackup="false" | ||
| 15 | + android:hasCode="false" | ||
| 16 | + android:label="frq" | ||
| 17 | + android:theme="@android:style/Theme.Material.NoActionBar"> | ||
| 18 | + <!-- | ||
| 19 | + adjustResize: without it the activity's content_rect never shrinks for | ||
| 20 | + the soft keyboard, so vidya has no IME inset to reserve and the compose | ||
| 21 | + bar sits under the keys. | ||
| 22 | + --> | ||
| 23 | + <activity | ||
| 24 | + android:name="android.app.NativeActivity" | ||
| 25 | + android:configChanges="orientation|keyboardHidden|screenSize" | ||
| 26 | + android:windowSoftInputMode="adjustResize" | ||
| 27 | + android:exported="true" | ||
| 28 | + android:screenOrientation="portrait"> | ||
| 29 | + <!-- | ||
| 30 | + The activity's library is libvidya.so, not the Jolt one: whoever | ||
| 31 | + holds android-activity's glue owns the event loop, and winit | ||
| 32 | + cannot build one without the AndroidApp handle that glue receives. | ||
| 33 | + It dlopens libjoltapp.so in turn. See ffi/src/android.rs. | ||
| 34 | + --> | ||
| 35 | + <meta-data | ||
| 36 | + android:name="android.app.lib_name" | ||
| 37 | + android:value="vidya" /> | ||
| 38 | + <intent-filter> | ||
| 39 | + <action android:name="android.intent.action.MAIN" /> | ||
| 40 | + <category android:name="android.intent.category.LAUNCHER" /> | ||
| 41 | + </intent-filter> | ||
| 42 | + </activity> | ||
| 43 | + </application> | ||
| 44 | +</manifest> | ||
added
android/build-apk.sh +118 -0 | new file mode 100755 | ||
| @@ -0,0 +1,118 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Glue the two halves of the frq Android app into an APK. | |
| 3 | +# | |
| 4 | +# libvidya.so the C ABI on Rust/egui, cross-compiled by buck2, and the | |
| 5 | +# NativeActivity's own library (it holds android-activity's | |
| 6 | +# glue, so it owns the event loop) | |
| 7 | +# libjoltapp.so vidya's android/jolt_main.c plus frq's Jolt boot image, | |
| 8 | +# dlopened by the above | |
| 9 | +# | |
| 10 | +# Neither half is built here beyond that last link: the UI library comes from | |
| 11 | +# vidya's `just ffi-android` and the boot image from build-jolt-boot.sh. Both | |
| 12 | +# native pieces are vidya's — only the boot image is frq's. | |
| 13 | +set -euo pipefail | |
| 14 | + | |
| 15 | +ROOT="$(cd "$(dirname "$0")/.." && pwd)" | |
| 16 | +VIDYA="${VIDYA:-$(cd "$ROOT/../vidya" && pwd)}" | |
| 17 | +ANDROID_HOME="${ANDROID_HOME:-$HOME/.local/share/android-sdk}" | |
| 18 | +ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$HOME/.local/share/android-ndk-r29}" | |
| 19 | +CHEZ_ANDROID="${CHEZ_ANDROID:-$HOME/.cache/vidya-chez-android}" | |
| 20 | +BUILD="$ROOT/android/build" | |
| 21 | +JOLT_BUILD="$BUILD/jolt" | |
| 22 | +STAGE="$BUILD/stage" | |
| 23 | +TOOLS="$ANDROID_HOME/build-tools/36.0.0" | |
| 24 | +ADB="${ADB:-$ANDROID_HOME/platform-tools/adb}" | |
| 25 | +NDK_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" | |
| 26 | +PACKAGE="uk.nandi.frq" | |
| 27 | +ACTIVITY="$PACKAGE/android.app.NativeActivity" | |
| 28 | +API=28 | |
| 29 | + | |
| 30 | +for path in \ | |
| 31 | + "$NDK_BIN/aarch64-linux-android$API-clang" \ | |
| 32 | + "$ANDROID_HOME/platforms/android-36/android.jar" \ | |
| 33 | + "$TOOLS/aapt2" "$TOOLS/zipalign" "$TOOLS/apksigner"; do | |
| 34 | + [[ -e "$path" ]] || { echo "missing Android tool: $path" >&2; exit 1; } | |
| 35 | +done | |
| 36 | + | |
| 37 | +# --- the UI half ------------------------------------------------------------ | |
| 38 | +( cd "$VIDYA" && just ffi-android >&2 ) | |
| 39 | +VIDYA_SO="$VIDYA/build/android/arm64-v8a/libvidya.so" | |
| 40 | +[[ -f "$VIDYA_SO" ]] || { echo "missing $VIDYA_SO" >&2; exit 1; } | |
| 41 | + | |
| 42 | +# --- the Jolt half ---------------------------------------------------------- | |
| 43 | +"$ROOT/android/build-jolt-boot.sh" "$JOLT_BUILD" | |
| 44 | +( | |
| 45 | + cd "$JOLT_BUILD" | |
| 46 | + # The boot image travels as a blob in the object file's data section; the | |
| 47 | + # _binary_jolt_boot_{start,end} symbols jolt_main.c reads come from this. | |
| 48 | + "$NDK_BIN/llvm-objcopy" \ | |
| 49 | + --input-target=binary \ | |
| 50 | + --output-target=elf64-littleaarch64 \ | |
| 51 | + --binary-architecture=aarch64 \ | |
| 52 | + jolt.boot jolt_boot.o | |
| 53 | +) | |
| 54 | + | |
| 55 | +rm -rf "$STAGE" | |
| 56 | +mkdir -p "$STAGE/lib/arm64-v8a" | |
| 57 | +cp "$VIDYA_SO" "$STAGE/lib/arm64-v8a/libvidya.so" | |
| 58 | + | |
| 59 | +"$NDK_BIN/aarch64-linux-android$API-clang" \ | |
| 60 | + -shared -fPIC -O2 \ | |
| 61 | + -o "$STAGE/lib/arm64-v8a/libjoltapp.so" \ | |
| 62 | + "$VIDYA/android/jolt_main.c" \ | |
| 63 | + "$JOLT_BUILD/jolt_boot.o" \ | |
| 64 | + -I"$JOLT_BUILD" \ | |
| 65 | + -I"$VIDYA/raylib/include" \ | |
| 66 | + -I"$VIDYA/ffi/include" \ | |
| 67 | + -L"$STAGE/lib/arm64-v8a" \ | |
| 68 | + "$CHEZ_ANDROID/tarm64le/boot/tarm64le/libkernel.a" \ | |
| 69 | + "$CHEZ_ANDROID/lz4/lib/liblz4.a" \ | |
| 70 | + -lvidya -landroid -llog -lz -ldl -lm \ | |
| 71 | + -Wl,--no-undefined | |
| 72 | + | |
| 73 | +# --- the APK ---------------------------------------------------------------- | |
| 74 | +UNALIGNED="$BUILD/frq-unaligned.apk" | |
| 75 | +ALIGNED="$BUILD/frq-aligned.apk" | |
| 76 | +APK="$BUILD/frq.apk" | |
| 77 | +rm -f "$UNALIGNED" "$ALIGNED" "$APK" | |
| 78 | +"$TOOLS/aapt2" link \ | |
| 79 | + -o "$UNALIGNED" \ | |
| 80 | + -I "$ANDROID_HOME/platforms/android-36/android.jar" \ | |
| 81 | + --manifest "$ROOT/android/AndroidManifest.xml" \ | |
| 82 | + --min-sdk-version $API \ | |
| 83 | + --target-sdk-version 36 \ | |
| 84 | + --version-code 1 \ | |
| 85 | + --version-name 0.1.0 | |
| 86 | +# Stored, not deflated: the loader maps these straight out of the APK. | |
| 87 | +(cd "$STAGE" && zip -q -0 "$UNALIGNED" \ | |
| 88 | + lib/arm64-v8a/libvidya.so lib/arm64-v8a/libjoltapp.so) | |
| 89 | +"$TOOLS/zipalign" -f -p 4 "$UNALIGNED" "$ALIGNED" | |
| 90 | + | |
| 91 | +KEYSTORE="$HOME/.android/debug.keystore" | |
| 92 | +if [[ ! -f "$KEYSTORE" ]]; then | |
| 93 | + mkdir -p "$(dirname "$KEYSTORE")" | |
| 94 | + keytool -genkeypair -v \ | |
| 95 | + -keystore "$KEYSTORE" -storepass android -keypass android \ | |
| 96 | + -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \ | |
| 97 | + -dname "CN=Android Debug,O=Android,C=US" | |
| 98 | +fi | |
| 99 | +"$TOOLS/apksigner" sign \ | |
| 100 | + --ks "$KEYSTORE" --ks-key-alias androiddebugkey \ | |
| 101 | + --ks-pass pass:android --key-pass pass:android \ | |
| 102 | + --out "$APK" "$ALIGNED" | |
| 103 | +"$TOOLS/apksigner" verify "$APK" >/dev/null | |
| 104 | + | |
| 105 | +case "${1:-build}" in | |
| 106 | + build) printf '%s\n' "$APK" ;; | |
| 107 | + install) "$ADB" install -r "$APK" ;; | |
| 108 | + run) | |
| 109 | + "$ADB" install -r "$APK" | |
| 110 | + "$ADB" shell am force-stop "$PACKAGE" | |
| 111 | + "$ADB" shell am start -n "$ACTIVITY" | |
| 112 | + ;; | |
| 113 | + log) "$ADB" logcat -s VidyaJolt Vidya ;; | |
| 114 | + *) | |
| 115 | + echo "usage: $0 [build|install|run|log]" >&2 | |
| 116 | + exit 2 | |
| 117 | + ;; | |
| 118 | +esac | |
| new file mode 100755 | |||
| @@ -0,0 +1,118 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +# Glue the two halves of the frq Android app into an APK. | ||
| 3 | +# | ||
| 4 | +# libvidya.so the C ABI on Rust/egui, cross-compiled by buck2, and the | ||
| 5 | +# NativeActivity's own library (it holds android-activity's | ||
| 6 | +# glue, so it owns the event loop) | ||
| 7 | +# libjoltapp.so vidya's android/jolt_main.c plus frq's Jolt boot image, | ||
| 8 | +# dlopened by the above | ||
| 9 | +# | ||
| 10 | +# Neither half is built here beyond that last link: the UI library comes from | ||
| 11 | +# vidya's `just ffi-android` and the boot image from build-jolt-boot.sh. Both | ||
| 12 | +# native pieces are vidya's — only the boot image is frq's. | ||
| 13 | +set -euo pipefail | ||
| 14 | + | ||
| 15 | +ROOT="$(cd "$(dirname "$0")/.." && pwd)" | ||
| 16 | +VIDYA="${VIDYA:-$(cd "$ROOT/../vidya" && pwd)}" | ||
| 17 | +ANDROID_HOME="${ANDROID_HOME:-$HOME/.local/share/android-sdk}" | ||
| 18 | +ANDROID_NDK_HOME="${ANDROID_NDK_HOME:-$HOME/.local/share/android-ndk-r29}" | ||
| 19 | +CHEZ_ANDROID="${CHEZ_ANDROID:-$HOME/.cache/vidya-chez-android}" | ||
| 20 | +BUILD="$ROOT/android/build" | ||
| 21 | +JOLT_BUILD="$BUILD/jolt" | ||
| 22 | +STAGE="$BUILD/stage" | ||
| 23 | +TOOLS="$ANDROID_HOME/build-tools/36.0.0" | ||
| 24 | +ADB="${ADB:-$ANDROID_HOME/platform-tools/adb}" | ||
| 25 | +NDK_BIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" | ||
| 26 | +PACKAGE="uk.nandi.frq" | ||
| 27 | +ACTIVITY="$PACKAGE/android.app.NativeActivity" | ||
| 28 | +API=28 | ||
| 29 | + | ||
| 30 | +for path in \ | ||
| 31 | + "$NDK_BIN/aarch64-linux-android$API-clang" \ | ||
| 32 | + "$ANDROID_HOME/platforms/android-36/android.jar" \ | ||
| 33 | + "$TOOLS/aapt2" "$TOOLS/zipalign" "$TOOLS/apksigner"; do | ||
| 34 | + [[ -e "$path" ]] || { echo "missing Android tool: $path" >&2; exit 1; } | ||
| 35 | +done | ||
| 36 | + | ||
| 37 | +# --- the UI half ------------------------------------------------------------ | ||
| 38 | +( cd "$VIDYA" && just ffi-android >&2 ) | ||
| 39 | +VIDYA_SO="$VIDYA/build/android/arm64-v8a/libvidya.so" | ||
| 40 | +[[ -f "$VIDYA_SO" ]] || { echo "missing $VIDYA_SO" >&2; exit 1; } | ||
| 41 | + | ||
| 42 | +# --- the Jolt half ---------------------------------------------------------- | ||
| 43 | +"$ROOT/android/build-jolt-boot.sh" "$JOLT_BUILD" | ||
| 44 | +( | ||
| 45 | + cd "$JOLT_BUILD" | ||
| 46 | + # The boot image travels as a blob in the object file's data section; the | ||
| 47 | + # _binary_jolt_boot_{start,end} symbols jolt_main.c reads come from this. | ||
| 48 | + "$NDK_BIN/llvm-objcopy" \ | ||
| 49 | + --input-target=binary \ | ||
| 50 | + --output-target=elf64-littleaarch64 \ | ||
| 51 | + --binary-architecture=aarch64 \ | ||
| 52 | + jolt.boot jolt_boot.o | ||
| 53 | +) | ||
| 54 | + | ||
| 55 | +rm -rf "$STAGE" | ||
| 56 | +mkdir -p "$STAGE/lib/arm64-v8a" | ||
| 57 | +cp "$VIDYA_SO" "$STAGE/lib/arm64-v8a/libvidya.so" | ||
| 58 | + | ||
| 59 | +"$NDK_BIN/aarch64-linux-android$API-clang" \ | ||
| 60 | + -shared -fPIC -O2 \ | ||
| 61 | + -o "$STAGE/lib/arm64-v8a/libjoltapp.so" \ | ||
| 62 | + "$VIDYA/android/jolt_main.c" \ | ||
| 63 | + "$JOLT_BUILD/jolt_boot.o" \ | ||
| 64 | + -I"$JOLT_BUILD" \ | ||
| 65 | + -I"$VIDYA/raylib/include" \ | ||
| 66 | + -I"$VIDYA/ffi/include" \ | ||
| 67 | + -L"$STAGE/lib/arm64-v8a" \ | ||
| 68 | + "$CHEZ_ANDROID/tarm64le/boot/tarm64le/libkernel.a" \ | ||
| 69 | + "$CHEZ_ANDROID/lz4/lib/liblz4.a" \ | ||
| 70 | + -lvidya -landroid -llog -lz -ldl -lm \ | ||
| 71 | + -Wl,--no-undefined | ||
| 72 | + | ||
| 73 | +# --- the APK ---------------------------------------------------------------- | ||
| 74 | +UNALIGNED="$BUILD/frq-unaligned.apk" | ||
| 75 | +ALIGNED="$BUILD/frq-aligned.apk" | ||
| 76 | +APK="$BUILD/frq.apk" | ||
| 77 | +rm -f "$UNALIGNED" "$ALIGNED" "$APK" | ||
| 78 | +"$TOOLS/aapt2" link \ | ||
| 79 | + -o "$UNALIGNED" \ | ||
| 80 | + -I "$ANDROID_HOME/platforms/android-36/android.jar" \ | ||
| 81 | + --manifest "$ROOT/android/AndroidManifest.xml" \ | ||
| 82 | + --min-sdk-version $API \ | ||
| 83 | + --target-sdk-version 36 \ | ||
| 84 | + --version-code 1 \ | ||
| 85 | + --version-name 0.1.0 | ||
| 86 | +# Stored, not deflated: the loader maps these straight out of the APK. | ||
| 87 | +(cd "$STAGE" && zip -q -0 "$UNALIGNED" \ | ||
| 88 | + lib/arm64-v8a/libvidya.so lib/arm64-v8a/libjoltapp.so) | ||
| 89 | +"$TOOLS/zipalign" -f -p 4 "$UNALIGNED" "$ALIGNED" | ||
| 90 | + | ||
| 91 | +KEYSTORE="$HOME/.android/debug.keystore" | ||
| 92 | +if [[ ! -f "$KEYSTORE" ]]; then | ||
| 93 | + mkdir -p "$(dirname "$KEYSTORE")" | ||
| 94 | + keytool -genkeypair -v \ | ||
| 95 | + -keystore "$KEYSTORE" -storepass android -keypass android \ | ||
| 96 | + -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 \ | ||
| 97 | + -dname "CN=Android Debug,O=Android,C=US" | ||
| 98 | +fi | ||
| 99 | +"$TOOLS/apksigner" sign \ | ||
| 100 | + --ks "$KEYSTORE" --ks-key-alias androiddebugkey \ | ||
| 101 | + --ks-pass pass:android --key-pass pass:android \ | ||
| 102 | + --out "$APK" "$ALIGNED" | ||
| 103 | +"$TOOLS/apksigner" verify "$APK" >/dev/null | ||
| 104 | + | ||
| 105 | +case "${1:-build}" in | ||
| 106 | + build) printf '%s\n' "$APK" ;; | ||
| 107 | + install) "$ADB" install -r "$APK" ;; | ||
| 108 | + run) | ||
| 109 | + "$ADB" install -r "$APK" | ||
| 110 | + "$ADB" shell am force-stop "$PACKAGE" | ||
| 111 | + "$ADB" shell am start -n "$ACTIVITY" | ||
| 112 | + ;; | ||
| 113 | + log) "$ADB" logcat -s VidyaJolt Vidya ;; | ||
| 114 | + *) | ||
| 115 | + echo "usage: $0 [build|install|run|log]" >&2 | ||
| 116 | + exit 2 | ||
| 117 | + ;; | ||
| 118 | +esac | ||
added
android/build-jolt-boot.sh +90 -0 | new file mode 100755 | ||
| @@ -0,0 +1,90 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +set -euo pipefail | |
| 3 | + | |
| 4 | +ROOT="$(cd "$(dirname "$0")/.." && pwd)" | |
| 5 | +VIDYA="${VIDYA:-$(cd "$ROOT/../vidya" && pwd)}" | |
| 6 | +# glimmer itself is a git dependency, so its sources live in the jolt cache | |
| 7 | +# rather than in either checkout. The boot image is built from :paths alone — | |
| 8 | +# there is no dependency resolution inside a cross compile — so the cache path | |
| 9 | +# has to be named here. | |
| 10 | +GLIMMER="${GLIMMER:-$HOME/.jolt/gitlibs/https___github.com_jolt-lang_glimmer/5581c331c51aff989259b9e8e92ec920fe5e6741/src}" | |
| 11 | +OUT="${1:?usage: build-jolt-boot.sh OUTPUT_DIRECTORY}" | |
| 12 | +JOLT="${JOLT:-$(command -v jolt)}" | |
| 13 | +MODULE="${MODULE:-frq.app}" | |
| 14 | +CHEZ_ANDROID="${CHEZ_ANDROID:-$HOME/.cache/vidya-chez-android}" | |
| 15 | +HOST_SCHEME="$CHEZ_ANDROID/ta6le/bin/ta6le/scheme" | |
| 16 | +TARGET_BOOT="$CHEZ_ANDROID/boot/tarm64le" | |
| 17 | +XPATCH="$CHEZ_ANDROID/xc-tarm64le/s/xpatch" | |
| 18 | + | |
| 19 | +for path in "$HOST_SCHEME" "$TARGET_BOOT/petite.boot" \ | |
| 20 | + "$TARGET_BOOT/scheme.boot" "$TARGET_BOOT/scheme.h" "$XPATCH"; do | |
| 21 | + [[ -e "$path" ]] || { | |
| 22 | + echo "missing Android Chez artifact: $path" >&2 | |
| 23 | + echo "Build Chez's tarm64le cross target first; see ../vidya/android." >&2 | |
| 24 | + exit 1 | |
| 25 | + } | |
| 26 | +done | |
| 27 | +command -v "$JOLT" >/dev/null || { | |
| 28 | + echo "Jolt executable not found: $JOLT" >&2 | |
| 29 | + exit 1 | |
| 30 | +} | |
| 31 | + | |
| 32 | +# The boot image is a pure function of the Scheme sources, the module name, the | |
| 33 | +# flat-split flag and Chez's own boot files — all static. Hash them, and skip | |
| 34 | +# the whole thing when the stamp still matches: a Rust-only APK rebuild has no | |
| 35 | +# reason to spend fifteen single-threaded seconds recompiling Scheme. | |
| 36 | +# | |
| 37 | +# The flag is part of the stamp on purpose. JOLT_NO_FLAT_SPLIT changes the shape | |
| 38 | +# of what `jolt build` emits, so an app.build/ left by an ordinary build is not | |
| 39 | +# reusable here; a stamp miss wipes the tree below, which is what the | |
| 40 | +# unconditional `rm -rf` used to be defending against. | |
| 41 | +STAMP="$OUT/jolt.boot.stamp" | |
| 42 | +stamp_now() { | |
| 43 | + { | |
| 44 | + printf '%s\n' "$MODULE" "JOLT_NO_FLAT_SPLIT=1" | |
| 45 | + "$JOLT" --version 2>/dev/null || true | |
| 46 | + find "$ROOT/src" "$VIDYA/glimmer/src" "$GLIMMER" -type f \ | |
| 47 | + \( -name '*.jolt' -o -name '*.edn' \) -print0 | sort -z | xargs -0 sha256sum | |
| 48 | + sha256sum "$TARGET_BOOT/petite.boot" "$TARGET_BOOT/scheme.boot" "$XPATCH" | |
| 49 | + } | sha256sum | cut -d' ' -f1 | |
| 50 | +} | |
| 51 | + | |
| 52 | +WANT="$(stamp_now)" | |
| 53 | +if [[ -f "$OUT/jolt.boot" && -f "$OUT/scheme.h" && -f "$STAMP" ]] && | |
| 54 | + [[ "$(<"$STAMP")" == "$WANT" ]]; then | |
| 55 | + echo "jolt boot image up to date" >&2 | |
| 56 | + exit 0 | |
| 57 | +fi | |
| 58 | + | |
| 59 | +rm -f "$STAMP" | |
| 60 | +rm -rf "$OUT/project" "$OUT/cross" | |
| 61 | +mkdir -p "$OUT/project" "$OUT/cross" | |
| 62 | +cat > "$OUT/project/deps.edn" <<EOF | |
| 63 | +{:paths ["$ROOT/src" "$VIDYA/glimmer/src" "$GLIMMER"]} | |
| 64 | +EOF | |
| 65 | + | |
| 66 | +( | |
| 67 | + cd "$OUT/project" | |
| 68 | + JOLT_NO_FLAT_SPLIT=1 "$JOLT" build \ | |
| 69 | + -m "$MODULE" -o app | |
| 70 | +) | |
| 71 | + | |
| 72 | +cat > "$OUT/cross/compile.ss" <<EOF | |
| 73 | +(import (chezscheme)) | |
| 74 | +(load "$XPATCH") | |
| 75 | +(optimize-level 2) | |
| 76 | +(generate-inspector-information #f) | |
| 77 | +(compile-file "$OUT/project/app.build/flat.ss" "$OUT/cross/flat.so") | |
| 78 | +(make-boot-file "$OUT/jolt.boot" '() | |
| 79 | + "$TARGET_BOOT/petite.boot" | |
| 80 | + "$TARGET_BOOT/scheme.boot" | |
| 81 | + "$OUT/cross/flat.so") | |
| 82 | +EOF | |
| 83 | + | |
| 84 | +SCHEMEHEAPDIRS="$CHEZ_ANDROID/ta6le/boot/ta6le" \ | |
| 85 | + "$HOST_SCHEME" --script "$OUT/cross/compile.ss" | |
| 86 | + | |
| 87 | +cp "$TARGET_BOOT/scheme.h" "$OUT/scheme.h" | |
| 88 | + | |
| 89 | +# Last, so an interrupted build leaves no stamp and the next run redoes it. | |
| 90 | +printf '%s\n' "$WANT" > "$STAMP" | |
| new file mode 100755 | |||
| @@ -0,0 +1,90 @@ | |||
| 1 | +#!/usr/bin/env bash | ||
| 2 | +set -euo pipefail | ||
| 3 | + | ||
| 4 | +ROOT="$(cd "$(dirname "$0")/.." && pwd)" | ||
| 5 | +VIDYA="${VIDYA:-$(cd "$ROOT/../vidya" && pwd)}" | ||
| 6 | +# glimmer itself is a git dependency, so its sources live in the jolt cache | ||
| 7 | +# rather than in either checkout. The boot image is built from :paths alone — | ||
| 8 | +# there is no dependency resolution inside a cross compile — so the cache path | ||
| 9 | +# has to be named here. | ||
| 10 | +GLIMMER="${GLIMMER:-$HOME/.jolt/gitlibs/https___github.com_jolt-lang_glimmer/5581c331c51aff989259b9e8e92ec920fe5e6741/src}" | ||
| 11 | +OUT="${1:?usage: build-jolt-boot.sh OUTPUT_DIRECTORY}" | ||
| 12 | +JOLT="${JOLT:-$(command -v jolt)}" | ||
| 13 | +MODULE="${MODULE:-frq.app}" | ||
| 14 | +CHEZ_ANDROID="${CHEZ_ANDROID:-$HOME/.cache/vidya-chez-android}" | ||
| 15 | +HOST_SCHEME="$CHEZ_ANDROID/ta6le/bin/ta6le/scheme" | ||
| 16 | +TARGET_BOOT="$CHEZ_ANDROID/boot/tarm64le" | ||
| 17 | +XPATCH="$CHEZ_ANDROID/xc-tarm64le/s/xpatch" | ||
| 18 | + | ||
| 19 | +for path in "$HOST_SCHEME" "$TARGET_BOOT/petite.boot" \ | ||
| 20 | + "$TARGET_BOOT/scheme.boot" "$TARGET_BOOT/scheme.h" "$XPATCH"; do | ||
| 21 | + [[ -e "$path" ]] || { | ||
| 22 | + echo "missing Android Chez artifact: $path" >&2 | ||
| 23 | + echo "Build Chez's tarm64le cross target first; see ../vidya/android." >&2 | ||
| 24 | + exit 1 | ||
| 25 | + } | ||
| 26 | +done | ||
| 27 | +command -v "$JOLT" >/dev/null || { | ||
| 28 | + echo "Jolt executable not found: $JOLT" >&2 | ||
| 29 | + exit 1 | ||
| 30 | +} | ||
| 31 | + | ||
| 32 | +# The boot image is a pure function of the Scheme sources, the module name, the | ||
| 33 | +# flat-split flag and Chez's own boot files — all static. Hash them, and skip | ||
| 34 | +# the whole thing when the stamp still matches: a Rust-only APK rebuild has no | ||
| 35 | +# reason to spend fifteen single-threaded seconds recompiling Scheme. | ||
| 36 | +# | ||
| 37 | +# The flag is part of the stamp on purpose. JOLT_NO_FLAT_SPLIT changes the shape | ||
| 38 | +# of what `jolt build` emits, so an app.build/ left by an ordinary build is not | ||
| 39 | +# reusable here; a stamp miss wipes the tree below, which is what the | ||
| 40 | +# unconditional `rm -rf` used to be defending against. | ||
| 41 | +STAMP="$OUT/jolt.boot.stamp" | ||
| 42 | +stamp_now() { | ||
| 43 | + { | ||
| 44 | + printf '%s\n' "$MODULE" "JOLT_NO_FLAT_SPLIT=1" | ||
| 45 | + "$JOLT" --version 2>/dev/null || true | ||
| 46 | + find "$ROOT/src" "$VIDYA/glimmer/src" "$GLIMMER" -type f \ | ||
| 47 | + \( -name '*.jolt' -o -name '*.edn' \) -print0 | sort -z | xargs -0 sha256sum | ||
| 48 | + sha256sum "$TARGET_BOOT/petite.boot" "$TARGET_BOOT/scheme.boot" "$XPATCH" | ||
| 49 | + } | sha256sum | cut -d' ' -f1 | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +WANT="$(stamp_now)" | ||
| 53 | +if [[ -f "$OUT/jolt.boot" && -f "$OUT/scheme.h" && -f "$STAMP" ]] && | ||
| 54 | + [[ "$(<"$STAMP")" == "$WANT" ]]; then | ||
| 55 | + echo "jolt boot image up to date" >&2 | ||
| 56 | + exit 0 | ||
| 57 | +fi | ||
| 58 | + | ||
| 59 | +rm -f "$STAMP" | ||
| 60 | +rm -rf "$OUT/project" "$OUT/cross" | ||
| 61 | +mkdir -p "$OUT/project" "$OUT/cross" | ||
| 62 | +cat > "$OUT/project/deps.edn" <<EOF | ||
| 63 | +{:paths ["$ROOT/src" "$VIDYA/glimmer/src" "$GLIMMER"]} | ||
| 64 | +EOF | ||
| 65 | + | ||
| 66 | +( | ||
| 67 | + cd "$OUT/project" | ||
| 68 | + JOLT_NO_FLAT_SPLIT=1 "$JOLT" build \ | ||
| 69 | + -m "$MODULE" -o app | ||
| 70 | +) | ||
| 71 | + | ||
| 72 | +cat > "$OUT/cross/compile.ss" <<EOF | ||
| 73 | +(import (chezscheme)) | ||
| 74 | +(load "$XPATCH") | ||
| 75 | +(optimize-level 2) | ||
| 76 | +(generate-inspector-information #f) | ||
| 77 | +(compile-file "$OUT/project/app.build/flat.ss" "$OUT/cross/flat.so") | ||
| 78 | +(make-boot-file "$OUT/jolt.boot" '() | ||
| 79 | + "$TARGET_BOOT/petite.boot" | ||
| 80 | + "$TARGET_BOOT/scheme.boot" | ||
| 81 | + "$OUT/cross/flat.so") | ||
| 82 | +EOF | ||
| 83 | + | ||
| 84 | +SCHEMEHEAPDIRS="$CHEZ_ANDROID/ta6le/boot/ta6le" \ | ||
| 85 | + "$HOST_SCHEME" --script "$OUT/cross/compile.ss" | ||
| 86 | + | ||
| 87 | +cp "$TARGET_BOOT/scheme.h" "$OUT/scheme.h" | ||
| 88 | + | ||
| 89 | +# Last, so an interrupted build leaves no stamp and the next run redoes it. | ||
| 90 | +printf '%s\n' "$WANT" > "$STAMP" | ||
added
deps.edn +20 -0 | new file mode 100644 | ||
| @@ -0,0 +1,20 @@ | ||
| 1 | +{:paths ["src"] | |
| 2 | + | |
| 3 | + ;; glimmer owns the reactive half (ratom, components, reconciler); | |
| 4 | + ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI. | |
| 5 | + :deps {jolt-lang/glimmer {:git/url "https://github.com/jolt-lang/glimmer" | |
| 6 | + :git/tag "v0.1.0" | |
| 7 | + :git/sha "5581c331c51aff989259b9e8e92ec920fe5e6741"} | |
| 8 | + nandi/glimmer-vidya {:local/root "../vidya/glimmer"}} | |
| 9 | + | |
| 10 | + ;; libvidya, the Rust/egui build (the C/raylib one does not export the tree | |
| 11 | + ;; ABI the backend binds): | |
| 12 | + ;; cd ../vidya && just ffi | |
| 13 | + ;; LD_LIBRARY_PATH=../vidya/build jolt -M:frq | |
| 14 | + :jolt/native [{:name "vidya" | |
| 15 | + :darwin ["libvidya.dylib"] | |
| 16 | + :linux ["libvidya.so"]}] | |
| 17 | + | |
| 18 | + :aliases {:frq {:main-opts ["-m" "frq.app"]}} | |
| 19 | + | |
| 20 | + :tasks {frq "jolt -M:frq"}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,20 @@ | |||
| 1 | +{:paths ["src"] | ||
| 2 | + | ||
| 3 | + ;; glimmer owns the reactive half (ratom, components, reconciler); | ||
| 4 | + ;; glimmer-vidya paints it as Vidya/egui through libvidya's retained-tree ABI. | ||
| 5 | + :deps {jolt-lang/glimmer {:git/url "https://github.com/jolt-lang/glimmer" | ||
| 6 | + :git/tag "v0.1.0" | ||
| 7 | + :git/sha "5581c331c51aff989259b9e8e92ec920fe5e6741"} | ||
| 8 | + nandi/glimmer-vidya {:local/root "../vidya/glimmer"}} | ||
| 9 | + | ||
| 10 | + ;; libvidya, the Rust/egui build (the C/raylib one does not export the tree | ||
| 11 | + ;; ABI the backend binds): | ||
| 12 | + ;; cd ../vidya && just ffi | ||
| 13 | + ;; LD_LIBRARY_PATH=../vidya/build jolt -M:frq | ||
| 14 | + :jolt/native [{:name "vidya" | ||
| 15 | + :darwin ["libvidya.dylib"] | ||
| 16 | + :linux ["libvidya.so"]}] | ||
| 17 | + | ||
| 18 | + :aliases {:frq {:main-opts ["-m" "frq.app"]}} | ||
| 19 | + | ||
| 20 | + :tasks {frq "jolt -M:frq"}} | ||
added
justfile +14 -0 | new file mode 100644 | ||
| @@ -0,0 +1,14 @@ | ||
| 1 | +set shell := ["bash", "-euo", "pipefail", "-c"] | |
| 2 | + | |
| 3 | +vidya := justfile_directory() + "/../vidya" | |
| 4 | + | |
| 5 | +default: | |
| 6 | + @just --list | |
| 7 | + | |
| 8 | +# libvidya (Rust/egui build) — the tree ABI glimmer-vidya binds. | |
| 9 | +lib: | |
| 10 | + cd {{vidya}} && just ffi | |
| 11 | + | |
| 12 | +# The app. Point LD_LIBRARY_PATH at whichever libvidya build you have. | |
| 13 | +run *args: | |
| 14 | + LD_LIBRARY_PATH="{{vidya}}/build:{{vidya}}/ffi/target/release" jolt -M:frq {{args}} | |
| new file mode 100644 | |||
| @@ -0,0 +1,14 @@ | |||
| 1 | +set shell := ["bash", "-euo", "pipefail", "-c"] | ||
| 2 | + | ||
| 3 | +vidya := justfile_directory() + "/../vidya" | ||
| 4 | + | ||
| 5 | +default: | ||
| 6 | + @just --list | ||
| 7 | + | ||
| 8 | +# libvidya (Rust/egui build) — the tree ABI glimmer-vidya binds. | ||
| 9 | +lib: | ||
| 10 | + cd {{vidya}} && just ffi | ||
| 11 | + | ||
| 12 | +# The app. Point LD_LIBRARY_PATH at whichever libvidya build you have. | ||
| 13 | +run *args: | ||
| 14 | + LD_LIBRARY_PATH="{{vidya}}/build:{{vidya}}/ffi/target/release" jolt -M:frq {{args}} | ||
added
src/frq/app.jolt +192 -0 | new file mode 100644 | ||
| @@ -0,0 +1,192 @@ | ||
| 1 | +(ns frq.app | |
| 2 | + "frq — a freeq client written as glimmer components, painted by Vidya/egui. | |
| 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 the same widgets." | |
| 7 | + (:require [glimmer.ratom :as r :refer [atom]] | |
| 8 | + [glimmer.core :as ui] | |
| 9 | + [glimmer-vidya.core :as vidya] | |
| 10 | + [frq.state :as s])) | |
| 11 | + | |
| 12 | +;; ---------------------------------------------------------------- pieces | |
| 13 | + | |
| 14 | +(defn error-note [] | |
| 15 | + (when-let [e @s/error] | |
| 16 | + [:card {} | |
| 17 | + [:label {:label (str "⚠ " e)}] | |
| 18 | + [:button {:label "Dismiss" :on-click #(reset! s/error nil)}]])) | |
| 19 | + | |
| 20 | +(defn tab-bar [] | |
| 21 | + [:hbox {:spacing 8} | |
| 22 | + (for [[k label] [[:chats "Chats"] [:discover "Discover"] [:settings "Settings"]]] | |
| 23 | + [:button {:key k | |
| 24 | + :label label | |
| 25 | + :kind (if (= k @s/screen) :primary :default) | |
| 26 | + :on-click #(reset! s/screen k)}])]) | |
| 27 | + | |
| 28 | +;; ---------------------------------------------------------------- connect | |
| 29 | + | |
| 30 | +(defn connect-screen [] | |
| 31 | + [:page {:max-width 520} | |
| 32 | + [:title {:label "frq"}] | |
| 33 | + [:dim-label {:label "freeq client — guest connect over plain TCP."}] | |
| 34 | + [error-note] | |
| 35 | + [:card {} | |
| 36 | + [:title-2 {:label "Connect as guest"}] | |
| 37 | + [:label {:label "Nick"}] | |
| 38 | + [:entry {:text @s/form-nick | |
| 39 | + :width-request 320 | |
| 40 | + :placeholder "your nick" | |
| 41 | + :on-change #(reset! s/form-nick %)}] | |
| 42 | + [:label {:label "Server"}] | |
| 43 | + [:hbox {:spacing 8} | |
| 44 | + [:entry {:text @s/form-host | |
| 45 | + :width-request 220 | |
| 46 | + :placeholder "host" | |
| 47 | + :on-change #(reset! s/form-host %)}] | |
| 48 | + [:entry {:text @s/form-port | |
| 49 | + :width-request 90 | |
| 50 | + :placeholder "6697" | |
| 51 | + :on-change #(reset! s/form-port %)}]] | |
| 52 | + [:checkbutton {:label "TLS" | |
| 53 | + :active @s/form-tls? | |
| 54 | + :on-toggled #(do (swap! s/form-tls? not) | |
| 55 | + (reset! s/form-port | |
| 56 | + (if @s/form-tls? "6697" "6667")))}] | |
| 57 | + [:separator {}] | |
| 58 | + (if @s/connecting? | |
| 59 | + [:hbox {:spacing 8} | |
| 60 | + [:spinner {}] | |
| 61 | + [:dim-label {:label @s/status}]] | |
| 62 | + [:hbox {:spacing 8} | |
| 63 | + [:button {:label "Connect" :kind :primary :on-click s/connect!}] | |
| 64 | + [:dim-label {:label @s/status}]])] | |
| 65 | + [:dim-label {:label "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener."}]]) | |
| 66 | + | |
| 67 | +;; ---------------------------------------------------------------- chats | |
| 68 | + | |
| 69 | +(defn conversation-row [buffer] | |
| 70 | + (let [name (:name buffer) | |
| 71 | + unread (:unread buffer)] | |
| 72 | + [:card {:key name} | |
| 73 | + [:hbox {:spacing 12} | |
| 74 | + [:title-2 {:label name}] | |
| 75 | + (when (pos? unread) [:label {:label (str "● " unread)}])] | |
| 76 | + [:dim-label {:label (s/last-preview buffer)}] | |
| 77 | + [:hbox {:spacing 8} | |
| 78 | + [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}] | |
| 79 | + [:status {:label (if (:joined? buffer) "joined" "not joined") | |
| 80 | + :live (boolean (:joined? buffer))}]]])) | |
| 81 | + | |
| 82 | +(defn chats-screen [] | |
| 83 | + (let [buffers (s/channel-list)] | |
| 84 | + [:page {:max-width 620} | |
| 85 | + [:title {:label "Chats"}] | |
| 86 | + [error-note] | |
| 87 | + [:card {} | |
| 88 | + [:title-2 {:label "Join channel"}] | |
| 89 | + [:hbox {:spacing 8} | |
| 90 | + [:entry {:text @s/join-input | |
| 91 | + :width-request 380 | |
| 92 | + :placeholder "#channel" | |
| 93 | + :on-change #(reset! s/join-input %) | |
| 94 | + :on-activate #(do (s/join! @s/join-input) (reset! s/join-input ""))}] | |
| 95 | + [:button {:label "Join" | |
| 96 | + :kind :primary | |
| 97 | + :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]]] | |
| 98 | + [:entry {:text @s/search | |
| 99 | + :placeholder "Search channels" | |
| 100 | + :on-change #(reset! s/search %)}] | |
| 101 | + (if (seq buffers) | |
| 102 | + (for [b buffers] [conversation-row b]) | |
| 103 | + [:card {} [:dim-label {:label "No conversations yet — join a channel."}]]) | |
| 104 | + [:separator {}] | |
| 105 | + [tab-bar]])) | |
| 106 | + | |
| 107 | +;; ---------------------------------------------------------------- chat | |
| 108 | + | |
| 109 | +(defn message-row [i m] | |
| 110 | + ;; Sender above the text, not beside it. A wrapping label in a horizontal row | |
| 111 | + ;; lays out against the row's width rather than the column's, so one long URL | |
| 112 | + ;; drags every line that follows it off the left edge. | |
| 113 | + [:vbox {:key i :spacing 2} | |
| 114 | + [:dim-label {:label (:from m)}] | |
| 115 | + [:label {:label (:text m)}]]) | |
| 116 | + | |
| 117 | +(defn chat-screen [] | |
| 118 | + (let [name @s/current | |
| 119 | + buffer (get @s/channels name)] | |
| 120 | + ;; Not a :page — a page scrolls everything, which would carry the compose | |
| 121 | + ;; bar off the bottom with the backlog. The message list is the only thing | |
| 122 | + ;; that scrolls, bounded so what follows it keeps its room. | |
| 123 | + [:vbox {:spacing 8 :margin 12} | |
| 124 | + [:hbox {:spacing 8} | |
| 125 | + [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}] | |
| 126 | + [:title {:label (or name "Chat")}]] | |
| 127 | + [error-note] | |
| 128 | + ;; :reserve leaves room for the separator and compose bar below, so the | |
| 129 | + ;; list fills whatever the window has and no more. | |
| 130 | + [:scroll {:orientation :vertical :reserve 72 :stick-to-bottom true} | |
| 131 | + (if (seq (:messages buffer)) | |
| 132 | + (map-indexed message-row (:messages buffer)) | |
| 133 | + [:dim-label {:label "Nothing here yet."}])] | |
| 134 | + [:separator {}] | |
| 135 | + [:hbox {:spacing 8} | |
| 136 | + ;; narrow enough that Send keeps its place on a phone-width row | |
| 137 | + [:entry {:text @s/draft | |
| 138 | + :width-request 300 | |
| 139 | + :placeholder "Message" | |
| 140 | + :on-change #(reset! s/draft %) | |
| 141 | + :on-activate s/send-draft!}] | |
| 142 | + [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]])) | |
| 143 | + | |
| 144 | +;; ---------------------------------------------------------------- discover | |
| 145 | + | |
| 146 | +(defn discover-screen [] | |
| 147 | + [:page {:max-width 620} | |
| 148 | + [:title {:label "Discover"}] | |
| 149 | + [:dim-label {:label "Popular channels on freeq."}] | |
| 150 | + [error-note] | |
| 151 | + (for [[name blurb] s/popular-channels] | |
| 152 | + (let [joined? (get-in @s/channels [name :joined?])] | |
| 153 | + [:card {:key name} | |
| 154 | + [:title-2 {:label name}] | |
| 155 | + [:dim-label {:label blurb}] | |
| 156 | + [:button {:label (if joined? "Open" "Join") | |
| 157 | + :kind :primary | |
| 158 | + :on-click #(if joined? (s/open-channel! name) (s/join! name))}]])) | |
| 159 | + [:separator {}] | |
| 160 | + [tab-bar]]) | |
| 161 | + | |
| 162 | +;; ---------------------------------------------------------------- settings | |
| 163 | + | |
| 164 | +(defn settings-screen [] | |
| 165 | + [:page {:max-width 520} | |
| 166 | + [:title {:label "Settings"}] | |
| 167 | + [:card {} | |
| 168 | + [:title-2 {:label "Connection"}] | |
| 169 | + [:status {:label @s/status :live (s/connected?)}] | |
| 170 | + [:label {:label (str "Server: " @s/form-host ":" @s/form-port)}] | |
| 171 | + [:label {:label (str "Nick: " @s/form-nick)}] | |
| 172 | + [:separator {}] | |
| 173 | + [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] | |
| 174 | + [:card {} | |
| 175 | + [:title-2 {:label "frq"}] | |
| 176 | + [:dim-label {:label "freeq client in jolt — glimmer components on the Vidya/egui backend."}] | |
| 177 | + [:button {:label "Quit" :on-click vidya/quit!}]] | |
| 178 | + [:separator {}] | |
| 179 | + [tab-bar]]) | |
| 180 | + | |
| 181 | +;; ---------------------------------------------------------------- shell | |
| 182 | + | |
| 183 | +(defn app [] | |
| 184 | + (case @s/screen | |
| 185 | + :connect [connect-screen] | |
| 186 | + :chat [chat-screen] | |
| 187 | + :discover [discover-screen] | |
| 188 | + :settings [settings-screen] | |
| 189 | + [chats-screen])) | |
| 190 | + | |
| 191 | +(defn -main [& _] | |
| 192 | + (ui/run app :title "frq" :width 520 :height 860)) | |
| new file mode 100644 | |||
| @@ -0,0 +1,192 @@ | |||
| 1 | +(ns frq.app | ||
| 2 | + "frq — a freeq client written as glimmer components, painted by Vidya/egui. | ||
| 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 the same widgets." | ||
| 7 | + (:require [glimmer.ratom :as r :refer [atom]] | ||
| 8 | + [glimmer.core :as ui] | ||
| 9 | + [glimmer-vidya.core :as vidya] | ||
| 10 | + [frq.state :as s])) | ||
| 11 | + | ||
| 12 | +;; ---------------------------------------------------------------- pieces | ||
| 13 | + | ||
| 14 | +(defn error-note [] | ||
| 15 | + (when-let [e @s/error] | ||
| 16 | + [:card {} | ||
| 17 | + [:label {:label (str "⚠ " e)}] | ||
| 18 | + [:button {:label "Dismiss" :on-click #(reset! s/error nil)}]])) | ||
| 19 | + | ||
| 20 | +(defn tab-bar [] | ||
| 21 | + [:hbox {:spacing 8} | ||
| 22 | + (for [[k label] [[:chats "Chats"] [:discover "Discover"] [:settings "Settings"]]] | ||
| 23 | + [:button {:key k | ||
| 24 | + :label label | ||
| 25 | + :kind (if (= k @s/screen) :primary :default) | ||
| 26 | + :on-click #(reset! s/screen k)}])]) | ||
| 27 | + | ||
| 28 | +;; ---------------------------------------------------------------- connect | ||
| 29 | + | ||
| 30 | +(defn connect-screen [] | ||
| 31 | + [:page {:max-width 520} | ||
| 32 | + [:title {:label "frq"}] | ||
| 33 | + [:dim-label {:label "freeq client — guest connect over plain TCP."}] | ||
| 34 | + [error-note] | ||
| 35 | + [:card {} | ||
| 36 | + [:title-2 {:label "Connect as guest"}] | ||
| 37 | + [:label {:label "Nick"}] | ||
| 38 | + [:entry {:text @s/form-nick | ||
| 39 | + :width-request 320 | ||
| 40 | + :placeholder "your nick" | ||
| 41 | + :on-change #(reset! s/form-nick %)}] | ||
| 42 | + [:label {:label "Server"}] | ||
| 43 | + [:hbox {:spacing 8} | ||
| 44 | + [:entry {:text @s/form-host | ||
| 45 | + :width-request 220 | ||
| 46 | + :placeholder "host" | ||
| 47 | + :on-change #(reset! s/form-host %)}] | ||
| 48 | + [:entry {:text @s/form-port | ||
| 49 | + :width-request 90 | ||
| 50 | + :placeholder "6697" | ||
| 51 | + :on-change #(reset! s/form-port %)}]] | ||
| 52 | + [:checkbutton {:label "TLS" | ||
| 53 | + :active @s/form-tls? | ||
| 54 | + :on-toggled #(do (swap! s/form-tls? not) | ||
| 55 | + (reset! s/form-port | ||
| 56 | + (if @s/form-tls? "6697" "6667")))}] | ||
| 57 | + [:separator {}] | ||
| 58 | + (if @s/connecting? | ||
| 59 | + [:hbox {:spacing 8} | ||
| 60 | + [:spinner {}] | ||
| 61 | + [:dim-label {:label @s/status}]] | ||
| 62 | + [:hbox {:spacing 8} | ||
| 63 | + [:button {:label "Connect" :kind :primary :on-click s/connect!}] | ||
| 64 | + [:dim-label {:label @s/status}]])] | ||
| 65 | + [:dim-label {:label "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener."}]]) | ||
| 66 | + | ||
| 67 | +;; ---------------------------------------------------------------- chats | ||
| 68 | + | ||
| 69 | +(defn conversation-row [buffer] | ||
| 70 | + (let [name (:name buffer) | ||
| 71 | + unread (:unread buffer)] | ||
| 72 | + [:card {:key name} | ||
| 73 | + [:hbox {:spacing 12} | ||
| 74 | + [:title-2 {:label name}] | ||
| 75 | + (when (pos? unread) [:label {:label (str "● " unread)}])] | ||
| 76 | + [:dim-label {:label (s/last-preview buffer)}] | ||
| 77 | + [:hbox {:spacing 8} | ||
| 78 | + [:button {:label "Open" :kind :primary :on-click #(s/open-channel! name)}] | ||
| 79 | + [:status {:label (if (:joined? buffer) "joined" "not joined") | ||
| 80 | + :live (boolean (:joined? buffer))}]]])) | ||
| 81 | + | ||
| 82 | +(defn chats-screen [] | ||
| 83 | + (let [buffers (s/channel-list)] | ||
| 84 | + [:page {:max-width 620} | ||
| 85 | + [:title {:label "Chats"}] | ||
| 86 | + [error-note] | ||
| 87 | + [:card {} | ||
| 88 | + [:title-2 {:label "Join channel"}] | ||
| 89 | + [:hbox {:spacing 8} | ||
| 90 | + [:entry {:text @s/join-input | ||
| 91 | + :width-request 380 | ||
| 92 | + :placeholder "#channel" | ||
| 93 | + :on-change #(reset! s/join-input %) | ||
| 94 | + :on-activate #(do (s/join! @s/join-input) (reset! s/join-input ""))}] | ||
| 95 | + [:button {:label "Join" | ||
| 96 | + :kind :primary | ||
| 97 | + :on-click #(do (s/join! @s/join-input) (reset! s/join-input ""))}]]] | ||
| 98 | + [:entry {:text @s/search | ||
| 99 | + :placeholder "Search channels" | ||
| 100 | + :on-change #(reset! s/search %)}] | ||
| 101 | + (if (seq buffers) | ||
| 102 | + (for [b buffers] [conversation-row b]) | ||
| 103 | + [:card {} [:dim-label {:label "No conversations yet — join a channel."}]]) | ||
| 104 | + [:separator {}] | ||
| 105 | + [tab-bar]])) | ||
| 106 | + | ||
| 107 | +;; ---------------------------------------------------------------- chat | ||
| 108 | + | ||
| 109 | +(defn message-row [i m] | ||
| 110 | + ;; Sender above the text, not beside it. A wrapping label in a horizontal row | ||
| 111 | + ;; lays out against the row's width rather than the column's, so one long URL | ||
| 112 | + ;; drags every line that follows it off the left edge. | ||
| 113 | + [:vbox {:key i :spacing 2} | ||
| 114 | + [:dim-label {:label (:from m)}] | ||
| 115 | + [:label {:label (:text m)}]]) | ||
| 116 | + | ||
| 117 | +(defn chat-screen [] | ||
| 118 | + (let [name @s/current | ||
| 119 | + buffer (get @s/channels name)] | ||
| 120 | + ;; Not a :page — a page scrolls everything, which would carry the compose | ||
| 121 | + ;; bar off the bottom with the backlog. The message list is the only thing | ||
| 122 | + ;; that scrolls, bounded so what follows it keeps its room. | ||
| 123 | + [:vbox {:spacing 8 :margin 12} | ||
| 124 | + [:hbox {:spacing 8} | ||
| 125 | + [:button {:label "← Chats" :on-click #(reset! s/screen :chats)}] | ||
| 126 | + [:title {:label (or name "Chat")}]] | ||
| 127 | + [error-note] | ||
| 128 | + ;; :reserve leaves room for the separator and compose bar below, so the | ||
| 129 | + ;; list fills whatever the window has and no more. | ||
| 130 | + [:scroll {:orientation :vertical :reserve 72 :stick-to-bottom true} | ||
| 131 | + (if (seq (:messages buffer)) | ||
| 132 | + (map-indexed message-row (:messages buffer)) | ||
| 133 | + [:dim-label {:label "Nothing here yet."}])] | ||
| 134 | + [:separator {}] | ||
| 135 | + [:hbox {:spacing 8} | ||
| 136 | + ;; narrow enough that Send keeps its place on a phone-width row | ||
| 137 | + [:entry {:text @s/draft | ||
| 138 | + :width-request 300 | ||
| 139 | + :placeholder "Message" | ||
| 140 | + :on-change #(reset! s/draft %) | ||
| 141 | + :on-activate s/send-draft!}] | ||
| 142 | + [:button {:label "Send" :kind :primary :on-click s/send-draft!}]]])) | ||
| 143 | + | ||
| 144 | +;; ---------------------------------------------------------------- discover | ||
| 145 | + | ||
| 146 | +(defn discover-screen [] | ||
| 147 | + [:page {:max-width 620} | ||
| 148 | + [:title {:label "Discover"}] | ||
| 149 | + [:dim-label {:label "Popular channels on freeq."}] | ||
| 150 | + [error-note] | ||
| 151 | + (for [[name blurb] s/popular-channels] | ||
| 152 | + (let [joined? (get-in @s/channels [name :joined?])] | ||
| 153 | + [:card {:key name} | ||
| 154 | + [:title-2 {:label name}] | ||
| 155 | + [:dim-label {:label blurb}] | ||
| 156 | + [:button {:label (if joined? "Open" "Join") | ||
| 157 | + :kind :primary | ||
| 158 | + :on-click #(if joined? (s/open-channel! name) (s/join! name))}]])) | ||
| 159 | + [:separator {}] | ||
| 160 | + [tab-bar]]) | ||
| 161 | + | ||
| 162 | +;; ---------------------------------------------------------------- settings | ||
| 163 | + | ||
| 164 | +(defn settings-screen [] | ||
| 165 | + [:page {:max-width 520} | ||
| 166 | + [:title {:label "Settings"}] | ||
| 167 | + [:card {} | ||
| 168 | + [:title-2 {:label "Connection"}] | ||
| 169 | + [:status {:label @s/status :live (s/connected?)}] | ||
| 170 | + [:label {:label (str "Server: " @s/form-host ":" @s/form-port)}] | ||
| 171 | + [:label {:label (str "Nick: " @s/form-nick)}] | ||
| 172 | + [:separator {}] | ||
| 173 | + [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]] | ||
| 174 | + [:card {} | ||
| 175 | + [:title-2 {:label "frq"}] | ||
| 176 | + [:dim-label {:label "freeq client in jolt — glimmer components on the Vidya/egui backend."}] | ||
| 177 | + [:button {:label "Quit" :on-click vidya/quit!}]] | ||
| 178 | + [:separator {}] | ||
| 179 | + [tab-bar]]) | ||
| 180 | + | ||
| 181 | +;; ---------------------------------------------------------------- shell | ||
| 182 | + | ||
| 183 | +(defn app [] | ||
| 184 | + (case @s/screen | ||
| 185 | + :connect [connect-screen] | ||
| 186 | + :chat [chat-screen] | ||
| 187 | + :discover [discover-screen] | ||
| 188 | + :settings [settings-screen] | ||
| 189 | + [chats-screen])) | ||
| 190 | + | ||
| 191 | +(defn -main [& _] | ||
| 192 | + (ui/run app :title "frq" :width 520 :height 860)) | ||
added
src/frq/irc.jolt +151 -0 | new file mode 100644 | ||
| @@ -0,0 +1,151 @@ | ||
| 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 | + [jolt.ffi :as ffi] | |
| 19 | + [jolt.mvn-http :as tls] | |
| 20 | + [jolt.socket :as socket])) | |
| 21 | + | |
| 22 | +(def ^:private af-inet 2) | |
| 23 | +(def ^:private sock-stream 1) | |
| 24 | +(def ^:private buffer-size 8192) | |
| 25 | + | |
| 26 | +;; ---------------------------------------------------------------- parsing | |
| 27 | + | |
| 28 | +(defn parse-line | |
| 29 | + "An IRC line into {:prefix :command :params}. The trailing parameter (after | |
| 30 | + \" :\") keeps its spaces; everything before it splits on whitespace." | |
| 31 | + [line] | |
| 32 | + (let [line (str/trimr line) | |
| 33 | + [prefix rest-line] (if (str/starts-with? line ":") | |
| 34 | + (let [i (str/index-of line " ")] | |
| 35 | + [(subs line 1 i) (subs line (inc i))]) | |
| 36 | + [nil line]) | |
| 37 | + i (str/index-of rest-line " :") | |
| 38 | + head (if i (subs rest-line 0 i) rest-line) | |
| 39 | + trailing (when i (subs rest-line (+ i 2))) | |
| 40 | + parts (remove str/blank? (str/split head #" "))] | |
| 41 | + {:prefix prefix | |
| 42 | + :command (str/upper-case (or (first parts) "")) | |
| 43 | + :params (cond-> (vec (rest parts)) trailing (conj trailing))})) | |
| 44 | + | |
| 45 | +(defn nick-of | |
| 46 | + "The nick half of a `nick!user@host` prefix." | |
| 47 | + [prefix] | |
| 48 | + (when prefix | |
| 49 | + (let [i (str/index-of prefix "!")] | |
| 50 | + (if i (subs prefix 0 i) prefix)))) | |
| 51 | + | |
| 52 | +;; ---------------------------------------------------------------- transport | |
| 53 | + | |
| 54 | +(defn- send-all! | |
| 55 | + "send(2) until the whole string is gone — a short write is not an error." | |
| 56 | + [fd text] | |
| 57 | + (let [len (count (.getBytes text))] | |
| 58 | + (ffi/with-c-string [p text] | |
| 59 | + (loop [sent 0] | |
| 60 | + (when (< sent len) | |
| 61 | + (let [n (socket/c-send fd p (- len sent) 0)] | |
| 62 | + (when (neg? n) (throw (ex-info "send failed" {:fd fd}))) | |
| 63 | + (recur (+ sent n)))))))) | |
| 64 | + | |
| 65 | +(defn- write! | |
| 66 | + "Bytes out, whichever transport this is." | |
| 67 | + [conn text] | |
| 68 | + (if (= :tls (:kind conn)) | |
| 69 | + (tls/tls-write (:tls conn) (.getBytes text)) | |
| 70 | + (send-all! (:fd conn) text))) | |
| 71 | + | |
| 72 | +(defn- read-chunk! | |
| 73 | + "Block for the next chunk as a string, or nil at end of stream." | |
| 74 | + [conn] | |
| 75 | + (if (= :tls (:kind conn)) | |
| 76 | + (let [b (try (tls/tls-read (:tls conn)) (catch Exception _ nil))] | |
| 77 | + (when (and b (pos? (count b))) (String. b))) | |
| 78 | + (let [buf (:buf conn) | |
| 79 | + n (try (socket/c-recv (:fd conn) buf buffer-size 0) (catch Exception _ -1))] | |
| 80 | + (when (and n (pos? n)) (String. (ffi/read-bytes buf n)))))) | |
| 81 | + | |
| 82 | +(defn send-line! | |
| 83 | + "Send a raw IRC line. Safe from any thread; writes are serialised by the lock." | |
| 84 | + [conn line] | |
| 85 | + (locking (:lock conn) | |
| 86 | + (write! conn (str line "\r\n")))) | |
| 87 | + | |
| 88 | +(defn- reader-loop! | |
| 89 | + "Read bytes until the socket closes, splitting on CRLF and dispatching each | |
| 90 | + complete line. PING is answered here so a dropped UI never times the link | |
| 91 | + out; everything else goes to `on-msg`." | |
| 92 | + [conn on-msg] | |
| 93 | + (let [] | |
| 94 | + (loop [acc ""] | |
| 95 | + (let [chunk (read-chunk! conn)] | |
| 96 | + (if (nil? chunk) | |
| 97 | + (on-msg {:command "*DISCONNECTED*" :params []}) | |
| 98 | + (let [acc (str acc chunk) | |
| 99 | + lines (str/split acc #"\r?\n" -1) | |
| 100 | + complete (butlast lines)] | |
| 101 | + (doseq [line complete :when (seq (str/trim line))] | |
| 102 | + (let [msg (parse-line line)] | |
| 103 | + (when (= "PING" (:command msg)) | |
| 104 | + (send-line! conn (str "PONG :" (first (:params msg))))) | |
| 105 | + (on-msg (assoc msg :raw line)))) | |
| 106 | + (recur (last lines)))))))) | |
| 107 | + | |
| 108 | +(defn- open | |
| 109 | + "Dial `host`:`port`, over TLS unless `tls?` is false." | |
| 110 | + [host port tls? nick] | |
| 111 | + (if tls? | |
| 112 | + (do (tls/ensure-native!) | |
| 113 | + {:kind :tls :tls (tls/tls-connect host (int port)) | |
| 114 | + :lock (Object.) :nick nick}) | |
| 115 | + (let [ip (#'socket/ip->str (socket/resolve-host host)) | |
| 116 | + fd (socket/c-socket af-inet sock-stream 0)] | |
| 117 | + (when (neg? fd) (throw (ex-info "socket() failed" {:host host}))) | |
| 118 | + (let [rc (socket/c-connect fd (#'socket/make-sockaddr-in ip (int port)) 16)] | |
| 119 | + (when (neg? rc) | |
| 120 | + (socket/c-close fd) | |
| 121 | + (throw (ex-info "connect() failed" {:host host :ip ip :port port})))) | |
| 122 | + {:kind :plain :fd fd :buf (ffi/alloc buffer-size) | |
| 123 | + :lock (Object.) :nick nick}))) | |
| 124 | + | |
| 125 | +(defn connect! | |
| 126 | + "Open a connection, register as a guest `nick`, and start the reader. | |
| 127 | + `tls?` defaults to true — freeq's TLS listener is :6697, plain is :6667." | |
| 128 | + ([host port nick on-msg] (connect! host port nick on-msg true)) | |
| 129 | + ([host port nick on-msg tls?] | |
| 130 | + (let [conn (open host port tls? nick)] | |
| 131 | + (future | |
| 132 | + (try (reader-loop! conn on-msg) | |
| 133 | + (catch Exception e | |
| 134 | + (on-msg {:command "*ERROR*" :params [(str e)]})))) | |
| 135 | + (send-line! conn (str "NICK " nick)) | |
| 136 | + (send-line! conn (str "USER " nick " 0 * :" nick)) | |
| 137 | + conn))) | |
| 138 | + | |
| 139 | +(defn join! [conn channel] (send-line! conn (str "JOIN " channel))) | |
| 140 | +(defn part! [conn channel] (send-line! conn (str "PART " channel))) | |
| 141 | + | |
| 142 | +(defn privmsg! [conn target text] | |
| 143 | + (send-line! conn (str "PRIVMSG " target " :" text))) | |
| 144 | + | |
| 145 | +(defn close! [conn] | |
| 146 | + (try (send-line! conn "QUIT :frq") (catch Exception _ nil)) | |
| 147 | + (try (if (= :tls (:kind conn)) | |
| 148 | + (tls/tls-close (:tls conn)) | |
| 149 | + (do (socket/c-close (:fd conn)) | |
| 150 | + (ffi/free (:buf conn)))) | |
| 151 | + (catch Exception _ nil))) | |
| new file mode 100644 | |||
| @@ -0,0 +1,151 @@ | |||
| 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 | + [jolt.ffi :as ffi] | ||
| 19 | + [jolt.mvn-http :as tls] | ||
| 20 | + [jolt.socket :as socket])) | ||
| 21 | + | ||
| 22 | +(def ^:private af-inet 2) | ||
| 23 | +(def ^:private sock-stream 1) | ||
| 24 | +(def ^:private buffer-size 8192) | ||
| 25 | + | ||
| 26 | +;; ---------------------------------------------------------------- parsing | ||
| 27 | + | ||
| 28 | +(defn parse-line | ||
| 29 | + "An IRC line into {:prefix :command :params}. The trailing parameter (after | ||
| 30 | + \" :\") keeps its spaces; everything before it splits on whitespace." | ||
| 31 | + [line] | ||
| 32 | + (let [line (str/trimr line) | ||
| 33 | + [prefix rest-line] (if (str/starts-with? line ":") | ||
| 34 | + (let [i (str/index-of line " ")] | ||
| 35 | + [(subs line 1 i) (subs line (inc i))]) | ||
| 36 | + [nil line]) | ||
| 37 | + i (str/index-of rest-line " :") | ||
| 38 | + head (if i (subs rest-line 0 i) rest-line) | ||
| 39 | + trailing (when i (subs rest-line (+ i 2))) | ||
| 40 | + parts (remove str/blank? (str/split head #" "))] | ||
| 41 | + {:prefix prefix | ||
| 42 | + :command (str/upper-case (or (first parts) "")) | ||
| 43 | + :params (cond-> (vec (rest parts)) trailing (conj trailing))})) | ||
| 44 | + | ||
| 45 | +(defn nick-of | ||
| 46 | + "The nick half of a `nick!user@host` prefix." | ||
| 47 | + [prefix] | ||
| 48 | + (when prefix | ||
| 49 | + (let [i (str/index-of prefix "!")] | ||
| 50 | + (if i (subs prefix 0 i) prefix)))) | ||
| 51 | + | ||
| 52 | +;; ---------------------------------------------------------------- transport | ||
| 53 | + | ||
| 54 | +(defn- send-all! | ||
| 55 | + "send(2) until the whole string is gone — a short write is not an error." | ||
| 56 | + [fd text] | ||
| 57 | + (let [len (count (.getBytes text))] | ||
| 58 | + (ffi/with-c-string [p text] | ||
| 59 | + (loop [sent 0] | ||
| 60 | + (when (< sent len) | ||
| 61 | + (let [n (socket/c-send fd p (- len sent) 0)] | ||
| 62 | + (when (neg? n) (throw (ex-info "send failed" {:fd fd}))) | ||
| 63 | + (recur (+ sent n)))))))) | ||
| 64 | + | ||
| 65 | +(defn- write! | ||
| 66 | + "Bytes out, whichever transport this is." | ||
| 67 | + [conn text] | ||
| 68 | + (if (= :tls (:kind conn)) | ||
| 69 | + (tls/tls-write (:tls conn) (.getBytes text)) | ||
| 70 | + (send-all! (:fd conn) text))) | ||
| 71 | + | ||
| 72 | +(defn- read-chunk! | ||
| 73 | + "Block for the next chunk as a string, or nil at end of stream." | ||
| 74 | + [conn] | ||
| 75 | + (if (= :tls (:kind conn)) | ||
| 76 | + (let [b (try (tls/tls-read (:tls conn)) (catch Exception _ nil))] | ||
| 77 | + (when (and b (pos? (count b))) (String. b))) | ||
| 78 | + (let [buf (:buf conn) | ||
| 79 | + n (try (socket/c-recv (:fd conn) buf buffer-size 0) (catch Exception _ -1))] | ||
| 80 | + (when (and n (pos? n)) (String. (ffi/read-bytes buf n)))))) | ||
| 81 | + | ||
| 82 | +(defn send-line! | ||
| 83 | + "Send a raw IRC line. Safe from any thread; writes are serialised by the lock." | ||
| 84 | + [conn line] | ||
| 85 | + (locking (:lock conn) | ||
| 86 | + (write! conn (str line "\r\n")))) | ||
| 87 | + | ||
| 88 | +(defn- reader-loop! | ||
| 89 | + "Read bytes until the socket closes, splitting on CRLF and dispatching each | ||
| 90 | + complete line. PING is answered here so a dropped UI never times the link | ||
| 91 | + out; everything else goes to `on-msg`." | ||
| 92 | + [conn on-msg] | ||
| 93 | + (let [] | ||
| 94 | + (loop [acc ""] | ||
| 95 | + (let [chunk (read-chunk! conn)] | ||
| 96 | + (if (nil? chunk) | ||
| 97 | + (on-msg {:command "*DISCONNECTED*" :params []}) | ||
| 98 | + (let [acc (str acc chunk) | ||
| 99 | + lines (str/split acc #"\r?\n" -1) | ||
| 100 | + complete (butlast lines)] | ||
| 101 | + (doseq [line complete :when (seq (str/trim line))] | ||
| 102 | + (let [msg (parse-line line)] | ||
| 103 | + (when (= "PING" (:command msg)) | ||
| 104 | + (send-line! conn (str "PONG :" (first (:params msg))))) | ||
| 105 | + (on-msg (assoc msg :raw line)))) | ||
| 106 | + (recur (last lines)))))))) | ||
| 107 | + | ||
| 108 | +(defn- open | ||
| 109 | + "Dial `host`:`port`, over TLS unless `tls?` is false." | ||
| 110 | + [host port tls? nick] | ||
| 111 | + (if tls? | ||
| 112 | + (do (tls/ensure-native!) | ||
| 113 | + {:kind :tls :tls (tls/tls-connect host (int port)) | ||
| 114 | + :lock (Object.) :nick nick}) | ||
| 115 | + (let [ip (#'socket/ip->str (socket/resolve-host host)) | ||
| 116 | + fd (socket/c-socket af-inet sock-stream 0)] | ||
| 117 | + (when (neg? fd) (throw (ex-info "socket() failed" {:host host}))) | ||
| 118 | + (let [rc (socket/c-connect fd (#'socket/make-sockaddr-in ip (int port)) 16)] | ||
| 119 | + (when (neg? rc) | ||
| 120 | + (socket/c-close fd) | ||
| 121 | + (throw (ex-info "connect() failed" {:host host :ip ip :port port})))) | ||
| 122 | + {:kind :plain :fd fd :buf (ffi/alloc buffer-size) | ||
| 123 | + :lock (Object.) :nick nick}))) | ||
| 124 | + | ||
| 125 | +(defn connect! | ||
| 126 | + "Open a connection, register as a guest `nick`, and start the reader. | ||
| 127 | + `tls?` defaults to true — freeq's TLS listener is :6697, plain is :6667." | ||
| 128 | + ([host port nick on-msg] (connect! host port nick on-msg true)) | ||
| 129 | + ([host port nick on-msg tls?] | ||
| 130 | + (let [conn (open host port tls? nick)] | ||
| 131 | + (future | ||
| 132 | + (try (reader-loop! conn on-msg) | ||
| 133 | + (catch Exception e | ||
| 134 | + (on-msg {:command "*ERROR*" :params [(str e)]})))) | ||
| 135 | + (send-line! conn (str "NICK " nick)) | ||
| 136 | + (send-line! conn (str "USER " nick " 0 * :" nick)) | ||
| 137 | + conn))) | ||
| 138 | + | ||
| 139 | +(defn join! [conn channel] (send-line! conn (str "JOIN " channel))) | ||
| 140 | +(defn part! [conn channel] (send-line! conn (str "PART " channel))) | ||
| 141 | + | ||
| 142 | +(defn privmsg! [conn target text] | ||
| 143 | + (send-line! conn (str "PRIVMSG " target " :" text))) | ||
| 144 | + | ||
| 145 | +(defn close! [conn] | ||
| 146 | + (try (send-line! conn "QUIT :frq") (catch Exception _ nil)) | ||
| 147 | + (try (if (= :tls (:kind conn)) | ||
| 148 | + (tls/tls-close (:tls conn)) | ||
| 149 | + (do (socket/c-close (:fd conn)) | ||
| 150 | + (ffi/free (:buf conn)))) | ||
| 151 | + (catch Exception _ nil))) | ||
added
src/frq/state.jolt +209 -0 | new file mode 100644 | ||
| @@ -0,0 +1,209 @@ | ||
| 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 | + [glimmer.ratom :as r :refer [atom]] | |
| 9 | + [frq.irc :as irc])) | |
| 10 | + | |
| 11 | +(def default-host "irc.freeq.at") | |
| 12 | +(def default-port "6697") | |
| 13 | + | |
| 14 | +(def popular-channels | |
| 15 | + [["#general" "General discussion"] | |
| 16 | + ["#test" "Test channel"] | |
| 17 | + ["#freeq" "freeq development & support"] | |
| 18 | + ["#dev" "Programming & technology"] | |
| 19 | + ["#music" "Music recommendations"] | |
| 20 | + ["#random" "Off-topic chat"]]) | |
| 21 | + | |
| 22 | +;; screen: :connect | :chats | :chat | :discover | :settings | |
| 23 | +(defonce screen (atom :connect)) | |
| 24 | +(defonce conn (atom nil)) | |
| 25 | +(defonce status (atom "Not connected")) | |
| 26 | +(defonce error (atom nil)) | |
| 27 | +(defonce connecting? (atom false)) | |
| 28 | + | |
| 29 | +(defonce form-host (atom default-host)) | |
| 30 | +(defonce form-port (atom default-port)) | |
| 31 | +;; TLS is the default; untick it for a server's plain :6667 listener | |
| 32 | +(defonce form-tls? (atom true)) | |
| 33 | +(defonce form-nick (atom "frq-guest")) | |
| 34 | + | |
| 35 | +;; joined as soon as the server sends 001 | |
| 36 | +(def auto-join "#test") | |
| 37 | + | |
| 38 | +;; name -> {:name :messages [{:from :text}] :unread n :joined? bool} | |
| 39 | +(defonce channels (atom {})) | |
| 40 | +(defonce current (atom nil)) | |
| 41 | +(defonce draft (atom "")) | |
| 42 | +(defonce join-input (atom "")) | |
| 43 | +(defonce search (atom "")) | |
| 44 | + | |
| 45 | +(defn connected? [] (some? @conn)) | |
| 46 | + | |
| 47 | +(defn normalize-channel [s] | |
| 48 | + (let [s (str/trim (or s ""))] | |
| 49 | + (cond (str/blank? s) "" | |
| 50 | + (str/starts-with? s "#") s | |
| 51 | + :else (str "#" s)))) | |
| 52 | + | |
| 53 | +(defn- ensure-channel [m name] | |
| 54 | + (if (contains? m name) | |
| 55 | + m | |
| 56 | + (assoc m name {:name name :messages [] :unread 0 :joined? false}))) | |
| 57 | + | |
| 58 | +(defn push-message! | |
| 59 | + "Append a line to a buffer, creating it if needed, and bump the unread count | |
| 60 | + unless that buffer is the one on screen." | |
| 61 | + [channel from text] | |
| 62 | + (swap! channels | |
| 63 | + (fn [m] | |
| 64 | + (let [m (ensure-channel m channel) | |
| 65 | + viewing? (and (= :chat @screen) (= channel @current))] | |
| 66 | + (-> m | |
| 67 | + (update-in [channel :messages] conj {:from from :text text}) | |
| 68 | + (update-in [channel :unread] (if viewing? (constantly 0) inc))))))) | |
| 69 | + | |
| 70 | +(defn open-channel! | |
| 71 | + "Show a buffer. A channel we are not in is joined on the way — a row can | |
| 72 | + outlive the membership behind it (a disconnect drops every channel, the | |
| 73 | + buffer stays), and opening one is a request to be in it." | |
| 74 | + [name] | |
| 75 | + (reset! current name) | |
| 76 | + (reset! screen :chat) | |
| 77 | + (swap! channels #(assoc-in (ensure-channel % name) [name :unread] 0)) | |
| 78 | + (when (and @conn | |
| 79 | + (str/starts-with? name "#") | |
| 80 | + (not (get-in @channels [name :joined?]))) | |
| 81 | + (irc/join! @conn name))) | |
| 82 | + | |
| 83 | +(declare join!) | |
| 84 | + | |
| 85 | +(defn apply-msg! | |
| 86 | + "Fold one parsed IRC message into the state." | |
| 87 | + [msg] | |
| 88 | + (let [{:keys [command params prefix]} msg | |
| 89 | + from (irc/nick-of prefix)] | |
| 90 | + (case command | |
| 91 | + "001" (do (reset! status (str "Connected as " @form-nick)) | |
| 92 | + (reset! connecting? false) | |
| 93 | + (reset! screen :chats) | |
| 94 | + (join! auto-join)) | |
| 95 | + "PRIVMSG" (let [[target text] params | |
| 96 | + ;; a DM addressed to us belongs in a buffer named for the | |
| 97 | + ;; sender, not for our own nick | |
| 98 | + buffer (if (str/starts-with? (or target "") "#") target from)] | |
| 99 | + (push-message! buffer from text)) | |
| 100 | + "JOIN" (let [ch (first params)] | |
| 101 | + (if (= from @form-nick) | |
| 102 | + (do (swap! channels #(-> (ensure-channel % ch) | |
| 103 | + (assoc-in [ch :joined?] true))) | |
| 104 | + (push-message! ch "*" (str "You joined " ch))) | |
| 105 | + (push-message! ch "*" (str from " joined")))) | |
| 106 | + "PART" (let [ch (first params)] | |
| 107 | + (if (= from @form-nick) | |
| 108 | + (swap! channels #(assoc-in % [ch :joined?] false)) | |
| 109 | + (push-message! ch "*" (str from " left")))) | |
| 110 | + "QUIT" nil | |
| 111 | + ("NOTICE" "372" "375" "376" "002" "003" "004") | |
| 112 | + (reset! status (or (last params) @status)) | |
| 113 | + ("473" "474" "475" "477" "403" "471") | |
| 114 | + (reset! error (str "Cannot join: " (last params))) | |
| 115 | + "*DISCONNECTED*" (do (reset! conn nil) | |
| 116 | + (reset! connecting? false) | |
| 117 | + (swap! channels | |
| 118 | + #(reduce-kv (fn [m k v] (assoc m k (assoc v :joined? false))) | |
| 119 | + {} %)) | |
| 120 | + (reset! status "Disconnected")) | |
| 121 | + "*ERROR*" (do (reset! error (first params)) | |
| 122 | + (reset! connecting? false)) | |
| 123 | + nil))) | |
| 124 | + | |
| 125 | +(def plain-port 6667) | |
| 126 | + | |
| 127 | +(defn- describe | |
| 128 | + "What went wrong, in words. A jolt condition prints as #object[:object], so | |
| 129 | + the message and the ex-data are what has to be dug out by hand." | |
| 130 | + [e] | |
| 131 | + (let [msg (or (ex-message e) (str e)) | |
| 132 | + data (ex-data e)] | |
| 133 | + (str (type e) ": " msg (when data (str " " (pr-str data)))))) | |
| 134 | + | |
| 135 | +(defn- dial! [host port nick tls?] | |
| 136 | + ;; stderr is the only console on Android — this line lands in logcat. | |
| 137 | + (binding [*out* *err*] | |
| 138 | + (println "frq: dialing" host port (if tls? "tls" "plain"))) | |
| 139 | + (reset! conn (irc/connect! host port nick apply-msg! tls?))) | |
| 140 | + | |
| 141 | +(defn connect! | |
| 142 | + "Dial, and fall back to the plain listener when TLS cannot be had. Android is | |
| 143 | + the case that needs this: jolt's TLS is OpenSSL through the dynamic loader, | |
| 144 | + and there is no public libssl there to load." | |
| 145 | + [] | |
| 146 | + (reset! error nil) | |
| 147 | + (reset! connecting? true) | |
| 148 | + (reset! status (str "Connecting to " @form-host ":" @form-port "…")) | |
| 149 | + (let [host @form-host | |
| 150 | + port (parse-long (str/trim @form-port)) | |
| 151 | + nick (str/trim @form-nick)] | |
| 152 | + (try | |
| 153 | + (dial! host port nick @form-tls?) | |
| 154 | + (catch Exception e | |
| 155 | + (binding [*out* *err*] (println "frq: dial failed:" (describe e))) | |
| 156 | + (if @form-tls? | |
| 157 | + (do (reset! status (str "TLS unavailable — trying " host ":" plain-port "…")) | |
| 158 | + (try | |
| 159 | + (dial! host plain-port nick false) | |
| 160 | + (reset! form-tls? false) | |
| 161 | + (reset! form-port (str plain-port)) | |
| 162 | + (catch Exception e2 | |
| 163 | + (binding [*out* *err*] (println "frq: plain dial failed:" (describe e2))) | |
| 164 | + (reset! connecting? false) | |
| 165 | + (reset! conn nil) | |
| 166 | + (reset! status "Not connected") | |
| 167 | + (reset! error (str "Could not connect: " (describe e2)))))) | |
| 168 | + (do (reset! connecting? false) | |
| 169 | + (reset! conn nil) | |
| 170 | + (reset! status "Not connected") | |
| 171 | + (reset! error (str "Could not connect: " (describe e))))))))) | |
| 172 | + | |
| 173 | +(defn disconnect! [] | |
| 174 | + (when-let [c @conn] (irc/close! c)) | |
| 175 | + (reset! conn nil) | |
| 176 | + ;; The buffers survive, the memberships do not — leaving `joined?` set would | |
| 177 | + ;; have the next Open show a channel nobody is in. | |
| 178 | + (swap! channels #(reduce-kv (fn [m k v] (assoc m k (assoc v :joined? false))) {} %)) | |
| 179 | + (reset! status "Not connected") | |
| 180 | + (reset! screen :connect)) | |
| 181 | + | |
| 182 | +(defn join! [name] | |
| 183 | + (let [ch (normalize-channel name)] | |
| 184 | + (when (seq ch) | |
| 185 | + (reset! error nil) | |
| 186 | + (open-channel! ch)))) | |
| 187 | + | |
| 188 | +(defn send-draft! [] | |
| 189 | + (let [text (str/trim @draft) | |
| 190 | + target @current] | |
| 191 | + (when (and (seq text) target) | |
| 192 | + (when-let [c @conn] (irc/privmsg! c target text)) | |
| 193 | + (push-message! target @form-nick text) | |
| 194 | + (reset! draft "")))) | |
| 195 | + | |
| 196 | +(defn channel-list | |
| 197 | + "Buffers in name order, filtered by the search box." | |
| 198 | + [] | |
| 199 | + (let [q (str/lower-case (str/trim @search))] | |
| 200 | + (->> (vals @channels) | |
| 201 | + (filter #(or (str/blank? q) | |
| 202 | + (str/includes? (str/lower-case (:name %)) q))) | |
| 203 | + (sort-by :name) | |
| 204 | + vec))) | |
| 205 | + | |
| 206 | +(defn last-preview [buffer] | |
| 207 | + (if-let [m (last (:messages buffer))] | |
| 208 | + (str (:from m) ": " (:text m)) | |
| 209 | + "No messages yet")) | |
| new file mode 100644 | |||
| @@ -0,0 +1,209 @@ | |||
| 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 | + [glimmer.ratom :as r :refer [atom]] | ||
| 9 | + [frq.irc :as irc])) | ||
| 10 | + | ||
| 11 | +(def default-host "irc.freeq.at") | ||
| 12 | +(def default-port "6697") | ||
| 13 | + | ||
| 14 | +(def popular-channels | ||
| 15 | + [["#general" "General discussion"] | ||
| 16 | + ["#test" "Test channel"] | ||
| 17 | + ["#freeq" "freeq development & support"] | ||
| 18 | + ["#dev" "Programming & technology"] | ||
| 19 | + ["#music" "Music recommendations"] | ||
| 20 | + ["#random" "Off-topic chat"]]) | ||
| 21 | + | ||
| 22 | +;; screen: :connect | :chats | :chat | :discover | :settings | ||
| 23 | +(defonce screen (atom :connect)) | ||
| 24 | +(defonce conn (atom nil)) | ||
| 25 | +(defonce status (atom "Not connected")) | ||
| 26 | +(defonce error (atom nil)) | ||
| 27 | +(defonce connecting? (atom false)) | ||
| 28 | + | ||
| 29 | +(defonce form-host (atom default-host)) | ||
| 30 | +(defonce form-port (atom default-port)) | ||
| 31 | +;; TLS is the default; untick it for a server's plain :6667 listener | ||
| 32 | +(defonce form-tls? (atom true)) | ||
| 33 | +(defonce form-nick (atom "frq-guest")) | ||
| 34 | + | ||
| 35 | +;; joined as soon as the server sends 001 | ||
| 36 | +(def auto-join "#test") | ||
| 37 | + | ||
| 38 | +;; name -> {:name :messages [{:from :text}] :unread n :joined? bool} | ||
| 39 | +(defonce channels (atom {})) | ||
| 40 | +(defonce current (atom nil)) | ||
| 41 | +(defonce draft (atom "")) | ||
| 42 | +(defonce join-input (atom "")) | ||
| 43 | +(defonce search (atom "")) | ||
| 44 | + | ||
| 45 | +(defn connected? [] (some? @conn)) | ||
| 46 | + | ||
| 47 | +(defn normalize-channel [s] | ||
| 48 | + (let [s (str/trim (or s ""))] | ||
| 49 | + (cond (str/blank? s) "" | ||
| 50 | + (str/starts-with? s "#") s | ||
| 51 | + :else (str "#" s)))) | ||
| 52 | + | ||
| 53 | +(defn- ensure-channel [m name] | ||
| 54 | + (if (contains? m name) | ||
| 55 | + m | ||
| 56 | + (assoc m name {:name name :messages [] :unread 0 :joined? false}))) | ||
| 57 | + | ||
| 58 | +(defn push-message! | ||
| 59 | + "Append a line to a buffer, creating it if needed, and bump the unread count | ||
| 60 | + unless that buffer is the one on screen." | ||
| 61 | + [channel from text] | ||
| 62 | + (swap! channels | ||
| 63 | + (fn [m] | ||
| 64 | + (let [m (ensure-channel m channel) | ||
| 65 | + viewing? (and (= :chat @screen) (= channel @current))] | ||
| 66 | + (-> m | ||
| 67 | + (update-in [channel :messages] conj {:from from :text text}) | ||
| 68 | + (update-in [channel :unread] (if viewing? (constantly 0) inc))))))) | ||
| 69 | + | ||
| 70 | +(defn open-channel! | ||
| 71 | + "Show a buffer. A channel we are not in is joined on the way — a row can | ||
| 72 | + outlive the membership behind it (a disconnect drops every channel, the | ||
| 73 | + buffer stays), and opening one is a request to be in it." | ||
| 74 | + [name] | ||
| 75 | + (reset! current name) | ||
| 76 | + (reset! screen :chat) | ||
| 77 | + (swap! channels #(assoc-in (ensure-channel % name) [name :unread] 0)) | ||
| 78 | + (when (and @conn | ||
| 79 | + (str/starts-with? name "#") | ||
| 80 | + (not (get-in @channels [name :joined?]))) | ||
| 81 | + (irc/join! @conn name))) | ||
| 82 | + | ||
| 83 | +(declare join!) | ||
| 84 | + | ||
| 85 | +(defn apply-msg! | ||
| 86 | + "Fold one parsed IRC message into the state." | ||
| 87 | + [msg] | ||
| 88 | + (let [{:keys [command params prefix]} msg | ||
| 89 | + from (irc/nick-of prefix)] | ||
| 90 | + (case command | ||
| 91 | + "001" (do (reset! status (str "Connected as " @form-nick)) | ||
| 92 | + (reset! connecting? false) | ||
| 93 | + (reset! screen :chats) | ||
| 94 | + (join! auto-join)) | ||
| 95 | + "PRIVMSG" (let [[target text] params | ||
| 96 | + ;; a DM addressed to us belongs in a buffer named for the | ||
| 97 | + ;; sender, not for our own nick | ||
| 98 | + buffer (if (str/starts-with? (or target "") "#") target from)] | ||
| 99 | + (push-message! buffer from text)) | ||
| 100 | + "JOIN" (let [ch (first params)] | ||
| 101 | + (if (= from @form-nick) | ||
| 102 | + (do (swap! channels #(-> (ensure-channel % ch) | ||
| 103 | + (assoc-in [ch :joined?] true))) | ||
| 104 | + (push-message! ch "*" (str "You joined " ch))) | ||
| 105 | + (push-message! ch "*" (str from " joined")))) | ||
| 106 | + "PART" (let [ch (first params)] | ||
| 107 | + (if (= from @form-nick) | ||
| 108 | + (swap! channels #(assoc-in % [ch :joined?] false)) | ||
| 109 | + (push-message! ch "*" (str from " left")))) | ||
| 110 | + "QUIT" nil | ||
| 111 | + ("NOTICE" "372" "375" "376" "002" "003" "004") | ||
| 112 | + (reset! status (or (last params) @status)) | ||
| 113 | + ("473" "474" "475" "477" "403" "471") | ||
| 114 | + (reset! error (str "Cannot join: " (last params))) | ||
| 115 | + "*DISCONNECTED*" (do (reset! conn nil) | ||
| 116 | + (reset! connecting? false) | ||
| 117 | + (swap! channels | ||
| 118 | + #(reduce-kv (fn [m k v] (assoc m k (assoc v :joined? false))) | ||
| 119 | + {} %)) | ||
| 120 | + (reset! status "Disconnected")) | ||
| 121 | + "*ERROR*" (do (reset! error (first params)) | ||
| 122 | + (reset! connecting? false)) | ||
| 123 | + nil))) | ||
| 124 | + | ||
| 125 | +(def plain-port 6667) | ||
| 126 | + | ||
| 127 | +(defn- describe | ||
| 128 | + "What went wrong, in words. A jolt condition prints as #object[:object], so | ||
| 129 | + the message and the ex-data are what has to be dug out by hand." | ||
| 130 | + [e] | ||
| 131 | + (let [msg (or (ex-message e) (str e)) | ||
| 132 | + data (ex-data e)] | ||
| 133 | + (str (type e) ": " msg (when data (str " " (pr-str data)))))) | ||
| 134 | + | ||
| 135 | +(defn- dial! [host port nick tls?] | ||
| 136 | + ;; stderr is the only console on Android — this line lands in logcat. | ||
| 137 | + (binding [*out* *err*] | ||
| 138 | + (println "frq: dialing" host port (if tls? "tls" "plain"))) | ||
| 139 | + (reset! conn (irc/connect! host port nick apply-msg! tls?))) | ||
| 140 | + | ||
| 141 | +(defn connect! | ||
| 142 | + "Dial, and fall back to the plain listener when TLS cannot be had. Android is | ||
| 143 | + the case that needs this: jolt's TLS is OpenSSL through the dynamic loader, | ||
| 144 | + and there is no public libssl there to load." | ||
| 145 | + [] | ||
| 146 | + (reset! error nil) | ||
| 147 | + (reset! connecting? true) | ||
| 148 | + (reset! status (str "Connecting to " @form-host ":" @form-port "…")) | ||
| 149 | + (let [host @form-host | ||
| 150 | + port (parse-long (str/trim @form-port)) | ||
| 151 | + nick (str/trim @form-nick)] | ||
| 152 | + (try | ||
| 153 | + (dial! host port nick @form-tls?) | ||
| 154 | + (catch Exception e | ||
| 155 | + (binding [*out* *err*] (println "frq: dial failed:" (describe e))) | ||
| 156 | + (if @form-tls? | ||
| 157 | + (do (reset! status (str "TLS unavailable — trying " host ":" plain-port "…")) | ||
| 158 | + (try | ||
| 159 | + (dial! host plain-port nick false) | ||
| 160 | + (reset! form-tls? false) | ||
| 161 | + (reset! form-port (str plain-port)) | ||
| 162 | + (catch Exception e2 | ||
| 163 | + (binding [*out* *err*] (println "frq: plain dial failed:" (describe e2))) | ||
| 164 | + (reset! connecting? false) | ||
| 165 | + (reset! conn nil) | ||
| 166 | + (reset! status "Not connected") | ||
| 167 | + (reset! error (str "Could not connect: " (describe e2)))))) | ||
| 168 | + (do (reset! connecting? false) | ||
| 169 | + (reset! conn nil) | ||
| 170 | + (reset! status "Not connected") | ||
| 171 | + (reset! error (str "Could not connect: " (describe e))))))))) | ||
| 172 | + | ||
| 173 | +(defn disconnect! [] | ||
| 174 | + (when-let [c @conn] (irc/close! c)) | ||
| 175 | + (reset! conn nil) | ||
| 176 | + ;; The buffers survive, the memberships do not — leaving `joined?` set would | ||
| 177 | + ;; have the next Open show a channel nobody is in. | ||
| 178 | + (swap! channels #(reduce-kv (fn [m k v] (assoc m k (assoc v :joined? false))) {} %)) | ||
| 179 | + (reset! status "Not connected") | ||
| 180 | + (reset! screen :connect)) | ||
| 181 | + | ||
| 182 | +(defn join! [name] | ||
| 183 | + (let [ch (normalize-channel name)] | ||
| 184 | + (when (seq ch) | ||
| 185 | + (reset! error nil) | ||
| 186 | + (open-channel! ch)))) | ||
| 187 | + | ||
| 188 | +(defn send-draft! [] | ||
| 189 | + (let [text (str/trim @draft) | ||
| 190 | + target @current] | ||
| 191 | + (when (and (seq text) target) | ||
| 192 | + (when-let [c @conn] (irc/privmsg! c target text)) | ||
| 193 | + (push-message! target @form-nick text) | ||
| 194 | + (reset! draft "")))) | ||
| 195 | + | ||
| 196 | +(defn channel-list | ||
| 197 | + "Buffers in name order, filtered by the search box." | ||
| 198 | + [] | ||
| 199 | + (let [q (str/lower-case (str/trim @search))] | ||
| 200 | + (->> (vals @channels) | ||
| 201 | + (filter #(or (str/blank? q) | ||
| 202 | + (str/includes? (str/lower-case (:name %)) q))) | ||
| 203 | + (sort-by :name) | ||
| 204 | + vec))) | ||
| 205 | + | ||
| 206 | +(defn last-preview [buffer] | ||
| 207 | + (if-let [m (last (:messages buffer))] | ||
| 208 | + (str (:from m) ": " (:text m)) | ||
| 209 | + "No messages yet")) | ||