The core, compiled to JavaScript
`nim js` builds the whole of it — state, reducer, every screen — as `build/web/frq_core.js`, 124 KB gzipped. `just build core-js`, and `just test web` drives it under node. The platform modules are chosen by search path rather than by a conditional: `--path:src --path:web`, where the later wins, so every file in `nim/web/frq` shadows the one beside it in `nim/src/frq`. The shared code above them imports `frq/conn` and `frq/store` exactly as it always did and never learns which host it is on. That is the seam `common/` had against two ClojureDart implementations, and it is a better fit here than `when defined(js)` scattered through a reducer. What the five web modules are: `conn` is a pair of queues. A browser cannot open a TCP socket and does not need to — freeq publishes `wss://irc.freeq.at/irc`, SASL and all — so the host owns the WebSocket, feeds each line in and takes what the core queues. Those two functions are not new: they were added so the reducer could be tested without a socket, and a browser turns out to be the same problem. `store` is localStorage, with a note that the desktop's mode-600 session file has no equivalent there. `profilefetch` is `fetch` and a callback where the desktop has a thread and a channel — which fits only because the desktop's version was already asynchronous. `oauth` is the easy half of the handoff: a browser catches the broker's answer by *being* the page that was redirected. `crypto` is the one that says no. Ed25519 through WebCrypto is asynchronous and every signature here is wanted inline, so this build cannot sign: `msgsig` sees a key with no public half, claims nothing, and the reader is where a guest is. `@noble/ed25519` is the way out and is written down in the file. An app password is out for the same shape of reason — it wants a blocking call to the reader's own PDS — so the web build signs in through the broker or not at all. `frq_web.nim` is to JavaScript what `frq_core.nim` is to C: the only file that knows about the host. No `frq_free` on this side, because a string is a string. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5ac0521 parent: 0fee54c modified
justfile +30 -4 | @@ -55,7 +55,8 @@ build target="desktop": | ||
| 55 | 55 | case "{{target}}" in |
| 56 | 56 | desktop) just _flutter desktop build ;; |
| 57 | 57 | lib) just _nim-lib ;; |
| 58 | - *) echo "usage: just build [desktop|lib]" >&2; exit 1 ;; | |
| 58 | + core-js) just _nim-js ;; | |
| 59 | + *) echo "usage: just build [desktop|lib|core-js]" >&2; exit 1 ;; | |
| 59 | 60 | esac |
| 60 | 61 | |
| 61 | 62 | # Build a target and start it. |
| @@ -80,29 +81,34 @@ run target="desktop": | ||
| 80 | 81 | # dart the Dart side of the FFI boundary, on the plain Dart VM. Passing |
| 81 | 82 | # `nim` and failing this one is a marshalling bug, which is why the |
| 82 | 83 | # two are separate suites. |
| 84 | +# web the JavaScript build of the same core, driven as a browser | |
| 85 | +# drives it. Needs node and nothing else. | |
| 83 | 86 | # live the whole stack against a real freeq. Not in `all`: it wants a |
| 84 | 87 | # network and a running server. |
| 85 | 88 | # |
| 86 | 89 | # just test all of them |
| 87 | 90 | # just test nim tircparse one Nim file |
| 88 | -[doc('run a suite: all nim dart layout live')] | |
| 91 | +[doc('run a suite: all nim dart layout web live')] | |
| 89 | 92 | test suite="all" *args: |
| 90 | 93 | #!/usr/bin/env bash |
| 91 | 94 | set -euo pipefail |
| 92 | 95 | cd "{{root}}" |
| 93 | 96 | shift |
| 94 | 97 | case "{{suite}}" in |
| 95 | - all) just test nim && just test dart && just test layout ;; | |
| 98 | + all) just test nim && just test dart && just test layout \ | |
| 99 | + && just test web ;; | |
| 96 | 100 | layout) just _nim-lib |
| 97 | 101 | just _flutter layout test ;; |
| 98 | 102 | nim) just _nim-test "$@" ;; |
| 103 | + web) just _nim-js | |
| 104 | + exec node nim/web/test/smoke.js build/web/frq_core.js ;; | |
| 99 | 105 | dart) just _nim-lib |
| 100 | 106 | exec "{{tc}}" exec -- bash -c \ |
| 101 | 107 | 'cd dart/frq_core && dart pub get && dart test -r expanded' ;; |
| 102 | 108 | live) just _nim-lib |
| 103 | 109 | exec "{{tc}}" exec -- bash -c \ |
| 104 | 110 | 'cd dart/frq_core && dart pub get >/dev/null && dart run tool/live_ui.dart "$@"' _ "$@" ;; |
| 105 | - *) echo "usage: just test [all|nim|dart|layout|live]" >&2; exit 1 ;; | |
| 111 | + *) echo "usage: just test [all|nim|dart|layout|web|live]" >&2; exit 1 ;; | |
| 106 | 112 | esac |
| 107 | 113 | |
| 108 | 114 | # The containers in `.modal/`, run on Modal rather than here: this machine |
| @@ -124,6 +130,26 @@ modal container="dev" *args: | ||
| 124 | 130 | shift || true |
| 125 | 131 | exec modal run ".modal/{{container}}/container.py" "$@" |
| 126 | 132 | |
| 133 | +# The same core, compiled to JavaScript. | |
| 134 | +# | |
| 135 | +# `--path:src --path:web`, in that order, because the later path wins: every | |
| 136 | +# module under `nim/web/frq` shadows the one beside it in `nim/src/frq`, so | |
| 137 | +# `frq/conn` is a queue the host fills rather than two socket threads, and the | |
| 138 | +# shared code above them never learns which host it is on. | |
| 139 | +[private] | |
| 140 | +_nim-js: | |
| 141 | + #!/usr/bin/env bash | |
| 142 | + set -euo pipefail | |
| 143 | + cd "{{root}}" | |
| 144 | + out="{{root}}/build/web" | |
| 145 | + mkdir -p "$out" | |
| 146 | + exec "{{tc}}" exec -- bash -euo pipefail -c ' | |
| 147 | + cd nim | |
| 148 | + nim js -d:release --hints:off \ | |
| 149 | + --path:src --path:web --out:"'"$out"'/frq_core.js" web/frq_web.nim | |
| 150 | + printf "built %s (%s)\n" "'"$out"'/frq_core.js" \ | |
| 151 | + "$(gzip -9c "'"$out"'/frq_core.js" | wc -c | awk "{printf \"%d KB gzipped\", \$1/1024}")"' | |
| 152 | + | |
| 127 | 153 | [private] |
| 128 | 154 | _nim-lib: |
| 129 | 155 | #!/usr/bin/env bash |
| @@ -55,7 +55,8 @@ build target="desktop": | |||
| 55 | case "{{target}}" in | 55 | case "{{target}}" in |
| 56 | desktop) just _flutter desktop build ;; | 56 | desktop) just _flutter desktop build ;; |
| 57 | lib) just _nim-lib ;; | 57 | lib) just _nim-lib ;; |
| 58 | - *) echo "usage: just build [desktop|lib]" >&2; exit 1 ;; | 58 | + core-js) just _nim-js ;; |
| 59 | + *) echo "usage: just build [desktop|lib|core-js]" >&2; exit 1 ;; | ||
| 59 | esac | 60 | esac |
| 60 | 61 | ||
| 61 | # Build a target and start it. | 62 | # Build a target and start it. |
| @@ -80,29 +81,34 @@ run target="desktop": | |||
| 80 | # dart the Dart side of the FFI boundary, on the plain Dart VM. Passing | 81 | # dart the Dart side of the FFI boundary, on the plain Dart VM. Passing |
| 81 | # `nim` and failing this one is a marshalling bug, which is why the | 82 | # `nim` and failing this one is a marshalling bug, which is why the |
| 82 | # two are separate suites. | 83 | # two are separate suites. |
| 84 | +# web the JavaScript build of the same core, driven as a browser | ||
| 85 | +# drives it. Needs node and nothing else. | ||
| 83 | # live the whole stack against a real freeq. Not in `all`: it wants a | 86 | # live the whole stack against a real freeq. Not in `all`: it wants a |
| 84 | # network and a running server. | 87 | # network and a running server. |
| 85 | # | 88 | # |
| 86 | # just test all of them | 89 | # just test all of them |
| 87 | # just test nim tircparse one Nim file | 90 | # just test nim tircparse one Nim file |
| 88 | -[doc('run a suite: all nim dart layout live')] | 91 | +[doc('run a suite: all nim dart layout web live')] |
| 89 | test suite="all" *args: | 92 | test suite="all" *args: |
| 90 | #!/usr/bin/env bash | 93 | #!/usr/bin/env bash |
| 91 | set -euo pipefail | 94 | set -euo pipefail |
| 92 | cd "{{root}}" | 95 | cd "{{root}}" |
| 93 | shift | 96 | shift |
| 94 | case "{{suite}}" in | 97 | case "{{suite}}" in |
| 95 | - all) just test nim && just test dart && just test layout ;; | 98 | + all) just test nim && just test dart && just test layout \ |
| 99 | + && just test web ;; | ||
| 96 | layout) just _nim-lib | 100 | layout) just _nim-lib |
| 97 | just _flutter layout test ;; | 101 | just _flutter layout test ;; |
| 98 | nim) just _nim-test "$@" ;; | 102 | nim) just _nim-test "$@" ;; |
| 103 | + web) just _nim-js | ||
| 104 | + exec node nim/web/test/smoke.js build/web/frq_core.js ;; | ||
| 99 | dart) just _nim-lib | 105 | dart) just _nim-lib |
| 100 | exec "{{tc}}" exec -- bash -c \ | 106 | exec "{{tc}}" exec -- bash -c \ |
| 101 | 'cd dart/frq_core && dart pub get && dart test -r expanded' ;; | 107 | 'cd dart/frq_core && dart pub get && dart test -r expanded' ;; |
| 102 | live) just _nim-lib | 108 | live) just _nim-lib |
| 103 | exec "{{tc}}" exec -- bash -c \ | 109 | exec "{{tc}}" exec -- bash -c \ |
| 104 | 'cd dart/frq_core && dart pub get >/dev/null && dart run tool/live_ui.dart "$@"' _ "$@" ;; | 110 | 'cd dart/frq_core && dart pub get >/dev/null && dart run tool/live_ui.dart "$@"' _ "$@" ;; |
| 105 | - *) echo "usage: just test [all|nim|dart|layout|live]" >&2; exit 1 ;; | 111 | + *) echo "usage: just test [all|nim|dart|layout|web|live]" >&2; exit 1 ;; |
| 106 | esac | 112 | esac |
| 107 | 113 | ||
| 108 | # The containers in `.modal/`, run on Modal rather than here: this machine | 114 | # The containers in `.modal/`, run on Modal rather than here: this machine |
| @@ -124,6 +130,26 @@ modal container="dev" *args: | |||
| 124 | shift || true | 130 | shift || true |
| 125 | exec modal run ".modal/{{container}}/container.py" "$@" | 131 | exec modal run ".modal/{{container}}/container.py" "$@" |
| 126 | 132 | ||
| 133 | +# The same core, compiled to JavaScript. | ||
| 134 | +# | ||
| 135 | +# `--path:src --path:web`, in that order, because the later path wins: every | ||
| 136 | +# module under `nim/web/frq` shadows the one beside it in `nim/src/frq`, so | ||
| 137 | +# `frq/conn` is a queue the host fills rather than two socket threads, and the | ||
| 138 | +# shared code above them never learns which host it is on. | ||
| 139 | +[private] | ||
| 140 | +_nim-js: | ||
| 141 | + #!/usr/bin/env bash | ||
| 142 | + set -euo pipefail | ||
| 143 | + cd "{{root}}" | ||
| 144 | + out="{{root}}/build/web" | ||
| 145 | + mkdir -p "$out" | ||
| 146 | + exec "{{tc}}" exec -- bash -euo pipefail -c ' | ||
| 147 | + cd nim | ||
| 148 | + nim js -d:release --hints:off \ | ||
| 149 | + --path:src --path:web --out:"'"$out"'/frq_core.js" web/frq_web.nim | ||
| 150 | + printf "built %s (%s)\n" "'"$out"'/frq_core.js" \ | ||
| 151 | + "$(gzip -9c "'"$out"'/frq_core.js" | wc -c | awk "{printf \"%d KB gzipped\", \$1/1024}")"' | ||
| 152 | + | ||
| 127 | [private] | 153 | [private] |
| 128 | _nim-lib: | 154 | _nim-lib: |
| 129 | #!/usr/bin/env bash | 155 | #!/usr/bin/env bash |
modified
nim/src/frq/msgsig.nim +12 -1 | @@ -51,7 +51,18 @@ proc forget*() = | ||
| 51 | 51 | proc generate*(did: string): string = |
| 52 | 52 | ## Mint a key for this connection and answer with its public half, base64url |
| 53 | 53 | ## — which is what goes out as `MSGSIG <pub>`. |
| 54 | - signer = Signer(has: true, did: did, key: newKey()) | |
| 54 | + ## | |
| 55 | + ## A key with no public half is a build that cannot sign — the web one, so | |
| 56 | + ## far, where Ed25519 is asynchronous and this is not. Nothing is claimed in | |
| 57 | + ## that case: `signedIn` stays false, no MSGSIG goes out, and the server | |
| 58 | + ## treats these lines as it treats a guest's. Better than announcing a key | |
| 59 | + ## and then failing to sign with it. | |
| 60 | + let key = newKey() | |
| 61 | + if key.public.len == 0: | |
| 62 | + signer = Signer() | |
| 63 | + trace("msgsig", "no signing in this build; lines go out unsigned") | |
| 64 | + return "" | |
| 65 | + signer = Signer(has: true, did: did, key: key) | |
| 55 | 66 | signer.kid = b64url(signer.key.public)[0 ..< 16] |
| 56 | 67 | trace("msgsig", "key for " & did & " kid=" & signer.kid) |
| 57 | 68 | b64url(signer.key.public) |
| @@ -51,7 +51,18 @@ proc forget*() = | |||
| 51 | proc generate*(did: string): string = | 51 | proc generate*(did: string): string = |
| 52 | ## Mint a key for this connection and answer with its public half, base64url | 52 | ## Mint a key for this connection and answer with its public half, base64url |
| 53 | ## — which is what goes out as `MSGSIG <pub>`. | 53 | ## — which is what goes out as `MSGSIG <pub>`. |
| 54 | - signer = Signer(has: true, did: did, key: newKey()) | 54 | + ## |
| 55 | + ## A key with no public half is a build that cannot sign — the web one, so | ||
| 56 | + ## far, where Ed25519 is asynchronous and this is not. Nothing is claimed in | ||
| 57 | + ## that case: `signedIn` stays false, no MSGSIG goes out, and the server | ||
| 58 | + ## treats these lines as it treats a guest's. Better than announcing a key | ||
| 59 | + ## and then failing to sign with it. | ||
| 60 | + let key = newKey() | ||
| 61 | + if key.public.len == 0: | ||
| 62 | + signer = Signer() | ||
| 63 | + trace("msgsig", "no signing in this build; lines go out unsigned") | ||
| 64 | + return "" | ||
| 65 | + signer = Signer(has: true, did: did, key: key) | ||
| 55 | signer.kid = b64url(signer.key.public)[0 ..< 16] | 66 | signer.kid = b64url(signer.key.public)[0 ..< 16] |
| 56 | trace("msgsig", "key for " & did & " kid=" & signer.kid) | 67 | trace("msgsig", "key for " & did & " kid=" & signer.kid) |
| 57 | b64url(signer.key.public) | 68 | b64url(signer.key.public) |
added
nim/web/frq/atproto.nim +35 -0 | new file mode 100644 | ||
| @@ -0,0 +1,35 @@ | ||
| 1 | +## Resolving an identity, in a browser. | |
| 2 | +## | |
| 3 | +## Not here, and that is the honest answer rather than a missing one. Every | |
| 4 | +## call in the desktop's half is a blocking HTTPS round trip, and `fetch` is | |
| 5 | +## asynchronous: a browser cannot be asked these questions and answer them on | |
| 6 | +## the same line. | |
| 7 | +## | |
| 8 | +## The web build therefore signs in one way, through the broker, which is the | |
| 9 | +## one flow where the answers arrive as a redirect rather than as a return | |
| 10 | +## value. An app password wants `createSession` against the reader's own PDS, | |
| 11 | +## which is exactly the shape that does not fit — and is why the connect | |
| 12 | +## screen's third tab is not offered here. | |
| 13 | +## | |
| 14 | +## `atprotocore` is what remains, and is what the handshake actually needs: | |
| 15 | +## what a session is, and the SASL payload built from one. | |
| 16 | + | |
| 17 | +import std/json | |
| 18 | +import frq/atprotocore | |
| 19 | +export atprotocore | |
| 20 | + | |
| 21 | +type NotOnTheWeb = object of CatchableError | |
| 22 | + | |
| 23 | +proc resolveHandle*(handle: string): string = | |
| 24 | + raise newException(NotOnTheWeb, "Handles are resolved by the broker here.") | |
| 25 | + | |
| 26 | +proc pdsFor*(did: string): string = | |
| 27 | + raise newException(NotOnTheWeb, "PDS lookup is not available in the browser.") | |
| 28 | + | |
| 29 | +proc getProfile*(actor: string): JsonNode = | |
| 30 | + raise newException(NotOnTheWeb, "Profiles are fetched by the host here.") | |
| 31 | + | |
| 32 | +proc createSession*(handle, password: string): Session = | |
| 33 | + raise newException(NotOnTheWeb, | |
| 34 | + "An app password needs a call to your PDS, which a page cannot make. " & | |
| 35 | + "Sign in with Bluesky instead.") | |
| new file mode 100644 | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | +## Resolving an identity, in a browser. | ||
| 2 | +## | ||
| 3 | +## Not here, and that is the honest answer rather than a missing one. Every | ||
| 4 | +## call in the desktop's half is a blocking HTTPS round trip, and `fetch` is | ||
| 5 | +## asynchronous: a browser cannot be asked these questions and answer them on | ||
| 6 | +## the same line. | ||
| 7 | +## | ||
| 8 | +## The web build therefore signs in one way, through the broker, which is the | ||
| 9 | +## one flow where the answers arrive as a redirect rather than as a return | ||
| 10 | +## value. An app password wants `createSession` against the reader's own PDS, | ||
| 11 | +## which is exactly the shape that does not fit — and is why the connect | ||
| 12 | +## screen's third tab is not offered here. | ||
| 13 | +## | ||
| 14 | +## `atprotocore` is what remains, and is what the handshake actually needs: | ||
| 15 | +## what a session is, and the SASL payload built from one. | ||
| 16 | + | ||
| 17 | +import std/json | ||
| 18 | +import frq/atprotocore | ||
| 19 | +export atprotocore | ||
| 20 | + | ||
| 21 | +type NotOnTheWeb = object of CatchableError | ||
| 22 | + | ||
| 23 | +proc resolveHandle*(handle: string): string = | ||
| 24 | + raise newException(NotOnTheWeb, "Handles are resolved by the broker here.") | ||
| 25 | + | ||
| 26 | +proc pdsFor*(did: string): string = | ||
| 27 | + raise newException(NotOnTheWeb, "PDS lookup is not available in the browser.") | ||
| 28 | + | ||
| 29 | +proc getProfile*(actor: string): JsonNode = | ||
| 30 | + raise newException(NotOnTheWeb, "Profiles are fetched by the host here.") | ||
| 31 | + | ||
| 32 | +proc createSession*(handle, password: string): Session = | ||
| 33 | + raise newException(NotOnTheWeb, | ||
| 34 | + "An app password needs a call to your PDS, which a page cannot make. " & | ||
| 35 | + "Sign in with Bluesky instead.") | ||
added
nim/web/frq/conn.nim +61 -0 | new file mode 100644 | ||
| @@ -0,0 +1,61 @@ | ||
| 1 | +## The transport, in a browser. | |
| 2 | +## | |
| 3 | +## The same three operations `frq/conn` has on a desktop — connect, send, | |
| 4 | +## close — and the same two queues the reducer drains. What is different is | |
| 5 | +## who owns the socket: there, two threads and an OpenSSL connection to | |
| 6 | +## :6697; here, a WebSocket in JavaScript, because a browser cannot open a | |
| 7 | +## TCP socket and does not need to. freeq publishes `wss://irc.freeq.at/irc` | |
| 8 | +## for exactly this, SASL and all. | |
| 9 | +## | |
| 10 | +## So nothing here dials anything. The host calls `feed` with each line that | |
| 11 | +## arrives and `event` when the socket opens or closes, and takes what this | |
| 12 | +## queues with `tryOutbound`. Those two are not new: they were added so the | |
| 13 | +## reducer's answer to a line could be tested without a socket, and a browser | |
| 14 | +## is the same problem — something else owns the I/O. | |
| 15 | + | |
| 16 | +import std/deques | |
| 17 | + | |
| 18 | +type ConnConfig* = object | |
| 19 | + host*: string | |
| 20 | + port*: int | |
| 21 | + tls*: bool | |
| 22 | + | |
| 23 | +var | |
| 24 | + inbound: Deque[string] | |
| 25 | + outbound: Deque[string] | |
| 26 | + events: Deque[string] | |
| 27 | + running: bool | |
| 28 | + want: ConnConfig | |
| 29 | + ## What the host should connect to, once it asks. | |
| 30 | + | |
| 31 | +proc open*(cfg: ConnConfig) = | |
| 32 | + ## Not a connection: a request for one. The host reads `wanted` and opens | |
| 33 | + ## the WebSocket itself, then says `open` through `event`. | |
| 34 | + inbound.clear() | |
| 35 | + events.clear() | |
| 36 | + outbound.clear() | |
| 37 | + want = cfg | |
| 38 | + running = true | |
| 39 | + | |
| 40 | +proc wanted*(): ConnConfig = want | |
| 41 | + ## Where the host is being asked to connect. A desktop would have dialled | |
| 42 | + ## by now; a browser is being told what to dial. | |
| 43 | + | |
| 44 | +proc send*(line: string) = outbound.addLast(line) | |
| 45 | +proc tryOutbound*(): (bool, string) = | |
| 46 | + if outbound.len == 0: (false, "") else: (true, outbound.popFirst()) | |
| 47 | + | |
| 48 | +proc feed*(line: string) = inbound.addLast(line) | |
| 49 | +proc event*(e: string) = events.addLast(e) | |
| 50 | + | |
| 51 | +proc close*() = | |
| 52 | + running = false | |
| 53 | + events.addLast("close: ") | |
| 54 | + | |
| 55 | +proc tryLine*(): (bool, string) = | |
| 56 | + if inbound.len == 0: (false, "") else: (true, inbound.popFirst()) | |
| 57 | + | |
| 58 | +proc tryEvent*(): (bool, string) = | |
| 59 | + if events.len == 0: (false, "") else: (true, events.popFirst()) | |
| 60 | + | |
| 61 | +proc isRunning*(): bool = running | |
| new file mode 100644 | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | +## The transport, in a browser. | ||
| 2 | +## | ||
| 3 | +## The same three operations `frq/conn` has on a desktop — connect, send, | ||
| 4 | +## close — and the same two queues the reducer drains. What is different is | ||
| 5 | +## who owns the socket: there, two threads and an OpenSSL connection to | ||
| 6 | +## :6697; here, a WebSocket in JavaScript, because a browser cannot open a | ||
| 7 | +## TCP socket and does not need to. freeq publishes `wss://irc.freeq.at/irc` | ||
| 8 | +## for exactly this, SASL and all. | ||
| 9 | +## | ||
| 10 | +## So nothing here dials anything. The host calls `feed` with each line that | ||
| 11 | +## arrives and `event` when the socket opens or closes, and takes what this | ||
| 12 | +## queues with `tryOutbound`. Those two are not new: they were added so the | ||
| 13 | +## reducer's answer to a line could be tested without a socket, and a browser | ||
| 14 | +## is the same problem — something else owns the I/O. | ||
| 15 | + | ||
| 16 | +import std/deques | ||
| 17 | + | ||
| 18 | +type ConnConfig* = object | ||
| 19 | + host*: string | ||
| 20 | + port*: int | ||
| 21 | + tls*: bool | ||
| 22 | + | ||
| 23 | +var | ||
| 24 | + inbound: Deque[string] | ||
| 25 | + outbound: Deque[string] | ||
| 26 | + events: Deque[string] | ||
| 27 | + running: bool | ||
| 28 | + want: ConnConfig | ||
| 29 | + ## What the host should connect to, once it asks. | ||
| 30 | + | ||
| 31 | +proc open*(cfg: ConnConfig) = | ||
| 32 | + ## Not a connection: a request for one. The host reads `wanted` and opens | ||
| 33 | + ## the WebSocket itself, then says `open` through `event`. | ||
| 34 | + inbound.clear() | ||
| 35 | + events.clear() | ||
| 36 | + outbound.clear() | ||
| 37 | + want = cfg | ||
| 38 | + running = true | ||
| 39 | + | ||
| 40 | +proc wanted*(): ConnConfig = want | ||
| 41 | + ## Where the host is being asked to connect. A desktop would have dialled | ||
| 42 | + ## by now; a browser is being told what to dial. | ||
| 43 | + | ||
| 44 | +proc send*(line: string) = outbound.addLast(line) | ||
| 45 | +proc tryOutbound*(): (bool, string) = | ||
| 46 | + if outbound.len == 0: (false, "") else: (true, outbound.popFirst()) | ||
| 47 | + | ||
| 48 | +proc feed*(line: string) = inbound.addLast(line) | ||
| 49 | +proc event*(e: string) = events.addLast(e) | ||
| 50 | + | ||
| 51 | +proc close*() = | ||
| 52 | + running = false | ||
| 53 | + events.addLast("close: ") | ||
| 54 | + | ||
| 55 | +proc tryLine*(): (bool, string) = | ||
| 56 | + if inbound.len == 0: (false, "") else: (true, inbound.popFirst()) | ||
| 57 | + | ||
| 58 | +proc tryEvent*(): (bool, string) = | ||
| 59 | + if events.len == 0: (false, "") else: (true, events.popFirst()) | ||
| 60 | + | ||
| 61 | +proc isRunning*(): bool = running | ||
added
nim/web/frq/crypto.nim +61 -0 | new file mode 100644 | ||
| @@ -0,0 +1,61 @@ | ||
| 1 | +## Signing, in a browser — or rather, not yet. | |
| 2 | +## | |
| 3 | +## The desktop binds OpenSSL's EVP interface for Ed25519 and SHA-256. A | |
| 4 | +## browser has neither that library nor a synchronous way to do the same | |
| 5 | +## work: WebCrypto signs through a Promise, and everything that wants a | |
| 6 | +## signature here wants it inline, on the line being sent. | |
| 7 | +## | |
| 8 | +## So this says so, rather than pretending. `newKey` returns a pair with no | |
| 9 | +## public half, `msgsig` sees that and does not claim to be signing, and the | |
| 10 | +## reader is in the position a guest is in: their lines are relayed by the | |
| 11 | +## server with the server's word for who sent them, and freeq refuses the | |
| 12 | +## reactions and edits that need an author's signature. | |
| 13 | +## | |
| 14 | +## The way out is a synchronous Ed25519 in JavaScript — `@noble/ed25519` has | |
| 15 | +## one, and is the same sort of answer as binding OpenSSL: somebody else's | |
| 16 | +## audited implementation, not ours. That needs a bundling step this build | |
| 17 | +## does not have yet, which is why it is written down here instead of done. | |
| 18 | + | |
| 19 | +import std/times | |
| 20 | + | |
| 21 | +type | |
| 22 | + KeyPair* = object | |
| 23 | + public*: seq[byte] | |
| 24 | + private*: seq[byte] | |
| 25 | + | |
| 26 | + CryptoError* = object of CatchableError | |
| 27 | + | |
| 28 | +proc randomBytes*(n: int): seq[byte] = | |
| 29 | + ## From the platform's CSPRNG, through `getRandomValues`. | |
| 30 | + result = newSeq[byte](n) | |
| 31 | + {.emit: """ | |
| 32 | + var buf = new Uint8Array(`n`); | |
| 33 | + (globalThis.crypto || window.crypto).getRandomValues(buf); | |
| 34 | + for (var i = 0; i < `n`; i++) { `result`[i] = buf[i]; } | |
| 35 | + """.} | |
| 36 | + | |
| 37 | +proc sha256*(data: openArray[byte]): array[32, byte] = | |
| 38 | + raise newException(CryptoError, "SHA-256 is not available in this build") | |
| 39 | + | |
| 40 | +proc sha256*(s: string): array[32, byte] = | |
| 41 | + raise newException(CryptoError, "SHA-256 is not available in this build") | |
| 42 | + | |
| 43 | +func toHex*(bs: openArray[byte]): string = | |
| 44 | + const hex = "0123456789abcdef" | |
| 45 | + for b in bs: | |
| 46 | + result.add hex[int(b shr 4)] | |
| 47 | + result.add hex[int(b and 0x0f)] | |
| 48 | + | |
| 49 | +proc keyFromSeed*(seed: openArray[byte]): KeyPair = KeyPair() | |
| 50 | + | |
| 51 | +proc newKey*(): KeyPair = KeyPair() | |
| 52 | + ## No public half, which is how `msgsig` knows there is nothing to sign | |
| 53 | + ## with. Deliberately not an exception: being unable to sign is a thing | |
| 54 | + ## this client can carry on without, and a sign-in that threw here would | |
| 55 | + ## take the whole connection with it. | |
| 56 | + | |
| 57 | +proc sign*(key: KeyPair, msg: openArray[byte]): array[64, byte] = | |
| 58 | + raise newException(CryptoError, "signing is not available in this build") | |
| 59 | + | |
| 60 | +proc sign*(key: KeyPair, s: string): array[64, byte] = | |
| 61 | + raise newException(CryptoError, "signing is not available in this build") | |
| new file mode 100644 | |||
| @@ -0,0 +1,61 @@ | |||
| 1 | +## Signing, in a browser — or rather, not yet. | ||
| 2 | +## | ||
| 3 | +## The desktop binds OpenSSL's EVP interface for Ed25519 and SHA-256. A | ||
| 4 | +## browser has neither that library nor a synchronous way to do the same | ||
| 5 | +## work: WebCrypto signs through a Promise, and everything that wants a | ||
| 6 | +## signature here wants it inline, on the line being sent. | ||
| 7 | +## | ||
| 8 | +## So this says so, rather than pretending. `newKey` returns a pair with no | ||
| 9 | +## public half, `msgsig` sees that and does not claim to be signing, and the | ||
| 10 | +## reader is in the position a guest is in: their lines are relayed by the | ||
| 11 | +## server with the server's word for who sent them, and freeq refuses the | ||
| 12 | +## reactions and edits that need an author's signature. | ||
| 13 | +## | ||
| 14 | +## The way out is a synchronous Ed25519 in JavaScript — `@noble/ed25519` has | ||
| 15 | +## one, and is the same sort of answer as binding OpenSSL: somebody else's | ||
| 16 | +## audited implementation, not ours. That needs a bundling step this build | ||
| 17 | +## does not have yet, which is why it is written down here instead of done. | ||
| 18 | + | ||
| 19 | +import std/times | ||
| 20 | + | ||
| 21 | +type | ||
| 22 | + KeyPair* = object | ||
| 23 | + public*: seq[byte] | ||
| 24 | + private*: seq[byte] | ||
| 25 | + | ||
| 26 | + CryptoError* = object of CatchableError | ||
| 27 | + | ||
| 28 | +proc randomBytes*(n: int): seq[byte] = | ||
| 29 | + ## From the platform's CSPRNG, through `getRandomValues`. | ||
| 30 | + result = newSeq[byte](n) | ||
| 31 | + {.emit: """ | ||
| 32 | + var buf = new Uint8Array(`n`); | ||
| 33 | + (globalThis.crypto || window.crypto).getRandomValues(buf); | ||
| 34 | + for (var i = 0; i < `n`; i++) { `result`[i] = buf[i]; } | ||
| 35 | + """.} | ||
| 36 | + | ||
| 37 | +proc sha256*(data: openArray[byte]): array[32, byte] = | ||
| 38 | + raise newException(CryptoError, "SHA-256 is not available in this build") | ||
| 39 | + | ||
| 40 | +proc sha256*(s: string): array[32, byte] = | ||
| 41 | + raise newException(CryptoError, "SHA-256 is not available in this build") | ||
| 42 | + | ||
| 43 | +func toHex*(bs: openArray[byte]): string = | ||
| 44 | + const hex = "0123456789abcdef" | ||
| 45 | + for b in bs: | ||
| 46 | + result.add hex[int(b shr 4)] | ||
| 47 | + result.add hex[int(b and 0x0f)] | ||
| 48 | + | ||
| 49 | +proc keyFromSeed*(seed: openArray[byte]): KeyPair = KeyPair() | ||
| 50 | + | ||
| 51 | +proc newKey*(): KeyPair = KeyPair() | ||
| 52 | + ## No public half, which is how `msgsig` knows there is nothing to sign | ||
| 53 | + ## with. Deliberately not an exception: being unable to sign is a thing | ||
| 54 | + ## this client can carry on without, and a sign-in that threw here would | ||
| 55 | + ## take the whole connection with it. | ||
| 56 | + | ||
| 57 | +proc sign*(key: KeyPair, msg: openArray[byte]): array[64, byte] = | ||
| 58 | + raise newException(CryptoError, "signing is not available in this build") | ||
| 59 | + | ||
| 60 | +proc sign*(key: KeyPair, s: string): array[64, byte] = | ||
| 61 | + raise newException(CryptoError, "signing is not available in this build") | ||
added
nim/web/frq/oauth.nim +73 -0 | new file mode 100644 | ||
| @@ -0,0 +1,73 @@ | ||
| 1 | +## The broker handoff, in a browser — which is the easy half of it. | |
| 2 | +## | |
| 3 | +## A desktop has to catch the broker's answer: bind a loopback port, open a | |
| 4 | +## browser at it, serve a page whose one job is to post the fragment back. | |
| 5 | +## None of that is needed here, because the browser *is* the thing being | |
| 6 | +## redirected. `begin` sends the page to the broker; the broker sends it back | |
| 7 | +## with the payload in the fragment; the host reads the fragment and calls | |
| 8 | +## `handoff`. | |
| 9 | +## | |
| 10 | +## `oauthcore` holds the parts that are the same either way — the login URL, | |
| 11 | +## and the payload once it is in hand. | |
| 12 | + | |
| 13 | +import std/[deques, strutils] | |
| 14 | +import frq/[oauthcore, trace] | |
| 15 | +export oauthcore | |
| 16 | + | |
| 17 | +var | |
| 18 | + events: Deque[string] ## "url: …" | "ok: …" | "error: …" | |
| 19 | + running: bool | |
| 20 | + | |
| 21 | +{.emit: """ | |
| 22 | +function frqGoTo(url) { window.location.href = url; } | |
| 23 | +function frqHere() { | |
| 24 | + // Without the fragment: `return_to` is where the broker sends the reader | |
| 25 | + // back, and it must be this page rather than this page plus whatever is | |
| 26 | + // already hanging off it. | |
| 27 | + return window.location.origin + window.location.pathname; | |
| 28 | +} | |
| 29 | +function frqRefreshSession(broker, token, done) { | |
| 30 | + fetch("https://" + broker + "/session", { | |
| 31 | + method: "POST", | |
| 32 | + headers: {"Content-Type": "application/json"}, | |
| 33 | + body: JSON.stringify({broker_token: token}), | |
| 34 | + }).then(function (r) { return r.text(); }) | |
| 35 | + .then(function (t) { done(t); }) | |
| 36 | + .catch(function (e) { done(""); }); | |
| 37 | +} | |
| 38 | +""".} | |
| 39 | + | |
| 40 | +proc goTo(url: cstring) {.importc: "frqGoTo".} | |
| 41 | +proc here(): cstring {.importc: "frqHere".} | |
| 42 | + | |
| 43 | +proc begin*(broker, handle: string, openBrowser = true) = | |
| 44 | + ## Leave for the broker. There is no waiting to do: this page is about to | |
| 45 | + ## stop existing, and what comes back comes back as a fresh load with the | |
| 46 | + ## payload in the fragment. | |
| 47 | + running = true | |
| 48 | + let url = loginUrl(broker, handle, $here()) | |
| 49 | + trace("oauth", "leaving for " & url) | |
| 50 | + events.addLast("url: " & url) | |
| 51 | + goTo(url.cstring) | |
| 52 | + | |
| 53 | +proc handoff*(payload: string) = | |
| 54 | + ## The fragment this page came back with, from the host. | |
| 55 | + if payload.len == 0: return | |
| 56 | + running = true | |
| 57 | + events.addLast("ok: " & payload.strip()) | |
| 58 | + | |
| 59 | +proc failed*(reason: string) = | |
| 60 | + running = true | |
| 61 | + events.addLast("error: " & reason) | |
| 62 | + | |
| 63 | +proc cancel*() = running = false | |
| 64 | +proc finished*() = running = false | |
| 65 | +proc waiting*(): bool = running | |
| 66 | +proc tryEvent*(): (bool, string) = | |
| 67 | + if events.len == 0: (false, "") else: (true, events.popFirst()) | |
| 68 | + | |
| 69 | +proc refreshSession*(broker, brokerToken: string): Tokens = | |
| 70 | + ## Not here. `fetch` is asynchronous and this is not, so on the web a | |
| 71 | + ## remembered token is spent by the host: it calls the broker, and hands | |
| 72 | + ## what comes back to `handoff` exactly as a fresh sign-in would. | |
| 73 | + raise newException(OauthError, "the host refreshes the session on the web") | |
| new file mode 100644 | |||
| @@ -0,0 +1,73 @@ | |||
| 1 | +## The broker handoff, in a browser — which is the easy half of it. | ||
| 2 | +## | ||
| 3 | +## A desktop has to catch the broker's answer: bind a loopback port, open a | ||
| 4 | +## browser at it, serve a page whose one job is to post the fragment back. | ||
| 5 | +## None of that is needed here, because the browser *is* the thing being | ||
| 6 | +## redirected. `begin` sends the page to the broker; the broker sends it back | ||
| 7 | +## with the payload in the fragment; the host reads the fragment and calls | ||
| 8 | +## `handoff`. | ||
| 9 | +## | ||
| 10 | +## `oauthcore` holds the parts that are the same either way — the login URL, | ||
| 11 | +## and the payload once it is in hand. | ||
| 12 | + | ||
| 13 | +import std/[deques, strutils] | ||
| 14 | +import frq/[oauthcore, trace] | ||
| 15 | +export oauthcore | ||
| 16 | + | ||
| 17 | +var | ||
| 18 | + events: Deque[string] ## "url: …" | "ok: …" | "error: …" | ||
| 19 | + running: bool | ||
| 20 | + | ||
| 21 | +{.emit: """ | ||
| 22 | +function frqGoTo(url) { window.location.href = url; } | ||
| 23 | +function frqHere() { | ||
| 24 | + // Without the fragment: `return_to` is where the broker sends the reader | ||
| 25 | + // back, and it must be this page rather than this page plus whatever is | ||
| 26 | + // already hanging off it. | ||
| 27 | + return window.location.origin + window.location.pathname; | ||
| 28 | +} | ||
| 29 | +function frqRefreshSession(broker, token, done) { | ||
| 30 | + fetch("https://" + broker + "/session", { | ||
| 31 | + method: "POST", | ||
| 32 | + headers: {"Content-Type": "application/json"}, | ||
| 33 | + body: JSON.stringify({broker_token: token}), | ||
| 34 | + }).then(function (r) { return r.text(); }) | ||
| 35 | + .then(function (t) { done(t); }) | ||
| 36 | + .catch(function (e) { done(""); }); | ||
| 37 | +} | ||
| 38 | +""".} | ||
| 39 | + | ||
| 40 | +proc goTo(url: cstring) {.importc: "frqGoTo".} | ||
| 41 | +proc here(): cstring {.importc: "frqHere".} | ||
| 42 | + | ||
| 43 | +proc begin*(broker, handle: string, openBrowser = true) = | ||
| 44 | + ## Leave for the broker. There is no waiting to do: this page is about to | ||
| 45 | + ## stop existing, and what comes back comes back as a fresh load with the | ||
| 46 | + ## payload in the fragment. | ||
| 47 | + running = true | ||
| 48 | + let url = loginUrl(broker, handle, $here()) | ||
| 49 | + trace("oauth", "leaving for " & url) | ||
| 50 | + events.addLast("url: " & url) | ||
| 51 | + goTo(url.cstring) | ||
| 52 | + | ||
| 53 | +proc handoff*(payload: string) = | ||
| 54 | + ## The fragment this page came back with, from the host. | ||
| 55 | + if payload.len == 0: return | ||
| 56 | + running = true | ||
| 57 | + events.addLast("ok: " & payload.strip()) | ||
| 58 | + | ||
| 59 | +proc failed*(reason: string) = | ||
| 60 | + running = true | ||
| 61 | + events.addLast("error: " & reason) | ||
| 62 | + | ||
| 63 | +proc cancel*() = running = false | ||
| 64 | +proc finished*() = running = false | ||
| 65 | +proc waiting*(): bool = running | ||
| 66 | +proc tryEvent*(): (bool, string) = | ||
| 67 | + if events.len == 0: (false, "") else: (true, events.popFirst()) | ||
| 68 | + | ||
| 69 | +proc refreshSession*(broker, brokerToken: string): Tokens = | ||
| 70 | + ## Not here. `fetch` is asynchronous and this is not, so on the web a | ||
| 71 | + ## remembered token is spent by the host: it calls the broker, and hands | ||
| 72 | + ## what comes back to `handoff` exactly as a fresh sign-in would. | ||
| 73 | + raise newException(OauthError, "the host refreshes the session on the web") | ||
added
nim/web/frq/profilefetch.nim +53 -0 | new file mode 100644 | ||
| @@ -0,0 +1,53 @@ | ||
| 1 | +## Going and getting a profile, in a browser. | |
| 2 | +## | |
| 3 | +## The desktop runs a worker thread with a channel each way. Here the shape is | |
| 4 | +## the same and the machinery is nothing: `want` asks JavaScript to fetch, the | |
| 5 | +## answer comes back through `arrived`, and `collect` folds it in — the same | |
| 6 | +## three steps, with `fetch` and a callback where the other has a thread and a | |
| 7 | +## channel. | |
| 8 | +## | |
| 9 | +## That the desktop's version is already asynchronous is why this fits at all. | |
| 10 | +## A blocking fetch would have had nowhere to go. | |
| 11 | + | |
| 12 | +import std/[deques, json] | |
| 13 | +import frq/profile | |
| 14 | + | |
| 15 | +var answers: Deque[(string, string)] ## actor, body — the body empty on failure | |
| 16 | + | |
| 17 | +{.emit: """ | |
| 18 | +function frqFetchProfile(actor, done) { | |
| 19 | + fetch("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=" | |
| 20 | + + encodeURIComponent(actor)) | |
| 21 | + .then(function (r) { return r.ok ? r.text() : ""; }) | |
| 22 | + .then(function (t) { done(actor, t); }) | |
| 23 | + .catch(function () { done(actor, ""); }); | |
| 24 | +} | |
| 25 | +""".} | |
| 26 | + | |
| 27 | +proc fetchProfile(actor: cstring, done: proc (a, b: cstring)) | |
| 28 | + {.importc: "frqFetchProfile".} | |
| 29 | + | |
| 30 | +proc arrived(actor, body: cstring) = | |
| 31 | + answers.addLast(($actor, $body)) | |
| 32 | + | |
| 33 | +proc want*(actor: string) = | |
| 34 | + ## Once per identity for the run, as on the desktop: `psLoading` goes in the | |
| 35 | + ## cache here, so a second ask while the first is in flight is not a second | |
| 36 | + ## round trip. | |
| 37 | + if actor.len == 0 or known(actor) or isAgent(actor): return | |
| 38 | + remember(actor, Profile(status: psLoading)) | |
| 39 | + fetchProfile(actor.cstring, arrived) | |
| 40 | + | |
| 41 | +proc collect*(): bool = | |
| 42 | + ## Fold whatever has come back into the cache. True where anything did. | |
| 43 | + while answers.len > 0: | |
| 44 | + let (actor, body) = answers.popFirst() | |
| 45 | + remember(actor, | |
| 46 | + if body.len == 0: Profile(status: psFailed) | |
| 47 | + else: | |
| 48 | + try: | |
| 49 | + let j = parseJson(body) | |
| 50 | + if j{"did"}.getStr().len > 0: parseProfile(j) | |
| 51 | + else: Profile(status: psFailed) | |
| 52 | + except CatchableError: Profile(status: psFailed)) | |
| 53 | + result = true | |
| new file mode 100644 | |||
| @@ -0,0 +1,53 @@ | |||
| 1 | +## Going and getting a profile, in a browser. | ||
| 2 | +## | ||
| 3 | +## The desktop runs a worker thread with a channel each way. Here the shape is | ||
| 4 | +## the same and the machinery is nothing: `want` asks JavaScript to fetch, the | ||
| 5 | +## answer comes back through `arrived`, and `collect` folds it in — the same | ||
| 6 | +## three steps, with `fetch` and a callback where the other has a thread and a | ||
| 7 | +## channel. | ||
| 8 | +## | ||
| 9 | +## That the desktop's version is already asynchronous is why this fits at all. | ||
| 10 | +## A blocking fetch would have had nowhere to go. | ||
| 11 | + | ||
| 12 | +import std/[deques, json] | ||
| 13 | +import frq/profile | ||
| 14 | + | ||
| 15 | +var answers: Deque[(string, string)] ## actor, body — the body empty on failure | ||
| 16 | + | ||
| 17 | +{.emit: """ | ||
| 18 | +function frqFetchProfile(actor, done) { | ||
| 19 | + fetch("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=" | ||
| 20 | + + encodeURIComponent(actor)) | ||
| 21 | + .then(function (r) { return r.ok ? r.text() : ""; }) | ||
| 22 | + .then(function (t) { done(actor, t); }) | ||
| 23 | + .catch(function () { done(actor, ""); }); | ||
| 24 | +} | ||
| 25 | +""".} | ||
| 26 | + | ||
| 27 | +proc fetchProfile(actor: cstring, done: proc (a, b: cstring)) | ||
| 28 | + {.importc: "frqFetchProfile".} | ||
| 29 | + | ||
| 30 | +proc arrived(actor, body: cstring) = | ||
| 31 | + answers.addLast(($actor, $body)) | ||
| 32 | + | ||
| 33 | +proc want*(actor: string) = | ||
| 34 | + ## Once per identity for the run, as on the desktop: `psLoading` goes in the | ||
| 35 | + ## cache here, so a second ask while the first is in flight is not a second | ||
| 36 | + ## round trip. | ||
| 37 | + if actor.len == 0 or known(actor) or isAgent(actor): return | ||
| 38 | + remember(actor, Profile(status: psLoading)) | ||
| 39 | + fetchProfile(actor.cstring, arrived) | ||
| 40 | + | ||
| 41 | +proc collect*(): bool = | ||
| 42 | + ## Fold whatever has come back into the cache. True where anything did. | ||
| 43 | + while answers.len > 0: | ||
| 44 | + let (actor, body) = answers.popFirst() | ||
| 45 | + remember(actor, | ||
| 46 | + if body.len == 0: Profile(status: psFailed) | ||
| 47 | + else: | ||
| 48 | + try: | ||
| 49 | + let j = parseJson(body) | ||
| 50 | + if j{"did"}.getStr().len > 0: parseProfile(j) | ||
| 51 | + else: Profile(status: psFailed) | ||
| 52 | + except CatchableError: Profile(status: psFailed)) | ||
| 53 | + result = true | ||
added
nim/web/frq/store.nim +99 -0 | new file mode 100644 | ||
| @@ -0,0 +1,99 @@ | ||
| 1 | +## What this client keeps between visits, in a browser. | |
| 2 | +## | |
| 3 | +## `localStorage` where the desktop has `$XDG_CONFIG_HOME/frq`. The same three | |
| 4 | +## files, the same JSON in them, under keys named for what they were: a reader | |
| 5 | +## who moves between the two does not carry their rooms across, but a reader | |
| 6 | +## who reloads the page keeps them. | |
| 7 | +## | |
| 8 | +## The desktop writes the session file with mode 600 and says why. | |
| 9 | +## `localStorage` has no such thing — it is readable by any script this origin | |
| 10 | +## runs — so the broker token is as safe as the page is, and no safer. | |
| 11 | + | |
| 12 | +import std/[json, tables] | |
| 13 | +import frq/[model, trace] | |
| 14 | + | |
| 15 | +type | |
| 16 | + SavedSession* = object | |
| 17 | + brokerToken*, handle*, did*, nick*: string | |
| 18 | + | |
| 19 | + SavedRoom* = object | |
| 20 | + name*: string | |
| 21 | + accessed*: int64 | |
| 22 | + lastReadId*: string | |
| 23 | + lastReadAt*: int64 | |
| 24 | + | |
| 25 | +{.emit: """ | |
| 26 | +function frqStoreGet(k) { | |
| 27 | + try { return window.localStorage.getItem(k) || ""; } catch (e) { return ""; } | |
| 28 | +} | |
| 29 | +function frqStoreSet(k, v) { | |
| 30 | + try { window.localStorage.setItem(k, v); return true; } catch (e) { return false; } | |
| 31 | +} | |
| 32 | +function frqStoreDel(k) { | |
| 33 | + try { window.localStorage.removeItem(k); } catch (e) {} | |
| 34 | +} | |
| 35 | +""".} | |
| 36 | + | |
| 37 | +proc getItem(key: cstring): cstring {.importc: "frqStoreGet".} | |
| 38 | +proc setItem(key, value: cstring): bool {.importc: "frqStoreSet".} | |
| 39 | +proc delItem(key: cstring) {.importc: "frqStoreDel".} | |
| 40 | + | |
| 41 | +const | |
| 42 | + sessionKey = "frq.session" | |
| 43 | + roomsKey = "frq.rooms" | |
| 44 | + prefsKey = "frq.prefs" | |
| 45 | + | |
| 46 | +proc readJson(key: string): JsonNode = | |
| 47 | + ## Absent and unparseable are the same answer here as on the desktop: a | |
| 48 | + ## stale credential is not worth an error on load. | |
| 49 | + let raw = $getItem(key.cstring) | |
| 50 | + if raw.len == 0: return nil | |
| 51 | + try: parseJson(raw) | |
| 52 | + except CatchableError as e: | |
| 53 | + trace("store", "ignoring " & key & ": " & e.msg) | |
| 54 | + nil | |
| 55 | + | |
| 56 | +proc loadSession*(): (SavedSession, bool) = | |
| 57 | + let j = readJson(sessionKey) | |
| 58 | + if j.isNil: return (SavedSession(), false) | |
| 59 | + let s = SavedSession(brokerToken: j{"brokerToken"}.getStr(), | |
| 60 | + handle: j{"handle"}.getStr(), | |
| 61 | + did: j{"did"}.getStr(), | |
| 62 | + nick: j{"nick"}.getStr()) | |
| 63 | + (s, s.brokerToken.len > 0) | |
| 64 | + | |
| 65 | +proc saveSession*(s: SavedSession): bool = | |
| 66 | + setItem(sessionKey.cstring, | |
| 67 | + ($(%*{"brokerToken": s.brokerToken, "handle": s.handle, | |
| 68 | + "did": s.did, "nick": s.nick})).cstring) | |
| 69 | + | |
| 70 | +proc clearSession*() = delItem(sessionKey.cstring) | |
| 71 | + | |
| 72 | +proc loadRooms*(): seq[SavedRoom] = | |
| 73 | + let j = readJson(roomsKey) | |
| 74 | + if j.isNil or j.kind != JArray: return | |
| 75 | + for r in j: | |
| 76 | + let name = r{"name"}.getStr() | |
| 77 | + if name.len == 0: continue | |
| 78 | + result.add SavedRoom(name: name, | |
| 79 | + accessed: r{"accessed"}.getBiggestInt(), | |
| 80 | + lastReadId: r{"lastReadId"}.getStr(), | |
| 81 | + lastReadAt: r{"lastReadAt"}.getBiggestInt()) | |
| 82 | + | |
| 83 | +proc saveRooms*(rooms: OrderedTable[string, Room]): bool = | |
| 84 | + var arr = newJArray() | |
| 85 | + for name, r in rooms: | |
| 86 | + arr.add %*{"name": name, "accessed": r.accessed, | |
| 87 | + "lastReadId": r.lastReadId, "lastReadAt": r.lastReadAt} | |
| 88 | + setItem(roomsKey.cstring, ($arr).cstring) | |
| 89 | + | |
| 90 | +proc loadPrefs*(): Table[string, bool] = | |
| 91 | + let j = readJson(prefsKey) | |
| 92 | + if j.isNil or j.kind != JObject: return | |
| 93 | + for k, v in j: | |
| 94 | + if v.kind == JBool: result[k] = v.getBool() | |
| 95 | + | |
| 96 | +proc savePrefs*(prefs: Table[string, bool]): bool = | |
| 97 | + var o = newJObject() | |
| 98 | + for k, v in prefs: o[k] = %v | |
| 99 | + setItem(prefsKey.cstring, ($o).cstring) | |
| new file mode 100644 | |||
| @@ -0,0 +1,99 @@ | |||
| 1 | +## What this client keeps between visits, in a browser. | ||
| 2 | +## | ||
| 3 | +## `localStorage` where the desktop has `$XDG_CONFIG_HOME/frq`. The same three | ||
| 4 | +## files, the same JSON in them, under keys named for what they were: a reader | ||
| 5 | +## who moves between the two does not carry their rooms across, but a reader | ||
| 6 | +## who reloads the page keeps them. | ||
| 7 | +## | ||
| 8 | +## The desktop writes the session file with mode 600 and says why. | ||
| 9 | +## `localStorage` has no such thing — it is readable by any script this origin | ||
| 10 | +## runs — so the broker token is as safe as the page is, and no safer. | ||
| 11 | + | ||
| 12 | +import std/[json, tables] | ||
| 13 | +import frq/[model, trace] | ||
| 14 | + | ||
| 15 | +type | ||
| 16 | + SavedSession* = object | ||
| 17 | + brokerToken*, handle*, did*, nick*: string | ||
| 18 | + | ||
| 19 | + SavedRoom* = object | ||
| 20 | + name*: string | ||
| 21 | + accessed*: int64 | ||
| 22 | + lastReadId*: string | ||
| 23 | + lastReadAt*: int64 | ||
| 24 | + | ||
| 25 | +{.emit: """ | ||
| 26 | +function frqStoreGet(k) { | ||
| 27 | + try { return window.localStorage.getItem(k) || ""; } catch (e) { return ""; } | ||
| 28 | +} | ||
| 29 | +function frqStoreSet(k, v) { | ||
| 30 | + try { window.localStorage.setItem(k, v); return true; } catch (e) { return false; } | ||
| 31 | +} | ||
| 32 | +function frqStoreDel(k) { | ||
| 33 | + try { window.localStorage.removeItem(k); } catch (e) {} | ||
| 34 | +} | ||
| 35 | +""".} | ||
| 36 | + | ||
| 37 | +proc getItem(key: cstring): cstring {.importc: "frqStoreGet".} | ||
| 38 | +proc setItem(key, value: cstring): bool {.importc: "frqStoreSet".} | ||
| 39 | +proc delItem(key: cstring) {.importc: "frqStoreDel".} | ||
| 40 | + | ||
| 41 | +const | ||
| 42 | + sessionKey = "frq.session" | ||
| 43 | + roomsKey = "frq.rooms" | ||
| 44 | + prefsKey = "frq.prefs" | ||
| 45 | + | ||
| 46 | +proc readJson(key: string): JsonNode = | ||
| 47 | + ## Absent and unparseable are the same answer here as on the desktop: a | ||
| 48 | + ## stale credential is not worth an error on load. | ||
| 49 | + let raw = $getItem(key.cstring) | ||
| 50 | + if raw.len == 0: return nil | ||
| 51 | + try: parseJson(raw) | ||
| 52 | + except CatchableError as e: | ||
| 53 | + trace("store", "ignoring " & key & ": " & e.msg) | ||
| 54 | + nil | ||
| 55 | + | ||
| 56 | +proc loadSession*(): (SavedSession, bool) = | ||
| 57 | + let j = readJson(sessionKey) | ||
| 58 | + if j.isNil: return (SavedSession(), false) | ||
| 59 | + let s = SavedSession(brokerToken: j{"brokerToken"}.getStr(), | ||
| 60 | + handle: j{"handle"}.getStr(), | ||
| 61 | + did: j{"did"}.getStr(), | ||
| 62 | + nick: j{"nick"}.getStr()) | ||
| 63 | + (s, s.brokerToken.len > 0) | ||
| 64 | + | ||
| 65 | +proc saveSession*(s: SavedSession): bool = | ||
| 66 | + setItem(sessionKey.cstring, | ||
| 67 | + ($(%*{"brokerToken": s.brokerToken, "handle": s.handle, | ||
| 68 | + "did": s.did, "nick": s.nick})).cstring) | ||
| 69 | + | ||
| 70 | +proc clearSession*() = delItem(sessionKey.cstring) | ||
| 71 | + | ||
| 72 | +proc loadRooms*(): seq[SavedRoom] = | ||
| 73 | + let j = readJson(roomsKey) | ||
| 74 | + if j.isNil or j.kind != JArray: return | ||
| 75 | + for r in j: | ||
| 76 | + let name = r{"name"}.getStr() | ||
| 77 | + if name.len == 0: continue | ||
| 78 | + result.add SavedRoom(name: name, | ||
| 79 | + accessed: r{"accessed"}.getBiggestInt(), | ||
| 80 | + lastReadId: r{"lastReadId"}.getStr(), | ||
| 81 | + lastReadAt: r{"lastReadAt"}.getBiggestInt()) | ||
| 82 | + | ||
| 83 | +proc saveRooms*(rooms: OrderedTable[string, Room]): bool = | ||
| 84 | + var arr = newJArray() | ||
| 85 | + for name, r in rooms: | ||
| 86 | + arr.add %*{"name": name, "accessed": r.accessed, | ||
| 87 | + "lastReadId": r.lastReadId, "lastReadAt": r.lastReadAt} | ||
| 88 | + setItem(roomsKey.cstring, ($arr).cstring) | ||
| 89 | + | ||
| 90 | +proc loadPrefs*(): Table[string, bool] = | ||
| 91 | + let j = readJson(prefsKey) | ||
| 92 | + if j.isNil or j.kind != JObject: return | ||
| 93 | + for k, v in j: | ||
| 94 | + if v.kind == JBool: result[k] = v.getBool() | ||
| 95 | + | ||
| 96 | +proc savePrefs*(prefs: Table[string, bool]): bool = | ||
| 97 | + var o = newJObject() | ||
| 98 | + for k, v in prefs: o[k] = %v | ||
| 99 | + setItem(prefsKey.cstring, ($o).cstring) | ||
added
nim/web/frq_web.nim +157 -0 | new file mode 100644 | ||
| @@ -0,0 +1,157 @@ | ||
| 1 | +## The core, for a browser. | |
| 2 | +## | |
| 3 | +## `frq_core.nim` is the other one. That file is the only thing that knows | |
| 4 | +## about C; this is the only thing that knows about JavaScript, and the two | |
| 5 | +## have the same job: own the state and the screens, and hand a widget tree | |
| 6 | +## to whatever draws it. | |
| 7 | +## | |
| 8 | +## The seam is different because the host is. Across FFI a string is a pointer | |
| 9 | +## somebody has to free, and `frq_free` says so; here a string is a string. | |
| 10 | +## What is the same is the shape — a tree out, an event id back — and the | |
| 11 | +## polling, because the socket is somebody else's and nothing calls in. | |
| 12 | +## | |
| 13 | +## The platform modules underneath are chosen by search path: `nim/web/frq` | |
| 14 | +## comes before `nim/src/frq`, so `frq/conn` is the WebSocket queue rather | |
| 15 | +## than the socket threads, `frq/store` is localStorage rather than files, | |
| 16 | +## and so on. The shared code imports the same names either way and never | |
| 17 | +## learns which host it is on. | |
| 18 | + | |
| 19 | +import std/[json, strutils, tables] | |
| 20 | +import frq/[cells, model, reducer, rooms, trace, ui] | |
| 21 | +import frq/conn as tr | |
| 22 | +import frq/oauth as oa | |
| 23 | +import frq/screens/connect as scConnectScreen | |
| 24 | +import frq/screens/chats as scChatsScreen | |
| 25 | +import frq/screens/chat as scChatScreen | |
| 26 | +import frq/screens/settings as scSettingsScreen | |
| 27 | + | |
| 28 | +proc currentTree(): string = | |
| 29 | + ## Whichever screen the state says. `drain` first, so the tree the host gets | |
| 30 | + ## is built after every line that had arrived when it asked. | |
| 31 | + drain() | |
| 32 | + let connected = app.status.startsWith("Connected") | |
| 33 | + let node = | |
| 34 | + case app.screen | |
| 35 | + of scChat: scChatScreen.chatScreen(app, connected) | |
| 36 | + of scChats: scChatsScreen.chatsScreen(app, connected) | |
| 37 | + of scDiscover: scSettingsScreen.discoverScreen(app) | |
| 38 | + of scSettings: scSettingsScreen.settingsScreen(app, connected, true) | |
| 39 | + of scConnect: scConnectScreen.connectScreen(app) | |
| 40 | + $node.toJson | |
| 41 | + | |
| 42 | +# ----------------------------------------------------------------- the seam | |
| 43 | +# | |
| 44 | +# Everything below is called from JavaScript and nothing else. `exportc` with | |
| 45 | +# a `frq` prefix rather than Nim's mangled names, so the host can call them by | |
| 46 | +# the names written here. | |
| 47 | + | |
| 48 | +proc frqInit(payload: cstring) {.exportc.} = | |
| 49 | + ## Once, before anything else. `payload` is the URL fragment this page came | |
| 50 | + ## back with, or empty — a browser catches the broker's answer by being the | |
| 51 | + ## page that was redirected, so a sign-in finishes here rather than on a | |
| 52 | + ## loopback socket. | |
| 53 | + restore() | |
| 54 | + let p = $payload | |
| 55 | + if p.len > 0: oa.handoff(p) | |
| 56 | + | |
| 57 | +proc frqRender(): cstring {.exportc.} = currentTree().cstring | |
| 58 | + ## The current screen as a widget tree, in JSON. | |
| 59 | + | |
| 60 | +proc frqDispatch(event: cstring): cstring {.exportc.} = | |
| 61 | + ## Apply an event and answer with the tree it produced — one call, so there | |
| 62 | + ## is no window in which the host could draw a state nothing asked for. | |
| 63 | + try: | |
| 64 | + dispatch(parseJson($event)) | |
| 65 | + except CatchableError as e: | |
| 66 | + trace("dispatch", "!! " & e.msg) | |
| 67 | + currentTree().cstring | |
| 68 | + | |
| 69 | +# --------------------------------------------------------------- the socket | |
| 70 | +# | |
| 71 | +# The host owns it. These four are the whole of the transport seam, and they | |
| 72 | +# are the two the tests already use plus the two a real connection needs. | |
| 73 | + | |
| 74 | +proc frqWanted(): cstring {.exportc.} = | |
| 75 | + ## Where the core is asking to be connected, as JSON, or empty where it is | |
| 76 | + ## not asking. The host reads this after a dispatch and opens the socket. | |
| 77 | + let cfg = tr.wanted() | |
| 78 | + if cfg.host.len == 0: return "".cstring | |
| 79 | + ($(%*{"host": cfg.host, "port": cfg.port, "tls": cfg.tls})).cstring | |
| 80 | + | |
| 81 | +proc frqFeed(line: cstring) {.exportc.} = tr.feed($line) | |
| 82 | + ## A line the server sent. | |
| 83 | + | |
| 84 | +proc frqSocketEvent(e: cstring) {.exportc.} = tr.event($e) | |
| 85 | + ## "open", or "close: why", or "error: why". | |
| 86 | + | |
| 87 | +proc frqTakeOutbound(): cstring {.exportc.} = | |
| 88 | + ## Everything the core wants to say, newline-separated, taken as it is read. | |
| 89 | + ## One call rather than one per line: a registration is four lines and this | |
| 90 | + ## is a crossing each. | |
| 91 | + var lines: seq[string] | |
| 92 | + while true: | |
| 93 | + let (ok, line) = tr.tryOutbound() | |
| 94 | + if not ok: break | |
| 95 | + lines.add line | |
| 96 | + lines.join("\n").cstring | |
| 97 | + | |
| 98 | +# ----------------------------------------------------------------- sign-in | |
| 99 | + | |
| 100 | +proc frqBrokerToken(): cstring {.exportc.} = app.brokerToken.cstring | |
| 101 | + ## The remembered token, for the host to spend against the broker — `fetch` | |
| 102 | + ## is asynchronous, so the core cannot spend it itself. | |
| 103 | + | |
| 104 | +proc frqHandoff(payload: cstring) {.exportc.} = oa.handoff($payload) | |
| 105 | + ## What the broker answered, whether from a redirect or from the host's own | |
| 106 | + ## call to `/session`. | |
| 107 | + | |
| 108 | +proc frqSignInFailed(reason: cstring) {.exportc.} = oa.failed($reason) | |
| 109 | + | |
| 110 | +# ------------------------------------------------------------------- the rest | |
| 111 | + | |
| 112 | +proc frqTrace(on: bool) {.exportc.} = trace.enabled = on | |
| 113 | + ## Tracing has no environment to be switched on from here, so the console | |
| 114 | + ## switches it: `frqTrace(true)`. | |
| 115 | + | |
| 116 | +proc frqDemo() {.exportc.} = | |
| 117 | + ## The same representative room `frq_core.frq_ui_demo` fills, for looking at | |
| 118 | + ## the screens without a server. | |
| 119 | + app = initState() | |
| 120 | + app.formNick = "me" | |
| 121 | + app.rooms.ensureRoom("#test") | |
| 122 | + var r = app.rooms["#test"] | |
| 123 | + r.joined = true | |
| 124 | + r.users = {"me": "", "alice": "@", "bob": ""}.toTable | |
| 125 | + r.topic = "a room" | |
| 126 | + let t0 = 1_700_000_000_000'i64 | |
| 127 | + r.messages = @[ | |
| 128 | + Message(id: "1", frm: "*", text: "me joined #test", at: t0, system: true), | |
| 129 | + Message(id: "2", frm: "alice", text: "hello there", at: t0 + 1000), | |
| 130 | + Message(id: "3", frm: "bob", text: "answering", at: t0 + 2000, | |
| 131 | + replyTo: "2"), | |
| 132 | + Message(id: "4", frm: "me", text: "a line of my own", at: t0 + 3000)] | |
| 133 | + app.rooms["#test"] = r | |
| 134 | + app.current = "#test" | |
| 135 | + app.screen = scChat | |
| 136 | + app.status = "Connected as me" | |
| 137 | + | |
| 138 | +# The host reaches these by name, so they are put where a name can be reached | |
| 139 | +# from: a `<script>` tag's globals are the browser's, and a module wrapper's | |
| 140 | +# are nobody's. One object rather than a scattering of globals, and the same | |
| 141 | +# object under node, which is what the smoke test drives. | |
| 142 | +{.emit: """ | |
| 143 | +globalThis.frq = { | |
| 144 | + init: frqInit, | |
| 145 | + render: frqRender, | |
| 146 | + dispatch: frqDispatch, | |
| 147 | + wanted: frqWanted, | |
| 148 | + feed: frqFeed, | |
| 149 | + socketEvent: frqSocketEvent, | |
| 150 | + takeOutbound: frqTakeOutbound, | |
| 151 | + brokerToken: frqBrokerToken, | |
| 152 | + handoff: frqHandoff, | |
| 153 | + signInFailed: frqSignInFailed, | |
| 154 | + trace: frqTrace, | |
| 155 | + demo: frqDemo, | |
| 156 | +}; | |
| 157 | +""".} | |
| new file mode 100644 | |||
| @@ -0,0 +1,157 @@ | |||
| 1 | +## The core, for a browser. | ||
| 2 | +## | ||
| 3 | +## `frq_core.nim` is the other one. That file is the only thing that knows | ||
| 4 | +## about C; this is the only thing that knows about JavaScript, and the two | ||
| 5 | +## have the same job: own the state and the screens, and hand a widget tree | ||
| 6 | +## to whatever draws it. | ||
| 7 | +## | ||
| 8 | +## The seam is different because the host is. Across FFI a string is a pointer | ||
| 9 | +## somebody has to free, and `frq_free` says so; here a string is a string. | ||
| 10 | +## What is the same is the shape — a tree out, an event id back — and the | ||
| 11 | +## polling, because the socket is somebody else's and nothing calls in. | ||
| 12 | +## | ||
| 13 | +## The platform modules underneath are chosen by search path: `nim/web/frq` | ||
| 14 | +## comes before `nim/src/frq`, so `frq/conn` is the WebSocket queue rather | ||
| 15 | +## than the socket threads, `frq/store` is localStorage rather than files, | ||
| 16 | +## and so on. The shared code imports the same names either way and never | ||
| 17 | +## learns which host it is on. | ||
| 18 | + | ||
| 19 | +import std/[json, strutils, tables] | ||
| 20 | +import frq/[cells, model, reducer, rooms, trace, ui] | ||
| 21 | +import frq/conn as tr | ||
| 22 | +import frq/oauth as oa | ||
| 23 | +import frq/screens/connect as scConnectScreen | ||
| 24 | +import frq/screens/chats as scChatsScreen | ||
| 25 | +import frq/screens/chat as scChatScreen | ||
| 26 | +import frq/screens/settings as scSettingsScreen | ||
| 27 | + | ||
| 28 | +proc currentTree(): string = | ||
| 29 | + ## Whichever screen the state says. `drain` first, so the tree the host gets | ||
| 30 | + ## is built after every line that had arrived when it asked. | ||
| 31 | + drain() | ||
| 32 | + let connected = app.status.startsWith("Connected") | ||
| 33 | + let node = | ||
| 34 | + case app.screen | ||
| 35 | + of scChat: scChatScreen.chatScreen(app, connected) | ||
| 36 | + of scChats: scChatsScreen.chatsScreen(app, connected) | ||
| 37 | + of scDiscover: scSettingsScreen.discoverScreen(app) | ||
| 38 | + of scSettings: scSettingsScreen.settingsScreen(app, connected, true) | ||
| 39 | + of scConnect: scConnectScreen.connectScreen(app) | ||
| 40 | + $node.toJson | ||
| 41 | + | ||
| 42 | +# ----------------------------------------------------------------- the seam | ||
| 43 | +# | ||
| 44 | +# Everything below is called from JavaScript and nothing else. `exportc` with | ||
| 45 | +# a `frq` prefix rather than Nim's mangled names, so the host can call them by | ||
| 46 | +# the names written here. | ||
| 47 | + | ||
| 48 | +proc frqInit(payload: cstring) {.exportc.} = | ||
| 49 | + ## Once, before anything else. `payload` is the URL fragment this page came | ||
| 50 | + ## back with, or empty — a browser catches the broker's answer by being the | ||
| 51 | + ## page that was redirected, so a sign-in finishes here rather than on a | ||
| 52 | + ## loopback socket. | ||
| 53 | + restore() | ||
| 54 | + let p = $payload | ||
| 55 | + if p.len > 0: oa.handoff(p) | ||
| 56 | + | ||
| 57 | +proc frqRender(): cstring {.exportc.} = currentTree().cstring | ||
| 58 | + ## The current screen as a widget tree, in JSON. | ||
| 59 | + | ||
| 60 | +proc frqDispatch(event: cstring): cstring {.exportc.} = | ||
| 61 | + ## Apply an event and answer with the tree it produced — one call, so there | ||
| 62 | + ## is no window in which the host could draw a state nothing asked for. | ||
| 63 | + try: | ||
| 64 | + dispatch(parseJson($event)) | ||
| 65 | + except CatchableError as e: | ||
| 66 | + trace("dispatch", "!! " & e.msg) | ||
| 67 | + currentTree().cstring | ||
| 68 | + | ||
| 69 | +# --------------------------------------------------------------- the socket | ||
| 70 | +# | ||
| 71 | +# The host owns it. These four are the whole of the transport seam, and they | ||
| 72 | +# are the two the tests already use plus the two a real connection needs. | ||
| 73 | + | ||
| 74 | +proc frqWanted(): cstring {.exportc.} = | ||
| 75 | + ## Where the core is asking to be connected, as JSON, or empty where it is | ||
| 76 | + ## not asking. The host reads this after a dispatch and opens the socket. | ||
| 77 | + let cfg = tr.wanted() | ||
| 78 | + if cfg.host.len == 0: return "".cstring | ||
| 79 | + ($(%*{"host": cfg.host, "port": cfg.port, "tls": cfg.tls})).cstring | ||
| 80 | + | ||
| 81 | +proc frqFeed(line: cstring) {.exportc.} = tr.feed($line) | ||
| 82 | + ## A line the server sent. | ||
| 83 | + | ||
| 84 | +proc frqSocketEvent(e: cstring) {.exportc.} = tr.event($e) | ||
| 85 | + ## "open", or "close: why", or "error: why". | ||
| 86 | + | ||
| 87 | +proc frqTakeOutbound(): cstring {.exportc.} = | ||
| 88 | + ## Everything the core wants to say, newline-separated, taken as it is read. | ||
| 89 | + ## One call rather than one per line: a registration is four lines and this | ||
| 90 | + ## is a crossing each. | ||
| 91 | + var lines: seq[string] | ||
| 92 | + while true: | ||
| 93 | + let (ok, line) = tr.tryOutbound() | ||
| 94 | + if not ok: break | ||
| 95 | + lines.add line | ||
| 96 | + lines.join("\n").cstring | ||
| 97 | + | ||
| 98 | +# ----------------------------------------------------------------- sign-in | ||
| 99 | + | ||
| 100 | +proc frqBrokerToken(): cstring {.exportc.} = app.brokerToken.cstring | ||
| 101 | + ## The remembered token, for the host to spend against the broker — `fetch` | ||
| 102 | + ## is asynchronous, so the core cannot spend it itself. | ||
| 103 | + | ||
| 104 | +proc frqHandoff(payload: cstring) {.exportc.} = oa.handoff($payload) | ||
| 105 | + ## What the broker answered, whether from a redirect or from the host's own | ||
| 106 | + ## call to `/session`. | ||
| 107 | + | ||
| 108 | +proc frqSignInFailed(reason: cstring) {.exportc.} = oa.failed($reason) | ||
| 109 | + | ||
| 110 | +# ------------------------------------------------------------------- the rest | ||
| 111 | + | ||
| 112 | +proc frqTrace(on: bool) {.exportc.} = trace.enabled = on | ||
| 113 | + ## Tracing has no environment to be switched on from here, so the console | ||
| 114 | + ## switches it: `frqTrace(true)`. | ||
| 115 | + | ||
| 116 | +proc frqDemo() {.exportc.} = | ||
| 117 | + ## The same representative room `frq_core.frq_ui_demo` fills, for looking at | ||
| 118 | + ## the screens without a server. | ||
| 119 | + app = initState() | ||
| 120 | + app.formNick = "me" | ||
| 121 | + app.rooms.ensureRoom("#test") | ||
| 122 | + var r = app.rooms["#test"] | ||
| 123 | + r.joined = true | ||
| 124 | + r.users = {"me": "", "alice": "@", "bob": ""}.toTable | ||
| 125 | + r.topic = "a room" | ||
| 126 | + let t0 = 1_700_000_000_000'i64 | ||
| 127 | + r.messages = @[ | ||
| 128 | + Message(id: "1", frm: "*", text: "me joined #test", at: t0, system: true), | ||
| 129 | + Message(id: "2", frm: "alice", text: "hello there", at: t0 + 1000), | ||
| 130 | + Message(id: "3", frm: "bob", text: "answering", at: t0 + 2000, | ||
| 131 | + replyTo: "2"), | ||
| 132 | + Message(id: "4", frm: "me", text: "a line of my own", at: t0 + 3000)] | ||
| 133 | + app.rooms["#test"] = r | ||
| 134 | + app.current = "#test" | ||
| 135 | + app.screen = scChat | ||
| 136 | + app.status = "Connected as me" | ||
| 137 | + | ||
| 138 | +# The host reaches these by name, so they are put where a name can be reached | ||
| 139 | +# from: a `<script>` tag's globals are the browser's, and a module wrapper's | ||
| 140 | +# are nobody's. One object rather than a scattering of globals, and the same | ||
| 141 | +# object under node, which is what the smoke test drives. | ||
| 142 | +{.emit: """ | ||
| 143 | +globalThis.frq = { | ||
| 144 | + init: frqInit, | ||
| 145 | + render: frqRender, | ||
| 146 | + dispatch: frqDispatch, | ||
| 147 | + wanted: frqWanted, | ||
| 148 | + feed: frqFeed, | ||
| 149 | + socketEvent: frqSocketEvent, | ||
| 150 | + takeOutbound: frqTakeOutbound, | ||
| 151 | + brokerToken: frqBrokerToken, | ||
| 152 | + handoff: frqHandoff, | ||
| 153 | + signInFailed: frqSignInFailed, | ||
| 154 | + trace: frqTrace, | ||
| 155 | + demo: frqDemo, | ||
| 156 | +}; | ||
| 157 | +""".} | ||
added
nim/web/test/smoke.js +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +// The web core, driven the way a browser drives it. | |
| 2 | +// | |
| 3 | +// Not a unit test — the Nim suite is that, and it covers the same reducer | |
| 4 | +// this runs. What is checked here is the seam: that the JavaScript build | |
| 5 | +// loads, that a tree comes out as JSON, that an event goes in, and that a | |
| 6 | +// line fed in as if from a socket reaches the screen. Those are the four | |
| 7 | +// things that can break without a single Nim test noticing, because the | |
| 8 | +// desktop build does them through a different file. | |
| 9 | +// | |
| 10 | +// node nim/web/test/smoke.js build/web/frq_core.js | |
| 11 | + | |
| 12 | +const fs = require('fs'); | |
| 13 | +const path = process.argv[2] || 'build/web/frq_core.js'; | |
| 14 | + | |
| 15 | +// The bits of a browser this build touches. The core asks localStorage for a | |
| 16 | +// saved session and asks `location` where to send a reader for sign-in. | |
| 17 | +globalThis.window = { | |
| 18 | + localStorage: { | |
| 19 | + _v: {}, | |
| 20 | + getItem(k) { return this._v[k] || ""; }, | |
| 21 | + setItem(k, v) { this._v[k] = String(v); return true; }, | |
| 22 | + removeItem(k) { delete this._v[k]; }, | |
| 23 | + }, | |
| 24 | + location: { href: "", origin: "http://localhost", pathname: "/" }, | |
| 25 | +}; | |
| 26 | + | |
| 27 | +// Global scope, not a module's: the core puts `frq` on globalThis, and a | |
| 28 | +// `require` would wrap it where nothing could see it. | |
| 29 | +(0, eval)(fs.readFileSync(path, 'utf8')); | |
| 30 | + | |
| 31 | +let failures = 0; | |
| 32 | +function check(what, ok) { | |
| 33 | + console.log((ok ? " ok " : " FAIL ") + what); | |
| 34 | + if (!ok) failures++; | |
| 35 | +} | |
| 36 | + | |
| 37 | +frq.init(""); | |
| 38 | +check("the connect screen renders", JSON.parse(frq.render()).tag === "page"); | |
| 39 | + | |
| 40 | +frq.demo(); | |
| 41 | +check("the demo room renders", frq.render().includes("hello there")); | |
| 42 | + | |
| 43 | +const chats = frq.dispatch(JSON.stringify({ id: "screen.chats" })); | |
| 44 | +check("an event answers with the tree it produced", chats.includes("#test")); | |
| 45 | + | |
| 46 | +// Connecting: the core asks to be dialled rather than dialling. | |
| 47 | +frq.dispatch(JSON.stringify({ id: "screen.connect" })); | |
| 48 | +frq.dispatch(JSON.stringify({ id: "nick.change", value: "webtester" })); | |
| 49 | +frq.dispatch(JSON.stringify({ id: "connect" })); | |
| 50 | +const wanted = JSON.parse(frq.wanted() || "{}"); | |
| 51 | +check("it asks the host for a socket", wanted.host === "irc.freeq.at" && wanted.tls === true); | |
| 52 | + | |
| 53 | +// The host says the socket opened; the core answers with registration. | |
| 54 | +frq.socketEvent("open"); | |
| 55 | +frq.render(); | |
| 56 | +const out = frq.takeOutbound().split("\n"); | |
| 57 | +check("registration goes out on open", | |
| 58 | + out[0] === "CAP LS 302" && out[1] === "NICK webtester"); | |
| 59 | +check("and is taken once", frq.takeOutbound() === ""); | |
| 60 | + | |
| 61 | +// A line arrives. | |
| 62 | +frq.feed(":irc.freeq.at 001 webtester :Welcome"); | |
| 63 | +frq.feed(":alice!a@h PRIVMSG #test :hello from the web"); | |
| 64 | +const tree = frq.render(); | |
| 65 | +check("a fed line reaches the screen", tree.includes("hello from the web")); | |
| 66 | + | |
| 67 | +// What is saved is saved. | |
| 68 | +check("the store writes through localStorage", | |
| 69 | + Object.keys(window.localStorage._v).some(k => k.startsWith("frq."))); | |
| 70 | + | |
| 71 | +console.log(failures === 0 ? "all ok" : failures + " failed"); | |
| 72 | +process.exit(failures === 0 ? 0 : 1); | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +// The web core, driven the way a browser drives it. | ||
| 2 | +// | ||
| 3 | +// Not a unit test — the Nim suite is that, and it covers the same reducer | ||
| 4 | +// this runs. What is checked here is the seam: that the JavaScript build | ||
| 5 | +// loads, that a tree comes out as JSON, that an event goes in, and that a | ||
| 6 | +// line fed in as if from a socket reaches the screen. Those are the four | ||
| 7 | +// things that can break without a single Nim test noticing, because the | ||
| 8 | +// desktop build does them through a different file. | ||
| 9 | +// | ||
| 10 | +// node nim/web/test/smoke.js build/web/frq_core.js | ||
| 11 | + | ||
| 12 | +const fs = require('fs'); | ||
| 13 | +const path = process.argv[2] || 'build/web/frq_core.js'; | ||
| 14 | + | ||
| 15 | +// The bits of a browser this build touches. The core asks localStorage for a | ||
| 16 | +// saved session and asks `location` where to send a reader for sign-in. | ||
| 17 | +globalThis.window = { | ||
| 18 | + localStorage: { | ||
| 19 | + _v: {}, | ||
| 20 | + getItem(k) { return this._v[k] || ""; }, | ||
| 21 | + setItem(k, v) { this._v[k] = String(v); return true; }, | ||
| 22 | + removeItem(k) { delete this._v[k]; }, | ||
| 23 | + }, | ||
| 24 | + location: { href: "", origin: "http://localhost", pathname: "/" }, | ||
| 25 | +}; | ||
| 26 | + | ||
| 27 | +// Global scope, not a module's: the core puts `frq` on globalThis, and a | ||
| 28 | +// `require` would wrap it where nothing could see it. | ||
| 29 | +(0, eval)(fs.readFileSync(path, 'utf8')); | ||
| 30 | + | ||
| 31 | +let failures = 0; | ||
| 32 | +function check(what, ok) { | ||
| 33 | + console.log((ok ? " ok " : " FAIL ") + what); | ||
| 34 | + if (!ok) failures++; | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +frq.init(""); | ||
| 38 | +check("the connect screen renders", JSON.parse(frq.render()).tag === "page"); | ||
| 39 | + | ||
| 40 | +frq.demo(); | ||
| 41 | +check("the demo room renders", frq.render().includes("hello there")); | ||
| 42 | + | ||
| 43 | +const chats = frq.dispatch(JSON.stringify({ id: "screen.chats" })); | ||
| 44 | +check("an event answers with the tree it produced", chats.includes("#test")); | ||
| 45 | + | ||
| 46 | +// Connecting: the core asks to be dialled rather than dialling. | ||
| 47 | +frq.dispatch(JSON.stringify({ id: "screen.connect" })); | ||
| 48 | +frq.dispatch(JSON.stringify({ id: "nick.change", value: "webtester" })); | ||
| 49 | +frq.dispatch(JSON.stringify({ id: "connect" })); | ||
| 50 | +const wanted = JSON.parse(frq.wanted() || "{}"); | ||
| 51 | +check("it asks the host for a socket", wanted.host === "irc.freeq.at" && wanted.tls === true); | ||
| 52 | + | ||
| 53 | +// The host says the socket opened; the core answers with registration. | ||
| 54 | +frq.socketEvent("open"); | ||
| 55 | +frq.render(); | ||
| 56 | +const out = frq.takeOutbound().split("\n"); | ||
| 57 | +check("registration goes out on open", | ||
| 58 | + out[0] === "CAP LS 302" && out[1] === "NICK webtester"); | ||
| 59 | +check("and is taken once", frq.takeOutbound() === ""); | ||
| 60 | + | ||
| 61 | +// A line arrives. | ||
| 62 | +frq.feed(":irc.freeq.at 001 webtester :Welcome"); | ||
| 63 | +frq.feed(":alice!a@h PRIVMSG #test :hello from the web"); | ||
| 64 | +const tree = frq.render(); | ||
| 65 | +check("a fed line reaches the screen", tree.includes("hello from the web")); | ||
| 66 | + | ||
| 67 | +// What is saved is saved. | ||
| 68 | +check("the store writes through localStorage", | ||
| 69 | + Object.keys(window.localStorage._v).some(k => k.startsWith("frq."))); | ||
| 70 | + | ||
| 71 | +console.log(failures === 0 ? "all ok" : failures + " failed"); | ||
| 72 | +process.exit(failures === 0 ? 0 : 1); | ||