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

Tell the truth about TLS on the phone, and stop the guest spinner

The caveat at the foot of the connect screen said TLS rides jolt's OpenSSL
bindings and sign-in is desktop-only. That is still true where jolt runs it
and a lie where dart:io does, so it is a reader conditional now — a
compile-time fact, like `atom` in frq.cells. The phone's copy says TLS comes
from the Dart runtime and names the real reason sign-in is not there yet: the
SASL handshake wants frq.msgsig, which is not ported.

And the spinner that would not stop. Only 001 cleared `connecting?`, so
anything the server said instead of it spun for ever with nothing on screen
to say why — and the likeliest of those is the dullest: 433, the nick is
already in use, which is what a second attempt gets from its own predecessor
still holding frq-guest on the old socket.

Three things, because one of them alone would still leave a way to hang:

`connect!` closes whatever was open before it opens anything. Connecting
twice was colliding with itself.

The registration numerics that mean it will never finish — 431, 432, 433,
436, 464, 465 and ERROR — clear the spinner and say what happened, with 433
spelled out as the nick rather than as a number. Everything else numeric
updates the status while connecting, so a slow server looks slow rather than
stuck.

And a twenty-second watchdog, because "no answer at all" is a real outcome: a
handshake that hangs or a server that accepts the socket and says nothing
fires none of those callbacks.

