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

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

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>
nandi committed 2026-08-30T00:14:14-07:00 Browse files
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"))