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>
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. | ||
| 137 | 137 | |
| 138 | 138 | ## The order to do the rest in |
| 139 | 139 | |
| 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. | |
| 143 | 148 | 2. **`frq.atproto`** (209), **`frq.oauth`** (182) — hand-rolled HTTPS over |
| 144 | 149 | OpenSSL bindings today, `dart:io` and `package:http` here. |
| 145 | 150 | 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 in | 138 | ## The order to do the rest in |
| 139 | 139 | ||
| 140 | -1. **`frq.irc`** (433) — the parser is pure; the reader is a blocking thread in | 140 | +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 what | 141 | + 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 over | 148 | 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 the | 150 | 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 @@ | ||
| 1 | 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 | 6 | <application |
| 3 | 7 | android:label="frq" |
| 4 | 8 | 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 | <application | 6 | <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 @@ | ||
| 21 | 21 | [cljd.flutter :as f] |
| 22 | 22 | [frq.hiccup :as h] |
| 23 | 23 | [frq.io.dart :as host] |
| 24 | + [frq.net.dart :as net] | |
| 24 | 25 | [frq.clock :as clock] |
| 25 | 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- 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) | |
| 31 | 34 | |
| 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." | |
| 37 | 66 | [] |
| 38 | - [:page {:max-width 520} | |
| 67 | + [:page {:max-width 560} | |
| 39 | 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 | 70 | [: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}] | |
| 48 | 73 | [: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))))}]]))] | |
| 56 | 88 | [:card {} |
| 57 | 89 | [:title-2 {:label "Shared with the desktop"}] |
| 58 | 90 | [:label {:label (str "clock " (clock/clock-time (clock/now-ms)))}] |
| 59 | 91 | [:label {:label (str "store " (if-let [s (store/load-session)] |
| 60 | 92 | (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"))}]]]) | |
| 63 | 94 | |
| 64 | 95 | (defn ^:async main [] |
| 65 | 96 | (m/WidgetsFlutterBinding.ensureInitialized) |
| @@ -72,6 +103,11 @@ | ||
| 72 | 103 | .home |
| 73 | 104 | (m/Scaffold .appBar (m/AppBar .title (m/Text "frq"))) |
| 74 | 105 | .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- demo | 32 | +(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 screens | 35 | +(defn- note! [m] |
| 33 | - cannot be required here yet — `frq.state` reaches for jolt.host through | 36 | + ;; 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 built | 37 | + ;; 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, and | 38 | + ;; 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 | .home | 103 | .home |
| 73 | (m/Scaffold .appBar (m/AppBar .title (m/Text "frq"))) | 104 | (m/Scaffold .appBar (m/AppBar .title (m/Text "frq"))) |
| 74 | .body | 105 | .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 @@ | ||
| 16 | 16 | `send-line!` and `close!` accept." |
| 17 | 17 | (:require [clojure.string :as str] |
| 18 | 18 | [frq.atproto :as atproto] |
| 19 | + [frq.irc.parse :as parse] | |
| 19 | 20 | [frq.msgsig :as msgsig] |
| 20 | 21 | [frq.wire :as wire] |
| 21 | 22 | [jolt.ffi :as ffi] |
| @@ -42,85 +43,18 @@ | ||
| 42 | 43 | (defn- secs-since [t] (quot (- (host/mono-nanos) t) 1000000000)) |
| 43 | 44 | |
| 44 | 45 | ;; ---------------------------------------------------------------- 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) | |
| 124 | 58 | |
| 125 | 59 | ;; ---------------------------------------------------------------- transport |
| 126 | 60 | |
| @@ -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 | ;; ---------------------------------------------------------------- parsing | 45 | ;; ---------------------------------------------------------------- parsing |
| 45 | - | 46 | +;; |
| 46 | -(defn parse-line | 47 | +;; Moved to `frq.irc.parse` under common/, so ClojureDart compiles it too — |
| 47 | - "An IRC line into {:tags :prefix :command :params}. The trailing parameter | 48 | +;; 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 CAP | 51 | +;; 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 them | 52 | + |
| 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 | ;; ---------------------------------------------------------------- transport | 59 | ;; ---------------------------------------------------------------- transport |
| 126 | 60 | ||