nandi/frqpublic Fork 0
e80514e
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.

Reach irc.freeq.at over TLS from the phone

The thing the jolt APK could never do. jolt finds OpenSSL through the dynamic
loader and Android ships no public libssl, so :6697 was unreachable there —
sign-in was desktop-only and the connect screen fell back to the plain :6667
listener on its own. SecureSocket is in the Dart runtime: nothing to load,
nothing to find. It registers and reads the MOTD.

The parser moved rather than being rewritten. common/frq/irc/parse.cljc is
the wire format as text — parse-line, the tag escapes, nick-of — and it names
no host, so both compilers take it. frq.irc re-exports the five under its own
name, because irc/tag-value and irc/nick-of are read in twenty-three places
across frq.state and frq.av and none of them care which file it is in. The
desktop renders identically after the move.

The transport could not move, and that is the whole point of the split: jolt
has one future blocking on a read, which also has to flush the outbox because
the reader owns the connection; Dart has an event loop, so a socket is a
Stream and a write is a write. No thread, no outbox, no poll interval.

Three things bit, all of them cljd rather than Clojure:

LineSplitter throws at the first byte. Two transforms deep the stream is a
CastStream<String, dynamic> where it wants a Stream<String> — the generic is
lost through the dynamic call — so lines are cut by splitting the decoded
chunks on \n and keeping the tail. A chunk boundary falls wherever TCP puts
it, which is not where IRC put its lines.

`:watch` as a bare link in an f/run chain builds nothing to rebuild. The
cells changed and the screen did not, which looks exactly like a button that
does not fire; it is one f/widget with the render as its body now.