Verified on the device: Connect reports "Connected as frq-guest", and
Connect again while connected reconnects rather than colliding with itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-11T20:52:37-07:00 Browse files
ba9e7a9 parent: 232a455
modified common/frq/screens/connect.cljc +17 -1
@@ -14,6 +14,22 @@
1414 (:require [frq.actions :as actions]
1515 [frq.cells :as cells]))
1616
17+(def transport-note
18+ "What the foot of the screen says about the transport, which is the one
19+ sentence here that is not the same on both.
20+
21+ It is a reader conditional rather than a seam because it is a compile-time
22+ fact: jolt reaches OpenSSL through the dynamic loader, and Android ships no
23+ public libssl, so :6697 was unreachable there and the screen said so. Under
24+ ClojureDart TLS is in the Dart runtime — `SecureSocket`, nothing to load —
25+ and that sentence would now be a lie.
26+
27+ Sign-in is still unavailable on the phone, but for a different reason worth
28+ naming rather than papering over: the SASL handshake wants `frq.msgsig` and
29+ `frq.atproto`'s session, and msgsig is not ported yet."
30+ #?(:cljd "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener. Sign-in needs SASL, which is not ported to the phone yet."
31+ :jolt "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener. Sign-in needs TLS, so it is desktop-only."))
32+
1733 (defn error-note
1834 "Always a node, never nil.
1935
@@ -130,4 +146,4 @@
130146 [server-fields]
131147 [:separator {}]
132148 [connect-action]]
133- [:dim-label {:label "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener. Sign-in needs TLS, so it is desktop-only."}]])
149+ [:dim-label {:label transport-note}]])
@@ -14,6 +14,22 @@
14 (:require [frq.actions :as actions]14 (:require [frq.actions :as actions]
15 [frq.cells :as cells]))15 [frq.cells :as cells]))
16 16
17+(def transport-note
18+ "What the foot of the screen says about the transport, which is the one
19+ sentence here that is not the same on both.
20+
21+ It is a reader conditional rather than a seam because it is a compile-time
22+ fact: jolt reaches OpenSSL through the dynamic loader, and Android ships no
23+ public libssl, so :6697 was unreachable there and the screen said so. Under
24+ ClojureDart TLS is in the Dart runtime — `SecureSocket`, nothing to load —
25+ and that sentence would now be a lie.
26+
27+ Sign-in is still unavailable on the phone, but for a different reason worth
28+ naming rather than papering over: the SASL handshake wants `frq.msgsig` and
29+ `frq.atproto`'s session, and msgsig is not ported yet."
30+ #?(:cljd "TLS comes from dart:io, so :6697 works here; untick it for a plain :6667 listener. Sign-in needs SASL, which is not ported to the phone yet."
31+ :jolt "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener. Sign-in needs TLS, so it is desktop-only."))
32+
17 (defn error-note33 (defn error-note
18 "Always a node, never nil.34 "Always a node, never nil.
19 35
@@ -130,4 +146,4 @@
130 [server-fields]146 [server-fields]
131 [:separator {}]147 [:separator {}]
132 [connect-action]]148 [connect-action]]
133- [:dim-label {:label "TLS rides jolt's OpenSSL bindings; untick it for a plain :6667 listener. Sign-in needs TLS, so it is desktop-only."}]])149+ [:dim-label {:label transport-note}]])
modified flutter/src/frq/main.cljd +82 -36
@@ -16,7 +16,8 @@
1616 port. What this paints is the proof the shared half is alive under a second
1717 compiler: the clock and the saved session, read through exactly the
1818 namespaces the desktop reads them through."
19- (:require ["package:flutter/material.dart" :as m]
19+ (:require ["dart:async" :as async]
20+ ["package:flutter/material.dart" :as m]
2021 ["package:path_provider/path_provider.dart" :as pp]
2122 [cljd.flutter :as f]
2223 [frq.hiccup :as h]
@@ -36,6 +37,28 @@
3637 (defn- note! [m]
3738 (swap! lines (fn [v] (vec (take-last 8 (conj v m))))))
3839
40+(def ^:private registration-failed
41+ "Numerics that mean registration will never complete.
42+
43+ Only 001 used to clear `connecting?`, so anything the server said instead of
44+ it spun for ever with no message — and the likeliest of them is the dullest:
45+ 433, the nick is already in use, which is what a second phone or a session
46+ left open elsewhere gets."
47+ #{"431" ; no nickname given
48+ "432" ; erroneous nickname
49+ "433" ; nickname in use
50+ "436" ; nick collision
51+ "464" ; password incorrect
52+ "465" ; banned
53+ "ERROR"})
54+
55+(defonce ^:private attempt (atom 0))
56+
57+(defn- fail! [why]
58+ (reset! cells/connecting? false)
59+ (reset! cells/status "Not connected")
60+ (reset! cells/error why))
61+
3962 (defn ^:async connect!
4063 "What `frq.actions/connect!` is on the phone.
4164
@@ -44,42 +67,65 @@
4467 host, the port and the TLS tick are `frq.cells`, filled in by the entry and
4568 the checkbutton on screen.
4669
47- Guest registration only. SASL wants `frq.msgsig`, which is not portable
48- yet — so `frq.actions` answering a name it cannot fully honour is the point
49- of that seam: the screen renders and the button works, and what is behind it
50- grows."
70+ Guest registration only. SASL wants `frq.msgsig`, which is not portable yet."
5171 []
52- (reset! cells/connecting? true)
53- (reset! cells/error nil)
54- (reset! cells/status (str "Connecting to " @cells/form-host "…"))
55- (reset! lines [])
56- (try
57- (let [sock (await (net/connect!
58- {:host @cells/form-host
59- ;; The cell is a string, because it is what an :entry holds.
60- :port (or (parse-long (str @cells/form-port)) 6697)
61- :tls? (boolean @cells/form-tls?)
62- :on-msg (fn [m]
63- (note! m)
64- (when (= "001" (:command m))
65- (reset! cells/connecting? false)
66- (reset! cells/status
67- (str "Connected as " @cells/form-nick))))
68- :on-close (fn [why]
69- (reset! cells/connecting? false)
70- (reset! cells/status "Not connected")
71- (when why (reset! cells/error why)))}))]
72- (reset! conn sock)
73- (net/send-line! sock (str "NICK " @cells/form-nick))
74- (net/send-line! sock (str "USER " @cells/form-nick " 0 * :frq")))
75- ;; Object, not Exception. Dart keeps Error and Exception in separate
76- ;; hierarchies, so a TypeError or a NoSuchMethodError is not an Exception
77- ;; and would leave `connecting?` true for ever — a spinner that never
78- ;; stops and no message saying why, which is exactly what this did.
79- (catch Object e
80- (reset! cells/connecting? false)
81- (reset! cells/status "Not connected")
82- (reset! cells/error (str e)))))
72+ ;; Close whatever was open first. Connecting twice left the old socket
73+ ;; holding the nick, so the second attempt got 433 from its own predecessor.
74+ (when-let [c @conn] (net/close! c) (reset! conn nil))
75+ (let [n (swap! attempt inc)]
76+ (reset! cells/connecting? true)
77+ (reset! cells/error nil)
78+ (reset! cells/status (str "Connecting to " @cells/form-host "…"))
79+ (reset! lines [])
80+ ;; A watchdog, because "no answer at all" is a real outcome: a TLS
81+ ;; handshake that hangs, or a server that accepts the socket and says
82+ ;; nothing, leaves every callback below unfired.
83+ (.then (async/Future.delayed (Duration .seconds 20))
84+ (fn [_]
85+ (when (and @cells/connecting? (= n @attempt))
86+ (fail! "No answer from the server after 20s"))))
87+ (try
88+ (let [sock (await (net/connect!
89+ {:host @cells/form-host
90+ ;; The cell is a string, because it is what an
91+ ;; :entry holds.
92+ :port (or (parse-long (str @cells/form-port)) 6697)
93+ :tls? (boolean @cells/form-tls?)
94+ :on-msg (fn [m]
95+ (note! m)
96+ (let [cmd (str (:command m))
97+ text (str (last (:params m)))]
98+ (cond
99+ (= "001" cmd)
100+ (do (reset! cells/connecting? false)
101+ (reset! cells/error nil)
102+ (reset! cells/status
103+ (str "Connected as " @cells/form-nick)))
104+
105+ (contains? registration-failed cmd)
106+ (fail! (if (= "433" cmd)
107+ (str "Nick " @cells/form-nick
108+ " is already in use — try another")
109+ (str cmd " " text)))
110+
111+ ;; Something is happening; say so
112+ ;; rather than sit on one message.
113+ @cells/connecting?
114+ (reset! cells/status (str "… " cmd)))))
115+ :on-close (fn [why]
116+ (reset! conn nil)
117+ (if why
118+ (fail! why)
119+ (do (reset! cells/connecting? false)
120+ (reset! cells/status "Not connected"))))}))]
121+ (reset! conn sock)
122+ (net/send-line! sock (str "NICK " @cells/form-nick))
123+ (net/send-line! sock (str "USER " @cells/form-nick " 0 * :frq")))
124+ ;; Object, not Exception. Dart keeps Error and Exception in separate
125+ ;; hierarchies, so a TypeError is not an Exception and would leave
126+ ;; `connecting?` true for ever.
127+ (catch Object e
128+ (fail! (str e))))))
83129
84130 (defn- disconnect! []
85131 (when-let [c @conn] (net/close! c))
@@ -16,7 +16,8 @@
16 port. What this paints is the proof the shared half is alive under a second16 port. What this paints is the proof the shared half is alive under a second
17 compiler: the clock and the saved session, read through exactly the17 compiler: the clock and the saved session, read through exactly the
18 namespaces the desktop reads them through."18 namespaces the desktop reads them through."
19- (:require ["package:flutter/material.dart" :as m]19+ (:require ["dart:async" :as async]
20+ ["package:flutter/material.dart" :as m]
20 ["package:path_provider/path_provider.dart" :as pp]21 ["package:path_provider/path_provider.dart" :as pp]
21 [cljd.flutter :as f]22 [cljd.flutter :as f]
22 [frq.hiccup :as h]23 [frq.hiccup :as h]
@@ -36,6 +37,28 @@
36 (defn- note! [m]37 (defn- note! [m]
37 (swap! lines (fn [v] (vec (take-last 8 (conj v m))))))38 (swap! lines (fn [v] (vec (take-last 8 (conj v m))))))
38 39
40+(def ^:private registration-failed
41+ "Numerics that mean registration will never complete.
42+
43+ Only 001 used to clear `connecting?`, so anything the server said instead of
44+ it spun for ever with no message — and the likeliest of them is the dullest:
45+ 433, the nick is already in use, which is what a second phone or a session
46+ left open elsewhere gets."
47+ #{"431" ; no nickname given
48+ "432" ; erroneous nickname
49+ "433" ; nickname in use
50+ "436" ; nick collision
51+ "464" ; password incorrect
52+ "465" ; banned
53+ "ERROR"})
54+
55+(defonce ^:private attempt (atom 0))
56+
57+(defn- fail! [why]
58+ (reset! cells/connecting? false)
59+ (reset! cells/status "Not connected")
60+ (reset! cells/error why))
61+
39 (defn ^:async connect!62 (defn ^:async connect!
40 "What `frq.actions/connect!` is on the phone.63 "What `frq.actions/connect!` is on the phone.
41 64
@@ -44,42 +67,65 @@
44 host, the port and the TLS tick are `frq.cells`, filled in by the entry and67 host, the port and the TLS tick are `frq.cells`, filled in by the entry and
45 the checkbutton on screen.68 the checkbutton on screen.
46 69
47- Guest registration only. SASL wants `frq.msgsig`, which is not portable70+ Guest registration only. SASL wants `frq.msgsig`, which is not portable yet."
48- yet — so `frq.actions` answering a name it cannot fully honour is the point
49- of that seam: the screen renders and the button works, and what is behind it
50- grows."
51 []71 []
52- (reset! cells/connecting? true)72+ ;; Close whatever was open first. Connecting twice left the old socket
53- (reset! cells/error nil)73+ ;; holding the nick, so the second attempt got 433 from its own predecessor.
54- (reset! cells/status (str "Connecting to " @cells/form-host "…"))74+ (when-let [c @conn] (net/close! c) (reset! conn nil))
55- (reset! lines [])75+ (let [n (swap! attempt inc)]
56- (try76+ (reset! cells/connecting? true)
57- (let [sock (await (net/connect!77+ (reset! cells/error nil)
58- {:host @cells/form-host78+ (reset! cells/status (str "Connecting to " @cells/form-host "…"))
59- ;; The cell is a string, because it is what an :entry holds.79+ (reset! lines [])
60- :port (or (parse-long (str @cells/form-port)) 6697)80+ ;; A watchdog, because "no answer at all" is a real outcome: a TLS
61- :tls? (boolean @cells/form-tls?)81+ ;; handshake that hangs, or a server that accepts the socket and says
62- :on-msg (fn [m]82+ ;; nothing, leaves every callback below unfired.
63- (note! m)83+ (.then (async/Future.delayed (Duration .seconds 20))
64- (when (= "001" (:command m))84+ (fn [_]
65- (reset! cells/connecting? false)85+ (when (and @cells/connecting? (= n @attempt))
66- (reset! cells/status86+ (fail! "No answer from the server after 20s"))))
67- (str "Connected as " @cells/form-nick))))87+ (try
68- :on-close (fn [why]88+ (let [sock (await (net/connect!
69- (reset! cells/connecting? false)89+ {:host @cells/form-host
70- (reset! cells/status "Not connected")90+ ;; The cell is a string, because it is what an
71- (when why (reset! cells/error why)))}))]91+ ;; :entry holds.
72- (reset! conn sock)92+ :port (or (parse-long (str @cells/form-port)) 6697)
73- (net/send-line! sock (str "NICK " @cells/form-nick))93+ :tls? (boolean @cells/form-tls?)
74- (net/send-line! sock (str "USER " @cells/form-nick " 0 * :frq")))94+ :on-msg (fn [m]
75- ;; Object, not Exception. Dart keeps Error and Exception in separate95+ (note! m)
76- ;; hierarchies, so a TypeError or a NoSuchMethodError is not an Exception96+ (let [cmd (str (:command m))
77- ;; and would leave `connecting?` true for ever — a spinner that never97+ text (str (last (:params m)))]
78- ;; stops and no message saying why, which is exactly what this did.98+ (cond
79- (catch Object e99+ (= "001" cmd)
80- (reset! cells/connecting? false)100+ (do (reset! cells/connecting? false)
81- (reset! cells/status "Not connected")101+ (reset! cells/error nil)
82- (reset! cells/error (str e)))))102+ (reset! cells/status
103+ (str "Connected as " @cells/form-nick)))
104+
105+ (contains? registration-failed cmd)
106+ (fail! (if (= "433" cmd)
107+ (str "Nick " @cells/form-nick
108+ " is already in use — try another")
109+ (str cmd " " text)))
110+
111+ ;; Something is happening; say so
112+ ;; rather than sit on one message.
113+ @cells/connecting?
114+ (reset! cells/status (str "… " cmd)))))
115+ :on-close (fn [why]
116+ (reset! conn nil)
117+ (if why
118+ (fail! why)
119+ (do (reset! cells/connecting? false)
120+ (reset! cells/status "Not connected"))))}))]
121+ (reset! conn sock)
122+ (net/send-line! sock (str "NICK " @cells/form-nick))
123+ (net/send-line! sock (str "USER " @cells/form-nick " 0 * :frq")))
124+ ;; Object, not Exception. Dart keeps Error and Exception in separate
125+ ;; hierarchies, so a TypeError is not an Exception and would leave
126+ ;; `connecting?` true for ever.
127+ (catch Object e
128+ (fail! (str e))))))
83 129
84 (defn- disconnect! []130 (defn- disconnect! []
85 (when-let [c @conn] (net/close! c))131 (when-let [c @conn] (net/close! c))