morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
service_client.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <cstddef>
6#include <exception>
7#include <expected>
8#include <functional>
9#include <map>
10#include <memory>
11#include <mutex>
12#include <optional>
13#include <stdexcept>
14#include <string>
15#include <string_view>
16#include <utility>
17#include <vector>
18
19#include "../attributes.hpp"
20#include "../core/completion.hpp"
22#include "../core/executor.hpp"
23#include "../core/logger.hpp"
24#include "../core/wire.hpp"
25#include "../session/session.hpp"
26#include "../ui/frontend.hpp"
27#include "bundle.hpp"
28#include "file_store.hpp"
29#include "theme.hpp"
30
31/// @file
32/// @brief The client side of the UI service (spec 5 §12, "Content addressing"): `UiClient` runs `ui-hello`,
33/// `ui-catalog` and `ui-fetch` over a transport the caller supplies, verifies every file against the manifest
34/// and the manifest against the `ui-hello` digest, and keeps what it verified in a `VerifiedCache`.
35///
36/// Specified in `docs/spec/ui/service.md`.
37
38namespace morph::ui {
39
40/// @brief Where a client keeps verified files between runs, by content hash, and the digest of each application's
41/// last verified bundle.
42///
43/// A store is not trusted: `VerifiedCache` hashes what it reads back. A client calls it from whichever thread its
44/// exchange calls back on, and runs of several `open`s may overlap, so an implementation is safe to call from any
45/// thread.
46class IBundleStore {
47public:
48 IBundleStore() = default;
49 virtual ~IBundleStore() = default;
50 IBundleStore(IBundleStore const&) = delete;
51 IBundleStore& operator=(IBundleStore const&) = delete;
52 IBundleStore(IBundleStore&&) = delete;
53 IBundleStore& operator=(IBundleStore&&) = delete;
54
55 /// @brief Reads a file.
56 /// @param hash The file's content hash.
57 /// @return Its bytes, or nothing when the store has none.
58 [[nodiscard]] virtual std::optional<std::string> get(std::string_view hash) const = 0;
59
60 /// @brief Keeps a file.
61 /// @param hash The file's content hash.
62 /// @param bytes The file.
63 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): the key, then the value.
64 virtual void put(std::string const& hash, std::string bytes) = 0;
65
66 /// @brief Drops a file.
67 /// @param hash The file's content hash.
68 virtual void remove(std::string_view hash) = 0;
69
70 /// @brief The digest of an application's last verified bundle.
71 /// @param application The application's id.
72 /// @return The digest, or nothing.
73 [[nodiscard]] virtual std::optional<std::string> lastDigest(std::string_view application) const = 0;
74
75 /// @brief Records an application's last verified bundle.
76 /// @param application The application's id.
77 /// @param digest The manifest's content hash.
78 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): the key, then the value.
79 virtual void setLastDigest(std::string const& application, std::string digest) = 0;
80};
81
82/// @brief An `IBundleStore` in memory: it lasts as long as the process. Safe to call from any thread.
83class MemoryBundleStore final : public IBundleStore {
84public:
85 /// @brief Reads a file.
86 /// @param hash The file's content hash.
87 /// @return Its bytes, or nothing.
88 [[nodiscard]] std::optional<std::string> get(std::string_view hash) const override {
89 std::scoped_lock const lock{_mutex};
90 auto const found = _files.find(hash);
91 if (found == _files.end()) {
92 return std::nullopt;
93 }
94 return found->second;
95 }
96
97 /// @brief Keeps a file.
98 /// @param hash The file's content hash.
99 /// @param bytes The file.
100 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): the key, then the value.
101 void put(std::string const& hash, std::string bytes) override {
102 std::scoped_lock const lock{_mutex};
103 _files.insert_or_assign(hash, std::move(bytes));
104 }
105
106 /// @brief Drops a file.
107 /// @param hash The file's content hash.
108 void remove(std::string_view hash) override {
109 std::scoped_lock const lock{_mutex};
110 if (auto const found = _files.find(hash); found != _files.end()) {
111 _files.erase(found);
112 }
113 }
114
115 /// @brief The digest of an application's last verified bundle.
116 /// @param application The application's id.
117 /// @return The digest, or nothing.
118 [[nodiscard]] std::optional<std::string> lastDigest(std::string_view application) const override {
119 std::scoped_lock const lock{_mutex};
120 auto const found = _digests.find(application);
121 if (found == _digests.end()) {
122 return std::nullopt;
123 }
124 return found->second;
125 }
126
127 /// @brief Records an application's last verified bundle.
128 /// @param application The application's id.
129 /// @param digest The manifest's content hash.
130 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): the key, then the value.
131 void setLastDigest(std::string const& application, std::string digest) override {
132 std::scoped_lock const lock{_mutex};
133 _digests.insert_or_assign(application, std::move(digest));
134 }
135
136 /// @brief How many files the store holds.
137 /// @return The count.
138 [[nodiscard]] std::size_t size() const {
139 std::scoped_lock const lock{_mutex};
140 return _files.size();
141 }
142
143private:
144 mutable std::mutex _mutex;
145 std::map<std::string, std::string, std::less<>> _files;
146 std::map<std::string, std::string, std::less<>> _digests;
147};
148
149/// @brief A content-addressed cache that hands out only bytes that hash to the hash they are asked for.
151public:
152 /// @brief A cache over @p store.
153 /// @param store The store; shared with the caller.
154 /// @throws std::invalid_argument if @p store is null.
155 explicit VerifiedCache(std::shared_ptr<IBundleStore> store) : _store{std::move(store)} {
156 if (!_store) {
157 throw std::invalid_argument("VerifiedCache: the store is null");
158 }
159 }
160
161 /// @brief Reads a file, verified. A stored file that does not hash to @p hash is dropped and reads as a miss.
162 /// @param hash The content hash.
163 /// @return The bytes, or nothing.
164 [[nodiscard]] std::optional<std::string> get(std::string const& hash) {
165 auto bytes = _store->get(hash);
166 if (bytes && contentHash(*bytes) != hash) {
167 ::morph::log::logWarn("[ui-cache] dropped {}: the stored bytes do not hash to it", hash);
168 _store->remove(hash);
169 return std::nullopt;
170 }
171 return bytes;
172 }
173
174 /// @brief Keeps a file under its content hash.
175 /// @param bytes The bytes.
176 /// @return The hash they are kept under.
177 std::string put(std::string bytes) {
178 std::string hash = contentHash(bytes);
179 _store->put(hash, std::move(bytes));
180 return hash;
181 }
182
183 /// @brief The store.
184 /// @return It.
185 [[nodiscard]] IBundleStore& store() noexcept { return *_store; }
186
187private:
188 std::shared_ptr<IBundleStore> _store;
189};
190
191/// @brief A bundle the client verified: what the shell mounts, and what it was verified against.
193 /// @brief The application's id, the manifest digest, and the documents (the screens it may open and the app
194 /// document) by id.
196 /// @brief The `ui-hello` reply; empty when the bundle was opened offline.
198 /// @brief The manifest.
200 /// @brief The catalog for the principal; empty when the bundle was opened offline.
202 /// @brief Every verified file the bundle holds, by content hash: documents, themes, message catalogues and,
203 /// for a client that loads them, components.
204 std::map<std::string, std::string, std::less<>> files;
205};
206
207/// @brief One reply, or why the transport could not deliver it.
208using ExchangeReply = std::expected<::morph::wire::Envelope, std::string>;
209
210/// @brief Sends one request and calls its callback once with the reply. The transport assigns the request's `callId`
211/// and matches the reply to it; a failure to deliver either is the error.
212using Exchange = std::function<void(::morph::wire::Envelope request, std::function<void(ExchangeReply reply)> done)>;
213
214/// @brief An `Exchange` over a transport: each request is one `IEnvelopeTransport::request`, and its reply, `ok` or
215/// `err`, is the exchange's reply; a failure to deliver either is the error, as its message.
216///
217/// Call it on @p cbExec's thread, where its replies arrive: `UiClient` sends each request from the reply before it,
218/// so a client opened there stays there.
219/// @param backend The transport: `SocketBackend` or `QtWebSocketBackend`. Borrowed: it must outlive every request
220/// sent through the exchange, so a caller that may destroy it first guards the exchange itself.
221/// @param cbExec The executor replies are delivered on: the interpreter runtime's owner. Borrowed.
222/// @return The exchange.
223[[nodiscard]] inline Exchange exchangeOver(::morph::backend::IEnvelopeTransport& backend MORPH_LIFETIMEBOUND,
224 ::morph::exec::IExecutor& cbExec MORPH_LIFETIMEBOUND) {
225 return [transport = &backend, owner = &cbExec](::morph::wire::Envelope request,
226 std::function<void(ExchangeReply reply)> done) {
227 auto const answer = std::make_shared<std::function<void(ExchangeReply reply)>>(std::move(done));
228 transport->request(std::move(request), *owner)
229 .thenDetached([answer](::morph::wire::Envelope const& reply) { (*answer)(reply); })
230 .onErrorDetached([answer](std::exception_ptr const& failure) {
231 std::string message = "the request was not delivered";
232 try {
233 std::rethrow_exception(failure);
234 } catch (std::exception const& error) {
235 message = error.what();
236 } catch (...) { // NOLINT(bugprone-empty-catch): the default message stands for a non-std failure
237 }
238 (*answer)(std::unexpected(std::move(message)));
239 });
240 };
241}
242
243/// @brief Opens an application's bundle through the UI service, verifying everything it fetches.
244///
245/// `open` runs `ui-hello`, refuses a bundle that needs a vocabulary the client does not speak, takes the manifest from
246/// the cache or fetches it by the digest and verifies it, runs `ui-catalog`, and fetches the files the principal may
247/// use that the cache does not hold, all at once, verifying each. A file that does not hash to its manifest entry,
248/// or a manifest that does not hash to the digest, refuses the whole bundle (`Unverified`). An unchanged manifest
249/// fetches nothing.
250class UiClient {
251public:
252 /// @brief A client.
253 /// @param exchange The transport.
254 /// @param client What the client speaks and carries, sent with `ui-hello`.
255 /// @param store Where verified files are kept.
256 /// @throws std::invalid_argument if @p exchange is empty or @p store is null.
257 UiClient(Exchange exchange, HelloRequest client, std::shared_ptr<IBundleStore> store)
258 : _shared{std::make_shared<Shared>(std::move(exchange), std::move(client), VerifiedCache{std::move(store)})} {
259 if (!_shared->exchange) {
260 throw std::invalid_argument("UiClient: the exchange is empty");
261 }
262 }
263
264 /// @brief What `open` delivers.
265 using Done = std::function<void(std::expected<LoadedBundle, ConnectError>)>;
266
267 /// @brief Opens the bundle. @p done is called once, from whichever thread the exchange calls back on; a
268 /// synchronous exchange calls it before `open` returns.
269 /// @param session The session every request carries: the principal's token, or none before sign-in.
270 /// @param done Called with the bundle, or why there is none.
271 void open(::morph::session::Context session, Done done) const {
272 auto const run = std::make_shared<Run>(_shared, std::move(session), std::move(done));
273 sendHello(run);
274 }
275
276 /// @brief What `poll` delivers: the changed bundle, nothing when the digest is the one already held, or why
277 /// there is none.
278 using PollDone = std::function<void(std::expected<std::optional<LoadedBundle>, ConnectError>)>;
279
280 /// @brief One step of a development client's reload (spec 5 §12): sends `ui-hello`, and when the manifest digest
281 /// differs from @p knownDigest, opens the changed bundle as `open` does, fetching only what the cache
282 /// does not hold. @p done is called once, as `open`'s is.
283 /// @param session The session every request carries.
284 /// @param knownDigest The digest of the bundle the client has, as `HelloReply::digest` spells it.
285 /// @param done Called with the changed bundle, with nothing when the digest is unchanged, or with why the
286 /// poll failed.
287 void poll(::morph::session::Context session, std::string knownDigest, PollDone done) const {
288 auto const answer = std::make_shared<PollDone>(std::move(done));
289 auto const run = std::make_shared<Run>(_shared, std::move(session),
290 [answer](std::expected<LoadedBundle, ConnectError> opened) {
291 if (!opened) {
292 (*answer)(std::unexpected(std::move(opened.error())));
293 return;
294 }
295 (*answer)(std::optional<LoadedBundle>{std::move(*opened)});
296 });
297 run->knownDigest = std::move(knownDigest);
298 run->unchanged = [answer] { (*answer)(std::optional<LoadedBundle>{}); };
299 sendHello(run);
300 }
301
302 /// @brief Rebuilds the last bundle this store verified for @p application, without a connection. Files the
303 /// store no longer holds, or that no longer verify, are left out; the catalog is empty.
304 /// @param application The application's id.
305 /// @return The bundle, or `Unreachable` when no verified manifest is cached.
306 [[nodiscard]] std::expected<LoadedBundle, ConnectError> openOffline(std::string_view application) const {
307 auto const digest = _shared->cache.store().lastDigest(application);
308 if (!digest) {
309 return std::unexpected(
311 .message = "no verified bundle of `" + std::string{application} + "` is cached"});
312 }
313 auto const text = _shared->cache.get(*digest);
314 if (!text) {
315 return std::unexpected(ConnectError{
317 .message = "the cached manifest of `" + std::string{application} + "` is gone or does not verify"});
318 }
319 auto manifest = parseManifest(*text);
320 if (!manifest) {
321 return std::unexpected(ConnectError{.kind = ConnectError::Kind::Unsupported, .message = manifest.error()});
322 }
323 LoadedBundle loaded;
324 loaded.manifest = std::move(*manifest);
325 for (ManifestFile const& file : loaded.manifest.files) {
326 if (!usable(_shared->client, file.kind)) {
327 continue;
328 }
329 if (auto bytes = _shared->cache.get(file.hash)) {
330 loaded.files.emplace(file.hash, std::move(*bytes));
331 }
332 }
333 assemble(loaded, *digest);
334 return loaded;
335 }
336
337private:
338 /// @brief What the client and every run share; a run holds it, so a run outlives the client that started it.
339 struct Shared {
340 Shared(Exchange exchangeFn, HelloRequest clientInfo, VerifiedCache verified)
341 : exchange{std::move(exchangeFn)}, client{std::move(clientInfo)}, cache{std::move(verified)} {}
342
343 Exchange exchange;
344 HelloRequest client;
345 VerifiedCache cache;
346 };
347
348 /// @brief One `open`: its replies so far, and the files still in flight.
349 ///
350 /// The steps up to the catalog run one at a time. The fetches after it are in flight together, and a transport
351 /// may answer them on different threads, so what they change is guarded by `mutex`.
352 struct Run {
353 Run(std::shared_ptr<Shared> sharedState, ::morph::session::Context sessionContext, Done doneFn)
354 : shared{std::move(sharedState)}, session{std::move(sessionContext)}, done{std::move(doneFn)} {}
355
356 std::shared_ptr<Shared> shared;
357 ::morph::session::Context session;
358 Done done;
359 // A poll's: the digest it already has, and what it calls instead of `done` when the reply carries it.
360 std::string knownDigest;
361 std::function<void()> unchanged;
362 LoadedBundle loaded;
363 std::mutex mutex;
364 std::size_t pending = 0;
365 std::optional<ConnectError> failure;
366 };
367
368 /// @brief A file a run expects: its hash, and how a refusal names it.
369 struct Expected {
370 std::string hash;
371 std::string what;
372 };
373
374 /// @brief Whether a client fetches files of @p kind: every kind it knows, components only when it loads them.
375 [[nodiscard]] static bool usable(HelloRequest const& client, std::string_view kind) {
376 if (kind == kComponentFile) {
377 return client.components;
378 }
379 return kind == kScreenFile || kind == kAppFile || kind == kThemeFile || kind == kMessagesFile ||
380 kind == kIconFile;
381 }
382
383 /// @brief Fills the bundle the shell reads from the verified files.
384 static void assemble(LoadedBundle& loaded, std::string const& digest) {
385 loaded.bundle.applicationId = loaded.manifest.application;
386 loaded.bundle.manifestDigest = digest.substr(kHashPrefix.size());
387 for (ManifestFile const& file : loaded.manifest.files) {
388 if (file.kind != kScreenFile && file.kind != kAppFile && file.kind != kThemeFile) {
389 continue;
390 }
391 if (auto const found = loaded.files.find(file.hash); found != loaded.files.end()) {
392 auto& into = file.kind == kThemeFile ? loaded.bundle.themes : loaded.bundle.documents;
393 into.insert_or_assign(file.id, found->second);
394 }
395 }
396 // The icon set the theme index names, each icon's SVG by its name; a build refuses an index naming a file
397 // the bundle lacks.
398 for (auto const& [fileId, text] : loaded.bundle.themes) {
399 static_cast<void>(fileId);
400 if (!isThemeIndex(text)) {
401 continue;
402 }
403 auto const index = parseThemeIndex(text);
404 if (!index) {
405 continue;
406 }
407 for (auto const& [name, iconId] : index->icons) {
408 auto const file = std::ranges::find_if(loaded.manifest.files, [&iconId](ManifestFile const& listed) {
409 return listed.kind == kIconFile && listed.id == iconId;
410 });
411 if (file == loaded.manifest.files.end()) {
412 continue;
413 }
414 if (auto const found = loaded.files.find(file->hash); found != loaded.files.end()) {
415 loaded.bundle.icons.insert_or_assign(name, found->second);
416 }
417 }
418 }
419 }
420
421 /// @brief A request of @p kind carrying the run's session.
422 [[nodiscard]] static ::morph::wire::Envelope requestOf(std::shared_ptr<Run> const& run, std::string_view kind) {
423 ::morph::wire::Envelope request;
424 request.kind = std::string{kind};
425 request.session = run->session;
426 return request;
427 }
428
429 /// @brief Sends the `ui-hello`.
430 static void sendHello(std::shared_ptr<Run> const& run) {
431 ::morph::wire::Envelope request = requestOf(run, kHelloKind);
432 request.body = detail::writeMessage(run->shared->client);
433 run->shared->exchange(std::move(request), [run](ExchangeReply reply) { onHello(run, std::move(reply)); });
434 }
435
436 /// @brief Sends the `ui-catalog`.
437 static void sendCatalog(std::shared_ptr<Run> const& run) {
438 run->shared->exchange(requestOf(run, kCatalogKind),
439 [run](ExchangeReply reply) { onCatalog(run, std::move(reply)); });
440 }
441
442 /// @brief Sends a `ui-fetch` for @p hash.
443 static void fetch(std::shared_ptr<Run> const& run, std::string const& hash,
444 std::function<void(ExchangeReply)> then) {
445 ::morph::wire::Envelope request = requestOf(run, kFetchKind);
446 request.body = detail::writeMessage(FetchRequest{.hash = hash});
447 run->shared->exchange(std::move(request), std::move(then));
448 }
449
450 static void fail(std::shared_ptr<Run> const& run, ConnectError error) {
451 ::morph::log::logWarn("[ui-fetch] {}", error.message);
452 run->done(std::unexpected(std::move(error)));
453 }
454
455 static void fail(std::shared_ptr<Run> const& run, ConnectError::Kind kind, std::string message) {
456 fail(run, ConnectError{.kind = kind, .message = std::move(message)});
457 }
458
459 /// @brief The reply to a request, or the error it carries: a transport failure is `Unreachable`, an `err` is
460 /// `Refused` with the server's message.
461 [[nodiscard]] static std::expected<std::string, ConnectError> bodyOf(ExchangeReply reply) {
462 if (!reply) {
463 return std::unexpected(ConnectError{.kind = ConnectError::Kind::Unreachable, .message = reply.error()});
464 }
465 if (reply->kind != "ok") {
466 return std::unexpected(
467 ConnectError{.kind = ConnectError::Kind::Refused, .message = std::move(reply->message)});
468 }
469 return std::move(reply->body);
470 }
471
472 /// @brief A fetched file, verified against its hash, or why it is refused.
473 [[nodiscard]] static std::expected<std::string, ConnectError> verified(ExchangeReply reply,
474 Expected const& expected) {
475 auto body = bodyOf(std::move(reply));
476 if (!body) {
477 body.error().message = expected.what + ": " + body.error().message;
478 return body;
479 }
480 if (body->size() > kMaxFileBytes) {
481 return std::unexpected(ConnectError{
483 .message = expected.what + " is larger than " + std::to_string(kMaxFileBytes) + " bytes"});
484 }
485 if (std::string const actual = contentHash(*body); actual != expected.hash) {
486 return std::unexpected(ConnectError{.kind = ConnectError::Kind::Unverified,
487 .message = expected.what + " does not match its hash (expected " +
488 expected.hash + ", got " + actual + ")"});
489 }
490 return body;
491 }
492
493 static void onHello(std::shared_ptr<Run> const& run, ExchangeReply reply) {
494 auto body = bodyOf(std::move(reply));
495 if (!body) {
496 ConnectError error = std::move(body.error());
497 if (error.message == "unknown envelope kind: " + std::string{kHelloKind}) {
499 error.message = "the server serves no UI";
500 } else if (error.message.starts_with("missing vocabulary: ")) {
502 }
503 fail(run, std::move(error));
504 return;
505 }
506 auto hello = parseHelloReply(*body);
507 if (!hello) {
508 fail(run, ConnectError::Kind::Unsupported, std::move(hello.error()));
509 return;
510 }
511 HelloRequest const& client = run->shared->client;
512 for (std::string const& vocabulary : hello->vocabularies) {
513 if (std::ranges::find(client.vocabularies, vocabulary) == client.vocabularies.end()) {
515 "the application needs the vocabulary " + vocabulary + ", which this client does not speak");
516 return;
517 }
518 }
519 if (run->unchanged && hello->digest == run->knownDigest) {
520 ::morph::log::logDebug("[ui-fetch] polling `{}`: digest {}, known {}, manifest unchanged",
521 hello->application, hello->digest, run->knownDigest);
522 run->unchanged();
523 return;
524 }
525 ::morph::log::logDebug("[ui-fetch] {} `{}`: digest {}, known {}, manifest {}",
526 run->unchanged ? "polling" : "opening", hello->application, hello->digest,
527 run->knownDigest.empty() ? std::string{"<none>"} : run->knownDigest,
528 hello->digest == run->knownDigest ? "unchanged" : "changed");
529 run->loaded.hello = std::move(*hello);
530 std::string const& digest = run->loaded.hello.digest;
531 if (auto cached = run->shared->cache.get(digest)) {
532 onManifest(run, std::move(*cached));
533 return;
534 }
535 fetch(run, digest, [run](ExchangeReply fetched) {
536 auto text =
537 verified(std::move(fetched), Expected{.hash = run->loaded.hello.digest, .what = "the manifest"});
538 if (!text) {
539 fail(run, std::move(text.error()));
540 return;
541 }
542 onManifest(run, std::move(*text));
543 });
544 }
545
546 static void onManifest(std::shared_ptr<Run> const& run, std::string text) {
547 auto manifest = parseManifest(text);
548 if (!manifest) {
549 fail(run, ConnectError::Kind::Unsupported, std::move(manifest.error()));
550 return;
551 }
552 static_cast<void>(run->shared->cache.put(std::move(text)));
553 run->loaded.manifest = std::move(*manifest);
554 sendCatalog(run);
555 }
556
557 static void onCatalog(std::shared_ptr<Run> const& run, ExchangeReply reply) {
558 auto body = bodyOf(std::move(reply));
559 if (!body) {
560 fail(run, std::move(body.error()));
561 return;
562 }
563 auto catalog = parseCatalogReply(*body);
564 if (!catalog) {
565 fail(run, ConnectError::Kind::Unsupported, std::move(catalog.error()));
566 return;
567 }
568 run->loaded.catalog = std::move(*catalog);
569 std::vector<Expected> missing;
570 for (ManifestFile const& file : run->loaded.manifest.files) {
571 if (!wanted(run->shared->client, run->loaded.catalog, file)) {
572 continue;
573 }
574 if (auto cached = run->shared->cache.get(file.hash)) {
575 run->loaded.files.insert_or_assign(file.hash, std::move(*cached));
576 } else {
577 Expected expected{.hash = file.hash, .what = "the " + file.kind + " `" + file.id + "`"};
578 ::morph::log::logDebug("[ui-fetch] fetching {} ({})", expected.hash, expected.what);
579 missing.push_back(std::move(expected));
580 }
581 }
582 // One count for each fetch and one for this loop, so a synchronous exchange that answers every fetch
583 // before the next is sent cannot settle the run early.
584 run->pending = missing.size() + 1;
585 for (Expected& expected : missing) {
586 std::string const hash = expected.hash;
587 fetch(run, hash, [run, expected = std::move(expected)](ExchangeReply fetched) {
588 auto bytes = verified(std::move(fetched), expected);
589 {
590 std::scoped_lock const lock{run->mutex};
591 if (!bytes) {
592 if (!run->failure) {
593 run->failure = std::move(bytes.error());
594 }
595 } else {
596 static_cast<void>(run->shared->cache.put(*bytes));
597 run->loaded.files.insert_or_assign(expected.hash, std::move(*bytes));
598 }
599 }
600 settle(run);
601 });
602 }
603 settle(run);
604 }
605
606 /// @brief Whether the run fetches @p file: a kind the client uses, and for a screen, one the catalog lists as
607 /// available or public, which is what the server serves this principal.
608 [[nodiscard]] static bool wanted(HelloRequest const& client, CatalogReply const& catalog,
609 ManifestFile const& file) {
610 if (!usable(client, file.kind)) {
611 return false;
612 }
613 if (file.kind != kScreenFile) {
614 return true;
615 }
616 return std::ranges::any_of(catalog.entries, [&file](CatalogEntry const& entry) {
617 return entry.id == file.id && (entry.available || entry.isPublic);
618 });
619 }
620
621 /// @brief Counts one fetch, or the loop that sent them, as settled; the last delivers the run.
622 static void settle(std::shared_ptr<Run> const& run) {
623 {
624 std::scoped_lock const lock{run->mutex};
625 if (--run->pending != 0) {
626 return;
627 }
628 }
629 // Past the last count nothing else touches the run, so it is read without the lock.
630 if (run->failure) {
631 fail(run, std::move(*run->failure));
632 return;
633 }
634 assemble(run->loaded, run->loaded.hello.digest);
635 run->shared->cache.store().setLastDigest(run->loaded.hello.application, run->loaded.hello.digest);
636 run->done(std::move(run->loaded));
637 }
638
639 std::shared_ptr<Shared> _shared;
640};
641
642/// @brief The value an `evaluate` query takes from a `ui-evaluate` reply: the evaluated body's members, with the field
643/// errors under `errors` and the verdict under `ready`, where `errors.<field>` attaches each to its element.
644///
645/// An action field named `errors` or `ready` is shadowed by them.
646/// @param reply The reply, or why the transport could not deliver it.
647/// @return The value as JSON text, or why there is none: the transport's reason, the server's `err` message, or why
648/// the reply body was refused.
649[[nodiscard]] inline std::expected<std::string, std::string> evaluationValue(ExchangeReply reply) {
650 if (!reply) {
651 return std::unexpected(std::move(reply.error()));
652 }
653 if (reply->kind != "ok") {
654 return std::unexpected(std::move(reply->message));
655 }
656 auto evaluated = parseEvaluateReply(reply->body);
657 if (!evaluated) {
658 return std::unexpected(std::move(evaluated.error()));
659 }
660 Json::object_t errors;
661 for (auto const& [field, message] : evaluated->errors) {
662 errors.insert_or_assign(field, Json(message));
663 }
664 auto& members = evaluated->body.get<Json::object_t>();
665 members.insert_or_assign("errors", Json(std::move(errors)));
666 members.insert_or_assign("ready", Json(evaluated->ready));
667 auto text = glz::write_json(evaluated->body);
668 if (!text) {
669 return std::unexpected("the ui-evaluate reply's body cannot be written");
670 }
671 return std::move(*text);
672}
673
674/// @brief An evaluator over the UI service: each evaluation is one `ui-evaluate` request through @p exchange, and
675/// resolves with `evaluationValue` of its reply. What `BridgeDispatcher` takes as its `Evaluator`.
676/// @param exchange The transport.
677/// @param session The session every request carries.
678/// @param cbExec The executor the completion is delivered on: the interpreter runtime's owner. Borrowed.
679/// @return The evaluator: the model's type id, the action id and its JSON body in, the query's value out.
680[[nodiscard]] inline std::function<::morph::async::Completion<std::string>(std::string const&, std::string const&,
681 std::string const&)>
683 return [exchange = std::move(exchange), session = std::move(session), cbExec](
684 std::string const& typeId, std::string const& actionId, std::string const& bodyJson) {
685 auto [completion, promise] = ::morph::async::Completion<std::string>::makeSettleable(cbExec);
686 auto settle = std::make_shared<decltype(promise)>(std::move(promise));
688 request.kind = std::string{kEvaluateKind};
689 request.session = session;
690 request.modelType = typeId;
691 request.actionType = actionId;
692 request.body = bodyJson;
693 exchange(std::move(request), [settle](ExchangeReply reply) {
694 auto value = evaluationValue(std::move(reply));
695 if (value) {
696 settle->resolve(std::move(*value));
697 } else {
698 settle->reject(std::make_exception_ptr(std::runtime_error{value.error()}));
699 }
700 });
701 return std::move(completion);
702 };
703}
704
705/// @brief A watcher over the server (spec 8 §7): each `watch` or `unwatch` is one request of that kind through
706/// @p exchange, carrying the session @p currentSession returns when it is sent. What `SessionOptions::watcher`
707/// takes.
708///
709/// The session is read at each send, not fixed when the watcher is built, because a `watch` must carry whoever
710/// is signed in now: the exchange does not stamp it, and the server refuses an unsigned one.
711/// @param exchange The transport.
712/// @param currentSession The session to stamp on each request, read when it is sent.
713/// @param cbExec The executor the completion is delivered on: the interpreter runtime's owner. Borrowed.
714/// @return The watcher: the kind and the `WatchBody` JSON in; the reply body out, or the transport's or the
715/// server's reason as the failure.
716[[nodiscard]] inline std::function<::morph::async::Completion<std::string>(std::string const&, std::string const&)>
717watcherOver(Exchange exchange, std::function<::morph::session::Context()> currentSession,
718 ::morph::exec::IExecutor* cbExec) {
719 return [exchange = std::move(exchange), currentSession = std::move(currentSession), cbExec](
720 std::string const& kind, std::string const& bodyJson) {
721 auto [completion, promise] = ::morph::async::Completion<std::string>::makeSettleable(cbExec);
722 auto settle = std::make_shared<decltype(promise)>(std::move(promise));
724 request.kind = kind;
725 request.session = currentSession ? currentSession() : ::morph::session::Context{};
726 request.body = bodyJson;
727 exchange(std::move(request), [settle](ExchangeReply reply) {
728 if (!reply) {
729 settle->reject(std::make_exception_ptr(std::runtime_error{reply.error()}));
730 } else if (reply->kind != "ok") {
731 settle->reject(std::make_exception_ptr(std::runtime_error{reply->message}));
732 } else {
733 settle->resolve(std::move(reply->body));
734 }
735 });
736 return std::move(completion);
737 };
738}
739
740/// @brief A file a download brought back.
742 /// @brief Its bytes.
743 std::string bytes;
744 /// @brief Its name.
745 std::string name;
746 /// @brief Its media type.
747 std::string type;
748};
749
750/// @brief The client of the file side channel (`files/1`, spec 6 §18): uploads and downloads through `ui-file`, one
751/// chunk of at most `kMaxFileChunkBytes` in flight at a time, reporting progress after each.
752///
753/// What it moves is bytes: reading the file the user picked is the platform's, and an application's
754/// `InterpreterHost::upload` reads it and hands the bytes here.
756public:
757 /// @brief A transfer over @p exchange, its requests carrying @p session.
758 /// @param exchange The transport.
759 /// @param session The session every request carries.
760 /// @throws std::invalid_argument for an empty @p exchange.
762 : _shared{std::make_shared<Shared>(Shared{.exchange = std::move(exchange), .session = std::move(session)})} {
763 if (!_shared->exchange) {
764 throw std::invalid_argument("FileTransfer: the exchange is empty");
765 }
766 }
767
768 /// @brief Uploads a file, authorised as the mutation that will reference it.
769 /// @param info Its name, type and size; `size` is taken from @p bytes.
770 /// @param bytes The file.
771 /// @param model The model type of the mutation that will reference it.
772 /// @param action That mutation's action.
773 /// @param progress Called with 0 to 1 after each chunk; may be empty.
774 /// @param done Called once with the file's reference, or why the upload failed.
775 void upload(FileInfo info, std::string bytes, std::string model, std::string action,
776 std::function<void(double)> progress,
777 std::function<void(std::expected<std::string, std::string>)> done) const {
778 auto const run = std::make_shared<Upload>(Upload{.shared = _shared,
779 .bytes = std::move(bytes),
780 .upload = {},
781 .progress = std::move(progress),
782 .done = std::move(done)});
783 FileRequest begin;
784 begin.op = "begin";
785 begin.name = std::move(info.name);
786 begin.type = std::move(info.type);
787 begin.size = run->bytes.size();
788 begin.model = std::move(model);
789 begin.action = std::move(action);
790 send(*_shared, begin, [run](std::expected<FileReply, std::string> reply) {
791 if (!reply || !reply->upload) {
792 run->fail(reply ? std::string{"the ui-file begin reply has no upload id"} : std::move(reply.error()));
793 return;
794 }
795 run->upload = std::move(*reply->upload);
796 sendChunk(run, 0);
797 });
798 }
799
800 /// @brief Downloads a file by its reference.
801 /// @param ref The reference a reply carried.
802 /// @param progress Called with 0 to 1 after each chunk; may be empty.
803 /// @param done Called once with the file, or why the download failed.
804 void download(std::string ref, std::function<void(double)> progress,
805 std::function<void(std::expected<DownloadedFile, std::string>)> done) const {
806 auto const run = std::make_shared<Download>(Download{.shared = _shared,
807 .ref = std::move(ref),
808 .file = {},
809 .progress = std::move(progress),
810 .done = std::move(done)});
811 readChunk(run);
812 }
813
814private:
815 struct Shared {
816 Exchange exchange;
818 };
819
820 struct Upload {
821 std::shared_ptr<Shared> shared;
822 std::string bytes;
823 std::string upload;
824 std::function<void(double)> progress;
825 std::function<void(std::expected<std::string, std::string>)> done;
826
827 void fail(std::string reason) const {
828 if (done) {
829 done(std::unexpected(std::move(reason)));
830 }
831 }
832 };
833
834 struct Download {
835 std::shared_ptr<Shared> shared;
836 std::string ref;
837 DownloadedFile file;
838 std::function<void(double)> progress;
839 std::function<void(std::expected<DownloadedFile, std::string>)> done;
840 };
841
842 /// Sends one `ui-file` request and hands its reply body, or why there is none, to @p then.
843 static void send(Shared const& shared, FileRequest const& request,
844 std::function<void(std::expected<FileReply, std::string>)> then) {
845 ::morph::wire::Envelope envelope;
846 envelope.kind = std::string{kFileKind};
847 envelope.session = shared.session;
848 envelope.body = detail::writeMessage(request);
849 shared.exchange(std::move(envelope), [then = std::move(then)](ExchangeReply reply) {
850 if (!reply) {
851 then(std::unexpected(std::move(reply.error())));
852 return;
853 }
854 if (reply->kind != "ok") {
855 then(std::unexpected(std::move(reply->message)));
856 return;
857 }
858 then(parseFileReply(reply->body));
859 });
860 }
861
862 /// Sends the chunk at @p offset, then the next, then `end`.
863 static void sendChunk(std::shared_ptr<Upload> const& run, std::size_t offset) {
864 if (offset >= run->bytes.size()) {
865 FileRequest end;
866 end.op = "end";
867 end.upload = run->upload;
868 send(*run->shared, end, [run](std::expected<FileReply, std::string> reply) {
869 if (!reply || !reply->ref) {
870 run->fail(reply ? std::string{"the ui-file end reply has no reference"}
871 : std::move(reply.error()));
872 return;
873 }
874 if (run->done) {
875 run->done(std::move(*reply->ref));
876 }
877 });
878 return;
879 }
880 FileRequest chunk;
881 chunk.op = "chunk";
882 chunk.upload = run->upload;
883 chunk.offset = offset;
884 std::string_view const all{run->bytes};
885 chunk.data = ::morph::session::detail::base64UrlEncode(all.substr(offset, kMaxFileChunkBytes));
886 send(*run->shared, chunk, [run](std::expected<FileReply, std::string> reply) {
887 if (!reply || !reply->received) {
888 run->fail(reply ? std::string{"the ui-file chunk reply has no count"} : std::move(reply.error()));
889 return;
890 }
891 if (run->progress) {
892 run->progress(static_cast<double>(*reply->received) / static_cast<double>(run->bytes.size()));
893 }
894 sendChunk(run, *reply->received);
895 });
896 }
897
898 /// Reads the next chunk, until the whole file has arrived.
899 static void readChunk(std::shared_ptr<Download> const& run) {
900 FileRequest read;
901 read.op = "read";
902 read.ref = run->ref;
903 read.offset = run->file.bytes.size();
904 send(*run->shared, read, [run](std::expected<FileReply, std::string> reply) {
905 std::optional<std::string> bytes;
906 if (reply && reply->data && reply->total) {
907 bytes = ::morph::session::detail::base64UrlDecode(*reply->data);
908 }
909 if (!bytes) {
910 if (run->done) {
911 run->done(std::unexpected(reply ? std::string{"the ui-file read reply is malformed"}
912 : std::move(reply.error())));
913 }
914 return;
915 }
916 run->file.bytes += *bytes;
917 run->file.name = reply->name.value_or("");
918 run->file.type = reply->type.value_or("");
919 std::size_t const total = *reply->total;
920 if (run->progress && total > 0) {
921 run->progress(static_cast<double>(run->file.bytes.size()) / static_cast<double>(total));
922 }
923 if (run->file.bytes.size() >= total || bytes->empty()) {
924 if (run->done) {
925 run->done(std::move(run->file));
926 }
927 return;
928 }
929 readChunk(run);
930 });
931 }
932
933 std::shared_ptr<Shared> _shared;
934};
935
936} // namespace morph::ui
Node text(TextOptions const &options)
A text node.
Definition author.hpp:3126
Command all(Commands const &steps)
all: steps started together.
Definition author.hpp:2928
Command run(MutationHandle< Action > const &mutation, Body< Action > const &body={}, RunOptions const &options={})
run: runs a mutation.
Definition author.hpp:2755
What the UI service's client and server share (spec 5 §12): content hashes, the bundle's manifest,...
constexpr std::string_view kScreenFile
The file kinds of a manifest.
Definition bundle.hpp:82
std::expected< FileReply, std::string > parseFileReply(std::string_view text)
Reads a ui-file reply body.
Definition bundle.hpp:672
constexpr std::string_view kFileKind
The envelope kind of the file side channel (files/1, spec 6 §18): uploads and downloads in chunks.
Definition bundle.hpp:47
constexpr std::string_view kIconFile
An icon of a theme's icon set: SVG text.
Definition bundle.hpp:92
std::expected< EvaluateReply, std::string > parseEvaluateReply(std::string_view text)
Reads a ui-evaluate reply body.
Definition bundle.hpp:633
std::expected< CatalogReply, std::string > parseCatalogReply(std::string_view text)
Reads a ui-catalog reply body.
Definition bundle.hpp:683
constexpr std::string_view kHashPrefix
The prefix of a content hash.
Definition bundle.hpp:59
std::expected< HelloReply, std::string > parseHelloReply(std::string_view text)
Reads a ui-hello reply body.
Definition bundle.hpp:607
std::expected< Manifest, std::string > parseManifest(std::string_view text)
Reads a manifest a server sent.
Definition bundle.hpp:532
constexpr std::string_view kEvaluateKind
The envelope kind that evaluates an action body without running it (spec 5 §7).
Definition bundle.hpp:45
constexpr std::string_view kAppFile
The app document (spec 6 §5).
Definition bundle.hpp:84
constexpr std::string_view kMessagesFile
A message catalogue.
Definition bundle.hpp:88
std::string contentHash(std::string_view bytes)
The content hash of bytes: sha256: and the SHA-256 digest in 64 lower-case hexadecimal digits.
Definition bundle.hpp:99
constexpr std::string_view kThemeFile
A theme (ui-theme/1, spec 6 §6).
Definition bundle.hpp:86
static std::pair< Completion< T >, Promise > makeSettleable(::morph::exec::IExecutor *execPtr)
Constructs a Completion<T>/Promise<T> pair sharing one settleable state.
What a wire transport offers beside IBackend: one request, one reply, matched by callId.
void download(std::string ref, std::function< void(double)> progress, std::function< void(std::expected< DownloadedFile, std::string >)> done) const
Downloads a file by its reference.
void upload(FileInfo info, std::string bytes, std::string model, std::string action, std::function< void(double)> progress, std::function< void(std::expected< std::string, std::string >)> done) const
Uploads a file, authorised as the mutation that will reference it.
FileTransfer(Exchange exchange, ::morph::session::Context session)
A transfer over exchange, its requests carrying session.
Where a client keeps verified files between runs, by content hash, and the digest of each application...
virtual std::optional< std::string > get(std::string_view hash) const =0
Reads a file.
virtual void setLastDigest(std::string const &application, std::string digest)=0
Records an application's last verified bundle.
virtual void remove(std::string_view hash)=0
Drops a file.
virtual std::optional< std::string > lastDigest(std::string_view application) const =0
The digest of an application's last verified bundle.
virtual void put(std::string const &hash, std::string bytes)=0
Keeps a file.
An IBundleStore in memory: it lasts as long as the process. Safe to call from any thread.
void put(std::string const &hash, std::string bytes) override
Keeps a file.
void setLastDigest(std::string const &application, std::string digest) override
Records an application's last verified bundle.
std::optional< std::string > get(std::string_view hash) const override
Reads a file.
std::optional< std::string > lastDigest(std::string_view application) const override
The digest of an application's last verified bundle.
void remove(std::string_view hash) override
Drops a file.
std::size_t size() const
How many files the store holds.
std::function< void(std::expected< std::optional< LoadedBundle >, ConnectError >)> PollDone
What poll delivers: the changed bundle, nothing when the digest is the one already held,...
std::function< void(std::expected< LoadedBundle, ConnectError >)> Done
What open delivers.
std::expected< LoadedBundle, ConnectError > openOffline(std::string_view application) const
Rebuilds the last bundle this store verified for application, without a connection....
void open(::morph::session::Context session, Done done) const
Opens the bundle. done is called once, from whichever thread the exchange calls back on; a synchronou...
void poll(::morph::session::Context session, std::string knownDigest, PollDone done) const
One step of a development client's reload (spec 5 §12): sends ui-hello, and when the manifest digest ...
UiClient(Exchange exchange, HelloRequest client, std::shared_ptr< IBundleStore > store)
A client.
A content-addressed cache that hands out only bytes that hash to the hash they are asked for.
std::string put(std::string bytes)
Keeps a file under its content hash.
VerifiedCache(std::shared_ptr< IBundleStore > store)
A cache over store.
IBundleStore & store() noexcept
The store.
std::optional< std::string > get(std::string const &hash)
Reads a file, verified. A stored file that does not hash to hash is dropped and reads as a miss.
IEnvelopeTransport: a transport that sends a request envelope of a kind its backend verbs do not buil...
The file side channel's store (files/1, spec 6 §18): uploads in chunks, and files a principal may rea...
std::function<::morph::async::Completion< std::string >(std::string const &, std::string const &)> watcherOver(Exchange exchange, std::function<::morph::session::Context()> currentSession, ::morph::exec::IExecutor *cbExec)
A watcher over the server (spec 8 §7): each watch or unwatch is one request of that kind through exch...
Exchange exchangeOver(::morph::backend::IEnvelopeTransport &backend, ::morph::exec::IExecutor &cbExec)
An Exchange over a transport: each request is one IEnvelopeTransport::request, and its reply,...
std::function<::morph::async::Completion< std::string >(std::string const &, std::string const &, std::string const &)> evaluatorOver(Exchange exchange, ::morph::session::Context session, ::morph::exec::IExecutor *cbExec)
An evaluator over the UI service: each evaluation is one ui-evaluate request through exchange,...
std::expected< std::string, std::string > evaluationValue(ExchangeReply reply)
The value an evaluate query takes from a ui-evaluate reply: the evaluated body's members,...
std::function< void(::morph::wire::Envelope request, std::function< void(ExchangeReply reply)> done)> Exchange
Sends one request and calls its callback once with the reply. The transport assigns the request's cal...
std::expected<::morph::wire::Envelope, std::string > ExchangeReply
One reply, or why the transport could not deliver it.
void request(Store< ViewState, Msg > &store, bridge::BridgeHandler< Model, Sharing > &handler, async::CallbackScope const &scope, Action action, ToMsg toMsg, ToFailMsg toFailMsg)
Executes action and sends its result, or its failure, to store as one message.
Definition store.hpp:202
Abstract executor interface.
Definition executor.hpp:70
Per-call session context carried through the bridge to the model.
Definition session.hpp:26
What an open source hands the frontend: the application's screens, verified.
Definition frontend.hpp:91
The body of a ui-catalog reply: the catalog filtered for the principal, and the facts a client's load...
Definition bundle.hpp:280
Why a source could not be opened.
Definition frontend.hpp:108
Kind
What went wrong.
Definition frontend.hpp:110
@ Unreachable
The server could not be reached, or the connection broke during the handshake.
Definition frontend.hpp:111
@ NoUiService
The peer answered, but serves no UI (unknown envelope kind: ui-hello).
Definition frontend.hpp:112
@ Unsupported
The bundle needs a vocabulary this client does not speak; the message names it.
Definition frontend.hpp:113
@ Unverified
A file or the manifest did not match its digest.
Definition frontend.hpp:114
@ Refused
The application refused this client or this user: a sign-in, a licence, an authoriser.
Definition frontend.hpp:115
A file a download brought back.
std::string name
Its name.
std::string bytes
Its bytes.
std::string type
Its media type.
What a file is, as its uploader described it.
The body of a ui-file request: one step of an upload or a download, named by op.
Definition bundle.hpp:207
std::string op
begin, chunk, end or read.
Definition bundle.hpp:209
The body of a ui-hello reply.
Definition bundle.hpp:182
The body of a ui-hello request: what the client speaks and carries.
Definition bundle.hpp:164
A bundle the client verified: what the shell mounts, and what it was verified against.
CatalogReply catalog
The catalog for the principal; empty when the bundle was opened offline.
Manifest manifest
The manifest.
Bundle bundle
The application's id, the manifest digest, and the documents (the screens it may open and the app doc...
std::map< std::string, std::string, std::less<> > files
Every verified file the bundle holds, by content hash: documents, themes, message catalogues and,...
HelloReply hello
The ui-hello reply; empty when the bundle was opened offline.
One file of a manifest.
Definition bundle.hpp:125
std::string hash
The file's content hash.
Definition bundle.hpp:131
std::string kind
screen, app, theme, messages, component or icon; a client fetches no kind it does not know.
Definition bundle.hpp:129
A bundle's manifest: every file the application serves, by its hash (spec 5 §12, "Content addressing"...
Definition bundle.hpp:143
std::vector< ManifestFile > files
The files, sorted by kind then id.
Definition bundle.hpp:149
JSON wire envelope used between any client and RemoteServer.
Definition wire.hpp:91
std::string kind
Discriminator — see class docstring for valid values.
Definition wire.hpp:93
::morph::session::Context session
Session context for authorization and routing. Populated on execute; ignored on every other kind.
Definition wire.hpp:149
std::string body
Serialized JSON of the action payload (execute) or of the deserializable result (ok).
Definition wire.hpp:142
The frontend seam: main picks a frontend at runtime, and the frontend runs whatever an AppSource open...
Theme tokens (ui-theme/1, spec 6 §6): DTCG 2025.10 token files read and checked, layered into a varia...
bool isThemeIndex(std::string_view text)
Whether a theme file is a theme index rather than a token file: its top level has a string vocab.
Definition theme.hpp:739
std::expected< ThemeIndex, std::string > parseThemeIndex(std::string_view text)
Parses a theme index: {"vocab": "ui-theme/1", "base": id, "dark": id, …, "density": {....
Definition theme.hpp:799
glz::generic_u64 Json
The JSON tree the document layer reads and writes: glaze's generic value in its integer-preserving mo...
Definition value.hpp:35
Node text(Text spec)
Static or bound text.
Definition view.hpp:1194
Node progress(Progress spec)
A progress bar.
Definition view.hpp:1299