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

A web version, from the same core

`just build web`, and `just run web` serves it. It connects to freeq, it
shows the backlog, and it is the same program the desktop runs — the
same reducer, the same screens, the same widget tree — compiled by
`nim js` instead of to a shared library.

What is different is only the host, and both languages say so the same
way. On the Nim side `nim/web/frq/*.nim` shadows `nim/src/frq/*.nim` by
search path, so `frq/conn` is a pair of queues a WebSocket fills rather
than two socket threads. On the Dart side `host.dart` is a conditional
export, `dart:ffi` on one target and `dart:js_interop` on the other.
Nothing above either seam learns which host answered.

The transport is freeq's own `wss://irc.freeq.at/irc`, so no proxy
stands between the page and the server, and the browser does the TLS.
`frq_host.js` pumps both directions on a 50ms timer and catches the
broker's redirect out of the URL fragment.

Three things the web build will not do, each written down where it is
done: it cannot sign a message (Ed25519 in a browser is asynchronous and
every signature here is wanted inline, so a reader is in a guest's
position for reactions and edits), it has no app-password tab (that
wants a blocking call to the reader's own PDS), and it does not keep a
broker token (localStorage is readable by every script the origin runs).

Two things the browser made me fix that no test could have. Images
loaded through `NetworkImage` are fetched by XHR, which is subject to
CORS — every avatar on cdn.bsky.app and every picture on freeq's media
host failed, because neither sends a header to a third-party page and
neither has reason to. `WebHtmlElementStrategy.prefer` hands the URL to
an `<img>`, which has never needed permission. And `Platform.environment`
is not a thing a page has, which is what `FRQ_AUTOCONNECT` was read
through.

One thing is still wrong and is not fixed: emoji draw as boxes.
CanvasKit carries its own fonts and has no emoji face, and its
on-demand Noto fallback is not arriving here — naming the family made
no difference, and an empty family list is at least the honest request.
Bundling a Noto Color Emoji subset as an asset is the way out.

`just test web` drives the JavaScript core under node: a tree comes out,
an event goes in, a line fed in as if from a socket reaches the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-19T21:29:05-07:00 Browse files
23846db parent: 5ac0521
modified .gitignore +1 -0
@@ -34,3 +34,4 @@ __pycache__/
3434
3535 # The mirror `just serve` keeps of the Modal-built web bundle.
3636 .web-local/
37+flutter/web/frq_core.js
@@ -34,3 +34,4 @@ __pycache__/
34 34
35 # The mirror `just serve` keeps of the Modal-built web bundle.35 # The mirror `just serve` keeps of the Modal-built web bundle.
36 .web-local/36 .web-local/
37+flutter/web/frq_core.js
modified CLAUDE.md +26 -8
@@ -84,16 +84,26 @@ point: the whole boundary is checkable in about a second.
8484
8585 There is no `common/` any more, and that rule went with it. It said a module
8686 stays until there is a wasm build of the core, because a browser has no
87-dart:ffi — which was true, and the web target is gone rather than the rule
88-being wrong. Bringing it back means compiling the core to wasm, not restoring
89-ClojureDart.
87+dart:ffi. The premise was right and the conclusion was wrong: the answer was
88+not wasm but `nim js`, which compiles the same core — state, reducer, every
89+screen — to JavaScript that a page loads with a `<script>` tag.
90+
91+So there is a web target again, `just build web`. What differs from the
92+desktop is only the host: `nim/web/frq/*.nim` shadows `nim/src/frq/*.nim` by
93+search path (`--path:src --path:web`, later wins), so `frq/conn` is a queue a
94+WebSocket fills rather than two socket threads, `frq/store` is localStorage,
95+and `frq/crypto` says plainly that it cannot sign. The shared code above them
96+imports the same names either way and never learns which host it is on. Dart
97+does the same thing one layer up, in `dart/frq_core/lib/src/host.dart`.
9098
9199 ## The source trees
92100
93101 ```
94-nim/ the program: state, screens, IRC, signing
95-dart/frq_core the FFI binding — plain Dart, not a Flutter package
102+nim/src the program: state, screens, IRC, signing
103+nim/web the same program's host half, for a browser
104+dart/frq_core the binding — plain Dart, not a Flutter package
96105 flutter/lib the renderer, and the app's entry point
106+flutter/web the page, and the JavaScript that owns the socket
97107 ```
98108
99109 `nim/src/frq/ui.nim` builds a widget tree; `frq_core` carries it across the
@@ -104,9 +114,17 @@ the boundary is in the wrong place.
104114
105115 There used to be two more trees. `src/` was jolt and libcosmic; `common/` and
106116 `flutter/src/` were ClojureDart, compiled for Android, Linux and the web. Both
107-are gone, and with the second went the APK and the web target: a browser has
108-no `dart:ffi`, and the APK wants `libfrqcore.so` cross-compiled for Android's
109-ABIs. What is left builds one thing, `just build desktop`.
117+are gone. The APK went with them and has not come back — it wants
118+`libfrqcore.so` cross-compiled for Android's ABIs — but the web target has,
119+by a different road than the one that was expected: `just build web`.
120+
121+Three things the web build does not do, all of them written down where they
122+are done rather than only here. It cannot sign a message, because Ed25519 in
123+a browser is asynchronous and every signature here is wanted inline, so a
124+reader is in a guest's position for reactions and edits. It has no
125+app-password tab, because that wants a blocking call to the reader's own PDS.
126+And it does not keep a broker token, because `localStorage` is readable by
127+every script the origin runs.
110128
111129 Two modules were never ported and are gone rather than moved: `frq.profile`
112130 (the Bluesky profile behind a nick) and `frq.replies` (asking freeq what a
@@ -84,16 +84,26 @@ point: the whole boundary is checkable in about a second.
84 84
85 There is no `common/` any more, and that rule went with it. It said a module85 There is no `common/` any more, and that rule went with it. It said a module
86 stays until there is a wasm build of the core, because a browser has no86 stays until there is a wasm build of the core, because a browser has no
87-dart:ffi — which was true, and the web target is gone rather than the rule87+dart:ffi. The premise was right and the conclusion was wrong: the answer was
88-being wrong. Bringing it back means compiling the core to wasm, not restoring88+not wasm but `nim js`, which compiles the same core — state, reducer, every
89-ClojureDart.89+screen — to JavaScript that a page loads with a `<script>` tag.
90+
91+So there is a web target again, `just build web`. What differs from the
92+desktop is only the host: `nim/web/frq/*.nim` shadows `nim/src/frq/*.nim` by
93+search path (`--path:src --path:web`, later wins), so `frq/conn` is a queue a
94+WebSocket fills rather than two socket threads, `frq/store` is localStorage,
95+and `frq/crypto` says plainly that it cannot sign. The shared code above them
96+imports the same names either way and never learns which host it is on. Dart
97+does the same thing one layer up, in `dart/frq_core/lib/src/host.dart`.
90 98
91 ## The source trees99 ## The source trees
92 100
93 ```101 ```
94-nim/ the program: state, screens, IRC, signing102+nim/src the program: state, screens, IRC, signing
95-dart/frq_core the FFI binding — plain Dart, not a Flutter package103+nim/web the same program's host half, for a browser
104+dart/frq_core the binding — plain Dart, not a Flutter package
96 flutter/lib the renderer, and the app's entry point105 flutter/lib the renderer, and the app's entry point
106+flutter/web the page, and the JavaScript that owns the socket
97 ```107 ```
98 108
99 `nim/src/frq/ui.nim` builds a widget tree; `frq_core` carries it across the109 `nim/src/frq/ui.nim` builds a widget tree; `frq_core` carries it across the
@@ -104,9 +114,17 @@ the boundary is in the wrong place.
104 114
105 There used to be two more trees. `src/` was jolt and libcosmic; `common/` and115 There used to be two more trees. `src/` was jolt and libcosmic; `common/` and
106 `flutter/src/` were ClojureDart, compiled for Android, Linux and the web. Both116 `flutter/src/` were ClojureDart, compiled for Android, Linux and the web. Both
107-are gone, and with the second went the APK and the web target: a browser has117+are gone. The APK went with them and has not come back — it wants
108-no `dart:ffi`, and the APK wants `libfrqcore.so` cross-compiled for Android's118+`libfrqcore.so` cross-compiled for Android's ABIs — but the web target has,
109-ABIs. What is left builds one thing, `just build desktop`.119+by a different road than the one that was expected: `just build web`.
120+
121+Three things the web build does not do, all of them written down where they
122+are done rather than only here. It cannot sign a message, because Ed25519 in
123+a browser is asynchronous and every signature here is wanted inline, so a
124+reader is in a guest's position for reactions and edits. It has no
125+app-password tab, because that wants a blocking call to the reader's own PDS.
126+And it does not keep a broker token, because `localStorage` is readable by
127+every script the origin runs.
110 128
111 Two modules were never ported and are gone rather than moved: `frq.profile`129 Two modules were never ported and are gone rather than moved: `frq.profile`
112 (the Bluesky profile behind a nick) and `frq.replies` (asking freeq what a130 (the Bluesky profile behind a nick) and `frq.replies` (asking freeq what a
modified dart/frq_core/lib/frq_core.dart +37 -223
@@ -1,199 +1,31 @@
11 /// The Nim core, as Dart functions.
22 ///
3-/// This is the whole of what knows `libfrqcore.so` is a native library; see
4-/// `nim/README.md` for why the logic is there rather than under `common/`.
3+/// The logic lives in Nim; this is the shape it takes on this side. Two hosts
4+/// answer it — `src/host_ffi.dart` through `dart:ffi` on a desktop, and
5+/// `src/host_js.dart` against the `nim js` build in a browser — and
6+/// `src/host.dart` is the one line that chooses. Nothing below this comment
7+/// knows which, which is the point: the widget tree, its decoding, and the
8+/// change detection that makes polling cheap are the same work whatever
9+/// produced the JSON.
510 ///
611 /// **Dart and not ClojureDart, on purpose.** The point of the Nim core is to
712 /// have less Clojure, so new code on this side of the boundary is written in
8-/// the language the platform speaks. It also sidesteps a real problem:
9-/// `lookupFunction` takes two type arguments, and generic interop is the part
10-/// of ClojureDart least worth fighting for a file that is pure marshalling.
13+/// the language the platform speaks.
1114 ///
1215 /// No `package:ffi` either. That package exists mostly for `Utf8`
13-/// conversions, and doing them here against `dart:convert` costs about ten
14-/// lines and keeps `pubspec.yaml` unchanged — which matters because every
15-/// dependency added here has to work on three targets.
16-///
17-/// The two rules of the ABI, wrapped so no call site repeats them:
18-///
19-/// * `frq_init` runs once before anything else. [_lib] does it on the way
20-/// out, so holding the handle means it has happened.
21-/// * Every string the core returns is **ours to free**, with `frq_free`.
22-/// [_takeString] is that, in a `finally` so a throw between the read and
23-/// the free does not leak. Nim's allocator is not Dart's, so calling
24-/// `malloc.free` on one of these pointers is undefined rather than merely
25-/// untidy.
16+/// conversions, and doing them against `dart:convert` costs about ten lines
17+/// and keeps `pubspec.yaml` unchanged.
2618 library;
2719
2820 import 'dart:convert';
29-import 'dart:ffi';
30-import 'dart:io';
31-
32-// ---------------------------------------------------------------- the ABI
33-
34-typedef _InitNative = Void Function();
35-typedef _InitDart = void Function();
36-
37-typedef _FreeNative = Void Function(Pointer<Uint8>);
38-typedef _FreeDart = void Function(Pointer<Uint8>);
39-
40-typedef _VersionNative = Pointer<Uint8> Function();
41-typedef _VersionDart = Pointer<Uint8> Function();
42-
43-typedef _Str1Native = Pointer<Uint8> Function(Pointer<Uint8>);
44-typedef _Str1Dart = Pointer<Uint8> Function(Pointer<Uint8>);
45-
46-typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
47-typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
48-
49-typedef _Str0Native = Pointer<Uint8> Function();
50-typedef _Str0Dart = Pointer<Uint8> Function();
51-
52-typedef _VoidNative = Void Function();
53-typedef _VoidDart = void Function();
54-
55-/// Where to look for the library, in order.
56-///
57-/// Android resolves a bare soname out of the APK's `lib/<abi>/`. A desktop
58-/// build has no such rule, so the bare name is tried first (it works when the
59-/// object sits beside the executable or on the loader path) and then the
60-/// development path `just nim-lib` writes to. Named explicitly rather than by
61-/// exporting `LD_LIBRARY_PATH` from a launcher, because a variable set in a
62-/// wrapper script is a thing that works until someone starts the binary
63-/// another way.
64-DynamicLibrary _open() {
65- if (Platform.isAndroid) return DynamicLibrary.open('libfrqcore.so');
66- // The development paths are relative to whichever directory the process
67- // started in: `dart test` runs from `dart/frq_core`, a built desktop bundle
68- // from the repo root. All of them are tried rather than guessing which
69- // invocation this is, because the failure mode is a StateError at first use
70- // rather than anything a type checker would have caught.
71- for (final p in [
72- 'libfrqcore.so',
73- 'build/nim/libfrqcore.so',
74- '../build/nim/libfrqcore.so', // `flutter test`, from flutter/
75- '../../build/nim/libfrqcore.so', // `dart test`, from dart/frq_core/
76- ]) {
77- try {
78- return DynamicLibrary.open(p);
79- } on ArgumentError {
80- continue;
81- }
82- }
83- throw StateError(
84- 'libfrqcore.so not found — build it with `just nim-lib`, or ship it '
85- 'beside the executable');
86-}
87-
88-final DynamicLibrary _lib = () {
89- final lib = _open();
90- lib.lookupFunction<_InitNative, _InitDart>('frq_init')();
91- return lib;
92-}();
93-
94-// Every entry point resolved once, here, rather than on each call.
95-//
96-// `lookupFunction` is a dlsym plus a freshly built trampoline closure every
97-// time it runs. At 10Hz for `poll` and once per keystroke for `dispatch` that
98-// is measurable and, more to the point, free to avoid — these are `final`, so
99-// they cost one lookup for the life of the process.
100-final _free = _lib.lookupFunction<_FreeNative, _FreeDart>('frq_free');
101-final _version = _lib.lookupFunction<_VersionNative, _VersionDart>('frq_version');
102-final _tagValue = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_irc_tag_value');
103-final _traceFn = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_trace');
104-final _connOpen = _lib.lookupFunction<_ConnOpenNative, _ConnOpenDart>('frq_conn_open');
105-final _connSend = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_conn_send');
106-final _connCloseFn = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_conn_close');
107-final _connRecvFn = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_recv');
108-final _connEventFn = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_event');
109-final _uiRender = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render');
110-final _uiPoll = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll');
111-final _uiDispatch = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch');
112-final _uiDemo = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_demo');
113-final _uiReset = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset');
114-final _str1 = <String, _Str1Dart>{};
115-
116-/// The bytes at [p] as a string, with [p] freed afterwards. Null in, null out.
117-String? _takeString(Pointer<Uint8> p) {
118- if (p == nullptr) return null;
119- try {
120- // Walk to the NUL rather than asking for a length the ABI does not carry.
121- var len = 0;
122- while (p[len] != 0) {
123- len++;
124- }
125- return utf8.decode(p.asTypedList(len));
126- } finally {
127- _free(p);
128- }
129-}
130-
131-/// [s] as a NUL-terminated C string that the CALLER must free with [_freeArg].
132-///
133-/// Allocated with `malloc` from Dart's side, so it is freed from Dart's side —
134-/// the mirror of the rule for what comes back. The core never takes ownership
135-/// of an argument.
136-Pointer<Uint8> _toC(String s) {
137- final bytes = utf8.encode(s);
138- final p = _malloc(bytes.length + 1).cast<Uint8>();
139- for (var i = 0; i < bytes.length; i++) {
140- p[i] = bytes[i];
141- }
142- p[bytes.length] = 0;
143- return p;
144-}
145-
146-// malloc/free out of libc rather than package:ffi's allocator, for the same
147-// reason the rest of this file avoids that package: one less dependency to
148-// carry to three targets, for two symbols that are always there.
149-final DynamicLibrary _libc =
150- Platform.isWindows ? DynamicLibrary.open('msvcrt.dll') : DynamicLibrary.process();
151-final _malloc = _libc
152- .lookupFunction<Pointer<Void> Function(IntPtr), Pointer<Void> Function(int)>('malloc');
153-final _freeArg =
154- _libc.lookupFunction<Void Function(Pointer<Uint8>), void Function(Pointer<Uint8>)>('free');
155-
156-/// Call a two-strings-in, one-string-out entry point.
157-///
158-/// The mirror of [_call1], and it exists for the same reason: the
159-/// `_toC`/`try`/`finally`/`_freeArg` dance is four lines of ownership
160-/// bookkeeping that no call site should repeat.
161-String? _call2(_Str2Dart f, String x, String y) {
162- final a = _toC(x);
163- final b = _toC(y);
164- try {
165- return _takeString(f(a, b));
166- } finally {
167- _freeArg(a);
168- _freeArg(b);
169- }
170-}
17121
172-/// Call a one-string-in, one-string-out entry point.
173-String? _call1(String symbol, String arg) {
174- final f = _str1.putIfAbsent(
175- symbol, () => _lib.lookupFunction<_Str1Native, _Str1Dart>(symbol));
176- final a = _toC(arg);
177- try {
178- return _takeString(f(a));
179- } finally {
180- _freeArg(a);
181- }
182-}
22+import 'src/host.dart' as host;
18323
18424 // ------------------------------------------------------------------ public
18525
18626 /// The core's version, for a caller that wants to check the library it found
187-/// is the one it was built against. Static storage on the Nim side: the one
188-/// return value that is NOT freed.
189-String get version {
190- final p = _version();
191- var len = 0;
192- while (p[len] != 0) {
193- len++;
194- }
195- return utf8.decode(p.asTypedList(len));
196-}
27+/// is the one it was built against.
28+String get version => host.hostVersion();
19729
19830 /// An IRC line, taken apart: `{raw, tags, account, prefix, command, params}`.
19931 ///
@@ -202,28 +34,28 @@ String get version {
20234 /// on — a PRIVMSG from a server with no prefix is not the same line as one
20335 /// from a nick.
20436 Map<String, dynamic> parseLine(String line) {
205- final json = _call1('frq_irc_parse_line', line);
37+ final json = host.str1('frq_irc_parse_line', line);
20638 return jsonDecode(json ?? 'null') as Map<String, dynamic>;
20739 }
20840
20941 /// One IRCv3 tag's value, unescaped — null where the tag is absent OR empty,
21042 /// which IRCv3 says are the same thing.
21143 String? tagValue(String tags, String key) {
212- return _call2(_tagValue, tags, key);
44+ return host.tagValueOf(tags, key);
21345 }
21446
215-String unescapeTag(String v) => _call1('frq_irc_unescape_tag', v) ?? '';
47+String unescapeTag(String v) => host.str1('frq_irc_unescape_tag', v) ?? '';
21648
217-String escapeTagValue(String v) => _call1('frq_irc_escape_tag_value', v) ?? '';
49+String escapeTagValue(String v) => host.str1('frq_irc_escape_tag_value', v) ?? '';
21850
21951 /// The nick half of a `nick!user@host` prefix.
220-String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? '';
52+String nickOf(String prefix) => host.str1('frq_irc_nick_of', prefix) ?? '';
22153
22254
22355 /// Log through the Nim core's trace facility, so `FRQ_TRACE=1` gives one
22456 /// interleaved story rather than two half-ones in different places.
22557 void trace(String topic, String msg) {
226- _call2(_traceFn, topic, msg);
58+ host.traceTo(topic, msg);
22759 }
22860
22961 // --------------------------------------------------------------- transport
@@ -232,39 +64,24 @@ void trace(String topic, String msg) {
23264 // wiring that leaves the existing ClojureDart screens, cells and actions
23365 // alone: only the transport underneath them is Nim.
23466
235-typedef _ConnOpenNative = Void Function(Pointer<Uint8>, Int32, Int32);
236-typedef _ConnOpenDart = void Function(Pointer<Uint8>, int, int);
23767
23868 /// Dial. Non-blocking: the socket runs on a Nim thread and progress arrives
23969 /// through [connEvent].
240-void connOpen(String host, int port, {bool tls = true}) {
241- final f = _connOpen;
242- final a = _toC(host);
243- try {
244- f(a, port, tls ? 1 : 0);
245- } finally {
246- _freeArg(a);
247- }
248-}
70+void connOpen(String hostname, int port, {bool tls = true}) =>
71+ host.connOpenAt(hostname, port, tls);
72+
24973
25074 /// Queue a line. The transport adds the CRLF.
251-void connSend(String line) {
252- final f = _connSend;
253- final a = _toC(line);
254- try {
255- f(a);
256- } finally {
257- _freeArg(a);
258- }
259-}
75+void connSend(String line) => host.connSendLine(line);
76+
26077
261-void connClose() => _connCloseFn();
78+void connClose() => host.connCloseNow();
26279
26380 /// The next line, or null when none is waiting. Never blocks.
264-String? connRecv() => _takeString(_connRecvFn());
81+String? connRecv() => host.connRecvLine();
26582
26683 /// The next transport event — `open`, `close: …`, `error: …` — or null.
267-String? connEvent() => _takeString(_connEventFn());
84+String? connEvent() => host.connEventNext();
26885
26986
27087 // ---------------------------------------------------------------- the UI
@@ -335,17 +152,17 @@ class UiFrame {
335152 /// no [dispatch] between can differ when a line arrived in the gap. That is how
336153 /// the room fills, and why the renderer polls.
337154 UiNode render() => _treeFrom(
338- _takeString(_uiRender()));
155+ host.uiRender());
339156
340157 /// The tree, asked for because time passed rather than because anything
341158 /// happened. Same work as [render]; named for what the caller means.
342159 UiNode poll() => _treeFrom(
343- _takeString(_uiPoll()));
160+ host.uiPoll());
344161
345162 /// The current screen, with the JSON it came from. The starting point for
346163 /// [pollIfChanged].
347164 UiFrame renderFrame() {
348- final json = _takeString(_uiRender()) ?? _emptyTree;
165+ final json = host.uiRender() ?? _emptyTree;
349166 return UiFrame(json, _treeFrom(json));
350167 }
351168
@@ -356,7 +173,7 @@ UiFrame renderFrame() {
356173 /// already produced, so an unchanged frame costs one string compare rather
357174 /// than a decode and two recursive `toString()`s.
358175 UiFrame? pollIfChanged(String since) {
359- final json = _takeString(_uiPoll()) ?? _emptyTree;
176+ final json = host.uiPoll() ?? _emptyTree;
360177 if (json == since) return null;
361178 return UiFrame(json, _treeFrom(json));
362179 }
@@ -371,18 +188,15 @@ UiNode dispatch(String id, [String value = '']) => dispatchFrame(id, value).tree
371188 /// As [dispatch], but keeping the JSON so the poll loop can compare against
372189 /// it without re-stringifying the tree it just built.
373190 UiFrame dispatchFrame(String id, [String value = '']) {
374- final a = _toC(jsonEncode({'id': id, 'value': value}));
375- try {
376- final json = _takeString(_uiDispatch(a)) ?? '{"tag":"vbox"}';
377- return UiFrame(json, _treeFrom(json));
378- } finally {
379- _freeArg(a);
380- }
191+ final json = host.uiDispatch(jsonEncode({'id': id, 'value': value})) ??
192+ _emptyTree;
193+ return UiFrame(json, _treeFrom(json));
381194 }
382195
196+
383197 /// Fill a room with a representative conversation, so a test can lay the chat
384198 /// screen out without a server. See the Nim side for why it exists.
385-void demoUi() => _uiDemo();
199+void demoUi() => host.uiDemo();
386200
387201 /// Back to a fresh state, for a caller that wants a known starting point.
388-void resetUi() => _uiReset();
202+void resetUi() => host.uiReset();
@@ -1,199 +1,31 @@
1 /// The Nim core, as Dart functions.1 /// The Nim core, as Dart functions.
2 ///2 ///
3-/// This is the whole of what knows `libfrqcore.so` is a native library; see3+/// The logic lives in Nim; this is the shape it takes on this side. Two hosts
4-/// `nim/README.md` for why the logic is there rather than under `common/`.4+/// answer it — `src/host_ffi.dart` through `dart:ffi` on a desktop, and
5+/// `src/host_js.dart` against the `nim js` build in a browser — and
6+/// `src/host.dart` is the one line that chooses. Nothing below this comment
7+/// knows which, which is the point: the widget tree, its decoding, and the
8+/// change detection that makes polling cheap are the same work whatever
9+/// produced the JSON.
5 ///10 ///
6 /// **Dart and not ClojureDart, on purpose.** The point of the Nim core is to11 /// **Dart and not ClojureDart, on purpose.** The point of the Nim core is to
7 /// have less Clojure, so new code on this side of the boundary is written in12 /// have less Clojure, so new code on this side of the boundary is written in
8-/// the language the platform speaks. It also sidesteps a real problem:13+/// the language the platform speaks.
9-/// `lookupFunction` takes two type arguments, and generic interop is the part
10-/// of ClojureDart least worth fighting for a file that is pure marshalling.
11 ///14 ///
12 /// No `package:ffi` either. That package exists mostly for `Utf8`15 /// No `package:ffi` either. That package exists mostly for `Utf8`
13-/// conversions, and doing them here against `dart:convert` costs about ten16+/// conversions, and doing them against `dart:convert` costs about ten lines
14-/// lines and keeps `pubspec.yaml` unchanged — which matters because every17+/// and keeps `pubspec.yaml` unchanged.
15-/// dependency added here has to work on three targets.
16-///
17-/// The two rules of the ABI, wrapped so no call site repeats them:
18-///
19-/// * `frq_init` runs once before anything else. [_lib] does it on the way
20-/// out, so holding the handle means it has happened.
21-/// * Every string the core returns is **ours to free**, with `frq_free`.
22-/// [_takeString] is that, in a `finally` so a throw between the read and
23-/// the free does not leak. Nim's allocator is not Dart's, so calling
24-/// `malloc.free` on one of these pointers is undefined rather than merely
25-/// untidy.
26 library;18 library;
27 19
28 import 'dart:convert';20 import 'dart:convert';
29-import 'dart:ffi';
30-import 'dart:io';
31-
32-// ---------------------------------------------------------------- the ABI
33-
34-typedef _InitNative = Void Function();
35-typedef _InitDart = void Function();
36-
37-typedef _FreeNative = Void Function(Pointer<Uint8>);
38-typedef _FreeDart = void Function(Pointer<Uint8>);
39-
40-typedef _VersionNative = Pointer<Uint8> Function();
41-typedef _VersionDart = Pointer<Uint8> Function();
42-
43-typedef _Str1Native = Pointer<Uint8> Function(Pointer<Uint8>);
44-typedef _Str1Dart = Pointer<Uint8> Function(Pointer<Uint8>);
45-
46-typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
47-typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
48-
49-typedef _Str0Native = Pointer<Uint8> Function();
50-typedef _Str0Dart = Pointer<Uint8> Function();
51-
52-typedef _VoidNative = Void Function();
53-typedef _VoidDart = void Function();
54-
55-/// Where to look for the library, in order.
56-///
57-/// Android resolves a bare soname out of the APK's `lib/<abi>/`. A desktop
58-/// build has no such rule, so the bare name is tried first (it works when the
59-/// object sits beside the executable or on the loader path) and then the
60-/// development path `just nim-lib` writes to. Named explicitly rather than by
61-/// exporting `LD_LIBRARY_PATH` from a launcher, because a variable set in a
62-/// wrapper script is a thing that works until someone starts the binary
63-/// another way.
64-DynamicLibrary _open() {
65- if (Platform.isAndroid) return DynamicLibrary.open('libfrqcore.so');
66- // The development paths are relative to whichever directory the process
67- // started in: `dart test` runs from `dart/frq_core`, a built desktop bundle
68- // from the repo root. All of them are tried rather than guessing which
69- // invocation this is, because the failure mode is a StateError at first use
70- // rather than anything a type checker would have caught.
71- for (final p in [
72- 'libfrqcore.so',
73- 'build/nim/libfrqcore.so',
74- '../build/nim/libfrqcore.so', // `flutter test`, from flutter/
75- '../../build/nim/libfrqcore.so', // `dart test`, from dart/frq_core/
76- ]) {
77- try {
78- return DynamicLibrary.open(p);
79- } on ArgumentError {
80- continue;
81- }
82- }
83- throw StateError(
84- 'libfrqcore.so not found — build it with `just nim-lib`, or ship it '
85- 'beside the executable');
86-}
87-
88-final DynamicLibrary _lib = () {
89- final lib = _open();
90- lib.lookupFunction<_InitNative, _InitDart>('frq_init')();
91- return lib;
92-}();
93-
94-// Every entry point resolved once, here, rather than on each call.
95-//
96-// `lookupFunction` is a dlsym plus a freshly built trampoline closure every
97-// time it runs. At 10Hz for `poll` and once per keystroke for `dispatch` that
98-// is measurable and, more to the point, free to avoid — these are `final`, so
99-// they cost one lookup for the life of the process.
100-final _free = _lib.lookupFunction<_FreeNative, _FreeDart>('frq_free');
101-final _version = _lib.lookupFunction<_VersionNative, _VersionDart>('frq_version');
102-final _tagValue = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_irc_tag_value');
103-final _traceFn = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_trace');
104-final _connOpen = _lib.lookupFunction<_ConnOpenNative, _ConnOpenDart>('frq_conn_open');
105-final _connSend = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_conn_send');
106-final _connCloseFn = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_conn_close');
107-final _connRecvFn = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_recv');
108-final _connEventFn = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_event');
109-final _uiRender = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render');
110-final _uiPoll = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll');
111-final _uiDispatch = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch');
112-final _uiDemo = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_demo');
113-final _uiReset = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset');
114-final _str1 = <String, _Str1Dart>{};
115-
116-/// The bytes at [p] as a string, with [p] freed afterwards. Null in, null out.
117-String? _takeString(Pointer<Uint8> p) {
118- if (p == nullptr) return null;
119- try {
120- // Walk to the NUL rather than asking for a length the ABI does not carry.
121- var len = 0;
122- while (p[len] != 0) {
123- len++;
124- }
125- return utf8.decode(p.asTypedList(len));
126- } finally {
127- _free(p);
128- }
129-}
130-
131-/// [s] as a NUL-terminated C string that the CALLER must free with [_freeArg].
132-///
133-/// Allocated with `malloc` from Dart's side, so it is freed from Dart's side —
134-/// the mirror of the rule for what comes back. The core never takes ownership
135-/// of an argument.
136-Pointer<Uint8> _toC(String s) {
137- final bytes = utf8.encode(s);
138- final p = _malloc(bytes.length + 1).cast<Uint8>();
139- for (var i = 0; i < bytes.length; i++) {
140- p[i] = bytes[i];
141- }
142- p[bytes.length] = 0;
143- return p;
144-}
145-
146-// malloc/free out of libc rather than package:ffi's allocator, for the same
147-// reason the rest of this file avoids that package: one less dependency to
148-// carry to three targets, for two symbols that are always there.
149-final DynamicLibrary _libc =
150- Platform.isWindows ? DynamicLibrary.open('msvcrt.dll') : DynamicLibrary.process();
151-final _malloc = _libc
152- .lookupFunction<Pointer<Void> Function(IntPtr), Pointer<Void> Function(int)>('malloc');
153-final _freeArg =
154- _libc.lookupFunction<Void Function(Pointer<Uint8>), void Function(Pointer<Uint8>)>('free');
155-
156-/// Call a two-strings-in, one-string-out entry point.
157-///
158-/// The mirror of [_call1], and it exists for the same reason: the
159-/// `_toC`/`try`/`finally`/`_freeArg` dance is four lines of ownership
160-/// bookkeeping that no call site should repeat.
161-String? _call2(_Str2Dart f, String x, String y) {
162- final a = _toC(x);
163- final b = _toC(y);
164- try {
165- return _takeString(f(a, b));
166- } finally {
167- _freeArg(a);
168- _freeArg(b);
169- }
170-}
171 21
172-/// Call a one-string-in, one-string-out entry point.22+import 'src/host.dart' as host;
173-String? _call1(String symbol, String arg) {
174- final f = _str1.putIfAbsent(
175- symbol, () => _lib.lookupFunction<_Str1Native, _Str1Dart>(symbol));
176- final a = _toC(arg);
177- try {
178- return _takeString(f(a));
179- } finally {
180- _freeArg(a);
181- }
182-}
183 23
184 // ------------------------------------------------------------------ public24 // ------------------------------------------------------------------ public
185 25
186 /// The core's version, for a caller that wants to check the library it found26 /// The core's version, for a caller that wants to check the library it found
187-/// is the one it was built against. Static storage on the Nim side: the one27+/// is the one it was built against.
188-/// return value that is NOT freed.28+String get version => host.hostVersion();
189-String get version {
190- final p = _version();
191- var len = 0;
192- while (p[len] != 0) {
193- len++;
194- }
195- return utf8.decode(p.asTypedList(len));
196-}
197 29
198 /// An IRC line, taken apart: `{raw, tags, account, prefix, command, params}`.30 /// An IRC line, taken apart: `{raw, tags, account, prefix, command, params}`.
199 ///31 ///
@@ -202,28 +34,28 @@ String get version {
202 /// on — a PRIVMSG from a server with no prefix is not the same line as one34 /// on — a PRIVMSG from a server with no prefix is not the same line as one
203 /// from a nick.35 /// from a nick.
204 Map<String, dynamic> parseLine(String line) {36 Map<String, dynamic> parseLine(String line) {
205- final json = _call1('frq_irc_parse_line', line);37+ final json = host.str1('frq_irc_parse_line', line);
206 return jsonDecode(json ?? 'null') as Map<String, dynamic>;38 return jsonDecode(json ?? 'null') as Map<String, dynamic>;
207 }39 }
208 40
209 /// One IRCv3 tag's value, unescaped — null where the tag is absent OR empty,41 /// One IRCv3 tag's value, unescaped — null where the tag is absent OR empty,
210 /// which IRCv3 says are the same thing.42 /// which IRCv3 says are the same thing.
211 String? tagValue(String tags, String key) {43 String? tagValue(String tags, String key) {
212- return _call2(_tagValue, tags, key);44+ return host.tagValueOf(tags, key);
213 }45 }
214 46
215-String unescapeTag(String v) => _call1('frq_irc_unescape_tag', v) ?? '';47+String unescapeTag(String v) => host.str1('frq_irc_unescape_tag', v) ?? '';
216 48
217-String escapeTagValue(String v) => _call1('frq_irc_escape_tag_value', v) ?? '';49+String escapeTagValue(String v) => host.str1('frq_irc_escape_tag_value', v) ?? '';
218 50
219 /// The nick half of a `nick!user@host` prefix.51 /// The nick half of a `nick!user@host` prefix.
220-String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? '';52+String nickOf(String prefix) => host.str1('frq_irc_nick_of', prefix) ?? '';
221 53
222 54
223 /// Log through the Nim core's trace facility, so `FRQ_TRACE=1` gives one55 /// Log through the Nim core's trace facility, so `FRQ_TRACE=1` gives one
224 /// interleaved story rather than two half-ones in different places.56 /// interleaved story rather than two half-ones in different places.
225 void trace(String topic, String msg) {57 void trace(String topic, String msg) {
226- _call2(_traceFn, topic, msg);58+ host.traceTo(topic, msg);
227 }59 }
228 60
229 // --------------------------------------------------------------- transport61 // --------------------------------------------------------------- transport
@@ -232,39 +64,24 @@ void trace(String topic, String msg) {
232 // wiring that leaves the existing ClojureDart screens, cells and actions64 // wiring that leaves the existing ClojureDart screens, cells and actions
233 // alone: only the transport underneath them is Nim.65 // alone: only the transport underneath them is Nim.
234 66
235-typedef _ConnOpenNative = Void Function(Pointer<Uint8>, Int32, Int32);
236-typedef _ConnOpenDart = void Function(Pointer<Uint8>, int, int);
237 67
238 /// Dial. Non-blocking: the socket runs on a Nim thread and progress arrives68 /// Dial. Non-blocking: the socket runs on a Nim thread and progress arrives
239 /// through [connEvent].69 /// through [connEvent].
240-void connOpen(String host, int port, {bool tls = true}) {70+void connOpen(String hostname, int port, {bool tls = true}) =>
241- final f = _connOpen;71+ host.connOpenAt(hostname, port, tls);
242- final a = _toC(host);72+
243- try {
244- f(a, port, tls ? 1 : 0);
245- } finally {
246- _freeArg(a);
247- }
248-}
249 73
250 /// Queue a line. The transport adds the CRLF.74 /// Queue a line. The transport adds the CRLF.
251-void connSend(String line) {75+void connSend(String line) => host.connSendLine(line);
252- final f = _connSend;76+
253- final a = _toC(line);
254- try {
255- f(a);
256- } finally {
257- _freeArg(a);
258- }
259-}
260 77
261-void connClose() => _connCloseFn();78+void connClose() => host.connCloseNow();
262 79
263 /// The next line, or null when none is waiting. Never blocks.80 /// The next line, or null when none is waiting. Never blocks.
264-String? connRecv() => _takeString(_connRecvFn());81+String? connRecv() => host.connRecvLine();
265 82
266 /// The next transport event — `open`, `close: …`, `error: …` — or null.83 /// The next transport event — `open`, `close: …`, `error: …` — or null.
267-String? connEvent() => _takeString(_connEventFn());84+String? connEvent() => host.connEventNext();
268 85
269 86
270 // ---------------------------------------------------------------- the UI87 // ---------------------------------------------------------------- the UI
@@ -335,17 +152,17 @@ class UiFrame {
335 /// no [dispatch] between can differ when a line arrived in the gap. That is how152 /// no [dispatch] between can differ when a line arrived in the gap. That is how
336 /// the room fills, and why the renderer polls.153 /// the room fills, and why the renderer polls.
337 UiNode render() => _treeFrom(154 UiNode render() => _treeFrom(
338- _takeString(_uiRender()));155+ host.uiRender());
339 156
340 /// The tree, asked for because time passed rather than because anything157 /// The tree, asked for because time passed rather than because anything
341 /// happened. Same work as [render]; named for what the caller means.158 /// happened. Same work as [render]; named for what the caller means.
342 UiNode poll() => _treeFrom(159 UiNode poll() => _treeFrom(
343- _takeString(_uiPoll()));160+ host.uiPoll());
344 161
345 /// The current screen, with the JSON it came from. The starting point for162 /// The current screen, with the JSON it came from. The starting point for
346 /// [pollIfChanged].163 /// [pollIfChanged].
347 UiFrame renderFrame() {164 UiFrame renderFrame() {
348- final json = _takeString(_uiRender()) ?? _emptyTree;165+ final json = host.uiRender() ?? _emptyTree;
349 return UiFrame(json, _treeFrom(json));166 return UiFrame(json, _treeFrom(json));
350 }167 }
351 168
@@ -356,7 +173,7 @@ UiFrame renderFrame() {
356 /// already produced, so an unchanged frame costs one string compare rather173 /// already produced, so an unchanged frame costs one string compare rather
357 /// than a decode and two recursive `toString()`s.174 /// than a decode and two recursive `toString()`s.
358 UiFrame? pollIfChanged(String since) {175 UiFrame? pollIfChanged(String since) {
359- final json = _takeString(_uiPoll()) ?? _emptyTree;176+ final json = host.uiPoll() ?? _emptyTree;
360 if (json == since) return null;177 if (json == since) return null;
361 return UiFrame(json, _treeFrom(json));178 return UiFrame(json, _treeFrom(json));
362 }179 }
@@ -371,18 +188,15 @@ UiNode dispatch(String id, [String value = '']) => dispatchFrame(id, value).tree
371 /// As [dispatch], but keeping the JSON so the poll loop can compare against188 /// As [dispatch], but keeping the JSON so the poll loop can compare against
372 /// it without re-stringifying the tree it just built.189 /// it without re-stringifying the tree it just built.
373 UiFrame dispatchFrame(String id, [String value = '']) {190 UiFrame dispatchFrame(String id, [String value = '']) {
374- final a = _toC(jsonEncode({'id': id, 'value': value}));191+ final json = host.uiDispatch(jsonEncode({'id': id, 'value': value})) ??
375- try {192+ _emptyTree;
376- final json = _takeString(_uiDispatch(a)) ?? '{"tag":"vbox"}';193+ return UiFrame(json, _treeFrom(json));
377- return UiFrame(json, _treeFrom(json));
378- } finally {
379- _freeArg(a);
380- }
381 }194 }
382 195
196+
383 /// Fill a room with a representative conversation, so a test can lay the chat197 /// Fill a room with a representative conversation, so a test can lay the chat
384 /// screen out without a server. See the Nim side for why it exists.198 /// screen out without a server. See the Nim side for why it exists.
385-void demoUi() => _uiDemo();199+void demoUi() => host.uiDemo();
386 200
387 /// Back to a fresh state, for a caller that wants a known starting point.201 /// Back to a fresh state, for a caller that wants a known starting point.
388-void resetUi() => _uiReset();202+void resetUi() => host.uiReset();
added dart/frq_core/lib/src/host.dart +9 -0
new file mode 100644
@@ -0,0 +1,9 @@
1+/// Which half of the seam this build got.
2+///
3+/// The condition is `dart.library.js_interop`, which is true exactly where
4+/// `dart:ffi` is false. Everything above this file imports these names and
5+/// never learns which implementation answered — the same arrangement the Nim
6+/// side has, where `nim/web/frq` shadows `nim/src/frq` by search path.
7+library;
8+
9+export 'host_ffi.dart' if (dart.library.js_interop) 'host_js.dart';
new file mode 100644
@@ -0,0 +1,9 @@
1+/// Which half of the seam this build got.
2+///
3+/// The condition is `dart.library.js_interop`, which is true exactly where
4+/// `dart:ffi` is false. Everything above this file imports these names and
5+/// never learns which implementation answered — the same arrangement the Nim
6+/// side has, where `nim/web/frq` shadows `nim/src/frq` by search path.
7+library;
8+
9+export 'host_ffi.dart' if (dart.library.js_interop) 'host_js.dart';
added dart/frq_core/lib/src/host_ffi.dart +228 -0
new file mode 100644
@@ -0,0 +1,228 @@
1+/// The native half of the seam: `libfrqcore.so`, through `dart:ffi`.
2+///
3+/// Split out of `frq_core.dart` so that file can be imported where there is
4+/// no `dart:ffi` to import — a browser. `host.dart` picks this or `host_js`
5+/// and nothing above it knows which.
6+///
7+/// The two rules of the ABI live here, wrapped so no call site repeats them:
8+/// `frq_init` runs once before anything else, and every string the core
9+/// returns is ours to free with `frq_free`.
10+library;
11+
12+import 'dart:convert';
13+import 'dart:ffi';
14+import 'dart:io';
15+
16+// ---------------------------------------------------------------- the ABI
17+
18+typedef _InitNative = Void Function();
19+typedef _InitDart = void Function();
20+
21+typedef _FreeNative = Void Function(Pointer<Uint8>);
22+typedef _FreeDart = void Function(Pointer<Uint8>);
23+
24+typedef _VersionNative = Pointer<Uint8> Function();
25+typedef _VersionDart = Pointer<Uint8> Function();
26+
27+typedef _Str1Native = Pointer<Uint8> Function(Pointer<Uint8>);
28+typedef _Str1Dart = Pointer<Uint8> Function(Pointer<Uint8>);
29+
30+typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
31+typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
32+
33+typedef _Str0Native = Pointer<Uint8> Function();
34+typedef _Str0Dart = Pointer<Uint8> Function();
35+
36+typedef _VoidNative = Void Function();
37+typedef _VoidDart = void Function();
38+
39+/// Where to look for the library, in order.
40+///
41+/// Android resolves a bare soname out of the APK's `lib/<abi>/`. A desktop
42+/// build has no such rule, so the bare name is tried first (it works when the
43+/// object sits beside the executable or on the loader path) and then the
44+/// development path `just nim-lib` writes to. Named explicitly rather than by
45+/// exporting `LD_LIBRARY_PATH` from a launcher, because a variable set in a
46+/// wrapper script is a thing that works until someone starts the binary
47+/// another way.
48+DynamicLibrary _open() {
49+ if (Platform.isAndroid) return DynamicLibrary.open('libfrqcore.so');
50+ // The development paths are relative to whichever directory the process
51+ // started in: `dart test` runs from `dart/frq_core`, a built desktop bundle
52+ // from the repo root. All of them are tried rather than guessing which
53+ // invocation this is, because the failure mode is a StateError at first use
54+ // rather than anything a type checker would have caught.
55+ for (final p in [
56+ 'libfrqcore.so',
57+ 'build/nim/libfrqcore.so',
58+ '../build/nim/libfrqcore.so', // `flutter test`, from flutter/
59+ '../../build/nim/libfrqcore.so', // `dart test`, from dart/frq_core/
60+ ]) {
61+ try {
62+ return DynamicLibrary.open(p);
63+ } on ArgumentError {
64+ continue;
65+ }
66+ }
67+ throw StateError(
68+ 'libfrqcore.so not found — build it with `just nim-lib`, or ship it '
69+ 'beside the executable');
70+}
71+
72+final DynamicLibrary _lib = () {
73+ final lib = _open();
74+ lib.lookupFunction<_InitNative, _InitDart>('frq_init')();
75+ return lib;
76+}();
77+
78+// Every entry point resolved once, here, rather than on each call.
79+//
80+// `lookupFunction` is a dlsym plus a freshly built trampoline closure every
81+// time it runs. At 10Hz for `poll` and once per keystroke for `dispatch` that
82+// is measurable and, more to the point, free to avoid — these are `final`, so
83+// they cost one lookup for the life of the process.
84+final _free = _lib.lookupFunction<_FreeNative, _FreeDart>('frq_free');
85+final _version = _lib.lookupFunction<_VersionNative, _VersionDart>('frq_version');
86+final _tagValue = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_irc_tag_value');
87+final _traceFn = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_trace');
88+final _connOpen = _lib.lookupFunction<_ConnOpenNative, _ConnOpenDart>('frq_conn_open');
89+final _connSend = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_conn_send');
90+final _connCloseFn = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_conn_close');
91+final _connRecvFn = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_recv');
92+final _connEventFn = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_event');
93+final _uiRender = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render');
94+final _uiPoll = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll');
95+final _uiDispatch = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch');
96+final _uiDemo = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_demo');
97+final _uiReset = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset');
98+final _str1 = <String, _Str1Dart>{};
99+
100+/// The bytes at [p] as a string, with [p] freed afterwards. Null in, null out.
101+String? _takeString(Pointer<Uint8> p) {
102+ if (p == nullptr) return null;
103+ try {
104+ // Walk to the NUL rather than asking for a length the ABI does not carry.
105+ var len = 0;
106+ while (p[len] != 0) {
107+ len++;
108+ }
109+ return utf8.decode(p.asTypedList(len));
110+ } finally {
111+ _free(p);
112+ }
113+}
114+
115+/// [s] as a NUL-terminated C string that the CALLER must free with [_freeArg].
116+///
117+/// Allocated with `malloc` from Dart's side, so it is freed from Dart's side —
118+/// the mirror of the rule for what comes back. The core never takes ownership
119+/// of an argument.
120+Pointer<Uint8> _toC(String s) {
121+ final bytes = utf8.encode(s);
122+ final p = _malloc(bytes.length + 1).cast<Uint8>();
123+ for (var i = 0; i < bytes.length; i++) {
124+ p[i] = bytes[i];
125+ }
126+ p[bytes.length] = 0;
127+ return p;
128+}
129+
130+// malloc/free out of libc rather than package:ffi's allocator, for the same
131+// reason the rest of this file avoids that package: one less dependency to
132+// carry to three targets, for two symbols that are always there.
133+final DynamicLibrary _libc =
134+ Platform.isWindows ? DynamicLibrary.open('msvcrt.dll') : DynamicLibrary.process();
135+final _malloc = _libc
136+ .lookupFunction<Pointer<Void> Function(IntPtr), Pointer<Void> Function(int)>('malloc');
137+final _freeArg =
138+ _libc.lookupFunction<Void Function(Pointer<Uint8>), void Function(Pointer<Uint8>)>('free');
139+
140+/// Call a two-strings-in, one-string-out entry point.
141+///
142+/// The mirror of [_call1], and it exists for the same reason: the
143+/// `_toC`/`try`/`finally`/`_freeArg` dance is four lines of ownership
144+/// bookkeeping that no call site should repeat.
145+String? _call2(_Str2Dart f, String x, String y) {
146+ final a = _toC(x);
147+ final b = _toC(y);
148+ try {
149+ return _takeString(f(a, b));
150+ } finally {
151+ _freeArg(a);
152+ _freeArg(b);
153+ }
154+}
155+
156+/// Call a one-string-in, one-string-out entry point.
157+String? _call1(String symbol, String arg) {
158+ final f = _str1.putIfAbsent(
159+ symbol, () => _lib.lookupFunction<_Str1Native, _Str1Dart>(symbol));
160+ final a = _toC(arg);
161+ try {
162+ return _takeString(f(a));
163+ } finally {
164+ _freeArg(a);
165+ }
166+}
167+
168+
169+typedef _ConnOpenNative = Void Function(Pointer<Uint8>, Int32, Int32);
170+typedef _ConnOpenDart = void Function(Pointer<Uint8>, int, int);
171+
172+// ------------------------------------------------------------------ the host
173+//
174+// What `frq_core.dart` calls, and what `host_js.dart` answers with the same
175+// names. Everything above is how; this is what.
176+
177+String? uiRender() => _takeString(_uiRender());
178+String? uiPoll() => _takeString(_uiPoll());
179+
180+String? uiDispatch(String event) {
181+ final a = _toC(event);
182+ try {
183+ return _takeString(_uiDispatch(a));
184+ } finally {
185+ _freeArg(a);
186+ }
187+}
188+
189+void uiDemo() => _uiDemo();
190+void uiReset() => _uiReset();
191+
192+/// Static storage on the Nim side: the one return value that is NOT freed.
193+String hostVersion() {
194+ final p = _version();
195+ var len = 0;
196+ while (p[len] != 0) {
197+ len++;
198+ }
199+ return utf8.decode(p.asTypedList(len));
200+}
201+
202+String? str1(String symbol, String arg) => _call1(symbol, arg);
203+String? tagValueOf(String tags, String key) => _call2(_tagValue, tags, key);
204+void traceTo(String topic, String msg) {
205+ _call2(_traceFn, topic, msg);
206+}
207+
208+void connOpenAt(String host, int port, bool tls) {
209+ final h = _toC(host);
210+ try {
211+ _connOpen(h, port, tls ? 1 : 0);
212+ } finally {
213+ _freeArg(h);
214+ }
215+}
216+
217+void connSendLine(String line) {
218+ final a = _toC(line);
219+ try {
220+ _connSend(a);
221+ } finally {
222+ _freeArg(a);
223+ }
224+}
225+
226+void connCloseNow() => _connCloseFn();
227+String? connRecvLine() => _takeString(_connRecvFn());
228+String? connEventNext() => _takeString(_connEventFn());
new file mode 100644
@@ -0,0 +1,228 @@
1+/// The native half of the seam: `libfrqcore.so`, through `dart:ffi`.
2+///
3+/// Split out of `frq_core.dart` so that file can be imported where there is
4+/// no `dart:ffi` to import — a browser. `host.dart` picks this or `host_js`
5+/// and nothing above it knows which.
6+///
7+/// The two rules of the ABI live here, wrapped so no call site repeats them:
8+/// `frq_init` runs once before anything else, and every string the core
9+/// returns is ours to free with `frq_free`.
10+library;
11+
12+import 'dart:convert';
13+import 'dart:ffi';
14+import 'dart:io';
15+
16+// ---------------------------------------------------------------- the ABI
17+
18+typedef _InitNative = Void Function();
19+typedef _InitDart = void Function();
20+
21+typedef _FreeNative = Void Function(Pointer<Uint8>);
22+typedef _FreeDart = void Function(Pointer<Uint8>);
23+
24+typedef _VersionNative = Pointer<Uint8> Function();
25+typedef _VersionDart = Pointer<Uint8> Function();
26+
27+typedef _Str1Native = Pointer<Uint8> Function(Pointer<Uint8>);
28+typedef _Str1Dart = Pointer<Uint8> Function(Pointer<Uint8>);
29+
30+typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
31+typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
32+
33+typedef _Str0Native = Pointer<Uint8> Function();
34+typedef _Str0Dart = Pointer<Uint8> Function();
35+
36+typedef _VoidNative = Void Function();
37+typedef _VoidDart = void Function();
38+
39+/// Where to look for the library, in order.
40+///
41+/// Android resolves a bare soname out of the APK's `lib/<abi>/`. A desktop
42+/// build has no such rule, so the bare name is tried first (it works when the
43+/// object sits beside the executable or on the loader path) and then the
44+/// development path `just nim-lib` writes to. Named explicitly rather than by
45+/// exporting `LD_LIBRARY_PATH` from a launcher, because a variable set in a
46+/// wrapper script is a thing that works until someone starts the binary
47+/// another way.
48+DynamicLibrary _open() {
49+ if (Platform.isAndroid) return DynamicLibrary.open('libfrqcore.so');
50+ // The development paths are relative to whichever directory the process
51+ // started in: `dart test` runs from `dart/frq_core`, a built desktop bundle
52+ // from the repo root. All of them are tried rather than guessing which
53+ // invocation this is, because the failure mode is a StateError at first use
54+ // rather than anything a type checker would have caught.
55+ for (final p in [
56+ 'libfrqcore.so',
57+ 'build/nim/libfrqcore.so',
58+ '../build/nim/libfrqcore.so', // `flutter test`, from flutter/
59+ '../../build/nim/libfrqcore.so', // `dart test`, from dart/frq_core/
60+ ]) {
61+ try {
62+ return DynamicLibrary.open(p);
63+ } on ArgumentError {
64+ continue;
65+ }
66+ }
67+ throw StateError(
68+ 'libfrqcore.so not found — build it with `just nim-lib`, or ship it '
69+ 'beside the executable');
70+}
71+
72+final DynamicLibrary _lib = () {
73+ final lib = _open();
74+ lib.lookupFunction<_InitNative, _InitDart>('frq_init')();
75+ return lib;
76+}();
77+
78+// Every entry point resolved once, here, rather than on each call.
79+//
80+// `lookupFunction` is a dlsym plus a freshly built trampoline closure every
81+// time it runs. At 10Hz for `poll` and once per keystroke for `dispatch` that
82+// is measurable and, more to the point, free to avoid — these are `final`, so
83+// they cost one lookup for the life of the process.
84+final _free = _lib.lookupFunction<_FreeNative, _FreeDart>('frq_free');
85+final _version = _lib.lookupFunction<_VersionNative, _VersionDart>('frq_version');
86+final _tagValue = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_irc_tag_value');
87+final _traceFn = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_trace');
88+final _connOpen = _lib.lookupFunction<_ConnOpenNative, _ConnOpenDart>('frq_conn_open');
89+final _connSend = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_conn_send');
90+final _connCloseFn = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_conn_close');
91+final _connRecvFn = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_recv');
92+final _connEventFn = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_event');
93+final _uiRender = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render');
94+final _uiPoll = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll');
95+final _uiDispatch = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch');
96+final _uiDemo = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_demo');
97+final _uiReset = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset');
98+final _str1 = <String, _Str1Dart>{};
99+
100+/// The bytes at [p] as a string, with [p] freed afterwards. Null in, null out.
101+String? _takeString(Pointer<Uint8> p) {
102+ if (p == nullptr) return null;
103+ try {
104+ // Walk to the NUL rather than asking for a length the ABI does not carry.
105+ var len = 0;
106+ while (p[len] != 0) {
107+ len++;
108+ }
109+ return utf8.decode(p.asTypedList(len));
110+ } finally {
111+ _free(p);
112+ }
113+}
114+
115+/// [s] as a NUL-terminated C string that the CALLER must free with [_freeArg].
116+///
117+/// Allocated with `malloc` from Dart's side, so it is freed from Dart's side —
118+/// the mirror of the rule for what comes back. The core never takes ownership
119+/// of an argument.
120+Pointer<Uint8> _toC(String s) {
121+ final bytes = utf8.encode(s);
122+ final p = _malloc(bytes.length + 1).cast<Uint8>();
123+ for (var i = 0; i < bytes.length; i++) {
124+ p[i] = bytes[i];
125+ }
126+ p[bytes.length] = 0;
127+ return p;
128+}
129+
130+// malloc/free out of libc rather than package:ffi's allocator, for the same
131+// reason the rest of this file avoids that package: one less dependency to
132+// carry to three targets, for two symbols that are always there.
133+final DynamicLibrary _libc =
134+ Platform.isWindows ? DynamicLibrary.open('msvcrt.dll') : DynamicLibrary.process();
135+final _malloc = _libc
136+ .lookupFunction<Pointer<Void> Function(IntPtr), Pointer<Void> Function(int)>('malloc');
137+final _freeArg =
138+ _libc.lookupFunction<Void Function(Pointer<Uint8>), void Function(Pointer<Uint8>)>('free');
139+
140+/// Call a two-strings-in, one-string-out entry point.
141+///
142+/// The mirror of [_call1], and it exists for the same reason: the
143+/// `_toC`/`try`/`finally`/`_freeArg` dance is four lines of ownership
144+/// bookkeeping that no call site should repeat.
145+String? _call2(_Str2Dart f, String x, String y) {
146+ final a = _toC(x);
147+ final b = _toC(y);
148+ try {
149+ return _takeString(f(a, b));
150+ } finally {
151+ _freeArg(a);
152+ _freeArg(b);
153+ }
154+}
155+
156+/// Call a one-string-in, one-string-out entry point.
157+String? _call1(String symbol, String arg) {
158+ final f = _str1.putIfAbsent(
159+ symbol, () => _lib.lookupFunction<_Str1Native, _Str1Dart>(symbol));
160+ final a = _toC(arg);
161+ try {
162+ return _takeString(f(a));
163+ } finally {
164+ _freeArg(a);
165+ }
166+}
167+
168+
169+typedef _ConnOpenNative = Void Function(Pointer<Uint8>, Int32, Int32);
170+typedef _ConnOpenDart = void Function(Pointer<Uint8>, int, int);
171+
172+// ------------------------------------------------------------------ the host
173+//
174+// What `frq_core.dart` calls, and what `host_js.dart` answers with the same
175+// names. Everything above is how; this is what.
176+
177+String? uiRender() => _takeString(_uiRender());
178+String? uiPoll() => _takeString(_uiPoll());
179+
180+String? uiDispatch(String event) {
181+ final a = _toC(event);
182+ try {
183+ return _takeString(_uiDispatch(a));
184+ } finally {
185+ _freeArg(a);
186+ }
187+}
188+
189+void uiDemo() => _uiDemo();
190+void uiReset() => _uiReset();
191+
192+/// Static storage on the Nim side: the one return value that is NOT freed.
193+String hostVersion() {
194+ final p = _version();
195+ var len = 0;
196+ while (p[len] != 0) {
197+ len++;
198+ }
199+ return utf8.decode(p.asTypedList(len));
200+}
201+
202+String? str1(String symbol, String arg) => _call1(symbol, arg);
203+String? tagValueOf(String tags, String key) => _call2(_tagValue, tags, key);
204+void traceTo(String topic, String msg) {
205+ _call2(_traceFn, topic, msg);
206+}
207+
208+void connOpenAt(String host, int port, bool tls) {
209+ final h = _toC(host);
210+ try {
211+ _connOpen(h, port, tls ? 1 : 0);
212+ } finally {
213+ _freeArg(h);
214+ }
215+}
216+
217+void connSendLine(String line) {
218+ final a = _toC(line);
219+ try {
220+ _connSend(a);
221+ } finally {
222+ _freeArg(a);
223+ }
224+}
225+
226+void connCloseNow() => _connCloseFn();
227+String? connRecvLine() => _takeString(_connRecvFn());
228+String? connEventNext() => _takeString(_connEventFn());
added dart/frq_core/lib/src/host_js.dart +56 -0
new file mode 100644
@@ -0,0 +1,56 @@
1+/// The web half of the seam: the same core, compiled by `nim js`.
2+///
3+/// `nim/web/frq_web.nim` puts one object on `globalThis` and these are its
4+/// methods. No pointers and nothing to free — a string is a string — so this
5+/// file is short where `host_ffi.dart` is careful.
6+///
7+/// The functions that are not here in spirit are the ones a browser has no
8+/// business calling: the socket is JavaScript's on this target, opened by the
9+/// page rather than by Dart, so `connOpenAt` and its neighbours throw rather
10+/// than pretend. They are reached only by the native tests and by
11+/// `tool/live_ui.dart`.
12+library;
13+
14+import 'dart:js_interop';
15+
16+@JS('frq')
17+external _Frq get _frq;
18+
19+@JS()
20+@staticInterop
21+class _Frq {}
22+
23+extension on _Frq {
24+ external JSString render();
25+ external JSString dispatch(JSString event);
26+ external void demo();
27+ external void trace(JSBoolean on);
28+}
29+
30+String? uiRender() => _frq.render().toDart;
31+
32+/// The same call as [uiRender]. There is no cheaper "has anything changed?"
33+/// on this side either — the core drains its queue and builds a tree, and the
34+/// comparison that saves the work happens a layer up, on the JSON.
35+String? uiPoll() => _frq.render().toDart;
36+
37+String? uiDispatch(String event) => _frq.dispatch(event.toJS).toDart;
38+
39+void uiDemo() => _frq.demo();
40+
41+void uiReset() => _frq.dispatch('{"id":"reset"}'.toJS);
42+
43+String hostVersion() => 'js';
44+
45+Never _notHere(String what) => throw UnsupportedError(
46+ '$what is not available in the browser build: the page owns the socket '
47+ 'and the core is reached through globalThis.frq');
48+
49+String? str1(String symbol, String arg) => _notHere(symbol);
50+String? tagValueOf(String tags, String key) => _notHere('tag values');
51+void traceTo(String topic, String msg) => _frq.trace(true.toJS);
52+void connOpenAt(String host, int port, bool tls) => _notHere('connOpen');
53+void connSendLine(String line) => _notHere('connSend');
54+void connCloseNow() => _notHere('connClose');
55+String? connRecvLine() => _notHere('connRecv');
56+String? connEventNext() => _notHere('connEvent');
new file mode 100644
@@ -0,0 +1,56 @@
1+/// The web half of the seam: the same core, compiled by `nim js`.
2+///
3+/// `nim/web/frq_web.nim` puts one object on `globalThis` and these are its
4+/// methods. No pointers and nothing to free — a string is a string — so this
5+/// file is short where `host_ffi.dart` is careful.
6+///
7+/// The functions that are not here in spirit are the ones a browser has no
8+/// business calling: the socket is JavaScript's on this target, opened by the
9+/// page rather than by Dart, so `connOpenAt` and its neighbours throw rather
10+/// than pretend. They are reached only by the native tests and by
11+/// `tool/live_ui.dart`.
12+library;
13+
14+import 'dart:js_interop';
15+
16+@JS('frq')
17+external _Frq get _frq;
18+
19+@JS()
20+@staticInterop
21+class _Frq {}
22+
23+extension on _Frq {
24+ external JSString render();
25+ external JSString dispatch(JSString event);
26+ external void demo();
27+ external void trace(JSBoolean on);
28+}
29+
30+String? uiRender() => _frq.render().toDart;
31+
32+/// The same call as [uiRender]. There is no cheaper "has anything changed?"
33+/// on this side either — the core drains its queue and builds a tree, and the
34+/// comparison that saves the work happens a layer up, on the JSON.
35+String? uiPoll() => _frq.render().toDart;
36+
37+String? uiDispatch(String event) => _frq.dispatch(event.toJS).toDart;
38+
39+void uiDemo() => _frq.demo();
40+
41+void uiReset() => _frq.dispatch('{"id":"reset"}'.toJS);
42+
43+String hostVersion() => 'js';
44+
45+Never _notHere(String what) => throw UnsupportedError(
46+ '$what is not available in the browser build: the page owns the socket '
47+ 'and the core is reached through globalThis.frq');
48+
49+String? str1(String symbol, String arg) => _notHere(symbol);
50+String? tagValueOf(String tags, String key) => _notHere('tag values');
51+void traceTo(String topic, String msg) => _frq.trace(true.toJS);
52+void connOpenAt(String host, int port, bool tls) => _notHere('connOpen');
53+void connSendLine(String line) => _notHere('connSend');
54+void connCloseNow() => _notHere('connClose');
55+String? connRecvLine() => _notHere('connRecv');
56+String? connEventNext() => _notHere('connEvent');
modified flutter/lib/main.dart +4 -4
@@ -12,12 +12,12 @@
1212 /// `nim/README.md` for what is done and what is not.
1313 library;
1414
15-import 'dart:io';
1615
1716 import 'package:flutter/material.dart';
1817 import 'package:frq_core/frq_core.dart' as core;
1918
2019 import 'nim_renderer.dart';
20+import 'src/host.dart' as host;
2121
2222 void main() {
2323 // FRQ_AUTOCONNECT presses Connect at startup, and FRQ_NICK overrides the
@@ -29,10 +29,10 @@ void main() {
2929 // button uses. It lived in `currentTree()` before, which meant the function
3030 // whose job is "serialise the current screen" opened a socket on its first
3131 // call depending on the process environment.
32- final env = Platform.environment;
33- final want = env['FRQ_AUTOCONNECT'] ?? '';
32+ // There is no environment in a browser, so this is simply never on there.
33+ final want = host.envOr('FRQ_AUTOCONNECT', '');
3434 if (want.isNotEmpty && want != '0') {
35- final nick = env['FRQ_NICK'] ?? '';
35+ final nick = host.envOr('FRQ_NICK', '');
3636 if (nick.isNotEmpty) core.dispatch('nick.change', nick);
3737 core.dispatch('connect');
3838 }
@@ -12,12 +12,12 @@
12 /// `nim/README.md` for what is done and what is not.12 /// `nim/README.md` for what is done and what is not.
13 library;13 library;
14 14
15-import 'dart:io';
16 15
17 import 'package:flutter/material.dart';16 import 'package:flutter/material.dart';
18 import 'package:frq_core/frq_core.dart' as core;17 import 'package:frq_core/frq_core.dart' as core;
19 18
20 import 'nim_renderer.dart';19 import 'nim_renderer.dart';
20+import 'src/host.dart' as host;
21 21
22 void main() {22 void main() {
23 // FRQ_AUTOCONNECT presses Connect at startup, and FRQ_NICK overrides the23 // FRQ_AUTOCONNECT presses Connect at startup, and FRQ_NICK overrides the
@@ -29,10 +29,10 @@ void main() {
29 // button uses. It lived in `currentTree()` before, which meant the function29 // button uses. It lived in `currentTree()` before, which meant the function
30 // whose job is "serialise the current screen" opened a socket on its first30 // whose job is "serialise the current screen" opened a socket on its first
31 // call depending on the process environment.31 // call depending on the process environment.
32- final env = Platform.environment;32+ // There is no environment in a browser, so this is simply never on there.
33- final want = env['FRQ_AUTOCONNECT'] ?? '';33+ final want = host.envOr('FRQ_AUTOCONNECT', '');
34 if (want.isNotEmpty && want != '0') {34 if (want.isNotEmpty && want != '0') {
35- final nick = env['FRQ_NICK'] ?? '';35+ final nick = host.envOr('FRQ_NICK', '');
36 if (nick.isNotEmpty) core.dispatch('nick.change', nick);36 if (nick.isNotEmpty) core.dispatch('nick.change', nick);
37 core.dispatch('connect');37 core.dispatch('connect');
38 }38 }
modified flutter/lib/nim_renderer.dart +39 -31
@@ -11,12 +11,12 @@
1111 library;
1212
1313 import 'dart:async';
14-import 'dart:io';
1514
1615 import 'package:flutter/gestures.dart';
1716
1817 import 'package:flutter/material.dart';
1918 import 'package:frq_core/frq_core.dart' as core;
19+import 'src/host.dart' as host;
2020
2121 import 'nim_theme.dart' as t;
2222
@@ -165,16 +165,10 @@ class _NimAppState extends State<NimApp> {
165165 ///
166166 /// A family list rather than one name, because the font that has them
167167 /// differs by platform, and a name nothing matches costs nothing.
168- static const List<String> _emojiFonts = <String>[
169- 'Noto Color Emoji', // Linux, Android
170- 'Apple Color Emoji', // macOS, iOS
171- 'Segoe UI Emoji', // Windows
172- ];
173-
174168 TextStyle _emojiStyle(double size) => TextStyle(
175169 fontSize: size,
176- fontFamily: _emojiFonts.first,
177- fontFamilyFallback: _emojiFonts,
170+ fontFamily: host.emojiFonts.isEmpty ? null : host.emojiFonts.first,
171+ fontFamilyFallback: host.emojiFonts.isEmpty ? null : host.emojiFonts,
178172 );
179173
180174 double _d(dynamic v, double fallback) =>
@@ -203,7 +197,7 @@ class _NimAppState extends State<NimApp> {
203197 if (src.startsWith('http://') || src.startsWith('https://')) {
204198 return NetworkImage(src);
205199 }
206- return FileImage(File(src));
200+ return host.localImage(src);
207201 }
208202
209203 Widget _wrapTap(String onClick, Widget child, {BorderRadius? radius}) {
@@ -610,20 +604,27 @@ class _NimAppState extends State<NimApp> {
610604 case 'avatar':
611605 {
612606 final size = _d(n.props['size'], 32);
613- final provider = _imageProvider(n.prop('url', ''));
607+ final url = n.prop('url', '');
614608 final fallback = n.prop('fallback', '');
609+ final initial = Text(
610+ fallback.isNotEmpty ? fallback.substring(0, 1).toUpperCase() : '?',
611+ style: _style(t.textBody, t.onBg));
612+ // Through the host rather than as a `backgroundImage`: on the web a
613+ // face is an <img> the browser fetches, which is the only kind CORS
614+ // lets through, and an element cannot be a decoration.
615615 final face = CircleAvatar(
616616 radius: size / 2,
617617 backgroundColor: t.component,
618- backgroundImage: provider,
619- onBackgroundImageError: provider == null ? null : (_, _) {},
620- child: provider == null
621- ? Text(
622- fallback.isNotEmpty
623- ? fallback.substring(0, 1).toUpperCase()
624- : '?',
625- style: _style(t.textBody, t.onBg))
626- : null,
618+ child: url.isEmpty
619+ ? initial
620+ : ClipOval(
621+ child: SizedBox(
622+ width: size,
623+ height: size,
624+ child: host.networkImage(url,
625+ fit: BoxFit.cover, onError: () => initial),
626+ ),
627+ ),
627628 );
628629 final onClick = n.prop('onClick', '');
629630 if (onClick.isEmpty) return face;
@@ -636,19 +637,26 @@ class _NimAppState extends State<NimApp> {
636637
637638 case 'image':
638639 {
639- final provider = _imageProvider(n.prop('src', ''));
640- if (provider == null) return const SizedBox.shrink();
640+ final src = n.prop('src', '');
641+ if (src.isEmpty) return const SizedBox.shrink();
641642 final maxW = _d(n.props['maxWidth'], 0);
642643 final maxH = _d(n.props['maxHeight'], 0);
643- Widget img = Image(
644- image: provider,
645- fit: BoxFit.contain,
646- // A half-written cache file, or one deleted under us: the decoder
647- // throws during the build, and an exception in a build is a red
648- // screen for the whole conversation rather than a gap where one
649- // picture was.
650- errorBuilder: (_, _, _) => const SizedBox.shrink(),
651- );
644+ // A half-written cache file, or one deleted under us: the decoder
645+ // throws during the build, and an exception in a build is a red
646+ // screen for the whole conversation rather than a gap where one
647+ // picture was.
648+ Widget img;
649+ if (src.startsWith('http://') || src.startsWith('https://')) {
650+ img = host.networkImage(src);
651+ } else {
652+ final provider = _imageProvider(src);
653+ if (provider == null) return const SizedBox.shrink();
654+ img = Image(
655+ image: provider,
656+ fit: BoxFit.contain,
657+ errorBuilder: (_, _, _) => const SizedBox.shrink(),
658+ );
659+ }
652660 if (maxW > 0 || maxH > 0) {
653661 img = ConstrainedBox(
654662 constraints: BoxConstraints(
@@ -11,12 +11,12 @@
11 library;11 library;
12 12
13 import 'dart:async';13 import 'dart:async';
14-import 'dart:io';
15 14
16 import 'package:flutter/gestures.dart';15 import 'package:flutter/gestures.dart';
17 16
18 import 'package:flutter/material.dart';17 import 'package:flutter/material.dart';
19 import 'package:frq_core/frq_core.dart' as core;18 import 'package:frq_core/frq_core.dart' as core;
19+import 'src/host.dart' as host;
20 20
21 import 'nim_theme.dart' as t;21 import 'nim_theme.dart' as t;
22 22
@@ -165,16 +165,10 @@ class _NimAppState extends State<NimApp> {
165 ///165 ///
166 /// A family list rather than one name, because the font that has them166 /// A family list rather than one name, because the font that has them
167 /// differs by platform, and a name nothing matches costs nothing.167 /// differs by platform, and a name nothing matches costs nothing.
168- static const List<String> _emojiFonts = <String>[
169- 'Noto Color Emoji', // Linux, Android
170- 'Apple Color Emoji', // macOS, iOS
171- 'Segoe UI Emoji', // Windows
172- ];
173-
174 TextStyle _emojiStyle(double size) => TextStyle(168 TextStyle _emojiStyle(double size) => TextStyle(
175 fontSize: size,169 fontSize: size,
176- fontFamily: _emojiFonts.first,170+ fontFamily: host.emojiFonts.isEmpty ? null : host.emojiFonts.first,
177- fontFamilyFallback: _emojiFonts,171+ fontFamilyFallback: host.emojiFonts.isEmpty ? null : host.emojiFonts,
178 );172 );
179 173
180 double _d(dynamic v, double fallback) =>174 double _d(dynamic v, double fallback) =>
@@ -203,7 +197,7 @@ class _NimAppState extends State<NimApp> {
203 if (src.startsWith('http://') || src.startsWith('https://')) {197 if (src.startsWith('http://') || src.startsWith('https://')) {
204 return NetworkImage(src);198 return NetworkImage(src);
205 }199 }
206- return FileImage(File(src));200+ return host.localImage(src);
207 }201 }
208 202
209 Widget _wrapTap(String onClick, Widget child, {BorderRadius? radius}) {203 Widget _wrapTap(String onClick, Widget child, {BorderRadius? radius}) {
@@ -610,20 +604,27 @@ class _NimAppState extends State<NimApp> {
610 case 'avatar':604 case 'avatar':
611 {605 {
612 final size = _d(n.props['size'], 32);606 final size = _d(n.props['size'], 32);
613- final provider = _imageProvider(n.prop('url', ''));607+ final url = n.prop('url', '');
614 final fallback = n.prop('fallback', '');608 final fallback = n.prop('fallback', '');
609+ final initial = Text(
610+ fallback.isNotEmpty ? fallback.substring(0, 1).toUpperCase() : '?',
611+ style: _style(t.textBody, t.onBg));
612+ // Through the host rather than as a `backgroundImage`: on the web a
613+ // face is an <img> the browser fetches, which is the only kind CORS
614+ // lets through, and an element cannot be a decoration.
615 final face = CircleAvatar(615 final face = CircleAvatar(
616 radius: size / 2,616 radius: size / 2,
617 backgroundColor: t.component,617 backgroundColor: t.component,
618- backgroundImage: provider,618+ child: url.isEmpty
619- onBackgroundImageError: provider == null ? null : (_, _) {},619+ ? initial
620- child: provider == null620+ : ClipOval(
621- ? Text(621+ child: SizedBox(
622- fallback.isNotEmpty622+ width: size,
623- ? fallback.substring(0, 1).toUpperCase()623+ height: size,
624- : '?',624+ child: host.networkImage(url,
625- style: _style(t.textBody, t.onBg))625+ fit: BoxFit.cover, onError: () => initial),
626- : null,626+ ),
627+ ),
627 );628 );
628 final onClick = n.prop('onClick', '');629 final onClick = n.prop('onClick', '');
629 if (onClick.isEmpty) return face;630 if (onClick.isEmpty) return face;
@@ -636,19 +637,26 @@ class _NimAppState extends State<NimApp> {
636 637
637 case 'image':638 case 'image':
638 {639 {
639- final provider = _imageProvider(n.prop('src', ''));640+ final src = n.prop('src', '');
640- if (provider == null) return const SizedBox.shrink();641+ if (src.isEmpty) return const SizedBox.shrink();
641 final maxW = _d(n.props['maxWidth'], 0);642 final maxW = _d(n.props['maxWidth'], 0);
642 final maxH = _d(n.props['maxHeight'], 0);643 final maxH = _d(n.props['maxHeight'], 0);
643- Widget img = Image(644+ // A half-written cache file, or one deleted under us: the decoder
644- image: provider,645+ // throws during the build, and an exception in a build is a red
645- fit: BoxFit.contain,646+ // screen for the whole conversation rather than a gap where one
646- // A half-written cache file, or one deleted under us: the decoder647+ // picture was.
647- // throws during the build, and an exception in a build is a red648+ Widget img;
648- // screen for the whole conversation rather than a gap where one649+ if (src.startsWith('http://') || src.startsWith('https://')) {
649- // picture was.650+ img = host.networkImage(src);
650- errorBuilder: (_, _, _) => const SizedBox.shrink(),651+ } else {
651- );652+ final provider = _imageProvider(src);
653+ if (provider == null) return const SizedBox.shrink();
654+ img = Image(
655+ image: provider,
656+ fit: BoxFit.contain,
657+ errorBuilder: (_, _, _) => const SizedBox.shrink(),
658+ );
659+ }
652 if (maxW > 0 || maxH > 0) {660 if (maxW > 0 || maxH > 0) {
653 img = ConstrainedBox(661 img = ConstrainedBox(
654 constraints: BoxConstraints(662 constraints: BoxConstraints(
added flutter/lib/src/host.dart +4 -0
new file mode 100644
@@ -0,0 +1,4 @@
1+/// Whichever of the two this build got.
2+library;
3+
4+export 'host_io.dart' if (dart.library.js_interop) 'host_web.dart';
new file mode 100644
@@ -0,0 +1,4 @@
1+/// Whichever of the two this build got.
2+library;
3+
4+export 'host_io.dart' if (dart.library.js_interop) 'host_web.dart';
added flutter/lib/src/host_io.dart +43 -0
new file mode 100644
@@ -0,0 +1,43 @@
1+/// The two things the renderer and the entry point want from the platform,
2+/// on a platform that has them.
3+///
4+/// Both are small and neither is worth a package. What they have in common is
5+/// that a browser has neither: there is no environment to read and no file to
6+/// open, and `dart:io` cannot even be imported there — so the import lives
7+/// here, behind the conditional in `host.dart`.
8+library;
9+
10+import 'dart:io';
11+
12+import 'package:flutter/widgets.dart';
13+
14+/// An environment variable, or [fallback].
15+String envOr(String name, String fallback) =>
16+ Platform.environment[name] ?? fallback;
17+
18+/// A picture from the filesystem — an attachment the reader picked, before it
19+/// has been uploaded anywhere.
20+ImageProvider? localImage(String path) => FileImage(File(path));
21+
22+/// A picture from the network.
23+///
24+/// The plain widget here. The web needs a different one, and the difference
25+/// is not cosmetic — see `host_web.dart`.
26+Widget networkImage(String url,
27+ {BoxFit fit = BoxFit.contain,
28+ Widget Function()? onError}) =>
29+ Image.network(url,
30+ fit: fit,
31+ errorBuilder: (_, _, _) =>
32+ onError == null ? const SizedBox.shrink() : onError());
33+
34+/// The families to ask for when a widget is nothing but an emoji.
35+///
36+/// Naming one matters here: a glyph like ✏️ is U+270F plus a variation
37+/// selector, and DejaVu Sans claims U+270F — so ordinary fallback draws the
38+/// monochrome pencil and never reaches the colour font.
39+const List<String> emojiFonts = <String>[
40+ 'Noto Color Emoji', // Linux, Android
41+ 'Apple Color Emoji', // macOS, iOS
42+ 'Segoe UI Emoji', // Windows
43+];
new file mode 100644
@@ -0,0 +1,43 @@
1+/// The two things the renderer and the entry point want from the platform,
2+/// on a platform that has them.
3+///
4+/// Both are small and neither is worth a package. What they have in common is
5+/// that a browser has neither: there is no environment to read and no file to
6+/// open, and `dart:io` cannot even be imported there — so the import lives
7+/// here, behind the conditional in `host.dart`.
8+library;
9+
10+import 'dart:io';
11+
12+import 'package:flutter/widgets.dart';
13+
14+/// An environment variable, or [fallback].
15+String envOr(String name, String fallback) =>
16+ Platform.environment[name] ?? fallback;
17+
18+/// A picture from the filesystem — an attachment the reader picked, before it
19+/// has been uploaded anywhere.
20+ImageProvider? localImage(String path) => FileImage(File(path));
21+
22+/// A picture from the network.
23+///
24+/// The plain widget here. The web needs a different one, and the difference
25+/// is not cosmetic — see `host_web.dart`.
26+Widget networkImage(String url,
27+ {BoxFit fit = BoxFit.contain,
28+ Widget Function()? onError}) =>
29+ Image.network(url,
30+ fit: fit,
31+ errorBuilder: (_, _, _) =>
32+ onError == null ? const SizedBox.shrink() : onError());
33+
34+/// The families to ask for when a widget is nothing but an emoji.
35+///
36+/// Naming one matters here: a glyph like ✏️ is U+270F plus a variation
37+/// selector, and DejaVu Sans claims U+270F — so ordinary fallback draws the
38+/// monochrome pencil and never reaches the colour font.
39+const List<String> emojiFonts = <String>[
40+ 'Noto Color Emoji', // Linux, Android
41+ 'Apple Color Emoji', // macOS, iOS
42+ 'Segoe UI Emoji', // Windows
43+];
added flutter/lib/src/host_web.dart +47 -0
new file mode 100644
@@ -0,0 +1,47 @@
1+/// The same two things, in a browser, where there is neither.
2+///
3+/// A page has no environment: `FRQ_AUTOCONNECT` is a thing you set before
4+/// starting a process, and nothing here was started that way. And it has no
5+/// filesystem the renderer could open — a picture a reader attaches arrives
6+/// as a blob URL or not at all, and the path branch is never reached.
7+library;
8+
9+import 'package:flutter/widgets.dart';
10+
11+String envOr(String name, String fallback) => fallback;
12+
13+ImageProvider? localImage(String path) => null;
14+
15+/// A picture from the network, drawn by the browser rather than decoded by
16+/// Flutter.
17+///
18+/// This is the one place the web build cannot simply use the same widget.
19+/// Flutter fetches image bytes with an XMLHttpRequest so it can decode them
20+/// into a texture, and an XHR is subject to CORS — so every avatar on
21+/// `cdn.bsky.app` and every picture on freeq's media host failed with "No
22+/// 'Access-Control-Allow-Origin' header", because neither sends one to a
23+/// third-party page and neither has any reason to.
24+///
25+/// An `<img>` element has never needed permission to display a picture, and
26+/// `WebHtmlElementStrategy.prefer` is Flutter asking for exactly that: the
27+/// browser loads and draws it, and Flutter positions the element. The cost is
28+/// that such an image is outside the canvas — it cannot be blended or
29+/// transformed like a texture — which for a face and a photograph in a
30+/// conversation is no cost at all.
31+Widget networkImage(String url,
32+ {BoxFit fit = BoxFit.contain,
33+ Widget Function()? onError}) =>
34+ Image.network(url,
35+ fit: fit,
36+ webHtmlElementStrategy: WebHtmlElementStrategy.prefer,
37+ errorBuilder: (_, _, _) =>
38+ onError == null ? const SizedBox.shrink() : onError());
39+
40+/// No family is named on the web, and that is the fix rather than the gap.
41+///
42+/// A browser is not painting with the machine's fonts: CanvasKit carries its
43+/// own, and downloads a Noto face on demand for any glyph it cannot draw —
44+/// emoji included. Naming a family it does not have defeats that, because a
45+/// named family that is missing is a notdef box rather than a search. Every
46+/// reaction chip drew ▯ until this list was empty.
47+const List<String> emojiFonts = <String>[];
new file mode 100644
@@ -0,0 +1,47 @@
1+/// The same two things, in a browser, where there is neither.
2+///
3+/// A page has no environment: `FRQ_AUTOCONNECT` is a thing you set before
4+/// starting a process, and nothing here was started that way. And it has no
5+/// filesystem the renderer could open — a picture a reader attaches arrives
6+/// as a blob URL or not at all, and the path branch is never reached.
7+library;
8+
9+import 'package:flutter/widgets.dart';
10+
11+String envOr(String name, String fallback) => fallback;
12+
13+ImageProvider? localImage(String path) => null;
14+
15+/// A picture from the network, drawn by the browser rather than decoded by
16+/// Flutter.
17+///
18+/// This is the one place the web build cannot simply use the same widget.
19+/// Flutter fetches image bytes with an XMLHttpRequest so it can decode them
20+/// into a texture, and an XHR is subject to CORS — so every avatar on
21+/// `cdn.bsky.app` and every picture on freeq's media host failed with "No
22+/// 'Access-Control-Allow-Origin' header", because neither sends one to a
23+/// third-party page and neither has any reason to.
24+///
25+/// An `<img>` element has never needed permission to display a picture, and
26+/// `WebHtmlElementStrategy.prefer` is Flutter asking for exactly that: the
27+/// browser loads and draws it, and Flutter positions the element. The cost is
28+/// that such an image is outside the canvas — it cannot be blended or
29+/// transformed like a texture — which for a face and a photograph in a
30+/// conversation is no cost at all.
31+Widget networkImage(String url,
32+ {BoxFit fit = BoxFit.contain,
33+ Widget Function()? onError}) =>
34+ Image.network(url,
35+ fit: fit,
36+ webHtmlElementStrategy: WebHtmlElementStrategy.prefer,
37+ errorBuilder: (_, _, _) =>
38+ onError == null ? const SizedBox.shrink() : onError());
39+
40+/// No family is named on the web, and that is the fix rather than the gap.
41+///
42+/// A browser is not painting with the machine's fonts: CanvasKit carries its
43+/// own, and downloads a Noto face on demand for any glyph it cannot draw —
44+/// emoji included. Naming a family it does not have defeats that, because a
45+/// named family that is missing is a notdef box rather than a search. Every
46+/// reaction chip drew ▯ until this list was empty.
47+const List<String> emojiFonts = <String>[];
deleted flutter/web/frq_dpop.js +0 -134
deleted file mode 100644
@@ -1,134 +0,0 @@
1-// DPoP for the browser OAuth client: ES256 keys, proofs, PKCE.
2-//
3-// JavaScript rather than ClojureDart, deliberately. What this does is
4-// WebCrypto — generateKey, sign, digest, exportKey — and every one of those
5-// speaks in Promises, ArrayBuffers, JWK objects and JS algorithm records.
6-// Reaching them from cljd means dart:js_util for each value in both
7-// directions, and ArrayBuffer-to-bytes is the kind of conversion that fails
8-// at run time rather than at the compiler. Here it is the language's home
9-// ground, and what crosses the boundary is a string.
10-//
11-// So the contract is narrow on purpose: every function below takes strings
12-// and returns a string or a Promise of one. `frq.dpop.web` is the other half.
13-(function () {
14- 'use strict';
15-
16- const enc = new TextEncoder();
17-
18- const b64u = (buf) =>
19- btoa(String.fromCharCode(...new Uint8Array(buf)))
20- .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
21-
22- const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
23- const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
24-
25- // The key pair this client proves it holds. One per sign-in, and it must
26- // outlive a full-page redirect — the authorization leg leaves for the PDS
27- // and comes back as a fresh load — so it is kept as JWK in localStorage
28- // rather than as a non-extractable CryptoKey in IndexedDB.
29- //
30- // That is a deliberate trade and worth naming: an extractable key sits
31- // beside the access token it is bound to, in the same store, and anything
32- // that can read one can read the other. They share a lifetime and a blast
33- // radius, so the key being extractable costs nothing the token does not
34- // already cost — and IndexedDB interop through cljd would cost a great deal.
35- const KEY = 'frq:dpop:jwk';
36-
37- let cached = null;
38-
39- async function keys() {
40- if (cached) return cached;
41- let jwk = null;
42- try { jwk = JSON.parse(localStorage.getItem(KEY)); } catch (e) { jwk = null; }
43- if (!jwk) {
44- const kp = await crypto.subtle.generateKey(ALG, true, ['sign', 'verify']);
45- jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
46- try { localStorage.setItem(KEY, JSON.stringify(jwk)); } catch (e) { /* private mode */ }
47- }
48- const priv = await crypto.subtle.importKey('jwk', jwk, ALG, true, ['sign']);
49- // The public half of the same key, which is what a proof carries in its
50- // header. Derived from the private JWK by dropping the private fields
51- // rather than exported separately, so the two cannot drift apart.
52- const pub = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
53- cached = { priv, pub };
54- return cached;
55- }
56-
57- async function jws(header, payload, priv) {
58- const h = b64u(enc.encode(JSON.stringify(header)));
59- const p = b64u(enc.encode(JSON.stringify(payload)));
60- // WebCrypto signs ECDSA as raw R||S, which is exactly what JOSE wants —
61- // no DER unwrapping, unlike most non-browser crypto libraries.
62- const sig = await crypto.subtle.sign(SIGN, priv, enc.encode(h + '.' + p));
63- return h + '.' + p + '.' + b64u(sig);
64- }
65-
66- // One DPoP proof. `nonce` and `token` may be empty strings — cljd has no
67- // convenient undefined, and an empty string is the honest "not this time".
68- //
69- // `ath` is the access token's SHA-256, and it is what lets a proof be
70- // minted for a request this client will never make: freeq's SASL calls the
71- // PDS's getSession on our behalf, with our token and our proof, and the PDS
72- // checks that the proof names that token and that URL.
73- async function proof(htm, htu, nonce, token) {
74- const { priv, pub } = await keys();
75- const payload = {
76- jti: crypto.randomUUID(),
77- htm: htm,
78- htu: htu,
79- iat: Math.floor(Date.now() / 1000),
80- };
81- if (nonce) payload.nonce = nonce;
82- if (token) {
83- payload.ath = b64u(await crypto.subtle.digest('SHA-256', enc.encode(token)));
84- }
85- return jws({ typ: 'dpop+jwt', alg: 'ES256', jwk: pub }, payload, priv);
86- }
87-
88- // PKCE. The verifier is kept by the caller (it has to survive the redirect
89- // and `frq.io` already knows how to keep things); this only makes the pair.
90- function verifier() {
91- return b64u(crypto.getRandomValues(new Uint8Array(32)));
92- }
93-
94- async function challenge(verifier) {
95- return b64u(await crypto.subtle.digest('SHA-256', enc.encode(verifier)));
96- }
97-
98- function random(n) {
99- return b64u(crypto.getRandomValues(new Uint8Array(n)));
100- }
101-
102- // Forget the key. Called when a session is dropped: a DPoP key outliving
103- // the token it was bound to is a key with nothing to prove.
104- function forget() {
105- cached = null;
106- try { localStorage.removeItem(KEY); } catch (e) { /* nothing to do */ }
107- }
108-
109- // Callbacks rather than Promises, and node-style `cb(err, value)`.
110- //
111- // ClojureDart can only reach JavaScript through `dart:js` here: cljd's
112- // analyzer resolves that library and neither `dart:js_util` nor
113- // `dart:js_interop` ("Can't find Dart lib"), so there is no
114- // `promiseToFuture` to turn a thenable into a Future. What `dart:js` does
115- // give is automatic wrapping of a Dart closure passed as an argument — so
116- // the Promise is unwrapped on this side and the answer handed back through
117- // a function call, which crosses the boundary cleanly.
118- const cbify = (fn) => (...args) => {
119- const cb = args.pop();
120- Promise.resolve(fn(...args)).then(
121- (v) => cb('', v),
122- (e) => cb(String(e && e.message ? e.message : e), ''),
123- );
124- };
125-
126- window.frqDpop = {
127- proof: cbify(proof),
128- challenge: cbify(challenge),
129- // Synchronous already: no crypto to await, just random bytes.
130- verifier: verifier,
131- random: random,
132- forget: forget,
133- };
134-})();
deleted file mode 100644
@@ -1,134 +0,0 @@
1-// DPoP for the browser OAuth client: ES256 keys, proofs, PKCE.
2-//
3-// JavaScript rather than ClojureDart, deliberately. What this does is
4-// WebCrypto — generateKey, sign, digest, exportKey — and every one of those
5-// speaks in Promises, ArrayBuffers, JWK objects and JS algorithm records.
6-// Reaching them from cljd means dart:js_util for each value in both
7-// directions, and ArrayBuffer-to-bytes is the kind of conversion that fails
8-// at run time rather than at the compiler. Here it is the language's home
9-// ground, and what crosses the boundary is a string.
10-//
11-// So the contract is narrow on purpose: every function below takes strings
12-// and returns a string or a Promise of one. `frq.dpop.web` is the other half.
13-(function () {
14- 'use strict';
15-
16- const enc = new TextEncoder();
17-
18- const b64u = (buf) =>
19- btoa(String.fromCharCode(...new Uint8Array(buf)))
20- .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
21-
22- const ALG = { name: 'ECDSA', namedCurve: 'P-256' };
23- const SIGN = { name: 'ECDSA', hash: 'SHA-256' };
24-
25- // The key pair this client proves it holds. One per sign-in, and it must
26- // outlive a full-page redirect — the authorization leg leaves for the PDS
27- // and comes back as a fresh load — so it is kept as JWK in localStorage
28- // rather than as a non-extractable CryptoKey in IndexedDB.
29- //
30- // That is a deliberate trade and worth naming: an extractable key sits
31- // beside the access token it is bound to, in the same store, and anything
32- // that can read one can read the other. They share a lifetime and a blast
33- // radius, so the key being extractable costs nothing the token does not
34- // already cost — and IndexedDB interop through cljd would cost a great deal.
35- const KEY = 'frq:dpop:jwk';
36-
37- let cached = null;
38-
39- async function keys() {
40- if (cached) return cached;
41- let jwk = null;
42- try { jwk = JSON.parse(localStorage.getItem(KEY)); } catch (e) { jwk = null; }
43- if (!jwk) {
44- const kp = await crypto.subtle.generateKey(ALG, true, ['sign', 'verify']);
45- jwk = await crypto.subtle.exportKey('jwk', kp.privateKey);
46- try { localStorage.setItem(KEY, JSON.stringify(jwk)); } catch (e) { /* private mode */ }
47- }
48- const priv = await crypto.subtle.importKey('jwk', jwk, ALG, true, ['sign']);
49- // The public half of the same key, which is what a proof carries in its
50- // header. Derived from the private JWK by dropping the private fields
51- // rather than exported separately, so the two cannot drift apart.
52- const pub = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
53- cached = { priv, pub };
54- return cached;
55- }
56-
57- async function jws(header, payload, priv) {
58- const h = b64u(enc.encode(JSON.stringify(header)));
59- const p = b64u(enc.encode(JSON.stringify(payload)));
60- // WebCrypto signs ECDSA as raw R||S, which is exactly what JOSE wants —
61- // no DER unwrapping, unlike most non-browser crypto libraries.
62- const sig = await crypto.subtle.sign(SIGN, priv, enc.encode(h + '.' + p));
63- return h + '.' + p + '.' + b64u(sig);
64- }
65-
66- // One DPoP proof. `nonce` and `token` may be empty strings — cljd has no
67- // convenient undefined, and an empty string is the honest "not this time".
68- //
69- // `ath` is the access token's SHA-256, and it is what lets a proof be
70- // minted for a request this client will never make: freeq's SASL calls the
71- // PDS's getSession on our behalf, with our token and our proof, and the PDS
72- // checks that the proof names that token and that URL.
73- async function proof(htm, htu, nonce, token) {
74- const { priv, pub } = await keys();
75- const payload = {
76- jti: crypto.randomUUID(),
77- htm: htm,
78- htu: htu,
79- iat: Math.floor(Date.now() / 1000),
80- };
81- if (nonce) payload.nonce = nonce;
82- if (token) {
83- payload.ath = b64u(await crypto.subtle.digest('SHA-256', enc.encode(token)));
84- }
85- return jws({ typ: 'dpop+jwt', alg: 'ES256', jwk: pub }, payload, priv);
86- }
87-
88- // PKCE. The verifier is kept by the caller (it has to survive the redirect
89- // and `frq.io` already knows how to keep things); this only makes the pair.
90- function verifier() {
91- return b64u(crypto.getRandomValues(new Uint8Array(32)));
92- }
93-
94- async function challenge(verifier) {
95- return b64u(await crypto.subtle.digest('SHA-256', enc.encode(verifier)));
96- }
97-
98- function random(n) {
99- return b64u(crypto.getRandomValues(new Uint8Array(n)));
100- }
101-
102- // Forget the key. Called when a session is dropped: a DPoP key outliving
103- // the token it was bound to is a key with nothing to prove.
104- function forget() {
105- cached = null;
106- try { localStorage.removeItem(KEY); } catch (e) { /* nothing to do */ }
107- }
108-
109- // Callbacks rather than Promises, and node-style `cb(err, value)`.
110- //
111- // ClojureDart can only reach JavaScript through `dart:js` here: cljd's
112- // analyzer resolves that library and neither `dart:js_util` nor
113- // `dart:js_interop` ("Can't find Dart lib"), so there is no
114- // `promiseToFuture` to turn a thenable into a Future. What `dart:js` does
115- // give is automatic wrapping of a Dart closure passed as an argument — so
116- // the Promise is unwrapped on this side and the answer handed back through
117- // a function call, which crosses the boundary cleanly.
118- const cbify = (fn) => (...args) => {
119- const cb = args.pop();
120- Promise.resolve(fn(...args)).then(
121- (v) => cb('', v),
122- (e) => cb(String(e && e.message ? e.message : e), ''),
123- );
124- };
125-
126- window.frqDpop = {
127- proof: cbify(proof),
128- challenge: cbify(challenge),
129- // Synchronous already: no crypto to await, just random bytes.
130- verifier: verifier,
131- random: random,
132- forget: forget,
133- };
134-})();
added flutter/web/frq_host.js +99 -0
new file mode 100644
@@ -0,0 +1,99 @@
1+// The half of the web build that owns the I/O.
2+//
3+// `frq_core.js` is the same Nim the desktop runs, compiled by `nim js`. It
4+// holds the state and builds the screens, and it opens nothing: a browser
5+// cannot dial a TCP socket, and `frq/conn` on this target is a pair of queues
6+// rather than two threads. This file is what fills them.
7+//
8+// Three jobs, and nothing else:
9+//
10+// * the socket. The core says where it wants to be connected; this opens a
11+// WebSocket to freeq's own bridge, feeds every line in, and sends
12+// everything the core has queued.
13+// * the sign-in. The broker answers by redirecting the page back with a
14+// payload in the fragment, so a sign-in finishes on the *next* load —
15+// this reads it, hands it over, and takes it off the URL.
16+// * the profiles, which the core asks for through `fetch` (in the Nim, not
17+// here), so there is nothing to do for them.
18+//
19+// Flutter draws. It reaches the core through `dart:js_interop`, and never
20+// touches any of this.
21+
22+(function () {
23+ "use strict";
24+
25+ var ws = null;
26+ var wantedNow = "";
27+
28+ // freeq publishes this for exactly this case — the same server, the same
29+ // SASL, over a transport a page is allowed to open. The host and the
30+ // scheme come from what the core asked for; the path is the bridge's.
31+ function urlFor(cfg) {
32+ return "wss://" + cfg.host + "/irc";
33+ }
34+
35+ function connect(cfg) {
36+ var url = urlFor(cfg);
37+ try {
38+ ws = new WebSocket(url);
39+ } catch (e) {
40+ frq.socketEvent("error: " + e);
41+ return;
42+ }
43+ ws.onopen = function () { frq.socketEvent("open"); };
44+ ws.onmessage = function (ev) {
45+ // A frame can carry more than one line, and carries the CRLF the wire
46+ // format puts between them. The core wants lines.
47+ String(ev.data).split(/\r?\n/).forEach(function (line) {
48+ if (line.length > 0) frq.feed(line);
49+ });
50+ };
51+ ws.onclose = function (ev) {
52+ ws = null;
53+ wantedNow = "";
54+ frq.socketEvent("close: " + (ev.reason || "the connection ended"));
55+ };
56+ ws.onerror = function () {
57+ // `onerror` carries nothing worth reporting — the browser withholds the
58+ // reason on purpose — and `onclose` always follows, so the message the
59+ // reader sees comes from there.
60+ frq.socketEvent("error: the connection failed");
61+ };
62+ }
63+
64+ // The pump. Both directions, on a timer, because nothing here is allowed to
65+ // call into Dart and Dart is not going to ask on the core's behalf.
66+ //
67+ // Twenty times a second: fast enough that a keystroke's PRIVMSG does not
68+ // sit in a queue where a reader would notice, and slow enough to be free.
69+ setInterval(function () {
70+ var want = frq.wanted();
71+ if (want && want !== wantedNow) {
72+ wantedNow = want;
73+ if (ws) { try { ws.close(); } catch (e) {} ws = null; }
74+ connect(JSON.parse(want));
75+ }
76+ if (ws && ws.readyState === 1) {
77+ var out = frq.takeOutbound();
78+ if (out) {
79+ out.split("\n").forEach(function (line) {
80+ if (line.length > 0) ws.send(line);
81+ });
82+ }
83+ }
84+ }, 50);
85+
86+ // The broker's answer, which arrives as a fragment on a fresh load.
87+ //
88+ // Taken off the URL once read: a payload carries a single-use token, and
89+ // leaving it in the address bar means it is in the history, in whatever
90+ // the reader pastes, and replayed by a refresh.
91+ var payload = "";
92+ if (window.location.hash) {
93+ var h = window.location.hash.replace(/^#/, "");
94+ payload = new URLSearchParams(h).get("oauth") || h.replace(/^oauth=/, "");
95+ history.replaceState(null, "", window.location.pathname + window.location.search);
96+ }
97+
98+ frq.init(payload);
99+})();
new file mode 100644
@@ -0,0 +1,99 @@
1+// The half of the web build that owns the I/O.
2+//
3+// `frq_core.js` is the same Nim the desktop runs, compiled by `nim js`. It
4+// holds the state and builds the screens, and it opens nothing: a browser
5+// cannot dial a TCP socket, and `frq/conn` on this target is a pair of queues
6+// rather than two threads. This file is what fills them.
7+//
8+// Three jobs, and nothing else:
9+//
10+// * the socket. The core says where it wants to be connected; this opens a
11+// WebSocket to freeq's own bridge, feeds every line in, and sends
12+// everything the core has queued.
13+// * the sign-in. The broker answers by redirecting the page back with a
14+// payload in the fragment, so a sign-in finishes on the *next* load —
15+// this reads it, hands it over, and takes it off the URL.
16+// * the profiles, which the core asks for through `fetch` (in the Nim, not
17+// here), so there is nothing to do for them.
18+//
19+// Flutter draws. It reaches the core through `dart:js_interop`, and never
20+// touches any of this.
21+
22+(function () {
23+ "use strict";
24+
25+ var ws = null;
26+ var wantedNow = "";
27+
28+ // freeq publishes this for exactly this case — the same server, the same
29+ // SASL, over a transport a page is allowed to open. The host and the
30+ // scheme come from what the core asked for; the path is the bridge's.
31+ function urlFor(cfg) {
32+ return "wss://" + cfg.host + "/irc";
33+ }
34+
35+ function connect(cfg) {
36+ var url = urlFor(cfg);
37+ try {
38+ ws = new WebSocket(url);
39+ } catch (e) {
40+ frq.socketEvent("error: " + e);
41+ return;
42+ }
43+ ws.onopen = function () { frq.socketEvent("open"); };
44+ ws.onmessage = function (ev) {
45+ // A frame can carry more than one line, and carries the CRLF the wire
46+ // format puts between them. The core wants lines.
47+ String(ev.data).split(/\r?\n/).forEach(function (line) {
48+ if (line.length > 0) frq.feed(line);
49+ });
50+ };
51+ ws.onclose = function (ev) {
52+ ws = null;
53+ wantedNow = "";
54+ frq.socketEvent("close: " + (ev.reason || "the connection ended"));
55+ };
56+ ws.onerror = function () {
57+ // `onerror` carries nothing worth reporting — the browser withholds the
58+ // reason on purpose — and `onclose` always follows, so the message the
59+ // reader sees comes from there.
60+ frq.socketEvent("error: the connection failed");
61+ };
62+ }
63+
64+ // The pump. Both directions, on a timer, because nothing here is allowed to
65+ // call into Dart and Dart is not going to ask on the core's behalf.
66+ //
67+ // Twenty times a second: fast enough that a keystroke's PRIVMSG does not
68+ // sit in a queue where a reader would notice, and slow enough to be free.
69+ setInterval(function () {
70+ var want = frq.wanted();
71+ if (want && want !== wantedNow) {
72+ wantedNow = want;
73+ if (ws) { try { ws.close(); } catch (e) {} ws = null; }
74+ connect(JSON.parse(want));
75+ }
76+ if (ws && ws.readyState === 1) {
77+ var out = frq.takeOutbound();
78+ if (out) {
79+ out.split("\n").forEach(function (line) {
80+ if (line.length > 0) ws.send(line);
81+ });
82+ }
83+ }
84+ }, 50);
85+
86+ // The broker's answer, which arrives as a fragment on a fresh load.
87+ //
88+ // Taken off the URL once read: a payload carries a single-use token, and
89+ // leaving it in the address bar means it is in the history, in whatever
90+ // the reader pastes, and replayed by a refresh.
91+ var payload = "";
92+ if (window.location.hash) {
93+ var h = window.location.hash.replace(/^#/, "");
94+ payload = new URLSearchParams(h).get("oauth") || h.replace(/^oauth=/, "");
95+ history.replaceState(null, "", window.location.pathname + window.location.search);
96+ }
97+
98+ frq.init(payload);
99+})();
modified flutter/web/index.html +10 -5
@@ -27,12 +27,17 @@
2727 <link rel="manifest" href="manifest.json">
2828
2929 <!--
30- WebCrypto for the OAuth client, loaded before the bundle so it is there
31- the moment ClojureDart asks. Not `async`: `frq.dpop.web` calls into it
32- during sign-in, and a helper that might not have parsed yet is a race
33- nobody would enjoy debugging.
30+ The core, and the half of the web build that owns the I/O. Both before
31+ the bundle and neither `async`: Flutter reaches `globalThis.frq` as soon
32+ as it starts, and a core that might not have parsed yet is a race nobody
33+ would enjoy debugging.
34+
35+ This is where the ClojureDart build loaded `frq_dpop.js`, a WebCrypto
36+ helper for an OAuth client written in Dart. The broker does that work
37+ now, and the client asking for it is Nim.
3438 -->
35- <script src="frq_dpop.js"></script>
39+ <script src="frq_core.js"></script>
40+ <script src="frq_host.js"></script>
3641 </head>
3742 <body>
3843 <script src="flutter_bootstrap.js" async></script>
@@ -27,12 +27,17 @@
27 <link rel="manifest" href="manifest.json">27 <link rel="manifest" href="manifest.json">
28 28
29 <!--29 <!--
30- WebCrypto for the OAuth client, loaded before the bundle so it is there30+ The core, and the half of the web build that owns the I/O. Both before
31- the moment ClojureDart asks. Not `async`: `frq.dpop.web` calls into it31+ the bundle and neither `async`: Flutter reaches `globalThis.frq` as soon
32- during sign-in, and a helper that might not have parsed yet is a race32+ as it starts, and a core that might not have parsed yet is a race nobody
33- nobody would enjoy debugging.33+ would enjoy debugging.
34+
35+ This is where the ClojureDart build loaded `frq_dpop.js`, a WebCrypto
36+ helper for an OAuth client written in Dart. The broker does that work
37+ now, and the client asking for it is Nim.
34 -->38 -->
35- <script src="frq_dpop.js"></script>39+ <script src="frq_core.js"></script>
40+ <script src="frq_host.js"></script>
36 </head>41 </head>
37 <body>42 <body>
38 <script src="flutter_bootstrap.js" async></script>43 <script src="flutter_bootstrap.js" async></script>
modified justfile +32 -4
@@ -40,23 +40,27 @@ tools *args:
4040 # Build a target.
4141 #
4242 # desktop the app: Nim owns the state and the screens, Flutter paints
43+# web the same, in a browser: the core compiled by `nim js`, the
44+# socket a WebSocket to freeq's own bridge, Flutter painting
4345 # lib the Nim core alone, as build/nim/libfrqcore.so
46+# core-js the core alone, as build/web/frq_core.js
4447 #
4548 # There were four more. `apk` and `web` compiled ClojureDart and went with it:
4649 # the web target cannot come back without a wasm build of the core, since a
4750 # browser has no dart:ffi, and the APK wants libfrqcore.so cross-compiled for
4851 # Android's ABIs. `ui` and `app` were the two halves of the migration, and
4952 # there is one app now.
50-[doc('build a target: desktop lib')]
53+[doc('build a target: desktop web lib core-js')]
5154 build target="desktop":
5255 #!/usr/bin/env bash
5356 set -euo pipefail
5457 cd "{{root}}"
5558 case "{{target}}" in
5659 desktop) just _flutter desktop build ;;
60+ web) just _web-bundle ;;
5761 lib) just _nim-lib ;;
5862 core-js) just _nim-js ;;
59- *) echo "usage: just build [desktop|lib|core-js]" >&2; exit 1 ;;
63+ *) echo "usage: just build [desktop|web|lib|core-js]" >&2; exit 1 ;;
6064 esac
6165
6266 # Build a target and start it.
@@ -64,14 +68,17 @@ build target="desktop":
6468 # FRQ_TRACE=1 every line in and out, both languages in one log
6569 # FRQ_AUTOCONNECT=1 press Connect at startup, for a window a script cannot
6670 # click; FRQ_NICK overrides the nickname
67-[doc('build the app and start it')]
71+[doc('build the app and start it: desktop web')]
6872 run target="desktop":
6973 #!/usr/bin/env bash
7074 set -euo pipefail
7175 cd "{{root}}"
7276 case "{{target}}" in
7377 desktop) just _flutter desktop run ;;
74- *) echo "usage: just run [desktop]" >&2; exit 1 ;;
78+ web) just _web-bundle
79+ echo "serving build/web on http://localhost:8000"
80+ exec python3 -m http.server 8000 --directory build/web ;;
81+ *) echo "usage: just run [desktop|web]" >&2; exit 1 ;;
7582 esac
7683
7784 # Test a suite.
@@ -136,6 +143,27 @@ modal container="dev" *args:
136143 # module under `nim/web/frq` shadows the one beside it in `nim/src/frq`, so
137144 # `frq/conn` is a queue the host fills rather than two socket threads, and the
138145 # shared code above them never learns which host it is on.
146+# The web bundle: the core as JavaScript, and Flutter around it.
147+#
148+# The core goes into `flutter/web/` rather than being copied afterwards,
149+# because `flutter build web` copies that directory into the bundle — so the
150+# page's `<script src="frq_core.js">` resolves the same in a dev server as it
151+# does in the built output.
152+[private]
153+_web-bundle:
154+ #!/usr/bin/env bash
155+ set -euo pipefail
156+ cd "{{root}}"
157+ just _nim-js
158+ cp build/web/frq_core.js flutter/web/frq_core.js
159+ exec "{{tc}}" exec -- bash -euo pipefail -c '
160+ cd flutter
161+ flutter pub get
162+ flutter build web
163+ rm -rf ../build/web
164+ cp -r build/web ../build/web
165+ echo "built build/web"'
166+
139167 [private]
140168 _nim-js:
141169 #!/usr/bin/env bash
@@ -40,23 +40,27 @@ tools *args:
40 # Build a target.40 # Build a target.
41 #41 #
42 # desktop the app: Nim owns the state and the screens, Flutter paints42 # desktop the app: Nim owns the state and the screens, Flutter paints
43+# web the same, in a browser: the core compiled by `nim js`, the
44+# socket a WebSocket to freeq's own bridge, Flutter painting
43 # lib the Nim core alone, as build/nim/libfrqcore.so45 # lib the Nim core alone, as build/nim/libfrqcore.so
46+# core-js the core alone, as build/web/frq_core.js
44 #47 #
45 # There were four more. `apk` and `web` compiled ClojureDart and went with it:48 # There were four more. `apk` and `web` compiled ClojureDart and went with it:
46 # the web target cannot come back without a wasm build of the core, since a49 # the web target cannot come back without a wasm build of the core, since a
47 # browser has no dart:ffi, and the APK wants libfrqcore.so cross-compiled for50 # browser has no dart:ffi, and the APK wants libfrqcore.so cross-compiled for
48 # Android's ABIs. `ui` and `app` were the two halves of the migration, and51 # Android's ABIs. `ui` and `app` were the two halves of the migration, and
49 # there is one app now.52 # there is one app now.
50-[doc('build a target: desktop lib')]53+[doc('build a target: desktop web lib core-js')]
51 build target="desktop":54 build target="desktop":
52 #!/usr/bin/env bash55 #!/usr/bin/env bash
53 set -euo pipefail56 set -euo pipefail
54 cd "{{root}}"57 cd "{{root}}"
55 case "{{target}}" in58 case "{{target}}" in
56 desktop) just _flutter desktop build ;;59 desktop) just _flutter desktop build ;;
60+ web) just _web-bundle ;;
57 lib) just _nim-lib ;;61 lib) just _nim-lib ;;
58 core-js) just _nim-js ;;62 core-js) just _nim-js ;;
59- *) echo "usage: just build [desktop|lib|core-js]" >&2; exit 1 ;;63+ *) echo "usage: just build [desktop|web|lib|core-js]" >&2; exit 1 ;;
60 esac64 esac
61 65
62 # Build a target and start it.66 # Build a target and start it.
@@ -64,14 +68,17 @@ build target="desktop":
64 # FRQ_TRACE=1 every line in and out, both languages in one log68 # FRQ_TRACE=1 every line in and out, both languages in one log
65 # FRQ_AUTOCONNECT=1 press Connect at startup, for a window a script cannot69 # FRQ_AUTOCONNECT=1 press Connect at startup, for a window a script cannot
66 # click; FRQ_NICK overrides the nickname70 # click; FRQ_NICK overrides the nickname
67-[doc('build the app and start it')]71+[doc('build the app and start it: desktop web')]
68 run target="desktop":72 run target="desktop":
69 #!/usr/bin/env bash73 #!/usr/bin/env bash
70 set -euo pipefail74 set -euo pipefail
71 cd "{{root}}"75 cd "{{root}}"
72 case "{{target}}" in76 case "{{target}}" in
73 desktop) just _flutter desktop run ;;77 desktop) just _flutter desktop run ;;
74- *) echo "usage: just run [desktop]" >&2; exit 1 ;;78+ web) just _web-bundle
79+ echo "serving build/web on http://localhost:8000"
80+ exec python3 -m http.server 8000 --directory build/web ;;
81+ *) echo "usage: just run [desktop|web]" >&2; exit 1 ;;
75 esac82 esac
76 83
77 # Test a suite.84 # Test a suite.
@@ -136,6 +143,27 @@ modal container="dev" *args:
136 # module under `nim/web/frq` shadows the one beside it in `nim/src/frq`, so143 # 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 the144 # `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.145 # shared code above them never learns which host it is on.
146+# The web bundle: the core as JavaScript, and Flutter around it.
147+#
148+# The core goes into `flutter/web/` rather than being copied afterwards,
149+# because `flutter build web` copies that directory into the bundle — so the
150+# page's `<script src="frq_core.js">` resolves the same in a dev server as it
151+# does in the built output.
152+[private]
153+_web-bundle:
154+ #!/usr/bin/env bash
155+ set -euo pipefail
156+ cd "{{root}}"
157+ just _nim-js
158+ cp build/web/frq_core.js flutter/web/frq_core.js
159+ exec "{{tc}}" exec -- bash -euo pipefail -c '
160+ cd flutter
161+ flutter pub get
162+ flutter build web
163+ rm -rf ../build/web
164+ cp -r build/web ../build/web
165+ echo "built build/web"'
166+
139 [private]167 [private]
140 _nim-js:168 _nim-js:
141 #!/usr/bin/env bash169 #!/usr/bin/env bash
modified nim/web/frq/store.nim +14 -7
@@ -54,18 +54,25 @@ proc readJson(key: string): JsonNode =
5454 nil
5555
5656 proc loadSession*(): (SavedSession, bool) =
57+ ## No session is remembered here, and that is a decision rather than a gap.
58+ ##
59+ ## The desktop writes the broker token to a file only its owner can read.
60+ ## `localStorage` has no such thing: it is readable by every script this
61+ ## origin ever runs, and a durable credential sitting there is one
62+ ## cross-site script away from being somebody else's. A page can afford to
63+ ## ask the broker again — and usually the broker still knows the reader, so
64+ ## asking is a redirect and back rather than a login.
65+ ##
66+ ## The handle is kept, so the connect screen opens with the right name in
67+ ## it. That is not a credential.
5768 let j = readJson(sessionKey)
5869 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)
70+ (SavedSession(handle: j{"handle"}.getStr(), nick: j{"nick"}.getStr()), false)
6471
6572 proc saveSession*(s: SavedSession): bool =
73+ ## The handle and the nick, and deliberately not the token; see above.
6674 setItem(sessionKey.cstring,
67- ($(%*{"brokerToken": s.brokerToken, "handle": s.handle,
68- "did": s.did, "nick": s.nick})).cstring)
75+ ($(%*{"handle": s.handle, "nick": s.nick})).cstring)
6976
7077 proc clearSession*() = delItem(sessionKey.cstring)
7178
@@ -54,18 +54,25 @@ proc readJson(key: string): JsonNode =
54 nil54 nil
55 55
56 proc loadSession*(): (SavedSession, bool) =56 proc loadSession*(): (SavedSession, bool) =
57+ ## No session is remembered here, and that is a decision rather than a gap.
58+ ##
59+ ## The desktop writes the broker token to a file only its owner can read.
60+ ## `localStorage` has no such thing: it is readable by every script this
61+ ## origin ever runs, and a durable credential sitting there is one
62+ ## cross-site script away from being somebody else's. A page can afford to
63+ ## ask the broker again — and usually the broker still knows the reader, so
64+ ## asking is a redirect and back rather than a login.
65+ ##
66+ ## The handle is kept, so the connect screen opens with the right name in
67+ ## it. That is not a credential.
57 let j = readJson(sessionKey)68 let j = readJson(sessionKey)
58 if j.isNil: return (SavedSession(), false)69 if j.isNil: return (SavedSession(), false)
59- let s = SavedSession(brokerToken: j{"brokerToken"}.getStr(),70+ (SavedSession(handle: j{"handle"}.getStr(), nick: j{"nick"}.getStr()), false)
60- handle: j{"handle"}.getStr(),
61- did: j{"did"}.getStr(),
62- nick: j{"nick"}.getStr())
63- (s, s.brokerToken.len > 0)
64 71
65 proc saveSession*(s: SavedSession): bool =72 proc saveSession*(s: SavedSession): bool =
73+ ## The handle and the nick, and deliberately not the token; see above.
66 setItem(sessionKey.cstring,74 setItem(sessionKey.cstring,
67- ($(%*{"brokerToken": s.brokerToken, "handle": s.handle,75+ ($(%*{"handle": s.handle, "nick": s.nick})).cstring)
68- "did": s.did, "nick": s.nick})).cstring)
69 76
70 proc clearSession*() = delItem(sessionKey.cstring)77 proc clearSession*() = delItem(sessionKey.cstring)
71 78