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>
23846db parent: 5ac0521 modified
.gitignore +1 -0 | @@ -34,3 +34,4 @@ __pycache__/ | ||
| 34 | 34 | |
| 35 | 35 | # The mirror `just serve` keeps of the Modal-built web bundle. |
| 36 | 36 | .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. | ||
| 84 | 84 | |
| 85 | 85 | There is no `common/` any more, and that rule went with it. It said a module |
| 86 | 86 | 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`. | |
| 90 | 98 | |
| 91 | 99 | ## The source trees |
| 92 | 100 | |
| 93 | 101 | ``` |
| 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 | |
| 96 | 105 | 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 | 109 | `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 | 115 | There used to be two more trees. `src/` was jolt and libcosmic; `common/` and |
| 106 | 116 | `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. | |
| 110 | 128 | |
| 111 | 129 | Two modules were never ported and are gone rather than moved: `frq.profile` |
| 112 | 130 | (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 module | 85 | 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 no | 86 | 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 | 87 | +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 restoring | 88 | +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 trees | 99 | ## The source trees |
| 92 | 100 | ||
| 93 | ``` | 101 | ``` |
| 94 | -nim/ the program: state, screens, IRC, signing | 102 | +nim/src the program: state, screens, IRC, signing |
| 95 | -dart/frq_core the FFI binding — plain Dart, not a Flutter package | 103 | +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 point | 105 | 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 the | 109 | `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/` and | 115 | 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. Both | 116 | `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 | 117 | +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's | 118 | +`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 a | 130 | (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 @@ | ||
| 1 | 1 | /// The Nim core, as Dart functions. |
| 2 | 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/`. | |
| 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. | |
| 5 | 10 | /// |
| 6 | 11 | /// **Dart and not ClojureDart, on purpose.** The point of the Nim core is to |
| 7 | 12 | /// 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. | |
| 11 | 14 | /// |
| 12 | 15 | /// 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. | |
| 26 | 18 | library; |
| 27 | 19 | |
| 28 | 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. | |
| 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; | |
| 183 | 23 | |
| 184 | 24 | // ------------------------------------------------------------------ public |
| 185 | 25 | |
| 186 | 26 | /// 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(); | |
| 197 | 29 | |
| 198 | 30 | /// An IRC line, taken apart: `{raw, tags, account, prefix, command, params}`. |
| 199 | 31 | /// |
| @@ -202,28 +34,28 @@ String get version { | ||
| 202 | 34 | /// on — a PRIVMSG from a server with no prefix is not the same line as one |
| 203 | 35 | /// from a nick. |
| 204 | 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 | 38 | return jsonDecode(json ?? 'null') as Map<String, dynamic>; |
| 207 | 39 | } |
| 208 | 40 | |
| 209 | 41 | /// One IRCv3 tag's value, unescaped — null where the tag is absent OR empty, |
| 210 | 42 | /// which IRCv3 says are the same thing. |
| 211 | 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 | 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 | 55 | /// Log through the Nim core's trace facility, so `FRQ_TRACE=1` gives one |
| 224 | 56 | /// interleaved story rather than two half-ones in different places. |
| 225 | 57 | void trace(String topic, String msg) { |
| 226 | - _call2(_traceFn, topic, msg); | |
| 58 | + host.traceTo(topic, msg); | |
| 227 | 59 | } |
| 228 | 60 | |
| 229 | 61 | // --------------------------------------------------------------- transport |
| @@ -232,39 +64,24 @@ void trace(String topic, String msg) { | ||
| 232 | 64 | // wiring that leaves the existing ClojureDart screens, cells and actions |
| 233 | 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 | 68 | /// Dial. Non-blocking: the socket runs on a Nim thread and progress arrives |
| 239 | 69 | /// 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 | + | |
| 249 | 73 | |
| 250 | 74 | /// 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 | + | |
| 260 | 77 | |
| 261 | -void connClose() => _connCloseFn(); | |
| 78 | +void connClose() => host.connCloseNow(); | |
| 262 | 79 | |
| 263 | 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 | 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 | 87 | // ---------------------------------------------------------------- the UI |
| @@ -335,17 +152,17 @@ class UiFrame { | ||
| 335 | 152 | /// no [dispatch] between can differ when a line arrived in the gap. That is how |
| 336 | 153 | /// the room fills, and why the renderer polls. |
| 337 | 154 | UiNode render() => _treeFrom( |
| 338 | - _takeString(_uiRender())); | |
| 155 | + host.uiRender()); | |
| 339 | 156 | |
| 340 | 157 | /// The tree, asked for because time passed rather than because anything |
| 341 | 158 | /// happened. Same work as [render]; named for what the caller means. |
| 342 | 159 | UiNode poll() => _treeFrom( |
| 343 | - _takeString(_uiPoll())); | |
| 160 | + host.uiPoll()); | |
| 344 | 161 | |
| 345 | 162 | /// The current screen, with the JSON it came from. The starting point for |
| 346 | 163 | /// [pollIfChanged]. |
| 347 | 164 | UiFrame renderFrame() { |
| 348 | - final json = _takeString(_uiRender()) ?? _emptyTree; | |
| 165 | + final json = host.uiRender() ?? _emptyTree; | |
| 349 | 166 | return UiFrame(json, _treeFrom(json)); |
| 350 | 167 | } |
| 351 | 168 | |
| @@ -356,7 +173,7 @@ UiFrame renderFrame() { | ||
| 356 | 173 | /// already produced, so an unchanged frame costs one string compare rather |
| 357 | 174 | /// than a decode and two recursive `toString()`s. |
| 358 | 175 | UiFrame? pollIfChanged(String since) { |
| 359 | - final json = _takeString(_uiPoll()) ?? _emptyTree; | |
| 176 | + final json = host.uiPoll() ?? _emptyTree; | |
| 360 | 177 | if (json == since) return null; |
| 361 | 178 | return UiFrame(json, _treeFrom(json)); |
| 362 | 179 | } |
| @@ -371,18 +188,15 @@ UiNode dispatch(String id, [String value = '']) => dispatchFrame(id, value).tree | ||
| 371 | 188 | /// As [dispatch], but keeping the JSON so the poll loop can compare against |
| 372 | 189 | /// it without re-stringifying the tree it just built. |
| 373 | 190 | 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)); | |
| 381 | 194 | } |
| 382 | 195 | |
| 196 | + | |
| 383 | 197 | /// Fill a room with a representative conversation, so a test can lay the chat |
| 384 | 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 | 201 | /// 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; see | 3 | +/// 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 to | 11 | /// **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 | 12 | /// 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 ten | 16 | +/// conversions, and doing them against `dart:convert` costs about ten lines |
| 14 | -/// lines and keeps `pubspec.yaml` unchanged — which matters because every | 17 | +/// 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 | // ------------------------------------------------------------------ public | 24 | // ------------------------------------------------------------------ public |
| 185 | 25 | ||
| 186 | /// The core's version, for a caller that wants to check the library it found | 26 | /// 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 | 27 | +/// 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 one | 34 | /// 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 one | 55 | /// 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 | // --------------------------------------------------------------- transport | 61 | // --------------------------------------------------------------- transport |
| @@ -232,39 +64,24 @@ void trace(String topic, String msg) { | |||
| 232 | // wiring that leaves the existing ClojureDart screens, cells and actions | 64 | // 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 arrives | 68 | /// 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 UI | 87 | // ---------------------------------------------------------------- the UI |
| @@ -335,17 +152,17 @@ class UiFrame { | |||
| 335 | /// no [dispatch] between can differ when a line arrived in the gap. That is how | 152 | /// 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 anything | 157 | /// 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 for | 162 | /// 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 rather | 173 | /// 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 against | 188 | /// 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 chat | 197 | /// 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 @@ | ||
| 12 | 12 | /// `nim/README.md` for what is done and what is not. |
| 13 | 13 | library; |
| 14 | 14 | |
| 15 | -import 'dart:io'; | |
| 16 | 15 | |
| 17 | 16 | import 'package:flutter/material.dart'; |
| 18 | 17 | import 'package:frq_core/frq_core.dart' as core; |
| 19 | 18 | |
| 20 | 19 | import 'nim_renderer.dart'; |
| 20 | +import 'src/host.dart' as host; | |
| 21 | 21 | |
| 22 | 22 | void main() { |
| 23 | 23 | // FRQ_AUTOCONNECT presses Connect at startup, and FRQ_NICK overrides the |
| @@ -29,10 +29,10 @@ void main() { | ||
| 29 | 29 | // button uses. It lived in `currentTree()` before, which meant the function |
| 30 | 30 | // whose job is "serialise the current screen" opened a socket on its first |
| 31 | 31 | // 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', ''); | |
| 34 | 34 | if (want.isNotEmpty && want != '0') { |
| 35 | - final nick = env['FRQ_NICK'] ?? ''; | |
| 35 | + final nick = host.envOr('FRQ_NICK', ''); | |
| 36 | 36 | if (nick.isNotEmpty) core.dispatch('nick.change', nick); |
| 37 | 37 | core.dispatch('connect'); |
| 38 | 38 | } |
| @@ -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 the | 23 | // 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 function | 29 | // button uses. It lived in `currentTree()` before, which meant the function |
| 30 | // whose job is "serialise the current screen" opened a socket on its first | 30 | // 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 @@ | ||
| 11 | 11 | library; |
| 12 | 12 | |
| 13 | 13 | import 'dart:async'; |
| 14 | -import 'dart:io'; | |
| 15 | 14 | |
| 16 | 15 | import 'package:flutter/gestures.dart'; |
| 17 | 16 | |
| 18 | 17 | import 'package:flutter/material.dart'; |
| 19 | 18 | import 'package:frq_core/frq_core.dart' as core; |
| 19 | +import 'src/host.dart' as host; | |
| 20 | 20 | |
| 21 | 21 | import 'nim_theme.dart' as t; |
| 22 | 22 | |
| @@ -165,16 +165,10 @@ class _NimAppState extends State<NimApp> { | ||
| 165 | 165 | /// |
| 166 | 166 | /// A family list rather than one name, because the font that has them |
| 167 | 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 | 168 | TextStyle _emojiStyle(double size) => TextStyle( |
| 175 | 169 | 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, | |
| 178 | 172 | ); |
| 179 | 173 | |
| 180 | 174 | double _d(dynamic v, double fallback) => |
| @@ -203,7 +197,7 @@ class _NimAppState extends State<NimApp> { | ||
| 203 | 197 | if (src.startsWith('http://') || src.startsWith('https://')) { |
| 204 | 198 | return NetworkImage(src); |
| 205 | 199 | } |
| 206 | - return FileImage(File(src)); | |
| 200 | + return host.localImage(src); | |
| 207 | 201 | } |
| 208 | 202 | |
| 209 | 203 | Widget _wrapTap(String onClick, Widget child, {BorderRadius? radius}) { |
| @@ -610,20 +604,27 @@ class _NimAppState extends State<NimApp> { | ||
| 610 | 604 | case 'avatar': |
| 611 | 605 | { |
| 612 | 606 | final size = _d(n.props['size'], 32); |
| 613 | - final provider = _imageProvider(n.prop('url', '')); | |
| 607 | + final url = n.prop('url', ''); | |
| 614 | 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 | 615 | final face = CircleAvatar( |
| 616 | 616 | radius: size / 2, |
| 617 | 617 | 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 | + ), | |
| 627 | 628 | ); |
| 628 | 629 | final onClick = n.prop('onClick', ''); |
| 629 | 630 | if (onClick.isEmpty) return face; |
| @@ -636,19 +637,26 @@ class _NimAppState extends State<NimApp> { | ||
| 636 | 637 | |
| 637 | 638 | case 'image': |
| 638 | 639 | { |
| 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(); | |
| 641 | 642 | final maxW = _d(n.props['maxWidth'], 0); |
| 642 | 643 | 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 | + } | |
| 652 | 660 | if (maxW > 0 || maxH > 0) { |
| 653 | 661 | img = ConstrainedBox( |
| 654 | 662 | 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 them | 166 | /// 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 == null | 620 | + : ClipOval( |
| 621 | - ? Text( | 621 | + child: SizedBox( |
| 622 | - fallback.isNotEmpty | 622 | + 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 decoder | 647 | + // picture was. |
| 647 | - // throws during the build, and an exception in a build is a red | 648 | + Widget img; |
| 648 | - // screen for the whole conversation rather than a gap where one | 649 | + 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 @@ | ||
| 27 | 27 | <link rel="manifest" href="manifest.json"> |
| 28 | 28 | |
| 29 | 29 | <!-- |
| 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. | |
| 34 | 38 | --> |
| 35 | - <script src="frq_dpop.js"></script> | |
| 39 | + <script src="frq_core.js"></script> | |
| 40 | + <script src="frq_host.js"></script> | |
| 36 | 41 | </head> |
| 37 | 42 | <body> |
| 38 | 43 | <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 there | 30 | + 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 it | 31 | + 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 race | 32 | + 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: | ||
| 40 | 40 | # Build a target. |
| 41 | 41 | # |
| 42 | 42 | # 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 | 45 | # 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 | 48 | # There were four more. `apk` and `web` compiled ClojureDart and went with it: |
| 46 | 49 | # the web target cannot come back without a wasm build of the core, since a |
| 47 | 50 | # browser has no dart:ffi, and the APK wants libfrqcore.so cross-compiled for |
| 48 | 51 | # Android's ABIs. `ui` and `app` were the two halves of the migration, and |
| 49 | 52 | # there is one app now. |
| 50 | -[doc('build a target: desktop lib')] | |
| 53 | +[doc('build a target: desktop web lib core-js')] | |
| 51 | 54 | build target="desktop": |
| 52 | 55 | #!/usr/bin/env bash |
| 53 | 56 | set -euo pipefail |
| 54 | 57 | cd "{{root}}" |
| 55 | 58 | case "{{target}}" in |
| 56 | 59 | desktop) just _flutter desktop build ;; |
| 60 | + web) just _web-bundle ;; | |
| 57 | 61 | lib) just _nim-lib ;; |
| 58 | 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 | 64 | esac |
| 61 | 65 | |
| 62 | 66 | # Build a target and start it. |
| @@ -64,14 +68,17 @@ build target="desktop": | ||
| 64 | 68 | # FRQ_TRACE=1 every line in and out, both languages in one log |
| 65 | 69 | # FRQ_AUTOCONNECT=1 press Connect at startup, for a window a script cannot |
| 66 | 70 | # 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 | 72 | run target="desktop": |
| 69 | 73 | #!/usr/bin/env bash |
| 70 | 74 | set -euo pipefail |
| 71 | 75 | cd "{{root}}" |
| 72 | 76 | case "{{target}}" in |
| 73 | 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 | 82 | esac |
| 76 | 83 | |
| 77 | 84 | # Test a suite. |
| @@ -136,6 +143,27 @@ modal container="dev" *args: | ||
| 136 | 143 | # module under `nim/web/frq` shadows the one beside it in `nim/src/frq`, so |
| 137 | 144 | # `frq/conn` is a queue the host fills rather than two socket threads, and the |
| 138 | 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 | 167 | [private] |
| 140 | 168 | _nim-js: |
| 141 | 169 | #!/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 paints | 42 | # 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.so | 45 | # 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 a | 49 | # 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 for | 50 | # 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, and | 51 | # 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 bash | 55 | #!/usr/bin/env bash |
| 53 | set -euo pipefail | 56 | set -euo pipefail |
| 54 | cd "{{root}}" | 57 | cd "{{root}}" |
| 55 | case "{{target}}" in | 58 | 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 | esac | 64 | 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 log | 68 | # 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 cannot | 69 | # FRQ_AUTOCONNECT=1 press Connect at startup, for a window a script cannot |
| 66 | # click; FRQ_NICK overrides the nickname | 70 | # 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 bash | 73 | #!/usr/bin/env bash |
| 70 | set -euo pipefail | 74 | set -euo pipefail |
| 71 | cd "{{root}}" | 75 | cd "{{root}}" |
| 72 | case "{{target}}" in | 76 | 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 | esac | 82 | 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`, so | 143 | # module under `nim/web/frq` shadows the one beside it in `nim/src/frq`, so |
| 137 | # `frq/conn` is a queue the host fills rather than two socket threads, and the | 144 | # `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 bash | 169 | #!/usr/bin/env bash |
modified
nim/web/frq/store.nim +14 -7 | @@ -54,18 +54,25 @@ proc readJson(key: string): JsonNode = | ||
| 54 | 54 | nil |
| 55 | 55 | |
| 56 | 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 | 68 | let j = readJson(sessionKey) |
| 58 | 69 | 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) | |
| 64 | 71 | |
| 65 | 72 | proc saveSession*(s: SavedSession): bool = |
| 73 | + ## The handle and the nick, and deliberately not the token; see above. | |
| 66 | 74 | 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) | |
| 69 | 76 | |
| 70 | 77 | proc clearSession*() = delItem(sessionKey.cstring) |
| 71 | 78 | |
| @@ -54,18 +54,25 @@ proc readJson(key: string): JsonNode = | |||
| 54 | nil | 54 | 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 | ||