And a compile error inside a namespace only reports the ns form of whatever
required it — `clojure -M:cljd compile frq.net.dart` on its own said
"Unmatched delimiter" and a line number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T18:20:01-07:00 Browse files
e80514e parent: bc877e5
added common/frq/irc/parse.cljc +93 -0
new file mode 100644
@@ -0,0 +1,93 @@
1+(ns frq.irc.parse
2+ "The IRC wire format, as text. No socket, no host, no platform.
3+
4+ Split out of `frq.irc` so both compilers can have it: the transport under it
5+ is a blocking reader thread on the desktop and a `Stream` over
6+ `SecureSocket` on the phone, and neither of those is this. Everything here
7+ is a string going in and a map coming out, which is the half of IRC that is
8+ the same everywhere.
9+
10+ `frq.irc` still re-exports these under its own name, so callers that say
11+ `irc/tag-value` — and there are twenty-three of them in `frq.state` and
12+ `frq.av` — did not have to move."
13+ (:require [clojure.string :as str]))
14+
15+(defn parse-line
16+ "An IRC line into {:tags :prefix :command :params}. The trailing parameter
17+ (after \" :\") keeps its spaces; everything before it splits on whitespace.
18+
19+ IRCv3 tags come first when there are any. A connection that negotiates CAP
20+ gets them where a bare one does not — which is why a client that ignores them
21+ looks fine as a guest and goes silent once it authenticates."
22+ [line]
23+ (let [line (str/trimr line)
24+ [tags line] (if (str/starts-with? line "@")
25+ (let [i (str/index-of line " ")]
26+ [(subs line 1 i) (str/triml (subs line i))])
27+ [nil line])
28+ [prefix rest-line] (if (str/starts-with? line ":")
29+ (let [i (str/index-of line " ")]
30+ [(subs line 1 i) (subs line (inc i))])
31+ [nil line])
32+ i (str/index-of rest-line " :")
33+ head (if i (subs rest-line 0 i) rest-line)
34+ trailing (when i (subs rest-line (+ i 2)))
35+ parts (remove str/blank? (str/split head #" "))]
36+ {:tags tags
37+ :account (when tags
38+ (second (re-find #"(?:^|;)account=([^;]*)" tags)))
39+ :prefix prefix
40+ :command (str/upper-case (or (first parts) ""))
41+ :params (cond-> (vec (rest parts)) trailing (conj trailing))}))
42+
43+(defn unescape-tag
44+ "An IRCv3 tag value with its escapes undone.
45+
46+ `\\:` is a semicolon, `\\s` a space, and `\\\\`, `\\r` and `\\n` themselves —
47+ the escaping exists because `;` separates tags and a space ends them. It
48+ matters for any value that can contain either: a reaction tally is
49+ `emoji:nick;emoji:nick` on the wire and arrives with every one of those
50+ semicolons written `\\:`, so a reader that skips this step sees one tally
51+ where there were three, and counts to match."
52+ [v]
53+ (when v
54+ (loop [in (seq v) out []]
55+ (if-let [c (first in)]
56+ (if (and (= \\ c) (second in))
57+ (recur (drop 2 in)
58+ (conj out (case (second in)
59+ \: \;
60+ \s \space
61+ \r \return
62+ \n \newline
63+ (second in))))
64+ (recur (rest in) (conj out c)))
65+ (apply str out)))))
66+
67+(defn escape-tag-value
68+ "The inverse, for a tag this client sends. An emoji needs none of it; a
69+ message id could, and the cost of being right is a pass over a short string."
70+ [v]
71+ (-> (str v)
72+ (str/replace "\\" "\\\\")
73+ (str/replace ";" "\\:")
74+ (str/replace " " "\\s")
75+ (str/replace "\r" "\\r")
76+ (str/replace "\n" "\\n")))
77+
78+(defn tag-value
79+ "One IRCv3 tag's value, unescaped, or nil."
80+ [tags key]
81+ (when tags
82+ (some (fn [pair]
83+ (let [[k v] (str/split pair #"=" 2)]
84+ (when (= k key) (unescape-tag v))))
85+ (str/split tags #";"))))
86+
87+(defn nick-of
88+ "The nick half of a `nick!user@host` prefix."
89+ [prefix]
90+ (when prefix
91+ (let [i (str/index-of prefix "!")]
92+ (if i (subs prefix 0 i) prefix))))
93+
new file mode 100644
@@ -0,0 +1,93 @@
1+(ns frq.irc.parse
2+ "The IRC wire format, as text. No socket, no host, no platform.
3+
4+ Split out of `frq.irc` so both compilers can have it: the transport under it
5+ is a blocking reader thread on the desktop and a `Stream` over
6+ `SecureSocket` on the phone, and neither of those is this. Everything here
7+ is a string going in and a map coming out, which is the half of IRC that is
8+ the same everywhere.
9+
10+ `frq.irc` still re-exports these under its own name, so callers that say
11+ `irc/tag-value` — and there are twenty-three of them in `frq.state` and
12+ `frq.av` — did not have to move."
13+ (:require [clojure.string :as str]))
14+
15+(defn parse-line
16+ "An IRC line into {:tags :prefix :command :params}. The trailing parameter
17+ (after \" :\") keeps its spaces; everything before it splits on whitespace.
18+
19+ IRCv3 tags come first when there are any. A connection that negotiates CAP
20+ gets them where a bare one does not — which is why a client that ignores them
21+ looks fine as a guest and goes silent once it authenticates."
22+ [line]
23+ (let [line (str/trimr line)
24+ [tags line] (if (str/starts-with? line "@")
25+ (let [i (str/index-of line " ")]
26+ [(subs line 1 i) (str/triml (subs line i))])
27+ [nil line])
28+ [prefix rest-line] (if (str/starts-with? line ":")
29+ (let [i (str/index-of line " ")]
30+ [(subs line 1 i) (subs line (inc i))])
31+ [nil line])
32+ i (str/index-of rest-line " :")
33+ head (if i (subs rest-line 0 i) rest-line)
34+ trailing (when i (subs rest-line (+ i 2)))
35+ parts (remove str/blank? (str/split head #" "))]
36+ {:tags tags
37+ :account (when tags
38+ (second (re-find #"(?:^|;)account=([^;]*)" tags)))
39+ :prefix prefix
40+ :command (str/upper-case (or (first parts) ""))
41+ :params (cond-> (vec (rest parts)) trailing (conj trailing))}))
42+
43+(defn unescape-tag
44+ "An IRCv3 tag value with its escapes undone.
45+
46+ `\\:` is a semicolon, `\\s` a space, and `\\\\`, `\\r` and `\\n` themselves —
47+ the escaping exists because `;` separates tags and a space ends them. It
48+ matters for any value that can contain either: a reaction tally is
49+ `emoji:nick;emoji:nick` on the wire and arrives with every one of those
50+ semicolons written `\\:`, so a reader that skips this step sees one tally
51+ where there were three, and counts to match."
52+ [v]
53+ (when v
54+ (loop [in (seq v) out []]
55+ (if-let [c (first in)]
56+ (if (and (= \\ c) (second in))
57+ (recur (drop 2 in)
58+ (conj out (case (second in)
59+ \: \;
60+ \s \space
61+ \r \return
62+ \n \newline
63+ (second in))))
64+ (recur (rest in) (conj out c)))
65+ (apply str out)))))
66+
67+(defn escape-tag-value
68+ "The inverse, for a tag this client sends. An emoji needs none of it; a
69+ message id could, and the cost of being right is a pass over a short string."
70+ [v]
71+ (-> (str v)
72+ (str/replace "\\" "\\\\")
73+ (str/replace ";" "\\:")
74+ (str/replace " " "\\s")
75+ (str/replace "\r" "\\r")
76+ (str/replace "\n" "\\n")))
77+
78+(defn tag-value
79+ "One IRCv3 tag's value, unescaped, or nil."
80+ [tags key]
81+ (when tags
82+ (some (fn [pair]
83+ (let [[k v] (str/split pair #"=" 2)]
84+ (when (= k key) (unescape-tag v))))
85+ (str/split tags #";"))))
86+
87+(defn nick-of
88+ "The nick half of a `nick!user@host` prefix."
89+ [prefix]
90+ (when prefix
91+ (let [i (str/index-of prefix "!")]
92+ (if i (subs prefix 0 i) prefix))))
93+
modified flutter/README.md +8 -3
@@ -137,9 +137,14 @@ only tags `frq.app` uses, so it is a test of the backend and nothing more.
137137
138138 ## The order to do the rest in
139139
140-1. **`frq.irc`** (433) — the parser is pure; the reader is a blocking thread in
141- a `future` and becomes a `Stream` over `SecureSocket`. This is also what
142- makes sign-in work on a phone at all.
140+1. ~~**`frq.irc`**~~ — started. The parser is `common/frq/irc/parse.cljc` now,
141+ shared, with `frq.irc` re-exporting it so the twenty-three `irc/tag-value`
142+ and `irc/nick-of` call sites in `frq.state` and `frq.av` did not move. The
143+ transport is `frq.net.dart`: `SecureSocket`, a `Stream`, no thread and no
144+ outbox. **TLS reaches irc.freeq.at:6697 from the phone** — registration and
145+ MOTD, which is the thing the jolt APK could never do. What is left of this
146+ one is the protocol half: CAP, SASL and the idle-ping logic still live in
147+ `src/frq/irc.clj` and want `frq.msgsig` and `frq.atproto` under them first.
143148 2. **`frq.atproto`** (209), **`frq.oauth`** (182) — hand-rolled HTTPS over
144149 OpenSSL bindings today, `dart:io` and `package:http` here.
145150 3. **`frq.msgsig`** (268), **`frq.wire`** (81) — need a crypto seam beside the
@@ -137,9 +137,14 @@ only tags `frq.app` uses, so it is a test of the backend and nothing more.
137 137
138 ## The order to do the rest in138 ## The order to do the rest in
139 139
140-1. **`frq.irc`** (433) — the parser is pure; the reader is a blocking thread in140+1. ~~**`frq.irc`**~~ — started. The parser is `common/frq/irc/parse.cljc` now,
141- a `future` and becomes a `Stream` over `SecureSocket`. This is also what141+ shared, with `frq.irc` re-exporting it so the twenty-three `irc/tag-value`
142- makes sign-in work on a phone at all.142+ and `irc/nick-of` call sites in `frq.state` and `frq.av` did not move. The
143+ transport is `frq.net.dart`: `SecureSocket`, a `Stream`, no thread and no
144+ outbox. **TLS reaches irc.freeq.at:6697 from the phone** — registration and
145+ MOTD, which is the thing the jolt APK could never do. What is left of this
146+ one is the protocol half: CAP, SASL and the idle-ping logic still live in
147+ `src/frq/irc.clj` and want `frq.msgsig` and `frq.atproto` under them first.
143 2. **`frq.atproto`** (209), **`frq.oauth`** (182) — hand-rolled HTTPS over148 2. **`frq.atproto`** (209), **`frq.oauth`** (182) — hand-rolled HTTPS over
144 OpenSSL bindings today, `dart:io` and `package:http` here.149 OpenSSL bindings today, `dart:io` and `package:http` here.
145 3. **`frq.msgsig`** (268), **`frq.wire`** (81) — need a crypto seam beside the150 3. **`frq.msgsig`** (268), **`frq.wire`** (81) — need a crypto seam beside the
modified flutter/android/app/src/main/AndroidManifest.xml +4 -0
@@ -1,4 +1,8 @@
11 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+ <!-- The Flutter template declares this only in the debug manifest, where
3+ it is there for the hot-reload VM service. frq is an IRC client: it
4+ needs the network in a release build too. -->
5+ <uses-permission android:name="android.permission.INTERNET"/>
26 <application
37 android:label="frq"
48 android:name="${applicationName}"
@@ -1,4 +1,8 @@
1 <manifest xmlns:android="http://schemas.android.com/apk/res/android">1 <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+ <!-- The Flutter template declares this only in the debug manifest, where
3+ it is there for the hot-reload VM service. frq is an IRC client: it
4+ needs the network in a release build too. -->
5+ <uses-permission android:name="android.permission.INTERNET"/>
2 <application6 <application
3 android:label="frq"7 android:label="frq"
4 android:name="${applicationName}"8 android:name="${applicationName}"
modified flutter/src/frq/main.cljd +64 -28
@@ -21,45 +21,76 @@
2121 [cljd.flutter :as f]
2222 [frq.hiccup :as h]
2323 [frq.io.dart :as host]
24+ [frq.net.dart :as net]
2425 [frq.clock :as clock]
2526 [frq.store :as store]))
2627
27-(defonce ^:private tick (atom 0))
28+(defonce ^:private lines (atom []))
29+(defonce ^:private status (atom "not connected"))
30+(defonce ^:private conn (atom nil))
2831
29-(defn- demo
30- "A hiccup tree in glimmer's tags, rendered by `frq.hiccup`.
32+(def ^:private host-name "irc.freeq.at")
33+(def ^:private host-port 6697)
3134
32- Hand-written, and that is the point of it for now: `frq.app`'s own screens
33- cannot be required here yet `frq.state` reaches for jolt.host through
34- frq.irc and everything under it so this exercises the tags they are built
35- from until that chain is portable. Every tag here is one `frq.app` uses, and
36- none of it is Flutter."
35+(defn- note! [m]
36+ ;; Bounded, because a backlog is hundreds of lines and this is a proof
37+ ;; rather than a message list `frq.state/apply-msg!` is what turns these
38+ ;; into rooms, and it is not portable yet.
39+ (swap! lines (fn [v] (vec (take-last 14 (conj v m))))))
40+
41+(defn ^:async connect! []
42+ (reset! status (str "connecting to " host-name ":" host-port " over TLS…"))
43+ (reset! lines [])
44+ (try
45+ (let [sock (await (net/connect!
46+ {:host host-name
47+ :port host-port
48+ :tls? true
49+ :on-msg (fn [m]
50+ (note! m)
51+ (when (= "001" (:command m))
52+ (reset! status "registered")))
53+ :on-close (fn [why]
54+ (reset! status (str "closed" (when why (str ": " why)))))}))]
55+ (reset! conn sock)
56+ (reset! status "TLS up — registering")
57+ ;; A guest registration and nothing else. No SASL: that is frq.msgsig
58+ ;; and frq.atproto, neither of which is portable yet.
59+ (net/send-line! sock "NICK frq-phone")
60+ (net/send-line! sock "USER frq-phone 0 * :frq"))
61+ (catch Exception e
62+ (reset! status (str "failed: " e)))))
63+
64+(defn- screen
65+ "glimmer's tags, painted by `frq.hiccup`. Nothing here is Flutter."
3766 []
38- [:page {:max-width 520}
67+ [:page {:max-width 560}
3968 [:title {:label "frq"}]
40- [:dim-label {:label "freeq client — guest, or your Bluesky identity."}]
69+ [:dim-label {:label "TLS from dart:io, parsed by the desktop's own frq.irc.parse."}]
4170 [:card {}
42- [:title-2 {:label "Sign in with Bluesky"}]
43- [:dim-label {:label "Opens your browser for AT Protocol OAuth. freeq's broker hands back a token; no password passes through frq."}]
44- [:label {:label "Handle"}]
45- [:entry {:key :handle
46- :placeholder "alice.bsky.social"
47- :on-change (fn [_] nil)}]
71+ [:title-2 {:label "Connection"}]
72+ [:label {:label @status}]
4873 [:hbox {:spacing 8}
49- [:button {:label "Connect" :primary true :on-click #(swap! tick inc)}]
50- [:button {:label "Forget saved session" :destructive true :on-click #(swap! tick inc)}]]
51- [:separator {}]
52- [:checkbutton {:label "TLS" :active true :on-toggled (fn [_] nil)}]
53- [:hbox {:spacing 6}
54- [:spinner {}]
55- [:dim-label {:label "Resuming your session…"}]]]
74+ [:button {:label "Connect" :primary true :on-click #(connect!)}]
75+ [:button {:label "Disconnect"
76+ :destructive true
77+ :on-click #(do (when-let [c @conn] (net/close! c))
78+ (reset! status "closed"))}]]]
79+ [:card {}
80+ [:title-2 {:label "What the server said"}]
81+ (if (empty? @lines)
82+ [:dim-label {:label "nothing yet"}]
83+ (for [[i m] (map-indexed vector @lines)]
84+ [:vbox {:key i :spacing 0}
85+ [:label {:label (str (:command m)
86+ (when-let [p (seq (:params m))]
87+ (str " " (last p))))}]]))]
5688 [:card {}
5789 [:title-2 {:label "Shared with the desktop"}]
5890 [:label {:label (str "clock " (clock/clock-time (clock/now-ms)))}]
5991 [:label {:label (str "store " (if-let [s (store/load-session)]
6092 (str "signed in as " (:handle s))
61- "no saved session"))}]
62- [:dim-label {:label "Both read through frq.io, same source as jolt."}]]])
93+ "no saved session"))}]]])
6394
6495 (defn ^:async main []
6596 (m/WidgetsFlutterBinding.ensureInitialized)
@@ -72,6 +103,11 @@
72103 .home
73104 (m/Scaffold .appBar (m/AppBar .title (m/Text "frq")))
74105 .body
75- (f/widget :watch [_ tick])
76- (m/SingleChildScrollView)
77- (h/render (demo)))))
106+ ;; One f/widget with the render as its body, rather than `:watch` as a
107+ ;; link in the chain. A bare directive in the chain builds nothing to
108+ ;; rebuild, so the cells changed and the screen did not which looks
109+ ;; exactly like a button that does not fire.
110+ (f/widget
111+ :watch [st status ls lines]
112+ (m/SingleChildScrollView
113+ .child (h/render (screen)))))))
@@ -21,45 +21,76 @@
21 [cljd.flutter :as f]21 [cljd.flutter :as f]
22 [frq.hiccup :as h]22 [frq.hiccup :as h]
23 [frq.io.dart :as host]23 [frq.io.dart :as host]
24+ [frq.net.dart :as net]
24 [frq.clock :as clock]25 [frq.clock :as clock]
25 [frq.store :as store]))26 [frq.store :as store]))
26 27
27-(defonce ^:private tick (atom 0))28+(defonce ^:private lines (atom []))
29+(defonce ^:private status (atom "not connected"))
30+(defonce ^:private conn (atom nil))
28 31
29-(defn- demo32+(def ^:private host-name "irc.freeq.at")
30- "A hiccup tree in glimmer's tags, rendered by `frq.hiccup`.33+(def ^:private host-port 6697)
31 34
32- Hand-written, and that is the point of it for now: `frq.app`'s own screens35+(defn- note! [m]
33- cannot be required here yet `frq.state` reaches for jolt.host through36+ ;; Bounded, because a backlog is hundreds of lines and this is a proof
34- frq.irc and everything under it so this exercises the tags they are built37+ ;; rather than a message list `frq.state/apply-msg!` is what turns these
35- from until that chain is portable. Every tag here is one `frq.app` uses, and38+ ;; into rooms, and it is not portable yet.
36- none of it is Flutter."39+ (swap! lines (fn [v] (vec (take-last 14 (conj v m))))))
40+
41+(defn ^:async connect! []
42+ (reset! status (str "connecting to " host-name ":" host-port " over TLS…"))
43+ (reset! lines [])
44+ (try
45+ (let [sock (await (net/connect!
46+ {:host host-name
47+ :port host-port
48+ :tls? true
49+ :on-msg (fn [m]
50+ (note! m)
51+ (when (= "001" (:command m))
52+ (reset! status "registered")))
53+ :on-close (fn [why]
54+ (reset! status (str "closed" (when why (str ": " why)))))}))]
55+ (reset! conn sock)
56+ (reset! status "TLS up — registering")
57+ ;; A guest registration and nothing else. No SASL: that is frq.msgsig
58+ ;; and frq.atproto, neither of which is portable yet.
59+ (net/send-line! sock "NICK frq-phone")
60+ (net/send-line! sock "USER frq-phone 0 * :frq"))
61+ (catch Exception e
62+ (reset! status (str "failed: " e)))))
63+
64+(defn- screen
65+ "glimmer's tags, painted by `frq.hiccup`. Nothing here is Flutter."
37 []66 []
38- [:page {:max-width 520}67+ [:page {:max-width 560}
39 [:title {:label "frq"}]68 [:title {:label "frq"}]
40- [:dim-label {:label "freeq client — guest, or your Bluesky identity."}]69+ [:dim-label {:label "TLS from dart:io, parsed by the desktop's own frq.irc.parse."}]
41 [:card {}70 [:card {}
42- [:title-2 {:label "Sign in with Bluesky"}]71+ [:title-2 {:label "Connection"}]
43- [:dim-label {:label "Opens your browser for AT Protocol OAuth. freeq's broker hands back a token; no password passes through frq."}]72+ [:label {:label @status}]
44- [:label {:label "Handle"}]
45- [:entry {:key :handle
46- :placeholder "alice.bsky.social"
47- :on-change (fn [_] nil)}]
48 [:hbox {:spacing 8}73 [:hbox {:spacing 8}
49- [:button {:label "Connect" :primary true :on-click #(swap! tick inc)}]74+ [:button {:label "Connect" :primary true :on-click #(connect!)}]
50- [:button {:label "Forget saved session" :destructive true :on-click #(swap! tick inc)}]]75+ [:button {:label "Disconnect"
51- [:separator {}]76+ :destructive true
52- [:checkbutton {:label "TLS" :active true :on-toggled (fn [_] nil)}]77+ :on-click #(do (when-let [c @conn] (net/close! c))
53- [:hbox {:spacing 6}78+ (reset! status "closed"))}]]]
54- [:spinner {}]79+ [:card {}
55- [:dim-label {:label "Resuming your session…"}]]]80+ [:title-2 {:label "What the server said"}]
81+ (if (empty? @lines)
82+ [:dim-label {:label "nothing yet"}]
83+ (for [[i m] (map-indexed vector @lines)]
84+ [:vbox {:key i :spacing 0}
85+ [:label {:label (str (:command m)
86+ (when-let [p (seq (:params m))]
87+ (str " " (last p))))}]]))]
56 [:card {}88 [:card {}
57 [:title-2 {:label "Shared with the desktop"}]89 [:title-2 {:label "Shared with the desktop"}]
58 [:label {:label (str "clock " (clock/clock-time (clock/now-ms)))}]90 [:label {:label (str "clock " (clock/clock-time (clock/now-ms)))}]
59 [:label {:label (str "store " (if-let [s (store/load-session)]91 [:label {:label (str "store " (if-let [s (store/load-session)]
60 (str "signed in as " (:handle s))92 (str "signed in as " (:handle s))
61- "no saved session"))}]93+ "no saved session"))}]]])
62- [:dim-label {:label "Both read through frq.io, same source as jolt."}]]])
63 94
64 (defn ^:async main []95 (defn ^:async main []
65 (m/WidgetsFlutterBinding.ensureInitialized)96 (m/WidgetsFlutterBinding.ensureInitialized)
@@ -72,6 +103,11 @@
72 .home103 .home
73 (m/Scaffold .appBar (m/AppBar .title (m/Text "frq")))104 (m/Scaffold .appBar (m/AppBar .title (m/Text "frq")))
74 .body105 .body
75- (f/widget :watch [_ tick])106+ ;; One f/widget with the render as its body, rather than `:watch` as a
76- (m/SingleChildScrollView)107+ ;; link in the chain. A bare directive in the chain builds nothing to
77- (h/render (demo)))))108+ ;; rebuild, so the cells changed and the screen did not which looks
109+ ;; exactly like a button that does not fire.
110+ (f/widget
111+ :watch [st status ls lines]
112+ (m/SingleChildScrollView
113+ .child (h/render (screen)))))))
added flutter/src/frq/net/dart.cljd +74 -0
new file mode 100644
@@ -0,0 +1,74 @@
1+(ns frq.net.dart
2+ "An IRC socket over dart:io. The thing the jolt APK could never have.
3+
4+ `frq.irc` reaches OpenSSL through the dynamic loader for TLS, and Android
5+ ships no public libssl so on the phone `:6697` was unreachable, sign-in was
6+ desktop-only, and the connect screen fell back to the plain `:6667` listener
7+ on its own. `SecureSocket` is in the Dart runtime. There is nothing to load
8+ and nothing to find.
9+
10+ The shape is different from the desktop's and deliberately so. jolt has one
11+ future blocking on a read, which also has to flush the outbox because the
12+ reader owns the connection; Dart has an event loop, so a socket is a `Stream`
13+ and a write is a write. No thread, no outbox, no poll interval.
14+
15+ What comes out is `frq.irc.parse/parse-line`'s maps — the same parser the
16+ desktop runs, out of common/."
17+ (:require ["dart:convert" :as conv]
18+ ["dart:io" :as io]
19+ [frq.irc.parse :as parse]))
20+
21+(defn ^:async connect!
22+ "Open a connection and start reading it.
23+
24+ `on-msg` is called with a parsed map per line; `on-close` with a reason, or
25+ nil where the peer simply went away. Returns the socket, which `send-line!`
26+ and `close!` take."
27+ [{:keys [host port tls? on-msg on-close]}]
28+ (let [buffer (atom "")
29+ sock (await (if tls?
30+ (io/SecureSocket.connect host port)
31+ (io/Socket.connect host port)))]
32+ ;; Lines are cut here rather than by a LineSplitter in the chain. Two
33+ ;; transforms deep, cljd hands the splitter a CastStream<String, dynamic>
34+ ;; where it wants a Stream<String>, and it throws at the first byte the
35+ ;; generic is lost through the dynamic call. Buffering the decoded chunks
36+ ;; is a few lines and has no type to lose.
37+ ;;
38+ ;; \r\n or \n: IRC says the former and servers send both.
39+ (-> sock
40+ (.transform (.-decoder conv/utf8))
41+ (.listen (fn [chunk]
42+ ;; Split rather than a LineSplitter in the chain: two
43+ ;; transforms deep, cljd hands the splitter a
44+ ;; CastStream<String, dynamic> where it wants a
45+ ;; Stream<String> and it throws at the first byte the
46+ ;; generic is lost through the dynamic call.
47+ ;;
48+ ;; The tail after the last newline is a partial line and
49+ ;; goes back in the buffer. A chunk boundary falls wherever
50+ ;; TCP puts it, which is not where IRC put its lines.
51+ (let [parts (.split (str @buffer chunk) "\n")
52+ whole (butlast parts)]
53+ (reset! buffer (str (last parts)))
54+ (doseq [raw whole]
55+ (let [line (.trim (str raw))]
56+ (when (and on-msg (pos? (count line)))
57+ (let [m (parse/parse-line line)]
58+ ;; PING is answered here rather than upstairs: a
59+ ;; client that leaves it to the reducer is one
60+ ;; queue away from a timeout, and nothing about
61+ ;; the answer is a decision.
62+ (when (= "PING" (:command m))
63+ (.write sock (str "PONG :" (first (:params m)) "\r\n")))
64+ (on-msg m)))))))
65+ .onDone (fn [] (when on-close (on-close nil)))
66+ .onError (fn [e _] (when on-close (on-close (str e))))
67+ .cancelOnError true))
68+ sock))
69+
70+(defn send-line! [sock line]
71+ (.write sock (str line "\r\n")))
72+
73+(defn close! [sock]
74+ (try (.destroy sock) (catch Exception _ nil)))
new file mode 100644
@@ -0,0 +1,74 @@
1+(ns frq.net.dart
2+ "An IRC socket over dart:io. The thing the jolt APK could never have.
3+
4+ `frq.irc` reaches OpenSSL through the dynamic loader for TLS, and Android
5+ ships no public libssl so on the phone `:6697` was unreachable, sign-in was
6+ desktop-only, and the connect screen fell back to the plain `:6667` listener
7+ on its own. `SecureSocket` is in the Dart runtime. There is nothing to load
8+ and nothing to find.
9+
10+ The shape is different from the desktop's and deliberately so. jolt has one
11+ future blocking on a read, which also has to flush the outbox because the
12+ reader owns the connection; Dart has an event loop, so a socket is a `Stream`
13+ and a write is a write. No thread, no outbox, no poll interval.
14+
15+ What comes out is `frq.irc.parse/parse-line`'s maps — the same parser the
16+ desktop runs, out of common/."
17+ (:require ["dart:convert" :as conv]
18+ ["dart:io" :as io]
19+ [frq.irc.parse :as parse]))
20+
21+(defn ^:async connect!
22+ "Open a connection and start reading it.
23+
24+ `on-msg` is called with a parsed map per line; `on-close` with a reason, or
25+ nil where the peer simply went away. Returns the socket, which `send-line!`
26+ and `close!` take."
27+ [{:keys [host port tls? on-msg on-close]}]
28+ (let [buffer (atom "")
29+ sock (await (if tls?
30+ (io/SecureSocket.connect host port)
31+ (io/Socket.connect host port)))]
32+ ;; Lines are cut here rather than by a LineSplitter in the chain. Two
33+ ;; transforms deep, cljd hands the splitter a CastStream<String, dynamic>
34+ ;; where it wants a Stream<String>, and it throws at the first byte the
35+ ;; generic is lost through the dynamic call. Buffering the decoded chunks
36+ ;; is a few lines and has no type to lose.
37+ ;;
38+ ;; \r\n or \n: IRC says the former and servers send both.
39+ (-> sock
40+ (.transform (.-decoder conv/utf8))
41+ (.listen (fn [chunk]
42+ ;; Split rather than a LineSplitter in the chain: two
43+ ;; transforms deep, cljd hands the splitter a
44+ ;; CastStream<String, dynamic> where it wants a
45+ ;; Stream<String> and it throws at the first byte the
46+ ;; generic is lost through the dynamic call.
47+ ;;
48+ ;; The tail after the last newline is a partial line and
49+ ;; goes back in the buffer. A chunk boundary falls wherever
50+ ;; TCP puts it, which is not where IRC put its lines.
51+ (let [parts (.split (str @buffer chunk) "\n")
52+ whole (butlast parts)]
53+ (reset! buffer (str (last parts)))
54+ (doseq [raw whole]
55+ (let [line (.trim (str raw))]
56+ (when (and on-msg (pos? (count line)))
57+ (let [m (parse/parse-line line)]
58+ ;; PING is answered here rather than upstairs: a
59+ ;; client that leaves it to the reducer is one
60+ ;; queue away from a timeout, and nothing about
61+ ;; the answer is a decision.
62+ (when (= "PING" (:command m))
63+ (.write sock (str "PONG :" (first (:params m)) "\r\n")))
64+ (on-msg m)))))))
65+ .onDone (fn [] (when on-close (on-close nil)))
66+ .onError (fn [e _] (when on-close (on-close (str e))))
67+ .cancelOnError true))
68+ sock))
69+
70+(defn send-line! [sock line]
71+ (.write sock (str line "\r\n")))
72+
73+(defn close! [sock]
74+ (try (.destroy sock) (catch Exception _ nil)))
modified src/frq/irc.clj +13 -79
@@ -16,6 +16,7 @@
1616 `send-line!` and `close!` accept."
1717 (:require [clojure.string :as str]
1818 [frq.atproto :as atproto]
19+ [frq.irc.parse :as parse]
1920 [frq.msgsig :as msgsig]
2021 [frq.wire :as wire]
2122 [jolt.ffi :as ffi]
@@ -42,85 +43,18 @@
4243 (defn- secs-since [t] (quot (- (host/mono-nanos) t) 1000000000))
4344
4445 ;; ---------------------------------------------------------------- parsing
45-
46-(defn parse-line
47- "An IRC line into {:tags :prefix :command :params}. The trailing parameter
48- (after \" :\") keeps its spaces; everything before it splits on whitespace.
49-
50- IRCv3 tags come first when there are any. A connection that negotiates CAP
51- gets them where a bare one does not — which is why a client that ignores them
52- looks fine as a guest and goes silent once it authenticates."
53- [line]
54- (let [line (str/trimr line)
55- [tags line] (if (str/starts-with? line "@")
56- (let [i (str/index-of line " ")]
57- [(subs line 1 i) (str/triml (subs line i))])
58- [nil line])
59- [prefix rest-line] (if (str/starts-with? line ":")
60- (let [i (str/index-of line " ")]
61- [(subs line 1 i) (subs line (inc i))])
62- [nil line])
63- i (str/index-of rest-line " :")
64- head (if i (subs rest-line 0 i) rest-line)
65- trailing (when i (subs rest-line (+ i 2)))
66- parts (remove str/blank? (str/split head #" "))]
67- {:tags tags
68- :account (when tags
69- (second (re-find #"(?:^|;)account=([^;]*)" tags)))
70- :prefix prefix
71- :command (str/upper-case (or (first parts) ""))
72- :params (cond-> (vec (rest parts)) trailing (conj trailing))}))
73-
74-(defn unescape-tag
75- "An IRCv3 tag value with its escapes undone.
76-
77- `\\:` is a semicolon, `\\s` a space, and `\\\\`, `\\r` and `\\n` themselves —
78- the escaping exists because `;` separates tags and a space ends them. It
79- matters for any value that can contain either: a reaction tally is
80- `emoji:nick;emoji:nick` on the wire and arrives with every one of those
81- semicolons written `\\:`, so a reader that skips this step sees one tally
82- where there were three, and counts to match."
83- [v]
84- (when v
85- (loop [in (seq v) out []]
86- (if-let [c (first in)]
87- (if (and (= \\ c) (second in))
88- (recur (drop 2 in)
89- (conj out (case (second in)
90- \: \;
91- \s \space
92- \r \return
93- \n \newline
94- (second in))))
95- (recur (rest in) (conj out c)))
96- (apply str out)))))
97-
98-(defn escape-tag-value
99- "The inverse, for a tag this client sends. An emoji needs none of it; a
100- message id could, and the cost of being right is a pass over a short string."
101- [v]
102- (-> (str v)
103- (str/replace "\\" "\\\\")
104- (str/replace ";" "\\:")
105- (str/replace " " "\\s")
106- (str/replace "\r" "\\r")
107- (str/replace "\n" "\\n")))
108-
109-(defn tag-value
110- "One IRCv3 tag's value, unescaped, or nil."
111- [tags key]
112- (when tags
113- (some (fn [pair]
114- (let [[k v] (str/split pair #"=" 2)]
115- (when (= k key) (unescape-tag v))))
116- (str/split tags #";"))))
117-
118-(defn nick-of
119- "The nick half of a `nick!user@host` prefix."
120- [prefix]
121- (when prefix
122- (let [i (str/index-of prefix "!")]
123- (if i (subs prefix 0 i) prefix))))
46+;;
47+;; Moved to `frq.irc.parse` under common/, so ClojureDart compiles it too —
48+;; the wire format is the same on a phone, and only the socket under it is
49+;; not. Re-exported here rather than left to the callers: `irc/tag-value` and
50+;; `irc/nick-of` are read in twenty-three places across frq.state and frq.av,
51+;; and none of them care which file it lives in.
52+
53+(def parse-line parse/parse-line)
54+(def unescape-tag parse/unescape-tag)
55+(def escape-tag-value parse/escape-tag-value)
56+(def tag-value parse/tag-value)
57+(def nick-of parse/nick-of)
12458
12559 ;; ---------------------------------------------------------------- transport
12660
@@ -16,6 +16,7 @@
16 `send-line!` and `close!` accept."16 `send-line!` and `close!` accept."
17 (:require [clojure.string :as str]17 (:require [clojure.string :as str]
18 [frq.atproto :as atproto]18 [frq.atproto :as atproto]
19+ [frq.irc.parse :as parse]
19 [frq.msgsig :as msgsig]20 [frq.msgsig :as msgsig]
20 [frq.wire :as wire]21 [frq.wire :as wire]
21 [jolt.ffi :as ffi]22 [jolt.ffi :as ffi]
@@ -42,85 +43,18 @@
42 (defn- secs-since [t] (quot (- (host/mono-nanos) t) 1000000000))43 (defn- secs-since [t] (quot (- (host/mono-nanos) t) 1000000000))
43 44
44 ;; ---------------------------------------------------------------- parsing45 ;; ---------------------------------------------------------------- parsing
45-46+;;
46-(defn parse-line47+;; Moved to `frq.irc.parse` under common/, so ClojureDart compiles it too —
47- "An IRC line into {:tags :prefix :command :params}. The trailing parameter48+;; the wire format is the same on a phone, and only the socket under it is
48- (after \" :\") keeps its spaces; everything before it splits on whitespace.49+;; not. Re-exported here rather than left to the callers: `irc/tag-value` and
49-50+;; `irc/nick-of` are read in twenty-three places across frq.state and frq.av,
50- IRCv3 tags come first when there are any. A connection that negotiates CAP51+;; and none of them care which file it lives in.
51- gets them where a bare one does not — which is why a client that ignores them52+
52- looks fine as a guest and goes silent once it authenticates."53+(def parse-line parse/parse-line)
53- [line]54+(def unescape-tag parse/unescape-tag)
54- (let [line (str/trimr line)55+(def escape-tag-value parse/escape-tag-value)
55- [tags line] (if (str/starts-with? line "@")56+(def tag-value parse/tag-value)
56- (let [i (str/index-of line " ")]57+(def nick-of parse/nick-of)
57- [(subs line 1 i) (str/triml (subs line i))])
58- [nil line])
59- [prefix rest-line] (if (str/starts-with? line ":")
60- (let [i (str/index-of line " ")]
61- [(subs line 1 i) (subs line (inc i))])
62- [nil line])
63- i (str/index-of rest-line " :")
64- head (if i (subs rest-line 0 i) rest-line)
65- trailing (when i (subs rest-line (+ i 2)))
66- parts (remove str/blank? (str/split head #" "))]
67- {:tags tags
68- :account (when tags
69- (second (re-find #"(?:^|;)account=([^;]*)" tags)))
70- :prefix prefix
71- :command (str/upper-case (or (first parts) ""))
72- :params (cond-> (vec (rest parts)) trailing (conj trailing))}))
73-
74-(defn unescape-tag
75- "An IRCv3 tag value with its escapes undone.
76-
77- `\\:` is a semicolon, `\\s` a space, and `\\\\`, `\\r` and `\\n` themselves —
78- the escaping exists because `;` separates tags and a space ends them. It
79- matters for any value that can contain either: a reaction tally is
80- `emoji:nick;emoji:nick` on the wire and arrives with every one of those
81- semicolons written `\\:`, so a reader that skips this step sees one tally
82- where there were three, and counts to match."
83- [v]
84- (when v
85- (loop [in (seq v) out []]
86- (if-let [c (first in)]
87- (if (and (= \\ c) (second in))
88- (recur (drop 2 in)
89- (conj out (case (second in)
90- \: \;
91- \s \space
92- \r \return
93- \n \newline
94- (second in))))
95- (recur (rest in) (conj out c)))
96- (apply str out)))))
97-
98-(defn escape-tag-value
99- "The inverse, for a tag this client sends. An emoji needs none of it; a
100- message id could, and the cost of being right is a pass over a short string."
101- [v]
102- (-> (str v)
103- (str/replace "\\" "\\\\")
104- (str/replace ";" "\\:")
105- (str/replace " " "\\s")
106- (str/replace "\r" "\\r")
107- (str/replace "\n" "\\n")))
108-
109-(defn tag-value
110- "One IRCv3 tag's value, unescaped, or nil."
111- [tags key]
112- (when tags
113- (some (fn [pair]
114- (let [[k v] (str/split pair #"=" 2)]
115- (when (= k key) (unescape-tag v))))
116- (str/split tags #";"))))
117-
118-(defn nick-of
119- "The nick half of a `nick!user@host` prefix."
120- [prefix]
121- (when prefix
122- (let [i (str/index-of prefix "!")]
123- (if i (subs prefix 0 i) prefix))))
124 58
125 ;; ---------------------------------------------------------------- transport59 ;; ---------------------------------------------------------------- transport
126 60