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

frq_core.dart · 388 lines · 14.6 KBDart Blame HistoryRaw
The binding is Dart, and it works f7aea3b nandi 21h ago1/// The Nim core, as Dart functions.
2///
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/`.
5///
6/// **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 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.
11///
12/// 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.
26library;
27
28import 'dart:convert';
29import 'dart:ffi';
30import 'dart:io';
31
32// ---------------------------------------------------------------- the ABI
33
34typedef _InitNative = Void Function();
35typedef _InitDart = void Function();
36
37typedef _FreeNative = Void Function(Pointer<Uint8>);
38typedef _FreeDart = void Function(Pointer<Uint8>);
39
40typedef _VersionNative = Pointer<Uint8> Function();
41typedef _VersionDart = Pointer<Uint8> Function();
42
43typedef _Str1Native = Pointer<Uint8> Function(Pointer<Uint8>);
44typedef _Str1Dart = Pointer<Uint8> Function(Pointer<Uint8>);
45
46typedef _Str2Native = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
47typedef _Str2Dart = Pointer<Uint8> Function(Pointer<Uint8>, Pointer<Uint8>);
48
Nim owns the screen, Dart owns the pixels 43a02c2 nandi 20h ago49typedef _Str0Native = Pointer<Uint8> Function();
50typedef _Str0Dart = Pointer<Uint8> Function();
51
52typedef _VoidNative = Void Function();
53typedef _VoidDart = void Function();
54
The binding is Dart, and it works f7aea3b nandi 21h ago55/// 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.
64DynamicLibrary _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',
Nim owns the screen, Dart owns the pixels 43a02c2 nandi 20h ago74 '../build/nim/libfrqcore.so', // `flutter test`, from flutter/
75 '../../build/nim/libfrqcore.so', // `dart test`, from dart/frq_core/
The binding is Dart, and it works f7aea3b nandi 21h ago76 ]) {
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
88final DynamicLibrary _lib = () {
89 final lib = _open();
90 lib.lookupFunction<_InitNative, _InitDart>('frq_init')();
91 return lib;
92}();
93
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago94// 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.
The binding is Dart, and it works f7aea3b nandi 21h ago100final _free = _lib.lookupFunction<_FreeNative, _FreeDart>('frq_free');
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago101final _version = _lib.lookupFunction<_VersionNative, _VersionDart>('frq_version');
102final _tagValue = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_irc_tag_value');
103final _traceFn = _lib.lookupFunction<_Str2Native, _Str2Dart>('frq_trace');
104final _connOpen = _lib.lookupFunction<_ConnOpenNative, _ConnOpenDart>('frq_conn_open');
105final _connSend = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_conn_send');
106final _connCloseFn = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_conn_close');
107final _connRecvFn = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_recv');
108final _connEventFn = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_conn_event');
109final _uiRender = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_render');
110final _uiPoll = _lib.lookupFunction<_Str0Native, _Str0Dart>('frq_ui_poll');
111final _uiDispatch = _lib.lookupFunction<_Str1Native, _Str1Dart>('frq_ui_dispatch');
112final _uiDemo = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_demo');
113final _uiReset = _lib.lookupFunction<_VoidNative, _VoidDart>('frq_ui_reset');
114final _str1 = <String, _Str1Dart>{};
The binding is Dart, and it works f7aea3b nandi 21h ago115
116/// The bytes at [p] as a string, with [p] freed afterwards. Null in, null out.
117String? _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.
136Pointer<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.
149final DynamicLibrary _libc =
150 Platform.isWindows ? DynamicLibrary.open('msvcrt.dll') : DynamicLibrary.process();
151final _malloc = _libc
152 .lookupFunction<Pointer<Void> Function(IntPtr), Pointer<Void> Function(int)>('malloc');
153final _freeArg =
154 _libc.lookupFunction<Void Function(Pointer<Uint8>), void Function(Pointer<Uint8>)>('free');
155
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago156/// 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.
161String? _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
The binding is Dart, and it works f7aea3b nandi 21h ago172/// Call a one-string-in, one-string-out entry point.
173String? _call1(String symbol, String arg) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago174 final f = _str1.putIfAbsent(
175 symbol, () => _lib.lookupFunction<_Str1Native, _Str1Dart>(symbol));
The binding is Dart, and it works f7aea3b nandi 21h ago176 final a = _toC(arg);
177 try {
178 return _takeString(f(a));
179 } finally {
180 _freeArg(a);
181 }
182}
183
184// ------------------------------------------------------------------ public
185
186/// 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.
189String get version {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago190 final p = _version();
The binding is Dart, and it works f7aea3b nandi 21h ago191 var len = 0;
192 while (p[len] != 0) {
193 len++;
194 }
195 return utf8.decode(p.asTypedList(len));
196}
197
198/// An IRC line, taken apart: `{raw, tags, account, prefix, command, params}`.
199///
200/// `tags`, `account` and `prefix` are null where the line carried none, which
201/// is the distinction `frq.irc.parse` draws with nil and every caller depends
202/// on — a PRIVMSG from a server with no prefix is not the same line as one
203/// from a nick.
204Map<String, dynamic> parseLine(String line) {
205 final json = _call1('frq_irc_parse_line', line);
206 return jsonDecode(json ?? 'null') as Map<String, dynamic>;
207}
208
209/// One IRCv3 tag's value, unescaped — null where the tag is absent OR empty,
210/// which IRCv3 says are the same thing.
211String? tagValue(String tags, String key) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago212 return _call2(_tagValue, tags, key);
The binding is Dart, and it works f7aea3b nandi 21h ago213}
214
215String unescapeTag(String v) => _call1('frq_irc_unescape_tag', v) ?? '';
216
217String escapeTagValue(String v) => _call1('frq_irc_escape_tag_value', v) ?? '';
218
219/// The nick half of a `nick!user@host` prefix.
220String nickOf(String prefix) => _call1('frq_irc_nick_of', prefix) ?? '';
Nim owns the screen, Dart owns the pixels 43a02c2 nandi 20h ago221
222
Nim under the existing UI, not instead of it 56551a8 nandi 20h ago223/// 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.
225void trace(String topic, String msg) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago226 _call2(_traceFn, topic, msg);
Nim under the existing UI, not instead of it 56551a8 nandi 20h ago227}
228
229// --------------------------------------------------------------- transport
230//
231// `frq.net`'s three operations, with a Nim socket behind them. This is the
232// wiring that leaves the existing ClojureDart screens, cells and actions
233// alone: only the transport underneath them is Nim.
234
235typedef _ConnOpenNative = Void Function(Pointer<Uint8>, Int32, Int32);
236typedef _ConnOpenDart = void Function(Pointer<Uint8>, int, int);
237
238/// Dial. Non-blocking: the socket runs on a Nim thread and progress arrives
239/// through [connEvent].
240void connOpen(String host, int port, {bool tls = true}) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago241 final f = _connOpen;
Nim under the existing UI, not instead of it 56551a8 nandi 20h ago242 final a = _toC(host);
243 try {
244 f(a, port, tls ? 1 : 0);
245 } finally {
246 _freeArg(a);
247 }
248}
249
250/// Queue a line. The transport adds the CRLF.
251void connSend(String line) {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago252 final f = _connSend;
Nim under the existing UI, not instead of it 56551a8 nandi 20h ago253 final a = _toC(line);
254 try {
255 f(a);
256 } finally {
257 _freeArg(a);
258 }
259}
260
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago261void connClose() => _connCloseFn();
Nim under the existing UI, not instead of it 56551a8 nandi 20h ago262
263/// The next line, or null when none is waiting. Never blocks.
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago264String? connRecv() => _takeString(_connRecvFn());
Nim under the existing UI, not instead of it 56551a8 nandi 20h ago265
266/// The next transport event — `open`, `close: …`, `error: …` — or null.
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago267String? connEvent() => _takeString(_connEventFn());
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago268
269
270// ---------------------------------------------------------------- the UI
271//
272// Nim owns the state and the screens; Dart owns the pixels. A tree goes out,
273// an event id comes back, and nothing else crosses.
274//
275// `UiNode` is deliberately a dumb bag — a tag, a props map, children. A class
276// per widget would put the tag vocabulary in two places and make every new tag
277// a change on both sides; the point is that Nim can grow a screen without this
278// file being touched.
279
280/// One node of the widget tree Nim emitted.
281class UiNode {
282 final String tag;
283 final Map<String, dynamic> props;
284 final List<UiNode> children;
285
286 const UiNode(this.tag, this.props, this.children);
287
288 factory UiNode.fromJson(Map<String, dynamic> j) => UiNode(
289 j['tag'] as String,
290 (j['props'] as Map?)?.cast<String, dynamic>() ?? const {},
291 ((j['children'] as List?) ?? const [])
292 .map((c) => UiNode.fromJson((c as Map).cast<String, dynamic>()))
293 .toList(growable: false),
294 );
295
296 /// A prop, or [fallback] when it is absent or the wrong shape. Tolerant on
297 /// purpose: a renderer should skip a prop it does not understand rather than
298 /// fail a whole screen over one.
299 T prop<T>(String name, T fallback) {
300 final v = props[name];
301 return v is T ? v : fallback;
302 }
303
304 /// Structural, and that matters: the poll loop compares two trees by this
305 /// string to decide whether to rebuild. A summary showing only tags and prop
306 /// NAMES would call two screens equal when a message had arrived, and the
307 /// room would never appear to fill.
308 @override
309 String toString() =>
310 '<$tag $props ${children.map((c) => c.toString()).join()}>';
311}
312
313UiNode _treeFrom(String? json) =>
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago314 UiNode.fromJson(jsonDecode(json ?? _emptyTree) as Map<String, dynamic>);
315
316/// A tree and the JSON it came from.
317///
318/// The raw string is kept because it is the cheapest possible change
319/// detector: Nim already produced it, and comparing two strings is free
320/// beside decoding one. The renderer polls ten times a second and the answer
321/// is almost always "nothing changed" — doing a `jsonDecode` and two
322/// recursive `toString()`s to discover that was most of the idle cost of the
323/// app in a busy room.
324const _emptyTree = '{"tag":"vbox"}';
325
326class UiFrame {
327 final String json;
328 final UiNode tree;
329 const UiFrame(this.json, this.tree);
330}
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago331
332/// The current screen.
333///
334/// Not pure: the Nim side drains the socket's queue first, so two calls with
335/// no [dispatch] between can differ when a line arrived in the gap. That is how
336/// the room fills, and why the renderer polls.
337UiNode render() => _treeFrom(
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago338 _takeString(_uiRender()));
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago339
340/// The tree, asked for because time passed rather than because anything
341/// happened. Same work as [render]; named for what the caller means.
342UiNode poll() => _treeFrom(
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago343 _takeString(_uiPoll()));
344
345/// The current screen, with the JSON it came from. The starting point for
346/// [pollIfChanged].
347UiFrame renderFrame() {
348 final json = _takeString(_uiRender()) ?? _emptyTree;
349 return UiFrame(json, _treeFrom(json));
350}
351
352/// The tree, decoded only when it differs from [since] — otherwise null,
353/// meaning "the screen you already have is current".
354///
355/// This is what the renderer polls with. The comparison is the JSON Nim
356/// already produced, so an unchanged frame costs one string compare rather
357/// than a decode and two recursive `toString()`s.
358UiFrame? pollIfChanged(String since) {
359 final json = _takeString(_uiPoll()) ?? _emptyTree;
360 if (json == since) return null;
361 return UiFrame(json, _treeFrom(json));
362}
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago363
364/// Apply an event and get the tree it produced.
365///
366/// One call rather than dispatch-then-render, and not to save a crossing: it
367/// makes the pair atomic, so there is no window in which Dart could render a
368/// state nothing asked for.
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago369UiNode dispatch(String id, [String value = '']) => dispatchFrame(id, value).tree;
370
371/// As [dispatch], but keeping the JSON so the poll loop can compare against
372/// it without re-stringifying the tree it just built.
373UiFrame dispatchFrame(String id, [String value = '']) {
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago374 final a = _toC(jsonEncode({'id': id, 'value': value}));
375 try {
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago376 final json = _takeString(_uiDispatch(a)) ?? '{"tag":"vbox"}';
377 return UiFrame(json, _treeFrom(json));
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago378 } finally {
379 _freeArg(a);
380 }
381}
382
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 16h ago383/// 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.
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago385void demoUi() => _uiDemo();
Lay every screen out in a test, and fix what that found 36bdfc5 nandi 16h ago386
The renderer, and a transport bug that took four tries 58e6c97 nandi 18h ago387/// Back to a fresh state, for a caller that wants a known starting point.
Quality pass: reuse, dead weight, and two real costs 4dfc719 nandi 16h ago388void resetUi() => _uiReset();