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>
ba9e7a9 parent: 232a455 modified
common/frq/screens/connect.cljc +17 -1 | @@ -14,6 +14,22 @@ | ||
| 14 | 14 | (:require [frq.actions :as actions] |
| 15 | 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 | 33 | (defn error-note |
| 18 | 34 | "Always a node, never nil. |
| 19 | 35 | |
| @@ -130,4 +146,4 @@ | ||
| 130 | 146 | [server-fields] |
| 131 | 147 | [:separator {}] |
| 132 | 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}]]) | |
| @@ -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-note | 33 | (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 @@ | ||
| 16 | 16 | port. What this paints is the proof the shared half is alive under a second |
| 17 | 17 | compiler: the clock and the saved session, read through exactly the |
| 18 | 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 | 21 | ["package:path_provider/path_provider.dart" :as pp] |
| 21 | 22 | [cljd.flutter :as f] |
| 22 | 23 | [frq.hiccup :as h] |
| @@ -36,6 +37,28 @@ | ||
| 36 | 37 | (defn- note! [m] |
| 37 | 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 | 62 | (defn ^:async connect! |
| 40 | 63 | "What `frq.actions/connect!` is on the phone. |
| 41 | 64 | |
| @@ -44,42 +67,65 @@ | ||
| 44 | 67 | host, the port and the TLS tick are `frq.cells`, filled in by the entry and |
| 45 | 68 | the checkbutton on screen. |
| 46 | 69 | |
| 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." | |
| 51 | 71 | [] |
| 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)))))) | |
| 83 | 129 | |
| 84 | 130 | (defn- disconnect! [] |
| 85 | 131 | (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 second | 16 | 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 the | 17 | 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 and | 67 | 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 portable | 70 | + 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 | - (try | 76 | + (reset! cells/connecting? true) |
| 57 | - (let [sock (await (net/connect! | 77 | + (reset! cells/error nil) |
| 58 | - {:host @cells/form-host | 78 | + (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/status | 86 | + (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 separate | 95 | + (note! m) |
| 76 | - ;; hierarchies, so a TypeError or a NoSuchMethodError is not an Exception | 96 | + (let [cmd (str (:command m)) |
| 77 | - ;; and would leave `connecting?` true for ever — a spinner that never | 97 | + text (str (last (:params m)))] |
| 78 | - ;; stops and no message saying why, which is exactly what this did. | 98 | + (cond |
| 79 | - (catch Object e | 99 | + (= "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)) |