nandi/frqpublic Fork 0
5ac0521
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

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>
nandi committed 2026-09-19T21:09:20-07:00 Browse files
5ac0521 parent: 0fee54c
modified justfile +30 -4
@@ -55,7 +55,8 @@ build target="desktop":
5555 case "{{target}}" in
5656 desktop) just _flutter desktop build ;;
5757 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 ;;
5960 esac
6061
6162 # Build a target and start it.
@@ -80,29 +81,34 @@ run target="desktop":
8081 # dart the Dart side of the FFI boundary, on the plain Dart VM. Passing
8182 # `nim` and failing this one is a marshalling bug, which is why the
8283 # 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.
8386 # live the whole stack against a real freeq. Not in `all`: it wants a
8487 # network and a running server.
8588 #
8689 # just test all of them
8790 # 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')]
8992 test suite="all" *args:
9093 #!/usr/bin/env bash
9194 set -euo pipefail
9295 cd "{{root}}"
9396 shift
9497 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 ;;
96100 layout) just _nim-lib
97101 just _flutter layout test ;;
98102 nim) just _nim-test "$@" ;;
103+ web) just _nim-js
104+ exec node nim/web/test/smoke.js build/web/frq_core.js ;;
99105 dart) just _nim-lib
100106 exec "{{tc}}" exec -- bash -c \
101107 'cd dart/frq_core && dart pub get && dart test -r expanded' ;;
102108 live) just _nim-lib
103109 exec "{{tc}}" exec -- bash -c \
104110 '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 ;;
106112 esac
107113
108114 # The containers in `.modal/`, run on Modal rather than here: this machine
@@ -124,6 +130,26 @@ modal container="dev" *args:
124130 shift || true
125131 exec modal run ".modal/{{container}}/container.py" "$@"
126132
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+
127153 [private]
128154 _nim-lib:
129155 #!/usr/bin/env bash
@@ -55,7 +55,8 @@ build target="desktop":
55 case "{{target}}" in55 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 esac60 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. Passing81 # 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 the82 # `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 a86 # 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 them89 # just test all of them
87 # just test nim tircparse one Nim file90 # 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 bash93 #!/usr/bin/env bash
91 set -euo pipefail94 set -euo pipefail
92 cd "{{root}}"95 cd "{{root}}"
93 shift96 shift
94 case "{{suite}}" in97 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-lib100 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-lib105 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-lib108 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 esac112 esac
107 113
108 # The containers in `.modal/`, run on Modal rather than here: this machine114 # The containers in `.modal/`, run on Modal rather than here: this machine
@@ -124,6 +130,26 @@ modal container="dev" *args:
124 shift || true130 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 bash155 #!/usr/bin/env bash
modified nim/src/frq/msgsig.nim +12 -1
@@ -51,7 +51,18 @@ proc forget*() =
5151 proc generate*(did: string): string =
5252 ## Mint a key for this connection and answer with its public half, base64url
5353 ## — 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)
5566 signer.kid = b64url(signer.key.public)[0 ..< 16]
5667 trace("msgsig", "key for " & did & " kid=" & signer.kid)
5768 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, base64url52 ## 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);