From 5f7cc00a76217743bdb5a7bb5a47feaf715a6180 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 18:39:13 -0700 Subject: [PATCH 01/40] lui migration: foundation (deps, worker/service interfaces, extension registry, native bridge) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/dune | 25 +- app/journal_bridge.ml | 81 +++++++ app/journal_bridge.mli | 43 ++++ app/journal_lui_bridge.c | 224 ++++++++++++++++++ app/journal_lui_native.ml | 98 ++++++++ app/journal_lui_native.mli | 46 ++++ app/journal_pump.ml | 36 +++ app/journal_pump.mli | 23 ++ app/native_embed.ml | 6 +- .../2026-09-23-replace-bonsai-ui-with-lui.md | 135 +++++++++++ dune-project | 24 +- logseq_db_storage.opam | 2 +- logseq_db_types.opam | 2 +- logseq_db_worker.opam | 7 +- logseq_db_worker/lui/dune | 26 ++ .../lui/journal_bounded_mailbox.mli | 46 ++++ logseq_db_worker/lui/journal_worker.mli | 189 +++++++++++++++ .../lui/journal_worker_eio_backend.mli | 24 ++ logseq_db_worker/lui/journal_worker_ids.ml | 70 ++++++ logseq_db_worker/lui/journal_worker_ids.mli | 86 +++++++ .../lui/journal_worker_runtime.mli | 67 ++++++ .../lui/logseq_db_worker_lui_service.mli | 212 +++++++++++++++++ logseq_journal.opam | 15 +- logseq_overlay_db.opam | 2 +- logseq_sync.opam | 2 +- 25 files changed, 1439 insertions(+), 52 deletions(-) create mode 100644 app/journal_bridge.ml create mode 100644 app/journal_bridge.mli create mode 100644 app/journal_lui_bridge.c create mode 100644 app/journal_lui_native.ml create mode 100644 app/journal_lui_native.mli create mode 100644 app/journal_pump.ml create mode 100644 app/journal_pump.mli create mode 100644 docs/agent-guide/exploring/2026-09-23-replace-bonsai-ui-with-lui.md create mode 100644 logseq_db_worker/lui/dune create mode 100644 logseq_db_worker/lui/journal_bounded_mailbox.mli create mode 100644 logseq_db_worker/lui/journal_worker.mli create mode 100644 logseq_db_worker/lui/journal_worker_eio_backend.mli create mode 100644 logseq_db_worker/lui/journal_worker_ids.ml create mode 100644 logseq_db_worker/lui/journal_worker_ids.mli create mode 100644 logseq_db_worker/lui/journal_worker_runtime.mli create mode 100644 logseq_db_worker/lui/logseq_db_worker_lui_service.mli diff --git a/app/dune b/app/dune index 1d14330..13f0bac 100644 --- a/app/dune +++ b/app/dune @@ -3,6 +3,9 @@ (wrapped false) (modules application + journal_bridge + journal_lui_native + journal_pump journal_uploads journal_asset_import journal_asset_settings @@ -33,35 +36,31 @@ journal_symbols) (libraries rrbvec - bonsai_swiftui.spec_impl base - bonsai - bonsai_swiftui - bonsai_swiftui.ui - bonsai_swiftui.driver core + lui + ocaml-signal datascript-ocaml-native - incr_dom.ui_incr logseq_db_types logseq_db_worker - logseq_db_worker.bonsai + logseq_db_worker.lui threads unix - virtual_dom.ui_effect - yojson)) + yojson) + (foreign_stubs + (language c) + (names journal_lui_bridge))) (executable (name native_embed) (modules native_embed) (libraries - bonsai_swiftui.spec_impl app - bonsai_swiftui.driver - bonsai_swiftui.native_backend) + lui) (link_flags (:standard -cclib - -L%{env:BONSAI_SWIFTUI_APPLE_SDK_ROOT=}/usr/lib + -L%{env:JOURNAL_APPLE_SDK_ROOT=}/usr/lib (:include native_static_link_flags.sexp))) (modes (native object))) diff --git a/app/journal_bridge.ml b/app/journal_bridge.ml new file mode 100644 index 0000000..8232745 --- /dev/null +++ b/app/journal_bridge.ml @@ -0,0 +1,81 @@ +type hooks = + { init : int -> int -> string + ; dispatch : Lui_protocol.event -> string + ; extension_event : int -> string -> string -> string + ; pump : unit -> string + ; platform_event : string -> unit + ; platform_response : string -> unit + ; dispose : unit -> string + ; root_node : unit -> int + } + +external wakeup : unit -> unit = "journal_ml_wakeup" + +external platform_request : string -> unit = "journal_ml_platform_request" + +let current : hooks option ref = ref None + +let hooks () = + match !current with + | Some hooks -> hooks + | None -> invalid_arg "Journal_bridge.register was not called" + +let initialize platform_code host_code = (hooks ()).init platform_code host_code + +let dispatch_lui event = (hooks ()).dispatch event + +let appear node = dispatch_lui (Lui_protocol.Appear node) + +let press node = dispatch_lui (Lui_protocol.Press node) + +let long_press node = dispatch_lui (Lui_protocol.LongPress node) + +let text_changed node text = dispatch_lui (Lui_protocol.TextChanged (node, text)) + +let submit node = dispatch_lui (Lui_protocol.Submit node) + +let dismiss node = dispatch_lui (Lui_protocol.Dismiss node) + +let double_press node = dispatch_lui (Lui_protocol.DoublePress node) + +let toggle_changed node checked = + dispatch_lui (Lui_protocol.ToggleChanged (node, checked)) +;; + +let radio_changed node = dispatch_lui (Lui_protocol.Change node) + +let slider_changed node value = dispatch_lui (Lui_protocol.ValueChanged (node, value)) + +let extension_event node name payload = + (hooks ()).extension_event node name payload +;; + +let pump () = (hooks ()).pump () + +let platform_event payload = (hooks ()).platform_event payload + +let platform_response payload = (hooks ()).platform_response payload + +let dispose () = (hooks ()).dispose () + +let root_node () = (hooks ()).root_node () + +let register hooks = + current := Some hooks; + Callback.register "lui_ocaml_init" initialize; + Callback.register "lui_ocaml_appear" appear; + Callback.register "lui_ocaml_press" press; + Callback.register "lui_ocaml_long_press" long_press; + Callback.register "lui_ocaml_text_changed" text_changed; + Callback.register "lui_ocaml_submit" submit; + Callback.register "lui_ocaml_dismiss" dismiss; + Callback.register "lui_ocaml_double_press" double_press; + Callback.register "lui_ocaml_toggle_changed" toggle_changed; + Callback.register "lui_ocaml_radio_changed" radio_changed; + Callback.register "lui_ocaml_slider_changed" slider_changed; + Callback.register "lui_ocaml_dispose" dispose; + Callback.register "lui_ocaml_root_node" root_node; + Callback.register "journal_ocaml_extension_event" extension_event; + Callback.register "journal_ocaml_pump" pump; + Callback.register "journal_ocaml_platform_event" platform_event; + Callback.register "journal_ocaml_platform_response" platform_response diff --git a/app/journal_bridge.mli b/app/journal_bridge.mli new file mode 100644 index 0000000..58da576 --- /dev/null +++ b/app/journal_bridge.mli @@ -0,0 +1,43 @@ +(** OCaml-side entries exposed to the native host through + [journal_lui_bridge.c]. [register] installs every [Callback] named value + the C stub resolves. Each entry that produces UI patches returns the + latest JSON patch batch, exactly like [lui_ocaml_bridge.c]'s emit_patch + protocol. *) + +type hooks = + { (** [platform_code -> host_code -> patch json]. Builds the app with the + lui backend for the given host profile, starts it, and returns the + initial patch batch. *) + init : int -> int -> string + ; (** Dispatches a standard lui UI event; returns the patch batch produced + by the dispatch + flush. *) + dispatch : Lui_protocol.event -> string + ; (** [node -> name -> values_json -> patch json]. Forwards an extension + event from a registered journal native component. *) + extension_event : int -> string -> string -> string + ; (** Drains the cross-thread work queue ([Journal_pump]) and flushes; + returns the patch batch. *) + pump : unit -> string + ; (** Host -> OCaml: an LJP2 platform envelope pushed by the host + (lifecycle, network, termination). Binary-safe string. *) + platform_event : string -> unit + ; (** Host -> OCaml: an LJP2 response envelope completing an earlier + platform request. Binary-safe string. *) + platform_response : string -> unit + ; (** Tears the app down; returns the final patch batch. *) + dispose : unit -> string + ; root_node : unit -> int + } + +val register : hooks -> unit + +(** OCaml -> host trampolines implemented by the C stub; the host installs + the underlying function pointers at startup. *) + +(** Invokes the host wakeup callback so it schedules [journal_ocaml_pump] on + the app thread. *) +external wakeup : unit -> unit = "journal_ml_wakeup" + +(** Forwards one LJP2 request envelope to the host platform bridge (the host + answers asynchronously through [platform_response]). *) +external platform_request : string -> unit = "journal_ml_platform_request" diff --git a/app/journal_lui_bridge.c b/app/journal_lui_bridge.c new file mode 100644 index 0000000..2016168 --- /dev/null +++ b/app/journal_lui_bridge.c @@ -0,0 +1,224 @@ +#include +#include +#include + +#include +#include +#include +#include + +#if defined(_WIN32) +#define LUI_EXPORT __declspec(dllexport) +#else +#define LUI_EXPORT __attribute__((visibility("default"))) +#endif + +typedef void (*lui_patch_callback)(const char *json); +typedef void (*journal_wakeup_callback)(void); +typedef void (*journal_platform_request_callback)(const char *data, + int32_t length); + +static int runtime_started = 0; +static lui_patch_callback patch_callback = NULL; +static journal_wakeup_callback wakeup_callback = NULL; +static journal_platform_request_callback platform_request_callback = NULL; + +static int emit_patch(value result) { + if (Is_exception_result(result)) { + return 0; + } + const char *json = String_val(result); + if (patch_callback != NULL && json[0] != '\0') { + patch_callback(json); + } + return 1; +} + +static value copy_bytes(const char *data, int32_t length) { + value text = caml_alloc_string((mlsize_t)length); + memcpy(Bytes_val(text), data, (size_t)length); + return text; +} + +LUI_EXPORT int32_t lui_ocaml_start( + lui_patch_callback callback, + int32_t platform_code, + int32_t host_code) { + patch_callback = callback; + if (!runtime_started) { + char *arguments[] = {"journal_lui_ocaml", NULL}; + caml_startup(arguments); + runtime_started = 1; + } + + const value *initialize = caml_named_value("lui_ocaml_init"); + if (initialize == NULL) { + return 0; + } + return emit_patch(caml_callback2_exn( + *initialize, + Val_long(platform_code), + Val_long(host_code))); +} + +static int dispatch_long(const char *name, int64_t node) { + const value *dispatch = caml_named_value(name); + if (dispatch == NULL) { + return 0; + } + return emit_patch(caml_callback_exn(*dispatch, Val_long(node))); +} + +LUI_EXPORT int32_t lui_ocaml_appear(int64_t node) { + return dispatch_long("lui_ocaml_appear", node); +} + +LUI_EXPORT int32_t lui_ocaml_press(int64_t node) { + return dispatch_long("lui_ocaml_press", node); +} + +LUI_EXPORT int32_t lui_ocaml_long_press(int64_t node) { + return dispatch_long("lui_ocaml_long_press", node); +} + +LUI_EXPORT int32_t lui_ocaml_text_changed(int64_t node, const char *text) { + const value *dispatch = caml_named_value("lui_ocaml_text_changed"); + if (dispatch == NULL) { + return 0; + } + return emit_patch(caml_callback2_exn( + *dispatch, Val_long(node), caml_copy_string(text))); +} + +LUI_EXPORT int32_t lui_ocaml_submit(int64_t node) { + return dispatch_long("lui_ocaml_submit", node); +} + +LUI_EXPORT int32_t lui_ocaml_dismiss(int64_t node) { + return dispatch_long("lui_ocaml_dismiss", node); +} + +LUI_EXPORT int32_t lui_ocaml_double_press(int64_t node) { + return dispatch_long("lui_ocaml_double_press", node); +} + +LUI_EXPORT int32_t lui_ocaml_toggle_changed(int64_t node, int32_t checked) { + const value *dispatch = caml_named_value("lui_ocaml_toggle_changed"); + if (dispatch == NULL) { + return 0; + } + return emit_patch(caml_callback2_exn( + *dispatch, Val_long(node), Val_bool(checked))); +} + +LUI_EXPORT int32_t lui_ocaml_radio_changed(int64_t node) { + return dispatch_long("lui_ocaml_radio_changed", node); +} + +LUI_EXPORT int32_t lui_ocaml_slider_changed(int64_t node, double fraction) { + const value *dispatch = caml_named_value("lui_ocaml_slider_changed"); + if (dispatch == NULL) { + return 0; + } + return emit_patch(caml_callback2_exn( + *dispatch, Val_long(node), caml_copy_double(fraction))); +} + +LUI_EXPORT int32_t lui_ocaml_stop(void) { + const value *dispose = caml_named_value("lui_ocaml_dispose"); + if (dispose == NULL) { + return 0; + } + return emit_patch(caml_callback_exn(*dispose, Val_unit)); +} + +LUI_EXPORT int64_t lui_ocaml_root_node(void) { + const value *root = caml_named_value("lui_ocaml_root_node"); + if (root == NULL) { + return 0; + } + value result = caml_callback_exn(*root, Val_unit); + if (Is_exception_result(result)) { + return 0; + } + return (int64_t)Long_val(result); +} + +/* ---- Journal-specific entries ---- */ + +/* Forwards a journal extension event: node id, extension event name, and a + JSON object of wire values decoded by the OCaml side. */ +LUI_EXPORT int32_t journal_ocaml_extension_event( + int64_t node, + const char *name, + const char *payload) { + const value *dispatch = caml_named_value("journal_ocaml_extension_event"); + if (dispatch == NULL) { + return 0; + } + return emit_patch(caml_callback3_exn( + *dispatch, + Val_long(node), + caml_copy_string(name), + caml_copy_string(payload))); +} + +/* Drains the cross-thread work queue on the app thread and flushes pending + patches. The host schedules this on the UI thread when the wakeup callback + fires. */ +LUI_EXPORT int32_t journal_ocaml_pump(void) { + const value *pump = caml_named_value("journal_ocaml_pump"); + if (pump == NULL) { + return 0; + } + return emit_patch(caml_callback_exn(*pump, Val_unit)); +} + +/* Host -> OCaml LJP2 envelopes (binary safe). */ +static void deliver_platform(const char *name, const char *data, + int32_t length) { + const value *handler = caml_named_value(name); + if (handler == NULL) { + return; + } + value payload = copy_bytes(data, length); + caml_callback_exn(*handler, payload); +} + +LUI_EXPORT void journal_ocaml_platform_event(const char *data, + int32_t length) { + deliver_platform("journal_ocaml_platform_event", data, length); +} + +LUI_EXPORT void journal_ocaml_platform_response(const char *data, + int32_t length) { + deliver_platform("journal_ocaml_platform_response", data, length); +} + +/* Host-installed callbacks for OCaml -> host delivery. */ +LUI_EXPORT void journal_ocaml_set_wakeup_callback( + journal_wakeup_callback callback) { + wakeup_callback = callback; +} + +LUI_EXPORT void journal_ocaml_set_platform_request_callback( + journal_platform_request_callback callback) { + platform_request_callback = callback; +} + +CAMLprim value journal_ml_wakeup(value unit) { + (void)unit; + if (wakeup_callback != NULL) { + wakeup_callback(); + } + return Val_unit; +} + +CAMLprim value journal_ml_platform_request(value payload) { + if (platform_request_callback != NULL) { + platform_request_callback( + (const char *)Bytes_val(payload), + (int32_t)caml_string_length(payload)); + } + return Val_unit; +} diff --git a/app/journal_lui_native.ml b/app/journal_lui_native.ml new file mode 100644 index 0000000..28990a6 --- /dev/null +++ b/app/journal_lui_native.ml @@ -0,0 +1,98 @@ +open Lui_protocol +open Lui_extension + +let chrome_identifier = "journal-chrome" +let asset_import_identifier = "journal-asset-import" +let media_identifier = "journal-media" +let asset_settings_identifier = "journal-asset-settings" +let list_identifier = "journal-list" + +let apple_profiles = + [ { profile_os = MacOS; profile_host = SwiftUIHost } + ; { profile_os = IOS; profile_host = SwiftUIHost } + ] + +let all_host_profiles = + apple_profiles + @ [ { profile_os = MacOS; profile_host = FlutterHost } + ; { profile_os = IOS; profile_host = FlutterHost } + ; { profile_os = AndroidOS; profile_host = FlutterHost } + ] + +let payload_property = + property "payload" StringScalar true None + +let event_schema = + event "event" + [ event_field "id" IntScalar true + ; event_field "payload" StringScalar true + ] + +let registry = + let registry = Lui_extension.registry () in + register_component registry + (component chrome_identifier apple_profiles true [] + [ payload_property ] []); + register_component registry + (component asset_import_identifier all_host_profiles false [] + [ payload_property ] [ event_schema ]); + register_component registry + (component media_identifier all_host_profiles false [] + [ payload_property ] [ event_schema ]); + register_component registry + (component asset_settings_identifier all_host_profiles true [] + [ payload_property ] [ event_schema ]); + register_component registry + (component list_identifier all_host_profiles false [] + [ payload_property ] [ event_schema ]); + freeze registry; + registry + +let mount ?key ~payload ~children identifier context parent = + let node = Lui_ui.extension context identifier in + Option.iter (Lui_ui.key context node) key; + Lui_ui.extension_property context node "payload" (StringValue payload); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter (fun child -> ignore (child context (Some node))) children; + node + +let chrome ?key ~payload children : Lui_elements.t = + fun context parent -> mount ?key ~payload ~children chrome_identifier context parent + +let asset_import ?key ~payload : Lui_elements.t = + fun context parent -> mount ?key ~payload ~children:[] asset_import_identifier context parent + +let media ?key ~payload : Lui_elements.t = + fun context parent -> mount ?key ~payload ~children:[] media_identifier context parent + +let asset_settings ?key ~payload children : Lui_elements.t = + fun context parent -> mount ?key ~payload ~children asset_settings_identifier context parent + +let list ?key ~payload : Lui_elements.t = + fun context parent -> mount ?key ~payload ~children:[] list_identifier context parent + +type event = + { identifier : string + ; node : int + ; event_id : int + ; payload : string + } + +let decode_event = function + | ExtensionEvent (node, identifier, name, values) + when String.equal name "event" + && (String.equal identifier chrome_identifier + || String.equal identifier asset_import_identifier + || String.equal identifier media_identifier + || String.equal identifier asset_settings_identifier + || String.equal identifier list_identifier) -> + (match + ( String_map.find_opt "id" values + , String_map.find_opt "payload" values ) + with + | Some (IntValue event_id), Some (StringValue payload) -> + Some { identifier; node; event_id; payload } + | _ -> None) + | _ -> None diff --git a/app/journal_lui_native.mli b/app/journal_lui_native.mli new file mode 100644 index 0000000..cb400e4 --- /dev/null +++ b/app/journal_lui_native.mli @@ -0,0 +1,46 @@ +(** Journal-specific lui extension components. + + Replaces the [Ui.Native_widget.Extension] registrations that previously + carried kinds 2103-2106 over the bonsai_swiftui native-widget channel. + Each component ships its properties as one [payload] string field holding + the same JSON object the Swift [Properties] structs already decode, and + reports events through one ["event"] extension event with [id] (int) and + [payload] (JSON string) fields, matching the old + [BonsaiNativeEvent(id, payload)] contract. *) + +(** Lui extension identifiers (slugs). *) +val chrome_identifier : string + +val asset_import_identifier : string +val media_identifier : string +val asset_settings_identifier : string +val list_identifier : string + +(** Extension schemas shared with the Apple/Flutter hosts. *) +val registry : Lui_extension.extension_registry + +(** Mount elements mirroring the old [Ui.Native_widget.widget] calls. + [payload] is the JSON-encoded properties object for that component + (the same JSON the previous [~encode_props] produced). *) +val chrome : ?key:string -> payload:string -> Lui_elements.t list -> Lui_elements.t + +val asset_import : ?key:string -> payload:string -> Lui_elements.t +val media : ?key:string -> payload:string -> Lui_elements.t +val asset_settings : ?key:string -> payload:string -> Lui_elements.t list -> Lui_elements.t + +(** Native virtualized collection (grouped sections, scroll positioning, + visible-range paging, swipe actions). Rows are described inside the + [payload] JSON rather than as children. *) +val list : ?key:string -> payload:string -> Lui_elements.t + +(** A journal extension event decoded from the lui event stream. *) +type event = + { identifier : string + ; node : int + ; event_id : int + ; payload : string + } + +(** Decodes a lui [ExtensionEvent] into a journal [event]; returns [None] for + events that are not journal extension events or are malformed. *) +val decode_event : Lui_protocol.event -> event option diff --git a/app/journal_pump.ml b/app/journal_pump.ml new file mode 100644 index 0000000..3a5bb66 --- /dev/null +++ b/app/journal_pump.ml @@ -0,0 +1,36 @@ +type t = + { mutex : Mutex.t + ; mutable queue : (unit -> unit) list + ; mutable wakeup : (unit -> unit) option + } + +let create () = { mutex = Mutex.create (); queue = []; wakeup = None } + +let enqueue t thunk = + Mutex.lock t.mutex; + t.queue <- thunk :: t.queue; + let wakeup = t.wakeup in + Mutex.unlock t.mutex; + match wakeup with + | Some wake -> wake () + | None -> () + +let set_wakeup t wakeup = + Mutex.lock t.mutex; + t.wakeup <- Some wakeup; + let pending = t.queue <> [] in + Mutex.unlock t.mutex; + if pending then wakeup () + +let drain t = + let rec loop () = + Mutex.lock t.mutex; + (match t.queue with + | [] -> Mutex.unlock t.mutex + | queue -> + t.queue <- []; + Mutex.unlock t.mutex; + List.iter (fun thunk -> thunk ()) (List.rev queue); + loop ()) + in + loop () diff --git a/app/journal_pump.mli b/app/journal_pump.mli new file mode 100644 index 0000000..ccb9527 --- /dev/null +++ b/app/journal_pump.mli @@ -0,0 +1,23 @@ +(** Cross-thread work queue between worker fibers and the lui app thread. + + ocaml-signal is single-threaded: [Lui_app.send]/[dispatch_event]/[flush] + must only run on the thread that owns the application. Worker fibers + (Eio, running on the worker domain) enqueue thunks here; the native + bridge invokes [drain] on the app thread via the C entry + [journal_ocaml_pump]. *) + +type t + +val create : unit -> t + +(** Enqueue a thunk from any thread. Signals the registered wakeup callback + (if any) so the host can schedule a pump on the app thread. *) +val enqueue : t -> (unit -> unit) -> unit + +(** Register the callback invoked (on the producing thread) whenever the + queue transitions to non-empty. The native bridge sets this to the C + wakeup trampoline that schedules [journal_ocaml_pump] on the UI thread. *) +val set_wakeup : t -> (unit -> unit) -> unit + +(** Runs all queued thunks in FIFO order. App thread only. *) +val drain : t -> unit diff --git a/app/native_embed.ml b/app/native_embed.ml index d68fed4..9be1fbc 100644 --- a/app/native_embed.ml +++ b/app/native_embed.ml @@ -1,5 +1 @@ -let () = - Native_backend.embed - ~name:(Bonsai_swiftui_spec.Id.Application.Entrypoint_name.of_string "logseq_journal") - Application.app -;; +let () = Journal_bridge.register Application.native_hooks diff --git a/docs/agent-guide/exploring/2026-09-23-replace-bonsai-ui-with-lui.md b/docs/agent-guide/exploring/2026-09-23-replace-bonsai-ui-with-lui.md new file mode 100644 index 0000000..d36d691 --- /dev/null +++ b/docs/agent-guide/exploring/2026-09-23-replace-bonsai-ui-with-lui.md @@ -0,0 +1,135 @@ +# Replace bonsai-ui with logseq/lui + +Status: in progress (branch `devin/lui-migration`) +Date: 2026-09-23 +Decision: replace the entire bonsai-ui stack (Jane Street `bonsai`, +`bonsai_swiftui` OCaml packages, `bonsai_flutter`, `bonsai-swiftui` host +toolchain) with `logseq/lui` (`lui` + `ocaml-signal` opam packages, +`LUIAppleBackend` Swift package, `lui_flutter_backend` Dart package). +Functionality and app logic stay unchanged; system presentation differences +are acceptable. + +## Why a rewrite, not a wrapper + +- bonsai is an incremental-computation graph (`Bonsai.Cont.state_machine0`, + `Bonsai.Effect`, `Cont.Clock.until`); lui is Elm-style + (`Lui_app.create backend model update view`, `update : 'model -> 'action -> 'model`). +- `bonsai_swiftui` renders `Ui.View` trees over a binary renderer protocol; + lui renders `Lui_elements.t` mount closures over a JSON patch protocol. +- Custom native components travel `Ui.Native_widget` (kind ids 2103-2106); + lui exposes them through `Lui_extension` + `LUIAppleExtensionRegistry` / + `LuiFlutterExtensionRegistry` with string identifiers and per-node props. +- The worker session RPC (`Worker.Service`, `Driver` pump) is bonsai-only; + it is ported in-repo as `journal_worker_*` modules. + +## Pinned dependencies + +| package | pin | +| --- | --- | +| `lui` | `git+https://github.com/logseq/lui.git#c4468ffdbb0e68319b90306933db7edb066b778b` | +| `ocaml-signal` | `git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be` | + +Both live in `logseq_journal.opam` `pin-depends`. `bonsai`, `bonsai_swiftui*`, +`incr_dom`, `virtual_dom` are removed; the `ocaml` bound moves from `= 5.1.1` +to `>= 5.4` (lui requirement). + +## Module map + +### `logseq_db_worker/lui/` (public lib `logseq_db_worker.lui`) + +- `journal_worker_ids` — replaces `Bonsai_swiftui_spec.Id` subsets used by the + worker (`Runtime.epoch`, `Worker.{generation,domain_id,request_id, + push_sequence,push_topic}`, `Application.entrypoint_name`) as private + int64/int/string wrappers. +- `journal_bounded_mailbox`, `journal_worker_eio_backend`, + `journal_worker_runtime`, `journal_worker` — mechanical port of + `bonsai-ui ocaml/runtime/{bounded_mailbox,worker_eio_backend, + worker_runtime,worker}.ml{,i}`. Deltas: `module ID = Bonsai_swiftui_spec.Id` + → `module ID = Journal_worker_ids`; `('response,'push) event -> unit + Bonsai.Effect.t` subscribers → `event -> unit`; `Private.drain_to_effects + ~schedule` → `Private.deliver ~max_events` (invokes subscribers on the app + thread, called from the lui pump entry). +- `logseq_db_worker_lui_service` — the former + `logseq_db_worker_bonsai_service`, same request/response/push types and + push topics (invalidation=0, manager=1, auth=2, bootstrap=3, graph_state=4, + asset=5), same `Service.create ~push_topic_count:6 + ~concurrency:(Concurrent{max_in_flight=2})`. Only the `Worker`/`ID` + module references change. + +### `app/` + +- `journal_lui_native` — lui extension registry + mount helpers. + Identifiers: `journal-chrome` (was kind 2103), `journal-asset-import` + (2104), `journal-media` (2105), `journal-asset-settings` (2106), + `journal-list` (former `Native_list` family). Every component ships one + required `payload` string property containing the same JSON the previous + `~encode_props` produced (so the Swift `Properties` Codable structs decode + unchanged), and emits one `"event"` extension event with `id:int` + + `payload:string` fields matching `BonsaiNativeEvent(id, payload)`. + `decode_event` maps `ExtensionEvent` back for the update dispatch. +- `journal_pump` — mutexed cross-thread work queue. Worker fibers enqueue + thunks; the app thread drains them inside the lui scheduler via the + `journal_ocaml_pump` C entry (ocaml-signal is single-threaded — no + `Lui_app.send` from worker threads). +- `journal_bridge` + `journal_lui_bridge.c` — native FFI. Mirrors + `lui/platform/native/lui_ocaml_bridge.c`: `lui_ocaml_start(patch_cb, + platform, host)` plus all `lui_ocaml_*` event entries, and journal extras: + `journal_ocaml_extension_event(node,name,payload_json)`, + `journal_ocaml_pump()`, `journal_ocaml_platform_event(data,len)` / + `journal_ocaml_platform_response(data,len)` (binary-safe LJP2 envelopes), + `journal_ocaml_set_wakeup_callback(cb)` and + `journal_ocaml_set_platform_request_callback(cb)` feeding the OCaml + `external`s `journal_ml_wakeup` / `journal_ml_platform_request`. +- `application.ml` — ported to `Lui_app`: the `state` record becomes the + model, `update` becomes `'model -> 'action -> 'model`, effects run inline + (worker `send`, platform requests, timers scheduled via `Journal_pump` + thunks), the view becomes `ui_context -> model signal -> send -> + Lui_elements.t` built with `create_with_extensions`. +- `native_embed.ml` — `Journal_bridge.register Application.native_hooks`. +- `journal_platform.ml` — unchanged LJP2 envelope codec; only the transport + binding changes (`Journal_bridge.platform_request` / + `platform_event` / `platform_response` instead of + `Driver.Handler.application_platform`). + +## Hosts + +### Apple (SwiftUI) + +`swift/` rewires to `LUIAppleBackend` + a `LUIAppleExtensionRegistry` holding +one `LUIAppleExtension` per journal identifier. The host links +`journal_lui_bridge.o` + the `-output-complete-obj` OCaml archive (same +shape as `lui/examples/components/ios-swiftui` + +`tooling/mobile/build_components_ios_simulator.sh`), declares the +`lui_ocaml_*`/`journal_ocaml_*` C exports, installs the patch callback into +`LUIAppleBackend.apply(json:)`, forwards backend `onEvent`s into the C +entries (kind codes as before, extension events via +`journal_ocaml_extension_event`), and binds the wakeup callback to +`DispatchQueue.main.async { journal_ocaml_pump() }`. +`JournalApplicationPlatform` keeps its LJP2 request/response semantics: +OCaml `platform_request` callback → async `services.response` → +`journal_ocaml_platform_response`; host pushes → +`journal_ocaml_platform_event`. Amplify (`amplify-swift` 2.61.0), +entitlements, bundle ids, and minimum versions move from +`bonsai-swiftui.sexp` into the new host configuration. + +### Flutter + +`flutter/` replaces `bonsai_flutter`/`bonsai_flutter_native` with +`lui_flutter_backend` (+ its native hook), rewiring the Dart host to the +same C exports and registering the journal extensions in +`LuiFlutterExtensionRegistry`. + +## Known semantic adaptations + +- `Ui.Event.Payload.text_edit` (session ids, revisions, selection, IME + composing) has no lui equivalent: `TextChanged (node, text)` carries the + full text only. `Journal_capture`/editor state simplifies to plain string + edits. +- `Cont.Clock.until` timers (sync-error card lifetime, notification + deadlines) become model fields + `Journal_pump`-scheduled actions. +- `Bonsai.Effect` return values (worker sends, platform requests, external + URL opens) run inline inside `update`. +- `with_test_id` → `accessibility_identifier`; `V.help` → `tooltip`; + `V.progress ~style:Circular` → `spinner`; `V.secure_field` → + `secure-field`; `V.text_editor`/`Text_editing.Value` → `text-field` / + `textarea` (no selection/composing props). diff --git a/dune-project b/dune-project index 9fb5a59..040b4f7 100644 --- a/dune-project +++ b/dune-project @@ -8,14 +8,14 @@ (name logseq_db_types) (allow_empty) (depends - (ocaml (= 5.1.1)) + (ocaml (>= 5.4)) (dune (= 3.23.1)))) (package (name logseq_db_storage) (allow_empty) (depends - (ocaml (= 5.1.1)) + (ocaml (>= 5.4)) (dune (= 3.23.1)) (logseq_db_types (= 0.1.0)) (datascript_ocaml (= dev)) @@ -30,7 +30,7 @@ (allow_empty) (depends (rrbvec (= dev)) - (ocaml (= 5.1.1)) + (ocaml (>= 5.4)) (dune (= 3.23.1)) (logseq_db_types (= 0.1.0)) (logseq_db_storage (= 0.1.0)) @@ -51,7 +51,7 @@ (name logseq_sync) (allow_empty) (depends - (ocaml (= 5.1.1)) + (ocaml (>= 5.4)) (dune (= 3.23.1)) (alcotest (and :with-test (= 1.7.0))) (logseq_db_types (= 0.1.0)) @@ -79,19 +79,17 @@ (allow_empty) (depends (rrbvec (= dev)) - (ocaml (= 5.1.1)) + (ocaml (>= 5.4)) (dune (= 3.23.1)) (logseq_db_worker (= 0.1.0)) (logseq_db_types (= 0.1.0)) (logseq_sync (= 0.1.0)) - (bonsai_swiftui (= 0.1.0~dev)) - (bonsai_swiftui_test (= 0.1.0~dev)) + (lui (= 0.1.0)) + (ocaml-signal (= 0.1.0)) (base (= v0.17.3)) - (bonsai (= v0.17.0)) (core (= v0.17.2)) (datascript_ocaml (= dev)) (datascript-ocaml-native (= dev)) - (incr_dom (= v0.17.0)) (melange-edn-core (= 0.5.0)) (melange-edn-native (= 0.5.0)) (melange-transit-core (= 0.1.2)) @@ -100,23 +98,19 @@ (sqlite3 (= 5.4.0)) (uucp (= 17.0.0)) (uunf (= 17.0.0)) - (uutf (= 1.0.4)) - (virtual_dom (= v0.17.0)))) + (uutf (= 1.0.4)))) (package (name logseq_db_worker) (allow_empty) (depends (rrbvec (= dev)) - (ocaml (= 5.1.1)) + (ocaml (>= 5.4)) (dune (= 3.23.1)) (logseq_db_types (= 0.1.0)) (logseq_overlay_db (= 0.1.0)) (logseq_sync (= 0.1.0)) (base (= v0.17.3)) - (bonsai (= v0.17.0)) - (bonsai_swiftui (= 0.1.0~dev)) - (bonsai_swiftui_test (= 0.1.0~dev)) (core (= v0.17.2)) (eio (= 1.2)) (melange-transit-native (= 0.1.2)) diff --git a/logseq_db_storage.opam b/logseq_db_storage.opam index 01efbb5..885c681 100644 --- a/logseq_db_storage.opam +++ b/logseq_db_storage.opam @@ -8,7 +8,7 @@ license: "MIT" homepage: "https://github.com/RCmerci/logseq_journal" bug-reports: "https://github.com/RCmerci/logseq_journal/issues" depends: [ - "ocaml" {= "5.1.1"} + "ocaml" {>= "5.4"} "dune" {= "3.23.1"} "logseq_db_types" {= "0.1.0"} "datascript_ocaml" {= "dev"} diff --git a/logseq_db_types.opam b/logseq_db_types.opam index ffbd4f4..bd93cda 100644 --- a/logseq_db_types.opam +++ b/logseq_db_types.opam @@ -8,7 +8,7 @@ license: "MIT" homepage: "https://github.com/RCmerci/logseq_journal" bug-reports: "https://github.com/RCmerci/logseq_journal/issues" depends: [ - "ocaml" {= "5.1.1"} + "ocaml" {>= "5.4"} "dune" {= "3.23.1"} ] build: [["dune" "build" "-p" name "-j" jobs]] diff --git a/logseq_db_worker.opam b/logseq_db_worker.opam index b5fe186..219fc6f 100644 --- a/logseq_db_worker.opam +++ b/logseq_db_worker.opam @@ -9,15 +9,12 @@ homepage: "https://github.com/RCmerci/logseq_journal" bug-reports: "https://github.com/RCmerci/logseq_journal/issues" depends: [ "rrbvec" {= "dev"} - "ocaml" {= "5.1.1"} + "ocaml" {>= "5.4"} "dune" {= "3.23.1"} "logseq_db_types" {= "0.1.0"} "logseq_overlay_db" {= "0.1.0"} "logseq_sync" {= "0.1.0"} "base" {= "v0.17.3"} - "bonsai" {= "v0.17.0"} - "bonsai_swiftui" {= "0.1.0~dev"} - "bonsai_swiftui_test" {with-test & = "0.1.0~dev"} "core" {= "v0.17.2"} "eio" {= "1.2"} "mtime" {= "2.1.0"} @@ -33,8 +30,6 @@ pin-depends: [ ["melange-edn-native.0.5.0" "git+https://github.com/RCmerci/melange-edn.git#3cb79f278e972388a0a2b2ea1caec7a008a0b956"] ["melange-transit-core.0.1.2" "git+https://github.com/RCmerci/melange-transit.git#35f8afe7d6506863c7253e67a20befb3dde5c18f"] ["melange-transit-native.0.1.2" "git+https://github.com/RCmerci/melange-transit.git#35f8afe7d6506863c7253e67a20befb3dde5c18f"] - ["bonsai_swiftui.0.1.0~dev" "git+https://github.com/logseq/bonsai-ui.git#9e51259c4adf7eaf8b3595b98a674f1174662af6"] - ["bonsai_swiftui_test.0.1.0~dev" "git+https://github.com/logseq/bonsai-ui.git#9e51259c4adf7eaf8b3595b98a674f1174662af6"] ] build: [ ["dune" "build" "-p" name "-j" jobs] diff --git a/logseq_db_worker/lui/dune b/logseq_db_worker/lui/dune new file mode 100644 index 0000000..a4c8a12 --- /dev/null +++ b/logseq_db_worker/lui/dune @@ -0,0 +1,26 @@ +(library + (name logseq_db_worker_lui) + (public_name logseq_db_worker.lui) + (modules + journal_worker_ids + journal_bounded_mailbox + journal_worker_eio_backend + journal_worker_runtime + journal_worker) + (libraries + eio + eio.core + eio.unix + eio_posix + logseq_db_worker + logseq_db_types + logseq_overlay_db + logseq_sync.effect_runner + logseq_sync.pure_reducer + melange-transit-native + mtime.clock + mtime.clock.os + threads + unix + uri + yojson)) diff --git a/logseq_db_worker/lui/journal_bounded_mailbox.mli b/logseq_db_worker/lui/journal_bounded_mailbox.mli new file mode 100644 index 0000000..0eed3dd --- /dev/null +++ b/logseq_db_worker/lui/journal_bounded_mailbox.mli @@ -0,0 +1,46 @@ +(** Bounded synchronization primitives for the singleton Worker Domain. *) + +module Fifo : sig + type 'a t + + val create : capacity:int -> 'a t + val try_push : 'a t -> 'a -> [ `Ok | `Full | `Closed ] + val pop : 'a t -> 'a option + val length : 'a t -> int + + (** Blocks on a condition until an item is available or the mailbox is both + closed and empty. *) + val wait_pop : 'a t -> 'a option + + val drain : 'a t -> max_items:int -> 'a list + val close : 'a t -> unit +end + +module Reserved : sig + type 'a t + + val create : capacity:int -> 'a t + + (** Reserves capacity before a corresponding request is accepted. *) + val reserve : 'a t -> bool + + val cancel : 'a t -> unit + + (** Publishes one response against an existing reservation. *) + val publish : 'a t -> 'a -> unit + + val pop : 'a t -> 'a option + val drain : 'a t -> max_items:int -> 'a list +end + +module Coalesced : sig + type 'a t + + val create : capacity:int -> 'a t + + (** Stores the latest value for [topic]. A new topic is rejected when all + topic slots are occupied, while an existing topic is always replaced. *) + val push : 'a t -> topic:int -> 'a -> [ `Added | `Replaced | `Full ] + + val drain : 'a t -> max_items:int -> (int * 'a) list +end diff --git a/logseq_db_worker/lui/journal_worker.mli b/logseq_db_worker/lui/journal_worker.mli new file mode 100644 index 0000000..cae2fce --- /dev/null +++ b/logseq_db_worker/lui/journal_worker.mli @@ -0,0 +1,189 @@ +(** Typed domain-0 client and Worker Domain service contract. *) + +type mono_clock = Eio.Time.Mono.ty Eio.Resource.t +type net = [ `Generic ] Eio.Net.ty Eio.Resource.t +type data_dir = Eio.Fs.dir_ty Eio.Path.t +type environment = Journal_worker_eio_backend.environment + +module Session_context : sig + type 'push t + + val switch : 'push t -> Eio.Switch.t + val environment : 'push t -> environment + val clock : 'push t -> mono_clock + val net : 'push t -> net + val data_dir : 'push t -> data_dir option + val emit : 'push t -> topic:Journal_worker_ids.Worker.push_topic -> 'push -> unit + val fork_daemon : 'push t -> name:string -> (unit -> unit) -> unit +end + +module Request_context : sig + type 'push t + + val request_id : 'push t -> Journal_worker_ids.Worker.request_id + val switch : 'push t -> Eio.Switch.t + val environment : 'push t -> environment + val clock : 'push t -> mono_clock + val net : 'push t -> net + val data_dir : 'push t -> data_dir option + val emit : 'push t -> topic:Journal_worker_ids.Worker.push_topic -> 'push -> unit +end + +type 'response outcome = + | Completed of 'response + | Failed of string + | Cancelled + | Shutdown + +type ('response, 'push) event = + | Response of + { runtime_epoch : Journal_worker_ids.Runtime.epoch + ; worker_generation : Journal_worker_ids.Worker.generation + ; request_id : Journal_worker_ids.Worker.request_id + ; outcome : 'response outcome + } + | Push of + { runtime_epoch : Journal_worker_ids.Runtime.epoch + ; worker_generation : Journal_worker_ids.Worker.generation + ; push_sequence : Journal_worker_ids.Worker.push_sequence + ; topic : Journal_worker_ids.Worker.push_topic + ; payload : 'push + } + | Terminal of + { runtime_epoch : Journal_worker_ids.Runtime.epoch + ; worker_generation : Journal_worker_ids.Worker.generation + ; error : string + } + +type send_result = + | Accepted of Journal_worker_ids.Worker.request_id + | Full + | Not_ready + | Stopping + +type ('request, 'response, 'push) client + +module Service : sig + type concurrency = + | Serial + | Concurrent of { max_in_flight : int } + + type ('config, 'request, 'response, 'push) t + + val create + : push_topic_count:int + -> concurrency:concurrency + -> ?data_directory:('config -> (string, string) result) + -> init:('push Session_context.t -> 'config -> ('state, string) result) + -> handle: + ('push Request_context.t -> 'state -> 'request -> ('response, string) result) + -> shutdown:('state -> unit) + -> unit + -> ('config, 'request, 'response, 'push) t +end + +(** Non-blocking domain-0 request enqueue. *) +val send : ('request, 'response, 'push) client -> 'request -> send_result + +(** Requests cooperative cancellation without entering the bounded request + lane. *) +val cancel + : ('request, 'response, 'push) client + -> request_id:Journal_worker_ids.Worker.request_id + -> unit + +(** Registers a domain-0 event handler. The handler is invoked only by a + later accepted drain on the application thread. *) +val on_event + : ('request, 'response, 'push) client + -> (('response, 'push) event -> unit) + -> unit + +val runtime_epoch + : ('request, 'response, 'push) client + -> Journal_worker_ids.Runtime.epoch + +val worker_generation + : ('request, 'response, 'push) client + -> Journal_worker_ids.Worker.generation + +module Private : sig + type packed_startup + type packed_client + + type metrics = + { configured_concurrency_limit : int + ; queued_requests : int + ; active_request_fibers : int + ; waiting_request_fibers : int + ; active_handlers : int + ; peak_active_handlers : int + ; active_background_fibers : int + ; peak_active_background_fibers : int + ; request_queue_wait_count : int + ; max_request_queue_wait_ns : int64 + ; handler_wall_count : int + ; max_handler_wall_ns : int64 + ; cancellation_unwind_count : int + ; max_cancellation_unwind_ns : int64 + ; session_cancellation_duration_ns : int64 option + ; shutdown_duration_ns : int64 option + } + + type run_result = + | Session_stopped + | Session_startup_failed of string + | Session_callback_failed of string + + val prepare + : runtime_epoch:Journal_worker_ids.Runtime.epoch + -> worker_generation:Journal_worker_ids.Worker.generation + -> ('config, 'request, 'response, 'push) Service.t + -> 'config + -> ('request, 'response, 'push) client * packed_startup + + val run_session + : packed_startup + -> environment:Journal_worker_eio_backend.environment + -> session_switch:Eio.Switch.t + -> on_startup:((unit, string) result -> unit) + -> on_idle_wait:(unit -> unit) + -> on_yield:(unit -> unit) + -> run_result + + val pack_client : ('request, 'response, 'push) client -> packed_client + val metrics : packed_client -> metrics + val request_stop : ('request, 'response, 'push) client -> unit + val request_stop_packed : packed_client -> unit + val await_stopped : ('request, 'response, 'push) client -> unit + val await_stopped_packed : packed_client -> unit + val fail_unrecoverable : packed_client -> string -> unit + + (** Drains pending events and invokes every registered subscriber for each, + in drain order. Must be called on the application thread (the lui pump + entry point), never from worker fibers. *) + val deliver + : ('request, 'response, 'push) client + -> max_events:int + -> unit +end + +module For_testing : sig + val drain_events + : ('request, 'response, 'push) client + -> max_events:int + -> ('response, 'push) event list + + val await_output : ('request, 'response, 'push) client -> unit + val pending_output_count : ('request, 'response, 'push) client -> int + val is_stopping : ('request, 'response, 'push) client -> bool + + val inject_push + : ('request, 'response, 'push) client + -> runtime_epoch:Journal_worker_ids.Runtime.epoch + -> worker_generation:Journal_worker_ids.Worker.generation + -> push_sequence:Journal_worker_ids.Worker.push_sequence + -> topic:Journal_worker_ids.Worker.push_topic + -> 'push + -> unit +end diff --git a/logseq_db_worker/lui/journal_worker_eio_backend.mli b/logseq_db_worker/lui/journal_worker_eio_backend.mli new file mode 100644 index 0000000..a6cbbb8 --- /dev/null +++ b/logseq_db_worker/lui/journal_worker_eio_backend.mli @@ -0,0 +1,24 @@ +(** Eio backend selected for the native Worker Domain. *) + +type environment = + < stdin : Eio_unix.source_ty Eio.Resource.t + ; stdout : Eio_unix.sink_ty Eio.Resource.t + ; stderr : Eio_unix.sink_ty Eio.Resource.t + ; net : [ `Unix | `Generic ] Eio.Net.ty Eio.Resource.t + ; domain_mgr : Eio.Domain_manager.ty Eio.Resource.t + ; clock : float Eio.Time.clock_ty Eio.Resource.t + ; mono_clock : Eio.Time.Mono.ty Eio.Resource.t + ; fs : Eio.Fs.dir_ty Eio.Path.t + ; cwd : Eio.Fs.dir_ty Eio.Path.t + ; secure_random : Eio.Flow.source_ty Eio.Resource.t + ; debug : Eio.Debug.t + ; backend_id : string > + +(** Run on the dedicated Worker Domain thread. The thread keeps SIGPIPE blocked + for its lifetime; the host's signal dispositions are not replaced. *) +val run : (environment -> 'a) -> 'a + +val stdenv : environment -> environment +val mono_clock : environment -> Eio.Time.Mono.ty Eio.Resource.t +val net : environment -> [ `Generic ] Eio.Net.ty Eio.Resource.t +val fs : environment -> Eio.Fs.dir_ty Eio.Path.t diff --git a/logseq_db_worker/lui/journal_worker_ids.ml b/logseq_db_worker/lui/journal_worker_ids.ml new file mode 100644 index 0000000..94663aa --- /dev/null +++ b/logseq_db_worker/lui/journal_worker_ids.ml @@ -0,0 +1,70 @@ +module Make_int64_id () = struct + type t = int64 + + let of_int64 x = x + let to_int64 x = x + let compare = Int64.compare + let equal = Int64.equal + let succ = Int64.succ + let pred = Int64.pred + let zero = 0L + let one = 1L + let max_value = Int64.max_int +end + +module Make_int_id () = struct + type t = int + + let of_int x = x + let to_int x = x + let compare = Int.compare + let equal = Int.equal +end + +module Make_string_id () = struct + type t = string + + let of_string x = x + let to_string x = x + let compare = String.compare + let equal = String.equal +end + +module Runtime = struct + type epoch = int64 + + module Epoch = Make_int64_id () +end + +module Worker = struct + type generation = int64 + + module Generation = Make_int64_id () + + type domain_id = Domain.id + + module Domain_id = struct + type t = domain_id + + let of_domain_id x = x + let to_domain_id x = x + end + + type request_id = int64 + + module Request_id = Make_int64_id () + + type push_sequence = int64 + + module Push_sequence = Make_int64_id () + + type push_topic = int + + module Push_topic = Make_int_id () +end + +module Application = struct + type entrypoint_name = string + + module Entrypoint_name = Make_string_id () +end diff --git a/logseq_db_worker/lui/journal_worker_ids.mli b/logseq_db_worker/lui/journal_worker_ids.mli new file mode 100644 index 0000000..43c150e --- /dev/null +++ b/logseq_db_worker/lui/journal_worker_ids.mli @@ -0,0 +1,86 @@ +(** Identity types at the worker-session and native-entrypoint boundaries. + + Replaces the subset of [Bonsai_swiftui_spec.Id] used by the journal worker + runtime and the lui-based app layer. All values are private wrappers over + plain scalars so they can cross the C ABI and the lui protocol directly. *) + +module type Int64_id = sig + type t = private int64 + + val of_int64 : int64 -> t + val to_int64 : t -> int64 + val compare : t -> t -> int + val equal : t -> t -> bool + val succ : t -> t + val pred : t -> t + val zero : t + val one : t + val max_value : t +end + +module type Int_id = sig + type t = private int + + val of_int : int -> t + val to_int : t -> int + val compare : t -> t -> int + val equal : t -> t -> bool +end + +module type String_id = sig + type t = private string + + val of_string : string -> t + val to_string : t -> string + val compare : t -> t -> int + val equal : t -> t -> bool +end + +(** Runtime lifecycle identity fencing worker messages to one runtime + lifetime. *) +module Runtime : sig + type epoch = private int64 + + module Epoch : Int64_id with type t = epoch +end + +(** Worker Domain and attached worker-session identities. *) +module Worker : sig + (** Identity of one session attached to the process-wide Worker Domain. *) + type generation = private int64 + + module Generation : Int64_id with type t = generation + + (** Diagnostic identity of the process-wide OCaml Worker Domain. *) + type domain_id = private Domain.id + + module Domain_id : sig + type t = domain_id + + val of_domain_id : Domain.id -> t + val to_domain_id : t -> Domain.id + end + + (** Correlation identity for one worker-session request. *) + type request_id = private int64 + + module Request_id : Int64_id with type t = request_id + + (** Monotonic ordering identity for one worker push. *) + type push_sequence = private int64 + + module Push_sequence : Int64_id with type t = push_sequence + + (** Latest-wins mailbox topic identity declared by a worker service. *) + type push_topic = private int + + module Push_topic : Int_id with type t = push_topic +end + +(** Native application registry identities. *) +module Application : sig + (** Stable application identity at the native entrypoint boundary. *) + type entrypoint_name = private string + + module Entrypoint_name : String_id with type t = entrypoint_name +end diff --git a/logseq_db_worker/lui/journal_worker_runtime.mli b/logseq_db_worker/lui/journal_worker_runtime.mli new file mode 100644 index 0000000..18b6350 --- /dev/null +++ b/logseq_db_worker/lui/journal_worker_runtime.mli @@ -0,0 +1,67 @@ +(** Process-wide singleton OCaml Worker Domain lifecycle. *) + +type state = + | Not_started + | Idle + | Attached + | Stopping + | Stopped + | Terminal + +type diagnostics = + { state : state + ; spawn_count : int + ; join_count : int + ; worker_domain_id : Journal_worker_ids.Worker.domain_id option + ; active_sessions : int + ; peak_active_sessions : int + ; idle_wait_count : int + ; backend_run_count : int + ; backend_running : bool + ; coordinator_start_count : int + ; active_coordinators : int + ; peak_active_coordinators : int + ; coordinator_yield_count : int + ; configured_concurrency_limit : int option + ; queued_requests : int + ; active_request_fibers : int + ; waiting_request_fibers : int + ; active_handlers : int + ; peak_active_handlers : int + ; active_background_fibers : int + ; peak_active_background_fibers : int + ; logical_live_fibers : int + ; request_queue_wait_count : int + ; max_request_queue_wait_ns : int64 + ; handler_wall_count : int + ; max_handler_wall_ns : int64 + ; cancellation_unwind_count : int + ; max_cancellation_unwind_ns : int64 + ; session_cancellation_duration_ns : int64 option + ; shutdown_duration_ns : int64 option + ; backend_identity : string + ; backend_version : string + } + +val start + : runtime_epoch:Journal_worker_ids.Runtime.epoch + -> ('config, 'request, 'response, 'push) Journal_worker.Service.t + -> 'config + -> (('request, 'response, 'push) Journal_worker.client, string) result + +(** Cooperatively removes one attached session. This never joins the + process-wide Worker Domain. *) +val stop : ('request, 'response, 'push) Journal_worker.client -> unit + +module For_testing : sig + val diagnostics : unit -> diagnostics + val await_state : state -> unit + val await_idle_wait_count : int -> unit + + (** Stops and joins the process-wide Worker Domain exactly once when a + Domain was successfully spawned. *) + val final_shutdown : unit -> unit + + val crash_worker_loop : unit -> unit + val fail_next_spawn : exn -> unit +end diff --git a/logseq_db_worker/lui/logseq_db_worker_lui_service.mli b/logseq_db_worker/lui/logseq_db_worker_lui_service.mli new file mode 100644 index 0000000..54d9674 --- /dev/null +++ b/logseq_db_worker/lui/logseq_db_worker_lui_service.mli @@ -0,0 +1,212 @@ +type graph_id = Logseq_db_types.Graph_types.Uuid.t +type graph = Logseq_db_types.Managed_graph.t + +type sync_phase = + | Offline + | Connecting + | Pulling + | Submitting + | Current + | Paused + | Failed + +type startup_failure_stage = + | During_authentication + | During_catalog + | During_local_restore + | During_bootstrap + | During_e2ee + +type startup_facts = + { authenticated : bool + ; catalog_loading : bool + ; awaiting_selection : bool + ; restoring_local : bool + ; bootstrapping : bool + ; awaiting_e2ee_password : bool + ; failure : startup_failure_stage option + ; account_generation : int + ; graph_generation : int + ; presentation_generation : int + } + +type local_deletion_stage = Logseq_sync_pure_reducer.Core.local_deletion_stage = + | Closing_graph + | Deleting_mirror + | Clearing_selection + +type local_deletion = Logseq_sync_pure_reducer.Core.local_deletion = + | Deletion_in_progress of local_deletion_stage + | Deletion_failed of local_deletion_stage + +type snapshot = + { sync_phase : sync_phase + ; catalog : graph list + ; selected_graph : graph_id option + ; applied_server_t : int option + ; timeline_presentation_pending : bool + ; startup : startup_facts + ; last_error : string option + ; local_deletion : local_deletion option + } + +type diagnostic_group = + { title : string + ; entries : (string * string) list + } + +type diagnostics = { groups : diagnostic_group list } + +type state = + { snapshot : snapshot + ; diagnostics : diagnostics + } + +type token_request + +val token_request_id : token_request -> string + +type bootstrap_progress = + { graph_id : graph_id + ; received_bytes : int64 + ; total_bytes : int64 option + } + +type client_command = + | Restore_local_account of { user_id : string } + | Reconcile_authenticated_user of { user_id : string option } + | Acknowledge_local_feed + | Acknowledge_timeline_presented + | Provide_token of + { request : token_request + ; token : string + } + | Reject_token of token_request + | Select_graph of graph_id + | Return_to_graph_picker + | Refresh_catalog + | Begin_online_recovery + | Submit_e2ee_password of string + | Delete_local_cache of graph_id + | Set_foreground of bool + +module Asset : sig + type priority = Logseq_sync_pure_reducer.Asset_transfer.priority = + | Foreground + | Background + + type failure = Logseq_sync_pure_reducer.Asset_transfer.failure = + | Network + | Not_found + | Checksum_mismatch + | Authentication + | Locked + | Storage_full + | Invalid_content of string + + type availability = Logseq_sync_pure_reducer.Asset_transfer.availability = + | Queued + | Downloading + | Ready of string + | Waiting_remote + | Waiting_network + | Waiting_unlock + | Failed of + { failure : failure + ; attempts : int + ; retry_scheduled : bool + } +end + +type asset_scope = Logseq_sync_pure_reducer.Core.graph_scope + +type asset_notice = Logseq_db_worker_pure_reducer.Core.asset_notice = + | Asset_availability of + { consumer : string + ; asset : Logseq_db_types.Graph_types.Uuid.t + ; availability : Logseq_sync_pure_reducer.Asset_transfer.availability + } + | Asset_demand_accepted of string + | Asset_backpressure of string + | Asset_capacity_available + | Upload_status of + { operation : Logseq_db_types.Graph_types.Uuid.t + ; asset : Logseq_db_types.Graph_types.Uuid.t + ; target : Logseq_db_types.Graph_types.Uuid.t + ; title : string + ; status : Logseq_db_worker_pure_reducer.Asset_upload.status + } + +type asset_command = + | Replace_asset_demand of + { consumer : string + ; priority : Logseq_sync_pure_reducer.Asset_transfer.priority + ; assets : Logseq_db_types.Asset_descriptor.t list + } + | Release_asset_demand of string + | Retry_asset of Logseq_db_types.Graph_types.Uuid.t + | Retry_upload of Logseq_db_types.Graph_types.Uuid.t + +type request = + | Import_asset of + { graph_generation : int + ; source : Logseq_db_types.Asset_import.t + } + | Client_command of client_command + | Graph_request of Logseq_db_worker.Protocol.request + | Get_graph_state + | Asset_command of + { graph_generation : int + ; command : asset_command + } + | Acquire_imported_file of + { scope : Logseq_sync_pure_reducer.Core.graph_scope + ; operation : Logseq_db_types.Graph_types.Uuid.t + } + | Acquire_asset_file of + { scope : Logseq_sync_pure_reducer.Core.graph_scope + ; handle : string + } + | Release_asset_file of + { scope : Logseq_sync_pure_reducer.Core.graph_scope + ; handle : string + } + +type response = + | Asset_imported of (Logseq_db_worker.import_receipt, string) result + | Client_command_completed + | Asset_file of (string * string) option + | Graph_response of Logseq_db_worker.Protocol.response + | Graph_state of Logseq_db_worker.graph_state + +type push = + | Graph_push of Logseq_db_worker.Protocol.push + | Client_state_changed of state + | Need_id_token of token_request + | Bootstrap_progress of bootstrap_progress + | Graph_state_changed of Logseq_db_worker.graph_state + | Asset_notice of + Logseq_sync_pure_reducer.Core.graph_scope + * Logseq_db_worker_pure_reducer.Core.asset_notice + +val invalidation_topic : Journal_worker_ids.Worker.Push_topic.t +val manager_topic : Journal_worker_ids.Worker.Push_topic.t +val auth_topic : Journal_worker_ids.Worker.Push_topic.t +val bootstrap_topic : Journal_worker_ids.Worker.Push_topic.t +val graph_state_topic : Journal_worker_ids.Worker.Push_topic.t +val asset_topic : Journal_worker_ids.Worker.Push_topic.t + +type dependencies + +val dependencies + : overlay:Logseq_overlay_db.Database.dependencies + -> tls_authenticator:Logseq_sync_effect_runner.Effect_runner.tls_authenticator + -> secrets:Logseq_sync_effect_runner.Effect_runner.secrets + -> crypto:Logseq_sync_effect_runner.Effect_runner.crypto + -> dependencies + +val create + : dependencies:dependencies + -> (Logseq_db_worker.Config.t, request, response, push) Journal_worker.Service.t + +val service : (Logseq_db_worker.Config.t, request, response, push) Journal_worker.Service.t diff --git a/logseq_journal.opam b/logseq_journal.opam index 4a617e0..3bd8692 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -1,7 +1,7 @@ opam-version: "2.0" name: "logseq_journal" version: "0.1.0" -synopsis: "Logseq Journal Bonsai SwiftUI application" +synopsis: "Logseq Journal LUI application" maintainer: "application authors" authors: ["application authors"] license: "MIT" @@ -9,22 +9,20 @@ homepage: "https://github.com/RCmerci/logseq_journal" bug-reports: "https://github.com/RCmerci/logseq_journal/issues" depends: [ "rrbvec" {= "dev"} - "ocaml" {= "5.1.1"} + "ocaml" {>= "5.4"} "dune" {= "3.23.1"} "logseq_db_worker" {= "0.1.0"} "logseq_db_types" {= "0.1.0"} "logseq_sync" {= "0.1.0"} - "bonsai_swiftui" {= "0.1.0~dev"} - "bonsai_swiftui_test" {with-test & = "0.1.0~dev"} + "lui" {= "0.1.0"} + "ocaml-signal" {= "0.1.0"} "base" {= "v0.17.3"} "bigstringaf" {= "0.10.0"} - "bonsai" {= "v0.17.0"} "core" {= "v0.17.2"} "ca-certs-nss" {= "3.126"} "cstruct" {= "6.2.0"} "datascript_ocaml" {= "dev"} "datascript-ocaml-native" {= "dev"} - "incr_dom" {= "v0.17.0"} "digestif" {= "1.3.1"} "domain-name" {= "0.5.0"} "eio" {= "1.2"} @@ -45,12 +43,11 @@ depends: [ "uucp" {= "17.0.0"} "uunf" {= "17.0.0"} "uutf" {= "1.0.4"} - "virtual_dom" {= "v0.17.0"} ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] - ["bonsai_swiftui.0.1.0~dev" "git+https://github.com/logseq/bonsai-ui.git#9e51259c4adf7eaf8b3595b98a674f1174662af6"] - ["bonsai_swiftui_test.0.1.0~dev" "git+https://github.com/logseq/bonsai-ui.git#9e51259c4adf7eaf8b3595b98a674f1174662af6"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#c4468ffdbb0e68319b90306933db7edb066b778b"] + ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["persistent_sorted_set_ocaml.dev" "git+https://github.com/logseq/persistent-sorted-set-ocaml.git#4016dae1cdf4304207d8277ff9957656cdd8b177"] diff --git a/logseq_overlay_db.opam b/logseq_overlay_db.opam index bb8b7ff..644c9f0 100644 --- a/logseq_overlay_db.opam +++ b/logseq_overlay_db.opam @@ -9,7 +9,7 @@ homepage: "https://github.com/RCmerci/logseq_journal" bug-reports: "https://github.com/RCmerci/logseq_journal/issues" depends: [ "rrbvec" {= "dev"} - "ocaml" {= "5.1.1"} + "ocaml" {>= "5.4"} "dune" {= "3.23.1"} "logseq_db_types" {= "0.1.0"} "logseq_db_storage" {= "0.1.0"} diff --git a/logseq_sync.opam b/logseq_sync.opam index 6fd6ecd..1ec6baa 100644 --- a/logseq_sync.opam +++ b/logseq_sync.opam @@ -8,7 +8,7 @@ license: "MIT" homepage: "https://github.com/RCmerci/logseq_journal" bug-reports: "https://github.com/RCmerci/logseq_journal/issues" depends: [ - "ocaml" {= "5.1.1"} + "ocaml" {>= "5.4"} "dune" {= "3.23.1"} "alcotest" {with-test & = "1.7.0"} "logseq_db_types" {= "0.1.0"} From 09a33528f40d11f6d661f6b017e37f7622f20ba8 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 18:52:41 -0700 Subject: [PATCH 02/40] Port worker-session runtime to lui-compatible journal_worker_* modules Mechanical port of bonsai-ui ocaml/runtime worker stack: - journal_bounded_mailbox / journal_worker_eio_backend: verbatim copies - journal_worker_runtime: Worker.Private -> Journal_worker.Private, Worker_eio_backend -> Journal_worker_eio_backend, ID = Journal_worker_ids - journal_worker: same renames; subscribers are plain event -> unit callbacks; drain_to_effects ~schedule -> deliver (invokes subscribers directly on the app thread) - journal_worker_ids: add Int64_id/Int_id/String_id module types required by the existing .mli Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../lui/journal_bounded_mailbox.ml | 184 +++ logseq_db_worker/lui/journal_worker.ml | 1147 +++++++++++++++++ .../lui/journal_worker_eio_backend.ml | 65 + logseq_db_worker/lui/journal_worker_ids.ml | 32 + .../lui/journal_worker_runtime.ml | 506 ++++++++ 5 files changed, 1934 insertions(+) create mode 100644 logseq_db_worker/lui/journal_bounded_mailbox.ml create mode 100644 logseq_db_worker/lui/journal_worker.ml create mode 100644 logseq_db_worker/lui/journal_worker_eio_backend.ml create mode 100644 logseq_db_worker/lui/journal_worker_runtime.ml diff --git a/logseq_db_worker/lui/journal_bounded_mailbox.ml b/logseq_db_worker/lui/journal_bounded_mailbox.ml new file mode 100644 index 0000000..71c3b55 --- /dev/null +++ b/logseq_db_worker/lui/journal_bounded_mailbox.ml @@ -0,0 +1,184 @@ +let validate_capacity name capacity = + if capacity <= 0 then invalid_arg (name ^ ": capacity must be positive") +;; + +let validate_max_items name max_items = + if max_items < 0 then invalid_arg (name ^ ": max_items must be nonnegative") +;; + +module Fifo = struct + type 'a t = + { capacity : int + ; mutex : Mutex.t + ; condition : Condition.t + ; queue : 'a Queue.t + ; mutable closed : bool + } + + let create ~capacity = + validate_capacity "Bounded_mailbox.Fifo.create" capacity; + { capacity + ; mutex = Mutex.create () + ; condition = Condition.create () + ; queue = Queue.create () + ; closed = false + } + ;; + + let with_lock t f = + Mutex.lock t.mutex; + Fun.protect ~finally:(fun () -> Mutex.unlock t.mutex) f + ;; + + let try_push t value = + with_lock t (fun () -> + if t.closed + then `Closed + else if Queue.length t.queue >= t.capacity + then `Full + else ( + Queue.add value t.queue; + Condition.signal t.condition; + `Ok)) + ;; + + let pop t = + with_lock t (fun () -> + if Queue.is_empty t.queue then None else Some (Queue.take t.queue)) + ;; + + let length t = with_lock t (fun () -> Queue.length t.queue) + + let wait_pop t = + with_lock t (fun () -> + while Queue.is_empty t.queue && not t.closed do + Condition.wait t.condition t.mutex + done; + if Queue.is_empty t.queue then None else Some (Queue.take t.queue)) + ;; + + let drain t ~max_items = + validate_max_items "Bounded_mailbox.Fifo.drain" max_items; + with_lock t (fun () -> + let rec loop remaining reversed = + if remaining = 0 || Queue.is_empty t.queue + then List.rev reversed + else loop (remaining - 1) (Queue.take t.queue :: reversed) + in + loop max_items []) + ;; + + let close t = + with_lock t (fun () -> + if not t.closed + then ( + t.closed <- true; + Condition.broadcast t.condition)) + ;; +end + +module Reserved = struct + type 'a t = + { capacity : int + ; mutex : Mutex.t + ; queue : 'a Queue.t + ; mutable reserved : int + } + + let create ~capacity = + validate_capacity "Bounded_mailbox.Reserved.create" capacity; + { capacity; mutex = Mutex.create (); queue = Queue.create (); reserved = 0 } + ;; + + let with_lock t f = + Mutex.lock t.mutex; + Fun.protect ~finally:(fun () -> Mutex.unlock t.mutex) f + ;; + + let reserve t = + with_lock t (fun () -> + if t.reserved + Queue.length t.queue >= t.capacity + then false + else ( + t.reserved <- t.reserved + 1; + true)) + ;; + + let cancel t = + with_lock t (fun () -> + if t.reserved = 0 + then invalid_arg "Bounded_mailbox.Reserved.cancel: no reservation" + else t.reserved <- t.reserved - 1) + ;; + + let publish t value = + with_lock t (fun () -> + if t.reserved = 0 + then invalid_arg "Bounded_mailbox.Reserved.publish: no reservation" + else ( + t.reserved <- t.reserved - 1; + Queue.add value t.queue)) + ;; + + let pop t = + with_lock t (fun () -> + if Queue.is_empty t.queue then None else Some (Queue.take t.queue)) + ;; + + let drain t ~max_items = + validate_max_items "Bounded_mailbox.Reserved.drain" max_items; + with_lock t (fun () -> + let rec loop remaining reversed = + if remaining = 0 || Queue.is_empty t.queue + then List.rev reversed + else loop (remaining - 1) (Queue.take t.queue :: reversed) + in + loop max_items []) + ;; +end + +module Coalesced = struct + type 'a t = + { capacity : int + ; mutex : Mutex.t + ; slots : (int, 'a) Hashtbl.t + } + + let create ~capacity = + validate_capacity "Bounded_mailbox.Coalesced.create" capacity; + { capacity; mutex = Mutex.create (); slots = Hashtbl.create capacity } + ;; + + let with_lock t f = + Mutex.lock t.mutex; + Fun.protect ~finally:(fun () -> Mutex.unlock t.mutex) f + ;; + + let push t ~topic value = + with_lock t (fun () -> + if Hashtbl.mem t.slots topic + then ( + Hashtbl.replace t.slots topic value; + `Replaced) + else if Hashtbl.length t.slots >= t.capacity + then `Full + else ( + Hashtbl.add t.slots topic value; + `Added)) + ;; + + let drain t ~max_items = + validate_max_items "Bounded_mailbox.Coalesced.drain" max_items; + with_lock t (fun () -> + let topics = Hashtbl.to_seq_keys t.slots |> List.of_seq |> List.sort Int.compare in + let rec take remaining reversed = function + | _ when remaining = 0 -> List.rev reversed + | [] -> List.rev reversed + | topic :: rest -> + let value = Hashtbl.find t.slots topic in + Hashtbl.remove t.slots topic; + take (remaining - 1) ((topic, value) :: reversed) rest + in + take max_items [] topics) + ;; +end diff --git a/logseq_db_worker/lui/journal_worker.ml b/logseq_db_worker/lui/journal_worker.ml new file mode 100644 index 0000000..89c7f1a --- /dev/null +++ b/logseq_db_worker/lui/journal_worker.ml @@ -0,0 +1,1147 @@ +module ID = Journal_worker_ids + +type mono_clock = Eio.Time.Mono.ty Eio.Resource.t +type net = [ `Generic ] Eio.Net.ty Eio.Resource.t +type data_dir = Eio.Fs.dir_ty Eio.Path.t +type environment = Journal_worker_eio_backend.environment + +type 'response outcome = + | Completed of 'response + | Failed of string + | Cancelled + | Shutdown + +type ('response, 'push) event = + | Response of + { runtime_epoch : ID.Runtime.epoch + ; worker_generation : ID.Worker.generation + ; request_id : ID.Worker.request_id + ; outcome : 'response outcome + } + | Push of + { runtime_epoch : ID.Runtime.epoch + ; worker_generation : ID.Worker.generation + ; push_sequence : ID.Worker.push_sequence + ; topic : ID.Worker.push_topic + ; payload : 'push + } + | Terminal of + { runtime_epoch : ID.Runtime.epoch + ; worker_generation : ID.Worker.generation + ; error : string + } + +type send_result = + | Accepted of ID.Worker.request_id + | Full + | Not_ready + | Stopping + +type client_status = + | Starting_status + | Ready_status + | Stopping_status + | Stopped_status + | Terminal_status + +let status_code = function + | Starting_status -> 0 + | Ready_status -> 1 + | Stopping_status -> 2 + | Stopped_status -> 3 + | Terminal_status -> 4 +;; + +let status_of_code = function + | 0 -> Starting_status + | 1 -> Ready_status + | 2 -> Stopping_status + | 3 -> Stopped_status + | 4 -> Terminal_status + | _ -> failwith "Worker client status invariant failed" +;; + +module Session_context = struct + type 'push t = + { switch : Eio.Switch.t + ; environment : environment + ; clock : mono_clock + ; net : net + ; data_dir : data_dir option + ; emit : topic:ID.Worker.push_topic -> 'push -> unit + ; fork_daemon : name:string -> (unit -> unit) -> unit + } + + let switch t = t.switch + let environment t = t.environment + let clock t = t.clock + let net t = t.net + let data_dir t = t.data_dir + let emit t = t.emit + let fork_daemon t = t.fork_daemon +end + +module Request_context = struct + type 'push t = + { request_id : ID.Worker.request_id + ; switch : Eio.Switch.t + ; environment : environment + ; clock : mono_clock + ; net : net + ; data_dir : data_dir option + ; emit : topic:ID.Worker.push_topic -> 'push -> unit + } + + let request_id t = t.request_id + let switch t = t.switch + let environment t = t.environment + let clock t = t.clock + let net t = t.net + let data_dir t = t.data_dir + let emit t = t.emit +end + +module Service = struct + type concurrency = + | Serial + | Concurrent of { max_in_flight : int } + + type ('config, 'request, 'response, 'push, 'state) direct_callbacks = + { push_topic_count : int + ; concurrency : concurrency + ; data_directory : ('config -> (string, string) result) option + ; init : 'push Session_context.t -> 'config -> ('state, string) result + ; handle : 'push Request_context.t -> 'state -> 'request -> ('response, string) result + ; shutdown : 'state -> unit + } + + type ('config, 'request, 'response, 'push) t = + | Direct : + ('config, 'request, 'response, 'push, 'state) direct_callbacks + -> ('config, 'request, 'response, 'push) t + + let validate_push_topic_count push_topic_count = + if push_topic_count <= 0 + then invalid_arg "Worker.Service.create: push_topic_count must be positive" + ;; + + let validate_concurrency = function + | Serial -> () + | Concurrent { max_in_flight } when max_in_flight > 0 -> () + | Concurrent _ -> invalid_arg "Worker concurrency must be positive" + ;; + + let create ~push_topic_count ~concurrency ?data_directory ~init ~handle ~shutdown () = + validate_push_topic_count push_topic_count; + validate_concurrency concurrency; + Direct { push_topic_count; concurrency; data_directory; init; handle; shutdown } + ;; +end + +type 'request request_envelope = + { request_id : ID.Worker.request_id + ; payload : 'request + ; enqueued_at_ns : int64 + } + +type control_message = Cancel of ID.Worker.request_id + +exception Request_cancelled +exception Request_shutdown + +type ('request, 'response, 'push) client = + { runtime_epoch : ID.Runtime.epoch + ; worker_generation : ID.Worker.generation + ; requests : 'request request_envelope Journal_bounded_mailbox.Fifo.t + ; wake : Eio.Condition.t + ; responses : ('response, 'push) event Journal_bounded_mailbox.Reserved.t + ; pushes : ('response, 'push) event Journal_bounded_mailbox.Coalesced.t + ; injected : ('response, 'push) event Journal_bounded_mailbox.Fifo.t + ; status : int Atomic.t + ; stop_requested : bool Atomic.t + ; cancellation_mutex : Mutex.t + ; cancellations : (ID.Worker.request_id, unit) Hashtbl.t + ; controls : control_message Queue.t + ; cancellation_started_ns : (ID.Worker.request_id, int64) Hashtbl.t + ; request_switches : (ID.Worker.request_id, Eio.Switch.t) Hashtbl.t + ; terminal_requests : (ID.Worker.request_id, unit) Hashtbl.t + ; output_mutex : Mutex.t + ; output_condition : Condition.t + ; pending_output_count : int Atomic.t + ; stopped_mutex : Mutex.t + ; stopped_condition : Condition.t + ; mutable stopped : bool + ; mutable next_request_id : ID.Worker.request_id + ; pending_requests : (ID.Worker.request_id, unit) Hashtbl.t + ; mutable subscribers : (('response, 'push) event -> unit) list + ; mutable last_push_sequence : ID.Worker.push_sequence + ; mutable terminal_event : ('response, 'push) event option + ; configured_concurrency_limit : int + ; active_request_fibers : int Atomic.t + ; waiting_request_fibers : int Atomic.t + ; active_handlers : int Atomic.t + ; peak_active_handlers : int Atomic.t + ; active_background_fibers : int Atomic.t + ; peak_active_background_fibers : int Atomic.t + ; request_queue_wait_count : int Atomic.t + ; max_request_queue_wait_ns : int64 Atomic.t + ; handler_wall_count : int Atomic.t + ; max_handler_wall_ns : int64 Atomic.t + ; cancellation_unwind_count : int Atomic.t + ; max_cancellation_unwind_ns : int64 Atomic.t + ; stop_started_ns : int64 Atomic.t + ; session_cancellation_duration_ns : int64 Atomic.t + ; shutdown_duration_ns : int64 Atomic.t + } + +type packed_startup = + | Packed_startup : + { service : ('config, 'request, 'response, 'push) Service.t + ; config : 'config + ; client : ('request, 'response, 'push) client + } + -> packed_startup + +type packed_client = + | Packed_client : ('request, 'response, 'push) client -> packed_client + +let request_capacity = 32 +let response_capacity = 32 +let injected_capacity = 1024 +let no_duration = -1L +let now_ns () = Mtime_clock.elapsed_ns () + +let elapsed_ns started = + let elapsed = Int64.sub (now_ns ()) started in + if Int64.compare elapsed 0L < 0 then 0L else elapsed +;; + +let concurrency_limit = function + | Service.Serial -> 1 + | Concurrent { max_in_flight } -> max_in_flight +;; + +let update_peak peak value = + let rec loop observed = + if value <= observed + then () + else if not (Atomic.compare_and_set peak observed value) + then loop (Atomic.get peak) + in + loop (Atomic.get peak) +;; + +let update_max_int64 maximum value = + let rec loop observed = + if Int64.compare value observed <= 0 + then () + else if not (Atomic.compare_and_set maximum observed value) + then loop (Atomic.get maximum) + in + loop (Atomic.get maximum) +;; + +let record_duration count maximum started = + let duration = elapsed_ns started in + Atomic.incr count; + update_max_int64 maximum duration +;; + +let duration_option value = if Int64.equal value no_duration then None else Some value + +let prepare ~runtime_epoch ~worker_generation service config = + let push_topic_count, configured_concurrency_limit = + match service with + | Service.Direct { push_topic_count; concurrency; _ } -> + push_topic_count, concurrency_limit concurrency + in + let client = + { runtime_epoch + ; worker_generation + ; requests = Journal_bounded_mailbox.Fifo.create ~capacity:request_capacity + ; wake = Eio.Condition.create () + ; responses = Journal_bounded_mailbox.Reserved.create ~capacity:response_capacity + ; pushes = Journal_bounded_mailbox.Coalesced.create ~capacity:push_topic_count + ; injected = Journal_bounded_mailbox.Fifo.create ~capacity:injected_capacity + ; status = Atomic.make (status_code Starting_status) + ; stop_requested = Atomic.make false + ; cancellation_mutex = Mutex.create () + ; cancellations = Hashtbl.create request_capacity + ; controls = Queue.create () + ; cancellation_started_ns = Hashtbl.create request_capacity + ; request_switches = Hashtbl.create request_capacity + ; terminal_requests = Hashtbl.create request_capacity + ; output_mutex = Mutex.create () + ; output_condition = Condition.create () + ; pending_output_count = Atomic.make 0 + ; stopped_mutex = Mutex.create () + ; stopped_condition = Condition.create () + ; stopped = false + ; next_request_id = ID.Worker.Request_id.one + ; pending_requests = Hashtbl.create request_capacity + ; subscribers = [] + ; last_push_sequence = ID.Worker.Push_sequence.zero + ; terminal_event = None + ; configured_concurrency_limit + ; active_request_fibers = Atomic.make 0 + ; waiting_request_fibers = Atomic.make 0 + ; active_handlers = Atomic.make 0 + ; peak_active_handlers = Atomic.make 0 + ; active_background_fibers = Atomic.make 0 + ; peak_active_background_fibers = Atomic.make 0 + ; request_queue_wait_count = Atomic.make 0 + ; max_request_queue_wait_ns = Atomic.make 0L + ; handler_wall_count = Atomic.make 0 + ; max_handler_wall_ns = Atomic.make 0L + ; cancellation_unwind_count = Atomic.make 0 + ; max_cancellation_unwind_ns = Atomic.make 0L + ; stop_started_ns = Atomic.make no_duration + ; session_cancellation_duration_ns = Atomic.make no_duration + ; shutdown_duration_ns = Atomic.make no_duration + } + in + client, Packed_startup { service; config; client } +;; + +let runtime_epoch client = client.runtime_epoch +let worker_generation client = client.worker_generation + +let with_output_lock client f = + Mutex.lock client.output_mutex; + Fun.protect ~finally:(fun () -> Mutex.unlock client.output_mutex) f +;; + +let increment_pending_output_locked client = + ignore (Atomic.fetch_and_add client.pending_output_count 1 : int); + Condition.broadcast client.output_condition +;; + +let decrement_pending_output_locked client count = + if count > 0 + then ignore (Atomic.fetch_and_add client.pending_output_count (-count) : int) +;; + +let next_request_id client = + let request_id = client.next_request_id in + if ID.Worker.Request_id.equal request_id ID.Worker.Request_id.max_value + then failwith "Worker request ID counter exhausted" + else client.next_request_id <- ID.Worker.Request_id.succ request_id; + request_id +;; + +let send client payload = + match status_of_code (Atomic.get client.status) with + | Starting_status -> Not_ready + | Stopping_status | Stopped_status | Terminal_status -> Stopping + | Ready_status -> + if not (Journal_bounded_mailbox.Reserved.reserve client.responses) + then Full + else ( + let request_id = next_request_id client in + let request = { request_id; payload; enqueued_at_ns = now_ns () } in + match Journal_bounded_mailbox.Fifo.try_push client.requests request with + | `Ok -> + Hashtbl.add client.pending_requests request_id (); + Eio.Condition.broadcast client.wake; + Accepted request_id + | `Full -> + Journal_bounded_mailbox.Reserved.cancel client.responses; + Full + | `Closed -> + Journal_bounded_mailbox.Reserved.cancel client.responses; + Stopping) +;; + +let with_cancellations client f = + Mutex.lock client.cancellation_mutex; + Fun.protect ~finally:(fun () -> Mutex.unlock client.cancellation_mutex) f +;; + +let cancel client ~request_id = + if Hashtbl.mem client.pending_requests request_id + then ( + let published = + with_cancellations client (fun () -> + if + Hashtbl.mem client.cancellations request_id + || Hashtbl.mem client.terminal_requests request_id + then false + else ( + Hashtbl.add client.cancellations request_id (); + Hashtbl.replace client.cancellation_started_ns request_id (now_ns ()); + Queue.add (Cancel request_id) client.controls; + true)) + in + if published then Eio.Condition.broadcast client.wake) +;; + +let is_cancelled client request_id = + Atomic.get client.stop_requested + || with_cancellations client (fun () -> Hashtbl.mem client.cancellations request_id) +;; + +let clear_cancelled client request_id = + with_cancellations client (fun () -> Hashtbl.remove client.cancellations request_id) +;; + +let take_cancel_control client = + with_cancellations client (fun () -> + if Queue.is_empty client.controls then None else Some (Queue.take client.controls)) +;; + +let attach_request_switch client request_id switch = + with_cancellations client (fun () -> + if Hashtbl.mem client.terminal_requests request_id + then `Finished + else if Atomic.get client.stop_requested + then `Shutdown + else if Hashtbl.mem client.cancellations request_id + then `Cancelled + else ( + Hashtbl.replace client.request_switches request_id switch; + `Attached)) +;; + +let fail_request_switch client request_id = + let switch = + with_cancellations client (fun () -> + Hashtbl.find_opt client.request_switches request_id) + in + Option.iter (fun switch -> Eio.Switch.fail switch Request_cancelled) switch +;; + +let fail_all_request_switches client = + let switches = + with_cancellations client (fun () -> + Hashtbl.fold + (fun _request_id switch switches -> switch :: switches) + client.request_switches + []) + in + List.iter (fun switch -> Eio.Switch.fail switch Request_shutdown) switches +;; + +let remove_cancel_control_locked client request_id = + let retained = Queue.create () in + while not (Queue.is_empty client.controls) do + match Queue.take client.controls with + | Cancel queued when ID.Worker.Request_id.equal queued request_id -> () + | control -> Queue.add control retained + done; + Queue.transfer retained client.controls +;; + +let claim_direct_outcome client request_id outcome = + with_cancellations client (fun () -> + if Hashtbl.mem client.terminal_requests request_id + then None + else ( + Hashtbl.add client.terminal_requests request_id (); + Hashtbl.remove client.request_switches request_id; + let outcome, cancellation_started = + if Atomic.get client.stop_requested + then Shutdown, duration_option (Atomic.get client.stop_started_ns) + else if Hashtbl.mem client.cancellations request_id + then Cancelled, Hashtbl.find_opt client.cancellation_started_ns request_id + else outcome, None + in + Hashtbl.remove client.cancellations request_id; + Hashtbl.remove client.cancellation_started_ns request_id; + remove_cancel_control_locked client request_id; + Option.iter + (record_duration + client.cancellation_unwind_count + client.max_cancellation_unwind_ns) + cancellation_started; + Some outcome)) +;; + +let forget_direct_terminal client request_id = + with_cancellations client (fun () -> Hashtbl.remove client.terminal_requests request_id) +;; + +let on_event client handler = client.subscribers <- client.subscribers @ [ handler ] + +let publish_response client request_id outcome = + with_output_lock client (fun () -> + Journal_bounded_mailbox.Reserved.publish + client.responses + (Response + { runtime_epoch = client.runtime_epoch + ; worker_generation = client.worker_generation + ; request_id + ; outcome + }); + increment_pending_output_locked client) +;; + +let set_terminal_event client error = + with_output_lock client (fun () -> + if Option.is_none client.terminal_event + then ( + client.terminal_event + <- Some + (Terminal + { runtime_epoch = client.runtime_epoch + ; worker_generation = client.worker_generation + ; error + }); + increment_pending_output_locked client)) +;; + +let mark_stopped client status = + Atomic.set client.status (status_code status); + Mutex.lock client.stopped_mutex; + client.stopped <- true; + Condition.broadcast client.stopped_condition; + Mutex.unlock client.stopped_mutex; + with_output_lock client (fun () -> Condition.broadcast client.output_condition) +;; + +let request_stop client = + let started = now_ns () in + ignore (Atomic.compare_and_set client.stop_started_ns no_duration started : bool); + let status = status_of_code (Atomic.get client.status) in + (match status with + | Starting_status | Ready_status -> + Atomic.set client.status (status_code Stopping_status) + | Stopping_status | Stopped_status | Terminal_status -> ()); + Atomic.set client.stop_requested true; + Journal_bounded_mailbox.Fifo.close client.requests; + Eio.Condition.broadcast client.wake +;; + +let await_stopped client = + Mutex.lock client.stopped_mutex; + while not client.stopped do + Condition.wait client.stopped_condition client.stopped_mutex + done; + Mutex.unlock client.stopped_mutex +;; + +let request_stop_packed (Packed_client client) = request_stop client +let await_stopped_packed (Packed_client client) = await_stopped client +let pack_client client = Packed_client client + +type metrics = + { configured_concurrency_limit : int + ; queued_requests : int + ; active_request_fibers : int + ; waiting_request_fibers : int + ; active_handlers : int + ; peak_active_handlers : int + ; active_background_fibers : int + ; peak_active_background_fibers : int + ; request_queue_wait_count : int + ; max_request_queue_wait_ns : int64 + ; handler_wall_count : int + ; max_handler_wall_ns : int64 + ; cancellation_unwind_count : int + ; max_cancellation_unwind_ns : int64 + ; session_cancellation_duration_ns : int64 option + ; shutdown_duration_ns : int64 option + } + +let metrics (Packed_client client) = + { configured_concurrency_limit = client.configured_concurrency_limit + ; queued_requests = Journal_bounded_mailbox.Fifo.length client.requests + ; active_request_fibers = Atomic.get client.active_request_fibers + ; waiting_request_fibers = Atomic.get client.waiting_request_fibers + ; active_handlers = Atomic.get client.active_handlers + ; peak_active_handlers = Atomic.get client.peak_active_handlers + ; active_background_fibers = Atomic.get client.active_background_fibers + ; peak_active_background_fibers = Atomic.get client.peak_active_background_fibers + ; request_queue_wait_count = Atomic.get client.request_queue_wait_count + ; max_request_queue_wait_ns = Atomic.get client.max_request_queue_wait_ns + ; handler_wall_count = Atomic.get client.handler_wall_count + ; max_handler_wall_ns = Atomic.get client.max_handler_wall_ns + ; cancellation_unwind_count = Atomic.get client.cancellation_unwind_count + ; max_cancellation_unwind_ns = Atomic.get client.max_cancellation_unwind_ns + ; session_cancellation_duration_ns = + duration_option (Atomic.get client.session_cancellation_duration_ns) + ; shutdown_duration_ns = duration_option (Atomic.get client.shutdown_duration_ns) + } +;; + +let fail_unrecoverable (Packed_client client) error = + request_stop client; + set_terminal_event client error; + mark_stopped client Terminal_status +;; + +let exception_message exception_ = + match exception_ with + | Failure message | Invalid_argument message -> message + | _ -> Printexc.to_string exception_ +;; + +type run_result = + | Session_stopped + | Session_startup_failed of string + | Session_callback_failed of string + +let run_direct_session + (type config request response push state) + (callbacks : (config, request, response, push, state) Service.direct_callbacks) + (client : (request, response, push) client) + (config : config) + ~(environment : Journal_worker_eio_backend.environment) + ~session_switch + ~on_startup + ~on_idle_wait + ~on_yield + = + let next_push_sequence = ref ID.Worker.Push_sequence.one in + let emit_push ~topic payload = + let topic_index = ID.Worker.Push_topic.to_int topic in + if topic_index < 0 || topic_index >= callbacks.push_topic_count + then failwith "Worker push topic invariant failed"; + let push_sequence = !next_push_sequence in + if ID.Worker.Push_sequence.equal push_sequence ID.Worker.Push_sequence.max_value + then failwith "Worker push sequence exhausted" + else next_push_sequence := ID.Worker.Push_sequence.succ push_sequence; + let event = + Push + { runtime_epoch = client.runtime_epoch + ; worker_generation = client.worker_generation + ; push_sequence + ; topic + ; payload + } + in + with_output_lock client (fun () -> + match + Journal_bounded_mailbox.Coalesced.push client.pushes ~topic:topic_index event + with + | `Added -> increment_pending_output_locked client + | `Replaced -> Condition.broadcast client.output_condition + | `Full -> failwith "Worker push mailbox invariant failed") + in + let mono_clock = Journal_worker_eio_backend.mono_clock environment in + let network = Journal_worker_eio_backend.net environment in + let stdenv = Journal_worker_eio_backend.stdenv environment in + let directory_capability = + match callbacks.data_directory with + | None -> Ok None + | Some resolve -> + (match resolve config with + | Error _ as error -> error + | Ok path when Filename.is_relative path -> + Error "Worker data directory must be an absolute path" + | Ok path -> + (try + let directory = + Eio.Path.open_dir + ~sw:session_switch + Eio.Path.(Journal_worker_eio_backend.fs environment / path) + in + Ok (Some (directory :> data_dir)) + with + | exception_ -> Error (exception_message exception_))) + in + match directory_capability with + | Error error -> + on_startup (Error error); + mark_stopped client Stopped_status; + Session_startup_failed error + | Ok directory_capability -> + let fatal_error = ref None in + let daemon_switches = ref [] in + let daemons_stopping = ref false in + let daemon_finished () = + Atomic.decr client.active_background_fibers; + Eio.Condition.broadcast client.wake + in + let report_daemon_failure name exception_ = + let error = + Printf.sprintf "Worker daemon %S failed: %s" name (exception_message exception_) + in + if Option.is_none !fatal_error then fatal_error := Some error; + Eio.Condition.broadcast client.wake + in + let start_daemon ~name run = + if !daemons_stopping + then invalid_arg "Worker.Session_context.fork_daemon: session is stopping"; + Atomic.incr client.active_background_fibers; + update_peak + client.peak_active_background_fibers + (Atomic.get client.active_background_fibers); + try + Eio.Fiber.fork ~sw:session_switch (fun () -> + Fun.protect ~finally:daemon_finished (fun () -> + try + Eio.Switch.run (fun daemon_switch -> + daemon_switches := daemon_switch :: !daemon_switches; + if !daemons_stopping then Eio.Switch.fail daemon_switch Request_shutdown; + Fun.protect + ~finally:(fun () -> + daemon_switches + := List.filter + (fun registered -> registered != daemon_switch) + !daemon_switches) + run) + with + | Request_shutdown | Eio.Cancel.Cancelled Request_shutdown -> () + | exception_ -> report_daemon_failure name exception_)) + with + | exception_ -> + daemon_finished (); + raise exception_ + in + let rec await_background_fibers () = + if Atomic.get client.active_background_fibers = 0 + then () + else ( + on_idle_wait (); + Eio.Condition.loop_no_mutex client.wake (fun () -> + if Atomic.get client.active_background_fibers = 0 then Some () else None); + await_background_fibers ()) + in + let stop_daemons () = + daemons_stopping := true; + List.iter + (fun daemon_switch -> Eio.Switch.fail daemon_switch Request_shutdown) + !daemon_switches; + await_background_fibers () + in + let session_context = + Session_context. + { switch = session_switch + ; environment = stdenv + ; clock = mono_clock + ; net = network + ; data_dir = directory_capability + ; emit = emit_push + ; fork_daemon = start_daemon + } + in + let init_result, resolve_init = Eio.Promise.create () in + let init_switch = ref None in + Eio.Fiber.fork ~sw:session_switch (fun () -> + let result = + try + Eio.Switch.run (fun switch -> + init_switch := Some switch; + callbacks.init session_context config) + with + | Request_shutdown | Eio.Cancel.Cancelled Request_shutdown -> + Error "Worker session stopped during initialization" + | exception_ -> Error (exception_message exception_) + in + ignore (Eio.Promise.try_resolve resolve_init result : bool); + Eio.Condition.broadcast client.wake); + let rec cancel_initialization () = + match !init_switch with + | Some switch -> Eio.Switch.fail switch Request_shutdown + | None -> + Eio.Fiber.yield (); + cancel_initialization () + in + let rec await_initialization () = + if Option.is_some !fatal_error + then ( + cancel_initialization (); + Error (Option.get !fatal_error)) + else if Atomic.get client.stop_requested + then ( + cancel_initialization (); + Error "Worker session stopped during initialization") + else ( + match Eio.Promise.peek init_result with + | Some result -> result + | None -> + on_idle_wait (); + Eio.Condition.loop_no_mutex client.wake (fun () -> + if + Atomic.get client.stop_requested + || Option.is_some !fatal_error + || Eio.Promise.is_resolved init_result + then Some () + else None); + await_initialization ()) + in + (match await_initialization () with + | Error error -> + stop_daemons (); + on_startup (Error error); + mark_stopped client Stopped_status; + Session_startup_failed error + | Ok state -> + Atomic.set client.status (status_code Ready_status); + on_startup (Ok ()); + let handler_slots = + match callbacks.concurrency with + | Service.Serial -> Eio.Semaphore.make 1 + | Concurrent { max_in_flight } -> Eio.Semaphore.make max_in_flight + in + let publish_direct request_id outcome = + match claim_direct_outcome client request_id outcome with + | None -> () + | Some outcome -> publish_response client request_id outcome + in + let request_finished () = + Atomic.decr client.active_request_fibers; + Eio.Condition.broadcast client.wake + in + let run_handler request request_switch = + match attach_request_switch client request.request_id request_switch with + | `Finished -> `No_outcome + | `Cancelled -> `Outcome Cancelled + | `Shutdown -> `Outcome Shutdown + | `Attached -> + let acquired = ref false in + let handler_started = ref None in + let response = + try + Atomic.incr client.waiting_request_fibers; + (try Eio.Semaphore.acquire handler_slots with + | exception_ -> + Atomic.decr client.waiting_request_fibers; + raise exception_); + Atomic.decr client.waiting_request_fibers; + acquired := true; + handler_started := Some (now_ns ()); + let active_handlers = Atomic.fetch_and_add client.active_handlers 1 + 1 in + update_peak client.peak_active_handlers active_handlers; + Eio.Fiber.check (); + let context = + Request_context. + { request_id = request.request_id + ; switch = request_switch + ; environment = stdenv + ; clock = mono_clock + ; net = network + ; data_dir = directory_capability + ; emit = emit_push + } + in + let result = callbacks.handle context state request.payload in + match result with + | Ok response -> `Outcome (Completed response) + | Error error -> `Outcome (Failed error) + with + | Request_cancelled | Eio.Cancel.Cancelled Request_cancelled -> + `Outcome Cancelled + | Request_shutdown | Eio.Cancel.Cancelled Request_shutdown -> + `Outcome Shutdown + | exception_ -> `Fatal (exception_message exception_) + in + if !acquired + then ( + Atomic.decr client.active_handlers; + Option.iter + (record_duration client.handler_wall_count client.max_handler_wall_ns) + !handler_started; + Eio.Semaphore.release handler_slots); + response + in + let dispatch request = + record_duration + client.request_queue_wait_count + client.max_request_queue_wait_ns + request.enqueued_at_ns; + if is_cancelled client request.request_id + then + publish_direct + request.request_id + (if Atomic.get client.stop_requested then Shutdown else Cancelled) + else ( + Atomic.incr client.active_request_fibers; + Eio.Fiber.fork ~sw:session_switch (fun () -> + Fun.protect ~finally:request_finished (fun () -> + let completion = + try Eio.Switch.run (run_handler request) with + | Request_cancelled -> `Outcome Cancelled + | Request_shutdown -> `Outcome Shutdown + in + match completion with + | `No_outcome -> () + | `Outcome outcome -> publish_direct request.request_id outcome + | `Fatal error -> + publish_direct request.request_id (Failed error); + if Option.is_none !fatal_error then fatal_error := Some error; + Eio.Condition.broadcast client.wake))) + in + let drain_shutdown_requests () = + Journal_bounded_mailbox.Fifo.drain client.requests ~max_items:max_int + |> List.iter (fun request -> + record_duration + client.request_queue_wait_count + client.max_request_queue_wait_ns + request.enqueued_at_ns; + publish_direct request.request_id Shutdown) + in + let shutdown_state () = + let started = now_ns () in + let result = + try + callbacks.shutdown state; + Ok () + with + | exception_ -> Error (exception_message exception_) + in + Atomic.set client.shutdown_duration_ns (elapsed_ns started); + result + in + let rec await_active_requests () = + if Atomic.get client.active_request_fibers = 0 + then () + else ( + on_idle_wait (); + Eio.Condition.loop_no_mutex client.wake (fun () -> + if Atomic.get client.active_request_fibers = 0 then Some () else None); + await_active_requests ()) + in + let stop_session terminal_error = + request_stop client; + fail_all_request_switches client; + drain_shutdown_requests (); + await_active_requests (); + stop_daemons (); + let shutdown_error = shutdown_state () in + Option.iter + (fun started -> + Atomic.set client.session_cancellation_duration_ns (elapsed_ns started)) + (duration_option (Atomic.get client.stop_started_ns)); + let terminal_error = + match terminal_error, shutdown_error with + | None, Ok () -> None + | Some error, Ok () -> Some error + | None, Error error -> Some error + | Some error, Error shutdown_error -> Some (error ^ "\n" ^ shutdown_error) + in + match terminal_error with + | None -> + mark_stopped client Stopped_status; + Session_stopped + | Some error -> + set_terminal_event client error; + mark_stopped client Terminal_status; + Session_callback_failed error + in + let rec coordinate consecutive_requests = + match !fatal_error with + | Some error -> stop_session (Some error) + | None when Atomic.get client.stop_requested -> stop_session None + | None -> + (match take_cancel_control client with + | Some (Cancel request_id) -> + fail_request_switch client request_id; + coordinate consecutive_requests + | None when consecutive_requests >= 8 -> + on_yield (); + Eio.Fiber.yield (); + coordinate 0 + | None -> + (match Journal_bounded_mailbox.Fifo.pop client.requests with + | Some request -> + dispatch request; + coordinate (consecutive_requests + 1) + | None -> + on_idle_wait (); + let next = + Eio.Condition.loop_no_mutex client.wake (fun () -> + match !fatal_error with + | Some _ -> Some `Fatal + | None -> + if Atomic.get client.stop_requested + then Some (`Action `Stop) + else ( + match take_cancel_control client with + | Some control -> Some (`Action (`Control control)) + | None -> + (match Journal_bounded_mailbox.Fifo.pop client.requests with + | Some request -> Some (`Action (`Request request)) + | None -> None))) + in + (match next with + | `Fatal -> coordinate 0 + | `Action `Stop -> coordinate 0 + | `Action (`Control (Cancel request_id)) -> + fail_request_switch client request_id; + coordinate 0 + | `Action (`Request request) -> + dispatch request; + coordinate 1))) + in + coordinate 0) +;; + +let run_session + (Packed_startup { service; config; client }) + ~(environment : Journal_worker_eio_backend.environment) + ~session_switch + ~on_startup + ~on_idle_wait + ~on_yield + = + match service with + | Service.Direct callbacks -> + run_direct_session + callbacks + client + config + ~environment + ~session_switch + ~on_startup + ~on_idle_wait + ~on_yield +;; + +let take_terminal_locked client = + let terminal = client.terminal_event in + client.terminal_event <- None; + terminal +;; + +let raw_events client ~max_events = + if max_events < 0 then invalid_arg "Worker drain max_events must be nonnegative"; + with_output_lock client (fun () -> + let responses = + Journal_bounded_mailbox.Reserved.drain client.responses ~max_items:max_events + in + let remaining = max_events - List.length responses in + let terminal = + if remaining = 0 + then [] + else ( + match take_terminal_locked client with + | None -> [] + | Some terminal -> [ terminal ]) + in + let remaining = remaining - List.length terminal in + let injected = + Journal_bounded_mailbox.Fifo.drain client.injected ~max_items:remaining + in + let remaining = remaining - List.length injected in + let pushes = + Journal_bounded_mailbox.Coalesced.drain client.pushes ~max_items:remaining + |> List.map snd + |> List.sort (fun left right -> + match left, right with + | Push left, Push right -> + ID.Worker.Push_sequence.compare left.push_sequence right.push_sequence + | _ -> 0) + in + let events = responses @ terminal @ injected @ pushes in + decrement_pending_output_locked client (List.length events); + events) +;; + +let accepted_event client = function + | Response response as event -> + if + ID.Runtime.Epoch.equal response.runtime_epoch client.runtime_epoch + && ID.Worker.Generation.equal response.worker_generation client.worker_generation + && Hashtbl.mem client.pending_requests response.request_id + then ( + Hashtbl.remove client.pending_requests response.request_id; + clear_cancelled client response.request_id; + forget_direct_terminal client response.request_id; + Some event) + else None + | Push push as event -> + if + ID.Runtime.Epoch.equal push.runtime_epoch client.runtime_epoch + && ID.Worker.Generation.equal push.worker_generation client.worker_generation + && ID.Worker.Push_sequence.compare push.push_sequence client.last_push_sequence > 0 + then ( + client.last_push_sequence <- push.push_sequence; + Some event) + else None + | Terminal terminal as event -> + if + ID.Runtime.Epoch.equal terminal.runtime_epoch client.runtime_epoch + && ID.Worker.Generation.equal terminal.worker_generation client.worker_generation + then Some event + else None +;; + +let drain_events client ~max_events = + raw_events client ~max_events |> List.filter_map (accepted_event client) +;; + +let deliver client ~max_events = + let events = drain_events client ~max_events in + List.iter + (fun event -> List.iter (fun subscriber -> subscriber event) client.subscribers) + events +;; + +let await_output client = + Mutex.lock client.output_mutex; + while + Atomic.get client.pending_output_count = 0 + && + match status_of_code (Atomic.get client.status) with + | Starting_status | Ready_status | Stopping_status -> true + | Stopped_status | Terminal_status -> false + do + Condition.wait client.output_condition client.output_mutex + done; + Mutex.unlock client.output_mutex +;; + +let pending_output_count client = Atomic.get client.pending_output_count + +let is_stopping client = + match status_of_code (Atomic.get client.status) with + | Starting_status | Ready_status -> false + | Stopping_status | Stopped_status | Terminal_status -> true +;; + +let inject_push client ~runtime_epoch ~worker_generation ~push_sequence ~topic payload = + let event = Push { runtime_epoch; worker_generation; push_sequence; topic; payload } in + with_output_lock client (fun () -> + match Journal_bounded_mailbox.Fifo.try_push client.injected event with + | `Ok -> increment_pending_output_locked client + | `Full | `Closed -> failwith "Worker test injection mailbox is unavailable") +;; + +module Private = struct + type nonrec packed_startup = packed_startup + type nonrec packed_client = packed_client + + type nonrec metrics = metrics = + { configured_concurrency_limit : int + ; queued_requests : int + ; active_request_fibers : int + ; waiting_request_fibers : int + ; active_handlers : int + ; peak_active_handlers : int + ; active_background_fibers : int + ; peak_active_background_fibers : int + ; request_queue_wait_count : int + ; max_request_queue_wait_ns : int64 + ; handler_wall_count : int + ; max_handler_wall_ns : int64 + ; cancellation_unwind_count : int + ; max_cancellation_unwind_ns : int64 + ; session_cancellation_duration_ns : int64 option + ; shutdown_duration_ns : int64 option + } + + type nonrec run_result = run_result = + | Session_stopped + | Session_startup_failed of string + | Session_callback_failed of string + + let prepare = prepare + let run_session = run_session + let pack_client = pack_client + let metrics = metrics + let request_stop = request_stop + let request_stop_packed = request_stop_packed + let await_stopped = await_stopped + let await_stopped_packed = await_stopped_packed + let fail_unrecoverable = fail_unrecoverable + let deliver = deliver +end + +module For_testing = struct + let drain_events = drain_events + let await_output = await_output + let pending_output_count = pending_output_count + let is_stopping = is_stopping + let inject_push = inject_push +end diff --git a/logseq_db_worker/lui/journal_worker_eio_backend.ml b/logseq_db_worker/lui/journal_worker_eio_backend.ml new file mode 100644 index 0000000..72d1b10 --- /dev/null +++ b/logseq_db_worker/lui/journal_worker_eio_backend.ml @@ -0,0 +1,65 @@ +(* + * Copyright (C) 2023 Thomas Leonard + * + * Permission to use, copy, modify, and distribute this software for any + * purpose with or without fee is hereby granted, provided that the above + * copyright notice and this permission notice appear in all copies. + * + * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + *) + +type environment = + < stdin : Eio_unix.source_ty Eio.Resource.t + ; stdout : Eio_unix.sink_ty Eio.Resource.t + ; stderr : Eio_unix.sink_ty Eio.Resource.t + ; net : [ `Unix | `Generic ] Eio.Net.ty Eio.Resource.t + ; domain_mgr : Eio.Domain_manager.ty Eio.Resource.t + ; clock : float Eio.Time.clock_ty Eio.Resource.t + ; mono_clock : Eio.Time.Mono.ty Eio.Resource.t + ; fs : Eio.Fs.dir_ty Eio.Path.t + ; cwd : Eio.Fs.dir_ty Eio.Path.t + ; secure_random : Eio.Flow.source_ty Eio.Resource.t + ; debug : Eio.Debug.t + ; backend_id : string > + +(* Eio 1.2's command-line entrypoint installs a process-wide SIGCHLD handler. + Embedded hosts own their signals and do not give their UI threads OCaml TLS. + Assemble the pinned backend directly, without a process manager or handler. *) +module Posix = Eio_posix__ + +let run main = + (* This thread belongs to the Worker for its entire lifetime. Its Eio helper + threads inherit the mask. Keep broken-pipe errors local without replacing + the host application's process-wide SIGPIPE disposition. *) + ignore (Unix.sigprocmask Unix.SIG_BLOCK [ Sys.sigpipe ] : int list); + let stdin = (Posix.Flow.of_fd Eio_unix.Fd.stdin :> Eio_unix.source_ty Eio.Resource.t) in + let stdout = (Posix.Flow.of_fd Eio_unix.Fd.stdout :> Eio_unix.sink_ty Eio.Resource.t) in + let stderr = (Posix.Flow.of_fd Eio_unix.Fd.stderr :> Eio_unix.sink_ty Eio.Resource.t) in + Posix.Domain_mgr.run_event_loop + main + object (_ : environment) + method stdin = stdin + method stdout = stdout + method stderr = stderr + method debug = Eio.Private.Debug.v + method clock = Posix.Time.clock + method mono_clock = Posix.Time.mono_clock + method net = Posix.Net.v + method domain_mgr = Posix.Domain_mgr.v + method cwd = ((Posix.Fs.cwd, "") :> Eio.Fs.dir_ty Eio.Path.t) + method fs = ((Posix.Fs.fs, "") :> Eio.Fs.dir_ty Eio.Path.t) + method secure_random = Posix.Flow.secure_random + method backend_id = "posix" + end +;; + +let stdenv environment = environment +let mono_clock environment = environment#mono_clock +let net environment = (environment#net :> [ `Generic ] Eio.Net.ty Eio.Resource.t) +let fs environment = environment#fs diff --git a/logseq_db_worker/lui/journal_worker_ids.ml b/logseq_db_worker/lui/journal_worker_ids.ml index 94663aa..9a59b1b 100644 --- a/logseq_db_worker/lui/journal_worker_ids.ml +++ b/logseq_db_worker/lui/journal_worker_ids.ml @@ -1,3 +1,35 @@ +module type Int64_id = sig + type t = private int64 + + val of_int64 : int64 -> t + val to_int64 : t -> int64 + val compare : t -> t -> int + val equal : t -> t -> bool + val succ : t -> t + val pred : t -> t + val zero : t + val one : t + val max_value : t +end + +module type Int_id = sig + type t = private int + + val of_int : int -> t + val to_int : t -> int + val compare : t -> t -> int + val equal : t -> t -> bool +end + +module type String_id = sig + type t = private string + + val of_string : string -> t + val to_string : t -> string + val compare : t -> t -> int + val equal : t -> t -> bool +end + module Make_int64_id () = struct type t = int64 diff --git a/logseq_db_worker/lui/journal_worker_runtime.ml b/logseq_db_worker/lui/journal_worker_runtime.ml new file mode 100644 index 0000000..e564d28 --- /dev/null +++ b/logseq_db_worker/lui/journal_worker_runtime.ml @@ -0,0 +1,506 @@ +module ID = Journal_worker_ids + +type state = + | Not_started + | Idle + | Attached + | Stopping + | Stopped + | Terminal + +type diagnostics = + { state : state + ; spawn_count : int + ; join_count : int + ; worker_domain_id : ID.Worker.domain_id option + ; active_sessions : int + ; peak_active_sessions : int + ; idle_wait_count : int + ; backend_run_count : int + ; backend_running : bool + ; coordinator_start_count : int + ; active_coordinators : int + ; peak_active_coordinators : int + ; coordinator_yield_count : int + ; configured_concurrency_limit : int option + ; queued_requests : int + ; active_request_fibers : int + ; waiting_request_fibers : int + ; active_handlers : int + ; peak_active_handlers : int + ; active_background_fibers : int + ; peak_active_background_fibers : int + ; logical_live_fibers : int + ; request_queue_wait_count : int + ; max_request_queue_wait_ns : int64 + ; handler_wall_count : int + ; max_handler_wall_ns : int64 + ; cancellation_unwind_count : int + ; max_cancellation_unwind_ns : int64 + ; session_cancellation_duration_ns : int64 option + ; shutdown_duration_ns : int64 option + ; backend_identity : string + ; backend_version : string + } + +type startup_reply = + { mutex : Mutex.t + ; condition : Condition.t + ; mutable result : (unit, string) result option + } + +type attachment = + { startup : Journal_worker.Private.packed_startup + ; client : Journal_worker.Private.packed_client + ; reply : startup_reply + } + +type control = + { mutex : Mutex.t + ; condition : Condition.t + ; mutable state : state + ; mutable pending_attachment : attachment option + ; mutable current_client : Journal_worker.Private.packed_client option + ; mutable final_stop : bool + ; mutable crash_requested : bool + ; mutable domain_handle : unit Domain.t option + ; mutable worker_domain_id : ID.Worker.domain_id option + ; mutable spawn_count : int + ; mutable join_count : int + ; mutable active_sessions : int + ; mutable peak_active_sessions : int + ; mutable idle_wait_count : int + ; mutable backend_run_count : int + ; mutable backend_running : bool + ; mutable coordinator_start_count : int + ; mutable active_coordinators : int + ; mutable peak_active_coordinators : int + ; mutable coordinator_yield_count : int + ; mutable last_session_metrics : Journal_worker.Private.metrics option + ; mutable next_generation : ID.Worker.generation + ; mutable fail_next_spawn : exn option + } + +let control = + { mutex = Mutex.create () + ; condition = Condition.create () + ; state = Not_started + ; pending_attachment = None + ; current_client = None + ; final_stop = false + ; crash_requested = false + ; domain_handle = None + ; worker_domain_id = None + ; spawn_count = 0 + ; join_count = 0 + ; active_sessions = 0 + ; peak_active_sessions = 0 + ; idle_wait_count = 0 + ; backend_run_count = 0 + ; backend_running = false + ; coordinator_start_count = 0 + ; active_coordinators = 0 + ; peak_active_coordinators = 0 + ; coordinator_yield_count = 0 + ; last_session_metrics = None + ; next_generation = ID.Worker.Generation.one + ; fail_next_spawn = None + } +;; + +let with_control f = + Mutex.lock control.mutex; + Fun.protect ~finally:(fun () -> Mutex.unlock control.mutex) f +;; + +let signal_reply (reply : startup_reply) result = + Mutex.lock reply.mutex; + if Option.is_none reply.result + then ( + reply.result <- Some result; + Condition.broadcast reply.condition); + Mutex.unlock reply.mutex +;; + +let await_reply (reply : startup_reply) = + Mutex.lock reply.mutex; + while Option.is_none reply.result do + Condition.wait reply.condition reply.mutex + done; + let result = Option.get reply.result in + Mutex.unlock reply.mutex; + result +;; + +let set_terminal_from_loop exception_ = + let error = + match exception_ with + | Failure message | Invalid_argument message -> message + | _ -> Printexc.to_string exception_ + in + with_control (fun () -> + Option.iter + (fun client -> Journal_worker.Private.fail_unrecoverable client error) + control.current_client; + Option.iter + (fun attachment -> signal_reply attachment.reply (Error error)) + control.pending_attachment; + control.pending_attachment <- None; + control.current_client <- None; + control.active_sessions <- 0; + control.state <- Terminal; + Condition.broadcast control.condition) +;; + +let coordinator_started () = + with_control (fun () -> + control.coordinator_start_count <- control.coordinator_start_count + 1; + control.active_coordinators <- control.active_coordinators + 1; + control.peak_active_coordinators + <- Int.max control.peak_active_coordinators control.active_coordinators) +;; + +let coordinator_stopped () = + with_control (fun () -> + control.active_coordinators <- control.active_coordinators - 1; + Condition.broadcast control.condition) +;; + +let run_coordinator environment session_switch attachment = + coordinator_started (); + Fun.protect ~finally:coordinator_stopped (fun () -> + Journal_worker.Private.run_session + attachment.startup + ~environment + ~session_switch + ~on_startup:(signal_reply attachment.reply) + ~on_idle_wait:(fun () -> + with_control (fun () -> + control.idle_wait_count <- control.idle_wait_count + 1; + Condition.broadcast control.condition)) + ~on_yield:(fun () -> + with_control (fun () -> + control.coordinator_yield_count <- control.coordinator_yield_count + 1; + Condition.broadcast control.condition))) +;; + +let run_session environment attachment = + Eio.Switch.run (fun session_switch -> + Eio.Fiber.fork_promise ~sw:session_switch (fun () -> + run_coordinator environment session_switch attachment) + |> Eio.Promise.await_exn) +;; + +let backend_started () = + with_control (fun () -> + control.backend_run_count <- control.backend_run_count + 1; + control.backend_running <- true; + Condition.broadcast control.condition) +;; + +let backend_stopped () = + with_control (fun () -> + control.backend_running <- false; + Condition.broadcast control.condition) +;; + +let worker_loop environment = + with_control (fun () -> + control.worker_domain_id <- Some (ID.Worker.Domain_id.of_domain_id (Domain.self ())); + Condition.broadcast control.condition); + let rec await_action () = + Mutex.lock control.mutex; + while + Option.is_none control.pending_attachment + && (not control.final_stop) + && not control.crash_requested + do + Condition.wait control.condition control.mutex + done; + if control.crash_requested + then ( + control.crash_requested <- false; + Mutex.unlock control.mutex; + failwith "Injected uncaught Worker Domain loop failure") + else if control.final_stop + then ( + Mutex.unlock control.mutex; + ()) + else ( + let attachment = Option.get control.pending_attachment in + control.pending_attachment <- None; + Mutex.unlock control.mutex; + let result = run_session environment attachment in + let session_metrics = Journal_worker.Private.metrics attachment.client in + with_control (fun () -> + control.last_session_metrics <- Some session_metrics; + control.current_client <- None; + control.active_sessions <- 0; + if control.final_stop then control.state <- Stopping else control.state <- Idle; + Condition.broadcast control.condition); + (match result with + | Journal_worker.Private.Session_startup_failed error -> + signal_reply attachment.reply (Error error) + | Session_stopped | Session_callback_failed _ -> ()); + await_action ()) + in + await_action () +;; + +let worker_entrypoint () = + try + Journal_worker_eio_backend.run (fun environment -> + backend_started (); + Fun.protect ~finally:backend_stopped (fun () -> worker_loop environment)) + with + | exception_ -> set_terminal_from_loop exception_ +;; + +let ensure_started () = + let action = + with_control (fun () -> + match control.state with + | Not_started -> + let injected_failure = control.fail_next_spawn in + control.fail_next_spawn <- None; + `Spawn injected_failure + | Idle -> `Ready + | Attached | Stopping -> `Busy + | Stopped -> `Error "Worker Domain subsystem is stopped" + | Terminal -> `Error "Worker Domain subsystem is terminal") + in + match action with + | `Ready -> Ok () + | `Busy -> Error "Worker Domain session is already attached" + | `Error error -> Error error + | `Spawn injected_failure -> + (try + Option.iter raise injected_failure; + let domain = Domain.spawn worker_entrypoint in + with_control (fun () -> + control.domain_handle <- Some domain; + control.spawn_count <- control.spawn_count + 1; + control.state <- Idle; + Condition.broadcast control.condition); + Ok () + with + | exception_ -> + let error = + match exception_ with + | Failure message | Invalid_argument message -> message + | _ -> Printexc.to_string exception_ + in + with_control (fun () -> + control.state <- Terminal; + Condition.broadcast control.condition); + Error ("Failed to spawn OCaml Worker Domain: " ^ error)) +;; + +let fresh_generation () = + with_control (fun () -> + let generation = control.next_generation in + if ID.Worker.Generation.equal generation ID.Worker.Generation.max_value + then failwith "Worker generation counter exhausted" + else control.next_generation <- ID.Worker.Generation.succ generation; + generation) +;; + +let start ~runtime_epoch service config = + match ensure_started () with + | Error _ as error -> error + | Ok () -> + let generation = fresh_generation () in + let client, startup = + Journal_worker.Private.prepare + ~runtime_epoch + ~worker_generation:generation + service + config + in + let packed_client = Journal_worker.Private.pack_client client in + let reply = + { mutex = Mutex.create (); condition = Condition.create (); result = None } + in + let attached = + with_control (fun () -> + match control.state with + | Idle -> + control.state <- Attached; + control.current_client <- Some packed_client; + control.pending_attachment <- Some { startup; client = packed_client; reply }; + control.active_sessions <- 1; + control.peak_active_sessions <- Int.max control.peak_active_sessions 1; + Condition.broadcast control.condition; + Ok () + | Attached | Stopping -> Error "Worker Domain session is already attached" + | Not_started -> Error "Worker Domain did not start" + | Stopped -> Error "Worker Domain subsystem is stopped" + | Terminal -> Error "Worker Domain subsystem is terminal") + in + (match attached with + | Error _ as error -> error + | Ok () -> + (match await_reply reply with + | Ok () -> Ok client + | Error error -> + with_control (fun () -> + while control.state = Attached do + Condition.wait control.condition control.mutex + done); + Error error)) +;; + +let stop client = + Journal_worker.Private.request_stop client; + Journal_worker.Private.await_stopped client; + with_control (fun () -> + while control.state = Attached do + Condition.wait control.condition control.mutex + done) +;; + +let diagnostics () = + with_control (fun () -> + let current_metrics = + Option.map Journal_worker.Private.metrics control.current_client + in + let session_metrics = + match current_metrics, control.last_session_metrics with + | Some metrics, _ | None, Some metrics -> Some metrics + | None, None -> None + in + let metric get default = Option.fold ~none:default ~some:get session_metrics in + let active_request_fibers = metric (fun metrics -> metrics.active_request_fibers) 0 in + let active_background_fibers = + metric (fun metrics -> metrics.active_background_fibers) 0 + in + { state = control.state + ; spawn_count = control.spawn_count + ; join_count = control.join_count + ; worker_domain_id = control.worker_domain_id + ; active_sessions = control.active_sessions + ; peak_active_sessions = control.peak_active_sessions + ; idle_wait_count = control.idle_wait_count + ; backend_run_count = control.backend_run_count + ; backend_running = control.backend_running + ; coordinator_start_count = control.coordinator_start_count + ; active_coordinators = control.active_coordinators + ; peak_active_coordinators = control.peak_active_coordinators + ; coordinator_yield_count = control.coordinator_yield_count + ; configured_concurrency_limit = + Option.map + (fun (metrics : Journal_worker.Private.metrics) -> + metrics.configured_concurrency_limit) + current_metrics + ; queued_requests = metric (fun metrics -> metrics.queued_requests) 0 + ; active_request_fibers + ; waiting_request_fibers = metric (fun metrics -> metrics.waiting_request_fibers) 0 + ; active_handlers = metric (fun metrics -> metrics.active_handlers) 0 + ; peak_active_handlers = metric (fun metrics -> metrics.peak_active_handlers) 0 + ; active_background_fibers + ; peak_active_background_fibers = + metric (fun metrics -> metrics.peak_active_background_fibers) 0 + ; logical_live_fibers = + (if control.backend_running then 1 else 0) + + control.active_coordinators + + active_request_fibers + + active_background_fibers + ; request_queue_wait_count = + metric (fun metrics -> metrics.request_queue_wait_count) 0 + ; max_request_queue_wait_ns = + metric (fun metrics -> metrics.max_request_queue_wait_ns) 0L + ; handler_wall_count = metric (fun metrics -> metrics.handler_wall_count) 0 + ; max_handler_wall_ns = metric (fun metrics -> metrics.max_handler_wall_ns) 0L + ; cancellation_unwind_count = + metric (fun metrics -> metrics.cancellation_unwind_count) 0 + ; max_cancellation_unwind_ns = + metric (fun metrics -> metrics.max_cancellation_unwind_ns) 0L + ; session_cancellation_duration_ns = + metric (fun metrics -> metrics.session_cancellation_duration_ns) None + ; shutdown_duration_ns = metric (fun metrics -> metrics.shutdown_duration_ns) None + ; backend_identity = "eio_posix" + ; backend_version = "1.2" + }) +;; + +let await_state state = + with_control (fun () -> + while control.state <> state do + Condition.wait control.condition control.mutex + done) +;; + +let await_idle_wait_count count = + with_control (fun () -> + while control.idle_wait_count < count do + Condition.wait control.condition control.mutex + done) +;; + +let final_shutdown () = + let handle, client = + with_control (fun () -> + match control.state with + | Stopped -> None, None + | Not_started -> + control.state <- Stopped; + Condition.broadcast control.condition; + None, None + | Idle | Terminal -> + control.final_stop <- true; + control.state <- Stopping; + Condition.broadcast control.condition; + control.domain_handle, None + | Attached -> + control.final_stop <- true; + control.state <- Stopping; + let client = control.current_client in + Option.iter Journal_worker.Private.request_stop_packed client; + Condition.broadcast control.condition; + control.domain_handle, client + | Stopping -> control.domain_handle, control.current_client) + in + Option.iter Journal_worker.Private.await_stopped_packed client; + Option.iter + (fun domain -> + let should_join = with_control (fun () -> control.join_count = 0) in + if should_join + then ( + Domain.join domain; + with_control (fun () -> control.join_count <- 1))) + handle; + with_control (fun () -> + control.current_client <- None; + control.pending_attachment <- None; + control.active_sessions <- 0; + control.state <- Stopped; + Condition.broadcast control.condition) +;; + +let crash_worker_loop () = + with_control (fun () -> + match control.state with + | Idle -> + control.crash_requested <- true; + Condition.broadcast control.condition + | Not_started | Attached | Stopping | Stopped | Terminal -> + invalid_arg "Journal_worker_runtime.For_testing.crash_worker_loop requires Idle") +;; + +let fail_next_spawn exception_ = + with_control (fun () -> + match control.state with + | Not_started -> control.fail_next_spawn <- Some exception_ + | Idle | Attached | Stopping | Stopped | Terminal -> + invalid_arg + "Journal_worker_runtime.For_testing.fail_next_spawn requires Not_started") +;; + +module For_testing = struct + let diagnostics = diagnostics + let await_state = await_state + let await_idle_wait_count = await_idle_wait_count + let final_shutdown = final_shutdown + let crash_worker_loop = crash_worker_loop + let fail_next_spawn = fail_next_spawn +end From aec5d2b7d19c351fbb8f476ce1db85121c0f5d82 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 19:03:51 -0700 Subject: [PATCH 03/40] lui migration: port worker service to Journal_worker Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- logseq_db_worker/lui/dune | 3 +- .../lui/logseq_db_worker_lui_service.ml | 601 ++++++++++++++++++ 2 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 logseq_db_worker/lui/logseq_db_worker_lui_service.ml diff --git a/logseq_db_worker/lui/dune b/logseq_db_worker/lui/dune index a4c8a12..00d72c0 100644 --- a/logseq_db_worker/lui/dune +++ b/logseq_db_worker/lui/dune @@ -6,7 +6,8 @@ journal_bounded_mailbox journal_worker_eio_backend journal_worker_runtime - journal_worker) + journal_worker + logseq_db_worker_lui_service) (libraries eio eio.core diff --git a/logseq_db_worker/lui/logseq_db_worker_lui_service.ml b/logseq_db_worker/lui/logseq_db_worker_lui_service.ml new file mode 100644 index 0000000..088d216 --- /dev/null +++ b/logseq_db_worker/lui/logseq_db_worker_lui_service.ml @@ -0,0 +1,601 @@ +module Db = Logseq_db_worker +module Pure = Logseq_db_worker_pure_reducer.Core +module Worker_runner = Logseq_db_worker_effect_runner.Effect_runner +module Sync = Logseq_sync_pure_reducer.Core +module Sync_runner = Logseq_sync_effect_runner.Effect_runner +module Protocol = Db.Protocol +module Overlay = Logseq_overlay_db.Database +module ID = Journal_worker_ids + +type graph_id = Logseq_db_types.Graph_types.Uuid.t +type graph = Logseq_db_types.Managed_graph.t + +type sync_phase = + | Offline + | Connecting + | Pulling + | Submitting + | Current + | Paused + | Failed + +type startup_failure_stage = + | During_authentication + | During_catalog + | During_local_restore + | During_bootstrap + | During_e2ee + +type startup_facts = + { authenticated : bool + ; catalog_loading : bool + ; awaiting_selection : bool + ; restoring_local : bool + ; bootstrapping : bool + ; awaiting_e2ee_password : bool + ; failure : startup_failure_stage option + ; account_generation : int + ; graph_generation : int + ; presentation_generation : int + } + +type local_deletion_stage = Logseq_sync_pure_reducer.Core.local_deletion_stage = + | Closing_graph + | Deleting_mirror + | Clearing_selection + +type local_deletion = Logseq_sync_pure_reducer.Core.local_deletion = + | Deletion_in_progress of local_deletion_stage + | Deletion_failed of local_deletion_stage + +type snapshot = + { sync_phase : sync_phase + ; catalog : graph list + ; selected_graph : graph_id option + ; applied_server_t : int option + ; timeline_presentation_pending : bool + ; startup : startup_facts + ; last_error : string option + ; local_deletion : local_deletion option + } + +type diagnostic_group = + { title : string + ; entries : (string * string) list + } + +type diagnostics = { groups : diagnostic_group list } + +type state = + { snapshot : snapshot + ; diagnostics : diagnostics + } + +type token_request = Worker_runner.id_token_request + +let token_request_id = Worker_runner.id_token_request_id + +type bootstrap_progress = + { graph_id : graph_id + ; received_bytes : int64 + ; total_bytes : int64 option + } + +let sync_phase = function + | Sync.Offline -> Offline + | Connecting -> Connecting + | Pulling -> Pulling + | Submitting -> Submitting + | Current -> Current + | Paused -> Paused + | Failed -> Failed +;; + +let failure_stage = function + | Sync.During_authentication -> During_authentication + | During_catalog -> During_catalog + | During_local_restore -> During_local_restore + | During_bootstrap -> During_bootstrap + | During_e2ee -> During_e2ee +;; + +let startup_facts (facts : Sync.startup_facts) = + { authenticated = facts.authenticated + ; catalog_loading = facts.catalog_loading + ; awaiting_selection = facts.awaiting_selection + ; restoring_local = facts.restoring_local + ; bootstrapping = facts.bootstrapping + ; awaiting_e2ee_password = facts.awaiting_e2ee_password + ; failure = Option.map failure_stage facts.failure + ; account_generation = facts.account_generation + ; graph_generation = facts.graph_generation + ; presentation_generation = facts.presentation_generation + } +;; + +let snapshot (value : Sync.snapshot) = + { sync_phase = sync_phase value.sync_phase + ; catalog = value.catalog + ; selected_graph = value.selected_graph + ; applied_server_t = value.applied_server_t + ; timeline_presentation_pending = value.timeline_presentation_pending + ; startup = startup_facts value.startup + ; last_error = value.last_error + ; local_deletion = value.local_deletion + } +;; + +let diagnostics (value : Sync.diagnostics) = + { groups = + List.map + (fun (group : Sync.diagnostic_group) -> + { title = group.title; entries = group.entries }) + value.groups + } +;; + +let client_state (value : Sync.state) = + { snapshot = snapshot value.snapshot; diagnostics = diagnostics value.diagnostics } +;; + +let bootstrap_progress (value : Sync.bootstrap_progress) = + { graph_id = value.graph_id + ; received_bytes = value.received_bytes + ; total_bytes = value.total_bytes + } +;; + +type client_command = + | Restore_local_account of { user_id : string } + | Reconcile_authenticated_user of { user_id : string option } + | Acknowledge_local_feed + | Acknowledge_timeline_presented + | Provide_token of + { request : token_request + ; token : string + } + | Reject_token of token_request + | Select_graph of Sync.graph_id + | Return_to_graph_picker + | Refresh_catalog + | Begin_online_recovery + | Submit_e2ee_password of string + | Delete_local_cache of Sync.graph_id + | Set_foreground of bool + +module Asset = struct + type priority = Logseq_sync_pure_reducer.Asset_transfer.priority = + | Foreground + | Background + + type failure = Logseq_sync_pure_reducer.Asset_transfer.failure = + | Network + | Not_found + | Checksum_mismatch + | Authentication + | Locked + | Storage_full + | Invalid_content of string + + type availability = Logseq_sync_pure_reducer.Asset_transfer.availability = + | Queued + | Downloading + | Ready of string + | Waiting_remote + | Waiting_network + | Waiting_unlock + | Failed of + { failure : failure + ; attempts : int + ; retry_scheduled : bool + } +end + +type asset_scope = Logseq_sync_pure_reducer.Core.graph_scope + +type asset_notice = Logseq_db_worker_pure_reducer.Core.asset_notice = + | Asset_availability of + { consumer : string + ; asset : Logseq_db_types.Graph_types.Uuid.t + ; availability : Logseq_sync_pure_reducer.Asset_transfer.availability + } + | Asset_demand_accepted of string + | Asset_backpressure of string + | Asset_capacity_available + | Upload_status of + { operation : Logseq_db_types.Graph_types.Uuid.t + ; asset : Logseq_db_types.Graph_types.Uuid.t + ; target : Logseq_db_types.Graph_types.Uuid.t + ; title : string + ; status : Logseq_db_worker_pure_reducer.Asset_upload.status + } + +type asset_command = + | Replace_asset_demand of + { consumer : string + ; priority : Logseq_sync_pure_reducer.Asset_transfer.priority + ; assets : Logseq_db_types.Asset_descriptor.t list + } + | Release_asset_demand of string + | Retry_asset of Logseq_db_types.Graph_types.Uuid.t + | Retry_upload of Logseq_db_types.Graph_types.Uuid.t + +type request = + | Import_asset of + { graph_generation : int + ; source : Logseq_db_types.Asset_import.t + } + | Client_command of client_command + | Graph_request of Protocol.request + | Get_graph_state + | Asset_command of + { graph_generation : int + ; command : asset_command + } + | Acquire_imported_file of + { scope : Logseq_sync_pure_reducer.Core.graph_scope + ; operation : Logseq_db_types.Graph_types.Uuid.t + } + | Acquire_asset_file of + { scope : Logseq_sync_pure_reducer.Core.graph_scope + ; handle : string + } + | Release_asset_file of + { scope : Logseq_sync_pure_reducer.Core.graph_scope + ; handle : string + } + +type response = + | Asset_imported of (Logseq_db_worker.import_receipt, string) result + | Client_command_completed + | Asset_file of (string * string) option + | Graph_response of Protocol.response + | Graph_state of Db.graph_state + +type push = + | Graph_push of Protocol.push + | Client_state_changed of state + | Need_id_token of token_request + | Bootstrap_progress of bootstrap_progress + | Graph_state_changed of Db.graph_state + | Asset_notice of + Logseq_sync_pure_reducer.Core.graph_scope + * Logseq_db_worker_pure_reducer.Core.asset_notice + +let invalidation_topic = ID.Worker.Push_topic.of_int 0 +let manager_topic = ID.Worker.Push_topic.of_int 1 +let auth_topic = ID.Worker.Push_topic.of_int 2 +let bootstrap_topic = ID.Worker.Push_topic.of_int 3 +let graph_state_topic = ID.Worker.Push_topic.of_int 4 +let asset_topic = ID.Worker.Push_topic.of_int 5 + +type dependencies = + { overlay : Overlay.dependencies + ; tls_authenticator : Sync_runner.tls_authenticator + ; secrets : Sync_runner.secrets + ; crypto : Sync_runner.crypto + } + +let dependencies ~overlay ~tls_authenticator ~secrets ~crypto = + { overlay; tls_authenticator; secrets; crypto } +;; + +let production_dependencies () = + let limits = + Logseq_overlay_db.Types. + { response_budget_bytes = Protocol.maximum_response_bytes + ; outbox_max_records = 4_096 + ; outbox_max_bytes = 8 * 1024 * 1024 + ; change_max_items = Protocol.maximum_changed_uuids + ; change_max_bytes = Protocol.maximum_push_bytes + ; dispatcher_capacity = 256 + ; wire_batch_max_bytes = Protocol.maximum_response_bytes + } + in + let overlay = + Overlay.dependencies + ~epoch_ms:(fun () -> Unix.gettimeofday () *. 1_000. |> Int64.of_float) + ~monotonic_ns:Mtime_clock.elapsed_ns + ~limits + |> Result.get_ok + in + let secrets = Sync_runner.apple_secrets () |> Result.get_ok in + let crypto = Sync_runner.apple_crypto () |> Result.get_ok in + let tls_authenticator = Sync_runner.system_tls_authenticator () |> Result.get_ok in + { overlay; tls_authenticator; secrets; crypto } +;; + +let publish context = function + | Pure.Asset_notice (scope, notice) -> + Journal_worker.Session_context.emit + context + ~topic:asset_topic + (Asset_notice (scope, notice)) + | Pure.Reply _ -> () + | Graph_push push -> + Journal_worker.Session_context.emit + context + ~topic:invalidation_topic + (Graph_push push) + | Sync_output output -> + (match output with + | State_changed state -> + Journal_worker.Session_context.emit + context + ~topic:manager_topic + (Client_state_changed (client_state state)) + | Bootstrap_progressed progress -> + Journal_worker.Session_context.emit + context + ~topic:bootstrap_topic + (Bootstrap_progress (bootstrap_progress progress))) + | Graph_state_changed state -> + Journal_worker.Session_context.emit + context + ~topic:graph_state_topic + (Graph_state_changed state) + | Diagnostic _ -> () +;; + +let sync_limits config = + Sync.limits + ~maximum_response_bytes:config.Db.Config.response_budget_bytes + ~maximum_artifact_bytes:(1024 * 1024 * 1024) + ~submission_batch_size:32 +;; + +let sync_dependencies dependencies context config id_token_provider = + let environment = Journal_worker.Session_context.environment context in + let clock = Eio.Stdenv.clock environment in + Result.bind + (Sync_runner.runtime + ~fork:(fun ~sw task -> Eio.Fiber.fork ~sw task) + ~sleep:(Eio.Time.sleep clock)) + (fun runtime -> + Result.bind + (Sync_runner.transport + ~websocket_liveness: + (Sync_runner.Ping_pong { interval_seconds = 30.; timeout_seconds = 10. }) + ~tls_authenticator:dependencies.tls_authenticator + ~network:(Eio.Stdenv.net environment) + ~clock) + (fun transport -> + Result.bind + (Sync_runner.local_store + ~application_support_directory: + config.Db.Config.application_support_directory) + (fun local_store -> + Result.bind + (Sync_runner.artifact_store + ~staging_directory: + (Filename.concat + config.application_support_directory + "sync-staging")) + (fun artifact_store -> + Sync_runner.dependencies + ~runtime + ~transport + ~local_store + ~artifact_store + ~secrets:dependencies.secrets + ~crypto:dependencies.crypto + ~id_token_provider)))) +;; + +let client_event = function + | Restore_local_account { user_id } -> + Pure.Sync_event (Sync.Restore_local_account { user_id }) + | Reconcile_authenticated_user { user_id } -> + Pure.Sync_event (Sync.Account_authenticated { user_id }) + | Acknowledge_local_feed -> Pure.Sync_event Sync.Local_feed_acknowledged + | Acknowledge_timeline_presented -> Pure.Sync_event Sync.Timeline_presented + | Provide_token _ | Reject_token _ -> + invalid_arg "token commands are handled by the service" + | Select_graph graph_id -> Pure.Sync_event (Sync.Graph_selected graph_id) + | Return_to_graph_picker -> Pure.Sync_event Sync.Graph_picker_requested + | Refresh_catalog -> Pure.Sync_event Sync.Catalog_refresh_requested + | Begin_online_recovery -> Pure.Sync_event Sync.Online_recovery_requested + | Submit_e2ee_password password -> + Pure.Sync_event (Sync.E2ee_password_submitted password) + | Delete_local_cache graph_id -> + Pure.Sync_event (Sync.Local_cache_deletion_requested graph_id) + | Set_foreground foreground -> Pure.Set_foreground foreground +;; + +let error_message = function + | Sync.Invalid_config message -> message +;; + +let sync_create_error_message = function + | Sync_runner.Invalid_create message -> message +;; + +let sync_dependency_error_message = function + | Sync_runner.Invalid_dependency message -> message +;; + +let worker_dependency_error_message = function + | Worker_runner.Invalid_dependency message -> message +;; + +let create ~(dependencies : dependencies) = + let module Session = struct + type t = + { worker : Db.t + ; token_cache : Worker_runner.id_token_cache + } + end + in + Journal_worker.Service.create + ~push_topic_count:6 + ~concurrency:(Journal_worker.Service.Concurrent { max_in_flight = 2 }) + ~data_directory:(fun config -> Ok config.Db.Config.application_support_directory) + ~init:(fun context config -> + let sw = Journal_worker.Session_context.switch context in + let event_sink = ref (fun (_ : Pure.event) -> ()) in + let token_cache = + Worker_runner.id_token_cache + ~wall_clock_s:Unix.gettimeofday + ~monotonic_ns:Mtime_clock.elapsed_ns + ~request:(fun request -> + Journal_worker.Session_context.emit + context + ~topic:auth_topic + (Need_id_token request)) + in + let id_token_provider = + Sync_runner.id_token_provider + ~acquire:(fun account -> Worker_runner.acquire_id_token token_cache ~account) + ~invalidate:(fun account ~token -> + Worker_runner.invalidate_id_token token_cache ~account ~token) + in + let (Managed_sync { base_url }) = config.Db.Config.target in + let selected = + match sync_limits config with + | Error error -> Error (error_message error) + | Ok limits -> + (match Sync.config ~managed_sync_origin:(Uri.of_string base_url) ~limits with + | Error error -> Error (error_message error) + | Ok sync_config -> + (match sync_dependencies dependencies context config id_token_provider with + | Error error -> Error (sync_dependency_error_message error) + | Ok runner_dependencies -> + (match + Sync_runner.create ~sw runner_dependencies ~post:(fun event -> + !event_sink (Pure.Sync_event event)) + with + | Error error -> Error (sync_create_error_message error) + | Ok runner -> + Ok + ( sync_config + , Worker_runner.sync_runner + ~stage_asset:(Sync_runner.stage_asset runner) + ~release_staging:(Sync_runner.release_staged_asset runner) + ~prune_staging:(Sync_runner.prune_staged_assets runner) + ~put_upload:(fun ~context intent ~current -> + match + Sync_runner.staged_asset_path + runner + ~scope:context.scope + ~file: + intent.Logseq_db_types.Asset_upload_intent.staged_file + with + | None -> + Error + Logseq_db_worker_pure_reducer.Asset_upload.Missing_source + | Some source_file -> + Sync_runner.upload_asset + runner + ~context + ~asset:intent.asset + ~version:intent.version + ~source_file + ~maximum_plaintext_bytes:(8 * 1024 * 1024) + ~current + |> Result.map_error (function + | Sync_runner.Upload_network -> + Logseq_db_worker_pure_reducer.Asset_upload.Network + | Upload_authentication | Upload_locked -> Authentication + | Upload_missing_source -> Missing_source + | Upload_size_rejected -> Size_rejected + | Upload_revoked_access -> Revoked_access + | Upload_invalid_content | Upload_cancelled -> + Invalid_content)) + ~submit_asset:(Sync_runner.run_scoped_asset runner) + ~delete_assets:(Sync_runner.delete_graph_assets runner) + ~close_assets:(Sync_runner.close_asset_scope runner) + ~retain_staged_file:(Sync_runner.retain_staged_file runner) + ~retain_asset_file:(Sync_runner.retain_asset_file runner) + ~release_asset_file:(Sync_runner.release_asset_file runner) + ~submit:(Sync_runner.submit runner) + ~shutdown:(fun () -> Sync_runner.shutdown runner) + ~decrypt_protected_value: + (Sync_runner.decrypt_protected_value runner) + ~encrypt_protected_values: + (Sync_runner.encrypt_protected_values runner) + () )))) + in + match selected with + | Error message -> Error message + | Ok (sync_config, selected_sync_runner) -> + (match + Worker_runner.runtime + ~sleep: + (Eio.Time.sleep + (Eio.Stdenv.clock (Journal_worker.Session_context.environment context))) + ~fork:(fun ~sw task -> Eio.Fiber.fork ~sw task) + with + | Error error -> Error (worker_dependency_error_message error) + | Ok runtime -> + let pure_config = Pure.config ~worker:config ~sync:sync_config in + (match + Worker_runner.dependencies + ~runtime + ~config + ~overlay:dependencies.overlay + ~sync_runner:selected_sync_runner + ~publish:(publish context) + with + | Error error -> Error (worker_dependency_error_message error) + | Ok runner_dependencies -> + (match Db.create ~sw ~config:pure_config ~runner_dependencies with + | Error (Db.Invalid_create message) -> Error message + | Ok worker -> + event_sink := Db.post worker; + Ok Session.{ worker; token_cache })))) + ~handle:(fun _context session request -> + match request with + | Import_asset { graph_generation; source } -> + Ok (Asset_imported (Db.import_asset session.worker ~graph_generation source)) + | Get_graph_state -> Ok (Graph_state (Db.graph_state session.worker)) + | Asset_command { graph_generation; command } -> + let event = + match command with + | Retry_upload operation -> + Pure.Upload_requested + { graph_generation + ; operation + ; event = Logseq_db_worker_pure_reducer.Asset_upload.Retry + } + | Replace_asset_demand { consumer; priority; assets } -> + Pure.Asset_requested + { graph_generation + ; event = + Logseq_sync_pure_reducer.Asset_transfer.Replace + { consumer; priority; assets } + } + | Release_asset_demand consumer -> + Pure.Asset_requested { graph_generation; event = Release consumer } + | Retry_asset asset -> + Pure.Asset_requested { graph_generation; event = Retry asset } + in + Db.post session.worker event; + Ok Client_command_completed + | Acquire_imported_file { scope; operation } -> + Ok (Asset_file (Db.retain_imported_file session.worker ~scope ~operation)) + | Acquire_asset_file { scope; handle } -> + Ok (Asset_file (Db.retain_asset_file session.worker ~scope ~handle)) + | Release_asset_file { scope; handle } -> + Db.release_asset_file session.worker ~scope ~handle; + Ok Client_command_completed + | Client_command (Provide_token { request; token }) -> + Worker_runner.provide_id_token session.token_cache request token; + Ok Client_command_completed + | Client_command (Reject_token request) -> + Worker_runner.reject_id_token session.token_cache request "host rejected request"; + Ok Client_command_completed + | Client_command (Reconcile_authenticated_user { user_id }) -> + Worker_runner.reconcile_authenticated_user session.token_cache ~user_id; + Db.post session.worker (client_event (Reconcile_authenticated_user { user_id })); + Ok Client_command_completed + | Client_command command -> + Db.post session.worker (client_event command); + Ok Client_command_completed + | Graph_request request -> Ok (Graph_response (Db.request session.worker request))) + ~shutdown:(fun session -> + Worker_runner.shutdown_id_token_cache session.token_cache; + Db.shutdown session.worker) + () +;; + +let service = create ~dependencies:(production_dependencies ()) From aeb2752f4716a8726c882255b60a3397860cf6e2 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 19:11:27 -0700 Subject: [PATCH 04/40] lui migration: app-layer shim, platform codec, native bridge - journal_view: Bonsai_swiftui_ui-shaped shim over Lui_elements - journal_ids/journal_environment: Id and Environment replacements - journal_platform: LJP2 codec + env-push (24) + notice (25/26/27) envelopes - journal_bridge/journal_lui_bridge.c: lui_ocaml bridge + startup payload - journal_lui_native/journal_pump: extension registry + cross-thread pump - Rename Ui/ID/Environment/Graph_service references across app modules - Drop logseq_db_worker/bonsai in favor of logseq_db_worker/lui Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/dune | 3 + app/journal_asset_import.ml | 4 +- app/journal_asset_import.mli | 4 +- app/journal_asset_policy.ml | 2 +- app/journal_asset_policy.mli | 2 +- app/journal_asset_runtime.ml | 4 +- app/journal_asset_runtime.mli | 6 +- app/journal_asset_settings.ml | 4 +- app/journal_asset_settings.mli | 2 +- app/journal_bridge.ml | 6 +- app/journal_bridge.mli | 27 +- app/journal_capture.ml | 4 +- app/journal_capture.mli | 4 +- app/journal_detail.ml | 4 +- app/journal_detail.mli | 8 +- app/journal_environment.ml | 200 ++ app/journal_environment.mli | 46 + app/journal_header.ml | 6 +- app/journal_header.mli | 30 +- app/journal_ids.ml | 87 + app/journal_ids.mli | 67 + app/journal_lui_bridge.c | 14 +- app/journal_lui_native.ml | 63 +- app/journal_lui_native.mli | 79 +- app/journal_media.ml | 2 +- app/journal_media.mli | 2 +- app/journal_media_runtime.ml | 2 +- app/journal_media_runtime.mli | 2 +- app/journal_media_view.ml | 4 +- app/journal_media_view.mli | 4 +- app/journal_native_collection.ml | 2 +- app/journal_native_collection.mli | 2 +- app/journal_platform.ml | 66 +- app/journal_platform.mli | 28 +- app/journal_routes.ml | 4 +- app/journal_row.ml | 2 +- app/journal_row.mli | 4 +- app/journal_startup.ml | 2 +- app/journal_startup.mli | 2 +- app/journal_symbols.ml | 2 +- app/journal_symbols.mli | 6 +- app/journal_timeline.ml | 2 +- app/journal_timeline.mli | 2 +- app/journal_timeline_state.ml | 2 +- app/journal_timeline_state.mli | 4 +- app/journal_uploads.ml | 2 +- app/journal_uploads.mli | 2 +- app/journal_view.ml | 1939 +++++++++++++++++ app/journal_view.mli | 886 ++++++++ app/journal_visual_tokens.ml | 6 +- app/journal_visual_tokens.mli | 4 +- dune-project | 2 +- logseq_db_worker/bonsai/dune | 16 - .../bonsai/logseq_db_worker_bonsai_service.ml | 592 ----- .../logseq_db_worker_bonsai_service.mli | 212 -- logseq_overlay_db.opam | 2 +- 56 files changed, 3522 insertions(+), 963 deletions(-) create mode 100644 app/journal_environment.ml create mode 100644 app/journal_environment.mli create mode 100644 app/journal_ids.ml create mode 100644 app/journal_ids.mli create mode 100644 app/journal_view.ml create mode 100644 app/journal_view.mli delete mode 100644 logseq_db_worker/bonsai/dune delete mode 100644 logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml delete mode 100644 logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.mli diff --git a/app/dune b/app/dune index 13f0bac..7e6ebe7 100644 --- a/app/dune +++ b/app/dune @@ -4,8 +4,11 @@ (modules application journal_bridge + journal_environment + journal_ids journal_lui_native journal_pump + journal_view journal_uploads journal_asset_import journal_asset_settings diff --git a/app/journal_asset_import.ml b/app/journal_asset_import.ml index b4c4563..28313c7 100644 --- a/app/journal_asset_import.ml +++ b/app/journal_asset_import.ml @@ -1,4 +1,4 @@ -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view module Uuid = Logseq_db_types.Graph_types.Uuid let decode ~target payload = @@ -48,7 +48,7 @@ let decode ~target payload = let extension = Ui.Native_widget.Extension.create - ~kind_id:(Bonsai_swiftui_spec.Id.Native_widget.Kind_id.of_int 2104) + ~kind_id:(Journal_ids.Native_widget.Kind_id.of_int 2104) ~version:1 ~capabilities:[ Stateful; Resource; Semantics ] ~encode_props:(fun props -> Bytes.of_string (Yojson.Basic.to_string props)) diff --git a/app/journal_asset_import.mli b/app/journal_asset_import.mli index c80f639..097b9ad 100644 --- a/app/journal_asset_import.mli +++ b/app/journal_asset_import.mli @@ -6,10 +6,10 @@ val decode val is_dismissal : string -> bool val view - : key:Bonsai_swiftui_ui.Key.t + : key:Journal_view.Key.t -> enabled:bool -> completion:(string * string option) option -> replacement:string option -> request:int -> on_select:(string -> unit) - -> Bonsai_swiftui_ui.View.t + -> Journal_view.View.t diff --git a/app/journal_asset_policy.ml b/app/journal_asset_policy.ml index e361875..18ecaef 100644 --- a/app/journal_asset_policy.ml +++ b/app/journal_asset_policy.ml @@ -1,6 +1,6 @@ module Asset = Logseq_db_types.Asset_descriptor module Graph = Logseq_db_types.Graph_types -module Transfer = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.Asset +module Transfer = Logseq_db_worker_lui.Logseq_db_worker_lui_service.Asset type reason = | Recent diff --git a/app/journal_asset_policy.mli b/app/journal_asset_policy.mli index ef471c5..9e3dc5c 100644 --- a/app/journal_asset_policy.mli +++ b/app/journal_asset_policy.mli @@ -1,6 +1,6 @@ module Asset = Logseq_db_types.Asset_descriptor module Graph = Logseq_db_types.Graph_types -module Transfer = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.Asset +module Transfer = Logseq_db_worker_lui.Logseq_db_worker_lui_service.Asset type reason = | Recent diff --git a/app/journal_asset_runtime.ml b/app/journal_asset_runtime.ml index afdcb30..7706a96 100644 --- a/app/journal_asset_runtime.ml +++ b/app/journal_asset_runtime.ml @@ -1,7 +1,7 @@ module Policy = Journal_asset_policy module Protocol = Logseq_db_worker.Protocol module Graph = Logseq_db_types.Graph_types -module Service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Service = Logseq_db_worker_lui.Logseq_db_worker_lui_service type t = { send : Service.request -> bool @@ -160,7 +160,7 @@ let reject t ~request_id = let notice t - (scope : Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.asset_scope) + (scope : Logseq_db_worker_lui.Logseq_db_worker_lui_service.asset_scope) notice = if t.generation = Some scope.graph_generation diff --git a/app/journal_asset_runtime.mli b/app/journal_asset_runtime.mli index 05b665c..225e787 100644 --- a/app/journal_asset_runtime.mli +++ b/app/journal_asset_runtime.mli @@ -1,7 +1,7 @@ type t val create - : send:(Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.request -> bool) + : send:(Logseq_db_worker_lui.Logseq_db_worker_lui_service.request -> bool) -> changed: (int option -> Journal_asset_policy.offline @@ -21,8 +21,8 @@ val receive : t -> Logseq_db_worker.Protocol.response -> bool val notice : t - -> Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.asset_scope - -> Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.asset_notice + -> Logseq_db_worker_lui.Logseq_db_worker_lui_service.asset_scope + -> Logseq_db_worker_lui.Logseq_db_worker_lui_service.asset_notice -> unit val visible : t -> consumer:string -> Logseq_db_types.Asset_descriptor.t list -> unit diff --git a/app/journal_asset_settings.ml b/app/journal_asset_settings.ml index ccd7f80..8555e59 100644 --- a/app/journal_asset_settings.ml +++ b/app/journal_asset_settings.ml @@ -1,4 +1,4 @@ -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view type event = | Days of Journal_asset_policy.settings @@ -28,7 +28,7 @@ let decode payload = let extension = Ui.Native_widget.Extension.create - ~kind_id:(Bonsai_swiftui_spec.Id.Native_widget.Kind_id.of_int 2106) + ~kind_id:(Journal_ids.Native_widget.Kind_id.of_int 2106) ~version:1 ~capabilities:[ Stateful; Semantics ] ~encode_props:(fun json -> Yojson.Basic.to_string json |> Bytes.of_string) diff --git a/app/journal_asset_settings.mli b/app/journal_asset_settings.mli index f48cf83..1bad36d 100644 --- a/app/journal_asset_settings.mli +++ b/app/journal_asset_settings.mli @@ -1,4 +1,4 @@ -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view type event = | Days of Journal_asset_policy.settings diff --git a/app/journal_bridge.ml b/app/journal_bridge.ml index 8232745..867c6ba 100644 --- a/app/journal_bridge.ml +++ b/app/journal_bridge.ml @@ -1,5 +1,5 @@ type hooks = - { init : int -> int -> string + { init : int -> int -> string -> string ; dispatch : Lui_protocol.event -> string ; extension_event : int -> string -> string -> string ; pump : unit -> string @@ -20,7 +20,9 @@ let hooks () = | Some hooks -> hooks | None -> invalid_arg "Journal_bridge.register was not called" -let initialize platform_code host_code = (hooks ()).init platform_code host_code +let initialize platform_code host_code payload = + (hooks ()).init platform_code host_code payload +;; let dispatch_lui event = (hooks ()).dispatch event diff --git a/app/journal_bridge.mli b/app/journal_bridge.mli index 58da576..382c701 100644 --- a/app/journal_bridge.mli +++ b/app/journal_bridge.mli @@ -5,39 +5,40 @@ protocol. *) type hooks = - { (** [platform_code -> host_code -> patch json]. Builds the app with the - lui backend for the given host profile, starts it, and returns the - initial patch batch. *) - init : int -> int -> string - ; (** Dispatches a standard lui UI event; returns the patch batch produced + { init : int -> int -> string -> string + (* [platform_code -> host_code -> payload -> patch json]. Builds the app + with the lui backend for the given host profile, decodes the startup + payload into the service config, starts it, and returns the initial + patch batch. *) + ; (* Dispatches a standard lui UI event; returns the patch batch produced by the dispatch + flush. *) dispatch : Lui_protocol.event -> string - ; (** [node -> name -> values_json -> patch json]. Forwards an extension + ; (* [node -> name -> values_json -> patch json]. Forwards an extension event from a registered journal native component. *) extension_event : int -> string -> string -> string - ; (** Drains the cross-thread work queue ([Journal_pump]) and flushes; + ; (* Drains the cross-thread work queue ([Journal_pump]) and flushes; returns the patch batch. *) pump : unit -> string - ; (** Host -> OCaml: an LJP2 platform envelope pushed by the host + ; (* Host -> OCaml: an LJP2 platform envelope pushed by the host (lifecycle, network, termination). Binary-safe string. *) platform_event : string -> unit - ; (** Host -> OCaml: an LJP2 response envelope completing an earlier + ; (* Host -> OCaml: an LJP2 response envelope completing an earlier platform request. Binary-safe string. *) platform_response : string -> unit - ; (** Tears the app down; returns the final patch batch. *) + ; (* Tears the app down; returns the final patch batch. *) dispose : unit -> string ; root_node : unit -> int } val register : hooks -> unit -(** OCaml -> host trampolines implemented by the C stub; the host installs +(* OCaml -> host trampolines implemented by the C stub; the host installs the underlying function pointers at startup. *) -(** Invokes the host wakeup callback so it schedules [journal_ocaml_pump] on +(* Invokes the host wakeup callback so it schedules [journal_ocaml_pump] on the app thread. *) external wakeup : unit -> unit = "journal_ml_wakeup" -(** Forwards one LJP2 request envelope to the host platform bridge (the host +(* Forwards one LJP2 request envelope to the host platform bridge (the host answers asynchronously through [platform_response]). *) external platform_request : string -> unit = "journal_ml_platform_request" diff --git a/app/journal_capture.ml b/app/journal_capture.ml index 22e9102..3dfa2a2 100644 --- a/app/journal_capture.ml +++ b/app/journal_capture.ml @@ -1,5 +1,5 @@ -module ID = Bonsai_swiftui_spec.Id -module Ui = Bonsai_swiftui_ui +module ID = Journal_ids +module Ui = Journal_view type phase = | Editing diff --git a/app/journal_capture.mli b/app/journal_capture.mli index c524434..fe838e1 100644 --- a/app/journal_capture.mli +++ b/app/journal_capture.mli @@ -1,5 +1,5 @@ -module ID = Bonsai_swiftui_spec.Id -module Ui = Bonsai_swiftui_ui +module ID = Journal_ids +module Ui = Journal_view type phase = | Editing diff --git a/app/journal_detail.ml b/app/journal_detail.ml index 424371d..0ae9618 100644 --- a/app/journal_detail.ml +++ b/app/journal_detail.ml @@ -1,5 +1,5 @@ -module ID = Bonsai_swiftui_spec.Id -module Ui = Bonsai_swiftui_ui +module ID = Journal_ids +module Ui = Journal_view module Blocks = Map.Make (String) type mode = diff --git a/app/journal_detail.mli b/app/journal_detail.mli index 6ab84bb..be4cd47 100644 --- a/app/journal_detail.mli +++ b/app/journal_detail.mli @@ -1,4 +1,4 @@ -module ID = Bonsai_swiftui_spec.Id +module ID = Journal_ids type mode = | Reading @@ -46,7 +46,7 @@ val reveal_id : t -> string option val request_back : t -> [ `Close ] val child_capture : t -> Journal_capture.t option val update_child_source : t -> string -> t -val apply_child_edit : t -> Bonsai_swiftui_ui.Event.Payload.text_edit -> t +val apply_child_edit : t -> Journal_view.Event.Payload.text_edit -> t val toggle_child_task : t -> t val fail : t -> message:string -> t val retry : t -> t * Journal_graph_request.t option @@ -85,10 +85,10 @@ val fail_retained_composer -> retained_composer val interrupt_retained_composer : retained_composer -> retained_composer -val reveal_outcome : t -> Bonsai_swiftui_ui.View.Native_list.outcome option +val reveal_outcome : t -> Journal_view.View.Native_list.outcome option val complete_reveal : t -> token:int64 - -> outcome:Bonsai_swiftui_ui.View.Native_list.outcome + -> outcome:Journal_view.View.Native_list.outcome -> t diff --git a/app/journal_environment.ml b/app/journal_environment.ml new file mode 100644 index 0000000..8d34650 --- /dev/null +++ b/app/journal_environment.ml @@ -0,0 +1,200 @@ +type edge_insets = + { left : float + ; top : float + ; right : float + ; bottom : float + } + +type brightness = + | Light + | Dark + +type orientation = + | Portrait + | Landscape + +type snapshot = + { viewport_width : float + ; viewport_height : float + ; device_pixel_ratio : float + ; text_scale : float + ; brightness : brightness + ; platform : string + ; locale : string + ; safe_area : edge_insets + ; keyboard_insets : edge_insets + ; accessible_navigation : bool + ; bold_text : bool + ; invert_colors : bool + ; disable_animations : bool + ; reduced_motion : bool + ; high_contrast : bool + ; orientation : orientation + ; pointer_kinds : int + } + +let equal_edge_insets left right = + Float.equal left.left right.left + && Float.equal left.top right.top + && Float.equal left.right right.right + && Float.equal left.bottom right.bottom +;; + +let equal left right = + Float.equal left.viewport_width right.viewport_width + && Float.equal left.viewport_height right.viewport_height + && Float.equal left.device_pixel_ratio right.device_pixel_ratio + && Float.equal left.text_scale right.text_scale + && left.brightness = right.brightness + && String.equal left.platform right.platform + && String.equal left.locale right.locale + && equal_edge_insets left.safe_area right.safe_area + && equal_edge_insets left.keyboard_insets right.keyboard_insets + && Bool.equal left.accessible_navigation right.accessible_navigation + && Bool.equal left.bold_text right.bold_text + && Bool.equal left.invert_colors right.invert_colors + && Bool.equal left.disable_animations right.disable_animations + && Bool.equal left.reduced_motion right.reduced_motion + && Bool.equal left.high_contrast right.high_contrast + && left.orientation = right.orientation + && Int.equal left.pointer_kinds right.pointer_kinds +;; + +let fallback = + { viewport_width = 0. + ; viewport_height = 0. + ; device_pixel_ratio = 1. + ; text_scale = 1. + ; brightness = Light + ; platform = "unknown" + ; locale = "en_US" + ; safe_area = { left = 0.; top = 0.; right = 0.; bottom = 0. } + ; keyboard_insets = { left = 0.; top = 0.; right = 0.; bottom = 0. } + ; accessible_navigation = false + ; bold_text = false + ; invert_colors = false + ; disable_animations = false + ; reduced_motion = false + ; high_contrast = false + ; orientation = Portrait + ; pointer_kinds = 0 + } +;; + +let number (fields : (string * Yojson.Basic.t) list) name = + match List.assoc_opt name fields with + | Some (`Float value) -> Ok value + | Some (`Int value) -> Ok (Float.of_int value) + | _ -> Error ("environment " ^ name ^ " is missing or not a number") +;; + +let boolean (fields : (string * Yojson.Basic.t) list) name = + match List.assoc_opt name fields with + | Some (`Bool value) -> Ok value + | _ -> Error ("environment " ^ name ^ " is missing or not a bool") +;; + +let string (fields : (string * Yojson.Basic.t) list) name = + match List.assoc_opt name fields with + | Some (`String value) -> Ok value + | _ -> Error ("environment " ^ name ^ " is missing or not a string") +;; + +let insets (fields : (string * Yojson.Basic.t) list) name = + match List.assoc_opt name fields with + | Some (`Assoc values) -> + let ( let* ) = Result.bind in + let* left = number values "left" in + let* top = number values "top" in + let* right = number values "right" in + let* bottom = number values "bottom" in + Ok { left; top; right; bottom } + | _ -> Error ("environment " ^ name ^ " is missing or not an object") +;; + +let decode_json (json : Yojson.Basic.t) = + match json with + | `Assoc fields -> + let ( let* ) = Result.bind in + let* viewport_width = number fields "viewportWidth" in + let* viewport_height = number fields "viewportHeight" in + let* device_pixel_ratio = number fields "devicePixelRatio" in + let* text_scale = number fields "textScale" in + let* platform = string fields "platform" in + let* locale = string fields "locale" in + let* safe_area = insets fields "safeArea" in + let* keyboard_insets = insets fields "keyboardInsets" in + let* accessible_navigation = boolean fields "accessibleNavigation" in + let* bold_text = boolean fields "boldText" in + let* invert_colors = boolean fields "invertColors" in + let* disable_animations = boolean fields "disableAnimations" in + let* reduced_motion = boolean fields "reducedMotion" in + let* high_contrast = boolean fields "highContrast" in + let* pointer_kinds = number fields "pointerKinds" in + let* brightness = + match List.assoc_opt "brightness" fields with + | Some (`String "light") -> Ok Light + | Some (`String "dark") -> Ok Dark + | _ -> Error "environment brightness is missing or unsupported" + in + let* orientation = + match List.assoc_opt "orientation" fields with + | Some (`String "portrait") -> Ok Portrait + | Some (`String "landscape") -> Ok Landscape + | _ -> Error "environment orientation is missing or unsupported" + in + Ok + { viewport_width + ; viewport_height + ; device_pixel_ratio + ; text_scale + ; brightness + ; platform + ; locale + ; safe_area + ; keyboard_insets + ; accessible_navigation + ; bold_text + ; invert_colors + ; disable_animations + ; reduced_motion + ; high_contrast + ; orientation + ; pointer_kinds = int_of_float pointer_kinds + } + | _ -> Error "environment snapshot must be a JSON object" +;; + +let encode_json snapshot = + let insets value = + `Assoc + [ "left", `Float value.left + ; "top", `Float value.top + ; "right", `Float value.right + ; "bottom", `Float value.bottom + ] + in + `Assoc + [ "viewportWidth", `Float snapshot.viewport_width + ; "viewportHeight", `Float snapshot.viewport_height + ; "devicePixelRatio", `Float snapshot.device_pixel_ratio + ; "textScale", `Float snapshot.text_scale + ; ( "brightness" + , `String (match snapshot.brightness with Light -> "light" | Dark -> "dark") ) + ; "platform", `String snapshot.platform + ; "locale", `String snapshot.locale + ; "safeArea", insets snapshot.safe_area + ; "keyboardInsets", insets snapshot.keyboard_insets + ; "accessibleNavigation", `Bool snapshot.accessible_navigation + ; "boldText", `Bool snapshot.bold_text + ; "invertColors", `Bool snapshot.invert_colors + ; "disableAnimations", `Bool snapshot.disable_animations + ; "reducedMotion", `Bool snapshot.reduced_motion + ; "highContrast", `Bool snapshot.high_contrast + ; ( "orientation" + , `String + (match snapshot.orientation with Portrait -> "portrait" | Landscape -> "landscape") + ) + ; "pointerKinds", `Int snapshot.pointer_kinds + ] +;; diff --git a/app/journal_environment.mli b/app/journal_environment.mli new file mode 100644 index 0000000..61a4fa3 --- /dev/null +++ b/app/journal_environment.mli @@ -0,0 +1,46 @@ +(** Host environment snapshot, replacing [Journal_environment]. The + native host pushes snapshots through the application platform channel; + the model stores the latest one. *) + +type edge_insets = + { left : float + ; top : float + ; right : float + ; bottom : float + } + +type brightness = + | Light + | Dark + +type orientation = + | Portrait + | Landscape + +type snapshot = + { viewport_width : float + ; viewport_height : float + ; device_pixel_ratio : float + ; text_scale : float + ; brightness : brightness + ; platform : string + ; locale : string + ; safe_area : edge_insets + ; keyboard_insets : edge_insets + ; accessible_navigation : bool + ; bold_text : bool + ; invert_colors : bool + ; disable_animations : bool + ; reduced_motion : bool + ; high_contrast : bool + ; orientation : orientation + ; pointer_kinds : int + } + +val equal : snapshot -> snapshot -> bool + +(** A neutral default used before the host delivers the first snapshot. *) +val fallback : snapshot + +val decode_json : Yojson.Basic.t -> (snapshot, string) result +val encode_json : snapshot -> Yojson.Basic.t diff --git a/app/journal_header.ml b/app/journal_header.ml index 84aeb32..c1b67b4 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -1,5 +1,5 @@ -module Graph_service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service -module Ui = Bonsai_swiftui_ui +module Graph_service = Logseq_db_worker_lui.Logseq_db_worker_lui_service +module Ui = Journal_view module Context = struct type t = @@ -21,7 +21,7 @@ let test_id id view = V.with_test_id (Ui.Test_id.string id) view let chrome = Ui.Native_widget.Extension.create - ~kind_id:(Bonsai_swiftui_spec.Id.Native_widget.Kind_id.of_int 2103) + ~kind_id:(Journal_ids.Native_widget.Kind_id.of_int 2103) ~version:2 ~capabilities:[ Stateful; Semantics ] ~encode_props:(fun props -> Yojson.Basic.to_string props |> Bytes.of_string) diff --git a/app/journal_header.mli b/app/journal_header.mli index 2c2eb1e..481bf16 100644 --- a/app/journal_header.mli +++ b/app/journal_header.mli @@ -7,28 +7,28 @@ module Context : sig end val view - : key:Bonsai_swiftui_ui.Key.t + : key:Journal_view.Key.t -> platform:string -> context:Context.t - -> sync_phase:Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.sync_phase option + -> sync_phase:Logseq_db_worker_lui.Logseq_db_worker_lui_service.sync_phase option -> sync_error:string option - -> on_error_info:Bonsai_swiftui_ui.Event.Handler.t option - -> on_account_action:Bonsai_swiftui_ui.Event.Handler.t option + -> on_error_info:Journal_view.Event.Handler.t option + -> on_account_action:Journal_view.Event.Handler.t option -> local_deletion_available:bool - -> on_journals:Bonsai_swiftui_ui.Event.Handler.t - -> on_favorites:Bonsai_swiftui_ui.Event.Handler.t - -> on_capture:Bonsai_swiftui_ui.Event.Handler.t + -> on_journals:Journal_view.Event.Handler.t + -> on_favorites:Journal_view.Event.Handler.t + -> on_capture:Journal_view.Event.Handler.t -> capture_enabled:bool - -> body:Bonsai_swiftui_ui.View.Body.t - -> Bonsai_swiftui_ui.View.Body.t + -> body:Journal_view.View.Body.t + -> Journal_view.View.Body.t val feedback - : key:Bonsai_swiftui_ui.Key.t + : key:Journal_view.Key.t -> top:bool -> visible:bool - -> compact:Bonsai_swiftui_ui.View.t - -> expanded:Bonsai_swiftui_ui.View.t - -> Bonsai_swiftui_ui.View.Body.t - -> Bonsai_swiftui_ui.View.Body.t + -> compact:Journal_view.View.t + -> expanded:Journal_view.View.t + -> Journal_view.View.Body.t + -> Journal_view.View.Body.t -val date_header : title:string -> Bonsai_swiftui_ui.View.t +val date_header : title:string -> Journal_view.View.t diff --git a/app/journal_ids.ml b/app/journal_ids.ml new file mode 100644 index 0000000..7dcf2ac --- /dev/null +++ b/app/journal_ids.ml @@ -0,0 +1,87 @@ +module type Int64_id = sig + type t = private int64 + + val of_int64 : int64 -> t + val to_int64 : t -> int64 + val compare : t -> t -> int + val equal : t -> t -> bool + val succ : t -> t + val pred : t -> t + val zero : t + val one : t + val max_value : t +end + +module type Int_id = sig + type t = private int + + val of_int : int -> t + val to_int : t -> int + val compare : t -> t -> int + val equal : t -> t -> bool +end + +module type String_id = sig + type t = private string + + val of_string : string -> t + val to_string : t -> string + val compare : t -> t -> int + val equal : t -> t -> bool +end + +module Int64_id_make () : Int64_id = struct + type t = int64 + + let of_int64 value = value + let to_int64 value = value + let compare = Int64.compare + let equal = Int64.equal + let succ = Int64.succ + let pred = Int64.pred + let zero = 0L + let one = 1L + let max_value = Int64.max_int +end + +module Int_id_make () : Int_id = struct + type t = int + + let of_int value = value + let to_int value = value + let compare = Int.compare + let equal = Int.equal +end + +module String_id_make () : String_id = struct + type t = string + + let of_string value = value + let to_string value = value + let compare = String.compare + let equal = String.equal +end + +module Text_input = struct + module Session_id = Int64_id_make () + module Document_revision = Int64_id_make () + module Local_revision = Int64_id_make () + + type session_id = Session_id.t + type document_revision = Document_revision.t + type local_revision = Local_revision.t +end + +module Navigation = struct + module Page_key = String_id_make () + + type page_key = Page_key.t +end + +module Native_widget = struct + module Kind_id = Int_id_make () + module Event_id = Int_id_make () + + type kind_id = Kind_id.t + type event_id = Event_id.t +end diff --git a/app/journal_ids.mli b/app/journal_ids.mli new file mode 100644 index 0000000..882a06b --- /dev/null +++ b/app/journal_ids.mli @@ -0,0 +1,67 @@ +(** Application-layer identity types, replacing [Journal_ids] for + the journal UI. *) + +module type Int64_id = sig + type t = private int64 + + val of_int64 : int64 -> t + val to_int64 : t -> int64 + val compare : t -> t -> int + val equal : t -> t -> bool + val succ : t -> t + val pred : t -> t + val zero : t + val one : t + val max_value : t +end + +module type Int_id = sig + type t = private int + + val of_int : int -> t + val to_int : t -> int + val compare : t -> t -> int + val equal : t -> t -> bool +end + +module type String_id = sig + type t = private string + + val of_string : string -> t + val to_string : t -> string + val compare : t -> t -> int + val equal : t -> t -> bool +end + +(** Text-input session and document revision identities. *) +module Text_input : sig + type session_id = private int64 + + module Session_id : Int64_id with type t = session_id + + type document_revision = private int64 + + module Document_revision : Int64_id with type t = document_revision + + type local_revision = private int64 + + module Local_revision : Int64_id with type t = local_revision +end + +(** Declarative navigation identities. *) +module Navigation : sig + type page_key = private string + + module Page_key : String_id with type t = page_key +end + +(** Registered native-widget extension identities. *) +module Native_widget : sig + type kind_id = private int + + module Kind_id : Int_id with type t = kind_id + + type event_id = private int + + module Event_id : Int_id with type t = event_id +end diff --git a/app/journal_lui_bridge.c b/app/journal_lui_bridge.c index 2016168..344c9aa 100644 --- a/app/journal_lui_bridge.c +++ b/app/journal_lui_bridge.c @@ -43,7 +43,9 @@ static value copy_bytes(const char *data, int32_t length) { LUI_EXPORT int32_t lui_ocaml_start( lui_patch_callback callback, int32_t platform_code, - int32_t host_code) { + int32_t host_code, + const char *payload_data, + int32_t payload_length) { patch_callback = callback; if (!runtime_started) { char *arguments[] = {"journal_lui_ocaml", NULL}; @@ -55,10 +57,16 @@ LUI_EXPORT int32_t lui_ocaml_start( if (initialize == NULL) { return 0; } - return emit_patch(caml_callback2_exn( + CAMLparam0(); + CAMLlocal2(payload_value, result); + payload_value = copy_bytes(payload_data, payload_length); + result = caml_callback3_exn( *initialize, Val_long(platform_code), - Val_long(host_code))); + Val_long(host_code), + payload_value); + int32_t accepted = emit_patch(result); + CAMLreturnT(int32_t, accepted); } static int dispatch_long(const char *name, int64_t node) { diff --git a/app/journal_lui_native.ml b/app/journal_lui_native.ml index 28990a6..789cc2a 100644 --- a/app/journal_lui_native.ml +++ b/app/journal_lui_native.ml @@ -48,31 +48,6 @@ let registry = freeze registry; registry -let mount ?key ~payload ~children identifier context parent = - let node = Lui_ui.extension context identifier in - Option.iter (Lui_ui.key context node) key; - Lui_ui.extension_property context node "payload" (StringValue payload); - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - List.iter (fun child -> ignore (child context (Some node))) children; - node - -let chrome ?key ~payload children : Lui_elements.t = - fun context parent -> mount ?key ~payload ~children chrome_identifier context parent - -let asset_import ?key ~payload : Lui_elements.t = - fun context parent -> mount ?key ~payload ~children:[] asset_import_identifier context parent - -let media ?key ~payload : Lui_elements.t = - fun context parent -> mount ?key ~payload ~children:[] media_identifier context parent - -let asset_settings ?key ~payload children : Lui_elements.t = - fun context parent -> mount ?key ~payload ~children asset_settings_identifier context parent - -let list ?key ~payload : Lui_elements.t = - fun context parent -> mount ?key ~payload ~children:[] list_identifier context parent - type event = { identifier : string ; node : int @@ -96,3 +71,41 @@ let decode_event = function Some { identifier; node; event_id; payload } | _ -> None) | _ -> None + +let mount ?key ~payload ~children ?on_event identifier context parent = + let node = Lui_ui.extension context identifier in + Option.iter (Lui_ui.key context node) key; + Lui_ui.extension_property context node "payload" (StringValue payload); + Option.iter + (fun handler -> + Lui_ui.on_event context node (fun raw -> + match decode_event raw with + | Some event -> handler event + | None -> ())) + on_event; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter (fun child -> ignore (child context (Some node))) children; + node + +let chrome ?key ~payload ?on_event children : Lui_elements.t = + fun context parent -> + mount ?key ~payload ~children ?on_event chrome_identifier context parent + +let asset_import ?key ~payload ?on_event () : Lui_elements.t = + fun context parent -> + mount ?key ~payload ~children:[] ?on_event asset_import_identifier context parent + +let media ?key ~payload ?on_event children : Lui_elements.t = + fun context parent -> + mount ?key ~payload ~children ?on_event media_identifier context parent + +let asset_settings ?key ~payload ?on_event children : Lui_elements.t = + fun context parent -> + mount ?key ~payload ~children ?on_event asset_settings_identifier context parent + +let list ?key ~payload ?on_event children : Lui_elements.t = + fun context parent -> + mount ?key ~payload ~children ?on_event list_identifier context parent + diff --git a/app/journal_lui_native.mli b/app/journal_lui_native.mli index cb400e4..3580a39 100644 --- a/app/journal_lui_native.mli +++ b/app/journal_lui_native.mli @@ -1,12 +1,12 @@ (** Journal-specific lui extension components. - Replaces the [Ui.Native_widget.Extension] registrations that previously - carried kinds 2103-2106 over the bonsai_swiftui native-widget channel. - Each component ships its properties as one [payload] string field holding - the same JSON object the Swift [Properties] structs already decode, and - reports events through one ["event"] extension event with [id] (int) and - [payload] (JSON string) fields, matching the old - [BonsaiNativeEvent(id, payload)] contract. *) + Replaces the [Ui.Native_widget.Extension] registrations that previously + carried kinds 2103-2106 over the bonsai_swiftui native-widget channel. + Each component ships its properties as one [payload] string field holding + the same JSON object the Swift [Properties] structs already decode, and + reports events through one ["event"] extension event with [id] (int) and + [payload] (JSON string) fields, matching the old + [BonsaiNativeEvent(id, payload)] contract. *) (** Lui extension identifiers (slugs). *) val chrome_identifier : string @@ -19,20 +19,6 @@ val list_identifier : string (** Extension schemas shared with the Apple/Flutter hosts. *) val registry : Lui_extension.extension_registry -(** Mount elements mirroring the old [Ui.Native_widget.widget] calls. - [payload] is the JSON-encoded properties object for that component - (the same JSON the previous [~encode_props] produced). *) -val chrome : ?key:string -> payload:string -> Lui_elements.t list -> Lui_elements.t - -val asset_import : ?key:string -> payload:string -> Lui_elements.t -val media : ?key:string -> payload:string -> Lui_elements.t -val asset_settings : ?key:string -> payload:string -> Lui_elements.t list -> Lui_elements.t - -(** Native virtualized collection (grouped sections, scroll positioning, - visible-range paging, swipe actions). Rows are described inside the - [payload] JSON rather than as children. *) -val list : ?key:string -> payload:string -> Lui_elements.t - (** A journal extension event decoded from the lui event stream. *) type event = { identifier : string @@ -44,3 +30,54 @@ type event = (** Decodes a lui [ExtensionEvent] into a journal [event]; returns [None] for events that are not journal extension events or are malformed. *) val decode_event : Lui_protocol.event -> event option + +(** Low-level mount helper shared by the element constructors and the + [Journal_view.Native_widget] shim. [payload] is the JSON-encoded + properties object (the same JSON the previous [~encode_props] produced); + [on_event] receives decoded journal extension events. *) +val mount + : ?key:string + -> payload:string + -> children:Lui_elements.t list + -> ?on_event:(event -> unit) + -> string + -> Lui_elements.t + +val chrome + : ?key:string + -> payload:string + -> ?on_event:(event -> unit) + -> Lui_elements.t list + -> Lui_elements.t + +val asset_import + : ?key:string + -> payload:string + -> ?on_event:(event -> unit) + -> unit + -> Lui_elements.t + +val media + : ?key:string + -> payload:string + -> ?on_event:(event -> unit) + -> Lui_elements.t list + -> Lui_elements.t + +val asset_settings + : ?key:string + -> payload:string + -> ?on_event:(event -> unit) + -> Lui_elements.t list + -> Lui_elements.t + +(** Native virtualized collection (grouped sections, scroll positioning, + visible-range paging, swipe actions). Section and row structure rides in + [payload]; each row's content element mounts as an extension child in the + order described by the payload's content indexes. *) +val list + : ?key:string + -> payload:string + -> ?on_event:(event -> unit) + -> Lui_elements.t list + -> Lui_elements.t diff --git a/app/journal_media.ml b/app/journal_media.ml index 744ce40..4b57b47 100644 --- a/app/journal_media.ml +++ b/app/journal_media.ml @@ -1,4 +1,4 @@ -module Service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Service = Logseq_db_worker_lui.Logseq_db_worker_lui_service module Asset = Logseq_db_types.Asset_descriptor type ticket = diff --git a/app/journal_media.mli b/app/journal_media.mli index 30726f7..63729fb 100644 --- a/app/journal_media.mli +++ b/app/journal_media.mli @@ -1,4 +1,4 @@ -module Service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Service = Logseq_db_worker_lui.Logseq_db_worker_lui_service module Asset = Logseq_db_types.Asset_descriptor type ticket = private diff --git a/app/journal_media_runtime.ml b/app/journal_media_runtime.ml index bf2018d..9e1b940 100644 --- a/app/journal_media_runtime.ml +++ b/app/journal_media_runtime.ml @@ -1,4 +1,4 @@ -module Service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Service = Logseq_db_worker_lui.Logseq_db_worker_lui_service module Asset = Logseq_db_types.Asset_descriptor module P = Journal_media module G = Logseq_db_types.Graph_types diff --git a/app/journal_media_runtime.mli b/app/journal_media_runtime.mli index 5587db5..504e478 100644 --- a/app/journal_media_runtime.mli +++ b/app/journal_media_runtime.mli @@ -1,4 +1,4 @@ -module Service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Service = Logseq_db_worker_lui.Logseq_db_worker_lui_service module Asset = Logseq_db_types.Asset_descriptor type ticket diff --git a/app/journal_media_view.ml b/app/journal_media_view.ml index dd05154..de4928a 100644 --- a/app/journal_media_view.ml +++ b/app/journal_media_view.ml @@ -1,8 +1,8 @@ -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view let extension = Ui.Native_widget.Extension.create - ~kind_id:(Bonsai_swiftui_spec.Id.Native_widget.Kind_id.of_int 2105) + ~kind_id:(Journal_ids.Native_widget.Kind_id.of_int 2105) ~version:1 ~capabilities:[ Stateful; Semantics ] ~encode_props:(fun json -> Yojson.Basic.to_string json |> Bytes.of_string) diff --git a/app/journal_media_view.mli b/app/journal_media_view.mli index 7056625..d93b25d 100644 --- a/app/journal_media_view.mli +++ b/app/journal_media_view.mli @@ -4,5 +4,5 @@ val view -> media:Journal_media_runtime.view option -> editable:bool -> on_event:(string -> unit) - -> Bonsai_swiftui_ui.View.t - -> Bonsai_swiftui_ui.View.t + -> Journal_view.View.t + -> Journal_view.View.t diff --git a/app/journal_native_collection.ml b/app/journal_native_collection.ml index 899f8dc..ad5c20c 100644 --- a/app/journal_native_collection.ml +++ b/app/journal_native_collection.ml @@ -1,4 +1,4 @@ -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view module V = Ui.View type row = diff --git a/app/journal_native_collection.mli b/app/journal_native_collection.mli index 5f05f16..7bc7c53 100644 --- a/app/journal_native_collection.mli +++ b/app/journal_native_collection.mli @@ -1,4 +1,4 @@ -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view type row = { id : string diff --git a/app/journal_platform.ml b/app/journal_platform.ml index 768c298..bd400d3 100644 --- a/app/journal_platform.ml +++ b/app/journal_platform.ml @@ -1,4 +1,4 @@ -module Graph_service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Graph_service = Logseq_db_worker_lui.Logseq_db_worker_lui_service type network_lifecycle = | Backgrounded of { generation : int64 } @@ -200,3 +200,67 @@ let decode_termination_ready_response bytes = | [ ("ready", `Bool true) ] -> Ok () | _ -> Error "termination-ready response fields are invalid")) ;; + +type notice_result = + | Notice_action + | Notice_dismiss + | Notice_swipe + | Notice_timeout + +let decode_environment_event bytes = + Result.bind (decode_envelope [ 24 ] bytes) (fun payload -> + match + Yojson.Basic.from_string (Bytes.to_string payload) + |> Journal_environment.decode_json + with + | Ok snapshot -> Ok snapshot + | Error error -> Error ("environment event: " ^ error) + | exception Yojson.Json_error _ -> Error "environment event is not valid JSON") +;; + +let is_environment_event bytes = + match decode_envelope [ 24 ] bytes with + | Ok _ -> true + | Error _ -> false +;; + +let show_notice_request ~token ~message ~action_label ~duration_ms = + `Assoc + [ "token", `String (Int64.to_string token) + ; "message", `String message + ; ( "actionLabel" + , match action_label with + | Some label -> `String label + | None -> `Null ) + ; "durationMs", `Int duration_ms + ] + |> Yojson.Safe.to_string + |> Bytes.of_string + |> encode_envelope 25 + |> Result.get_ok +;; + +let decode_notice_response ~token bytes = + Result.bind (decode_envelope [ 26 ] bytes) (fun payload -> + decode_json_object "notice response" payload (fun fields -> + match + List.assoc_opt "token" fields, List.assoc_opt "result" fields + with + | Some (`String actual), Some (`String result) + when String.equal actual (Int64.to_string token) -> + (match result with + | "action" -> Ok Notice_action + | "dismiss" -> Ok Notice_dismiss + | "swipe" -> Ok Notice_swipe + | "timeout" -> Ok Notice_timeout + | _ -> Error "notice response result is unsupported") + | _ -> Error "notice response fields are invalid")) +;; + +let notice_cancel_request ~token = + `Assoc [ "token", `String (Int64.to_string token) ] + |> Yojson.Safe.to_string + |> Bytes.of_string + |> encode_envelope 27 + |> Result.get_ok +;; diff --git a/app/journal_platform.mli b/app/journal_platform.mli index 4d6a9f2..786c7ce 100644 --- a/app/journal_platform.mli +++ b/app/journal_platform.mli @@ -18,7 +18,7 @@ val timeline_presented_request : bytes val decode_timeline_presented : bytes -> (unit, string) result val id_token_request - : Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.token_request + : Logseq_db_worker_lui.Logseq_db_worker_lui_service.token_request -> bytes val decode_id_token_response : challenge_id:string -> bytes -> (string, string) result @@ -32,3 +32,29 @@ val is_prepare_to_terminate_event : bytes -> bool val termination_ready_request : bytes val decode_termination_ready_response : bytes -> (unit, string) result + +type notice_result = + | Notice_action + | Notice_dismiss + | Notice_swipe + | Notice_timeout + +(** Host -> OCaml environment snapshot push (tag 24). *) +val decode_environment_event : bytes -> (Journal_environment.snapshot, string) result +val is_environment_event : bytes -> bool + +(** OCaml -> host notice request (tag 25); response arrives on tag 26. *) +val show_notice_request + : token:int64 + -> message:string + -> action_label:string option + -> duration_ms:int + -> bytes + +val decode_notice_response + : token:int64 + -> bytes + -> (notice_result, string) result + +(** OCaml -> host request cancelling a pending notice (tag 27). *) +val notice_cancel_request : token:int64 -> bytes diff --git a/app/journal_routes.ml b/app/journal_routes.ml index 2e92974..29055df 100644 --- a/app/journal_routes.ml +++ b/app/journal_routes.ml @@ -70,7 +70,7 @@ let track_detail_session t detail = next_session = Int64.max t.next_session - (Int64.succ (Bonsai_swiftui_spec.Id.Text_input.Session_id.to_int64 session)) + (Int64.succ (Journal_ids.Text_input.Session_id.to_int64 session)) } ;; @@ -276,7 +276,7 @@ let runtime_replaced t = ; request_generation = Int64.succ view.request_generation ; session_number = Int64.succ - (Bonsai_swiftui_spec.Id.Text_input.Session_id.to_int64 + (Journal_ids.Text_input.Session_id.to_int64 (Journal_detail.session_id view.detail)) } } diff --git a/app/journal_row.ml b/app/journal_row.ml index 7861cce..2b88ddb 100644 --- a/app/journal_row.ml +++ b/app/journal_row.ml @@ -1,4 +1,4 @@ -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view module V = Ui.View let view ~render_media ~show_timestamp (entry : Journal_graph_projection.timeline_entry) = diff --git a/app/journal_row.mli b/app/journal_row.mli index 4f206d8..f801e6a 100644 --- a/app/journal_row.mli +++ b/app/journal_row.mli @@ -1,5 +1,5 @@ val view - : render_media:(root:string -> Bonsai_swiftui_ui.View.t -> Bonsai_swiftui_ui.View.t) + : render_media:(root:string -> Journal_view.View.t -> Journal_view.View.t) -> show_timestamp:bool -> Journal_graph_projection.timeline_entry - -> Bonsai_swiftui_ui.View.t + -> Journal_view.View.t diff --git a/app/journal_startup.ml b/app/journal_startup.ml index 7fc0ad9..809ab1a 100644 --- a/app/journal_startup.ml +++ b/app/journal_startup.ml @@ -1,4 +1,4 @@ -module Graph_service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Graph_service = Logseq_db_worker_lui.Logseq_db_worker_lui_service module Error = struct type t = Invalid of string diff --git a/app/journal_startup.mli b/app/journal_startup.mli index d92f738..b9f7d7c 100644 --- a/app/journal_startup.mli +++ b/app/journal_startup.mli @@ -43,7 +43,7 @@ type startup_state = } val derive - : snapshot:Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.snapshot + : snapshot:Logseq_db_worker_lui.Logseq_db_worker_lui_service.snapshot -> graph:Logseq_db_worker.graph_state -> startup_state diff --git a/app/journal_symbols.ml b/app/journal_symbols.ml index f79896e..242bd4e 100644 --- a/app/journal_symbols.ml +++ b/app/journal_symbols.ml @@ -1,4 +1,4 @@ -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view type t = | Journals diff --git a/app/journal_symbols.mli b/app/journal_symbols.mli index e7ea8b1..ee7c0cc 100644 --- a/app/journal_symbols.mli +++ b/app/journal_symbols.mli @@ -20,8 +20,8 @@ val for_task_state : Journal_model.task_state -> t val name : t -> string val create - : ?key:Bonsai_swiftui_ui.Key.t + : ?key:Journal_view.Key.t -> ?size:float - -> ?color:Bonsai_swiftui_ui.Style.Color.t + -> ?color:Journal_view.Style.Color.t -> t - -> Bonsai_swiftui_ui.View.t + -> Journal_view.View.t diff --git a/app/journal_timeline.ml b/app/journal_timeline.ml index a99ae3f..0138bc6 100644 --- a/app/journal_timeline.ml +++ b/app/journal_timeline.ml @@ -1,5 +1,5 @@ module Timeline = Journal_timeline_state -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view module V = Ui.View let for_block handler block_id = diff --git a/app/journal_timeline.mli b/app/journal_timeline.mli index e6afdd3..2fb08c0 100644 --- a/app/journal_timeline.mli +++ b/app/journal_timeline.mli @@ -1,4 +1,4 @@ -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view val loading_view : unit -> Ui.View.t diff --git a/app/journal_timeline_state.ml b/app/journal_timeline_state.ml index 0dc6a0f..79839ff 100644 --- a/app/journal_timeline_state.ml +++ b/app/journal_timeline_state.ml @@ -46,7 +46,7 @@ type t = ; visible_last_exclusive : int ; scroll_generation : int64 ; scroll_target : (int64 * int * string) option - ; scroll_outcome : Bonsai_swiftui_ui.View.Native_list.outcome option + ; scroll_outcome : Journal_view.View.Native_list.outcome option ; visible_demand : request list option ; pending : (int64 * request) option ; recovery : recovery option diff --git a/app/journal_timeline_state.mli b/app/journal_timeline_state.mli index 89457af..25ced22 100644 --- a/app/journal_timeline_state.mli +++ b/app/journal_timeline_state.mli @@ -78,10 +78,10 @@ val retry_day : t -> day:int -> t val first_visible_index : t -> int val scroll_generation : t -> int64 val scroll_target : t -> (int64 * int * string) option -val scroll_outcome : t -> Bonsai_swiftui_ui.View.Native_list.outcome option +val scroll_outcome : t -> Journal_view.View.Native_list.outcome option val complete_scroll : t -> token:int64 - -> outcome:Bonsai_swiftui_ui.View.Native_list.outcome + -> outcome:Journal_view.View.Native_list.outcome -> t diff --git a/app/journal_uploads.ml b/app/journal_uploads.ml index daadbbf..e62ebb4 100644 --- a/app/journal_uploads.ml +++ b/app/journal_uploads.ml @@ -1,4 +1,4 @@ -module Service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Service = Logseq_db_worker_lui.Logseq_db_worker_lui_service module Uuid = Logseq_db_types.Graph_types.Uuid module U = Logseq_db_worker_pure_reducer.Asset_upload diff --git a/app/journal_uploads.mli b/app/journal_uploads.mli index 1afeb13..2fb02d0 100644 --- a/app/journal_uploads.mli +++ b/app/journal_uploads.mli @@ -1,4 +1,4 @@ -module Service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Service = Logseq_db_worker_lui.Logseq_db_worker_lui_service module Uuid = Logseq_db_types.Graph_types.Uuid type t diff --git a/app/journal_view.ml b/app/journal_view.ml new file mode 100644 index 0000000..5169677 --- /dev/null +++ b/app/journal_view.ml @@ -0,0 +1,1939 @@ +[@@@ocaml.warning "-69"] + +(* Journal view shim over lui elements. + + This module preserves the shape of the previous BonsaiSwiftUI view API + (V.*, Ui.Event.*, Ui.Key, Ui.Test_id, Ui.Style, Ui.Text_editing, + Ui.Native_widget, Ui.View.Native_list, ...) on top of Lui_elements so the + application layer ports mechanically. Elements carry their optional key + and test_id so [For_testing] can recover them like the old widget + identity did. *) + +type t = + { key : string option + ; test_id : string option + ; mount : Lui_elements.t + } + +module Key = struct + type t = string + + let string s = s + let int i = string_of_int i + let int64 i = Int64.to_string i +end + +module Test_id = struct + type t = string + + let string s = s + let to_string s = s +end + +let element ?key ?test_id mount = { key; test_id; mount } +let int_of_float_nan v = int_of_float (Float.round v) + +let modify f t = + { t with + mount = + (fun context parent -> + let node = t.mount context parent in + f context node; + node) + } +;; + +module Event = struct + module Payload = struct + type text_selection = + { start_utf16 : int + ; end_utf16 : int + } + + type text_edit = + { session_id : Journal_ids.Text_input.Session_id.t + ; local_revision : Journal_ids.Text_input.Local_revision.t + ; base_document_revision : Journal_ids.Text_input.Document_revision.t + ; text : string + ; selection : text_selection + ; composing : text_selection option + } + + type scroll = + { pixels : float + ; delta : float + } + + type visible_range = + { first_index : int64 + ; last_exclusive : int64 + } + + type native_event = + { kind_id : Journal_ids.Native_widget.Kind_id.t + ; version : int + ; event_id : int + ; payload : bytes + } + + type confirmation_result = + | Action of string + | Dismissed + + type confirmation_response = + { token : int64 + ; result : confirmation_result + } + + type native_list_outcome = + | Succeeded + | Missing_target + | Cancelled + | Superseded + | Positioning_failed + + type native_list_completion = + { token : int64 + ; outcome : native_list_outcome + } + + type t = + | Confirmation_response of confirmation_response + | Unit + | Bool of bool + | Text of string + | Text_edit of text_edit + | Int of int + | Int64 of int64 + | Int64_bool of + { id : int64 + ; value : bool + } + | Int64_pair of + { first : int64 + ; second : int64 + } + | Float of float + | Scroll of scroll + | Visible_range of visible_range + | Navigation_path_changed of Journal_ids.Navigation.Page_key.t list + | Native_event of native_event + | Native_list_completion of native_list_completion + | Event of Lui_protocol.event + end + + module Handler = struct + type t = + { name : string option + ; invoke : Payload.t -> unit + } + + let create ?name invoke = { name; invoke } + let name t = t.name + + module Private = struct + let same left right = left == right + let invoke t payload = t.invoke payload + end + end + + type handler = Handler.t +end + +module Style = struct + module Color = struct + type t = string + + let clamp_component value = + if value < 0 then 0 else if value > 255 then 255 else value + ;; + + let rgb ~red ~green ~blue = + Printf.sprintf + "#%02x%02x%02x" + (clamp_component red) + (clamp_component green) + (clamp_component blue) + ;; + + let argb ~alpha ~red ~green ~blue = + if alpha <= 0 then "transparent" else rgb ~red ~green ~blue + ;; + end + + module Text_style = struct + type foreground = + | Primary + | Secondary + + type font_weight = + | Regular + | Semi_bold + + type t = + { foreground : foreground option + ; font_weight : font_weight option + } + + let create ?foreground ?font_weight () = { foreground; font_weight } + end +end + +module Layout = struct + module Edge_insets = struct + type t = float + + let all v = v + end + + module Alignment = struct + type t = string + end + + module Horizontal_alignment = struct + type t = + | Leading + | Center + | Trailing + + let to_lui = function + | Leading -> "start" + | Center -> "center" + | Trailing -> "end" + ;; + end + + module Vertical_alignment = struct + type t = + | Top + | Center + | Bottom + + let to_lui = function + | Top -> "start" + | Center -> "center" + | Bottom -> "end" + ;; + end + + module Frame_limit = struct + type t = + | Fixed of float + | Fill + end +end + +module Semantics = struct + module Role = struct + type t = + | Generic + | Button + | Link + | Image + | Header + | Toggle + | Static_text + + let equal (left : t) right = left = right + + let to_string = function + | Generic -> "generic" + | Button -> "button" + | Link -> "link" + | Image -> "image" + | Header -> "header" + | Toggle -> "toggle" + | Static_text -> "static_text" + ;; + end + + module Children = struct + type t = + | Combine + | Contain + | Ignore + end + + module Action = struct + type t = + { id : int64 + ; label : string + } + + let create ~id ~label = { id; label } + let id t = t.id + let label t = t.label + let equal left right = Int64.equal left.id right.id + end + + type t = + { label : string option + ; selected : bool option + ; live_region : bool + ; role : Role.t option + ; children : Children.t option + ; actions : Action.t list option + } + + let create + ?label + ?hint:_ + ?value:_ + ?role + ?selected + ?children + ?hidden:_ + ?(live_region = false) + ?heading_level:_ + ?sort_priority:_ + ?identifier:_ + ?actions + () + = + { label; selected; live_region; role; children; actions } + ;; + + module Private = struct + let view t = + { label = t.label + ; selected = t.selected + ; live_region = t.live_region + ; role = t.role + ; children = t.children + ; actions = t.actions + } + ;; + end +end + +module Theme = struct + type mode = + | System + | Light + | Dark + + type t = mode + + let create ~mode () = mode +end + +module Text_editing = struct + module Range = struct + type t = + { start_utf16 : int + ; end_utf16 : int + } + + let create ~text:_ ~start_utf16 ~end_utf16 = { start_utf16; end_utf16 } + let start_utf16 t = t.start_utf16 + let end_utf16 t = t.end_utf16 + let equal left right = left.start_utf16 = right.start_utf16 && left.end_utf16 = right.end_utf16 + end + + module Value = struct + type t = + { text : string + ; selection : Range.t + ; composing : Range.t option + } + + let create ~text ~selection ?composing () = { text; selection; composing } + let text t = t.text + let selection t = t.selection + let composing t = t.composing + let equal left right = + String.equal left.text right.text + && Range.equal left.selection right.selection + && Option.equal Range.equal left.composing right.composing + ;; + end + + module Utf16 = struct + let length s = + let count = ref 0 in + let i = ref 0 in + let n = String.length s in + while !i < n do + let byte = Char.code (String.unsafe_get s !i) in + let advance, units = + if byte < 0x80 then 1, 1 + else if byte land 0xE0 = 0xC0 then 2, 1 + else if byte land 0xF0 = 0xE0 then 3, 1 + else if byte land 0xF8 = 0xF0 then 4, 2 + else 1, 1 + in + i := !i + advance; + count := !count + units + done; + !count + ;; + end + + type update_mode = + | Ack + | Force_replace + | Initiate + | Resume + + module Keyboard = struct + type t = + | Default + | Text + end + + module Submit_label = struct + type t = + | Default + | Go + | Done + | Return + | Send + end + + module Field_appearance = struct + type t = + | Rounded + | Plain + end +end + +let invoke handler payload = Event.Handler.Private.invoke handler payload + +module View = struct + type nonrec t = t + type element_ = t + + module For_testing = struct + let key t = t.key + let test_id t = t.test_id +end + +module Button_role = struct + type t = + | Normal + | Destructive + | Cancel + + let variant = function + | Destructive -> "destructive" + | Cancel -> "cancel" + | Normal -> "primary" + ;; +end + +module Button_style = struct + type t = + | Automatic + | Plain + | Bordered + | Prominent + | Button + + let variant = function + | Plain -> "plain" + | Bordered -> "bordered" + | Prominent -> "prominent" + | Button -> "button" + | Automatic -> "automatic" + ;; +end + +module Progress_style = struct + type t = + | Linear + | Circular +end + +let is_press = function + | Lui_protocol.Press _ -> true + | _ -> false +;; + +let with_test_id test_id t = + let mount context parent = + let node = t.mount context parent in + Lui_ui.accessibility_identifier context node (Test_id.to_string test_id); + node + in + { t with test_id = Some (Test_id.to_string test_id); mount } +;; + +let empty ?key:_ () = element (fun _context _parent -> 0) + +let text ?key ?(style : Style.Text_style.t option) ?text_align:_ ?line_limit:_ + ?truncation:_ value + = + element ?key (fun context parent -> + let node = Lui_ui.text context value in + Option.iter + (fun (style : Style.Text_style.t) -> + (match style.foreground with + | Some Style.Text_style.Secondary -> + Lui_ui.foreground context node "secondary" + | Some Primary | None -> ()); + (match style.font_weight with + | Some Style.Text_style.Semi_bold -> + Lui_ui.style_class context node "semibold" + | Some Regular | None -> ())) + style; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) +;; + +let symbol ?key ?size ?color ?rendering:_ ~name () = + element ?key (fun context parent -> + let node = Lui_ui.icon context name in + Option.iter (fun size -> Lui_ui.size context node (string_of_int (int_of_float_nan size))) size; + Option.iter (fun color -> Lui_ui.foreground context node color) color; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) +;; + +let label ?key ~title ~icon () = + element ?key (fun context parent -> + let node = Lui_ui.row context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (icon.mount context (Some node)); + ignore (title.mount context (Some node)); + node) +;; + +let divider ?key () = + element ?key (fun context parent -> + let node = Lui_ui.separator context "horizontal" in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) +;; + +let progress ?key ?value ?(style = Progress_style.Linear) () = + element ?key (fun context parent -> + let node = + match style, value with + | Progress_style.Circular, _ -> Lui_ui.spinner context + | Linear, Some value -> Lui_ui.progress_literal context value + | Linear, None -> Lui_ui.spinner context + in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) +;; + +let spacer ?key ?min_length:_ () = + element ?key (fun context parent -> + let node = Lui_ui.spacer context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) +;; + +let row ?key ?(spacing = 16.) ?(alignment = Layout.Vertical_alignment.Center) children = + element ?key (fun context parent -> + let node = Lui_ui.row context in + Lui_ui.gap context node (int_of_float_nan spacing); + Lui_ui.cross context node (Layout.Vertical_alignment.to_lui alignment); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter (fun child -> ignore (child.mount context (Some node))) children; + node) +;; + +let column ?key ?(spacing = 16.) ?(alignment = Layout.Horizontal_alignment.Center) children = + element ?key (fun context parent -> + let node = Lui_ui.column context in + Lui_ui.gap context node (int_of_float_nan spacing); + Lui_ui.cross context node (Layout.Horizontal_alignment.to_lui alignment); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter (fun child -> ignore (child.mount context (Some node))) children; + node) +;; + +let stack ?key ?alignment:_ children = + element ?key (fun context parent -> + let node = Lui_ui.stack context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter (fun child -> ignore (child.mount context (Some node))) children; + node) +;; + +let apply_frame_limit context node _min_prop max_prop limit = + match limit with + | Layout.Frame_limit.Fill -> Lui_ui.grow context node 1.0 + | Fixed value -> Lui_ui.int_property context node max_prop (int_of_float_nan value) +;; + +let frame + ?key:frame_key + ?width + ?height + ?min_width + ?ideal_width:_ + ?max_width + ?min_height + ?ideal_height:_ + ?max_height + ?alignment:_ + t + = + modify + (fun context node -> + Option.iter + (fun v -> Lui_ui.width context node (int_of_float_nan v)) + width; + Option.iter + (fun v -> Lui_ui.height context node (int_of_float_nan v)) + height; + Option.iter + (fun v -> Lui_ui.min_width context node (int_of_float_nan v)) + min_width; + Option.iter + (fun v -> Lui_ui.min_height context node (int_of_float_nan v)) + min_height; + Option.iter + (fun limit -> + apply_frame_limit context node Lui_protocol.MinWidth Lui_protocol.MaxWidth + limit) + max_width; + Option.iter + (fun limit -> + apply_frame_limit context node Lui_protocol.MinHeight Lui_protocol.MaxHeight + limit) + max_height) + t + |> fun result -> (match frame_key with Some key -> { result with key = Some key } | None -> result) +;; + +let padding ?key:_ ~insets t = + modify + (fun context node -> + Lui_ui.padding context node (int_of_float_nan insets)) + t +;; + +let semantics ?key:_ ~properties t = + modify + (fun context node -> + Option.iter + (fun label -> Lui_ui.accessibility_label context node label) + properties.Semantics.label) + t +;; + +let help ?key:_ ~message:_ t = t +let text_selection ?key:_ ~enabled:_ t = t +let opacity ?key:_ value t = modify (fun _ _ -> ignore value) t +let ignores_safe_area ?regions:_ ?edges:_ t = t +let safe_area_padding ?key:_ ~insets:_ t = t +let theme ?key:_ ~data:_ t = t +let background ?key:_ ?corner_radius ~color t = + modify + (fun context node -> + Lui_ui.background context node color; + Option.iter (fun radius -> Lui_ui.corner_radius context node (int_of_float_nan radius)) corner_radius) + t +;; + +let clip ?key:_ ?corner_radius:_ ?antialiased:_ t = t +let layout_priority ?key:_ _ t = t +let offset ?key:_ ?x:_ ?y:_ t = t +let animated_opacity ?key:_ ?duration:_ value t = opacity value t + +let button + ?key + ?(enabled = true) + ?(role = Button_role.Normal) + ?style + ?(autofocus = false) + ~on_press + ~child + () + = + element ?key (fun context parent -> + let node = Lui_ui.button context in + if not enabled then Lui_ui.disabled context node true; + Option.iter (fun style -> Lui_ui.string_property context node Lui_protocol.VariantValue (Button_style.variant style)) style; + (match role with + | Button_role.Normal -> () + | role -> Lui_ui.string_property context node Lui_protocol.VariantValue (Button_role.variant role)); + if autofocus then Lui_ui.bool_property context node Lui_protocol.Autofocus true; + Lui_ui.on_event context node (fun event -> + if is_press event then invoke on_press Event.Payload.Unit); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (child.mount context (Some node)); + node) +;; + +let toggle ?key ?style:_ ?(enabled = true) ~value ~on_changed ~label () = + element ?key (fun context parent -> + let node = Lui_ui.toggle context in + if not enabled then Lui_ui.disabled context node true; + Lui_ui.bool_property context node Lui_protocol.Checked value; + Lui_ui.on_event context node (fun event -> + match event with + | Lui_protocol.ToggleChanged (_, selected) -> + invoke on_changed (Event.Payload.Bool selected) + | _ -> ()); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (label.mount context (Some node)); + node) +;; + +let text_editor + ?key + ?(autofocus = false) + ?(enabled = true) + ?(read_only = false) + ?(submit_on_return = true) + ?max_utf8_bytes:_ + ~session_id + ~document_revision + ~accepted_local_revision + ~update_mode:_ + ~value + ~on_edit + ~on_submit + ~on_focus_changed:_ + ?on_limit_reached:_ + () + = + element ?key (fun context parent -> + let node = Lui_ui.textarea context in + Lui_ui.text_property context node (Text_editing.Value.text value); + if not (enabled && not read_only) then Lui_ui.disabled context node true; + if autofocus then Lui_ui.bool_property context node Lui_protocol.Autofocus true; + Lui_ui.bool_property context node Lui_protocol.SubmitOnEnter submit_on_return; + let local_revision = ref accepted_local_revision in + Lui_ui.on_event context node (fun event -> + match event with + | Lui_protocol.TextChanged (_, text) -> + local_revision := Journal_ids.Text_input.Local_revision.succ !local_revision; + invoke + on_edit + (Event.Payload.Text_edit + { session_id + ; local_revision = !local_revision + ; base_document_revision = document_revision + ; text + ; selection = { start_utf16 = 0; end_utf16 = 0 } + ; composing = None + }) + | Lui_protocol.Submit _ -> invoke on_submit Event.Payload.Unit + | _ -> ()); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) +;; + +let secure_field + ?key + ~label + ?(prompt = "") + ?keyboard:_ + ?submit_label:_ + ?appearance:_ + ?(autofocus = false) + ?(enabled = true) + ?read_only:_ + ?submit_on_return:_ + ?max_utf8_bytes:_ + ~session_id + ~document_revision + ~accepted_local_revision + ~update_mode:_ + ~value + ~on_edit + ~on_submit + ~on_focus_changed:_ + ?on_limit_reached:_ + () + = + element ?key (fun context parent -> + let node = Lui_ui.secure_field context in + Lui_ui.text_property context node (Text_editing.Value.text value); + Lui_ui.placeholder context node prompt; + Lui_ui.string_property context node Lui_protocol.TitleValue label; + if not enabled then Lui_ui.disabled context node true; + if autofocus then Lui_ui.bool_property context node Lui_protocol.Autofocus true; + let local_revision = ref accepted_local_revision in + Lui_ui.on_event context node (fun event -> + match event with + | Lui_protocol.TextChanged (_, text) -> + local_revision := Journal_ids.Text_input.Local_revision.succ !local_revision; + invoke + on_edit + (Event.Payload.Text_edit + { session_id + ; local_revision = !local_revision + ; base_document_revision = document_revision + ; text + ; selection = { start_utf16 = 0; end_utf16 = 0 } + ; composing = None + }) + | Lui_protocol.Submit _ -> invoke on_submit Event.Payload.Unit + | _ -> ()); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) +;; + +let labeled_content ?key ~label ~value () = + element ?key (fun context parent -> + let node = Lui_ui.row context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (label.mount context (Some node)); + let spacer = Lui_ui.spacer context in + Lui_ui.append context node spacer; + ignore (value.mount context (Some node)); + node) +;; + +let content_unavailable ?key ~label ?description ?actions () = + element ?key (fun context parent -> + let node = Lui_ui.column context in + Lui_ui.cross context node "center"; + Lui_ui.main context node "center"; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (label.mount context (Some node)); + Option.iter (fun description -> ignore (description.mount context (Some node))) description; + Option.iter (fun actions -> ignore (actions.mount context (Some node))) actions; + node) +;; + +let overlay ?key:_ ?alignment:_ ~overlay t = + element ?key:t.key (fun context parent -> + let node = Lui_ui.stack context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (t.mount context (Some node)); + ignore (overlay.mount context (Some node)); + node) +;; + +module Keyed = struct + type widget = t + + type nonrec t = + { key : string + ; view : t + } + + let create ~key view = { key; view } +end + +module Section = struct + let create ?key ?header ?footer entries = + element ?key (fun context parent -> + let node = Lui_ui.panel context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + Option.iter (fun header -> ignore (header.mount context (Some node))) header; + List.iter (fun (entry : Keyed.t) -> ignore (entry.view.mount context (Some node))) entries; + Option.iter (fun footer -> ignore (footer.mount context (Some node))) footer; + node) + ;; +end + +module Form = struct + let vertical ?key entries = + element ?key (fun context parent -> + let node = Lui_ui.list context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter + (fun (entry : Keyed.t) -> ignore (entry.view.mount context (Some node))) + entries; + node) + ;; +end + +module Toolbar = struct + type placement = + | Automatic + | Principal + | Navigation + | Primary_action + | Secondary_action + | Status + | Confirmation_action + | Cancellation_action + | Destructive_action + | Bottom_bar + + type spacing = + | Fixed + | Flexible + + type child = t + type item = + { item_key : string + ; placement : placement option + ; content : child + ; spacing : spacing option + ; is_group : bool + } + + let child ~key:_ view = view + let item ~key ?placement content = { item_key = key; placement; content; spacing = None; is_group = false } + + let group ~key ?placement children = + { item_key = key + ; placement + ; content = row ~key children + ; spacing = None + ; is_group = true + } + ;; + + let spacer ~key ?placement:_ _spacing = + { item_key = key + ; placement = None + ; content = element (fun context parent -> + let node = Lui_ui.spacer context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) + ; spacing = None + ; is_group = false + } + ;; + + let mount_items items = + element (fun context parent -> + let node = Lui_ui.toolbar context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter + (fun (item : item) -> + let child = item.content in + let mounted = child.mount context (Some node) in + Lui_ui.key context mounted item.item_key; + (match item.placement with + | Some placement -> + Lui_ui.string_property + context + mounted + Lui_protocol.RoleValue + (match placement with + | Automatic -> "automatic" + | Principal -> "principal" + | Navigation -> "navigation" + | Primary_action -> "primary_action" + | Secondary_action -> "secondary_action" + | Status -> "status" + | Confirmation_action -> "confirmation_action" + | Cancellation_action -> "cancellation_action" + | Destructive_action -> "destructive_action" + | Bottom_bar -> "bottom_bar") + | None -> ()); + (match item.spacing with + | Some Fixed -> + Lui_ui.string_property + context + mounted + Lui_protocol.VariantValue + "fixed_spacing" + | Some Flexible -> + Lui_ui.string_property + context + mounted + Lui_protocol.VariantValue + "flexible_spacing" + | None -> ()); + if item.is_group + then + Lui_ui.string_property + context + mounted + Lui_protocol.VariantValue + "item_group") + items; + node) + ;; + + let create ?key ~items t = + element ?key (fun context parent -> + let node = Lui_ui.column context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore ((mount_items items).mount context (Some node)); + ignore (t.mount context (Some node)); + node) + ;; +end + +let parent_overlay = overlay + +module Body = struct + type nonrec t = t + type widget = t + + let with_size ~width ~height t = frame ~width ~height t + let static t = t + let with_test_id id t = with_test_id id t + let padding ~insets t = padding ~insets t + let background ?corner_radius ~color t = background ?corner_radius ~color t + let semantics ~properties t = semantics ~properties t + let ignores_safe_area ?regions ?edges t = ignores_safe_area ?regions ?edges t + let safe_area_padding ~insets t = safe_area_padding ~insets t + let theme ~data t = theme ~data t + let toolbar ?key:_ ~items t = Toolbar.create ~items t + let overlay ?key ?alignment ~overlay t = parent_overlay ?key ?alignment ~overlay t + + module Vertical = struct + type child = t + + let fixed t = t + let fill ?weight:_ t = t + let create ?key children = column ?key children + end + + module Horizontal = struct + type child = t + + let fixed t = t + let fill ?weight:_ t = t + let create ?key children = row ?key children + end + + module Private = struct + let to_widget t = t + end +end + +module Viewport = struct + module Vertical = struct + type nonrec t = t + + let with_test_id id t = with_test_id id t + let padding ~insets t = padding ~insets t + let background ?corner_radius ~color t = background ?corner_radius ~color t + let semantics ~properties t = semantics ~properties t + let ignores_safe_area ?regions ?edges t = ignores_safe_area ?regions ?edges t + let safe_area_padding ~insets t = safe_area_padding ~insets t + let theme ~data t = theme ~data t + let overlay ?key ?alignment ~overlay t = parent_overlay ?key ?alignment ~overlay t + let with_height ~height t = frame ~height t + end + + module Horizontal = struct + type nonrec t = t + + let with_test_id id t = with_test_id id t + let with_width ~width t = frame ~width t + end +end + +module Scroll = struct + type anchor = + | Start + | End + + let vertical ?key ?on_scroll:_ ?shows_indicators:_ ?fill_viewport:_ ?initial_anchor:_ t = + element ?key (fun context parent -> + let node = Lui_ui.scroll context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (t.mount context (Some node)); + node) + ;; + +end + +module Swipe_actions = struct + type side = + | Start + | End + + type action = + { key : string + ; enabled : bool + ; role : Button_role.t + ; symbol : string option + ; side : side + ; title : string + ; background : Style.Color.t + ; on_press : Event.handler + } + + type nonrec t = action list + + let action ~key ?(enabled = true) ?(role = Button_role.Normal) ?symbol ~side ~title + ~background ~on_press () + = + { key; enabled; role; symbol; side; title; background; on_press } + ;; + + let create ?enabled:_ ?allows_full_swipe:_ ~actions () = actions +end + +module Context_menu = struct + type nonrec view = t + + type role = + | Normal + | Destructive + + type action = + { key : string + ; enabled : bool + ; role : role + ; symbol : string option + ; title : string + ; on_press : Event.handler + } + + type nonrec t = action list + + let action ~key ?(enabled = true) ?(role = Normal) ?symbol ~title ~on_press () = + { key; enabled; role; symbol; title; on_press } + ;; + + let create ?enabled:_ ~actions () = actions + + let attach ?key:_ actions (view : element_) = + element ?key:view.key (fun context parent -> + let node = Lui_ui.context_menu context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter + (fun (action : action) -> + let item = Lui_ui.menu_item context in + Lui_ui.text_property context item action.title; + Option.iter + (fun symbol -> ignore (Lui_ui.append context item (Lui_ui.icon context symbol))) + action.symbol; + if not action.enabled then Lui_ui.disabled context item true; + Lui_ui.on_event context item (fun event -> + if is_press event then invoke action.on_press Event.Payload.Unit); + Lui_ui.append context node item) + actions; + ignore (view.mount context (Some node)); + node) + ;; +end + +module Confirmation = struct + type action = + { key : string + ; title : string + ; enabled : bool + ; role : Button_role.t + } + + type request = + { token : int64 + ; title : string + ; message : string option + ; actions : action list + } + + let action ~key ~title ?(enabled = true) ?(role = Button_role.Normal) () = + { key; title; enabled; role } + ;; + + let request ~token ~title ?message actions = { token; title; message; actions } + + let alert ?key:_ ~request ~on_response (view : element_) = + element ?key:view.key (fun context parent -> + (match request with + | None -> view.mount context parent + | Some request -> + let node = + match parent with + | Some parent -> parent + | None -> view.mount context None + in + ignore (view.mount context (Some node)); + let dialog = Lui_ui.dialog context in + Lui_ui.text_property context dialog request.title; + Option.iter + (fun message -> Lui_ui.string_property context dialog Lui_protocol.DescriptionValue message) + request.message; + Lui_ui.append context node dialog; + List.iter + (fun (action : action) -> + let item = Lui_ui.button context in + Lui_ui.text_property context item action.title; + if not action.enabled then Lui_ui.disabled context item true; + (match action.role with + | Button_role.Normal -> () + | role -> + Lui_ui.string_property context item Lui_protocol.VariantValue + (Button_role.variant role)); + Lui_ui.on_event context item (fun event -> + match event with + | Lui_protocol.Press _ -> + invoke + on_response + (Event.Payload.Confirmation_response + { token = request.token + ; result = Action action.key + }) + | _ -> ()); + Lui_ui.append context dialog item) + request.actions; + Lui_ui.on_event context dialog (fun event -> + match event with + | Lui_protocol.Dismiss _ -> + invoke + on_response + (Event.Payload.Confirmation_response + { token = request.token; result = Dismissed }) + | _ -> ()); + node)) + ;; + + let dialog ?key ~request ~on_response view = alert ?key ~request ~on_response view +end + +module Native_list = struct + type anchor = + | Top + | Center + | Bottom + + type target = + { section : string + ; row_path : string list + } + + type scroll_request = + { token : int64 + ; target : target + ; anchor : anchor option + ; animated : bool option + } + + type outcome = Event.Payload.native_list_outcome + type completion = Event.Payload.native_list_completion + + let target ~section ~row_path = { section; row_path } + + let scroll_request ~token ~target ?anchor ?animated () = + { token; target; anchor; animated } + ;; + + let completion_of_payload = function + | Event.Payload.Native_list_completion completion -> Some completion + | _ -> None + ;; + + type style = + | Plain + | Inset + | Inset_grouped + + let style_name = function + | Plain -> "plain" + | Inset -> "inset" + | Inset_grouped -> "inset_grouped" + ;; + + type separator = + | Automatic + | Hidden + | Visible + + let separator_name = function + | Automatic -> "automatic" + | Hidden -> "hidden" + | Visible -> "visible" + ;; + + type row_kind = + | Row + | Disclosure of + { expanded : bool + ; children : row list + } + + and row = + { key : string + ; test_id : string option + ; separator : separator + ; swipe_actions : Swipe_actions.t option + ; context_menu : Context_menu.t option + ; kind : row_kind + ; content : t + ; on_expanded_changed : Event.handler option + } + + type section = + { section_key : string + ; header : t option + ; footer : t option + ; separator : separator + ; rows : row list + } + + let row ~key ?test_id ?(separator = Automatic) ?swipe_actions ?context_menu content = + { key + ; test_id + ; separator + ; swipe_actions + ; context_menu + ; kind = Row + ; content + ; on_expanded_changed = None + } + ;; + + let disclosure_row ~key ?test_id ?(separator = Automatic) ?swipe_actions ?context_menu + ~expanded ~on_expanded_changed ~label children + = + { key + ; test_id + ; separator + ; swipe_actions + ; context_menu + ; kind = Disclosure { expanded; children } + ; content = label + ; on_expanded_changed = Some on_expanded_changed + } + ;; + + let section ~key ?header ?footer ?(separator = Automatic) rows = + { section_key = key; header; footer; separator; rows } + ;; + + let swipe_json (actions : Swipe_actions.t) = + `Assoc + [ ( "actions" + , `List + (List.map + (fun (a : Swipe_actions.action) -> + `Assoc + [ "key", `String a.key + ; "enabled", `Bool a.enabled + ; "role", `String (Button_role.variant a.role) + ; ( "symbol" + , match a.symbol with + | Some s -> `String s + | None -> `Null ) + ; "side", `String (match a.side with Start -> "start" | End -> "end") + ; "title", `String a.title + ; "background", `String a.background + ]) + actions) ) + ] + ;; + + let context_menu_json (actions : Context_menu.t) = + `Assoc + [ ( "actions" + , `List + (List.map + (fun (a : Context_menu.action) -> + `Assoc + [ "key", `String a.key + ; "enabled", `Bool a.enabled + ; "role", `String (match a.role with Normal -> "normal" | Destructive -> "destructive") + ; ( "symbol" + , match a.symbol with + | Some s -> `String s + | None -> `Null ) + ; "title", `String a.title + ]) + actions) ) + ] + ;; + + (* Content elements (headers, rows, footers, disclosure labels) mount as + extension children in a deterministic order; the payload lists their + index so the host binds each child node to its list position. *) + let build sections ~style ~scroll_request ~track_visible ~track_scroll = + let contents = ref [] in + let push element = contents := !contents @ [ element ]; List.length !contents - 1 in + let rec row_json (row : row) = + let content_index = push row.content in + let base = + [ "key", `String row.key + ; "content_index", `Int content_index + ; "separator", `String (separator_name row.separator) + ] + in + let base = + match row.test_id with + | Some id -> ("test_id", `String id) :: base + | None -> base + in + let base = + match row.swipe_actions with + | Some actions -> ("swipe", swipe_json actions) :: base + | None -> base + in + let base = + match row.context_menu with + | Some menu -> ("context_menu", context_menu_json menu) :: base + | None -> base + in + match row.kind with + | Row -> `Assoc (("type", `String "row") :: base) + | Disclosure { expanded; children } -> + `Assoc + (("type", `String "disclosure") + :: ("expanded", `Bool expanded) + :: ("children", `List (List.map row_json children)) + :: base) + in + let section_json (section : section) = + `Assoc + [ "key", `String section.section_key + ; "separator", `String (separator_name section.separator) + ; ( "header_index" + , match section.header with + | Some header -> `Int (push header) + | None -> `Null ) + ; ( "footer_index" + , match section.footer with + | Some footer -> `Int (push footer) + | None -> `Null ) + ; "rows", `List (List.map row_json section.rows) + ] + in + let payload = + `Assoc + [ "style", `String (style_name style) + ; "sections", `List (List.map section_json sections) + ; ( "scroll_request" + , match scroll_request with + | None -> `Null + | Some request -> + `Assoc + [ "token", `String (Int64.to_string request.token) + ; ( "target" + , `Assoc + [ "section", `String request.target.section + ; "row_path", `List (List.map (fun key -> `String key) request.target.row_path) + ] ) + ; ( "anchor" + , match request.anchor with + | Some Top -> `String "top" + | Some Center -> `String "center" + | Some Bottom -> `String "bottom" + | None -> `Null ) + ; ( "animated" + , match request.animated with + | Some value -> `Bool value + | None -> `Null ) + ] ) + ; "track_visible_range", `Bool track_visible + ; "track_scroll_completion", `Bool track_scroll + ] + in + Yojson.Basic.to_string payload, List.rev !contents |> List.rev + ;; + + let decode_outcome = function + | `String "succeeded" -> Event.Payload.Succeeded + | `String "missing_target" -> Missing_target + | `String "cancelled" -> Cancelled + | `String "superseded" -> Superseded + | `String "positioning_failed" -> Positioning_failed + | _ -> Positioning_failed + ;; + + let vertical + ?key + ~style + ?scroll_request + ?on_scroll_completed + ?on_visible_range + ?(on_row_event : Event.handler option) + sections + = + let payload, contents = + build + sections + ~style + ~scroll_request + ~track_visible:(Option.is_some on_visible_range) + ~track_scroll:(Option.is_some on_scroll_completed) + in + (* Expansion state arrives as {"type":"expanded","key":..,"expanded":bool}; + the owning row's handler receives Bool like the old disclosure callback. *) + let expanded_handlers = + let rec collect acc (row : row) = + match row.kind with + | Row -> acc + | Disclosure { children; _ } -> + List.fold_left collect ((row.key, row.on_expanded_changed) :: acc) children + in + List.fold_left + (fun acc section -> List.fold_left collect acc section.rows) + [] + sections + in + let on_event (event : Journal_lui_native.event) = + match (try Yojson.Basic.from_string event.payload with _ -> `Null) with + | `Assoc fields -> + (match List.assoc_opt "type" fields with + | Some (`String "visible_range") -> + Option.iter + (fun handler -> + let get_int64 name = + match List.assoc_opt name fields with + | Some (`Int v) -> Int64.of_int v + | Some (`String s) -> Int64.of_string s + | _ -> 0L + in + invoke + handler + (Event.Payload.Visible_range + { first_index = get_int64 "first" + ; last_exclusive = get_int64 "last" + })) + on_visible_range + | Some (`String "scroll_completed") -> + Option.iter + (fun handler -> + let token = + match List.assoc_opt "token" fields with + | Some (`Int v) -> Int64.of_int v + | Some (`String s) -> Int64.of_string s + | _ -> 0L + in + let outcome = + match List.assoc_opt "outcome" fields with + | Some json -> decode_outcome json + | None -> Event.Payload.Positioning_failed + in + invoke + handler + (Event.Payload.Native_list_completion { token; outcome })) + on_scroll_completed + | Some (`String "expanded") -> + (match + ( List.assoc_opt "key" fields + , List.assoc_opt "expanded" fields ) + with + | Some (`String key), Some (`Bool expanded) -> + List.iter + (fun (row_key, handler) -> + if String.equal row_key key + then + Option.iter + (fun handler -> invoke handler (Event.Payload.Bool expanded)) + handler) + expanded_handlers + | _ -> ()) + | Some (`String "row_event") -> + Option.iter + (fun handler -> + match List.assoc_opt "payload" fields with + | Some (`String payload) -> + invoke + handler + (Event.Payload.Native_event + { kind_id = Journal_ids.Native_widget.Kind_id.of_int 0 + ; version = 0 + ; event_id = event.event_id + ; payload = Bytes.of_string payload + }) + | _ -> ()) + on_row_event + | _ -> ()) + | _ -> () + in + element ?key (fun context parent -> + Journal_lui_native.mount + ?key + ~payload + ~children:(List.map (fun element -> element.mount) contents) + ~on_event + Journal_lui_native.list_identifier + context + parent) + ;; +end + +module Navigation_link = struct + let create ?key ~activation_id:_ ?(enabled = true) ~on_activate ~label () = + element ?key (fun context parent -> + let node = Lui_ui.list_item context in + if not enabled then Lui_ui.disabled context node true; + Lui_ui.on_event context node (fun event -> + if is_press event then invoke on_activate Event.Payload.Unit); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (label.mount context (Some node)); + node) + ;; +end + +module Navigation_stack = struct + type destination = t + + let destination ~page_key:_ ~title:_ ~can_pop:_ content = content + + (* The lui widget set has no navigation-stack node. The router stays in the + model: the topmost destination renders, and interactive pops arrive as + [Navigation_path_changed] through the back affordance the shim renders. *) + let create ?key ~title:_ ~on_path_change ~path root = + element ?key (fun context parent -> + let node = Lui_ui.column context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + (match List.rev path with + | [] -> ignore (root.mount context (Some node)) + | top :: _ -> + let back = + element (fun context parent -> + let node = Lui_ui.button context in + Lui_ui.text_property context node "Back"; + Lui_ui.string_property context node Lui_protocol.VariantValue "plain"; + Lui_ui.accessibility_identifier context node "journal-nav-back"; + Lui_ui.on_event context node (fun event -> + if is_press event + then + invoke on_path_change (Event.Payload.Navigation_path_changed [])); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) + in + ignore (back.mount context (Some node)); + ignore (top.mount context (Some node))); + node) + ;; +end + +module Sheet = struct + type sizing = + | Automatic + | Form + | Fitted + + type detent = + | Medium + | Large + + let create + ?key + ~presented + ~on_presented_changed + ?(interactive_dismiss = true) + ?sizing:_ + ?detents:_ + ~content + base + = + element ?key (fun context parent -> + let node = Lui_ui.column context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (base.mount context (Some node)); + if presented + then ( + let sheet = Lui_ui.sheet context in + Lui_ui.append context node sheet; + if interactive_dismiss + then + Lui_ui.on_event context sheet (fun event -> + match event with + | Lui_protocol.Dismiss _ -> + invoke on_presented_changed (Event.Payload.Bool false) + | _ -> ()); + ignore (content.mount context (Some sheet))); + node) + ;; +end + +module Picker = struct + type style = + | Automatic + | Menu + | Segmented + | Inline + + type choice = + { id : int64 + ; enabled : bool + ; label : t + } + + let option ~id ?(enabled = true) ?(label = empty ()) () = { id; enabled; label } + + let create ?key ?label:_ ?(style = Automatic) ?(enabled = true) ~selected_id + ~on_select choices () + = + element ?key (fun context parent -> + let node = + match style with + | Segmented -> Lui_ui.toggle_group context + | Automatic | Menu | Inline -> Lui_ui.radio_group context + in + if not enabled then Lui_ui.disabled context node true; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter + (fun (choice : choice) -> + let item = Lui_ui.radio context in + Lui_ui.key context item (Int64.to_string choice.id); + if not choice.enabled then Lui_ui.disabled context item true; + (match selected_id with + | Some selected when selected = choice.id -> + Lui_ui.bool_property context item Lui_protocol.Checked true + | _ -> ()); + Lui_ui.on_event context item (fun event -> + match event with + | Lui_protocol.Press _ | ToggleChanged (_, true) -> + invoke on_select (Event.Payload.Int64 choice.id) + | _ -> ()); + Lui_ui.append context node item; + ignore (choice.label.mount context (Some item))) + choices; + node) + ;; +end + +module Menu = struct + type entry = + | Action of + { id : int64 + ; label : t + ; enabled : bool + ; role : Button_role.t + } + | Choice of + { id : int64 + ; label : t + ; selected : bool + ; enabled : bool + } + | Divider of int64 + | Section of + { id : int64 + ; label : t option + ; entries : entry list + } + | Submenu of + { id : int64 + ; label : t + ; enabled : bool + ; entries : entry list + } + + let action ~id ~label ?(enabled = true) ?(role = Button_role.Normal) () = + Action { id; label; enabled; role } + ;; + + let choice ~id ~label ~selected ?(enabled = true) () = + Choice { id; label; selected; enabled } + ;; + + let divider ~id = Divider id + let section ~id ?label entries = Section { id; label; entries } + let submenu ~id ~label ?(enabled = true) entries = Submenu { id; label; enabled; entries } + + let create ?key ?(enabled = true) ~on_select ~label entries = + element ?key (fun context parent -> + let node = Lui_ui.dropdown_menu context in + if not enabled then Lui_ui.disabled context node true; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (label.mount context (Some node)); + let rec mount_entry parent (entry : entry) = + match entry with + | Divider id -> + let separator = Lui_ui.separator context (Int64.to_string id) in + Lui_ui.append context parent separator + | entry -> + let item = Lui_ui.menu_item context in + Lui_ui.key + context + item + (Int64.to_string + (match entry with + | Action { id; _ } | Choice { id; _ } -> id + | Section { id; _ } | Submenu { id; _ } -> id + | Divider _ -> assert false)); + (match entry with + | Action { id; label; enabled; role } -> + if not enabled then Lui_ui.disabled context item true; + (match role with + | Button_role.Normal -> () + | role -> + Lui_ui.string_property context item Lui_protocol.VariantValue + (Button_role.variant role)); + Lui_ui.on_event context item (fun event -> + if is_press event then invoke on_select (Event.Payload.Int64 id)); + ignore (label.mount context (Some item)) + | Choice { id; label; selected; enabled } -> + if not enabled then Lui_ui.disabled context item true; + if selected then Lui_ui.bool_property context item Lui_protocol.Checked true; + Lui_ui.on_event context item (fun event -> + if is_press event then invoke on_select (Event.Payload.Int64 id)); + ignore (label.mount context (Some item)) + | Section { label; entries; _ } -> + Option.iter (fun label -> ignore (label.mount context (Some item))) label; + List.iter (mount_entry item) entries + | Submenu { label; enabled; entries; _ } -> + if not enabled then Lui_ui.disabled context item true; + ignore (label.mount context (Some item)); + List.iter (mount_entry item) entries + | Divider _ -> assert false); + Lui_ui.append context parent item + in + List.iter (mount_entry node) entries; + node) + ;; +end + +end + +module Native_widget = struct + module Capability = struct + type t = + | Stateful + | Resource + | Semantics + | Semantics_canvas + | Virtualized + + let bit = function + | Stateful -> 0L + | Resource -> 1L + | Semantics -> 2L + | Semantics_canvas -> 3L + | Virtualized -> 4L + ;; + + let bits capabilities = + List.fold_left + (fun acc capability -> + Int64.logor acc (Int64.shift_left 1L (Int64.to_int (bit capability)))) + 0L + capabilities + ;; + end + + module Extension = struct + type ('props, 'event) t = + { identifier : string + ; kind_id : Journal_ids.Native_widget.Kind_id.t + ; version : int + ; encode_props : 'props -> bytes + ; decode_event : + event_id:Journal_ids.Native_widget.Event_id.t + -> bytes + -> ('event, string) result + } + + let identifier_of_kind_id kind_id = + match Journal_ids.Native_widget.Kind_id.to_int kind_id with + | 2103 -> Journal_lui_native.chrome_identifier + | 2104 -> Journal_lui_native.asset_import_identifier + | 2105 -> Journal_lui_native.media_identifier + | 2106 -> Journal_lui_native.asset_settings_identifier + | other -> invalid_arg ("unregistered journal extension kind " ^ string_of_int other) + ;; + + let create ~kind_id ~version ~capabilities:_ ~encode_props ~decode_event () = + { identifier = identifier_of_kind_id kind_id + ; kind_id + ; version + ; encode_props + ; decode_event + } + ;; + end + + let decode extension event = + extension.Extension.decode_event + ~event_id:(Journal_ids.Native_widget.Event_id.of_int + event.Journal_lui_native.event_id) + (Bytes.of_string event.Journal_lui_native.payload) + ;; + + let event_handler ?name:_ extension callback = + Event.Handler.create (fun payload -> + match payload with + | Event.Payload.Native_event { event_id; payload; _ } -> + (match + extension.Extension.decode_event + ~event_id:(Journal_ids.Native_widget.Event_id.of_int event_id) + payload + with + | Ok event -> callback event + | Error _ -> ()) + | _ -> ()) + ;; + + let mount extension ?key ~props ~on_event ~children context parent = + let payload = Bytes.to_string (extension.Extension.encode_props props) in + Journal_lui_native.mount + ?key + ~payload + ~children:(List.map (fun element -> element.mount) children) + ~on_event:(fun event -> + match decode extension event with + | Ok decoded -> on_event decoded + | Error _ -> ()) + extension.Extension.identifier + context + parent + ;; + + let widget extension ?key ~props ~on_event ?(children = []) () = + element ?key (mount extension ~props ~on_event ~children) + ;; + + let widget_with_handler extension ?key ~props ~on_event ?(children = []) () = + element ?key (fun context parent -> + let payload = Bytes.to_string (extension.Extension.encode_props props) in + Journal_lui_native.mount + ?key + ~payload + ~children:(List.map (fun element -> element.mount) children) + ~on_event:(fun event -> + Event.Handler.Private.invoke + on_event + (Event.Payload.Native_event + { kind_id = extension.Extension.kind_id + ; version = extension.Extension.version + ; event_id = event.Journal_lui_native.event_id + ; payload = Bytes.of_string event.Journal_lui_native.payload + })) + extension.Extension.identifier + context + parent) + ;; +end diff --git a/app/journal_view.mli b/app/journal_view.mli new file mode 100644 index 0000000..9fc3b0a --- /dev/null +++ b/app/journal_view.mli @@ -0,0 +1,886 @@ +(** Journal view shim over lui elements, mirroring the BonsaiSwiftUI view API + used across the app layer. *) + +type t + +module Key : sig + type t + + val string : string -> t + val int : int -> t + val int64 : int64 -> t +end + +module Test_id : sig + type t + + val string : string -> t + val to_string : t -> string +end + +module Event : sig + module Payload : sig + type text_selection = + { start_utf16 : int + ; end_utf16 : int + } + + type text_edit = + { session_id : Journal_ids.Text_input.Session_id.t + ; local_revision : Journal_ids.Text_input.Local_revision.t + ; base_document_revision : Journal_ids.Text_input.Document_revision.t + ; text : string + ; selection : text_selection + ; composing : text_selection option + } + + type scroll = + { pixels : float + ; delta : float + } + + type visible_range = + { first_index : int64 + ; last_exclusive : int64 + } + + type native_event = + { kind_id : Journal_ids.Native_widget.Kind_id.t + ; version : int + ; event_id : int + ; payload : bytes + } + + type confirmation_result = + | Action of string + | Dismissed + + type confirmation_response = + { token : int64 + ; result : confirmation_result + } + + type native_list_outcome = + | Succeeded + | Missing_target + | Cancelled + | Superseded + | Positioning_failed + + type native_list_completion = + { token : int64 + ; outcome : native_list_outcome + } + + type t = + | Confirmation_response of confirmation_response + | Unit + | Bool of bool + | Text of string + | Text_edit of text_edit + | Int of int + | Int64 of int64 + | Int64_bool of + { id : int64 + ; value : bool + } + | Int64_pair of + { first : int64 + ; second : int64 + } + | Float of float + | Scroll of scroll + | Visible_range of visible_range + | Navigation_path_changed of Journal_ids.Navigation.Page_key.t list + | Native_event of native_event + | Native_list_completion of native_list_completion + | Event of Lui_protocol.event + end + + module Handler : sig + type t + + val create : ?name:string -> (Payload.t -> unit) -> t + val name : t -> string option + + module Private : sig + val same : t -> t -> bool + val invoke : t -> Payload.t -> unit + end + end + + type handler = Handler.t +end + +module Style : sig + module Color : sig + type t + + val rgb : red:int -> green:int -> blue:int -> t + val argb : alpha:int -> red:int -> green:int -> blue:int -> t + end + + module Text_style : sig + type foreground = + | Primary + | Secondary + + type font_weight = + | Regular + | Semi_bold + + type t + + val create : ?foreground:foreground -> ?font_weight:font_weight -> unit -> t + end +end + +module Layout : sig + module Edge_insets : sig + type t + + val all : float -> t + end + + module Alignment : sig + type t + end + + module Horizontal_alignment : sig + type t = + | Leading + | Center + | Trailing + end + + module Vertical_alignment : sig + type t = + | Top + | Center + | Bottom + end + + module Frame_limit : sig + type t = + | Fixed of float + | Fill + end +end + +module Semantics : sig + module Role : sig + type t = + | Generic + | Button + | Link + | Image + | Header + | Toggle + | Static_text + + val equal : t -> t -> bool + val to_string : t -> string + end + + module Children : sig + type t = + | Combine + | Contain + | Ignore + end + + module Action : sig + type t + + val create : id:int64 -> label:string -> t + val id : t -> int64 + val label : t -> string + val equal : t -> t -> bool + end + + type t + + val create + : ?label:string + -> ?hint:string + -> ?value:string + -> ?role:Role.t + -> ?selected:bool + -> ?children:Children.t + -> ?hidden:bool + -> ?live_region:bool + -> ?heading_level:int + -> ?sort_priority:float + -> ?identifier:string + -> ?actions:Action.t list + -> unit + -> t + + module Private : sig + val view : t -> t + end +end + +module Theme : sig + type mode = + | System + | Light + | Dark + + type t + + val create : mode:mode -> unit -> t +end + +module Text_editing : sig + module Range : sig + type t + + val create : text:string -> start_utf16:int -> end_utf16:int -> t + val start_utf16 : t -> int + val end_utf16 : t -> int + val equal : t -> t -> bool + end + + module Value : sig + type t + + val create : text:string -> selection:Range.t -> ?composing:Range.t -> unit -> t + val text : t -> string + val selection : t -> Range.t + val composing : t -> Range.t option + val equal : t -> t -> bool + end + + module Utf16 : sig + val length : string -> int + end + + type update_mode = + | Ack + | Force_replace + | Initiate + | Resume + + module Keyboard : sig + type t = + | Default + | Text + end + + module Submit_label : sig + type t = + | Default + | Go + | Done + | Return + | Send + end + + module Field_appearance : sig + type t = + | Rounded + | Plain + end +end + +module View : sig + type nonrec t = t + + module For_testing : sig + val key : t -> string option + val test_id : t -> string option +end + +module Button_role : sig + type t = + | Normal + | Destructive + | Cancel +end + +module Button_style : sig + type t = + | Automatic + | Plain + | Bordered + | Prominent + | Button +end + +module Progress_style : sig + type t = + | Linear + | Circular +end + +val with_test_id : Test_id.t -> t -> t +val empty : ?key:Key.t -> unit -> t + +val text + : ?key:Key.t + -> ?style:Style.Text_style.t + -> ?text_align:'a + -> ?line_limit:int + -> ?truncation:'b + -> string + -> t + +val symbol + : ?key:Key.t + -> ?size:float + -> ?color:Style.Color.t + -> ?rendering:'a + -> name:string + -> unit + -> t + +val label : ?key:Key.t -> title:t -> icon:t -> unit -> t +val divider : ?key:Key.t -> unit -> t + +val progress + : ?key:Key.t + -> ?value:float + -> ?style:Progress_style.t + -> unit + -> t + +val spacer : ?key:Key.t -> ?min_length:float -> unit -> t + +val row + : ?key:Key.t + -> ?spacing:float + -> ?alignment:Layout.Vertical_alignment.t + -> t list + -> t + +val column + : ?key:Key.t + -> ?spacing:float + -> ?alignment:Layout.Horizontal_alignment.t + -> t list + -> t + +val stack : ?key:Key.t -> ?alignment:Layout.Alignment.t -> t list -> t + +val frame + : ?key:Key.t + -> ?width:float + -> ?height:float + -> ?min_width:float + -> ?ideal_width:float + -> ?max_width:Layout.Frame_limit.t + -> ?min_height:float + -> ?ideal_height:float + -> ?max_height:Layout.Frame_limit.t + -> ?alignment:Layout.Alignment.t + -> t + -> t + +val padding : ?key:Key.t -> insets:Layout.Edge_insets.t -> t -> t +val semantics : ?key:Key.t -> properties:Semantics.t -> t -> t +val help : ?key:Key.t -> message:string -> t -> t +val text_selection : ?key:Key.t -> enabled:bool -> t -> t +val opacity : ?key:Key.t -> float -> t -> t + +val ignores_safe_area + : ?regions:'a + -> ?edges:'b list + -> t + -> t + +val safe_area_padding : ?key:Key.t -> insets:Layout.Edge_insets.t -> t -> t +val theme : ?key:Key.t -> data:Theme.t -> t -> t +val background : ?key:Key.t -> ?corner_radius:float -> color:Style.Color.t -> t -> t +val clip : ?key:Key.t -> ?corner_radius:float -> ?antialiased:bool -> t -> t +val layout_priority : ?key:Key.t -> float -> t -> t +val offset : ?key:Key.t -> ?x:float -> ?y:float -> t -> t +val animated_opacity : ?key:Key.t -> ?duration:float -> float -> t -> t + +val button + : ?key:Key.t + -> ?enabled:bool + -> ?role:Button_role.t + -> ?style:Button_style.t + -> ?autofocus:bool + -> on_press:Event.handler + -> child:t + -> unit + -> t + +val toggle + : ?key:Key.t + -> ?style:Button_style.t + -> ?enabled:bool + -> value:bool + -> on_changed:Event.handler + -> label:t + -> unit + -> t + +val text_editor + : ?key:Key.t + -> ?autofocus:bool + -> ?enabled:bool + -> ?read_only:bool + -> ?submit_on_return:bool + -> ?max_utf8_bytes:int + -> session_id:Journal_ids.Text_input.Session_id.t + -> document_revision:Journal_ids.Text_input.Document_revision.t + -> accepted_local_revision:Journal_ids.Text_input.Local_revision.t + -> update_mode:Text_editing.update_mode + -> value:Text_editing.Value.t + -> on_edit:Event.handler + -> on_submit:Event.handler + -> on_focus_changed:Event.handler + -> ?on_limit_reached:Event.handler + -> unit + -> t + +val secure_field + : ?key:Key.t + -> label:string + -> ?prompt:string + -> ?keyboard:Text_editing.Keyboard.t + -> ?submit_label:Text_editing.Submit_label.t + -> ?appearance:Text_editing.Field_appearance.t + -> ?autofocus:bool + -> ?enabled:bool + -> ?read_only:bool + -> ?submit_on_return:bool + -> ?max_utf8_bytes:int + -> session_id:Journal_ids.Text_input.Session_id.t + -> document_revision:Journal_ids.Text_input.Document_revision.t + -> accepted_local_revision:Journal_ids.Text_input.Local_revision.t + -> update_mode:Text_editing.update_mode + -> value:Text_editing.Value.t + -> on_edit:Event.handler + -> on_submit:Event.handler + -> on_focus_changed:Event.handler + -> ?on_limit_reached:Event.handler + -> unit + -> t + +val labeled_content : ?key:Key.t -> label:t -> value:t -> unit -> t + +val content_unavailable + : ?key:Key.t + -> label:t + -> ?description:t + -> ?actions:t + -> unit + -> t + +val overlay : ?key:Key.t -> ?alignment:Layout.Alignment.t -> overlay:t -> t -> t + +module Keyed : sig + type widget = t + + type nonrec t = + { key : string + ; view : widget + } + + val create : key:string -> widget -> t +end + +module Section : sig + val create : ?key:Key.t -> ?header:t -> ?footer:t -> Keyed.t list -> t +end + +module Form : sig + val vertical : ?key:Key.t -> Keyed.t list -> t +end + +module Toolbar : sig + type placement = + | Automatic + | Principal + | Navigation + | Primary_action + | Secondary_action + | Status + | Confirmation_action + | Cancellation_action + | Destructive_action + | Bottom_bar + + type spacing = + | Fixed + | Flexible + + type child + type item + + val child : key:Key.t -> t -> child + val item : key:Key.t -> ?placement:placement -> t -> item + val group : key:Key.t -> ?placement:placement -> child list -> item + val spacer : key:Key.t -> ?placement:placement -> spacing -> item + val create : ?key:Key.t -> items:item list -> t -> t +end + +module Body : sig + type nonrec t = t + type widget = t + + val with_size : width:float -> height:float -> t -> widget + val static : widget -> t + val with_test_id : Test_id.t -> t -> t + val padding : insets:Layout.Edge_insets.t -> t -> t + val background : ?corner_radius:float -> color:Style.Color.t -> t -> t + val semantics : properties:Semantics.t -> t -> t + + val ignores_safe_area + : ?regions:'a + -> ?edges:'b list + -> t + -> t + + val safe_area_padding : insets:Layout.Edge_insets.t -> t -> t + val theme : data:Theme.t -> t -> t + val toolbar : ?key:Key.t -> items:Toolbar.item list -> t -> t + + module Vertical : sig + type child + + val fixed : widget -> child + val fill : ?weight:float -> widget -> child + val create : ?key:Key.t -> child list -> t + end + + module Horizontal : sig + type child + + val fixed : widget -> child + val fill : ?weight:float -> widget -> child + val create : ?key:Key.t -> child list -> t + end + + val overlay : ?key:Key.t -> ?alignment:Layout.Alignment.t -> overlay:widget -> t -> t + + module Private : sig + val to_widget : t -> widget + end +end + +module Viewport : sig + module Vertical : sig + type nonrec t = t + + val with_test_id : Test_id.t -> t -> t + val padding : insets:Layout.Edge_insets.t -> t -> t + val background : ?corner_radius:float -> color:Style.Color.t -> t -> t + val semantics : properties:Semantics.t -> t -> t + + val ignores_safe_area + : ?regions:'a + -> ?edges:'b list + -> t + -> t + + val safe_area_padding : insets:Layout.Edge_insets.t -> t -> t + val theme : data:Theme.t -> t -> t + val overlay : ?key:Key.t -> ?alignment:Layout.Alignment.t -> overlay:t -> t -> t + val with_height : height:float -> t -> t + end + + module Horizontal : sig + type nonrec t = t + + val with_test_id : Test_id.t -> t -> t + val with_width : width:float -> t -> t + end +end + +module Scroll : sig + type anchor = + | Start + | End + + val vertical + : ?key:Key.t + -> ?on_scroll:Event.handler + -> ?shows_indicators:bool + -> ?fill_viewport:bool + -> ?initial_anchor:anchor + -> t + -> t +end + +module Swipe_actions : sig + type side = + | Start + | End + + type action + type nonrec t + + val action + : key:Key.t + -> ?enabled:bool + -> ?role:Button_role.t + -> ?symbol:string + -> side:side + -> title:string + -> background:Style.Color.t + -> on_press:Event.handler + -> unit + -> action + + val create : ?enabled:bool -> ?allows_full_swipe:bool -> actions:action list -> unit -> t +end + +module Context_menu : sig + type nonrec view = t + + type role = + | Normal + | Destructive + + type action + type nonrec t + + val action + : key:Key.t + -> ?enabled:bool + -> ?role:role + -> ?symbol:string + -> title:string + -> on_press:Event.handler + -> unit + -> action + + val create : ?enabled:bool -> actions:action list -> unit -> t + val attach : ?key:Key.t -> t -> view -> view +end + +module Confirmation : sig + type action + type request + + val action + : key:string + -> title:string + -> ?enabled:bool + -> ?role:Button_role.t + -> unit + -> action + + val request : token:int64 -> title:string -> ?message:string -> action list -> request + val alert : ?key:Key.t -> request:request option -> on_response:Event.handler -> t -> t + val dialog : ?key:Key.t -> request:request option -> on_response:Event.handler -> t -> t +end + +module Native_list : sig + type anchor = + | Top + | Center + | Bottom + + type target + type scroll_request + type outcome = Event.Payload.native_list_outcome + type completion = Event.Payload.native_list_completion + + val target : section:Key.t -> row_path:Key.t list -> target + + val scroll_request + : token:int64 + -> target:target + -> ?anchor:anchor + -> ?animated:bool + -> unit + -> scroll_request + + val completion_of_payload : Event.Payload.t -> completion option + + type style = + | Plain + | Inset + | Inset_grouped + + type separator = + | Automatic + | Hidden + | Visible + + type row + type section + + val row + : key:Key.t + -> ?test_id:Test_id.t + -> ?separator:separator + -> ?swipe_actions:Swipe_actions.t + -> ?context_menu:Context_menu.t + -> t + -> row + + val disclosure_row + : key:Key.t + -> ?test_id:Test_id.t + -> ?separator:separator + -> ?swipe_actions:Swipe_actions.t + -> ?context_menu:Context_menu.t + -> expanded:bool + -> on_expanded_changed:Event.handler + -> label:t + -> row list + -> row + + val section : key:Key.t -> ?header:t -> ?footer:t -> ?separator:separator -> row list -> section + + val vertical + : ?key:Key.t + -> style:style + -> ?scroll_request:scroll_request + -> ?on_scroll_completed:Event.handler + -> ?on_visible_range:Event.handler + -> ?on_row_event:Event.handler + -> section list + -> t +end + +module Navigation_link : sig + val create + : ?key:Key.t + -> activation_id:string + -> ?enabled:bool + -> on_activate:Event.handler + -> label:t + -> unit + -> t +end + +module Navigation_stack : sig + type destination + + val destination : page_key:string -> title:string -> can_pop:bool -> t -> destination + + val create + : ?key:Key.t + -> title:string + -> on_path_change:Event.handler + -> path:destination list + -> t + -> t +end + +module Sheet : sig + type sizing = + | Automatic + | Form + | Fitted + + type detent = + | Medium + | Large + + val create + : ?key:Key.t + -> presented:bool + -> on_presented_changed:Event.handler + -> ?interactive_dismiss:bool + -> ?sizing:sizing + -> ?detents:detent list + -> content:t + -> t + -> t +end + +module Picker : sig + type choice + + type style = + | Automatic + | Menu + | Segmented + | Inline + + val option : id:int64 -> ?enabled:bool -> ?label:t -> unit -> choice + + val create + : ?key:Key.t + -> ?label:string + -> ?style:style + -> ?enabled:bool + -> selected_id:int64 option + -> on_select:Event.handler + -> choice list + -> unit + -> t +end + +module Menu : sig + type entry + + val action : id:int64 -> label:t -> ?enabled:bool -> ?role:Button_role.t -> unit -> entry + val choice : id:int64 -> label:t -> selected:bool -> ?enabled:bool -> unit -> entry + val divider : id:int64 -> entry + val section : id:int64 -> ?label:t -> entry list -> entry + val submenu : id:int64 -> label:t -> ?enabled:bool -> entry list -> entry + + val create + : ?key:Key.t + -> ?enabled:bool + -> on_select:Event.handler + -> label:t + -> entry list + -> t +end + +end + +module Native_widget : sig + module Capability : sig + type t = + | Stateful + | Resource + | Semantics + | Semantics_canvas + | Virtualized + + val bit : t -> int64 + val bits : t list -> int64 + end + + module Extension : sig + type ('props, 'event) t + + val create + : kind_id:Journal_ids.Native_widget.Kind_id.t + -> version:int + -> capabilities:Capability.t list + -> encode_props:('props -> bytes) + -> decode_event: + (event_id:Journal_ids.Native_widget.Event_id.t + -> bytes + -> ('event, string) result) + -> unit + -> ('props, 'event) t + end + + val event_handler + : ?name:string + -> ('props, 'event) Extension.t + -> ('event -> unit) + -> Event.handler + + val widget + : ('props, 'event) Extension.t + -> ?key:Key.t + -> props:'props + -> on_event:('event -> unit) + -> ?children:View.t list + -> unit + -> View.t + + val widget_with_handler + : ('props, 'event) Extension.t + -> ?key:Key.t + -> props:'props + -> on_event:Event.handler + -> ?children:View.t list + -> unit + -> View.t +end diff --git a/app/journal_visual_tokens.ml b/app/journal_visual_tokens.ml index 76f119c..c2dc98d 100644 --- a/app/journal_visual_tokens.ml +++ b/app/journal_visual_tokens.ml @@ -1,4 +1,4 @@ -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view type swipe_action_colors = { background : Ui.Style.Color.t @@ -86,8 +86,8 @@ type t = let resolve ~brightness ~high_contrast = let presentation = match brightness with - | Bonsai_swiftui.Environment.Light -> Color_exceptions.Light - | Bonsai_swiftui.Environment.Dark -> Color_exceptions.Dark + | Journal_environment.Light -> Color_exceptions.Light + | Journal_environment.Dark -> Color_exceptions.Dark in { presentation; high_contrast } ;; diff --git a/app/journal_visual_tokens.mli b/app/journal_visual_tokens.mli index df7145f..78e3245 100644 --- a/app/journal_visual_tokens.mli +++ b/app/journal_visual_tokens.mli @@ -1,4 +1,4 @@ -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view type swipe_action_colors = { background : Ui.Style.Color.t @@ -7,7 +7,7 @@ type swipe_action_colors = type t -val resolve : brightness:Bonsai_swiftui.Environment.brightness -> high_contrast:bool -> t +val resolve : brightness:Journal_environment.brightness -> high_contrast:bool -> t val status_swipe_action : t -> Journal_model.task_state -> swipe_action_colors val status_action_background : Ui.Style.Color.t val delete_action_background : Ui.Style.Color.t diff --git a/dune-project b/dune-project index 040b4f7..5362d82 100644 --- a/dune-project +++ b/dune-project @@ -40,7 +40,7 @@ (digestif (= 1.3.1)) (eio (= 1.2)) (melange-transit-native (= 0.1.2)) - (ppx_deriving_yojson (= 3.9.1)) + (ppx_deriving_yojson (>= 3.9.1)) (sqlite3 (= 5.4.0)) (uucp (= 17.0.0)) (uunf (= 17.0.0)) diff --git a/logseq_db_worker/bonsai/dune b/logseq_db_worker/bonsai/dune deleted file mode 100644 index 4d4c323..0000000 --- a/logseq_db_worker/bonsai/dune +++ /dev/null @@ -1,16 +0,0 @@ -(library - (name logseq_db_worker_bonsai) - (public_name logseq_db_worker.bonsai) - (libraries - bonsai_swiftui.driver - eio - logseq_db_worker - logseq_db_types - logseq_overlay_db - logseq_sync.effect_runner - logseq_sync.pure_reducer - melange-transit-native - mtime.clock.os - unix - uri - yojson)) diff --git a/logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml b/logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml deleted file mode 100644 index 8a0e956..0000000 --- a/logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml +++ /dev/null @@ -1,592 +0,0 @@ -module Db = Logseq_db_worker -module Pure = Logseq_db_worker_pure_reducer.Core -module Worker_runner = Logseq_db_worker_effect_runner.Effect_runner -module Sync = Logseq_sync_pure_reducer.Core -module Sync_runner = Logseq_sync_effect_runner.Effect_runner -module Protocol = Db.Protocol -module Overlay = Logseq_overlay_db.Database -module ID = Bonsai_swiftui_spec.Id - -type graph_id = Logseq_db_types.Graph_types.Uuid.t -type graph = Logseq_db_types.Managed_graph.t - -type sync_phase = - | Offline - | Connecting - | Pulling - | Submitting - | Current - | Paused - | Failed - -type startup_failure_stage = - | During_authentication - | During_catalog - | During_local_restore - | During_bootstrap - | During_e2ee - -type startup_facts = - { authenticated : bool - ; catalog_loading : bool - ; awaiting_selection : bool - ; restoring_local : bool - ; bootstrapping : bool - ; awaiting_e2ee_password : bool - ; failure : startup_failure_stage option - ; account_generation : int - ; graph_generation : int - ; presentation_generation : int - } - -type local_deletion_stage = Logseq_sync_pure_reducer.Core.local_deletion_stage = - | Closing_graph - | Deleting_mirror - | Clearing_selection - -type local_deletion = Logseq_sync_pure_reducer.Core.local_deletion = - | Deletion_in_progress of local_deletion_stage - | Deletion_failed of local_deletion_stage - -type snapshot = - { sync_phase : sync_phase - ; catalog : graph list - ; selected_graph : graph_id option - ; applied_server_t : int option - ; timeline_presentation_pending : bool - ; startup : startup_facts - ; last_error : string option - ; local_deletion : local_deletion option - } - -type diagnostic_group = - { title : string - ; entries : (string * string) list - } - -type diagnostics = { groups : diagnostic_group list } - -type state = - { snapshot : snapshot - ; diagnostics : diagnostics - } - -type token_request = Worker_runner.id_token_request - -let token_request_id = Worker_runner.id_token_request_id - -type bootstrap_progress = - { graph_id : graph_id - ; received_bytes : int64 - ; total_bytes : int64 option - } - -let sync_phase = function - | Sync.Offline -> Offline - | Connecting -> Connecting - | Pulling -> Pulling - | Submitting -> Submitting - | Current -> Current - | Paused -> Paused - | Failed -> Failed -;; - -let failure_stage = function - | Sync.During_authentication -> During_authentication - | During_catalog -> During_catalog - | During_local_restore -> During_local_restore - | During_bootstrap -> During_bootstrap - | During_e2ee -> During_e2ee -;; - -let startup_facts (facts : Sync.startup_facts) = - { authenticated = facts.authenticated - ; catalog_loading = facts.catalog_loading - ; awaiting_selection = facts.awaiting_selection - ; restoring_local = facts.restoring_local - ; bootstrapping = facts.bootstrapping - ; awaiting_e2ee_password = facts.awaiting_e2ee_password - ; failure = Option.map failure_stage facts.failure - ; account_generation = facts.account_generation - ; graph_generation = facts.graph_generation - ; presentation_generation = facts.presentation_generation - } -;; - -let snapshot (value : Sync.snapshot) = - { sync_phase = sync_phase value.sync_phase - ; catalog = value.catalog - ; selected_graph = value.selected_graph - ; applied_server_t = value.applied_server_t - ; timeline_presentation_pending = value.timeline_presentation_pending - ; startup = startup_facts value.startup - ; last_error = value.last_error - ; local_deletion = value.local_deletion - } -;; - -let diagnostics (value : Sync.diagnostics) = - { groups = - List.map - (fun (group : Sync.diagnostic_group) -> - { title = group.title; entries = group.entries }) - value.groups - } -;; - -let client_state (value : Sync.state) = - { snapshot = snapshot value.snapshot; diagnostics = diagnostics value.diagnostics } -;; - -let bootstrap_progress (value : Sync.bootstrap_progress) = - { graph_id = value.graph_id - ; received_bytes = value.received_bytes - ; total_bytes = value.total_bytes - } -;; - -type client_command = - | Restore_local_account of { user_id : string } - | Reconcile_authenticated_user of { user_id : string option } - | Acknowledge_local_feed - | Acknowledge_timeline_presented - | Provide_token of - { request : token_request - ; token : string - } - | Reject_token of token_request - | Select_graph of Sync.graph_id - | Return_to_graph_picker - | Refresh_catalog - | Begin_online_recovery - | Submit_e2ee_password of string - | Delete_local_cache of Sync.graph_id - | Set_foreground of bool - -module Asset = struct - type priority = Logseq_sync_pure_reducer.Asset_transfer.priority = - | Foreground - | Background - - type failure = Logseq_sync_pure_reducer.Asset_transfer.failure = - | Network - | Not_found - | Checksum_mismatch - | Authentication - | Locked - | Storage_full - | Invalid_content of string - - type availability = Logseq_sync_pure_reducer.Asset_transfer.availability = - | Queued - | Downloading - | Ready of string - | Waiting_remote - | Waiting_network - | Waiting_unlock - | Failed of - { failure : failure - ; attempts : int - ; retry_scheduled : bool - } -end - -type asset_scope = Logseq_sync_pure_reducer.Core.graph_scope - -type asset_notice = Logseq_db_worker_pure_reducer.Core.asset_notice = - | Asset_availability of - { consumer : string - ; asset : Logseq_db_types.Graph_types.Uuid.t - ; availability : Logseq_sync_pure_reducer.Asset_transfer.availability - } - | Asset_demand_accepted of string - | Asset_backpressure of string - | Asset_capacity_available - | Upload_status of - { operation : Logseq_db_types.Graph_types.Uuid.t - ; asset : Logseq_db_types.Graph_types.Uuid.t - ; target : Logseq_db_types.Graph_types.Uuid.t - ; title : string - ; status : Logseq_db_worker_pure_reducer.Asset_upload.status - } - -type asset_command = - | Replace_asset_demand of - { consumer : string - ; priority : Logseq_sync_pure_reducer.Asset_transfer.priority - ; assets : Logseq_db_types.Asset_descriptor.t list - } - | Release_asset_demand of string - | Retry_asset of Logseq_db_types.Graph_types.Uuid.t - | Retry_upload of Logseq_db_types.Graph_types.Uuid.t - -type request = - | Import_asset of - { graph_generation : int - ; source : Logseq_db_types.Asset_import.t - } - | Client_command of client_command - | Graph_request of Protocol.request - | Get_graph_state - | Asset_command of - { graph_generation : int - ; command : asset_command - } - | Acquire_imported_file of - { scope : Logseq_sync_pure_reducer.Core.graph_scope - ; operation : Logseq_db_types.Graph_types.Uuid.t - } - | Acquire_asset_file of - { scope : Logseq_sync_pure_reducer.Core.graph_scope - ; handle : string - } - | Release_asset_file of - { scope : Logseq_sync_pure_reducer.Core.graph_scope - ; handle : string - } - -type response = - | Asset_imported of (Logseq_db_worker.import_receipt, string) result - | Client_command_completed - | Asset_file of (string * string) option - | Graph_response of Protocol.response - | Graph_state of Db.graph_state - -type push = - | Graph_push of Protocol.push - | Client_state_changed of state - | Need_id_token of token_request - | Bootstrap_progress of bootstrap_progress - | Graph_state_changed of Db.graph_state - | Asset_notice of - Logseq_sync_pure_reducer.Core.graph_scope - * Logseq_db_worker_pure_reducer.Core.asset_notice - -let invalidation_topic = ID.Worker.Push_topic.of_int 0 -let manager_topic = ID.Worker.Push_topic.of_int 1 -let auth_topic = ID.Worker.Push_topic.of_int 2 -let bootstrap_topic = ID.Worker.Push_topic.of_int 3 -let graph_state_topic = ID.Worker.Push_topic.of_int 4 -let asset_topic = ID.Worker.Push_topic.of_int 5 - -type dependencies = - { overlay : Overlay.dependencies - ; tls_authenticator : Sync_runner.tls_authenticator - ; secrets : Sync_runner.secrets - ; crypto : Sync_runner.crypto - } - -let dependencies ~overlay ~tls_authenticator ~secrets ~crypto = - { overlay; tls_authenticator; secrets; crypto } -;; - -let production_dependencies () = - let limits = - Logseq_overlay_db.Types. - { response_budget_bytes = Protocol.maximum_response_bytes - ; outbox_max_records = 4_096 - ; outbox_max_bytes = 8 * 1024 * 1024 - ; change_max_items = Protocol.maximum_changed_uuids - ; change_max_bytes = Protocol.maximum_push_bytes - ; dispatcher_capacity = 256 - ; wire_batch_max_bytes = Protocol.maximum_response_bytes - } - in - let overlay = - Overlay.dependencies - ~epoch_ms:(fun () -> Unix.gettimeofday () *. 1_000. |> Int64.of_float) - ~monotonic_ns:Mtime_clock.elapsed_ns - ~limits - |> Result.get_ok - in - let secrets = Sync_runner.apple_secrets () |> Result.get_ok in - let crypto = Sync_runner.apple_crypto () |> Result.get_ok in - let tls_authenticator = Sync_runner.system_tls_authenticator () |> Result.get_ok in - { overlay; tls_authenticator; secrets; crypto } -;; - -let publish context = function - | Pure.Asset_notice (scope, notice) -> - Worker.Session_context.emit context ~topic:asset_topic (Asset_notice (scope, notice)) - | Pure.Reply _ -> () - | Graph_push push -> - Worker.Session_context.emit context ~topic:invalidation_topic (Graph_push push) - | Sync_output output -> - (match output with - | State_changed state -> - Worker.Session_context.emit - context - ~topic:manager_topic - (Client_state_changed (client_state state)) - | Bootstrap_progressed progress -> - Worker.Session_context.emit - context - ~topic:bootstrap_topic - (Bootstrap_progress (bootstrap_progress progress))) - | Graph_state_changed state -> - Worker.Session_context.emit - context - ~topic:graph_state_topic - (Graph_state_changed state) - | Diagnostic _ -> () -;; - -let sync_limits config = - Sync.limits - ~maximum_response_bytes:config.Db.Config.response_budget_bytes - ~maximum_artifact_bytes:(1024 * 1024 * 1024) - ~submission_batch_size:32 -;; - -let sync_dependencies dependencies context config id_token_provider = - let environment = Worker.Session_context.environment context in - let clock = Eio.Stdenv.clock environment in - Result.bind - (Sync_runner.runtime - ~fork:(fun ~sw task -> Eio.Fiber.fork ~sw task) - ~sleep:(Eio.Time.sleep clock)) - (fun runtime -> - Result.bind - (Sync_runner.transport - ~websocket_liveness: - (Sync_runner.Ping_pong { interval_seconds = 30.; timeout_seconds = 10. }) - ~tls_authenticator:dependencies.tls_authenticator - ~network:(Eio.Stdenv.net environment) - ~clock) - (fun transport -> - Result.bind - (Sync_runner.local_store - ~application_support_directory: - config.Db.Config.application_support_directory) - (fun local_store -> - Result.bind - (Sync_runner.artifact_store - ~staging_directory: - (Filename.concat - config.application_support_directory - "sync-staging")) - (fun artifact_store -> - Sync_runner.dependencies - ~runtime - ~transport - ~local_store - ~artifact_store - ~secrets:dependencies.secrets - ~crypto:dependencies.crypto - ~id_token_provider)))) -;; - -let client_event = function - | Restore_local_account { user_id } -> - Pure.Sync_event (Sync.Restore_local_account { user_id }) - | Reconcile_authenticated_user { user_id } -> - Pure.Sync_event (Sync.Account_authenticated { user_id }) - | Acknowledge_local_feed -> Pure.Sync_event Sync.Local_feed_acknowledged - | Acknowledge_timeline_presented -> Pure.Sync_event Sync.Timeline_presented - | Provide_token _ | Reject_token _ -> - invalid_arg "token commands are handled by the service" - | Select_graph graph_id -> Pure.Sync_event (Sync.Graph_selected graph_id) - | Return_to_graph_picker -> Pure.Sync_event Sync.Graph_picker_requested - | Refresh_catalog -> Pure.Sync_event Sync.Catalog_refresh_requested - | Begin_online_recovery -> Pure.Sync_event Sync.Online_recovery_requested - | Submit_e2ee_password password -> - Pure.Sync_event (Sync.E2ee_password_submitted password) - | Delete_local_cache graph_id -> - Pure.Sync_event (Sync.Local_cache_deletion_requested graph_id) - | Set_foreground foreground -> Pure.Set_foreground foreground -;; - -let error_message = function - | Sync.Invalid_config message -> message -;; - -let sync_create_error_message = function - | Sync_runner.Invalid_create message -> message -;; - -let sync_dependency_error_message = function - | Sync_runner.Invalid_dependency message -> message -;; - -let worker_dependency_error_message = function - | Worker_runner.Invalid_dependency message -> message -;; - -let create ~(dependencies : dependencies) = - let module Session = struct - type t = - { worker : Db.t - ; token_cache : Worker_runner.id_token_cache - } - end - in - Worker.Service.create - ~push_topic_count:6 - ~concurrency:(Worker.Service.Concurrent { max_in_flight = 2 }) - ~data_directory:(fun config -> Ok config.Db.Config.application_support_directory) - ~init:(fun context config -> - let sw = Worker.Session_context.switch context in - let event_sink = ref (fun (_ : Pure.event) -> ()) in - let token_cache = - Worker_runner.id_token_cache - ~wall_clock_s:Unix.gettimeofday - ~monotonic_ns:Mtime_clock.elapsed_ns - ~request:(fun request -> - Worker.Session_context.emit context ~topic:auth_topic (Need_id_token request)) - in - let id_token_provider = - Sync_runner.id_token_provider - ~acquire:(fun account -> Worker_runner.acquire_id_token token_cache ~account) - ~invalidate:(fun account ~token -> - Worker_runner.invalidate_id_token token_cache ~account ~token) - in - let (Managed_sync { base_url }) = config.Db.Config.target in - let selected = - match sync_limits config with - | Error error -> Error (error_message error) - | Ok limits -> - (match Sync.config ~managed_sync_origin:(Uri.of_string base_url) ~limits with - | Error error -> Error (error_message error) - | Ok sync_config -> - (match sync_dependencies dependencies context config id_token_provider with - | Error error -> Error (sync_dependency_error_message error) - | Ok runner_dependencies -> - (match - Sync_runner.create ~sw runner_dependencies ~post:(fun event -> - !event_sink (Pure.Sync_event event)) - with - | Error error -> Error (sync_create_error_message error) - | Ok runner -> - Ok - ( sync_config - , Worker_runner.sync_runner - ~stage_asset:(Sync_runner.stage_asset runner) - ~release_staging:(Sync_runner.release_staged_asset runner) - ~prune_staging:(Sync_runner.prune_staged_assets runner) - ~put_upload:(fun ~context intent ~current -> - match - Sync_runner.staged_asset_path - runner - ~scope:context.scope - ~file: - intent.Logseq_db_types.Asset_upload_intent.staged_file - with - | None -> - Error - Logseq_db_worker_pure_reducer.Asset_upload.Missing_source - | Some source_file -> - Sync_runner.upload_asset - runner - ~context - ~asset:intent.asset - ~version:intent.version - ~source_file - ~maximum_plaintext_bytes:(8 * 1024 * 1024) - ~current - |> Result.map_error (function - | Sync_runner.Upload_network -> - Logseq_db_worker_pure_reducer.Asset_upload.Network - | Upload_authentication | Upload_locked -> Authentication - | Upload_missing_source -> Missing_source - | Upload_size_rejected -> Size_rejected - | Upload_revoked_access -> Revoked_access - | Upload_invalid_content | Upload_cancelled -> - Invalid_content)) - ~submit_asset:(Sync_runner.run_scoped_asset runner) - ~delete_assets:(Sync_runner.delete_graph_assets runner) - ~close_assets:(Sync_runner.close_asset_scope runner) - ~retain_staged_file:(Sync_runner.retain_staged_file runner) - ~retain_asset_file:(Sync_runner.retain_asset_file runner) - ~release_asset_file:(Sync_runner.release_asset_file runner) - ~submit:(Sync_runner.submit runner) - ~shutdown:(fun () -> Sync_runner.shutdown runner) - ~decrypt_protected_value: - (Sync_runner.decrypt_protected_value runner) - ~encrypt_protected_values: - (Sync_runner.encrypt_protected_values runner) - () )))) - in - match selected with - | Error message -> Error message - | Ok (sync_config, selected_sync_runner) -> - (match - Worker_runner.runtime - ~sleep: - (Eio.Time.sleep - (Eio.Stdenv.clock (Worker.Session_context.environment context))) - ~fork:(fun ~sw task -> Eio.Fiber.fork ~sw task) - with - | Error error -> Error (worker_dependency_error_message error) - | Ok runtime -> - let pure_config = Pure.config ~worker:config ~sync:sync_config in - (match - Worker_runner.dependencies - ~runtime - ~config - ~overlay:dependencies.overlay - ~sync_runner:selected_sync_runner - ~publish:(publish context) - with - | Error error -> Error (worker_dependency_error_message error) - | Ok runner_dependencies -> - (match Db.create ~sw ~config:pure_config ~runner_dependencies with - | Error (Db.Invalid_create message) -> Error message - | Ok worker -> - event_sink := Db.post worker; - Ok Session.{ worker; token_cache })))) - ~handle:(fun _context session request -> - match request with - | Import_asset { graph_generation; source } -> - Ok (Asset_imported (Db.import_asset session.worker ~graph_generation source)) - | Get_graph_state -> Ok (Graph_state (Db.graph_state session.worker)) - | Asset_command { graph_generation; command } -> - let event = - match command with - | Retry_upload operation -> - Pure.Upload_requested - { graph_generation - ; operation - ; event = Logseq_db_worker_pure_reducer.Asset_upload.Retry - } - | Replace_asset_demand { consumer; priority; assets } -> - Pure.Asset_requested - { graph_generation - ; event = - Logseq_sync_pure_reducer.Asset_transfer.Replace - { consumer; priority; assets } - } - | Release_asset_demand consumer -> - Pure.Asset_requested { graph_generation; event = Release consumer } - | Retry_asset asset -> - Pure.Asset_requested { graph_generation; event = Retry asset } - in - Db.post session.worker event; - Ok Client_command_completed - | Acquire_imported_file { scope; operation } -> - Ok (Asset_file (Db.retain_imported_file session.worker ~scope ~operation)) - | Acquire_asset_file { scope; handle } -> - Ok (Asset_file (Db.retain_asset_file session.worker ~scope ~handle)) - | Release_asset_file { scope; handle } -> - Db.release_asset_file session.worker ~scope ~handle; - Ok Client_command_completed - | Client_command (Provide_token { request; token }) -> - Worker_runner.provide_id_token session.token_cache request token; - Ok Client_command_completed - | Client_command (Reject_token request) -> - Worker_runner.reject_id_token session.token_cache request "host rejected request"; - Ok Client_command_completed - | Client_command (Reconcile_authenticated_user { user_id }) -> - Worker_runner.reconcile_authenticated_user session.token_cache ~user_id; - Db.post session.worker (client_event (Reconcile_authenticated_user { user_id })); - Ok Client_command_completed - | Client_command command -> - Db.post session.worker (client_event command); - Ok Client_command_completed - | Graph_request request -> Ok (Graph_response (Db.request session.worker request))) - ~shutdown:(fun session -> - Worker_runner.shutdown_id_token_cache session.token_cache; - Db.shutdown session.worker) - () -;; - -let service = create ~dependencies:(production_dependencies ()) diff --git a/logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.mli b/logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.mli deleted file mode 100644 index 55b4145..0000000 --- a/logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.mli +++ /dev/null @@ -1,212 +0,0 @@ -type graph_id = Logseq_db_types.Graph_types.Uuid.t -type graph = Logseq_db_types.Managed_graph.t - -type sync_phase = - | Offline - | Connecting - | Pulling - | Submitting - | Current - | Paused - | Failed - -type startup_failure_stage = - | During_authentication - | During_catalog - | During_local_restore - | During_bootstrap - | During_e2ee - -type startup_facts = - { authenticated : bool - ; catalog_loading : bool - ; awaiting_selection : bool - ; restoring_local : bool - ; bootstrapping : bool - ; awaiting_e2ee_password : bool - ; failure : startup_failure_stage option - ; account_generation : int - ; graph_generation : int - ; presentation_generation : int - } - -type local_deletion_stage = Logseq_sync_pure_reducer.Core.local_deletion_stage = - | Closing_graph - | Deleting_mirror - | Clearing_selection - -type local_deletion = Logseq_sync_pure_reducer.Core.local_deletion = - | Deletion_in_progress of local_deletion_stage - | Deletion_failed of local_deletion_stage - -type snapshot = - { sync_phase : sync_phase - ; catalog : graph list - ; selected_graph : graph_id option - ; applied_server_t : int option - ; timeline_presentation_pending : bool - ; startup : startup_facts - ; last_error : string option - ; local_deletion : local_deletion option - } - -type diagnostic_group = - { title : string - ; entries : (string * string) list - } - -type diagnostics = { groups : diagnostic_group list } - -type state = - { snapshot : snapshot - ; diagnostics : diagnostics - } - -type token_request - -val token_request_id : token_request -> string - -type bootstrap_progress = - { graph_id : graph_id - ; received_bytes : int64 - ; total_bytes : int64 option - } - -type client_command = - | Restore_local_account of { user_id : string } - | Reconcile_authenticated_user of { user_id : string option } - | Acknowledge_local_feed - | Acknowledge_timeline_presented - | Provide_token of - { request : token_request - ; token : string - } - | Reject_token of token_request - | Select_graph of graph_id - | Return_to_graph_picker - | Refresh_catalog - | Begin_online_recovery - | Submit_e2ee_password of string - | Delete_local_cache of graph_id - | Set_foreground of bool - -module Asset : sig - type priority = Logseq_sync_pure_reducer.Asset_transfer.priority = - | Foreground - | Background - - type failure = Logseq_sync_pure_reducer.Asset_transfer.failure = - | Network - | Not_found - | Checksum_mismatch - | Authentication - | Locked - | Storage_full - | Invalid_content of string - - type availability = Logseq_sync_pure_reducer.Asset_transfer.availability = - | Queued - | Downloading - | Ready of string - | Waiting_remote - | Waiting_network - | Waiting_unlock - | Failed of - { failure : failure - ; attempts : int - ; retry_scheduled : bool - } -end - -type asset_scope = Logseq_sync_pure_reducer.Core.graph_scope - -type asset_notice = Logseq_db_worker_pure_reducer.Core.asset_notice = - | Asset_availability of - { consumer : string - ; asset : Logseq_db_types.Graph_types.Uuid.t - ; availability : Logseq_sync_pure_reducer.Asset_transfer.availability - } - | Asset_demand_accepted of string - | Asset_backpressure of string - | Asset_capacity_available - | Upload_status of - { operation : Logseq_db_types.Graph_types.Uuid.t - ; asset : Logseq_db_types.Graph_types.Uuid.t - ; target : Logseq_db_types.Graph_types.Uuid.t - ; title : string - ; status : Logseq_db_worker_pure_reducer.Asset_upload.status - } - -type asset_command = - | Replace_asset_demand of - { consumer : string - ; priority : Logseq_sync_pure_reducer.Asset_transfer.priority - ; assets : Logseq_db_types.Asset_descriptor.t list - } - | Release_asset_demand of string - | Retry_asset of Logseq_db_types.Graph_types.Uuid.t - | Retry_upload of Logseq_db_types.Graph_types.Uuid.t - -type request = - | Import_asset of - { graph_generation : int - ; source : Logseq_db_types.Asset_import.t - } - | Client_command of client_command - | Graph_request of Logseq_db_worker.Protocol.request - | Get_graph_state - | Asset_command of - { graph_generation : int - ; command : asset_command - } - | Acquire_imported_file of - { scope : Logseq_sync_pure_reducer.Core.graph_scope - ; operation : Logseq_db_types.Graph_types.Uuid.t - } - | Acquire_asset_file of - { scope : Logseq_sync_pure_reducer.Core.graph_scope - ; handle : string - } - | Release_asset_file of - { scope : Logseq_sync_pure_reducer.Core.graph_scope - ; handle : string - } - -type response = - | Asset_imported of (Logseq_db_worker.import_receipt, string) result - | Client_command_completed - | Asset_file of (string * string) option - | Graph_response of Logseq_db_worker.Protocol.response - | Graph_state of Logseq_db_worker.graph_state - -type push = - | Graph_push of Logseq_db_worker.Protocol.push - | Client_state_changed of state - | Need_id_token of token_request - | Bootstrap_progress of bootstrap_progress - | Graph_state_changed of Logseq_db_worker.graph_state - | Asset_notice of - Logseq_sync_pure_reducer.Core.graph_scope - * Logseq_db_worker_pure_reducer.Core.asset_notice - -val invalidation_topic : Bonsai_swiftui_spec.Id.Worker.Push_topic.t -val manager_topic : Bonsai_swiftui_spec.Id.Worker.Push_topic.t -val auth_topic : Bonsai_swiftui_spec.Id.Worker.Push_topic.t -val bootstrap_topic : Bonsai_swiftui_spec.Id.Worker.Push_topic.t -val graph_state_topic : Bonsai_swiftui_spec.Id.Worker.Push_topic.t -val asset_topic : Bonsai_swiftui_spec.Id.Worker.Push_topic.t - -type dependencies - -val dependencies - : overlay:Logseq_overlay_db.Database.dependencies - -> tls_authenticator:Logseq_sync_effect_runner.Effect_runner.tls_authenticator - -> secrets:Logseq_sync_effect_runner.Effect_runner.secrets - -> crypto:Logseq_sync_effect_runner.Effect_runner.crypto - -> dependencies - -val create - : dependencies:dependencies - -> (Logseq_db_worker.Config.t, request, response, push) Worker.Service.t - -val service : (Logseq_db_worker.Config.t, request, response, push) Worker.Service.t diff --git a/logseq_overlay_db.opam b/logseq_overlay_db.opam index 644c9f0..e3ff156 100644 --- a/logseq_overlay_db.opam +++ b/logseq_overlay_db.opam @@ -18,7 +18,7 @@ depends: [ "digestif" {= "1.3.1"} "eio" {= "1.2"} "melange-transit-native" {= "0.1.2"} - "ppx_deriving_yojson" {= "3.9.1"} + "ppx_deriving_yojson" {>= "3.9.1"} "sqlite3" {= "5.4.0"} "uucp" {= "17.0.0"} "uunf" {= "17.0.0"} From 649c88c44a14d217510f03f1e2a9555df7c26c80 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 19:12:59 -0700 Subject: [PATCH 05/40] lui migration: prefer JOURNAL_APPLE_SDK_ROOT in link flags script Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tool/native_static_link_flags.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tool/native_static_link_flags.sh b/tool/native_static_link_flags.sh index 392dc67..6acac12 100755 --- a/tool/native_static_link_flags.sh +++ b/tool/native_static_link_flags.sh @@ -13,7 +13,7 @@ case "${2:-default}" in switch_prefix=${OPAM_SWITCH_PREFIX:-$(opam var prefix)} source_archive="$opam_root/download-cache/sha256/a3/$gmp_sha256" target_cc="$switch_prefix/ios-sysroot/bin/ios-cc" - sdk_root=$BONSAI_SWIFTUI_APPLE_SDK_ROOT + sdk_root=${JOURNAL_APPLE_SDK_ROOT:-${BONSAI_SWIFTUI_APPLE_SDK_ROOT:-}} deployment_target=18.0 if test ! -f "$source_archive"; then From 4349970cf06d9fe6091abf92ca20568076213cea Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 19:30:03 -0700 Subject: [PATCH 06/40] Port Flutter host from bonsai_flutter to lui_flutter_backend - pubspec: drop bonsai_flutter/bonsai_flutter_native for lui_flutter_backend (git dep pinned to the opam pin) + vendored journal_lui_native hook package that links the OCaml complete object (lui_ocaml_* + journal_ocaml_* exports from journal_lui_bridge.c) - main.dart: JournalRuntimeOwner owns JournalOcamlBridge + LUIFlutterBackend; patch callback applies LUI batches, LUIEvents forward to lui_ocaml_* entries, extension events to journal_ocaml_extension_event, wakeup schedules journal_ocaml_pump on the UI isolate, LJP2 platform channel bridged both directions, environment pushed as tag-24 envelopes, and notices resolve through a ScaffoldMessenger sink (tags 25/26/27) - extension registry: journal-chrome/-asset-import/-media/-asset-settings/ -list registered with fingerprints matching journal_lui_native.ml; renderers ported (grouped list with scroll requests, visible-range tracking, swipe + context-menu rows, disclosure expansion) - adapter: bonsai interfaces replaced with JournalPlatformServices/ JournalHostAdapter; LJP2 codec unchanged, tags 24-27 added - Runner configs renamed off bonsai artifacts; bonsai-flutter.sexp and bonsai-bound renderers/tests removed Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- bonsai-flutter.sexp | 16 - flutter/README.md | 2 +- flutter/analysis_options.yaml | 5 + .../encrypted_offline_warm_start_test.dart | 388 --- flutter/ios/Runner.xcodeproj/project.pbxproj | 12 +- flutter/ios/Runner/Info.plist | 4 +- flutter/lib/application_host_adapter.dart | 99 +- flutter/lib/journal_asset_import.dart | 141 ++ flutter/lib/journal_asset_settings.dart | 272 ++ flutter/lib/journal_chrome.dart | 283 +++ flutter/lib/journal_date_row.dart | 25 - flutter/lib/journal_detail_outline.dart | 287 --- flutter/lib/journal_ext_utils.dart | 95 + flutter/lib/journal_extension_registry.dart | 113 + flutter/lib/journal_list.dart | 426 ++++ flutter/lib/journal_media.dart | 311 +++ flutter/lib/journal_root_navigation.dart | 390 --- flutter/lib/journal_tail_fade.dart | 93 - flutter/lib/journal_widget_registry.dart | 29 - flutter/lib/main.dart | 350 ++- .../Flutter/GeneratedPluginRegistrant.swift | 6 +- .../macos/Runner.xcodeproj/project.pbxproj | 18 +- .../xcshareddata/xcschemes/Runner.xcscheme | 10 +- flutter/macos/Runner/Configs/AppInfo.xcconfig | 4 +- flutter/macos/Runner/Configs/Debug.xcconfig | 2 +- ...iFlutter.xcconfig => JournalHost.xcconfig} | 0 flutter/macos/Runner/Configs/Release.xcconfig | 2 +- flutter/macos/RunnerTests/RunnerTests.swift | 2 +- .../journal_lui_native/hook/build.dart | 228 ++ .../hook/ocaml_artifact.dart | 223 ++ .../lib/journal_lui_native.dart | 239 ++ ...journal_lui_native_bindings_generated.dart | 112 + .../packages/journal_lui_native/pubspec.yaml | 17 + .../src/journal_lui_exports.txt | 19 + .../src/journal_lui_ios_process_stubs.c | 98 + .../src/journal_lui_native.c | 75 + flutter/pubspec.lock | 228 +- flutter/pubspec.yaml | 27 +- .../test/application_host_adapter_test.dart | 39 +- flutter/test/journal_detail_outline_test.dart | 97 - flutter/test/journal_header_layout_test.dart | 786 ------ .../test/journal_root_navigation_test.dart | 921 ------- flutter/test/journal_runtime_golden_test.dart | 2180 ----------------- flutter/test/journal_tail_fade_test.dart | 90 - .../logseq_db_worker_host_adapter_test.dart | 2 +- flutter/test/macos_edit_menu_test.dart | 140 -- flutter/test/widget_test.dart | 6 +- 47 files changed, 3290 insertions(+), 5622 deletions(-) delete mode 100644 bonsai-flutter.sexp delete mode 100644 flutter/integration_test/encrypted_offline_warm_start_test.dart create mode 100644 flutter/lib/journal_asset_import.dart create mode 100644 flutter/lib/journal_asset_settings.dart create mode 100644 flutter/lib/journal_chrome.dart delete mode 100644 flutter/lib/journal_date_row.dart delete mode 100644 flutter/lib/journal_detail_outline.dart create mode 100644 flutter/lib/journal_ext_utils.dart create mode 100644 flutter/lib/journal_extension_registry.dart create mode 100644 flutter/lib/journal_list.dart create mode 100644 flutter/lib/journal_media.dart delete mode 100644 flutter/lib/journal_root_navigation.dart delete mode 100644 flutter/lib/journal_tail_fade.dart delete mode 100644 flutter/lib/journal_widget_registry.dart rename flutter/macos/Runner/Configs/{BonsaiFlutter.xcconfig => JournalHost.xcconfig} (100%) create mode 100644 flutter/packages/journal_lui_native/hook/build.dart create mode 100644 flutter/packages/journal_lui_native/hook/ocaml_artifact.dart create mode 100644 flutter/packages/journal_lui_native/lib/journal_lui_native.dart create mode 100644 flutter/packages/journal_lui_native/lib/journal_lui_native_bindings_generated.dart create mode 100644 flutter/packages/journal_lui_native/pubspec.yaml create mode 100644 flutter/packages/journal_lui_native/src/journal_lui_exports.txt create mode 100644 flutter/packages/journal_lui_native/src/journal_lui_ios_process_stubs.c create mode 100644 flutter/packages/journal_lui_native/src/journal_lui_native.c delete mode 100644 flutter/test/journal_detail_outline_test.dart delete mode 100644 flutter/test/journal_header_layout_test.dart delete mode 100644 flutter/test/journal_root_navigation_test.dart delete mode 100644 flutter/test/journal_runtime_golden_test.dart delete mode 100644 flutter/test/journal_tail_fade_test.dart delete mode 100644 flutter/test/macos_edit_menu_test.dart diff --git a/bonsai-flutter.sexp b/bonsai-flutter.sexp deleted file mode 100644 index 3b3272b..0000000 --- a/bonsai-flutter.sexp +++ /dev/null @@ -1,16 +0,0 @@ -(lang 2) - -(app - (name logseq_journal) - (flutter_root flutter) - (native_target app/native_embed.exe.o) - (features sqlite) - (host - (mode custom) - (main lib/main.dart)) - (macos - (minimum_version 26.0) - (architectures arm64)) - (ios - (minimum_version 15.0) - (architectures arm64))) diff --git a/flutter/README.md b/flutter/README.md index 2d2fa91..b2adef7 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -1,4 +1,4 @@ -# bonsai_flutter_logseq_journal_host +# logseq_journal_host A new Flutter project. diff --git a/flutter/analysis_options.yaml b/flutter/analysis_options.yaml index 0d29021..9021a68 100644 --- a/flutter/analysis_options.yaml +++ b/flutter/analysis_options.yaml @@ -7,6 +7,11 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - ios/** + - macos/** include: package:flutter_lints/flutter.yaml linter: diff --git a/flutter/integration_test/encrypted_offline_warm_start_test.dart b/flutter/integration_test/encrypted_offline_warm_start_test.dart deleted file mode 100644 index 9d1d01b..0000000 --- a/flutter/integration_test/encrypted_offline_warm_start_test.dart +++ /dev/null @@ -1,388 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:bonsai_flutter/bonsai_flutter.dart'; -// ignore: implementation_imports -import 'package:bonsai_flutter/src/runtime/foreground_frame_loop.dart'; -import 'package:bonsai_flutter_logseq_journal_host/application_host_adapter.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -const _cryptoChannel = MethodChannel('logseq_journal/platform'); -const _fixtureEnvironment = 'LOGSEQ_JOURNAL_ENCRYPTED_WARM_FIXTURES_JSON'; - -void main() { - final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.onlyPumps; - - testWidgets( - 'compiled encrypted warm start presents locally before network recovery', - (tester) async { - expect( - Platform.isMacOS, - isTrue, - reason: 'this lane requires a macOS host', - ); - final fixtures = _FixtureSet.fromEnvironment(); - - await _deleteAccountSecrets(fixtures.valid); - await _deleteAccountSecrets(fixtures.missingWrappedKey); - final obsoletePending = File( - '${fixtures.valid.graphDirectory.path}/pending-intents-v1.json', - ); - await obsoletePending.writeAsString( - '{"version":1,"entries":[{"secret":"obsolete"}]}', - flush: true, - ); - addTearDown(() async { - await _deleteAccountSecrets(fixtures.valid); - await _deleteAccountSecrets(fixtures.missingWrappedKey); - }); - - final installed = await _crypto({ - 'operation': 'installTestWrappedGraphKeyFixture', - ...fixtures.valid.identity, - }); - expect(installed['ok'], isTrue); - - final validAuth = _BlockingAuth(fixtures.valid.userId); - final valid = await _RuntimeHarness.start( - tester, - fixture: fixtures.valid, - auth: validAuth, - ); - try { - await valid.show(tester); - await valid.pumpUntil( - tester, - () => - find - .text(fixtures.valid.expectedTimelineText) - .evaluate() - .isNotEmpty && - JournalStartupTimeline.snapshot().containsKey( - JournalStartupMilestone.timelineFramePresented, - ), - reason: 'the encrypted local mirror did not present Timeline', - ); - expect(find.text(fixtures.valid.expectedTimelineText), findsOneWidget); - expect( - JournalStartupTimeline.snapshot(), - contains(JournalStartupMilestone.timelineFramePresented), - ); - expect( - validAuth.requestedBeforeTimeline, - isFalse, - reason: 'network authentication started before Timeline presentation', - ); - expect( - await obsoletePending.exists(), - isFalse, - reason: 'compiled startup retained obsolete pending data', - ); - } finally { - await valid.dispose(tester); - await _deleteAccountSecrets(fixtures.valid); - } - - final missingInstalled = await _crypto({ - 'operation': 'installTestWrappedGraphKeyFixture', - ...fixtures.missingWrappedKey.identity, - }); - expect(missingInstalled['ok'], isTrue); - final deletedWrappedKey = await _crypto({ - 'operation': 'deleteWrappedGraphKey', - ...fixtures.missingWrappedKey.identity, - }); - expect(deletedWrappedKey['ok'], isTrue); - - final missingAuth = _BlockingAuth(fixtures.missingWrappedKey.userId); - final missing = await _RuntimeHarness.start( - tester, - fixture: fixtures.missingWrappedKey, - auth: missingAuth, - ); - try { - await missing.show(tester); - await missing.pumpUntil( - tester, - () => find.text('Online recovery is required').evaluate().isNotEmpty, - reason: 'a missing wrapped key did not reach explicit recovery', - ); - expect(find.text('Continue online'), findsOneWidget); - expect( - missingAuth.tokenRequests, - 0, - reason: 'cache failure entered the network lane without user intent', - ); - } finally { - await missing.dispose(tester); - } - }, - timeout: const Timeout(Duration(minutes: 3)), - ); -} - -final class _FixtureSet { - const _FixtureSet({required this.valid, required this.missingWrappedKey}); - - final _Fixture valid; - final _Fixture missingWrappedKey; - - factory _FixtureSet.fromEnvironment() { - final source = Platform.environment[_fixtureEnvironment]; - if (source == null) { - throw StateError('$_fixtureEnvironment is required'); - } - final document = jsonDecode(source) as Map; - return _FixtureSet( - valid: _Fixture.fromJson(document['valid']), - missingWrappedKey: _Fixture.fromJson(document['missingWrappedKey']), - ); - } -} - -final class _Fixture { - const _Fixture({ - required this.supportRoot, - required this.baseUrl, - required this.userId, - required this.graphId, - required this.graphDirectory, - required this.expectedTimelineText, - }); - - final Directory supportRoot; - final Uri baseUrl; - final String userId; - final String graphId; - final Directory graphDirectory; - final String expectedTimelineText; - - Map get identity => { - 'origin': baseUrl.toString(), - 'userId': userId, - 'graphId': graphId, - }; - - factory _Fixture.fromJson(Object? value) { - if (value is! Map || value['formatVersion'] != 1) { - throw const FormatException('encrypted warm-start fixture is invalid'); - } - return _Fixture( - supportRoot: Directory(value['supportRoot']! as String), - baseUrl: Uri.parse(value['baseUrl']! as String), - userId: value['userId']! as String, - graphId: value['graphId']! as String, - graphDirectory: Directory(value['graphDir']! as String), - expectedTimelineText: value['expectedTimelineText']! as String, - ); - } -} - -final class _BlockingAuth implements JournalAuthCapability { - _BlockingAuth(this.userId); - - final String userId; - final Completer _neverToken = Completer(); - int tokenRequests = 0; - bool requestedBeforeTimeline = false; - - @override - Future currentUserId() async => userId; - - @override - Future freshIdToken() { - tokenRequests += 1; - if (!JournalStartupTimeline.snapshot().containsKey( - JournalStartupMilestone.timelineFramePresented, - )) { - requestedBeforeTimeline = true; - } - return _neverToken.future; - } - - @override - Future signOut() async => - throw StateError('the offline integration lane must not sign out'); -} - -final class _RuntimeHarness { - _RuntimeHarness({ - required this.runtime, - required this.config, - required this.adapter, - required this.platform, - required this.frameEligibility, - }); - - final RuntimeClient runtime; - final Uint8List config; - final ApplicationHostAdapter adapter; - final JournalApplicationPlatform platform; - final _FrameEligibility frameEligibility; - - static Future<_RuntimeHarness> start( - WidgetTester tester, { - required _Fixture fixture, - required JournalAuthCapability auth, - }) async { - final adapter = ApplicationHostAdapter( - applicationSupportDirectory: () async => fixture.supportRoot, - baseUrl: fixture.baseUrl, - auth: auth, - readPreference: (_) async => 'balanced', - writePreference: (_, _) async {}, - readLocalAccountBinding: () async => ( - userId: fixture.userId, - managedSyncOrigin: fixture.baseUrl.toString(), - ), - persistLocalAccountBinding: (_) async {}, - clearLocalAccountBinding: () async {}, - ); - final payload = await tester.runAsync(adapter.createApplicationPayload); - expect(payload, isNotNull); - final config = RuntimeBootstrapConfig( - entrypoint: 'logseq_journal', - launchPolicy: RuntimeLaunchPolicy.replaceExisting, - applicationPayload: payload!, - ).encode(); - final runtime = await tester.runAsync( - () => RuntimeClient.start( - config: config, - ).timeout(const Duration(seconds: 20)), - ); - expect(runtime, isNotNull); - final platform = - adapter.createApplicationPlatform() as JournalApplicationPlatform; - return _RuntimeHarness( - runtime: runtime!, - config: config, - adapter: adapter, - platform: platform, - frameEligibility: _FrameEligibility(), - ); - } - - Future show(WidgetTester tester) async { - tester.view.physicalSize = const Size(800, 632); - tester.view.devicePixelRatio = 1; - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - final root = BonsaiFlutterRoot( - config: config, - runtimeStarter: (_) async => runtime, - applicationPlatform: platform, - frameEligibilitySource: frameEligibility, - ); - await tester.pumpWidget( - Builder( - builder: (context) => adapter.buildHost(context: context, child: root), - ), - ); - } - - Future pumpUntil( - WidgetTester tester, - bool Function() predicate, { - required String reason, - }) async { - final stopwatch = Stopwatch()..start(); - while (!predicate() && stopwatch.elapsed < const Duration(seconds: 20)) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), - ); - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - await tester.pump(const Duration(milliseconds: 10)); - final exception = tester.takeException(); - if (exception != null) fail('$reason; renderer exception: $exception'); - } - if (!predicate()) { - final text = tester - .widgetList(find.byType(Text)) - .map((widget) => widget.data) - .whereType() - .toList(growable: false); - final snapshot = await tester.runAsync(runtime.debugSnapshot); - fail( - '$reason; mounted text: $text; ' - 'runtime: state=${snapshot?.state} ' - 'generation=${snapshot?.liveGeneration} ' - 'eligible=${snapshot?.eligible} ' - 'pumpCount=${snapshot?.pumpCount} ' - 'coalesced=${snapshot?.hasCoalescedGrant} ' - 'presentation=${snapshot?.unresolvedPresentationId} ' - 'revision=${snapshot?.unresolvedRevision}', - ); - } - } - - Future dispose(WidgetTester tester) async { - var snapshot = await tester.runAsync(runtime.debugSnapshot); - for ( - var attempt = 0; - attempt < 8 && snapshot?.state == RuntimeWorkerState.awaitingPresentation; - attempt += 1 - ) { - runtime.presentationSucceeded( - generation: snapshot!.liveGeneration, - presentationId: snapshot.unresolvedPresentationId!, - revision: snapshot.unresolvedRevision!, - eventBatch: Uint8List(0), - ); - snapshot = await tester.runAsync(() async { - await Future.delayed(const Duration(milliseconds: 10)); - return runtime.debugSnapshot(); - }); - } - frameEligibility.setEligible(false); - tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.runAsync( - () => runtime.dispose().timeout(const Duration(seconds: 20)), - ); - platform.dispose(); - tester.view.resetPhysicalSize(); - tester.view.resetDevicePixelRatio(); - } -} - -final class _FrameEligibility implements FrameEligibilitySource { - bool _eligible = true; - void Function(bool)? _onChanged; - - @override - bool get isEligible => _eligible; - - @override - void start(void Function(bool isEligible) onChanged) => - _onChanged = onChanged; - - void setEligible(bool eligible) { - if (_eligible == eligible) return; - _eligible = eligible; - _onChanged?.call(eligible); - } - - @override - void dispose() => _onChanged = null; -} - -Future> _crypto(Map request) async { - final result = await _cryptoChannel.invokeMapMethod( - 'e2eeCrypto', - request, - ); - if (result == null) throw StateError('native crypto response is missing'); - return result; -} - -Future _deleteAccountSecrets(_Fixture fixture) async { - await _crypto({ - 'operation': 'deleteAccountSecrets', - 'origin': fixture.baseUrl.toString(), - 'userId': fixture.userId, - }); -} diff --git a/flutter/ios/Runner.xcodeproj/project.pbxproj b/flutter/ios/Runner.xcodeproj/project.pbxproj index ed2d23a..bcef130 100644 --- a/flutter/ios/Runner.xcodeproj/project.pbxproj +++ b/flutter/ios/Runner.xcodeproj/project.pbxproj @@ -395,7 +395,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.bonsaiFlutterLogseqJournalHost; + PRODUCT_BUNDLE_IDENTIFIER = com.example.logseqJournalHost; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -411,7 +411,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.bonsaiFlutterLogseqJournalHost.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.logseqJournalHost.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -428,7 +428,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.bonsaiFlutterLogseqJournalHost.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.logseqJournalHost.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -443,7 +443,7 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.bonsaiFlutterLogseqJournalHost.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.logseqJournalHost.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -575,7 +575,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.bonsaiFlutterLogseqJournalHost; + PRODUCT_BUNDLE_IDENTIFIER = com.example.logseqJournalHost; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; @@ -599,7 +599,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.bonsaiFlutterLogseqJournalHost; + PRODUCT_BUNDLE_IDENTIFIER = com.example.logseqJournalHost; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/flutter/ios/Runner/Info.plist b/flutter/ios/Runner/Info.plist index a27766c..910c972 100644 --- a/flutter/ios/Runner/Info.plist +++ b/flutter/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Bonsai Flutter Logseq Journal Host + Logseq Journal CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - bonsai_flutter_logseq_journal_host + logseq_journal_host CFBundlePackageType APPL CFBundleShortVersionString diff --git a/flutter/lib/application_host_adapter.dart b/flutter/lib/application_host_adapter.dart index c324dfc..c6139c3 100644 --- a/flutter/lib/application_host_adapter.dart +++ b/flutter/lib/application_host_adapter.dart @@ -7,7 +7,6 @@ import 'dart:typed_data'; import 'package:amplify_auth_cognito/amplify_auth_cognito.dart'; import 'package:amplify_authenticator/amplify_authenticator.dart'; import 'package:amplify_flutter/amplify_flutter.dart'; -import 'package:bonsai_flutter/bonsai_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_slidable/flutter_slidable.dart' as fs; @@ -62,7 +61,11 @@ enum JournalPlatformTag { localAccountBindingRequest(20), localAccountBindingResponse(21), timelinePresentedRequest(22), - timelinePresentedResponse(23); + timelinePresentedResponse(23), + environmentEvent(24), + noticeShowRequest(25), + noticeResultResponse(26), + noticeCancelRequest(27); const JournalPlatformTag(this.wireId); final int wireId; @@ -365,8 +368,26 @@ final class JournalAmplifySession implements JournalAuthCapability { } } +/// Journal-side equivalent of the old `BonsaiFlutterApplicationPlatform`: +/// pushed host->OCaml envelopes are delivered via [events] to +/// `journal_ocaml_platform_event`, and OCaml->host requests arrive through +/// [handleRequest], with non-null results returned through +/// `journal_ocaml_platform_response`. +abstract interface class JournalPlatformServices { + Stream get events; + Future handleRequest(Uint8List request); +} + +typedef JournalNoticeResultCallback = + Future Function({ + required String token, + required String message, + required String? actionLabel, + required int durationMs, + }); + final class JournalApplicationPlatform extends WidgetsBindingObserver - implements BonsaiFlutterApplicationPlatform { + implements JournalPlatformServices { JournalApplicationPlatform({ required this.auth, required this.readPreference, @@ -436,8 +457,23 @@ final class JournalApplicationPlatform extends WidgetsBindingObserver if (!_disposed) _events.add(response); } + /// Notice plumbing, installed by the host widget once a + /// ScaffoldMessenger exists. Tag-25 requests resolve through + /// [JournalNoticeResultCallback] to `action|dismiss|swipe|timeout`; tag-27 + /// cancels a pending notice without a response. + JournalNoticeResultCallback? _notice; + void Function(String token)? _cancelNotice; + + void installNoticeSink({ + required JournalNoticeResultCallback showNotice, + required void Function(String token) cancelNotice, + }) { + _notice = showNotice; + _cancelNotice = cancelNotice; + } + @override - Future handleRequest(Uint8List request) async { + Future handleRequest(Uint8List request) async { switch (JournalPlatformCodec.requestTag(request)) { case 6: JournalPlatformCodec.validateEmpty( @@ -553,6 +589,44 @@ final class JournalApplicationPlatform extends WidgetsBindingObserver JournalPlatformTag.timelinePresentedResponse, {'presented': true}, ); + case 25: + final decoded = JournalPlatformCodec.decodeJsonRequest( + request, + JournalPlatformTag.noticeShowRequest, + ); + final notice = _notice; + if (notice == null) return null; + final token = decoded['token']; + final message = decoded['message']; + final durationMs = decoded['durationMs']; + if (token is! String || + message is! String || + durationMs is! num || + (decoded['actionLabel'] != null && + decoded['actionLabel'] is! String)) { + throw const FormatException('notice request is invalid'); + } + final result = await notice( + token: token, + message: message, + actionLabel: decoded['actionLabel'] as String?, + durationMs: durationMs.toInt(), + ); + return JournalPlatformCodec.encodeJson( + JournalPlatformTag.noticeResultResponse, + {'token': token, 'result': result}, + ); + case 27: + final decoded = JournalPlatformCodec.decodeJsonRequest( + request, + JournalPlatformTag.noticeCancelRequest, + ); + final token = decoded['token']; + if (token is! String) { + throw const FormatException('notice-cancel request is invalid'); + } + _cancelNotice?.call(token); + return null; default: throw const FormatException('unsupported application platform request'); } @@ -707,7 +781,16 @@ final class _NativeStartupEnvironment { typedef ApplicationSupportDirectoryProvider = Future Function(); -final class ApplicationHostAdapter implements BonsaiFlutterHostAdapter { +/// Journal-side equivalent of the old `BonsaiFlutterHostAdapter` — the +/// factory surface the application host consumes (payload, platform bridge, +/// host chrome). +abstract interface class JournalHostAdapter { + Future createApplicationPayload(); + JournalApplicationPlatform? createApplicationPlatform(); + Widget buildHost({required BuildContext context, required Widget child}); +} + +final class ApplicationHostAdapter implements JournalHostAdapter { ApplicationHostAdapter({ required this.applicationSupportDirectory, required this.baseUrl, @@ -746,6 +829,8 @@ final class ApplicationHostAdapter implements BonsaiFlutterHostAdapter { }, ); + /// The LDB1 startup envelope, passed to `lui_ocaml_start` — the worker + /// session starts inside init so it must arrive there. @override Future createApplicationPayload() async { final directory = await applicationSupportDirectory(); @@ -758,7 +843,7 @@ final class ApplicationHostAdapter implements BonsaiFlutterHostAdapter { } @override - BonsaiFlutterApplicationPlatform createApplicationPlatform() => + JournalApplicationPlatform createApplicationPlatform() => JournalApplicationPlatform( auth: auth, readPreference: readPreference, @@ -876,7 +961,7 @@ final class _AuthenticatedJournalHost extends StatelessWidget { ); } -ApplicationHostAdapter createBonsaiFlutterHostAdapter({ +ApplicationHostAdapter createJournalHostAdapter({ Uri? baseUrl, Future? amplifyReady, Widget Function()? authenticationFailureBuilder, diff --git a/flutter/lib/journal_asset_import.dart b/flutter/lib/journal_asset_import.dart new file mode 100644 index 0000000..b39c387 --- /dev/null +++ b/flutter/lib/journal_asset_import.dart @@ -0,0 +1,141 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:lui_flutter_backend/lui_flutter_backend.dart'; + +import 'journal_ext_utils.dart'; + +/// Port of `JournalAssetImport.swift` (`journal-asset-import`). +/// +/// Payload: `{enabled, completion?, error?, replace?, request}`. +/// `request` is a counter; when it changes while `replace` is armed the file +/// picker re-opens. `completion` carries the finished operation id so the +/// renderer can release its selection. Events are emitted with `id: 1` and a +/// JSON payload: a pick sends +/// `{operation, asset, localMutation, metadataMutation, path, title, +/// replaceReference?, type}` and cancelling an armed picker sends +/// `{"action": "dismissed"}`. +Widget buildJournalAssetImport(LUIFlutterExtensionContext context) => + _JournalAssetImportView(context: context); + +class _JournalAssetImportView extends StatefulWidget { + const _JournalAssetImportView({required this.context}); + + final LUIFlutterExtensionContext context; + + @override + State<_JournalAssetImportView> createState() => + _JournalAssetImportViewState(); +} + +class _JournalAssetImportViewState extends State<_JournalAssetImportView> { + String? _operation; + bool _pickerOpen = false; + bool _handled = false; + String? _error; + int _lastRequest = -1; + String? _lastCompletion; + + Map get _payload => decodeJournalPayload(widget.context); + + void _emitDismissed() { + if (_handled) return; + _handled = true; + emitJournalEvent(widget.context, 1, const {'action': 'dismissed'}); + } + + Future _pick() async { + setState(() { + _pickerOpen = true; + _handled = false; + }); + PlatformFile? file; + String? pickError; + try { + file = await FilePicker.pickFile(); + } catch (_) { + pickError = 'Unable to access the selected file. Please try again.'; + } + if (!mounted) return; + final replace = _payload['replace']; + setState(() => _pickerOpen = false); + if (file == null || file.path == null) { + // Cancelled (or errored) with an armed replace request counts as a + // dismissal, matching the iOS fileImporter behaviour. + setState(() => _error = pickError); + if (replace != null) _emitDismissed(); + return; + } + _handled = true; + final operation = newJournalUuid(); + final extension = file.extension?.toLowerCase() ?? ''; + emitJournalEvent(widget.context, 1, { + 'operation': operation, + 'asset': newJournalUuid(), + 'localMutation': newJournalUuid(), + 'metadataMutation': newJournalUuid(), + 'path': file.path, + 'title': file.name, + 'replaceReference': replace, + 'type': extension.isEmpty ? 'bin' : extension, + }); + setState(() => _operation = operation); + } + + @override + Widget build(BuildContext context) { + final payload = _payload; + final request = payload['request']; + final requestNumber = request is int ? request : 0; + final replace = payload['replace']; + if (replace != null && + requestNumber != _lastRequest && + _lastRequest >= 0 && + !_pickerOpen) { + _lastRequest = requestNumber; + WidgetsBinding.instance.addPostFrameCallback((_) => _pick()); + } else { + _lastRequest = requestNumber; + } + final completion = payload['completion']; + if (completion is String && + completion == _operation && + completion != _lastCompletion) { + _operation = null; + _error = payload['error'] is String ? payload['error'] as String : null; + } + _lastCompletion = completion is String ? completion : _lastCompletion; + + final enabled = payload['enabled'] != false && _operation == null; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + journalTestId( + 'journal-asset-import', + FilledButton.icon( + onPressed: enabled ? _pick : null, + icon: _operation == null + ? const Icon(Icons.attach_file, size: 18) + : const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + label: Text(_operation == null ? 'Attach file' : 'Importing file'), + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _error!, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.error, + ), + ), + ), + ], + ); + } +} diff --git a/flutter/lib/journal_asset_settings.dart b/flutter/lib/journal_asset_settings.dart new file mode 100644 index 0000000..a64ea04 --- /dev/null +++ b/flutter/lib/journal_asset_settings.dart @@ -0,0 +1,272 @@ +import 'package:flutter/material.dart'; +import 'package:lui_flutter_backend/lui_flutter_backend.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'journal_ext_utils.dart'; + +/// Port of `JournalAssetSettings.swift` (`journal-asset-settings`) plus the +/// `JournalAssetPreferences` UserDefaults shim (shared_preferences). +/// +/// Payload: `{presented, recent, favorites, uploads:[{id,title,message,busy, +/// retry}]}`. Children: exactly one (the button/row that presents the sheet). +/// Events are emitted with `id: 1` and a plain string payload: +/// `days:N` (recent-day setting), `dismissed`, `retry:`. +Widget buildJournalAssetSettings( + LUIFlutterExtensionContext context, + Widget Function(int nodeID) renderChild, +) => _JournalAssetSettingsHost(context: context, renderChild: renderChild); + +const _recentDaysKey = 'assetRecentDays'; +const _defaultRecentDays = 7; +const _maxRecentDays = 3660; + +class _JournalAssetSettingsHost extends StatefulWidget { + const _JournalAssetSettingsHost({ + required this.context, + required this.renderChild, + }); + + final LUIFlutterExtensionContext context; + final Widget Function(int nodeID) renderChild; + + @override + State<_JournalAssetSettingsHost> createState() => + _JournalAssetSettingsHostState(); +} + +class _JournalAssetSettingsHostState extends State<_JournalAssetSettingsHost> { + int _days = _defaultRecentDays; + int? _deliveredDays; + bool _sheetOpen = false; + void Function(VoidCallback)? _sheetUpdater; + + @override + void initState() { + super.initState(); + SharedPreferences.getInstance().then((prefs) { + final stored = prefs.getInt(_recentDaysKey); + if (!mounted) return; + setState(() { + _days = stored != null && stored >= 0 && stored <= _maxRecentDays + ? stored + : _defaultRecentDays; + }); + // Matches the Swift `task(id: isPresented)` initial delivery. + _deliverDays(); + }); + } + + void _emit(String value) { + emitJournalEvent(widget.context, 1, value); + } + + void _deliverDays() { + if (_deliveredDays != _days) { + _emit('days:$_days'); + _deliveredDays = _days; + } + } + + Future _setDays(int value) async { + if (value < 0 || value > _maxRecentDays) return; + setState(() => _days = value); + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_recentDaysKey, value); + _deliverDays(); + } + + void _openSheet() { + if (_sheetOpen) return; + _sheetOpen = true; + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (sheetContext) => StatefulBuilder( + builder: (context, setSheetState) { + _sheetUpdater = setSheetState; + return _AssetSettingsSheet( + days: _days, + onDaysChanged: _setDays, + payload: decodeJournalPayload(widget.context), + onRetry: (id) => _emit('retry:$id'), + onDone: () => Navigator.of(sheetContext).pop(), + ); + }, + ), + ).whenComplete(() { + _sheetOpen = false; + _sheetUpdater = null; + _emit('dismissed'); + }); + } + + @override + Widget build(BuildContext context) { + final payload = decodeJournalPayload(widget.context); + if (payload['presented'] == true && !_sheetOpen) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && + decodeJournalPayload(widget.context)['presented'] == true) { + _openSheet(); + } + }); + } + if (_sheetOpen) { + // Keep the sheet's payload/day state in sync with extension rebuilds. + final updater = _sheetUpdater; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) updater?.call(() {}); + }); + } + final childIDs = widget.context.childIDs; + return childIDs.isEmpty + ? const SizedBox.shrink() + : widget.renderChild(childIDs.first); + } +} + +class _AssetSettingsSheet extends StatelessWidget { + const _AssetSettingsSheet({ + required this.days, + required this.onDaysChanged, + required this.payload, + required this.onRetry, + required this.onDone, + }); + + final int days; + final ValueChanged onDaysChanged; + final Map payload; + final ValueChanged onRetry; + final VoidCallback onDone; + + @override + Widget build(BuildContext context) { + final uploads = (payload['uploads'] as List?) ?? const []; + final theme = Theme.of(context); + return SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Attachment settings', + style: theme.textTheme.titleMedium, + ), + ), + TextButton(onPressed: onDone, child: const Text('Done')), + ], + ), + const SizedBox(height: 8), + Text('Offline attachments', style: theme.textTheme.labelLarge), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: Text('Recent journal days: $days')), + IconButton( + icon: const Icon(Icons.remove), + onPressed: days > 0 ? () => onDaysChanged(days - 1) : null, + ), + IconButton( + icon: const Icon(Icons.add), + onPressed: days < _maxRecentDays + ? () => onDaysChanged(days + 1) + : null, + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Recent journals: ${payload['recent'] ?? ''}', + style: theme.textTheme.bodySmall, + ), + Text( + 'Favorites: ${payload['favorites'] ?? ''}', + style: theme.textTheme.bodySmall, + ), + if (uploads.isNotEmpty) ...[ + const SizedBox(height: 12), + Text('Uploads', style: theme.textTheme.titleSmall), + for (final upload in uploads) + if (upload is Map) + _UploadTile(upload: upload, onRetry: onRetry), + ], + const SizedBox(height: 12), + Text( + 'Downloads attachments from today and the preceding days. ' + 'Set to 0 to disable recent-journal downloads.', + style: theme.textTheme.bodySmall, + ), + Text( + 'Favorites include their complete subtrees, regardless of ' + 'this setting.', + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ); + } +} + +class _UploadTile extends StatelessWidget { + const _UploadTile({required this.upload, required this.onRetry}); + + final Map upload; + final ValueChanged onRetry; + + @override + Widget build(BuildContext context) { + final id = '${upload['id']}'; + return journalTestId( + 'journal-upload:$id', + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (upload['busy'] == true) + const Padding( + padding: EdgeInsets.only(right: 12, top: 4), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${upload['title']}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + Text( + '${upload['message']}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + if (upload['retry'] == true) + journalTestId( + 'journal-upload-retry:$id', + TextButton( + onPressed: () => onRetry(id), + child: const Text('Retry'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/flutter/lib/journal_chrome.dart b/flutter/lib/journal_chrome.dart new file mode 100644 index 0000000..36dfca8 --- /dev/null +++ b/flutter/lib/journal_chrome.dart @@ -0,0 +1,283 @@ +import 'package:flutter/material.dart'; +import 'package:lui_flutter_backend/lui_flutter_backend.dart'; + +import 'journal_ext_utils.dart'; + +/// Width of the header controls cluster, shared between the `journal` chrome +/// (which draws the controls) and `header` chromes (which pad their text so +/// controls don't overlap). Mirrors the SwiftUI `journalControlsSize` +/// environment key from `JournalChrome.swift`. +final ValueNotifier journalChromeControlsWidth = ValueNotifier(0); + +/// Port of `JournalChrome.swift` (`journal-chrome`). +/// +/// Payload contract (matches `~encode_props` output): +/// mode: 'journal' | 'header' | 'feedback' +/// title?: string (header) +/// visible?: bool (header, default true) +/// top?: 'leading' | 'trailing' (header alignment, default trailing) +/// connecting?: bool (journal mode spinner) +/// account?: string (journal mode, button -> event {account:'press'}) +/// error?: string (journal mode, red dot, tap -> {account:'error'}) +/// +/// Children (by position, standard nodes): +/// feedback: 0 body, 1 compact footer, 2 expanded footer +/// journal: 0 content, 1 account, 2 error, 3 progress +/// header: none +/// +/// [renderChild] renders a child node id (LUIFlutterExtensionContext exposes +/// no per-child widget accessor — see `content(for:)` on the Apple backend). +Widget buildJournalChrome( + LUIFlutterExtensionContext context, + Widget Function(int nodeID) renderChild, +) { + final payload = decodeJournalPayload(context); + switch (payload['mode']) { + case 'header': + return JournalHeaderChrome(payload: payload); + case 'feedback': + return JournalFeedbackChrome(context: context, renderChild: renderChild); + case 'journal': + return JournalJournalChrome( + context: context, + payload: payload, + renderChild: renderChild, + ); + default: + throw FormatException('journal-chrome unknown mode: ${payload['mode']}'); + } +} + +class JournalHeaderChrome extends StatelessWidget { + const JournalHeaderChrome({super.key, required this.payload}); + + final Map payload; + + @override + Widget build(BuildContext context) { + if (payload['visible'] == false) { + return const SizedBox.shrink(); + } + final title = payload['title']; + final topLeading = payload['top'] == 'leading'; + return ValueListenableBuilder( + valueListenable: journalChromeControlsWidth, + builder: (context, controlsWidth, child) => Padding( + padding: EdgeInsetsDirectional.only( + start: topLeading ? 0 : controlsWidth, + end: topLeading ? controlsWidth : 0, + ), + child: child, + ), + child: Container( + color: const Color(0xff111827), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + alignment: topLeading ? Alignment.centerLeft : Alignment.centerRight, + child: title is String && title.isNotEmpty + ? Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: topLeading ? TextAlign.left : TextAlign.right, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ) + : null, + ), + ); + } +} + +class JournalJournalChrome extends StatefulWidget { + const JournalJournalChrome({ + super.key, + required this.context, + required this.payload, + required this.renderChild, + }); + + final LUIFlutterExtensionContext context; + final Map payload; + final Widget Function(int nodeID) renderChild; + + @override + State createState() => _JournalJournalChromeState(); +} + +class _JournalJournalChromeState extends State { + final GlobalKey _controlsKey = GlobalKey(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _measureControls()); + } + + void _measureControls() { + final box = _controlsKey.currentContext?.findRenderObject() as RenderBox?; + if (box != null && box.hasSize) { + journalChromeControlsWidth.value = box.size.width + 8; + } + } + + @override + Widget build(BuildContext context) { + final childIDs = widget.context.childIDs; + final content = childIDs.isEmpty + ? const SizedBox.shrink() + : widget.renderChild(childIDs.first); + return Stack( + fit: StackFit.expand, + children: [ + content, + Positioned( + top: 4, + right: 8, + child: SafeArea( + child: _ChromeControls( + key: _controlsKey, + context: widget.context, + payload: widget.payload, + renderChild: widget.renderChild, + onMeasured: _measureControls, + ), + ), + ), + ], + ); + } +} + +class _ChromeControls extends StatelessWidget { + const _ChromeControls({ + super.key, + required this.context, + required this.payload, + required this.renderChild, + required this.onMeasured, + }); + + final LUIFlutterExtensionContext context; + final Map payload; + final Widget Function(int nodeID) renderChild; + final VoidCallback onMeasured; + + @override + Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) => onMeasured()); + final childIDs = this.context.childIDs; + final connecting = payload['connecting'] == true; + final account = payload['account']; + final error = payload['error']; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (childIDs.length > 3) renderChild(childIDs[3]), + if (connecting) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else ...[ + if (error is String && error.isNotEmpty) + _ControlSlot( + onTap: () => + emitJournalEvent(this.context, 0, const {'account': 'error'}), + child: childIDs.length > 2 + ? renderChild(childIDs[2]) + : const _ErrorDot(), + ), + if (account is String && account.isNotEmpty) + _ControlSlot( + onTap: () => + emitJournalEvent(this.context, 0, const {'account': 'press'}), + child: childIDs.length > 1 + ? renderChild(childIDs[1]) + : const Icon(Icons.account_circle), + ), + ], + ], + ); + } +} + +class _ErrorDot extends StatelessWidget { + const _ErrorDot(); + + @override + Widget build(BuildContext context) => const DecoratedBox( + decoration: BoxDecoration(color: Colors.red, shape: BoxShape.circle), + child: SizedBox(width: 8, height: 8), + ); +} + +class _ControlSlot extends StatelessWidget { + const _ControlSlot({required this.child, required this.onTap}); + + final Widget child; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsetsDirectional.only(start: 8), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding(padding: const EdgeInsets.all(4), child: child), + ), + ); +} + +class JournalFeedbackChrome extends StatelessWidget { + const JournalFeedbackChrome({ + super.key, + required this.context, + required this.renderChild, + }); + + final LUIFlutterExtensionContext context; + final Widget Function(int nodeID) renderChild; + + @override + Widget build(BuildContext context) { + final childIDs = this.context.childIDs; + final body = childIDs.isEmpty + ? const SizedBox.shrink() + : renderChild(childIDs.first); + return LayoutBuilder( + builder: (context, constraints) { + // ViewThatFits equivalent: prefer the expanded footer when the + // available width is comfortable, else the compact one. + final useExpanded = constraints.maxWidth >= 560; + Widget? footer; + if (useExpanded && childIDs.length > 2) { + footer = renderChild(childIDs[2]); + } + footer ??= childIDs.length > 1 ? renderChild(childIDs[1]) : null; + return Stack( + fit: StackFit.expand, + children: [ + body, + if (footer != null) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: SafeArea( + top: false, + child: Material( + color: const Color(0xcc111827), + child: footer, + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/flutter/lib/journal_date_row.dart b/flutter/lib/journal_date_row.dart deleted file mode 100644 index a677ace..0000000 --- a/flutter/lib/journal_date_row.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:bonsai_flutter/bonsai_flutter.dart'; -import 'package:material_ui/material_ui.dart'; - -// Use the native shrinking Row for both centered and content-leading dates. -void registerJournalDateRow(NativeWidgetRegistry registry) { - registry.register( - NativeWidgetRegistration( - kindId: 1004, - minVersion: 1, - maxVersion: 1, - capabilityBits: 0, - decodeProps: (payload) { - if (payload.isNotEmpty) { - throw const FormatException('Date rows have no properties'); - } - return const Object(); - }, - factory: (context) => Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: context.children, - ), - ), - ); -} diff --git a/flutter/lib/journal_detail_outline.dart b/flutter/lib/journal_detail_outline.dart deleted file mode 100644 index bb7de3a..0000000 --- a/flutter/lib/journal_detail_outline.dart +++ /dev/null @@ -1,287 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:bonsai_flutter/bonsai_flutter.dart'; -import 'package:flutter/rendering.dart' show RenderProxyBox; -import 'package:flutter/semantics.dart' show CustomSemanticsAction; -import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:material_ui/material_ui.dart'; - -// A mechanical viewport adapter. OCaml supplies every product widget, action, -// logical identity and materialized window; native state owns only geometry. -class JournalDetailProps { - const JournalDetailProps({ - required this.keys, - required this.actions, - required this.firstIndex, - required this.revealId, - }); - final List keys; - final Map actions; - final int firstIndex; - final String revealId; - static JournalDetailProps decode(Uint8List bytes) { - final data = ByteData.sublistView(bytes); - var offset = 0; - int integer() { - final n = data.getUint32(offset, Endian.little); - offset += 4; - return n; - } - - String string() { - final n = integer(); - final s = utf8.decode(bytes.sublist(offset, offset + n)); - offset += n; - return s; - } - - final first = integer(), reveal = string(), count = integer(); - final keys = [], actions = {}; - for (var i = 0; i < count; i++) { - final key = string(), label = string(), action = string(); - keys.add(key); - if (label.isNotEmpty) actions[key] = (label, action); - } - if (offset != bytes.length) - throw const FormatException('Invalid outline viewport payload'); - return JournalDetailProps( - keys: keys, - actions: actions, - firstIndex: first, - revealId: reveal, - ); - } -} - -void registerJournalDetailOutline(NativeWidgetRegistry registry) { - registry.register( - NativeWidgetRegistration( - kindId: 1005, - minVersion: 1, - maxVersion: 1, - capabilityBits: 0, - decodeProps: JournalDetailProps.decode, - factory: (context) => JournalDetailOutline( - props: context.props, - header: context.children[0], - composer: context.children[1], - notice: context.children[2], - items: context.children.sublist(3), - onAction: (action) => - context.emit?.call(1, Uint8List.fromList(utf8.encode(action))), - ), - ), - ); -} - -class JournalDetailOutline extends StatefulWidget { - const JournalDetailOutline({ - required this.props, - required this.header, - required this.composer, - required this.notice, - required this.items, - required this.onAction, - super.key, - }); - final JournalDetailProps props; - final Widget header, composer, notice; - final List items; - final ValueChanged onAction; - @override - State createState() => _JournalDetailOutlineState(); -} - -class _JournalDetailOutlineState extends State { - final _scroll = ScrollController(); - final _viewport = GlobalKey(); - final _rendered = {}; - final _heights = {}; - bool _scheduled = false; - String? _anchor; - double _anchorY = 0; - String _revealed = ''; - String _range = ''; - bool _restoreAnchor = false; - - @override - void initState() { - super.initState(); - _scroll.addListener(_schedule); - } - - @override - void didUpdateWidget(JournalDetailOutline oldWidget) { - super.didUpdateWidget(oldWidget); - _restoreAnchor = true; - _rendered.removeWhere((key, _) => !widget.props.keys.contains(key)); - _heights.removeWhere((key, _) => !widget.props.keys.contains(key)); - _schedule(); - } - - void _schedule() { - if (_scheduled) return; - _scheduled = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - _scheduled = false; - if (!mounted || !_scroll.hasClients) return; - final viewport = - _viewport.currentContext?.findRenderObject() as RenderBox?; - if (viewport == null || !viewport.hasSize) return; - final reveal = widget.props.revealId; - if (reveal.isNotEmpty && reveal != _revealed) { - final target = _rendered[reveal]?.currentContext; - if (target != null) { - _revealed = reveal; - Scrollable.ensureVisible(target, alignment: 0.7); - } else { - _scroll.jumpTo(_scroll.position.maxScrollExtent); - } - } else if (_restoreAnchor && _anchor != null) { - final box = - _rendered[_anchor]?.currentContext?.findRenderObject() - as RenderBox?; - if (box != null && box.hasSize) { - final delta = - box.localToGlobal(Offset.zero, ancestor: viewport).dy - _anchorY; - if (delta.abs() > 0.5) - _scroll.jumpTo( - (_scroll.offset + delta).clamp( - 0, - _scroll.position.maxScrollExtent, - ), - ); - } - } - _restoreAnchor = false; - var first = widget.props.keys.length, last = 0; - String? anchor; - var anchorY = 0.0; - for (final entry in _rendered.entries) { - final box = - entry.value.currentContext?.findRenderObject() as RenderBox?; - if (box == null || !box.hasSize) continue; - final y = box.localToGlobal(Offset.zero, ancestor: viewport).dy; - if (y + box.size.height > 0 && y < viewport.size.height) { - final index = widget.props.keys.indexOf(entry.key); - if (index < 0) continue; - if (index < first) { - first = index; - anchor = entry.key; - anchorY = y; - } - if (index + 1 > last) last = index + 1; - } - } - _anchor = anchor; - _anchorY = anchorY; - final range = '$first:$last'; - if (first < last && range != _range) { - _range = range; - widget.onAction('detail-visible:$range'); - } - }); - } - - @override - Widget build(BuildContext context) { - _schedule(); - return Scaffold( - appBar: AppBar( - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Directionality( - textDirection: TextDirection.ltr, - child: Semantics( - container: true, - explicitChildNodes: true, - child: widget.header, - ), - ), - ), - floatingActionButton: widget.composer, - body: Column( - children: [ - widget.notice, - Expanded( - child: SlidableAutoCloseBehavior( - child: ListView.builder( - key: _viewport, - controller: _scroll, - padding: EdgeInsets.only( - bottom: 96 + MediaQuery.paddingOf(context).bottom, - ), - itemCount: widget.props.keys.length, - findChildIndexCallback: (key) { - final index = key is ValueKey - ? widget.props.keys.indexOf(key.value) - : -1; - return index < 0 ? null : index; - }, - itemBuilder: (context, index) { - final key = widget.props.keys[index]; - final local = index - widget.props.firstIndex; - final supplied = local >= 0 && local < widget.items.length; - Widget child = supplied - ? widget.items[local] - : SizedBox(height: _heights[key] ?? 72); - final action = widget.props.actions[key]; - if (action != null) - child = Semantics( - customSemanticsActions: { - CustomSemanticsAction(label: action.$1): () => - widget.onAction(action.$2), - }, - child: child, - ); - return KeyedSubtree( - key: ValueKey(key), - child: _Measure( - key: _rendered.putIfAbsent(key, GlobalKey.new), - onSize: (height) { - if (supplied) _heights[key] = height; - _schedule(); - }, - child: child, - ), - ); - }, - ), - ), - ), - ], - ), - ); - } - - @override - void dispose() { - _scroll.dispose(); - super.dispose(); - } -} - -class _Measure extends SingleChildRenderObjectWidget { - const _Measure({required this.onSize, required super.child, super.key}); - final ValueChanged onSize; - @override - RenderObject createRenderObject(BuildContext context) => _MeasuredBox(onSize); - @override - void updateRenderObject( - BuildContext context, - covariant _MeasuredBox renderObject, - ) { - renderObject.onSize = onSize; - } -} - -class _MeasuredBox extends RenderProxyBox { - _MeasuredBox(this.onSize); - ValueChanged onSize; - @override - void performLayout() { - super.performLayout(); - onSize(size.height); - } -} diff --git a/flutter/lib/journal_ext_utils.dart b/flutter/lib/journal_ext_utils.dart new file mode 100644 index 0000000..9d6aced --- /dev/null +++ b/flutter/lib/journal_ext_utils.dart @@ -0,0 +1,95 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:lui_flutter_backend/lui_flutter_backend.dart'; + +/// Shared helpers for the journal LUI extension renderers. Every journal +/// extension carries one required string property `payload` (JSON, the same +/// shape the old `~encode_props` produced) and emits `event` events with +/// `id:int` + `payload:string` fields (the old `BonsaiNativeEvent` contract). + +Map decodeJournalPayload(LUIFlutterExtensionContext context) { + final raw = context.property('payload'); + if (raw is! String) { + throw const FormatException('journal extension payload is not a string'); + } + final value = jsonDecode(raw); + if (value is! Map) { + throw const FormatException('journal extension payload must be an object'); + } + return value; +} + +/// Emits `event` with the legacy `BonsaiNativeEvent(id, payload)` shape. +/// `payload` may be a pre-encoded string or a JSON-serializable value. +void emitJournalEvent( + LUIFlutterExtensionContext context, + int id, + Object payload, +) { + context.emit( + name: 'event', + values: { + 'id': id, + 'payload': payload is String ? payload : jsonEncode(payload), + }, + ); +} + +final Random _uuidRandom = Random.secure(); + +String newJournalUuid() { + final bytes = List.generate(16, (_) => _uuidRandom.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}'; +} + +/// Closest Material glyph for the SF Symbol names the journal payloads use. +/// Approximation: Flutter has no SF Symbols, so names map onto Material icons. +IconData journalSymbolIcon(String? name) => switch (name) { + 'checkmark.circle' => Icons.check_circle_outline, + 'trash' => Icons.delete_outline, + 'paperclip' => Icons.attach_file, + 'doc' => Icons.insert_drive_file_outlined, + 'photo' => Icons.image_outlined, + 'arrow.up.right.square' => Icons.open_in_new, + 'ellipsis.circle' => Icons.more_horiz, + 'book' => Icons.menu_book_outlined, + 'magnifyingglass' => Icons.search, + 'plus' => Icons.add, + 'xmark' => Icons.close, + 'chevron.right' => Icons.chevron_right, + 'chevron.down' => Icons.expand_more, + 'gearshape' => Icons.settings_outlined, + 'person.crop.circle' => Icons.account_circle_outlined, + 'bell' => Icons.notifications_none, + 'star' => Icons.star_outline, + 'star.fill' => Icons.star, + _ => Icons.circle_outlined, +}; + +/// `#rrggbb` / `#aarrggbb` / `rgb(r,g,b)` -> Color. Null-safe no-op on miss. +Color? journalColor(Object? value) { + if (value is! String || value.isEmpty) return null; + final hex = value.startsWith('#') ? value.substring(1) : value; + if (hex.length == 6) { + final v = int.tryParse(hex, radix: 16); + return v == null ? null : Color(0xff000000 | v); + } + if (hex.length == 8) { + final v = int.tryParse(hex, radix: 16); + return v == null ? null : Color(v); + } + return null; +} + +/// Accessibility hook used in place of `.accessibilityIdentifier`: keeps the +/// legacy identifier text on the semantics label so QA tooling can find it. +Widget journalTestId(String? testId, Widget child) { + if (testId == null || testId.isEmpty) return child; + return Semantics(container: true, label: testId, child: child); +} diff --git a/flutter/lib/journal_extension_registry.dart b/flutter/lib/journal_extension_registry.dart new file mode 100644 index 0000000..55815c9 --- /dev/null +++ b/flutter/lib/journal_extension_registry.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:lui_flutter_backend/lui_flutter_backend.dart'; + +import 'journal_asset_import.dart'; +import 'journal_asset_settings.dart'; +import 'journal_chrome.dart'; +import 'journal_list.dart'; +import 'journal_media.dart'; + +const _payloadProperty = LUIExtensionProperty( + name: 'payload', + kind: LUIExtensionValueKind.string, + isRequired: true, +); + +const _journalEvent = LUIExtensionEventSchema( + name: 'event', + fields: [ + LUIExtensionEventField( + name: 'id', + kind: LUIExtensionValueKind.integer, + isRequired: true, + ), + LUIExtensionEventField( + name: 'payload', + kind: LUIExtensionValueKind.string, + isRequired: true, + ), + ], +); + +/// Journal extension registry for the LUI Flutter backend. Fingerprints are +/// the `Lui_extension.fingerprint` digests of the OCaml registry in +/// `app/journal_lui_native.ml` — keep them in lockstep; a mismatch rejects the +/// extension batch. +/// [renderChild] renders an extension child node id — pass a closure over the +/// backend (`(id) => backend.widget(node: id)`), which can only be created +/// after this registry since the backend freezes it. +LUIFlutterExtensionRegistry journalExtensionRegistry( + Widget Function(int nodeID) renderChild, +) { + final registry = LUIFlutterExtensionRegistry(); + registry.register( + LUIFlutterExtension( + identifier: 'journal-chrome', + fingerprint: + 'lui-extension-v1|14:journal-chrome|profiles:ios/swiftui,macos/swiftui' + '|standard-children:1|children:|properties:' + '7:payload:string:required:none|events:', + acceptsStandardChildren: true, + properties: const [_payloadProperty], + builder: (context) => buildJournalChrome(context, renderChild), + ), + ); + registry.register( + LUIFlutterExtension( + identifier: 'journal-asset-import', + fingerprint: + 'lui-extension-v1|20:journal-asset-import|profiles:' + 'android/flutter,ios/flutter,ios/swiftui,macos/flutter,macos/swiftui' + '|standard-children:0|children:|properties:' + '7:payload:string:required:none|events:' + '5:event[2:id:int:required,7:payload:string:required]', + properties: const [_payloadProperty], + events: const [_journalEvent], + builder: buildJournalAssetImport, + ), + ); + registry.register( + LUIFlutterExtension( + identifier: 'journal-media', + fingerprint: + 'lui-extension-v1|13:journal-media|profiles:' + 'android/flutter,ios/flutter,ios/swiftui,macos/flutter,macos/swiftui' + '|standard-children:0|children:|properties:' + '7:payload:string:required:none|events:' + '5:event[2:id:int:required,7:payload:string:required]', + properties: const [_payloadProperty], + events: const [_journalEvent], + builder: (context) => buildJournalMedia(context, renderChild), + ), + ); + registry.register( + LUIFlutterExtension( + identifier: 'journal-asset-settings', + fingerprint: + 'lui-extension-v1|22:journal-asset-settings|profiles:' + 'android/flutter,ios/flutter,ios/swiftui,macos/flutter,macos/swiftui' + '|standard-children:1|children:|properties:' + '7:payload:string:required:none|events:' + '5:event[2:id:int:required,7:payload:string:required]', + acceptsStandardChildren: true, + properties: const [_payloadProperty], + events: const [_journalEvent], + builder: (context) => buildJournalAssetSettings(context, renderChild), + ), + ); + registry.register( + LUIFlutterExtension( + identifier: 'journal-list', + fingerprint: + 'lui-extension-v1|12:journal-list|profiles:' + 'android/flutter,ios/flutter,ios/swiftui,macos/flutter,macos/swiftui' + '|standard-children:0|children:|properties:' + '7:payload:string:required:none|events:' + '5:event[2:id:int:required,7:payload:string:required]', + properties: const [_payloadProperty], + events: const [_journalEvent], + builder: (context) => buildJournalList(context, renderChild), + ), + ); + return registry; +} diff --git a/flutter/lib/journal_list.dart b/flutter/lib/journal_list.dart new file mode 100644 index 0000000..9712289 --- /dev/null +++ b/flutter/lib/journal_list.dart @@ -0,0 +1,426 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:lui_flutter_backend/lui_flutter_backend.dart'; + +import 'journal_ext_utils.dart'; + +/// Port of the grouped native list (`V.Native_list` / `journal-list`). +/// +/// Payload (produced by `Journal_view.Native_list.build`): +/// style: 'plain' | 'inset' | 'inset_grouped' +/// sections: [{ +/// key, separator: 'automatic'|'hidden'|'visible', +/// header_index: int|null, footer_index: int|null, // into childIDs +/// rows: [{ +/// type: 'row' | 'disclosure', +/// key, content_index: int, separator, test_id?, +/// swipe?: {actions: [{key, enabled, role, symbol, side, title, background}]}, +/// context_menu?: {actions: [{key, enabled, role, symbol, title}]}, +/// expanded?: bool, // disclosure only +/// children?: [row...] // disclosure only +/// }] +/// }], +/// scroll_request: null | { +/// token: string, target: {section, row_path: [string]}, +/// anchor: 'top'|'center'|'bottom'|null, animated: bool|null +/// }, +/// track_visible_range: bool, +/// track_scroll_completion: bool +/// +/// Emitted events (id 1, payload JSON): +/// {type:'visible_range', first:int, last:int} — flat row ordinals +/// {type:'scroll_completed', token:string, outcome:string} +/// {type:'expanded', key:string, expanded:bool} +/// {type:'row_event', payload:string} — swipe / context-menu +/// presses. Inner payload: {"kind":"swipe"|"context_menu","key":actionKey, +/// "row":rowKey}. NOTE: OCaml-side routing for row_action presses is still +/// being wired (journal_view.ml `on_event` / `on_row_event`); the channel +/// and shape are the proposed contract. +Widget buildJournalList( + LUIFlutterExtensionContext context, + Widget Function(int nodeID) renderChild, +) => _JournalListView(context: context, renderChild: renderChild); + +class _RowEntry { + _RowEntry({required this.section, required this.map, required this.depth}); + + final Map section; + final Map map; + final int depth; +} + +class _JournalListView extends StatefulWidget { + const _JournalListView({required this.context, required this.renderChild}); + + final LUIFlutterExtensionContext context; + final Widget Function(int nodeID) renderChild; + + @override + State<_JournalListView> createState() => _JournalListViewState(); +} + +class _JournalListViewState extends State<_JournalListView> { + final ScrollController _scroll = ScrollController(); + final GlobalKey _viewportKey = GlobalKey(); + final Map _rowKeys = {}; + bool _scheduled = false; + String _range = ''; + String _completedScrollToken = ''; + + Map get _payload => decodeJournalPayload(widget.context); + + List get _childIDs => widget.context.childIDs; + + void _emit(String type, Map fields) { + emitJournalEvent(widget.context, 1, {'type': type, ...fields}); + } + + void _emitRowEvent(Map inner) { + emitJournalEvent(widget.context, 1, { + 'type': 'row_event', + 'payload': jsonEncode(inner), + }); + } + + Widget _childAt(Object? index) { + if (index is! int) return const SizedBox.shrink(); + final ids = _childIDs; + if (index < 0 || index >= ids.length) return const SizedBox.shrink(); + return widget.renderChild(ids[index] as int); + } + + /// Flattened visible entries: expanded disclosure rows contribute their + /// children inline, matching the grouped-list layout. + List _entries(Map payload) { + final entries = []; // header/footer maps or _RowEntry + final sections = (payload['sections'] as List?) ?? const []; + for (final section in sections) { + if (section is! Map) continue; + if (section['header_index'] is int) { + entries.add({'__kind__': 'header', 'index': section['header_index']}); + } + void addRows(List rows, int depth, Map section) { + for (final row in rows) { + if (row is! Map) continue; + entries.add(_RowEntry(section: section, map: row, depth: depth)); + if (row['type'] == 'disclosure' && row['expanded'] == true) { + final children = (row['children'] as List?) ?? const []; + addRows(children, depth + 1, section); + } + } + } + + addRows((section['rows'] as List?) ?? const [], 0, section); + if (section['footer_index'] is int) { + entries.add({'__kind__': 'footer', 'index': section['footer_index']}); + } + } + return entries; + } + + String _entryKey(Object? entry) => switch (entry) { + _RowEntry e => e.map['key'] as String? ?? '', + Map m => '${m['__kind__']}:${m['index']}', + _ => '', + }; + + @override + void initState() { + super.initState(); + _scroll.addListener(_schedule); + } + + @override + void didUpdateWidget(_JournalListView oldWidget) { + super.didUpdateWidget(oldWidget); + _handleScrollRequest(); + _schedule(); + } + + void _schedule() { + if (_scheduled) return; + _scheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scheduled = false; + if (!mounted) return; + _emitVisibleRange(); + }); + } + + void _emitVisibleRange() { + if (_payload['track_visible_range'] != true || !_scroll.hasClients) { + return; + } + final viewport = + _viewportKey.currentContext?.findRenderObject() as RenderBox?; + if (viewport == null || !viewport.hasSize) return; + // Row ordinals only — headers/footers do not count. + final entries = _entries(_payload); + var rowOrdinal = -1; + var first = -1; + var last = -1; + for (final entry in entries) { + if (entry is! _RowEntry) continue; + rowOrdinal += 1; + final box = + _rowKeys[entry.map['key']]?.currentContext?.findRenderObject() + as RenderBox?; + if (box == null || !box.hasSize) continue; + final y = box.localToGlobal(Offset.zero, ancestor: viewport).dy; + if (y + box.size.height > 0 && y < viewport.size.height) { + if (first < 0) first = rowOrdinal; + last = rowOrdinal + 1; + } + } + if (first < 0) return; + final range = '$first:$last'; + if (range != _range) { + _range = range; + _emit('visible_range', {'first': first, 'last': last}); + } + } + + void _handleScrollRequest() { + final request = _payload['scroll_request']; + if (request is! Map) return; + final token = request['token']; + if (token == null || '$token' == _completedScrollToken) return; + _completedScrollToken = '$token'; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final target = request['target']; + final rowPath = target is Map ? (target['row_path'] as List?) : null; + String outcome; + if (rowPath == null || rowPath.isEmpty) { + outcome = 'missing_target'; + } else { + final key = rowPath.last; + final rowContext = _rowKeys['$key']?.currentContext; + if (rowContext == null) { + outcome = 'missing_target'; + } else { + final alignment = switch (request['anchor']) { + 'top' => 0.0, + 'center' => 0.5, + 'bottom' => 1.0, + _ => 0.7, + }; + Scrollable.ensureVisible( + rowContext, + alignment: alignment, + duration: request['animated'] == true + ? const Duration(milliseconds: 250) + : Duration.zero, + ); + outcome = 'succeeded'; + } + } + if (_payload['track_scroll_completion'] == true) { + _emit('scroll_completed', {'token': '$token', 'outcome': outcome}); + } + }); + } + + Widget _buildSectionChrome(Object? entry, Map section) { + final separator = section['separator']; + final child = _buildEntry(entry); + if (separator == 'hidden') return child; + return Column( + mainAxisSize: MainAxisSize.min, + children: [child, const Divider(height: 1, indent: 16)], + ); + } + + Widget _buildEntry(Object? entry) { + if (entry is Map) { + return _childAt(entry['index']); + } + if (entry is! _RowEntry) return const SizedBox.shrink(); + final row = entry.map; + final key = row['key'] as String? ?? ''; + final content = Padding( + padding: EdgeInsetsDirectional.only(start: 16.0 + entry.depth * 16.0), + child: _childAt(row['content_index']), + ); + Widget child = row['type'] == 'disclosure' + ? Row( + children: [ + Icon( + row['expanded'] == true + ? Icons.expand_more + : Icons.chevron_right, + size: 18, + ), + Expanded(child: content), + ], + ) + : content; + if (row['type'] == 'disclosure') { + child = GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _emit('expanded', { + 'key': key, + 'expanded': row['expanded'] != true, + }), + child: child, + ); + } + final swipe = row['swipe']; + if (swipe is Map) { + child = _buildSwipe(key, swipe, child); + } + final menu = row['context_menu']; + if (menu is Map) { + child = _buildContextMenu(key, menu, child); + } + if (row['separator'] == 'visible') { + child = Column( + mainAxisSize: MainAxisSize.min, + children: [child, const Divider(height: 1, indent: 16)], + ); + } + child = KeyedSubtree( + key: _rowKeys.putIfAbsent(key, () => GlobalKey()), + child: child, + ); + return journalTestId(row['test_id'] as String?, child); + } + + Widget _buildSwipe(String rowKey, Map swipe, Widget child) { + final actions = (swipe['actions'] as List?) ?? const []; + List pane(String side) => [ + for (final action in actions) + if (action is Map && action['side'] == side) + SlidableAction( + onPressed: action['enabled'] == false + ? null + : (_) => _emitRowEvent({ + 'kind': 'swipe', + 'key': action['key'], + 'row': rowKey, + }), + backgroundColor: + journalColor(action['background']) ?? + Theme.of(context).colorScheme.surfaceContainerHighest, + foregroundColor: Colors.white, + icon: journalSymbolIcon(action['symbol'] as String?), + label: '${action['title'] ?? ''}', + ), + ]; + final start = pane('start'); + final end = pane('end'); + return Slidable( + groupTag: 'journal-list', + startActionPane: start.isEmpty + ? null + : ActionPane(motion: const ScrollMotion(), children: start), + endActionPane: end.isEmpty + ? null + : ActionPane(motion: const ScrollMotion(), children: end), + child: child, + ); + } + + Widget _buildContextMenu(String rowKey, Map menu, Widget child) { + final actions = (menu['actions'] as List?) ?? const []; + if (actions.isEmpty) return child; + return GestureDetector( + onSecondaryTapUp: (details) => _showMenu(details, actions, rowKey), + onLongPressStart: (details) => _showMenu(details, actions, rowKey), + child: child, + ); + } + + void _showMenu(dynamic details, List actions, String rowKey) { + final position = RelativeRect.fromLTRB( + details.globalPosition.dx, + details.globalPosition.dy, + details.globalPosition.dx, + details.globalPosition.dy, + ); + showMenu( + context: context, + position: position, + items: [ + for (final action in actions) + if (action is Map) + PopupMenuItem( + value: '${action['key']}', + enabled: action['enabled'] != false, + child: Row( + children: [ + if (action['symbol'] is String) ...[ + Icon( + journalSymbolIcon(action['symbol'] as String?), + size: 18, + color: action['role'] == 'destructive' + ? Theme.of(context).colorScheme.error + : null, + ), + const SizedBox(width: 8), + ], + Text( + '${action['title'] ?? ''}', + style: TextStyle( + color: action['role'] == 'destructive' + ? Theme.of(context).colorScheme.error + : null, + ), + ), + ], + ), + ), + ], + ).then((selected) { + if (selected != null) { + _emitRowEvent({'kind': 'context_menu', 'key': selected, 'row': rowKey}); + } + }); + } + + @override + Widget build(BuildContext context) { + final payload = _payload; + final entries = _entries(payload); + _handleScrollRequest(); + WidgetsBinding.instance.addPostFrameCallback((_) => _emitVisibleRange()); + final grouped = payload['style'] == 'inset_grouped'; + return SlidableAutoCloseBehavior( + child: ListView.builder( + key: _viewportKey, + controller: _scroll, + itemCount: entries.length, + findChildIndexCallback: (key) { + if (key is! ValueKey) return -1; + return entries.indexWhere((e) => 'k:${_entryKey(e)}' == key.value); + }, + itemBuilder: (context, index) { + final entry = entries[index]; + final built = entry is _RowEntry + ? _buildSectionChrome(entry, entry.section) + : _buildEntry(entry); + return KeyedSubtree( + key: ValueKey('k:${_entryKey(entry)}'), + child: grouped + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 1, + ), + child: built, + ) + : built, + ); + }, + ), + ); + } + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } +} diff --git a/flutter/lib/journal_media.dart b/flutter/lib/journal_media.dart new file mode 100644 index 0000000..fcea3b6 --- /dev/null +++ b/flutter/lib/journal_media.dart @@ -0,0 +1,311 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:lui_flutter_backend/lui_flutter_backend.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'journal_ext_utils.dart'; + +/// Port of `JournalMedia.swift` (`journal-media`). +/// +/// Payload: +/// root: string — graph root, echoed on every event +/// items: [{id, kind, value, type, width, height}] (<= 32) +/// more: bool — show "Next attachments" +/// editable: bool — show the actions menu +/// picker?: {items, more, busy} +/// error?: string +/// Children: exactly one (the row content the attachment UI anchors to). +/// Events: id 1, payload `{action, root, asset, visible}` with actions +/// retry/replace/reuse/reuse-select/reuse-next/reuse-cancel/next. +Widget buildJournalMedia( + LUIFlutterExtensionContext context, + Widget Function(int nodeID) renderChild, +) => _JournalMediaGroup(context: context, renderChild: renderChild); + +const _imageTypes = { + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'heic', + 'heif', + 'tif', + 'tiff', + 'bmp', + 'avif', +}; + +class _JournalMediaGroup extends StatelessWidget { + const _JournalMediaGroup({required this.context, required this.renderChild}); + + final LUIFlutterExtensionContext context; + final Widget Function(int nodeID) renderChild; + + void _emit(String action, {String asset = '', bool visible = true}) { + emitJournalEvent(context, 1, { + 'action': action, + 'root': _payload['root'], + 'asset': asset, + 'visible': visible, + }); + } + + Map get _payload => decodeJournalPayload(context); + + @override + Widget build(BuildContext context) { + final payload = _payload; + final childIDs = this.context.childIDs; + final items = (payload['items'] as List?) ?? const []; + final picker = payload['picker']; + final pickerMap = picker is Map ? picker : const {}; + final pickerItems = (pickerMap['items'] as List?) ?? const []; + final pickerBusy = pickerMap['busy'] == true; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: childIDs.isEmpty + ? const SizedBox.shrink() + : renderChild(childIDs.first), + ), + const SizedBox(width: 8), + if (payload['editable'] == true) + journalTestId( + 'journal-media-actions', + PopupMenuButton( + tooltip: 'Attachment actions', + icon: const Icon(Icons.more_horiz, size: 20), + itemBuilder: (context) => const [ + PopupMenuItem( + value: 'replace', + child: Text('Replace file…'), + ), + PopupMenuItem( + value: 'reuse', + child: Text('Reuse existing…'), + ), + ], + onSelected: (action) => _emit(action), + ), + ), + ], + ), + for (final item in items) + if (item is Map) _MediaItem(item: item, emit: _emit), + if (picker != null) ...[ + if (pickerBusy && pickerItems.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(strokeWidth: 2), + ), + SizedBox(width: 8), + Text('Loading attachments', style: TextStyle(fontSize: 12)), + ], + ), + ), + for (final item in pickerItems) + if (item is Map) + journalTestId( + 'journal-media-candidate:${item['id']}', + TextButton.icon( + onPressed: pickerBusy + ? null + : () => _emit('reuse-select', asset: '${item['id']}'), + icon: const Icon(Icons.insert_drive_file_outlined, size: 16), + label: Text( + (item['type'] as String?)?.isEmpty ?? true + ? 'file' + : item['type'] as String, + style: const TextStyle(fontSize: 13), + ), + ), + ), + if (pickerMap['more'] == true) + TextButton( + onPressed: pickerBusy ? null : () => _emit('reuse-next'), + child: const Text('More attachments'), + ), + TextButton( + onPressed: () => _emit('reuse-cancel'), + child: const Text('Cancel', style: TextStyle(fontSize: 12)), + ), + ], + if (payload['error'] is String) ...[ + Text( + payload['error'] as String, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.error, + ), + ), + TextButton( + onPressed: () => _emit('retry'), + child: const Text('Retry attachments'), + ), + ], + if (payload['more'] == true) + TextButton( + onPressed: () => _emit('next'), + child: const Text('Next attachments'), + ), + ], + ); + } +} + +class _MediaItem extends StatefulWidget { + const _MediaItem({required this.item, required this.emit}); + + final Map item; + final void Function(String action, {String asset, bool visible}) emit; + + @override + State<_MediaItem> createState() => _MediaItemState(); +} + +class _MediaItemState extends State<_MediaItem> { + bool _decodeFailed = false; + + String get _id => '${widget.item['id']}'; + String get _kind => '${widget.item['kind']}'; + String get _value => '${widget.item['value']}'; + String get _type => ('${widget.item['type']}').toLowerCase(); + bool get _isImage => _imageTypes.contains(_type); + + Future _openFile() async { + final uri = Uri.file(_value); + await launchUrl(uri); + } + + Future _openExternal() async { + final uri = Uri.tryParse(_value); + if (uri != null) await launchUrl(uri); + } + + void _previewImage() { + showDialog( + context: context, + builder: (context) => Dialog( + insetPadding: const EdgeInsets.all(16), + child: InteractiveViewer( + child: Image.file( + File(_value), + errorBuilder: (context, error, stack) => const Padding( + padding: EdgeInsets.all(24), + child: Text('Unable to preview image'), + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final width = (widget.item['width'] as num?)?.toInt() ?? 1; + final height = (widget.item['height'] as num?)?.toInt() ?? 1; + final body = _buildContent(); + return journalTestId( + 'journal-media:$_id', + _isImage + ? ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: AspectRatio( + aspectRatio: width <= 0 || height <= 0 ? 1 : width / height, + child: body, + ), + ) + : ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: body, + ), + ); + } + + Widget _buildContent() { + if (_kind == 'file' && _isImage) { + if (_decodeFailed) { + return TextButton( + onPressed: _previewImage, + child: const Text('Open image'), + ); + } + return GestureDetector( + onTap: _previewImage, + child: Image.file( + File(_value), + fit: BoxFit.contain, + cacheWidth: 1024, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) => + frame == null + ? const Center(child: Text('Opening image')) + : child, + errorBuilder: (context, error, stack) { + _decodeFailed = true; + return const Center(child: Text('Open image')); + }, + ), + ); + } + if (_kind == 'file') { + return TextButton.icon( + onPressed: _openFile, + icon: const Icon(Icons.insert_drive_file_outlined, size: 18), + label: const Text('Open attachment'), + ); + } + if (_kind == 'external') { + final uri = Uri.tryParse(_value); + if (uri != null && ['https', 'http'].contains(uri.scheme.toLowerCase())) { + return TextButton.icon( + onPressed: _openExternal, + icon: const Icon(Icons.open_in_new, size: 18), + label: const Text('Open external attachment'), + ); + } + } + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.image_outlined, size: 16), + const SizedBox(width: 6), + Flexible( + child: Text( + _value, + style: const TextStyle(fontSize: 12), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + TextButton( + onPressed: () => widget.emit('retry', asset: _id), + child: const Text('Retry', style: TextStyle(fontSize: 12)), + ), + ], + ), + ); + } +} diff --git a/flutter/lib/journal_root_navigation.dart b/flutter/lib/journal_root_navigation.dart deleted file mode 100644 index e48827f..0000000 --- a/flutter/lib/journal_root_navigation.dart +++ /dev/null @@ -1,390 +0,0 @@ -import 'dart:typed_data'; - -import 'package:bonsai_flutter/bonsai_flutter.dart'; -import 'package:material_ui/material_ui.dart'; - -// Track intent at the position that actually moves. Layout corrections and -// programmatic moves never inherit a previous drag's intent. -class _RootScrollPosition extends ScrollPositionWithSingleContext { - _RootScrollPosition({ - required super.physics, - required super.context, - super.oldPosition, - required this.onSample, - }) : super(keepScrollOffset: false); - final void Function(double, double) onSample; - bool _userMotion = false; - bool _pointerMotion = false; - double? _pendingOffset; - - void restoreBeforePaint(double offset) { - goIdle(); - _pendingOffset = offset; - // Invalidate the viewport even when the destination has equal dimensions. - notifyListeners(); - } - - @override - bool applyContentDimensions(double minScrollExtent, double maxScrollExtent) { - final pending = _pendingOffset; - if (pending != null) { - _pendingOffset = null; - final target = pending.clamp(minScrollExtent, maxScrollExtent); - if (target != pixels) { - correctPixels(target); - return false; - } - } - return super.applyContentDimensions(minScrollExtent, maxScrollExtent); - } - - @override - void applyUserOffset(double delta) { - _userMotion = true; - super.applyUserOffset(delta); - } - - @override - void pointerScroll(double delta) { - _pointerMotion = true; - try { - super.pointerScroll(delta); - } finally { - _pointerMotion = false; - } - } - - @override - double setPixels(double newPixels) { - final before = pixels; - final overscroll = super.setPixels(newPixels); - if (_userMotion && (pixels != before || pixels <= 0)) { - onSample(pixels, pixels - before); - } - return overscroll; - } - - @override - void forcePixels(double value) { - final before = pixels; - super.forcePixels(value); - if (_pointerMotion && pixels != before) onSample(pixels, pixels - before); - } - - @override - void goIdle() { - _userMotion = false; - super.goIdle(); - } - - @override - void jumpTo(double value) { - _userMotion = false; - super.jumpTo(value); - } - - @override - Future animateTo( - double to, { - required Duration duration, - required Curve curve, - }) { - _userMotion = false; - return super.animateTo(to, duration: duration, curve: curve); - } -} - -class _RootScrollController extends ScrollController { - _RootScrollController(this.onSample) : super(keepScrollOffset: false); - final void Function(double, double) onSample; - - @override - ScrollPosition createScrollPosition( - ScrollPhysics physics, - ScrollContext context, - ScrollPosition? oldPosition, - ) => _RootScrollPosition( - physics: physics, - context: context, - oldPosition: oldPosition, - onSample: onSample, - ); -} - -class _RootNavigationConfiguration extends InheritedWidget { - const _RootNavigationConfiguration({ - required this.visible, - required this.duration, - required super.child, - }); - final bool visible; - final Duration duration; - @override - bool updateShouldNotify(_RootNavigationConfiguration oldWidget) => - visible != oldWidget.visible || duration != oldWidget.duration; -} - -/// One graph lifetime owns a retained position and independent saved offsets. -class JournalRootScroll extends StatefulWidget { - const JournalRootScroll({ - required this.navigationVisible, - required this.duration, - required this.active, - required this.onScroll, - required this.onNonScrollable, - required this.destination, - required this.favoritesRevision, - required this.favoritesAnchorOffset, - required this.child, - super.key, - }); - final bool navigationVisible, active; - final Duration duration; - final void Function(int, double, double) onScroll; - final ValueChanged onNonScrollable; - final int destination, favoritesRevision; - final double favoritesAnchorOffset; - final Widget child; - @override - State createState() => _JournalRootScrollState(); -} - -class _JournalRootScrollState extends State { - final _offsets = [0.0, 0.0]; - late final _controller = _RootScrollController((pixels, delta) { - if (mounted && widget.active) { - widget.onScroll(widget.destination, pixels, delta); - } - }); - - _RootScrollPosition? get _position => _controller.hasClients - ? _controller.position as _RootScrollPosition - : null; - - @override - void didUpdateWidget(JournalRootScroll oldWidget) { - super.didUpdateWidget(oldWidget); - final position = _position; - final changedDestination = oldWidget.destination != widget.destination; - // Read the outgoing position before assigning any incoming correction. - if (position != null && position._pendingOffset == null) { - _offsets[oldWidget.destination] = position.pixels; - } - if (oldWidget.active != widget.active || changedDestination) { - position?.goIdle(); - } - final changedFavorites = - oldWidget.favoritesRevision != widget.favoritesRevision; - if (changedFavorites) { - _offsets[1] = - (_offsets[1] + - widget.favoritesAnchorOffset - - oldWidget.favoritesAnchorOffset) - .clamp(0.0, double.infinity); - } - if (changedDestination || (changedFavorites && widget.destination == 1)) { - position?.restoreBeforePaint(_offsets[widget.destination]); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => _RootNavigationConfiguration( - visible: widget.navigationVisible, - duration: widget.duration, - child: NotificationListener( - onNotification: (notification) { - if (widget.active && - notification.depth == 0 && - notification.metrics.axis == Axis.vertical && - notification.metrics.maxScrollExtent <= - notification.metrics.minScrollExtent) { - widget.onNonScrollable(widget.destination); - } - return false; - }, - child: PrimaryScrollController( - controller: _controller, - child: widget.child, - ), - ), - ); -} - -class JournalNavigationBar extends StatelessWidget { - const JournalNavigationBar({ - required this.selectedIndex, - required this.destinations, - required this.onDestinationSelected, - super.key, - }); - final int selectedIndex; - final List destinations; - final ValueChanged? onDestinationSelected; - - @override - Widget build(BuildContext context) { - final configuration = context - .dependOnInheritedWidgetOfExactType<_RootNavigationConfiguration>()!; - final media = MediaQuery.of(context); - final view = View.of(context); - final bottomInset = view.viewPadding.bottom / view.devicePixelRatio; - final visible = configuration.visible; - final backgroundColor = - NavigationBarTheme.of(context).backgroundColor ?? - Theme.of(context).colorScheme.surfaceContainer; - final bar = MediaQuery( - data: media.copyWith(padding: media.padding.copyWith(bottom: 0)), - child: NavigationBar( - backgroundColor: backgroundColor, - height: 44, - selectedIndex: selectedIndex, - labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, - destinations: destinations, - onDestinationSelected: onDestinationSelected, - ), - ); - final control = ExcludeSemantics( - excluding: !visible, - child: ExcludeFocus( - excluding: !visible, - child: IgnorePointer(ignoring: !visible, child: bar), - ), - ); - Widget size(double value, Widget child) => ColoredBox( - color: value > 0 ? backgroundColor : Colors.transparent, - child: Padding( - padding: EdgeInsets.only(bottom: bottomInset), - child: SizeTransition( - sizeFactor: AlwaysStoppedAnimation(value), - alignment: Alignment.bottomCenter, - child: child, - ), - ), - ); - return media.disableAnimations || configuration.duration == Duration.zero - ? size(visible ? 1 : 0, control) - : TweenAnimationBuilder( - tween: Tween(begin: visible ? 1 : 0, end: visible ? 1 : 0), - duration: configuration.duration, - curve: Curves.easeInOut, - builder: (context, value, child) => size(value, child!), - child: control, - ); - } -} - -// The renderer supplies font glyphs as Text. Give them native icon color and -// fixed geometry independently of destination-label and tooltip text scaling. -class _NavigationGlyph extends StatelessWidget { - const _NavigationGlyph(this.child); - final Widget child; - @override - Widget build(BuildContext context) => ExcludeSemantics( - child: MediaQuery.withNoTextScaling( - child: DefaultTextStyle.merge( - style: TextStyle( - color: IconTheme.of(context).color, - height: 1, - letterSpacing: 0, - ), - child: SizedBox.square(dimension: 24, child: Center(child: child)), - ), - ), - ); -} - -// Keep native destination semantics and tooltips while hiding persistent labels. -Widget buildJournalNavigationBar( - BuildContext context, - UiNode node, - List children, - RendererEventCallback? onEvent, -) { - final props = node.props as MaterialNavigationBarProps; - final binding = node.eventBindings - .where( - (value) => value.eventTag == EventTagId.navigationDestinationSelected, - ) - .firstOrNull; - var childIndex = 0; - final destinations = props.destinations.map((destination) { - final icon = _NavigationGlyph(children[childIndex++]); - final selectedIcon = destination.hasSelectedIcon - ? _NavigationGlyph(children[childIndex++]) - : null; - return NavigationDestination( - icon: icon, - selectedIcon: selectedIcon, - label: destination.label, - ); - }).toList(); - return JournalNavigationBar( - selectedIndex: props.selectedIndex, - destinations: destinations, - onDestinationSelected: binding == null || onEvent == null - ? null - : (index) => onEvent( - RendererEvent( - nodeId: node.id, - eventTag: binding.eventTag, - handlerId: binding.handlerId, - payload: Int64EventPayload(index), - ), - ), - ); -} - -void registerJournalRootNavigation(NativeWidgetRegistry registry) { - registry.register<(int, int, double, bool, int, bool)>( - NativeWidgetRegistration( - kindId: 1003, - minVersion: 1, - maxVersion: 1, - capabilityBits: 0, - decodeProps: (payload) { - if (payload.length != 32) { - throw const FormatException('Invalid root scroll props'); - } - final data = ByteData.sublistView(payload); - return ( - data.getUint32(0, Endian.little), - data.getInt64(8, Endian.little), - data.getFloat64(16, Endian.little), - data.getUint32(4, Endian.little) != 0, - data.getUint32(24, Endian.little), - data.getUint32(28, Endian.little) != 0, - ); - }, - factory: (context) { - final (destination, revision, offset, visible, milliseconds, active) = - context.props; - void emit(int event, int destination, double pixels, double delta) { - final data = ByteData(24) - ..setUint32(0, destination, Endian.little) - ..setFloat64(8, pixels, Endian.little) - ..setFloat64(16, delta, Endian.little); - context.emit?.call(event, data.buffer.asUint8List()); - } - - return JournalRootScroll( - navigationVisible: visible, - duration: Duration(milliseconds: milliseconds), - active: active, - onScroll: (destination, pixels, delta) => - emit(1, destination, pixels, delta), - onNonScrollable: (destination) => emit(2, destination, 0, 0), - destination: destination, - favoritesRevision: revision, - favoritesAnchorOffset: offset, - child: context.children.single, - ); - }, - ), - ); -} diff --git a/flutter/lib/journal_tail_fade.dart b/flutter/lib/journal_tail_fade.dart deleted file mode 100644 index fd4f758..0000000 --- a/flutter/lib/journal_tail_fade.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'dart:typed_data'; - -import 'package:bonsai_flutter/bonsai_flutter.dart'; -import 'package:material_ui/material_ui.dart'; - -const int journalTailFadeKindId = 1001; - -@immutable -final class JournalTailFadeProps { - const JournalTailFadeProps({ - required this.lineHeight, - required this.fadeWidth, - }); - - final double lineHeight; - final double fadeWidth; - - static JournalTailFadeProps decode(Uint8List payload) { - if (payload.length != 16) { - throw const FormatException( - 'Journal tail fade props must contain exactly 16 bytes', - ); - } - final data = ByteData.sublistView(payload); - final lineHeight = data.getFloat64(0, Endian.little); - final fadeWidth = data.getFloat64(8, Endian.little); - if (!lineHeight.isFinite || - lineHeight <= 0 || - !fadeWidth.isFinite || - fadeWidth <= 0) { - throw const FormatException( - 'Journal tail fade geometry must be finite and positive', - ); - } - return JournalTailFadeProps(lineHeight: lineHeight, fadeWidth: fadeWidth); - } -} - -void registerJournalTailFade(NativeWidgetRegistry registry) { - registry.register( - NativeWidgetRegistration( - kindId: journalTailFadeKindId, - minVersion: 1, - maxVersion: 1, - capabilityBits: 0, - decodeProps: JournalTailFadeProps.decode, - factory: (context) { - if (context.children.length != 1) { - throw ArgumentError('Journal tail fade requires exactly one child'); - } - return JournalTailFade( - props: context.props, - child: context.children.single, - ); - }, - ), - ); -} - -final class JournalTailFade extends StatelessWidget { - const JournalTailFade({required this.props, required this.child, super.key}); - - final JournalTailFadeProps props; - final Widget child; - - @override - Widget build(BuildContext context) { - final surface = Theme.of(context).scaffoldBackgroundColor; - return Stack( - clipBehavior: Clip.hardEdge, - children: [ - child, - PositionedDirectional( - end: 0, - bottom: 0, - width: props.fadeWidth, - height: props.lineHeight, - child: IgnorePointer( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: AlignmentDirectional.centerStart, - end: AlignmentDirectional.centerEnd, - colors: [surface.withAlpha(0), surface], - ), - ), - ), - ), - ), - ], - ); - } -} diff --git a/flutter/lib/journal_widget_registry.dart b/flutter/lib/journal_widget_registry.dart deleted file mode 100644 index f2fe8db..0000000 --- a/flutter/lib/journal_widget_registry.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:bonsai_flutter/bonsai_flutter.dart'; - -import 'journal_tail_fade.dart'; -import 'journal_detail_outline.dart'; -import 'journal_date_row.dart'; -import 'journal_root_navigation.dart'; - -WidgetRegistry createJournalWidgetRegistry() { - final nativeWidgets = NativeWidgetRegistry( - capabilityBits: NativeCapability.core, - ); - registerMorphingSurface(nativeWidgets); - registerSlidable(nativeWidgets); - registerSlidableAutoCloseBehavior(nativeWidgets); - registerNavigationShell(nativeWidgets); - registerMessageComposer(nativeWidgets); - registerExpandableMessageComposer(nativeWidgets); - registerJournalRootNavigation(nativeWidgets); - registerJournalTailFade(nativeWidgets); - registerJournalDateRow(nativeWidgets); - registerJournalDetailOutline(nativeWidgets); - final standard = WidgetRegistry.standard(nativeWidgets: nativeWidgets); - return WidgetRegistry({ - for (final kind in NodeKind.values) - kind: kind == NodeKind.materialNavigationBar - ? buildJournalNavigationBar - : standard.build, - }, nativeWidgets); -} diff --git a/flutter/lib/main.dart b/flutter/lib/main.dart index 4a15651..d51fc35 100644 --- a/flutter/lib/main.dart +++ b/flutter/lib/main.dart @@ -1,11 +1,14 @@ import 'dart:async'; -import 'dart:typed_data'; +import 'dart:convert'; +import 'dart:io' show Platform; -import 'package:bonsai_flutter/bonsai_flutter.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:journal_lui_native/journal_lui_native.dart'; +import 'package:lui_flutter_backend/lui_flutter_backend.dart'; import 'application_host_adapter.dart'; -import 'journal_widget_registry.dart'; +import 'journal_extension_registry.dart'; Future main() async { JournalStartupTimeline.mark(JournalStartupMilestone.dartEntrypointStarted); @@ -20,7 +23,7 @@ Future launchJournalApplication() async { ); }); final runtimeOwner = JournalRuntimeOwner(); - final adapter = createBonsaiFlutterHostAdapter( + final adapter = createJournalHostAdapter( amplifyReady: amplifyReady, authenticationFailureBuilder: () => const _ConfigurationFailureApplication(), @@ -56,29 +59,122 @@ final class _ConfigurationFailureApplication extends StatelessWidget { ); } +/// Owns the OCaml bridge + `LUIFlutterBackend` pair for the app lifetime: +/// applies patch batches from the patch callback, forwards `LUIEvent`s into +/// `lui_ocaml_*`/`journal_ocaml_*` entries, schedules `journal_ocaml_pump` +/// off the wakeup callback, and bridges the LJP2 platform channel both ways. final class JournalRuntimeOwner { - RuntimeSession? _runtime; + JournalOcamlBridge? _bridge; + LUIFlutterBackend? _backend; + JournalPlatformServices? _platform; + StreamSubscription? _platformEvents; Future? _shutdown; - Future start(Uint8List config) async { + static int get _platformCode => Platform.isMacOS + ? 1 + : Platform.isIOS + ? 2 + : Platform.isAndroid + ? 3 + : Platform.isLinux + ? 4 + : 5; + + LUIFlutterBackend start({ + required Uint8List payload, + required JournalPlatformServices platform, + }) { if (_shutdown != null) { throw StateError('The journal runtime is shutting down'); } - final runtime = await RuntimeClient.start(config: config); + _platform = platform; + late final LUIFlutterBackend backend; + backend = LUIFlutterBackend( + onEvent: _handleEvent, + extensionRegistry: journalExtensionRegistry( + (nodeID) => backend.widget(node: nodeID), + ), + ); + _backend = backend; + final bridge = JournalOcamlBridge( + onPatch: (source) { + backend.applyJson(source); + generation.value = backend.generation; + }, + ); + _bridge = bridge; + // `journal_ml_wakeup` may fire off the UI thread; the listener callable + // hops back to this isolate, then the pump drains queued work + patches. + bridge.installWakeupCallback(() { + scheduleMicrotask(() => _bridge?.pump()); + }); + bridge.installPlatformRequestCallback((bytes) { + final current = _bridge; + if (current == null) return; + unawaited( + platform.handleRequest(bytes).then((response) { + if (response != null) current.platformResponse(response); + }), + ); + }); + _platformEvents = platform.events.listen(bridge.platformEvent); + bridge.start(platform: _platformCode, payload: payload); JournalStartupTimeline.mark(JournalStartupMilestone.runtimeStarted); - if (_shutdown != null) { - await runtime.dispose(); - throw StateError('The journal runtime shut down during startup'); + return backend; + } + + /// Bumped per patch batch so hosts can wait for the root node to appear. + final ValueNotifier generation = ValueNotifier(0); + + int? get rootNode { + final bridge = _bridge; + if (bridge == null) return null; + try { + return bridge.rootNode; + } on StateError { + return null; + } + } + + void _handleEvent(LUIEvent event) { + final bridge = _bridge; + if (bridge == null) return; + switch (event) { + case LUIAppearEvent e: + bridge.appear(e.node); + case LUIPressEvent e: + bridge.press(e.node); + case LUILongPressEvent e: + bridge.longPress(e.node); + case LUIDoublePressEvent e: + bridge.doublePress(e.node); + case LUIDismissEvent e: + bridge.dismiss(e.node); + case LUISubmitEvent e: + bridge.submit(e.node); + case LUITextChangedEvent e: + bridge.textChanged(e.node, e.text); + case LUIToggleChangedEvent e: + bridge.toggleChanged(e.node, e.checked); + case LUIChangeEvent e: + bridge.radioChanged(e.node); + case LUIValueChangedEvent e: + bridge.sliderChanged(e.node, e.value); + case LUIExtensionComponentEvent e: + bridge.extensionEvent(e.node, e.name, jsonEncode(e.values)); } - _runtime = runtime; - return runtime; } + void pushEnvironment(Uint8List envelope) => _bridge?.platformEvent(envelope); + Future shutdown() => _shutdown ??= _shutdownOnce(); Future _shutdownOnce() async { - final runtime = _runtime; - if (runtime != null) await runtime.dispose(); + await _platformEvents?.cancel(); + final platform = _platform; + if (platform is JournalApplicationPlatform) platform.dispose(); + _bridge?.close(); + _backend?.dispose(); } } @@ -89,7 +185,7 @@ final class JournalApplicationHost extends StatefulWidget { super.key, }); - final BonsaiFlutterHostAdapter adapter; + final JournalHostAdapter adapter; final JournalRuntimeOwner runtimeOwner; @override @@ -98,30 +194,83 @@ final class JournalApplicationHost extends StatefulWidget { final class _PreparedRuntime { const _PreparedRuntime({ - required this.runtimeConfig, + required this.applicationPayload, required this.applicationPlatform, }); - final Uint8List runtimeConfig; - final BonsaiFlutterApplicationPlatform? applicationPlatform; + final Uint8List applicationPayload; + final JournalApplicationPlatform? applicationPlatform; +} + +/// Stand-in `JournalPlatformServices` for host adapters that expose no +/// platform (e.g. tests) — accepts requests silently, pushes nothing. +final class _NullJournalPlatformServices implements JournalPlatformServices { + final StreamController _events = + StreamController.broadcast(); + + @override + Stream get events => _events.stream; + + @override + Future handleRequest(Uint8List request) async => null; } final class _JournalApplicationHostState extends State { late final Future<_PreparedRuntime> _preparedRuntime = _prepareRuntime(); - late final WidgetRegistry _widgetRegistry = createJournalWidgetRegistry(); + final GlobalKey _messengerKey = GlobalKey(); + final Map> _pendingNotices = {}; + LUIFlutterBackend? _backend; Future<_PreparedRuntime> _prepareRuntime() async { final applicationPayload = await widget.adapter.createApplicationPayload(); return _PreparedRuntime( - runtimeConfig: RuntimeBootstrapConfig( - entrypoint: 'logseq_journal', - launchPolicy: RuntimeLaunchPolicy.replaceExisting, - applicationPayload: applicationPayload, - ).encode(), + applicationPayload: applicationPayload, applicationPlatform: widget.adapter.createApplicationPlatform(), ); } + Future _showNotice({ + required String token, + required String message, + required String? actionLabel, + required int durationMs, + }) { + final messenger = _messengerKey.currentState; + if (messenger == null) return Future.value('dismiss'); + final completer = Completer(); + _pendingNotices[token] = completer; + messenger + .showSnackBar( + SnackBar( + content: Text(message), + duration: Duration(milliseconds: durationMs), + action: actionLabel == null + ? null + : SnackBarAction(label: actionLabel, onPressed: () {}), + ), + ) + .closed + .then((reason) { + final result = switch (reason) { + SnackBarClosedReason.action => 'action', + SnackBarClosedReason.swipe => 'swipe', + SnackBarClosedReason.timeout => 'timeout', + _ => 'dismiss', + }; + final pending = _pendingNotices.remove(token); + if (pending != null && !pending.isCompleted) { + pending.complete(result); + } + }); + return completer.future; + } + + void _cancelNotice(String token) { + final pending = _pendingNotices.remove(token); + pending?.complete('dismiss'); + _messengerKey.currentState?.hideCurrentSnackBar(); + } + @override Widget build(BuildContext context) => FutureBuilder<_PreparedRuntime>( future: _preparedRuntime, @@ -139,14 +288,157 @@ final class _JournalApplicationHostState extends State { child: Center(child: CircularProgressIndicator()), ); } else { - home = BonsaiFlutterRoot( - config: prepared.runtimeConfig, - runtimeStarter: widget.runtimeOwner.start, - applicationPlatform: prepared.applicationPlatform, - registry: _widgetRegistry, + final platform = + prepared.applicationPlatform ?? _NullJournalPlatformServices(); + if (platform is JournalApplicationPlatform) { + platform.installNoticeSink( + showNotice: _showNotice, + cancelNotice: _cancelNotice, + ); + } + final backend = _backend ??= widget.runtimeOwner.start( + payload: prepared.applicationPayload, + platform: platform, + ); + home = ScaffoldMessenger( + key: _messengerKey, + child: _EnvironmentPusher( + onSnapshot: widget.runtimeOwner.pushEnvironment, + child: ValueListenableBuilder( + valueListenable: widget.runtimeOwner.generation, + builder: (context, _, child) { + final root = widget.runtimeOwner.rootNode; + return root == null + ? const Center(child: CircularProgressIndicator()) + : backend.widget(node: root); + }, + ), + ), ); } return widget.adapter.buildHost(context: context, child: home); }, ); } + +/// Pushes LJP2 tag-24 environment snapshots at startup and on every +/// environment change (metrics, brightness, text scale, locale). +final class _EnvironmentPusher extends StatefulWidget { + const _EnvironmentPusher({required this.onSnapshot, required this.child}); + + final void Function(Uint8List envelope) onSnapshot; + final Widget child; + + @override + State<_EnvironmentPusher> createState() => _EnvironmentPusherState(); +} + +class _EnvironmentPusherState extends State<_EnvironmentPusher> + with WidgetsBindingObserver { + String? _lastJson; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeMetrics() => _push(); + @override + void didChangePlatformBrightness() => _push(); + @override + void didChangeTextScaleFactor() => _push(); + @override + void didChangeLocales(List? locales) => _push(); + @override + void didChangeAccessibilityFeatures() => _push(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _push(); + } + + void _push() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + widget.onSnapshot( + JournalPlatformCodec.encodeJson( + JournalPlatformTag.environmentEvent, + _snapshot(), + ), + ); + }); + } + + Map _snapshot() { + final media = MediaQuery.of(context); + final dispatcher = View.of(context).platformDispatcher; + final safeArea = media.padding; + final keyboard = media.viewInsets; + final platform = switch (defaultTargetPlatform) { + TargetPlatform.iOS => 'ios', + TargetPlatform.android => 'android', + TargetPlatform.macOS => 'macos', + TargetPlatform.linux => 'linux', + TargetPlatform.windows => 'windows', + _ => 'unknown', + }; + // Host capability mask, not an inventory of connected input devices — + // mirrors the Swift host: touch-primary platforms 0x0f, desktop 0x0e. + final pointerKinds = switch (defaultTargetPlatform) { + TargetPlatform.iOS || TargetPlatform.android => 0x0f, + TargetPlatform.macOS || + TargetPlatform.linux || + TargetPlatform.windows => 0x0e, + _ => 0, + }; + Map insets(EdgeInsets value) => { + 'left': value.left, + 'top': value.top, + 'right': value.right, + 'bottom': value.bottom, + }; + final snapshot = { + 'viewportWidth': media.size.width, + 'viewportHeight': media.size.height, + 'devicePixelRatio': media.devicePixelRatio, + 'textScale': media.textScaler.scale(1), + 'brightness': media.platformBrightness == Brightness.dark + ? 'dark' + : 'light', + 'platform': platform, + 'locale': dispatcher.locale.toLanguageTag(), + 'safeArea': insets(safeArea), + 'keyboardInsets': insets(keyboard), + 'accessibleNavigation': media.accessibleNavigation, + 'boldText': media.boldText, + 'invertColors': media.invertColors, + 'disableAnimations': media.disableAnimations, + 'reducedMotion': media.disableAnimations, + 'highContrast': media.highContrast, + 'orientation': media.size.width > media.size.height + ? 'landscape' + : 'portrait', + 'pointerKinds': pointerKinds, + }; + // Skip redundant pushes: the wire is per-snapshot, not per-change-source. + final encoded = jsonEncode(snapshot); + if (encoded == _lastJson) return const {}; + _lastJson = encoded; + return snapshot; + } + + @override + Widget build(BuildContext context) { + _push(); + return widget.child; + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } +} diff --git a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift index 31f0645..b054f89 100644 --- a/flutter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -8,15 +8,17 @@ import Foundation import amplify_auth_cognito import amplify_secure_storage import device_info_plus -import dynamic_color +import file_picker_darwin import package_info_plus +import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AmplifyAuthCognitoPlugin.register(with: registry.registrar(forPlugin: "AmplifyAuthCognitoPlugin")) AmplifySecureStoragePlugin.register(with: registry.registrar(forPlugin: "AmplifySecureStoragePlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) - DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/flutter/macos/Runner.xcodeproj/project.pbxproj b/flutter/macos/Runner.xcodeproj/project.pbxproj index e2771f9..2bbb28c 100644 --- a/flutter/macos/Runner.xcodeproj/project.pbxproj +++ b/flutter/macos/Runner.xcodeproj/project.pbxproj @@ -69,7 +69,7 @@ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* bonsai_flutter_logseq_journal_host.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "bonsai_flutter_logseq_journal_host.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* logseq_journal_host.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "logseq_journal_host.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -138,7 +138,7 @@ 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* bonsai_flutter_logseq_journal_host.app */, + 33CC10ED2044A3C60003C045 /* logseq_journal_host.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -230,7 +230,7 @@ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, ); productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* bonsai_flutter_logseq_journal_host.app */; + productReference = 33CC10ED2044A3C60003C045 /* logseq_journal_host.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -403,10 +403,10 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.bonsaiFlutterLogseqJournalHost.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.logseqJournalHost.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bonsai_flutter_logseq_journal_host.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bonsai_flutter_logseq_journal_host"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/logseq_journal_host.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/logseq_journal_host"; }; name = Debug; }; @@ -417,10 +417,10 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.bonsaiFlutterLogseqJournalHost.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.logseqJournalHost.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bonsai_flutter_logseq_journal_host.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bonsai_flutter_logseq_journal_host"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/logseq_journal_host.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/logseq_journal_host"; }; name = Release; }; @@ -431,10 +431,10 @@ CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.bonsaiFlutterLogseqJournalHost.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.logseqJournalHost.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/bonsai_flutter_logseq_journal_host.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/bonsai_flutter_logseq_journal_host"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/logseq_journal_host.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/logseq_journal_host"; }; name = Profile; }; diff --git a/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 4f2ac93..9668f0b 100644 --- a/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/flutter/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -15,7 +15,7 @@ @@ -33,7 +33,7 @@ @@ -49,7 +49,7 @@ @@ -84,7 +84,7 @@ @@ -101,7 +101,7 @@ diff --git a/flutter/macos/Runner/Configs/AppInfo.xcconfig b/flutter/macos/Runner/Configs/AppInfo.xcconfig index e02d26b..9702611 100644 --- a/flutter/macos/Runner/Configs/AppInfo.xcconfig +++ b/flutter/macos/Runner/Configs/AppInfo.xcconfig @@ -5,11 +5,11 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = bonsai_flutter_logseq_journal_host +PRODUCT_NAME = logseq_journal_host // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = com.logseq.journal // The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. -#include "BonsaiFlutter.xcconfig" +#include "JournalHost.xcconfig" diff --git a/flutter/macos/Runner/Configs/Debug.xcconfig b/flutter/macos/Runner/Configs/Debug.xcconfig index f730cfb..acfcbdb 100644 --- a/flutter/macos/Runner/Configs/Debug.xcconfig +++ b/flutter/macos/Runner/Configs/Debug.xcconfig @@ -1,3 +1,3 @@ #include "../../Flutter/Flutter-Debug.xcconfig" #include "Warnings.xcconfig" -#include "BonsaiFlutter.xcconfig" +#include "JournalHost.xcconfig" diff --git a/flutter/macos/Runner/Configs/BonsaiFlutter.xcconfig b/flutter/macos/Runner/Configs/JournalHost.xcconfig similarity index 100% rename from flutter/macos/Runner/Configs/BonsaiFlutter.xcconfig rename to flutter/macos/Runner/Configs/JournalHost.xcconfig diff --git a/flutter/macos/Runner/Configs/Release.xcconfig b/flutter/macos/Runner/Configs/Release.xcconfig index 3dcf499..6e5ef40 100644 --- a/flutter/macos/Runner/Configs/Release.xcconfig +++ b/flutter/macos/Runner/Configs/Release.xcconfig @@ -1,3 +1,3 @@ #include "../../Flutter/Flutter-Release.xcconfig" #include "Warnings.xcconfig" -#include "BonsaiFlutter.xcconfig" +#include "JournalHost.xcconfig" diff --git a/flutter/macos/RunnerTests/RunnerTests.swift b/flutter/macos/RunnerTests/RunnerTests.swift index d908a11..8e605a6 100644 --- a/flutter/macos/RunnerTests/RunnerTests.swift +++ b/flutter/macos/RunnerTests/RunnerTests.swift @@ -4,7 +4,7 @@ import Foundation import Security import XCTest -@testable import bonsai_flutter_logseq_journal_host +@testable import logseq_journal_host class RunnerTests: XCTestCase { func testApplicationLaunchCallbackCompletesOnCurrentFlutterHost() { diff --git a/flutter/packages/journal_lui_native/hook/build.dart b/flutter/packages/journal_lui_native/hook/build.dart new file mode 100644 index 0000000..d06df65 --- /dev/null +++ b/flutter/packages/journal_lui_native/hook/build.dart @@ -0,0 +1,228 @@ +import 'dart:io'; + +import 'package:code_assets/code_assets.dart'; +import 'package:native_toolchain_c/native_toolchain_c.dart'; +import 'package:logging/logging.dart'; +import 'package:hooks/hooks.dart'; + +import 'ocaml_artifact.dart'; + +void main(List args) async { + await build(args, (input, output) async { + if (!input.config.buildCodeAssets) { + return; + } + final packageName = input.packageName; + final requireOcamlBackend = _requireOcamlBackend(input); + final target = _ocamlTarget(input); + final nativeArtifactRoot = input.userDefines.path('native_artifact_root'); + File? ocamlObject; + if (target == null) { + if (requireOcamlBackend) { + throw StateError( + 'require_ocaml_backend is enabled, but ' + '${input.config.code.targetOS} is not an Apple target.', + ); + } + } else { + final variant = nativeArtifactRoot == null && !requireOcamlBackend + ? null + : _artifactVariant(input, target, nativeArtifactRoot); + ocamlObject = await OcamlArtifactResolver().resolve( + nativeArtifactRoot: nativeArtifactRoot, + requireOcamlBackend: requireOcamlBackend, + target: target, + variant: variant, + ); + } + final embedOcaml = ocamlObject != null; + final exportList = input.packageRoot.resolve( + 'src/journal_lui_exports.txt', + ); + final cbuilder = CBuilder.library( + name: packageName, + assetName: '${packageName}_bindings_generated.dart', + sources: [ + if (!embedOcaml) 'src/$packageName.c', + if (ocamlObject != null) ocamlObject.path, + if (input.config.code.targetOS == OS.iOS) + 'src/journal_lui_ios_process_stubs.c', + ], + includes: ['src'], + flags: [ + if (input.config.code.targetOS == OS.macOS || + input.config.code.targetOS == OS.iOS) ...[ + '-Wl,-dead_strip', + '-Wl,-exported_symbols_list,${exportList.toFilePath()}', + ], + if (input.config.code.targetOS == OS.iOS) ...['-framework', 'Security'], + if (input.config.code.targetOS == OS.iOS) + ...iOSDeploymentTargetFlagsForTesting( + input.config.code.iOS.targetVersion, + input.userDefines['ios_deployment_target'], + ), + if (input.config.code.targetOS == OS.macOS) + ...macOSDeploymentTargetFlags( + input.userDefines['macos_deployment_target'], + ), + ...systemLinkFlagsForTesting( + input.config.code.targetOS, + input.userDefines['link_system_sqlite3'], + ), + ], + ); + await cbuilder.run( + input: input, + output: output, + logger: Logger('') + ..level = .ALL + ..onRecord.listen((record) => stderr.writeln(record.message)), + ); + output.dependencies.add(exportList); + }); +} + +bool _requireOcamlBackend(BuildInput input) { + return _booleanUserDefine(input, 'require_ocaml_backend'); +} + +/// `native_artifact_profile` selects the artifact variant explicitly; without +/// it, pick the first variant directory that actually contains the object so +/// `flutter test`/`build` work without toolchain-injected defines. +OcamlArtifactVariant _artifactVariant( + BuildInput input, + OcamlArtifactTarget target, + Uri? nativeArtifactRoot, +) { + final profile = _artifactProfile(input); + if (profile != null) return OcamlArtifactVariant.fromProfileName(profile); + if (nativeArtifactRoot != null) { + for (final variant in OcamlArtifactVariant.values) { + if (File.fromUri( + nativeArtifactRoot.resolve(target.artifactPathFor(variant: variant)), + ).existsSync()) { + return variant; + } + } + } + return OcamlArtifactVariant.debug; +} + +String? _artifactProfile(BuildInput input) { + final value = input.userDefines['native_artifact_profile']; + if (value == null || value is String) return value as String?; + throw FormatException( + 'native_artifact_profile must be debug, profile, or release; ' + 'found $value.', + ); +} + +bool _booleanUserDefine(BuildInput input, String name) { + return _booleanValue(name, input.userDefines[name]); +} + +bool _booleanValue(String name, Object? value) { + return switch (value) { + null => false, + true || 'true' => true, + false || 'false' => false, + _ => throw FormatException('$name must be true or false, found $value.'), + }; +} + +List systemLinkFlagsForTesting(OS targetOS, Object? userDefine) { + final enabled = _booleanValue('link_system_sqlite3', userDefine); + if (!enabled) return const []; + return switch (targetOS) { + OS.macOS || OS.iOS => const ['-lsqlite3'], + _ => const [], + }; +} + +String iOSMinimumVersionForTesting( + int nativeAssetsTargetVersion, + Object? userDefine, +) { + if (userDefine == null) return '$nativeAssetsTargetVersion.0'; + if (userDefine case final String value + when RegExp(r'^[1-9][0-9]*[.][0-9]+$').hasMatch(value)) { + return value; + } + throw FormatException( + 'ios_deployment_target must be a quoted major.minor version, ' + 'found $userDefine.', + ); +} + +List iOSDeploymentTargetFlagsForTesting( + int nativeAssetsTargetVersion, + Object? userDefine, +) { + if (userDefine == null) return const []; + final minimumVersion = iOSMinimumVersionForTesting( + nativeAssetsTargetVersion, + userDefine, + ); + return ['-mios-version-min=$minimumVersion']; +} + +String macOSMinimumVersion(Object? userDefine) { + if (userDefine case final String value + when RegExp(r'^[1-9][0-9]*[.][0-9]+$').hasMatch(value)) { + return value; + } + throw FormatException( + 'macos_deployment_target must be a quoted major.minor version, ' + 'found $userDefine.', + ); +} + +List macOSDeploymentTargetFlags(Object? userDefine) => [ + '-mmacosx-version-min=${macOSMinimumVersion(userDefine)}', +]; + +OcamlArtifactTarget? _ocamlTarget(BuildInput input) { + final config = input.config.code; + if (config.targetOS == OS.macOS) { + if (config.targetArchitecture == Architecture.x64) { + throw StateError( + 'Unsupported macOS architecture x86_64; ' + 'journal_lui_native supports arm64 only.', + ); + } + if (config.targetArchitecture != Architecture.arm64) return null; + return OcamlArtifactTarget( + operatingSystem: OcamlTargetOperatingSystem.macOS, + architecture: OcamlTargetArchitecture.arm64, + appleSdk: OcamlAppleSdk.macOS, + minimumVersion: macOSMinimumVersion( + input.userDefines['macos_deployment_target'], + ), + ); + } + if (config.targetOS == OS.iOS) { + final sdk = switch (config.iOS.targetSdk) { + IOSSdk.iPhoneOS => OcamlAppleSdk.iPhoneOS, + IOSSdk.iPhoneSimulator => throw StateError( + 'iOS Simulator is unsupported; use a physical iPhone.', + ), + _ => throw StateError('Unsupported iOS SDK ${config.iOS.targetSdk}.'), + }; + if (config.targetArchitecture != Architecture.arm64) { + throw StateError( + 'Unsupported iPhoneOS architecture ${config.targetArchitecture}; ' + 'journal_lui_native supports arm64 only.', + ); + } + return OcamlArtifactTarget( + operatingSystem: OcamlTargetOperatingSystem.iOS, + architecture: OcamlTargetArchitecture.arm64, + appleSdk: sdk, + minimumVersion: iOSMinimumVersionForTesting( + config.iOS.targetVersion, + input.userDefines['ios_deployment_target'], + ), + ); + } + return null; +} diff --git a/flutter/packages/journal_lui_native/hook/ocaml_artifact.dart b/flutter/packages/journal_lui_native/hook/ocaml_artifact.dart new file mode 100644 index 0000000..7e1ac58 --- /dev/null +++ b/flutter/packages/journal_lui_native/hook/ocaml_artifact.dart @@ -0,0 +1,223 @@ +import 'dart:io'; + +enum OcamlTargetOperatingSystem { macOS, iOS } + +enum OcamlTargetArchitecture { arm64 } + +enum OcamlAppleSdk { macOS, iPhoneOS } + +enum OcamlMachOPlatform { macOS, iOS } + +enum OcamlArtifactVariant { + debug, + profile, + release; + + static OcamlArtifactVariant fromProfileName(String? profile) { + return switch (profile) { + 'debug' => debug, + 'profile' => OcamlArtifactVariant.profile, + 'release' => release, + _ => throw FormatException( + 'native_artifact_profile must be debug, profile, or release; ' + 'found $profile.', + ), + }; + } +} + +final class OcamlArtifactTarget { + final OcamlTargetOperatingSystem operatingSystem; + final OcamlTargetArchitecture architecture; + final OcamlAppleSdk appleSdk; + final String minimumVersion; + + const OcamlArtifactTarget({ + required this.operatingSystem, + required this.architecture, + required this.appleSdk, + required this.minimumVersion, + }); + + String get artifactPath { + return artifactPathFor(); + } + + String artifactPathFor({OcamlArtifactVariant? variant}) { + final architectureDirectory = architecture.name; + final variantDirectory = variant == null ? '' : '${variant.name}/'; + return switch (appleSdk) { + OcamlAppleSdk.macOS => + 'macos/$architectureDirectory/' + '${variantDirectory}native_embed.exe.o', + OcamlAppleSdk.iPhoneOS => + 'ios/iphoneos/$architectureDirectory/' + '${variantDirectory}native_embed.exe.o', + }; + } + + String get description => + '${_operatingSystemLabel(operatingSystem)} ' + '${architecture.name} ${_sdkLabel(appleSdk)} minimum $minimumVersion'; +} + +final class OcamlArtifactMetadata { + final OcamlMachOPlatform platform; + final OcamlTargetArchitecture architecture; + final OcamlAppleSdk appleSdk; + final String minimumVersion; + + const OcamlArtifactMetadata({ + required this.platform, + required this.architecture, + required this.appleSdk, + required this.minimumVersion, + }); +} + +typedef OcamlArtifactInspector = + Future Function(File object); + +final class OcamlArtifactResolver { + final OcamlArtifactInspector inspect; + + OcamlArtifactResolver({OcamlArtifactInspector? inspect}) + : inspect = inspect ?? inspectOcamlArtifact; + + Future resolve({ + required Uri? nativeArtifactRoot, + required bool requireOcamlBackend, + required OcamlArtifactTarget target, + OcamlArtifactVariant? variant, + }) async { + if (nativeArtifactRoot == null) { + if (requireOcamlBackend) { + throw StateError( + 'require_ocaml_backend is enabled, but native_artifact_root is ' + 'missing for ${target.description}.', + ); + } + return null; + } + + final object = File.fromUri( + nativeArtifactRoot.resolve(target.artifactPathFor(variant: variant)), + ); + if (!object.existsSync()) { + throw StateError( + 'Required OCaml complete object is missing for ' + '${target.description}. Expected ${object.path}.', + ); + } + + final metadata = await inspect(object); + final expectedPlatform = switch (target.appleSdk) { + OcamlAppleSdk.macOS => OcamlMachOPlatform.macOS, + OcamlAppleSdk.iPhoneOS => OcamlMachOPlatform.iOS, + }; + if (metadata.platform != expectedPlatform) { + throw StateError( + 'OCaml object platform mismatch for ${target.description}: ' + 'found ${metadata.platform.name} at ${object.path}.', + ); + } + if (metadata.architecture != target.architecture) { + throw StateError( + 'OCaml object architecture mismatch for ${target.description}: ' + 'found ${metadata.architecture.name} at ${object.path}.', + ); + } + if (metadata.appleSdk != target.appleSdk) { + throw StateError( + 'OCaml object SDK mismatch for ${target.description}: ' + 'found ${_sdkLabel(metadata.appleSdk)} at ${object.path}.', + ); + } + if (metadata.minimumVersion != target.minimumVersion) { + throw StateError( + 'OCaml object minimum version mismatch for ${target.description}: ' + 'found ${metadata.minimumVersion} at ${object.path}.', + ); + } + return object; + } +} + +Future inspectOcamlArtifact(File object) async { + final fileOutput = await _run('file', [object.path]); + final architecture = switch (fileOutput) { + final output when output.contains('arm64') => OcamlTargetArchitecture.arm64, + _ => throw StateError( + 'Unsupported Mach-O architecture for ${object.path}: $fileOutput', + ), + }; + + final architectures = (await _run('xcrun', [ + 'lipo', + '-archs', + object.path, + ])).trim(); + if (architectures != architecture.name) { + throw StateError( + 'OCaml object must contain exactly ${architecture.name}; ' + 'found "$architectures" at ${object.path}.', + ); + } + + final build = await _run('xcrun', ['vtool', '-show-build', object.path]); + final platformMatch = RegExp( + r'^\s*platform\s+(\S+)\s*$', + multiLine: true, + ).firstMatch(build); + final minimumMatch = RegExp( + r'^\s*minos\s+(\S+)\s*$', + multiLine: true, + ).firstMatch(build); + if (platformMatch == null || minimumMatch == null) { + throw StateError( + 'OCaml object has no complete LC_BUILD_VERSION at ${object.path}.', + ); + } + + final (platform, sdk) = switch (platformMatch.group(1)) { + 'MACOS' => (OcamlMachOPlatform.macOS, OcamlAppleSdk.macOS), + 'IOS' => (OcamlMachOPlatform.iOS, OcamlAppleSdk.iPhoneOS), + final value => throw StateError( + 'Unsupported Mach-O platform $value at ${object.path}.', + ), + }; + + final loadCommands = await _run('xcrun', ['otool', '-l', object.path]); + if (RegExp(r'\b__LLVM\b').hasMatch(loadCommands)) { + throw StateError('Bitcode segment __LLVM is prohibited at ${object.path}.'); + } + + return OcamlArtifactMetadata( + platform: platform, + architecture: architecture, + appleSdk: sdk, + minimumVersion: minimumMatch.group(1)!, + ); +} + +Future _run(String executable, List arguments) async { + final result = await Process.run(executable, arguments); + if (result.exitCode != 0) { + throw StateError( + '$executable ${arguments.join(' ')} failed with exit code ' + '${result.exitCode}: ${result.stderr}', + ); + } + return result.stdout as String; +} + +String _operatingSystemLabel(OcamlTargetOperatingSystem operatingSystem) => + switch (operatingSystem) { + OcamlTargetOperatingSystem.macOS => 'macOS', + OcamlTargetOperatingSystem.iOS => 'iOS', + }; + +String _sdkLabel(OcamlAppleSdk sdk) => switch (sdk) { + OcamlAppleSdk.macOS => 'macOS', + OcamlAppleSdk.iPhoneOS => 'iPhoneOS', +}; diff --git a/flutter/packages/journal_lui_native/lib/journal_lui_native.dart b/flutter/packages/journal_lui_native/lib/journal_lui_native.dart new file mode 100644 index 0000000..ea8869f --- /dev/null +++ b/flutter/packages/journal_lui_native/lib/journal_lui_native.dart @@ -0,0 +1,239 @@ +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +import 'journal_lui_native_bindings_generated.dart' as bindings; + +typedef _NativePatchCallback = Void Function(Pointer); +typedef _NativeWakeupCallback = Void Function(); +typedef _NativePlatformRequestCallback = + Void Function(Pointer, Int32); + +/// Journal counterpart to `LUIOcamlBridge`: binds the `lui_ocaml_*` entries +/// plus the journal-specific exports (`journal_ocaml_*`) declared in +/// `app/journal_lui_bridge.c`. Symbols resolve through the `journal_lui_native` +/// code asset, not `DynamicLibrary`, so calls work wherever the asset is +/// linked (macOS / iPhoneOS Runner targets). +final class JournalOcamlBridge { + JournalOcamlBridge({required this.onPatch}); + + final void Function(String json) onPatch; + NativeCallable<_NativePatchCallback>? _patchCallback; + NativeCallable<_NativeWakeupCallback>? _wakeupCallback; + NativeCallable<_NativePlatformRequestCallback>? _platformRequestCallback; + bool _started = false; + + /// Boots the OCaml runtime and performs the initial patch flush. Platform + /// and host codes match `lui` (`platform/flutter/lib/lui_ocaml_bridge.dart`): + /// macOS 1, iOS 2, android 3, linux 4, windows 5; Flutter host 3. + /// + /// [payload] is the LDB1 startup envelope OCaml decodes via + /// `Journal_startup.decode` — it must arrive at init because the worker + /// session starts inside init. + void start({ + required int platform, + int host = 3, + Uint8List? payload, + }) { + if (_started) { + throw StateError('OCaml bridge is already started'); + } + final callback = NativeCallable<_NativePatchCallback>.isolateLocal( + (Pointer json) => onPatch(json.toDartString()), + ); + _patchCallback = callback; + final payloadBytes = payload ?? Uint8List(0); + final payloadData = malloc(payloadBytes.length); + try { + if (payloadBytes.isNotEmpty) { + payloadData.asTypedList(payloadBytes.length).setAll(0, payloadBytes); + } + if (bindings.lui_ocaml_start( + callback.nativeFunction, + platform, + host, + payloadData, + payloadBytes.length, + ) != + 1) { + callback.close(); + _patchCallback = null; + throw StateError('OCaml runtime initialization failed'); + } + } finally { + malloc.free(payloadData); + } + _started = true; + } + + /// OCaml -> host: schedule `wakeup()` on the UI isolate. `journal_ml_wakeup` + /// can fire from non-UI threads, so a `.listener` callable is required; + /// the delivered callback itself stays on this isolate. + void installWakeupCallback(void Function() wakeup) { + _wakeupCallback ??= NativeCallable<_NativeWakeupCallback>.listener( + wakeup, + ); + bindings.journal_ocaml_set_wakeup_callback( + _wakeupCallback!.nativeFunction, + ); + } + + /// OCaml -> host: LJP2 platform requests. Requests originate on the app + /// thread during dispatches/pumps, so a synchronous `isolateLocal` callable + /// is correct and lets the bytes be copied while the pointer is valid. + void installPlatformRequestCallback(void Function(Uint8List bytes) request) { + _platformRequestCallback ??= + NativeCallable<_NativePlatformRequestCallback>.isolateLocal( + (Pointer data, int length) { + final bytes = data == nullptr || length <= 0 + ? Uint8List(0) + : Uint8List.fromList(data.asTypedList(length)); + request(bytes); + }, + ); + bindings.journal_ocaml_set_platform_request_callback( + _platformRequestCallback!.nativeFunction, + ); + } + + void appear(int node) { + if (bindings.lui_ocaml_appear(node) != 1) { + throw StateError('OCaml appear dispatch failed'); + } + } + + void press(int node) { + if (bindings.lui_ocaml_press(node) != 1) { + throw StateError('OCaml press dispatch failed'); + } + } + + void longPress(int node) { + if (bindings.lui_ocaml_long_press(node) != 1) { + throw StateError('OCaml long-press dispatch failed'); + } + } + + void doublePress(int node) { + if (bindings.lui_ocaml_double_press(node) != 1) { + throw StateError('OCaml double-press dispatch failed'); + } + } + + void textChanged(int node, String text) { + final nativeText = text.toNativeUtf8(); + try { + if (bindings.lui_ocaml_text_changed(node, nativeText) != 1) { + throw StateError('OCaml text dispatch failed'); + } + } finally { + malloc.free(nativeText); + } + } + + void submit(int node) { + if (bindings.lui_ocaml_submit(node) != 1) { + throw StateError('OCaml submit dispatch failed'); + } + } + + void dismiss(int node) { + if (bindings.lui_ocaml_dismiss(node) != 1) { + throw StateError('OCaml dismiss dispatch failed'); + } + } + + void toggleChanged(int node, bool checked) { + if (bindings.lui_ocaml_toggle_changed(node, checked ? 1 : 0) != 1) { + throw StateError('OCaml toggle dispatch failed'); + } + } + + void radioChanged(int node) { + if (bindings.lui_ocaml_radio_changed(node) != 1) { + throw StateError('OCaml radio dispatch failed'); + } + } + + void sliderChanged(int node, double value) { + if (bindings.lui_ocaml_slider_changed(node, value) != 1) { + throw StateError('OCaml slider dispatch failed'); + } + } + + int get rootNode => _requiredNode(bindings.lui_ocaml_root_node(), 'root'); + + int _requiredNode(int node, String name) { + if (node < 0) throw StateError('OCaml $name node lookup failed'); + return node; + } + + /// Forwards a journal extension event. `payload` is the JSON object of the + /// event's wire values, e.g. `{"id":1,"payload":"{...}"}` — see + /// `journal_lui_bridge.c` and `journal_lui_native.decode_event`. + void extensionEvent(int node, String name, String payloadJson) { + final nativeName = name.toNativeUtf8(); + final nativePayload = payloadJson.toNativeUtf8(); + try { + if (bindings.journal_ocaml_extension_event( + node, + nativeName, + nativePayload, + ) != + 1) { + throw StateError('OCaml extension-event dispatch failed'); + } + } finally { + malloc.free(nativeName); + malloc.free(nativePayload); + } + } + + /// Drains the cross-thread work queue; invoked on the UI isolate when the + /// wakeup callback fires. May emit patches via the patch callback. + void pump() { + if (bindings.journal_ocaml_pump() != 1) { + throw StateError('OCaml pump dispatch failed'); + } + } + + void platformEvent(Uint8List bytes) => + _deliverPlatform(bytes, bindings.journal_ocaml_platform_event); + + void platformResponse(Uint8List bytes) => + _deliverPlatform(bytes, bindings.journal_ocaml_platform_response); + + void _deliverPlatform( + Uint8List bytes, + void Function(Pointer, int) entry, + ) { + if (bytes.isEmpty) { + entry(nullptr, 0); + return; + } + final data = malloc(bytes.length); + try { + data.asTypedList(bytes.length).setAll(0, bytes); + entry(data, bytes.length); + } finally { + malloc.free(data); + } + } + + void close() { + if (!_started) return; + if (bindings.lui_ocaml_stop() != 1) { + throw StateError('OCaml runtime disposal failed'); + } + bindings.journal_ocaml_set_wakeup_callback(nullptr); + bindings.journal_ocaml_set_platform_request_callback(nullptr); + _wakeupCallback?.close(); + _platformRequestCallback?.close(); + _wakeupCallback = null; + _platformRequestCallback = null; + _patchCallback?.close(); + _patchCallback = null; + _started = false; + } +} diff --git a/flutter/packages/journal_lui_native/lib/journal_lui_native_bindings_generated.dart b/flutter/packages/journal_lui_native/lib/journal_lui_native_bindings_generated.dart new file mode 100644 index 0000000..e4cd2e3 --- /dev/null +++ b/flutter/packages/journal_lui_native/lib/journal_lui_native_bindings_generated.dart @@ -0,0 +1,112 @@ +// ignore_for_file: camel_case_types, non_constant_identifier_names + +// Hand-maintained FFI surface for app/journal_lui_bridge.c. +// +// Symbols link through the `journal_lui_native` code asset built by +// hook/build.dart (the OCaml complete object already contains the bridge C +// stubs, so the hook only needs the artifact, the iOS process stubs and the +// export list). Names and signatures must stay in lockstep with the C file. + +import 'dart:ffi' as ffi; + +import 'package:ffi/ffi.dart' show Utf8; + +typedef JL_PATCH_CALLBACK = ffi.Void Function(ffi.Pointer); +typedef JL_WAKEUP_CALLBACK = ffi.Void Function(); +typedef JL_PLATFORM_REQUEST_CALLBACK = + ffi.Void Function(ffi.Pointer, ffi.Int32); + +@ffi.Native< + ffi.Int32 Function( + ffi.Pointer>, + ffi.Int32, + ffi.Int32, + ffi.Pointer, + ffi.Int32, + ) +>() +external int lui_ocaml_start( + ffi.Pointer> callback, + int platform, + int host, + ffi.Pointer payloadData, + int payloadLength, +); + +@ffi.Native() +external int lui_ocaml_stop(); + +@ffi.Native() +external int lui_ocaml_appear(int node); + +@ffi.Native() +external int lui_ocaml_press(int node); + +@ffi.Native() +external int lui_ocaml_long_press(int node); + +@ffi.Native)>() +external int lui_ocaml_text_changed(int node, ffi.Pointer text); + +@ffi.Native() +external int lui_ocaml_submit(int node); + +@ffi.Native() +external int lui_ocaml_dismiss(int node); + +@ffi.Native() +external int lui_ocaml_double_press(int node); + +@ffi.Native() +external int lui_ocaml_toggle_changed(int node, int checked); + +@ffi.Native() +external int lui_ocaml_radio_changed(int node); + +@ffi.Native() +external int lui_ocaml_slider_changed(int node, double fraction); + +@ffi.Native() +external int lui_ocaml_root_node(); + +@ffi.Native< + ffi.Int32 Function(ffi.Int64, ffi.Pointer, ffi.Pointer) +>() +external int journal_ocaml_extension_event( + int node, + ffi.Pointer name, + ffi.Pointer payload, +); + +@ffi.Native() +external int journal_ocaml_pump(); + +@ffi.Native, ffi.Int32)>() +external void journal_ocaml_platform_event( + ffi.Pointer data, + int length, +); + +@ffi.Native, ffi.Int32)>() +external void journal_ocaml_platform_response( + ffi.Pointer data, + int length, +); + +@ffi.Native< + ffi.Void Function( + ffi.Pointer>, + ) +>() +external void journal_ocaml_set_wakeup_callback( + ffi.Pointer> callback, +); + +@ffi.Native< + ffi.Void Function( + ffi.Pointer>, + ) +>() +external void journal_ocaml_set_platform_request_callback( + ffi.Pointer> callback, +); diff --git a/flutter/packages/journal_lui_native/pubspec.yaml b/flutter/packages/journal_lui_native/pubspec.yaml new file mode 100644 index 0000000..6a86635 --- /dev/null +++ b/flutter/packages/journal_lui_native/pubspec.yaml @@ -0,0 +1,17 @@ +name: journal_lui_native +description: Native-assets build boundary for the journal LUI OCaml runtime. +publish_to: none +version: 0.0.1 + +environment: + sdk: ^3.12.2 + +dependencies: + ffi: ^2.1.4 + code_assets: ^1.0.0 + hooks: ^1.0.0 + logging: ^1.3.0 + native_toolchain_c: ^0.17.4 + +dev_dependencies: + flutter_lints: ^6.0.0 diff --git a/flutter/packages/journal_lui_native/src/journal_lui_exports.txt b/flutter/packages/journal_lui_native/src/journal_lui_exports.txt new file mode 100644 index 0000000..5d79361 --- /dev/null +++ b/flutter/packages/journal_lui_native/src/journal_lui_exports.txt @@ -0,0 +1,19 @@ +_lui_ocaml_start +_lui_ocaml_stop +_lui_ocaml_appear +_lui_ocaml_press +_lui_ocaml_long_press +_lui_ocaml_text_changed +_lui_ocaml_submit +_lui_ocaml_dismiss +_lui_ocaml_double_press +_lui_ocaml_toggle_changed +_lui_ocaml_radio_changed +_lui_ocaml_slider_changed +_lui_ocaml_root_node +_journal_ocaml_extension_event +_journal_ocaml_pump +_journal_ocaml_platform_event +_journal_ocaml_platform_response +_journal_ocaml_set_wakeup_callback +_journal_ocaml_set_platform_request_callback diff --git a/flutter/packages/journal_lui_native/src/journal_lui_ios_process_stubs.c b/flutter/packages/journal_lui_native/src/journal_lui_ios_process_stubs.c new file mode 100644 index 0000000..a7480b2 --- /dev/null +++ b/flutter/packages/journal_lui_native/src/journal_lui_ios_process_stubs.c @@ -0,0 +1,98 @@ +#include +#include +#include +#include + +/* + * iOS applications cannot create child processes. Resolve the OCaml Unix + * runtime's otherwise-unused process imports inside the private framework so + * the shipped binary never imports process-creation APIs from libSystem. + */ + +pid_t fork(void) { + errno = ENOTSUP; + return -1; +} + +int execv(const char *path, char *const argv[]) { + (void)path; + (void)argv; + errno = ENOTSUP; + return -1; +} + +int execve(const char *path, char *const argv[], char *const envp[]) { + (void)path; + (void)argv; + (void)envp; + errno = ENOTSUP; + return -1; +} + +int execvp(const char *file, char *const argv[]) { + (void)file; + (void)argv; + errno = ENOTSUP; + return -1; +} + +int posix_spawn( + pid_t *restrict pid, + const char *restrict path, + const posix_spawn_file_actions_t *file_actions, + const posix_spawnattr_t *restrict attrp, + char *const argv[restrict], + char *const envp[restrict]) { + (void)pid; + (void)path; + (void)file_actions; + (void)attrp; + (void)argv; + (void)envp; + return ENOTSUP; +} + +int posix_spawnp( + pid_t *restrict pid, + const char *restrict file, + const posix_spawn_file_actions_t *file_actions, + const posix_spawnattr_t *restrict attrp, + char *const argv[restrict], + char *const envp[restrict]) { + (void)pid; + (void)file; + (void)file_actions; + (void)attrp; + (void)argv; + (void)envp; + return ENOTSUP; +} + +int posix_spawn_file_actions_init(posix_spawn_file_actions_t *file_actions) { + (void)file_actions; + return ENOTSUP; +} + +int posix_spawn_file_actions_destroy( + posix_spawn_file_actions_t *file_actions) { + (void)file_actions; + return ENOTSUP; +} + +int posix_spawn_file_actions_addclose( + posix_spawn_file_actions_t *file_actions, + int file_descriptor) { + (void)file_actions; + (void)file_descriptor; + return ENOTSUP; +} + +int posix_spawn_file_actions_adddup2( + posix_spawn_file_actions_t *file_actions, + int file_descriptor, + int new_file_descriptor) { + (void)file_actions; + (void)file_descriptor; + (void)new_file_descriptor; + return ENOTSUP; +} diff --git a/flutter/packages/journal_lui_native/src/journal_lui_native.c b/flutter/packages/journal_lui_native/src/journal_lui_native.c new file mode 100644 index 0000000..2b7fddb --- /dev/null +++ b/flutter/packages/journal_lui_native/src/journal_lui_native.c @@ -0,0 +1,75 @@ +#include + +/* + * Non-Apple stub: the LUI OCaml runtime is only embedded when the + * native_artifact_root complete object resolves (macOS / iPhoneOS builds). + * Every journal bridge export still resolves so the code asset links cleanly; + * calls report failure exactly like the real bridge does on error. + */ + +#if defined(_WIN32) +#define JL_EXPORT __declspec(dllexport) +#else +#define JL_EXPORT __attribute__((visibility("default"))) +#endif + +typedef void (*jl_patch_callback)(const char *json); +typedef void (*jl_wakeup_callback)(void); +typedef void (*jl_platform_request_callback)(const char *data, int32_t length); + +JL_EXPORT int32_t lui_ocaml_start( + jl_patch_callback callback, int32_t platform_code, int32_t host_code) { + (void)callback; + (void)platform_code; + (void)host_code; + return 0; +} + +JL_EXPORT int32_t lui_ocaml_stop(void) { return 0; } +JL_EXPORT int32_t lui_ocaml_appear(int64_t node) { (void)node; return 0; } +JL_EXPORT int32_t lui_ocaml_press(int64_t node) { (void)node; return 0; } +JL_EXPORT int32_t lui_ocaml_long_press(int64_t node) { (void)node; return 0; } +JL_EXPORT int32_t lui_ocaml_text_changed(int64_t node, const char *text) { + (void)node; + (void)text; + return 0; +} +JL_EXPORT int32_t lui_ocaml_submit(int64_t node) { (void)node; return 0; } +JL_EXPORT int32_t lui_ocaml_dismiss(int64_t node) { (void)node; return 0; } +JL_EXPORT int32_t lui_ocaml_double_press(int64_t node) { (void)node; return 0; } +JL_EXPORT int32_t lui_ocaml_toggle_changed(int64_t node, int32_t checked) { + (void)node; + (void)checked; + return 0; +} +JL_EXPORT int32_t lui_ocaml_radio_changed(int64_t node) { (void)node; return 0; } +JL_EXPORT int32_t lui_ocaml_slider_changed(int64_t node, double fraction) { + (void)node; + (void)fraction; + return 0; +} +JL_EXPORT int64_t lui_ocaml_root_node(void) { return -1; } + +JL_EXPORT int32_t journal_ocaml_extension_event( + int64_t node, const char *name, const char *payload) { + (void)node; + (void)name; + (void)payload; + return 0; +} +JL_EXPORT int32_t journal_ocaml_pump(void) { return 0; } +JL_EXPORT void journal_ocaml_platform_event(const char *data, int32_t length) { + (void)data; + (void)length; +} +JL_EXPORT void journal_ocaml_platform_response(const char *data, int32_t length) { + (void)data; + (void)length; +} +JL_EXPORT void journal_ocaml_set_wakeup_callback(jl_wakeup_callback callback) { + (void)callback; +} +JL_EXPORT void journal_ocaml_set_platform_request_callback( + jl_platform_request_callback callback) { + (void)callback; +} diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 4b8beb6..0271b17 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.12" + android_file_picker: + dependency: transitive + description: + name: android_file_picker + sha256: "014c74ab48d452c3252465682375a7fe6ddf56abb908ec361f207a3c4ffb2444" + url: "https://pub.dev" + source: hosted + version: "1.1.1" args: dependency: transitive description: @@ -121,20 +129,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.13" - bonsai_flutter: - dependency: "direct main" - description: - path: "../.bonsai-flutter/flutter-packages/bonsai_flutter" - relative: true - source: path - version: "0.1.0-dev.1" - bonsai_flutter_native: - dependency: transitive - description: - path: "../.bonsai-flutter/flutter-packages/bonsai_flutter_native" - relative: true - source: path - version: "0.0.1" boolean_selector: dependency: transitive description: @@ -207,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: @@ -215,14 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" - cupertino_ui: + dbus: dependency: transitive description: - name: cupertino_ui - sha256: e9dfe7fac704028f8928cbe4028a0be5e8a709498e8daf8247de99e99a32aef3 + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "0.7.15" device_info_plus: dependency: transitive description: @@ -247,22 +249,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.34.3" - dynamic_color: - dependency: transitive - description: - name: dynamic_color - sha256: "869b3bce0100eb519768ecd71c0111f0b5ea679f5f62e1f59c49d80067e730c5" - url: "https://pub.dev" - source: hosted - version: "2.1.0" - equatable: - dependency: transitive - description: - name: equatable - sha256: "3bce007a596ff8b3119c45d68aaef631272537c03d30e5d4534dd24bf4c5eaa2" - url: "https://pub.dev" - source: hosted - version: "2.1.0" fake_async: dependency: transitive description: @@ -295,6 +281,46 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "9be6aac79508dbcf8dac80a4fb20f27ce6f910837ca27e3670b9295995cc0110" + url: "https://pub.dev" + source: hosted + version: "12.3.0" + file_picker_darwin: + dependency: transitive + description: + name: file_picker_darwin + sha256: "59fa5394cfa5b6dc8bf491630cb17077b77b844adbb840df371e7be5355d6cf6" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + file_picker_linux: + dependency: transitive + description: + name: file_picker_linux + sha256: bd52ff1e0048f29df95f913c55ad2991c72791d93e6c42241912c4bdee946cb9 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_picker_platform_interface: + dependency: transitive + description: + name: file_picker_platform_interface + sha256: "0355558fd9af6da499d18e333d6b3beb44b0bf19e00933fccd15d18eb8d0e9ca" + url: "https://pub.dev" + source: hosted + version: "3.4.0" + file_picker_web: + dependency: transitive + description: + name: file_picker_web + sha256: "935560a9d29fa6f006f2855addebb88e88d438e13627d4cc24187033c106f227" + url: "https://pub.dev" + source: hosted + version: "3.1.0" fixnum: dependency: transitive description: @@ -406,10 +432,10 @@ packages: dependency: transitive description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" jni: dependency: transitive description: @@ -434,6 +460,13 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + journal_lui_native: + dependency: "direct main" + description: + path: "packages/journal_lui_native" + relative: true + source: path + version: "0.0.1" json_annotation: dependency: transitive description: @@ -482,22 +515,23 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + lui_flutter_backend: + dependency: "direct main" + description: + path: "platform/flutter" + ref: c4468ffdbb0e68319b90306933db7edb066b778b + resolved-ref: c4468ffdbb0e68319b90306933db7edb066b778b + url: "https://github.com/logseq/lui.git" + source: git + version: "0.1.0" matcher: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" - material_3_expressive: - dependency: transitive - description: - name: material_3_expressive - sha256: c29f289945ea90161b664dad4b3d2b412534ca61041e87b5f131824ea5461585 - url: "https://pub.dev" - source: hosted - version: "1.1.1" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -506,30 +540,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" - material_new_shapes: - dependency: transitive - description: - name: material_new_shapes - sha256: e4bc375205e187e8fb232573387112dd8c0dd45b03af8aa2b3c79eb4b9e3e0dc - url: "https://pub.dev" - source: hosted - version: "1.0.0" - material_ui: - dependency: "direct main" - description: - name: material_ui - sha256: "9c0156b0cf8b3f56d365c8ffe051a720be3cae9eec55119f01e01b986ffb7101" - url: "https://pub.dev" - source: hosted - version: "1.1.1" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: @@ -538,14 +556,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - motor: - dependency: transitive - description: - name: motor - sha256: cbd49f21b00e568c2b1a55f134ed803614a107782f4fea7769693bca32940c58 - url: "https://pub.dev" - source: hosted - version: "1.1.0" native_toolchain_c: dependency: transitive description: @@ -730,6 +740,62 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -827,10 +893,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" tuple: dependency: transitive description: @@ -848,7 +914,7 @@ packages: source: hosted version: "1.4.0" url_launcher: - dependency: transitive + dependency: "direct main" description: name: url_launcher sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 @@ -923,10 +989,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.3" vm_service: dependency: transitive description: @@ -967,6 +1033,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + windows_file_picker: + dependency: transitive + description: + name: windows_file_picker + sha256: "62e6e6e115231d1d1d71c7b5883b4091ce3e0190e49548dfb0ff8cba8d91ad96" + url: "https://pub.dev" + source: hosted + version: "1.3.0" worker_bee: dependency: transitive description: diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index ab3cfd9..f99f5e8 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -1,4 +1,4 @@ -name: bonsai_flutter_logseq_journal_host +name: logseq_journal_host description: Mechanical Flutter host for the OCaml-owned logseq_journal application. publish_to: none version: 0.1.0 @@ -9,27 +9,34 @@ environment: hooks: user_defines: - bonsai_flutter_native: - # bonsai-flutter:begin native-hook - native_artifact_root: ../_build/bonsai-flutter/artifacts/ + journal_lui_native: + # journal-lui:begin native-hook + native_artifact_root: ../_build/lui/artifacts/ macos_deployment_target: '26.0' ios_deployment_target: '15.0' require_ocaml_backend: true link_system_sqlite3: true - # bonsai-flutter:end native-hook + # journal-lui:end native-hook dependencies: - material_ui: 1.1.1 amplify_auth_cognito: 2.15.0 amplify_authenticator: 2.7.0 amplify_flutter: 2.15.0 - # bonsai-flutter:begin packages - bonsai_flutter: - path: ../.bonsai-flutter/flutter-packages/bonsai_flutter - # bonsai-flutter:end packages + file_picker: 12.3.0 + url_launcher: 6.3.2 + shared_preferences: 2.5.5 flutter: sdk: flutter flutter_slidable: 4.0.3 + journal_lui_native: + path: packages/journal_lui_native + # journal-lui:begin packages + lui_flutter_backend: + git: + url: https://github.com/logseq/lui.git + ref: c4468ffdbb0e68319b90306933db7edb066b778b + path: platform/flutter + # journal-lui:end packages dev_dependencies: flutter_lints: ^6.0.0 diff --git a/flutter/test/application_host_adapter_test.dart b/flutter/test/application_host_adapter_test.dart index d11fd6b..4282b30 100644 --- a/flutter/test/application_host_adapter_test.dart +++ b/flutter/test/application_host_adapter_test.dart @@ -3,9 +3,8 @@ import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'package:bonsai_flutter_logseq_journal_host/application_host_adapter.dart'; -import 'package:bonsai_flutter_logseq_journal_host/main.dart'; -import 'package:bonsai_flutter/bonsai_flutter.dart'; +import 'package:logseq_journal_host/application_host_adapter.dart'; +import 'package:logseq_journal_host/main.dart'; import 'package:amplify_authenticator/amplify_authenticator.dart'; import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:flutter/services.dart'; @@ -80,7 +79,7 @@ ApplicationHostAdapter _localBindingAdapter({ amplifyReady: amplifyReady, ); -final class _PendingHostAdapter implements BonsaiFlutterHostAdapter { +final class _PendingHostAdapter implements JournalHostAdapter { final Completer payload = Completer(); int payloadRequests = 0; @@ -91,7 +90,7 @@ final class _PendingHostAdapter implements BonsaiFlutterHostAdapter { } @override - BonsaiFlutterApplicationPlatform? createApplicationPlatform() => null; + JournalApplicationPlatform? createApplicationPlatform() => null; @override Widget buildHost({required BuildContext context, required Widget child}) => @@ -108,9 +107,9 @@ Uint8List request(JournalPlatformTag tag, [Object? payload]) => ), ); -Map responseJson(Uint8List response) => +Map responseJson(Uint8List? response) => jsonDecode( - utf8.decode(JournalPlatformEnvelopeCodec.decode(response).payload), + utf8.decode(JournalPlatformEnvelopeCodec.decode(response!).payload), ) as Map; @@ -254,8 +253,7 @@ void main() { clears += 1; }, ); - final platform = - adapter.createApplicationPlatform() as JournalApplicationPlatform; + final platform = adapter.createApplicationPlatform(); addTearDown(platform.dispose); await tester.pumpWidget( @@ -298,8 +296,7 @@ void main() { clears += 1; }, ); - final platform = - adapter.createApplicationPlatform() as JournalApplicationPlatform; + final platform = adapter.createApplicationPlatform(); addTearDown(platform.dispose); await tester.pumpWidget( @@ -381,16 +378,16 @@ void main() { ), ); - final adapter = createBonsaiFlutterHostAdapter( + final adapter = createJournalHostAdapter( baseUrl: Uri.parse('https://api.example.test'), ); final platform = adapter.createApplicationPlatform(); - addTearDown((platform as JournalApplicationPlatform).dispose); + addTearDown(platform.dispose); final loaded = await platform.handleRequest( rawJsonRequest(16, {'key': 'typographyPreset'}), ); - expect(ByteData.sublistView(loaded).getUint16(6, Endian.little), 17); + expect(ByteData.sublistView(loaded!).getUint16(6, Endian.little), 17); expect(responseJson(loaded), { 'key': 'typographyPreset', 'value': 'dense', @@ -399,7 +396,7 @@ void main() { final stored = await platform.handleRequest( rawJsonRequest(18, {'key': 'typographyPreset', 'value': 'comfortable'}), ); - expect(ByteData.sublistView(stored).getUint16(6, Endian.little), 19); + expect(ByteData.sublistView(stored!).getUint16(6, Endian.little), 19); expect(responseJson(stored), {'key': 'typographyPreset', 'stored': true}); expect(calls.map((call) => call.method), [ 'getStartupEnvironment', @@ -430,7 +427,7 @@ void main() { final response = await platform.handleRequest(rawRequest(13)); expect( - JournalPlatformEnvelopeCodec.decode(response).tag, + JournalPlatformEnvelopeCodec.decode(response!).tag, JournalPlatformTag.terminationReadyResponse, ); expect(responseJson(response), {'ready': true}); @@ -444,7 +441,7 @@ void main() { expect(source, contains("Uri.parse('https://api.logseq.io')")); expect(source, isNot(contains('LOGSEQ_SYNC_BASE_URL'))); - final injected = createBonsaiFlutterHostAdapter( + final injected = createJournalHostAdapter( baseUrl: Uri.parse('https://api.example.test'), ); expect(injected.baseUrl.toString(), 'https://api.example.test'); @@ -464,9 +461,6 @@ void main() { source, isNot(contains("child: MaterialApp(title: 'Logseq Journal'")), ); - final project = File('../bonsai-flutter.sexp').readAsStringSync(); - expect(project, contains('(mode custom)')); - expect(project, contains('(main lib/main.dart)')); expect(File('lib/application.dart').existsSync(), isFalse); }); @@ -645,12 +639,11 @@ void main() { ), ); - final adapter = createBonsaiFlutterHostAdapter( + final adapter = createJournalHostAdapter( baseUrl: Uri.parse('https://api.example.test'), ); await adapter.createApplicationPayload(); - final platform = - adapter.createApplicationPlatform() as JournalApplicationPlatform; + final platform = adapter.createApplicationPlatform(); addTearDown(platform.dispose); final typography = await platform.handleRequest( diff --git a/flutter/test/journal_detail_outline_test.dart b/flutter/test/journal_detail_outline_test.dart deleted file mode 100644 index 9b9ee05..0000000 --- a/flutter/test/journal_detail_outline_test.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:material_ui/material_ui.dart'; -import '../lib/journal_detail_outline.dart'; -import '../lib/journal_widget_registry.dart'; - -void main() { - test('all application native widgets register without a kind collision', () { - expect(createJournalWidgetRegistry, returnsNormally); - }); - Widget page(List keys, List actions, {bool rtl = false}) => - MaterialApp( - home: Directionality( - textDirection: rtl ? TextDirection.rtl : TextDirection.ltr, - child: JournalDetailOutline( - props: JournalDetailProps( - keys: keys, - actions: const {}, - firstIndex: 0, - revealId: '', - ), - header: Row( - children: [ - IconButton( - tooltip: 'Back', - onPressed: () => actions.add('back'), - icon: const Icon(Icons.chevron_left), - ), - const Text('Block'), - ], - ), - composer: const SizedBox(key: ValueKey('append-slot')), - notice: const SizedBox.shrink(), - items: keys - .take(40) - .map( - (id) => Padding( - padding: const EdgeInsets.all(12), - child: Text('$id\nSecond line'), - ), - ) - .toList(), - onAction: actions.add, - ), - ), - ); - - testWidgets('bounded supplied rows and physical left header under RTL', ( - tester, - ) async { - final actions = []; - await tester.pumpWidget( - page(List.generate(1000, (i) => 'block-$i'), actions, rtl: true), - ); - await tester.pumpAndSettle(); - expect(find.text('block-1\nSecond line'), findsOneWidget); - expect(tester.getCenter(find.byTooltip('Back')).dx, lessThan(70)); - expect(find.byKey(const ValueKey('append-slot')), findsOneWidget); - expect(find.textContaining('Second line').evaluate().length, lessThan(40)); - expect( - actions.any((action) => action.startsWith('detail-visible:')), - isTrue, - ); - }); - - testWidgets('content taps emit no navigation and Back remains operable', ( - tester, - ) async { - final actions = []; - await tester.pumpWidget( - page(List.generate(10, (i) => 'block-$i'), actions), - ); - await tester.pumpAndSettle(); - actions.clear(); - await tester.tap(find.text('block-1\nSecond line')); - expect(actions, isEmpty); - await tester.tap(find.byTooltip('Back')); - expect(actions, ['back']); - }); - - testWidgets('insertion above the viewport preserves a stable row anchor', ( - tester, - ) async { - final actions = []; - final keys = List.generate(40, (i) => 'block-$i'); - await tester.pumpWidget(page(keys, actions)); - await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, -400)); - await tester.pumpAndSettle(); - final before = tester.getTopLeft(find.text('block-10\nSecond line')).dy; - await tester.pumpWidget(page(['inserted', ...keys], actions)); - await tester.pumpAndSettle(); - expect( - tester.getTopLeft(find.text('block-10\nSecond line')).dy, - closeTo(before, 1), - ); - }); -} diff --git a/flutter/test/journal_header_layout_test.dart b/flutter/test/journal_header_layout_test.dart deleted file mode 100644 index 34c99f2..0000000 --- a/flutter/test/journal_header_layout_test.dart +++ /dev/null @@ -1,786 +0,0 @@ -import 'dart:io'; -import 'dart:ui' show Tristate, ImageByteFormat, SemanticsAction; - -import 'package:bonsai_flutter/bonsai_flutter.dart'; -import 'package:bonsai_flutter_logseq_journal_host/journal_widget_registry.dart'; -// ignore: depend_on_referenced_packages -import 'package:material_ui/material_ui.dart'; -// ignore: depend_on_referenced_packages -import 'package:material_3_expressive/material_3_expressive.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - late Directory frames; - favoritesVisualTests(() => frames); - retainedApplicationHeaderTests(() => frames); - setUpAll(() async { - var directory = File(Platform.resolvedExecutable).parent; - while (!Directory( - '${directory.path}/bin/cache/artifacts/material_fonts', - ).existsSync()) { - if (directory.parent.path == directory.path) { - throw StateError('Flutter material fonts are unavailable'); - } - directory = directory.parent; - } - for (final font in { - 'Roboto': 'Roboto-Regular.ttf', - 'MaterialIcons': 'MaterialIcons-Regular.otf', - }.entries) { - await (FontLoader(font.key)..addFont( - Future.value( - ByteData.sublistView( - File( - '${directory.path}/bin/cache/artifacts/material_fonts/${font.value}', - ).readAsBytesSync(), - ), - ), - )) - .load(); - } - frames = await Directory.systemTemp.createTemp('journal-header-frames-'); - final result = await Process.run( - 'dune', - ['exec', 'test/journal_semantics_test.exe'], - workingDirectory: '..', - environment: { - 'JOURNAL_HEADER_FRAME_DIR': frames.path, - 'JOURNAL_FAVORITES_FRAME_DIR': frames.path, - }, - ); - final rootResult = await Process.run( - 'python3', - ['tool/test_macos_regressions.py', '--case', 'application_dispatch'], - workingDirectory: '..', - environment: {'JOURNAL_ROOT_FRAME_DIR': frames.path}, - ); - expect( - rootResult.exitCode, - 0, - reason: '${rootResult.stdout}\n${rootResult.stderr}', - ); - expect(result.exitCode, 0, reason: '${result.stdout}\n${result.stderr}'); - }); - tearDownAll(() => frames.delete(recursive: true)); - - for (final brightness in Brightness.values) { - for (final highContrast in [false, true]) { - for (final layout in [ - (width: 390.0, scale: 1.0, direction: TextDirection.ltr), - (width: 320.0, scale: 1.0, direction: TextDirection.ltr), - (width: 320.0, scale: 3.2, direction: TextDirection.ltr), - (width: 320.0, scale: 3.2, direction: TextDirection.rtl), - (width: 390.0, scale: 3.2, direction: TextDirection.ltr), - (width: 720.0, scale: 3.2, direction: TextDirection.rtl), - ]) { - for (final withError in [false, true]) { - testWidgets('OCaml header edge $brightness contrast=$highContrast ' - '$layout error=$withError', (tester) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = Size(layout.width, 600); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - final store = NodeStore(); - final events = []; - final scheme = ColorScheme.fromSeed( - seedColor: const Color(0xff00262f), - brightness: brightness, - contrastLevel: highContrast ? 1 : 0, - ); - final scope = withError ? 'error' : 'account'; - Future applyPhase(int phase) async { - store.apply( - FrameCodec.decode( - File( - '${frames.path}/header-${layout.width.toInt()}-${layout.scale.toString().replaceAll(RegExp(r'\.0$'), '')}-$highContrast-$scope-$phase.bin', - ).readAsBytesSync(), - ), - ); - await tester.pump(); - } - - await applyPhase(0); - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(colorScheme: scheme, useMaterial3: true), - home: MediaQuery( - data: MediaQueryData( - size: Size(layout.width, 600), - padding: const EdgeInsets.only(top: 47), - textScaler: TextScaler.linear(layout.scale), - highContrast: highContrast, - ), - child: Directionality( - textDirection: layout.direction, - child: RepaintBoundary( - key: const ValueKey('header-screen'), - child: Scaffold( - body: BonsaiFlutterView( - store: store, - registry: createJournalWidgetRegistry(), - onEvent: events.add, - ), - ), - ), - ), - ), - ), - ); - await tester.pump(); - final scrollable = tester.state( - find.byType(Scrollable).first, - ); - final position = scrollable.position; - final maxScrollExtent = position.maxScrollExtent; - final anchorPositions = {}; - Uint8List? inactivePixels; - Rect? initialTitle; - Rect? initialAccount; - for (var phase = 0; phase < 5; phase++) { - final beforeOffset = position.pixels; - if (phase > 0) await applyPhase(phase); - expect(scrollable.position, same(position)); - expect(position.pixels, beforeOffset); - expect(position.maxScrollExtent, maxScrollExtent); - final connecting = phase == 1 || phase == 3; - for (final offset in [0.0, 80.0, 1200.0]) { - position.jumpTo(offset); - await tester.pump(); - final appBarFinder = find.byType(SliverAppBar); - final appBar = tester.widget(appBarFinder); - expect(appBar.pinned, isTrue); - expect(appBar.floating, isFalse); - expect(appBar.snap, isFalse); - expect(appBar.centerTitle, isTrue); - expect(appBar.actions, hasLength(withError ? 2 : 1)); - expect(appBar.flexibleSpace, isNull); - expect(appBar.backgroundColor, isNull); - expect(appBar.foregroundColor, isNull); - expect( - tester - .widgetList(find.byType(M3ETooltip)) - .map((tooltip) => tooltip.message), - unorderedEquals([ - "Account menu", - if (withError) "Error info", - ]), - ); - final toolbar = tester.renderObject(appBarFinder); - final headerBottom = appBar.toolbarHeight + 55; - expect( - toolbar.geometry!.paintExtent, - closeTo(headerBottom, 0.01), - ); - final title = find.text('2026.08.09'); - expect(title, findsOneWidget); - expect(find.text('SUN'), findsOneWidget); - final weekdayRect = tester.getRect(find.text('SUN')); - expect( - weekdayRect.center.dy, - closeTo(tester.getRect(title).center.dy, 0.1), - ); - expect(find.textContaining('Today'), findsNothing); - expect(tester.widget(title).maxLines, 1); - final titleRect = tester.getRect(title); - final account = find.bySemanticsLabel('Account menu'); - final accountRect = tester.getRect(account); - initialTitle ??= titleRect; - initialAccount ??= accountRect; - expect(titleRect, initialTitle); - expect(accountRect, initialAccount); - expect(titleRect.overlaps(accountRect), isFalse); - expect(accountRect.width, greaterThanOrEqualTo(44)); - expect(accountRect.height, greaterThanOrEqualTo(44)); - if (withError) { - final errorRect = tester.getRect( - find.bySemanticsLabel('Error info'), - ); - expect(titleRect.overlaps(errorRect), isFalse); - expect(errorRect.overlaps(accountRect), isFalse); - expect(errorRect.width, greaterThanOrEqualTo(44)); - expect(errorRect.height, greaterThanOrEqualTo(44)); - } - if (layout.width == 390 && layout.scale == 1) { - expect( - titleRect.expandToInclude(weekdayRect).center.dx, - closeTo(layout.width / 2, 0.5), - ); - } - final spoken = tester.getSemantics(title).getSemanticsData(); - expect(spoken.label, contains('2026.08.09')); - expect(spoken.label, contains('SUN')); - expect('2026.08.09'.allMatches(spoken.label), hasLength(1)); - expect('SUN'.allMatches(spoken.label), hasLength(1)); - expect(spoken.label, isNot(contains('Today'))); - expect(spoken.hasAction(SemanticsAction.tap), isFalse); - expect(spoken.flagsCollection.isFocused, Tristate.none); - final indicator = find.byType(M3EProgressIndicator); - expect(indicator, connecting ? findsOneWidget : findsNothing); - if (connecting) { - final progress = tester.widget( - indicator, - ); - expect(progress.value, isNull); - final rect = tester.getRect(indicator); - expect(rect.left, 0); - expect(rect.right, layout.width); - expect(rect.top, closeTo(headerBottom, 0.01)); - expect(rect.height, 2); - expect(rect.top, greaterThan(titleRect.bottom)); - expect( - find.ancestor(of: indicator, matching: find.byType(AppBar)), - findsNothing, - ); - } - final anchorY = tester - .getTopLeft( - find.text('Timeline anchor', skipOffstage: false), - ) - .dy; - anchorPositions.putIfAbsent(offset, () => anchorY); - expect(anchorY, anchorPositions[offset]); - if (offset == 0) { - expect(anchorY, closeTo(headerBottom + 2, 0.01)); - } - if (layout.width == 390 && - layout.scale == 1 && - !withError && - offset == 0 && - phase < 2) { - await tester.pump(const Duration(milliseconds: 500)); - final pixels = (await tester.runAsync(() async { - final boundary = tester.renderObject( - find.byKey(const ValueKey('header-screen')), - ); - final image = await boundary.toImage(); - final outputDirectory = - Platform.environment['JOURNAL_DATE_VISUAL_DIR']; - if (outputDirectory != null) { - final png = await image.toByteData( - format: ImageByteFormat.png, - ); - await File( - '$outputDirectory/header-${brightness.name}-$highContrast-$phase.png', - ).writeAsBytes(png!.buffer.asUint8List()); - } - final data = await image.toByteData(); - image.dispose(); - return data!.buffer.asUint8List(); - }))!; - if (phase == 0) { - inactivePixels = pixels; - } else { - final changedRows = {}; - for (var y = 100; y < 125; y++) { - for (var x = 0; x < 390; x++) { - final index = (y * 390 + x) * 4; - if (pixels[index] != inactivePixels![index] || - pixels[index + 1] != inactivePixels[index + 1] || - pixels[index + 2] != inactivePixels[index + 2]) { - changedRows.add(y); - } - } - } - expect( - changedRows, - {111, 112}, - reason: - 'progress must paint only inside its two-pixel bottom region', - ); - } - } - final visualDirectory = - Platform.environment['JOURNAL_DATE_VISUAL_DIR']; - if (visualDirectory != null && - withError && - phase == 1 && - offset == 0) { - await tester.runAsync(() async { - final boundary = tester.renderObject( - find.byKey(const ValueKey('header-screen')), - ); - final image = await boundary.toImage(); - final png = await image.toByteData( - format: ImageByteFormat.png, - ); - await File( - '$visualDirectory/actions-${brightness.name}-$highContrast-${layout.width.toInt()}-${layout.scale}-${layout.direction.name}.png', - ).writeAsBytes(png!.buffer.asUint8List()); - image.dispose(); - }); - } - expect(find.byType(Divider), findsNothing); - expect(tester.takeException(), isNull); - } - } - events.clear(); - await tester.tap(find.bySemanticsLabel('Account menu')); - expect(events, hasLength(1)); - expect(events.single.handlerId, isPositive); - if (withError) { - events.clear(); - await tester.tap(find.bySemanticsLabel('Error info')); - expect(events, hasLength(1)); - } - }); - } - } - } - } - for (final dark in [false, true]) { - for (final highContrast in [false, true]) { - for (final layout in [ - (390.0, 1.0, false), - (320.0, 3.2, true), - (390.0, 3.2, false), - (720.0, 3.2, true), - ]) { - final (width, scale, rtl) = layout; - final large = scale > 1; - for (final preset in ['dense', 'balanced', 'comfortable']) { - testWidgets( - 'date and rail preview dark=$dark contrast=$highContrast layout=$layout $preset', - (tester) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = Size(width, 844); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - final store = NodeStore(); - store.apply( - FrameCodec.decode( - File( - '${frames.path}/timeline-${width.toInt()}-${large ? '3.2' : '1'}-$highContrast-$dark-$rtl-$preset.bin', - ).readAsBytesSync(), - ), - ); - await tester.pumpWidget( - MaterialApp( - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xff00262f), - brightness: dark ? Brightness.dark : Brightness.light, - contrastLevel: highContrast ? 1 : 0, - ), - useMaterial3: true, - ), - home: MediaQuery( - data: MediaQueryData( - size: Size(width, 844), - padding: const EdgeInsets.only(top: 47), - textScaler: TextScaler.linear(scale), - highContrast: highContrast, - ), - child: Directionality( - textDirection: rtl - ? TextDirection.rtl - : TextDirection.ltr, - child: RepaintBoundary( - key: const ValueKey('date-preview'), - child: Scaffold( - body: BonsaiFlutterView( - store: store, - registry: createJournalWidgetRegistry(), - onEvent: (_) {}, - ), - ), - ), - ), - ), - ), - ); - await tester.pump(); - expect(find.text('2026.09.06'), findsOneWidget); - expect(find.text('SUN'), findsOneWidget); - final headerDate = tester.renderObject( - find.text('2026.09.06'), - ); - final historyDate = tester.renderObject( - find.text('2026.09.05'), - ); - final headerWeekday = tester.renderObject( - find.text('SUN'), - ); - final historyWeekday = tester.renderObject( - find.text('SAT'), - ); - double renderedSize(RenderParagraph paragraph) => - paragraph.textScaler.scale(paragraph.text.style!.fontSize!); - expect( - renderedSize(headerDate), - closeTo(renderedSize(historyDate), 0.01), - ); - expect( - renderedSize(headerWeekday), - closeTo(renderedSize(historyWeekday), 0.01), - ); - expect( - headerDate.text.style!.fontWeight, - historyDate.text.style!.fontWeight, - ); - expect( - headerWeekday.text.style!.fontWeight, - historyWeekday.text.style!.fontWeight, - ); - expect( - headerDate.text.style!.color, - historyDate.text.style!.color, - ); - expect( - headerDate.size.height, - closeTo(historyDate.size.height, 0.1), - ); - expect( - headerWeekday.size.height, - closeTo(historyWeekday.size.height, 0.1), - ); - for (final labels in [ - ('2026.09.06', 'SUN'), - ('2026.09.05', 'SAT'), - ]) { - final dateRect = tester.getRect(find.text(labels.$1)); - final weekdayRect = tester.getRect(find.text(labels.$2)); - final gap = rtl - ? dateRect.left - weekdayRect.right - : weekdayRect.left - dateRect.right; - expect(gap, closeTo(14, 0.1)); - final opacity = tester.widget( - find - .ancestor( - of: find.text(labels.$2), - matching: find.byType(Opacity), - ) - .first, - ); - expect(opacity.opacity, highContrast ? 0.85 : 0.50); - } - expect(headerDate.didExceedMaxLines, isFalse); - expect(historyDate.didExceedMaxLines, isFalse); - expect( - tester.getRect(find.text('SUN')).center.dy, - closeTo(tester.getRect(find.text('2026.09.06')).center.dy, 0.1), - ); - expect(tester.takeException(), isNull); - final scrollable = tester.state( - find.byType(Scrollable).first, - ); - for (final fraction in [0.0, 1.0]) { - scrollable.position.jumpTo( - scrollable.position.maxScrollExtent * fraction, - ); - await tester.pump(); - expect(tester.takeException(), isNull); - final outputDirectory = - Platform.environment['JOURNAL_DATE_VISUAL_DIR']; - if (outputDirectory != null) { - await tester.runAsync(() async { - final boundary = tester.renderObject( - find.byKey(const ValueKey('date-preview')), - ); - final image = await boundary.toImage(); - final data = await image.toByteData( - format: ImageByteFormat.png, - ); - image.dispose(); - await File( - '$outputDirectory/timeline-$dark-$highContrast-$large-${width.toInt()}-$preset-${fraction.toInt()}.png', - ).writeAsBytes(data!.buffer.asUint8List()); - }); - } - } - }, - ); - } - } - } - } -} - -void favoritesVisualTests(Directory Function() getFrames) { - for (final layout in [ - (390.0, 1.0, false), - (320.0, 1.0, false), - (320.0, 3.2, false), - (320.0, 3.2, true), - (720.0, 1.0, false), - ]) { - for (final dark in [false, true]) { - for (final contrast in [false, true]) { - testWidgets( - 'Favorites native layout $layout dark=$dark contrast=$contrast', - (tester) async { - final (width, scale, rtl) = layout; - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = Size(width, 700); - tester.view.viewPadding = const FakeViewPadding( - top: 47, - bottom: 34, - ); - tester.view.padding = const FakeViewPadding(top: 47, bottom: 34); - addTearDown(tester.view.resetViewPadding); - addTearDown(tester.view.resetPadding); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - final store = NodeStore(); - final scaleName = scale.toString().replaceAll(RegExp(r'\.0$'), ''); - store.apply( - FrameCodec.decode( - File( - '${getFrames().path}/favorites-${width.toInt()}-$scaleName-$rtl-$dark-$contrast.bin', - ).readAsBytesSync(), - ), - ); - final events = []; - await tester.pumpWidget( - MaterialApp( - theme: ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xff00262f), - brightness: dark ? Brightness.dark : Brightness.light, - contrastLevel: contrast ? 1 : 0, - ), - ), - home: MediaQuery( - data: MediaQueryData( - size: Size(width, 700), - padding: const EdgeInsets.only(top: 47, bottom: 34), - textScaler: TextScaler.linear(scale), - highContrast: contrast, - disableAnimations: true, - ), - child: Directionality( - textDirection: rtl ? TextDirection.rtl : TextDirection.ltr, - child: RepaintBoundary( - key: const ValueKey('favorites-screen'), - child: BonsaiFlutterView( - store: store, - registry: createJournalWidgetRegistry(), - onEvent: events.add, - ), - ), - ), - ), - ), - ); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 500)); - expect(tester.takeException(), isNull); - expect(find.text('Design notes'), findsOneWidget); - expect(find.text('Favorites'), findsNWidgets(2)); - expect(find.text('Journals'), findsOneWidget); - expect(find.byTooltip('Open Capture'), findsNothing); - expect(find.byType(TextField), findsNothing); - final source = tester.renderObject( - find.text('Design notes'), - ); - expect( - source.constraints.maxWidth, - greaterThanOrEqualTo(width.clamp(0, 720) - 80), - ); - final bar = tester.widget( - find.byType(NavigationBar), - ); - expect(bar.height, 44); - expect( - bar.labelBehavior, - NavigationDestinationLabelBehavior.alwaysHide, - ); - expect(find.text(String.fromCharCode(0xf495)), findsOneWidget); - for (final codePoint in [0xf495, 0xe5f9]) { - final glyph = find.text(String.fromCharCode(codePoint)); - expect(tester.getSize(glyph), const Size(24, 24)); - final paragraph = tester.renderObject(glyph); - final foreground = paragraph.text.style!.color! - .computeLuminance(); - final theme = Theme.of(tester.element(glyph)); - final background = - (codePoint == 0xe5f9 - ? (tester - .widgetList( - find.byType(NavigationIndicator), - ) - .last - .color ?? - theme.colorScheme.secondary) - : theme.colorScheme.surfaceContainer) - .computeLuminance(); - final ratio = foreground > background - ? (foreground + 0.05) / (background + 0.05) - : (background + 0.05) / (foreground + 0.05); - expect(ratio, greaterThanOrEqualTo(3)); - } - final navigation = tester.getRect(find.byType(NavigationBar)); - expect(navigation.bottom, lessThanOrEqualTo(700 - 34)); - final semantics = tester.ensureSemantics(); - expect( - tester - .getSemantics(find.text('Design notes')) - .getSemanticsData() - .hasAction(SemanticsAction.tap), - isFalse, - ); - events.clear(); - await tester.tap(find.text('Design notes')); - await tester.pump(); - expect( - events.where( - (event) => - event.eventTag != EventTagId.scrollNotification && - event.eventTag != EventTagId.visibleRangeChanged, - ), - isEmpty, - reason: events - .map( - (event) => "${event.eventTag}:${event.payload.runtimeType}", - ) - .join(","), - ); - final position = tester - .state(find.byType(Scrollable).first) - .position; - final before = position.pixels; - await tester.drag(find.text('Design notes'), const Offset(-160, 0)); - await tester.pump(); - expect(position.pixels, before); - expect(tester.takeException(), isNull); - semantics.dispose(); - events.clear(); - await tester.tap(find.text(String.fromCharCode(0xf495))); - await tester.pump(); - final selections = events - .where( - (event) => - event.eventTag == - EventTagId.navigationDestinationSelected, - ) - .toList(); - expect(selections, hasLength(1)); - expect((selections.single.payload as Int64EventPayload).value, 0); - final output = Platform.environment['JOURNAL_FAVORITES_VISUAL_DIR']; - if (output != null) { - await tester.runAsync(() async { - final boundary = tester.renderObject( - find.byKey(const ValueKey('favorites-screen')), - ); - final image = await boundary.toImage(); - final png = await image.toByteData(format: ImageByteFormat.png); - await File( - '$output/favorites-${width.toInt()}-$scaleName-$rtl-$dark-$contrast.png', - ).writeAsBytes(png!.buffer.asUint8List()); - image.dispose(); - }); - } - }, - ); - } - } - } -} - -void retainedApplicationHeaderTests(Directory Function() getFrames) { - testWidgets('Consecutive application destinations retain header at the top', ( - tester, - ) async { - final store = NodeStore(); - final files = - getFrames() - .listSync() - .whereType() - .where((file) => RegExp(r'/[0-9]{4}-').hasMatch(file.path)) - .toList() - ..sort((a, b) => a.path.compareTo(b.path)); - expect(files, isNotEmpty); - State? header; - Element? account; - Element? progress; - ScrollPosition? position; - final observations = []; - await tester.pumpWidget( - MaterialApp( - home: RepaintBoundary( - key: const ValueKey('retained-header-screen'), - child: BonsaiFlutterView( - store: store, - registry: createJournalWidgetRegistry(), - onEvent: (_) {}, - ), - ), - ), - ); - for (final file in files) { - store.apply(FrameCodec.decode(file.readAsBytesSync())); - await tester.pump(); - if (find.byType(SliverAppBar).evaluate().isEmpty) continue; - if (file.path.contains('-startup')) { - await tester.pump(const Duration(milliseconds: 300)); - continue; - } - final currentHeader = tester.state(find.byType(SliverAppBar)); - final currentAccount = tester.element( - find - .descendant( - of: find.byType(SliverAppBar), - matching: find.byType(IconButton), - ) - .last, - ); - final currentPosition = tester - .state(find.byType(Scrollable).first) - .position; - header ??= currentHeader; - account ??= currentAccount; - position ??= currentPosition; - expect(currentHeader, same(header), reason: file.path); - expect(currentAccount, same(account), reason: file.path); - expect(currentPosition, same(position), reason: file.path); - expect(currentPosition.pixels, 0); - if (file.path.contains('-sync')) { - final currentProgress = tester.element( - find.byType(M3EProgressIndicator), - ); - progress ??= currentProgress; - expect(currentProgress, same(progress)); - } - final favorites = file.path.contains('-favorites'); - expect( - find.descendant( - of: find.byType(SliverAppBar), - matching: find.text('Favorites'), - ), - favorites ? findsOneWidget : findsNothing, - ); - Future> headerPixels() async { - return (await tester.runAsync(() async { - final boundary = tester.renderObject( - find.byKey(const ValueKey('retained-header-screen')), - ); - final image = await boundary.toImage(); - final bytes = await image.toByteData(format: ImageByteFormat.rawRgba); - final rows = bytes!.buffer - .asUint8List() - .take(image.width * 56 * 4) - .toList(); - image.dispose(); - return rows; - }))!; - } - - final firstPaint = await headerPixels(); - for (final elapsed in [16, 16, 168]) { - await tester.pump(Duration(milliseconds: elapsed)); - expect( - await headerPixels(), - firstPaint, - reason: 'Header changed after its first painted frame: ${file.path}', - ); - } - observations.add(file.path); - expect(tester.takeException(), isNull); - } - expect(observations.any((path) => path.contains('-favorites')), isTrue); - expect(observations.any((path) => path.contains('-returned')), isTrue); - expect(progress, isNotNull); - }); -} diff --git a/flutter/test/journal_root_navigation_test.dart b/flutter/test/journal_root_navigation_test.dart deleted file mode 100644 index f1b623e..0000000 --- a/flutter/test/journal_root_navigation_test.dart +++ /dev/null @@ -1,921 +0,0 @@ -import 'package:bonsai_flutter_logseq_journal_host/journal_widget_registry.dart'; -import 'package:bonsai_flutter/bonsai_flutter.dart'; -import 'dart:ui' show SemanticsAction, Tristate; -import 'package:flutter/gestures.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; -import 'package:material_ui/material_ui.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:bonsai_flutter_logseq_journal_host/journal_root_navigation.dart'; - -Widget captureWidget({Key? key, ValueChanged? onChanged}) => - ExpandableMessageComposer( - key: key, - fabPresentation: ExpandableMessageComposerFabPresentation.extended, - fabLabel: 'Capture', - fabTooltip: 'Open Capture', - fabIcon: const Icon(Icons.add), - buttons: const [], - animationDuration: Duration.zero, - onChanged: onChanged, - ); - -void main() { - testWidgets('Application registry renders Capture native kind 7', ( - tester, - ) async { - final resources = RendererResourceStore(); - addTearDown(resources.dispose); - final registry = createJournalWidgetRegistry(); - final node = UiNode( - id: 1, - kind: NodeKind.nativeWidget, - props: NativeWidgetProps( - kindId: NativeWidgetKind.expandableMessageComposer, - version: 2, - capabilityBits: NativeCapability.stateful | NativeCapability.semantics, - payload: const ExpandableMessageComposerProps( - enabled: true, - fabPresentation: ExpandableMessageComposerFabPresentation.extended, - fabLabel: 'Capture', - fabTooltip: 'Open Capture', - animationDurationMilliseconds: 0, - animationCurve: AnimationCurveValue.easeOut, - maxLines: 5, - hintText: 'Capture a thought', - buttons: [], - ).encode(), - ), - eventBindings: const [], - parentData: const NoParentData(), - children: const [2], - localRevision: 1, - deliveryGeneration: 1, - ); - await tester.pumpWidget( - MaterialApp( - home: RendererResourceScope( - resources: resources, - child: Scaffold( - floatingActionButton: Builder( - builder: (context) => - registry.build(context, node, [const Icon(Icons.add)], null), - ), - ), - ), - ), - ); - expect(find.byType(UnsupportedNativeWidget), findsNothing); - expect(find.byTooltip('Open Capture'), findsOneWidget); - await tester.tap(find.byTooltip('Open Capture')); - await tester.pumpAndSettle(); - expect(find.byType(TextField), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - for (final brightness in Brightness.values) { - for (final checkSurface in [false, true]) { - testWidgets( - 'Capture sheet ${checkSurface ? "surface" : "FAB visibility"} $brightness', - (tester) async { - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(brightness: brightness), - home: Scaffold(floatingActionButton: captureWidget()), - ), - ); - await tester.tap(find.byTooltip('Open Capture')); - await tester.pumpAndSettle(); - if (checkSurface) { - final editorMaterial = tester - .widgetList( - find.ancestor( - of: find.byType(TextField), - matching: find.byType(Material), - ), - ) - .firstWhere( - (material) => material.type != MaterialType.transparency, - ); - final sheet = tester.widget(find.byType(BottomSheet)); - final sheetContext = tester.element(find.byType(BottomSheet)); - final sheetColor = - sheet.backgroundColor ?? - Theme.of(sheetContext).bottomSheetTheme.backgroundColor ?? - Theme.of(sheetContext).colorScheme.surfaceContainerLow; - expect(editorMaterial.color, sheetColor); - } else { - expect(find.byTooltip('Open Capture'), findsNothing); - } - }, - ); - } - } - - compactNavigationTests(); - retainedRootScrollTests(); - for (final enlarged in [false, true]) { - testWidgets( - 'Capture modal dismissal, destination removal, and save reset enlarged=$enlarged', - (tester) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = Size(enlarged ? 320 : 390, 900); - tester.view.viewPadding = const FakeViewPadding(top: 47, bottom: 34); - tester.view.padding = const FakeViewPadding(top: 47, bottom: 34); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetViewPadding); - addTearDown(tester.view.resetPadding); - addTearDown(tester.view.resetViewInsets); - var active = true; - var visible = true; - var draft = ''; - var captureKey = 0; - late StateSetter update; - Widget app() => MaterialApp( - theme: ThemeData( - brightness: enlarged ? Brightness.dark : Brightness.light, - ), - builder: (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear(enlarged ? 3.2 : 1), - highContrast: enlarged, - disableAnimations: true, - ), - child: Directionality( - textDirection: enlarged ? TextDirection.rtl : TextDirection.ltr, - child: child!, - ), - ), - home: StatefulBuilder( - builder: (context, setState) { - update = setState; - return JournalRootScroll( - destination: active ? 0 : 1, - favoritesRevision: 0, - favoritesAnchorOffset: 0, - navigationVisible: visible, - duration: Duration.zero, - active: true, - onScroll: (_, _, _) {}, - onNonScrollable: (_) {}, - child: Scaffold( - body: const Text('Root'), - bottomNavigationBar: JournalNavigationBar( - selectedIndex: active ? 0 : 1, - onDestinationSelected: (index) => - setState(() => active = index == 0), - destinations: const [ - NavigationDestination( - icon: Icon(Icons.view_day_outlined), - label: 'Journals', - ), - NavigationDestination( - icon: Icon(Icons.star), - label: 'Favorites', - ), - ], - ), - floatingActionButton: active - ? captureWidget( - key: ValueKey(captureKey), - onChanged: (value) => draft = value, - ) - : null, - ), - ); - }, - ), - ); - await tester.pumpWidget(app()); - await tester.tap(find.byTooltip('Open Capture')); - await tester.pumpAndSettle(); - tester.view.viewInsets = const FakeViewPadding(bottom: 300); - tester.view.padding = const FakeViewPadding(top: 47); - await tester.pumpAndSettle(); - expect( - tester.getRect(find.byType(TextField)).bottom, - lessThanOrEqualTo(600), - ); - expect(tester.takeException(), isNull); - expect( - tester.widget(find.byType(TextField)).controller!.text, - isEmpty, - ); - await tester.enterText(find.byType(TextField), 'Edited draft'); - update(() => visible = false); - await tester.pumpAndSettle(); - expect(tester.getSize(find.byType(JournalNavigationBar)).height, 34); - expect( - tester.getRect(find.byType(TextField)).bottom, - lessThanOrEqualTo(600), - ); - update(() => visible = true); - await tester.pumpAndSettle(); - Navigator.of(tester.element(find.byType(TextField))).pop(); - tester.view.viewInsets = const FakeViewPadding(); - await tester.pumpAndSettle(); - expect(find.byTooltip('Open Capture'), findsOneWidget); - await tester.tap(destinationTooltip('Favorites')); - await tester.pumpAndSettle(); - expect(find.byType(TextField), findsNothing); - expect(find.byTooltip('Open Capture'), findsNothing); - expect(draft, 'Edited draft'); - update(() => visible = false); - tester.view.viewInsets = const FakeViewPadding(); - tester.view.padding = const FakeViewPadding(top: 47, bottom: 34); - await tester.pumpAndSettle(); - expect(find.byTooltip('Open Capture'), findsNothing); - expect(tester.getSize(find.byType(JournalNavigationBar)).height, 34); - update(() => active = true); - await tester.pumpAndSettle(); - expect(find.byType(TextField), findsNothing); - await tester.tap(find.byTooltip('Open Capture')); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byType(TextField)).controller!.text, - isEmpty, - ); - await tester.enterText(find.byType(TextField), 'Saved draft'); - update(() => captureKey++); - await tester.pumpAndSettle(); - expect(find.byType(TextField), findsNothing); - await tester.tap(find.byTooltip('Open Capture')); - await tester.pumpAndSettle(); - expect( - tester.widget(find.byType(TextField)).controller!.text, - isEmpty, - ); - expect(tester.takeException(), isNull); - }, - ); - } - testWidgets( - 'Root retains independent scroll offsets and applies refreshed anchor delta', - (tester) async { - var destination = 0; - var revision = 1; - var anchorOffset = 0.0; - late StateSetter update; - await tester.pumpWidget( - MaterialApp( - home: StatefulBuilder( - builder: (context, setState) { - update = setState; - return JournalRootScroll( - navigationVisible: true, - duration: Duration.zero, - active: true, - onScroll: (_, _, _) {}, - onNonScrollable: (_) {}, - destination: destination, - favoritesRevision: revision, - favoritesAnchorOffset: anchorOffset, - child: Builder( - builder: (context) => Scaffold( - body: ListView.builder( - key: const ValueKey("root-scroll"), - controller: PrimaryScrollController.of(context), - itemExtent: 60, - itemCount: 100, - itemBuilder: (_, i) => Text('Row $i'), - ), - ), - ), - ); - }, - ), - ), - ); - await tester.drag(find.byType(ListView), const Offset(0, -600)); - await tester.pumpAndSettle(); - double offset() => tester - .state(find.byType(Scrollable)) - .position - .pixels; - final journals = offset(); - update(() => destination = 1); - await tester.pumpAndSettle(); - expect(offset(), 0); - await tester.drag(find.byType(ListView), const Offset(0, -300)); - await tester.pumpAndSettle(); - final favorites = offset(); - update(() => destination = 0); - await tester.pumpAndSettle(); - expect(offset(), closeTo(journals, 1)); - update(() => destination = 1); - await tester.pumpAndSettle(); - expect(offset(), closeTo(favorites, 1)); - update(() { - revision++; - anchorOffset = 120; - }); - await tester.pumpAndSettle(); - expect(offset(), closeTo(favorites + 120, 1)); - expect(tester.takeException(), isNull); - }, - ); -} - -Finder destinationTooltip(String label) => find.byWidgetPredicate( - (widget) => widget is Tooltip && widget.message == label, -); - -void compactNavigationTests() { - for (final wide in [false, true]) { - for (final dark in [false, true]) { - for (final inset in [0.0, 34.0]) { - testWidgets( - 'Compact navigation geometry and access wide=$wide dark=$dark inset=$inset', - (tester) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = Size(wide ? 900 : 320, 900); - tester.view.viewPadding = FakeViewPadding(bottom: inset); - tester.view.padding = FakeViewPadding(bottom: inset); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetViewPadding); - addTearDown(tester.view.resetPadding); - var visible = true; - var selected = 0; - late StateSetter update; - final semantics = tester.ensureSemantics(); - await tester.pumpWidget( - MaterialApp( - theme: ThemeData( - colorScheme: ColorScheme.fromSeed( - seedColor: const Color(0xff00262f), - brightness: dark ? Brightness.dark : Brightness.light, - contrastLevel: 1, - ), - ), - home: StatefulBuilder( - builder: (context, setState) { - update = setState; - return MediaQuery( - data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear(3.2), - highContrast: true, - disableAnimations: wide, - ), - child: Directionality( - textDirection: wide - ? TextDirection.rtl - : TextDirection.ltr, - child: JournalRootScroll( - destination: selected, - favoritesRevision: 0, - favoritesAnchorOffset: 0, - navigationVisible: visible, - duration: const Duration(milliseconds: 200), - active: true, - onScroll: (_, _, _) {}, - onNonScrollable: (_) {}, - child: Scaffold( - body: const SizedBox.expand(key: ValueKey('body')), - bottomNavigationBar: JournalNavigationBar( - selectedIndex: selected, - onDestinationSelected: (index) => - update(() => selected = index), - destinations: const [ - NavigationDestination( - icon: Icon(Icons.view_day_outlined, size: 24), - label: 'Journals', - ), - NavigationDestination( - icon: Icon(Icons.star, size: 24), - label: 'Favorites', - ), - ], - ), - ), - ), - ), - ); - }, - ), - ), - ); - await tester.pumpAndSettle(); - final bar = find.byType(NavigationBar); - expect( - tester.getSize(find.byType(JournalNavigationBar)).height, - 44 + inset, - ); - expect( - tester.widget(bar).labelBehavior, - NavigationDestinationLabelBehavior.alwaysHide, - ); - for (final label in ['Journals', 'Favorites']) { - final target = destinationTooltip(label); - final bounds = tester.getRect(target); - expect(bounds.height, greaterThanOrEqualTo(44)); - expect(bounds.width, (wide ? 900 : 320) / 2); - final data = tester.getSemantics(target).getSemanticsData(); - expect(data.label, contains(label)); - expect(data.hasAction(SemanticsAction.tap), isTrue); - } - final tooltip = tester.widget( - destinationTooltip('Favorites'), - ); - expect(tooltip.message, 'Favorites'); - expect( - MediaQuery.textScalerOf( - tester.element(destinationTooltip('Favorites')), - ).scale(10), - 32, - ); - final indicator = tester.getRect( - find.byType(NavigationIndicator).first, - ); - final bounds = tester.getRect(bar); - expect(indicator.top, greaterThanOrEqualTo(bounds.top)); - expect(indicator.bottom, lessThanOrEqualTo(bounds.bottom)); - final selectedData = tester - .getSemantics(destinationTooltip('Journals')) - .getSemanticsData(); - expect(selectedData.flagsCollection.isSelected, Tristate.isTrue); - final unselectedData = tester - .getSemantics(destinationTooltip('Favorites')) - .getSemanticsData(); - expect(unselectedData.flagsCollection.isSelected, Tristate.isFalse); - for (final glyph in [Icons.view_day_outlined, Icons.star]) { - final icon = find.byIcon(glyph); - expect(tester.getSize(icon), const Size(24, 24)); - final foreground = - (tester.widget(icon).color ?? - IconTheme.of(tester.element(icon)).color)! - .computeLuminance(); - final theme = Theme.of(tester.element(icon)); - final background = - (glyph == Icons.view_day_outlined - ? (tester - .widget( - find.byType(NavigationIndicator).first, - ) - .color ?? - theme.colorScheme.secondary) - : theme.colorScheme.surfaceContainer) - .computeLuminance(); - final ratio = foreground > background - ? (foreground + 0.05) / (background + 0.05) - : (background + 0.05) / (foreground + 0.05); - expect(ratio, greaterThanOrEqualTo(3)); - } - await tester.tapAt( - tester.getRect(destinationTooltip('Favorites')).topLeft + - const Offset(2, 2), - ); - await tester.pumpAndSettle(); - expect(selected, 1); - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await tester.pumpAndSettle(); - expect( - selected, - 0, - reason: "keyboard traversal activates Journals", - ); - expect(tester.takeException(), isNull); - update(() => visible = false); - await tester.pump(); - expect(destinationTooltip('Favorites').hitTestable(), findsNothing); - expect(find.semantics.byLabel(RegExp('Favorites')), findsNothing); - await tester.pumpAndSettle(); - expect( - tester.getRect(find.byKey(const ValueKey('body'))).bottom, - 900 - inset, - ); - final hiddenSelection = selected; - await tester.sendKeyEvent(LogicalKeyboardKey.tab); - await tester.sendKeyEvent(LogicalKeyboardKey.enter); - expect(selected, hiddenSelection); - update(() => visible = true); - await tester.pumpAndSettle(); - expect( - tester.getSize(find.byType(JournalNavigationBar)).height, - 44 + inset, - ); - expect(tester.takeException(), isNull); - semantics.dispose(); - }, - ); - } - } - } - - testWidgets( - 'Native root accepts touch, wheel and fling but excludes restoration and layout', - (tester) async { - var destination = 0; - var revision = 0; - var anchor = 0.0; - var visible = true; - var active = true; - var count = 100; - late StateSetter update; - final samples = <(int, double, double)>[]; - final empty = []; - await tester.pumpWidget( - MaterialApp( - home: StatefulBuilder( - builder: (context, setState) { - update = setState; - return JournalRootScroll( - destination: destination, - favoritesRevision: revision, - favoritesAnchorOffset: anchor, - navigationVisible: visible, - duration: const Duration(milliseconds: 200), - active: active, - onScroll: (tab, pixels, delta) => - samples.add((tab, pixels, delta)), - onNonScrollable: empty.add, - child: Builder( - builder: (context) => Scaffold( - body: ListView.builder( - key: const ValueKey("root-scroll"), - controller: PrimaryScrollController.of(context), - itemExtent: 60, - itemCount: count, - itemBuilder: (_, i) => Text('Row $i'), - ), - bottomNavigationBar: JournalNavigationBar( - selectedIndex: destination, - onDestinationSelected: (_) {}, - destinations: const [ - NavigationDestination( - icon: Icon(Icons.view_day_outlined), - label: 'Journals', - ), - NavigationDestination( - icon: Icon(Icons.star), - label: 'Favorites', - ), - ], - ), - ), - ), - ); - }, - ), - ), - ); - ScrollPosition position() => - tester.state(find.byType(Scrollable)).position; - await tester.drag(find.byType(ListView), const Offset(0, -180)); - await tester.pumpAndSettle(); - expect(samples, isNotEmpty); - expect(samples.every((s) => s.$1 == 0), isTrue); - samples.clear(); - position().jumpTo(900); - await tester.pumpAndSettle(); - expect(samples, isEmpty); - final animation = position().animateTo( - 1000, - duration: const Duration(milliseconds: 100), - curve: Curves.linear, - ); - await tester.pumpAndSettle(); - await animation; - expect(samples, isEmpty); - final offset = position().pixels; - update(() => visible = false); - await tester.pump(const Duration(milliseconds: 80)); - update(() => visible = true); - await tester.pump(const Duration(milliseconds: 60)); - update(() => visible = false); - await tester.pumpAndSettle(); - expect(position().pixels, offset); - expect(samples, isEmpty); - tester.view.physicalSize = const Size(900, 700); - addTearDown(tester.view.resetPhysicalSize); - await tester.pumpAndSettle(); - expect(position().pixels, offset); - expect(samples, isEmpty, reason: 'window resizing is not scroll intent'); - update(() => destination = 1); - await tester.pumpAndSettle(); - expect(position().pixels, 0); - expect(samples, isEmpty); - await tester.sendEventToBinding( - PointerScrollEvent( - position: tester.getCenter(find.byType(ListView)), - scrollDelta: const Offset(0, 80), - ), - ); - await tester.pumpAndSettle(); - expect(samples, isNotEmpty); - expect(samples.every((s) => s.$1 == 1), isTrue); - samples.clear(); - final favorite = position().pixels; - update(() { - revision++; - anchor = 120; - }); - await tester.pumpAndSettle(); - expect(position().pixels, favorite + 120); - expect(samples, isEmpty); - await tester.fling(find.byType(ListView), const Offset(0, -150), 1800); - final beforeFling = samples.length; - await tester.pumpAndSettle(); - expect(samples.length, greaterThan(beforeFling)); - samples.clear(); - final trackpad = await tester.createGesture( - kind: PointerDeviceKind.trackpad, - ); - final center = tester.getCenter(find.byType(ListView)); - await trackpad.panZoomStart(center); - await trackpad.panZoomUpdate( - center, - pan: const Offset(0, -80), - timeStamp: const Duration(milliseconds: 20), - ); - await trackpad.panZoomUpdate( - center, - pan: const Offset(0, -160), - timeStamp: const Duration(milliseconds: 40), - ); - await trackpad.panZoomEnd(timeStamp: const Duration(milliseconds: 100)); - await tester.pumpAndSettle(); - expect(samples, isNotEmpty); - samples.clear(); - position().jumpTo(0); - await tester.pumpAndSettle(); - await tester.drag(find.byType(ListView), const Offset(0, 180)); - await tester.pumpAndSettle(); - expect(position().pixels, 0); - expect( - samples.any((sample) => sample.$2 <= 0), - isTrue, - reason: 'user overscroll still reports the top on clamping platforms', - ); - samples.clear(); - position().jumpTo(20); - await tester.pumpAndSettle(); - expect(samples, isEmpty); - await tester.sendEventToBinding( - PointerScrollEvent( - position: tester.getCenter(find.byType(ListView)), - scrollDelta: const Offset(0, -40), - ), - ); - await tester.pumpAndSettle(); - expect( - samples.any((sample) => sample.$2 <= 0), - isTrue, - reason: 'wheel movement reaching the top remains observable', - ); - samples.clear(); - update(() => active = false); - await tester.pump(); - await tester.drag(find.byType(ListView), const Offset(0, -100)); - await tester.pumpAndSettle(); - expect(samples, isEmpty); - await tester.fling(find.byType(ListView), const Offset(0, -120), 1600); - update(() => active = true); - await tester.pumpAndSettle(); - expect( - samples, - isEmpty, - reason: 'return does not resume covered scroll intent', - ); - update(() { - count = 0; - }); - await tester.pumpAndSettle(); - expect(empty, contains(1)); - expect(samples, isEmpty); - expect(tester.takeException(), isNull); - }, - ); -} - -class _PaintOffset extends SingleChildRenderObjectWidget { - const _PaintOffset({required this.record, required super.child}); - final VoidCallback record; - @override - RenderObject createRenderObject(BuildContext context) => _Recorder(record); - @override - void updateRenderObject(BuildContext context, _Recorder renderObject) { - renderObject.record = record; - renderObject.markNeedsPaint(); - } -} - -class _Recorder extends RenderProxyBox { - _Recorder(this.record); - VoidCallback record; - @override - void paint(PaintingContext context, Offset offset) { - record(); - super.paint(context, offset); - } -} - -class _Session { - int destination = 0, revision = 0, graph = 0; - double anchor = 0; - bool active = true; - final lengths = [3000.0, 3000.0]; - final samples = <(int, double, double)>[]; - final paints = []; - late StateSetter update; - late ScrollController controller; - ScrollPosition get position => controller.position; - - Widget app() => MaterialApp( - home: StatefulBuilder( - builder: (context, setState) { - update = setState; - return JournalRootScroll( - key: ValueKey(graph), - navigationVisible: true, - duration: Duration.zero, - active: active, - destination: destination, - favoritesRevision: revision, - favoritesAnchorOffset: anchor, - onScroll: (d, p, delta) => samples.add((d, p, delta)), - onNonScrollable: (_) {}, - child: Builder( - builder: (context) { - controller = PrimaryScrollController.of(context); - return Scaffold( - body: _PaintOffset( - record: () => paints.add(position.pixels), - child: CustomScrollView( - key: const ValueKey('root-scroll'), - controller: controller, - slivers: [ - SliverAppBar( - key: const ValueKey('header'), - pinned: true, - title: Text( - destination == 0 ? 'Journals' : 'Favorites', - ), - actions: [ - IconButton( - onPressed: () {}, - icon: const Icon(Icons.person), - ), - ], - ), - SliverToBoxAdapter( - child: SizedBox(height: lengths[destination]), - ), - ], - ), - ), - ); - }, - ), - ); - }, - ), - ); - - Future change(WidgetTester tester, VoidCallback action) async { - paints.clear(); - samples.clear(); - update(action); - await tester.pump(); - } - - void paintedAt(double offset) { - expect(paints, isNotEmpty); - expect(paints, everyElement(closeTo(offset, 0.01))); - expect(position.pixels, closeTo(offset, 0.01)); - expect(samples, isEmpty, reason: 'Restoration is not user scroll intent'); - } -} - -void retainedRootScrollTests() { - testWidgets( - 'One native position restores equal-size destinations before paint', - (tester) async { - final session = _Session(); - await tester.pumpWidget(session.app()); - final position = session.position; - final header = tester.state(find.byType(SliverAppBar)); - final account = tester.element(find.byType(IconButton)); - session.position.jumpTo(700); - await tester.pump(); - await session.change(tester, () => session.destination = 1); - session.paintedAt(0); - expect(session.position, same(position)); - expect(tester.state(find.byType(SliverAppBar)), same(header)); - expect(tester.element(find.byType(IconButton)), same(account)); - session.position.jumpTo(350); - await tester.pump(); - await session.change(tester, () => session.destination = 0); - session.paintedAt(700); - await session.change(tester, () => session.destination = 1); - session.paintedAt(350); - }, - ); - - for (final length in [0.0, 200.0, 1000.0, 5000.0]) { - testWidgets('Restoration clamps against target content length $length', ( - tester, - ) async { - final session = _Session(); - await tester.pumpWidget(session.app()); - session.position.jumpTo(1800); - await tester.pump(); - await session.change(tester, () => session.destination = 1); - session.position.jumpTo(900); - await tester.pump(); - await session.change(tester, () { - session.destination = 0; - session.lengths[0] = length; - }); - session.paintedAt(1800.0.clamp(0, session.position.maxScrollExtent)); - await session.change(tester, () => session.destination = 1); - session.paintedAt(900); - }); - } - - testWidgets( - 'Favorites revisions correct active and inactive offsets before paint', - (tester) async { - final session = _Session(); - await tester.pumpWidget(session.app()); - session.position.jumpTo(700); - await tester.pump(); - await session.change(tester, () => session.destination = 1); - session.position.jumpTo(300); - await tester.pump(); - await session.change(tester, () { - session.revision++; - session.anchor = 120; - }); - session.paintedAt(420); - await session.change(tester, () => session.destination = 0); - session.paintedAt(700); - await session.change(tester, () { - session.revision++; - session.anchor = 200; - }); - expect(session.position.pixels, 700); - expect(session.samples, isEmpty); - await session.change(tester, () => session.destination = 1); - session.paintedAt(500); - await session.change(tester, () { - session.revision++; - session.anchor = -1000; - }); - session.paintedAt(0); - }, - ); - - testWidgets( - 'Rapid selection replaces pending correction and graph resets both offsets', - (tester) async { - final session = _Session(); - await tester.pumpWidget(session.app()); - session.position.jumpTo(700); - await tester.pump(); - await session.change(tester, () => session.destination = 1); - session.position.jumpTo(300); - await tester.pump(); - session.update(() => session.destination = 0); - await tester.pump(Duration.zero, EnginePhase.build); - session.update(() => session.destination = 1); - await tester.pump(Duration.zero, EnginePhase.build); - await session.change(tester, () => session.destination = 0); - session.paintedAt(700); - await session.change(tester, () => session.destination = 1); - session.paintedAt(300); - final oldPosition = session.position; - await session.change(tester, () => session.graph++); - session.paintedAt(0); - expect(session.position, isNot(same(oldPosition))); - await session.change(tester, () => session.destination = 0); - session.paintedAt(0); - }, - ); - - testWidgets( - 'Switching during a fling cancels outgoing motion and user samples', - (tester) async { - final session = _Session(); - await tester.pumpWidget(session.app()); - await tester.fling( - find.byType(CustomScrollView), - const Offset(0, -400), - 1800, - ); - await tester.pump(const Duration(milliseconds: 16)); - expect(session.position.isScrollingNotifier.value, isTrue); - final outgoing = session.position.pixels; - await session.change(tester, () => session.destination = 1); - session.paintedAt(0); - await tester.pump(const Duration(milliseconds: 80)); - expect(session.position.pixels, 0); - expect(session.position.isScrollingNotifier.value, isFalse); - expect(session.samples, isEmpty); - await session.change(tester, () => session.destination = 0); - session.paintedAt(outgoing); - }, - ); -} diff --git a/flutter/test/journal_runtime_golden_test.dart b/flutter/test/journal_runtime_golden_test.dart deleted file mode 100644 index 1fa06a1..0000000 --- a/flutter/test/journal_runtime_golden_test.dart +++ /dev/null @@ -1,2180 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; -import 'dart:ui' show Tristate; - -import 'package:bonsai_flutter/bonsai_flutter.dart'; -// ignore: implementation_imports -import 'package:bonsai_flutter/src/runtime/foreground_frame_loop.dart'; -// ignore: implementation_imports -import 'package:bonsai_flutter/src/renderer/pressable_host.dart'; -import 'package:bonsai_flutter_logseq_journal_host/application_host_adapter.dart'; -import 'package:bonsai_flutter_logseq_journal_host/journal_widget_registry.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; -// ignore: depend_on_referenced_packages -import 'package:material_3_expressive/material_3_expressive.dart'; -// ignore: depend_on_referenced_packages -import 'package:flutter_slidable/flutter_slidable.dart' as fs; -import 'package:flutter_test/flutter_test.dart'; - -const _parentSource = '混合脚本 Journal 2026 条目'; -const _firstChild = 'Increase block row height'; -const _secondChild = 'Show parent and child preview'; -const _thirdChild = 'Keep bounded virtualization'; -const _lightStatusCategoryBackgrounds = [ - Color(0xff585c7e), - Color(0xff00677c), - Color(0xff006b57), - Color(0xff7c3aed), -]; -const _lightStatusCategoryForegrounds = [ - Colors.white, - Colors.white, - Colors.white, - Colors.white, -]; -const _darkStatusCategoryBackgrounds = [ - Color(0xffc0c4eb), - Color(0xff86d1e9), - Color(0xff83d6bd), - Color(0xff7c3aed), -]; -const _darkStatusCategoryForegrounds = [ - Color(0xff2a2e50), - Color(0xff003642), - Color(0xff00382b), - Colors.white, -]; - -final _runtimeBrightness = - Platform.environment['JOURNAL_GOLDEN_BRIGHTNESS'] == 'dark' - ? Brightness.dark - : Brightness.light; -final _runtimeHighContrast = - Platform.environment['JOURNAL_GOLDEN_HIGH_CONTRAST'] == '1'; -final _writesReferenceGoldens = - _runtimeBrightness == Brightness.light && !_runtimeHighContrast; - -final class _TestAuth implements JournalAuthCapability { - _TestAuth({String? authenticatedUserId}) { - if (authenticatedUserId != null) { - _currentUser.complete(authenticatedUserId); - } - } - - final Completer _currentUser = Completer(); - final Completer _freshToken = Completer(); - bool _freshTokenRequested = false; - - @override - Future currentUserId() => _currentUser.future; - - @override - Future freshIdToken() { - _freshTokenRequested = true; - return _freshToken.future; - } - - @override - Future signOut() async => throw StateError('unused'); - - void rejectPendingAuthentication() { - if (!_currentUser.isCompleted) { - _currentUser.completeError(StateError('golden harness disposed')); - } - if (_freshTokenRequested && !_freshToken.isCompleted) { - _freshToken.completeError(StateError('golden harness disposed')); - } - } -} - -void main() { - final binding = TestWidgetsFlutterBinding.ensureInitialized(); - WidgetsApp.debugAllowBannerOverride = false; - if (binding is LiveTestWidgetsFlutterBinding) { - binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.onlyPumps; - } - - testWidgets( - 'real runtime renders all typography presets at 390 x 844', - (tester) async { - final harness = await _RuntimeHarness.start( - tester, - brightness: Brightness.light, - highContrast: false, - reconcileAuthenticatedUser: true, - ); - expect(tester.takeException(), isNull); - - Future expectPresetGolden(String preset) async { - final scrollable = find.descendant( - of: find.byType(CustomScrollView), - matching: find.byType(Scrollable), - ); - final position = tester.state(scrollable).position; - position.jumpTo(80); - await tester.pump(); - expect( - _sliverPaintExtent(tester, find.byType(SliverAppBar)), - closeTo(64 + 47, 0.5), - ); - position.jumpTo(0); - await tester.pump(const Duration(milliseconds: 220)); - _expectTimelineStartsBelowHeader(tester); - expect(find.text(_parentSource), findsOneWidget); - expect( - DefaultTextStyle.of( - tester.element(find.text(_parentSource)), - ).style.fontFamily, - 'PingFang SC', - ); - await expectLater( - find.byType(Scaffold).first, - matchesGoldenFile('goldens/journal-typography-$preset.png'), - ); - } - - Future selectPreset(String label, String storedValue) async { - await tester.tap(find.bySemanticsLabel('Account menu')); - await harness.pumpUntil( - () => find.text('Settings').evaluate().isNotEmpty, - reason: 'the Account dialog did not expose Settings', - ); - expect(find.byType(M3EDialog), findsOneWidget); - final accountDialog = tester.widget(find.byType(M3EDialog)); - expect(accountDialog.actions, hasLength(1)); - final accountButtons = find.descendant( - of: find.byType(M3EDialog), - matching: find.byType(M3EButton), - ); - expect(accountButtons, findsNWidgets(6)); - for (final element in accountButtons.evaluate()) { - expect((element.widget as M3EButton).style, M3EButtonStyle.text); - } - expect(tester.takeException(), isNull); - await tester.tap(find.text('Settings').last); - await harness.pumpUntil( - () => find.text(label).evaluate().isNotEmpty, - reason: 'the typography choice group did not open', - ); - await tester.tap(find.text(label)); - await harness.pumpUntil(() { - final chip = find.ancestor( - of: find.text(label), - matching: find.byType(M3EChip), - ); - return chip.evaluate().isNotEmpty && - tester.widget(chip).type == M3EChipType.filter && - tester.widget(chip).selected; - }, reason: 'the $storedValue preset did not become selected'); - await tester.tap(find.text('Close')); - await harness.pumpUntil( - () => find.byType(M3EChip).evaluate().isEmpty, - reason: 'Settings did not close after selecting $storedValue', - ); - await tester.pump(const Duration(milliseconds: 220)); - } - - await expectPresetGolden('balanced'); - await selectPreset('A Dense', 'dense'); - await expectPresetGolden('dense'); - await selectPreset('C Comfortable', 'comfortable'); - await expectPresetGolden('comfortable'); - }, - skip: Platform.environment['RUN_REAL_OCAML_GOLDEN'] != '1', - ); - - testWidgets( - 'real runtime morphs Capture FAB with standard motion', - (tester) async { - final harness = await _RuntimeHarness.start( - tester, - brightness: Brightness.light, - highContrast: false, - ); - tester.view.physicalSize = const Size(390, 600); - await tester.pump(); - expect(tester.takeException(), isNull); - final composer = find.byType(ExpandableMessageComposer); - final floatingActionButton = find.byType(FloatingActionButton); - final scrollable = find.descendant( - of: find.byType(CustomScrollView), - matching: find.byType(Scrollable), - ); - final position = tester.state(scrollable).position; - expect(position.maxScrollExtent, greaterThan(24)); - final extendedWidth = tester.getSize(floatingActionButton).width; - final extendedRect = tester.getRect(floatingActionButton); - - final gesture = await tester.startGesture( - tester.getCenter(find.text(_parentSource)), - ); - await gesture.moveBy(const Offset(0, -19)); - await tester.pump(); - await gesture.moveBy(const Offset(0, -23)); - await tester.pump(const Duration(milliseconds: 250)); - expect(position.pixels, closeTo(23, 0.1)); - expect( - tester.widget(composer).fabPresentation, - ExpandableMessageComposerFabPresentation.extended, - ); - - await gesture.moveBy(const Offset(0, -1)); - await harness.pumpUntil( - () => - tester - .widget(composer) - .fabPresentation == - ExpandableMessageComposerFabPresentation.compact, - reason: '24 points of downward travel did not compact Capture', - ); - expect(tester.getSize(floatingActionButton).width, extendedWidth); - expect(tester.getRect(floatingActionButton).right, extendedRect.right); - expect(find.text('Capture'), findsOneWidget); - - await tester.pump(const Duration(milliseconds: 90)); - expect( - tester.getSize(floatingActionButton).width, - allOf(greaterThan(56), lessThan(extendedWidth)), - reason: - 'standard motion did not animate FAB width from the trailing edge', - ); - final labelOpacity = tester.widget( - find.ancestor(of: find.text('Capture'), matching: find.byType(Opacity)), - ); - expect(labelOpacity.opacity, inExclusiveRange(0, 1)); - expect( - tester.getRect(floatingActionButton).right, - closeTo(extendedRect.right, 0.01), - ); - await tester.pump(const Duration(milliseconds: 100)); - expect(tester.getSize(floatingActionButton), const Size(56, 56)); - expect(find.text('Capture'), findsNothing); - await gesture.up(); - await harness.dispose(); - }, - skip: Platform.environment['RUN_REAL_OCAML_GOLDEN'] != '1', - timeout: const Timeout(Duration(seconds: 60)), - ); - - testWidgets( - 'real runtime renders compact Capture in RTL at large text', - (tester) async { - addTearDown(tester.binding.platformDispatcher.clearLocalesTestValue); - final harness = await _RuntimeHarness.start( - tester, - brightness: Brightness.light, - highContrast: false, - ); - tester.view.physicalSize = const Size(390, 600); - await tester.pump(); - expect(tester.takeException(), isNull); - final composer = find.byType(ExpandableMessageComposer); - final scrollable = find.descendant( - of: find.byType(CustomScrollView), - matching: find.byType(Scrollable), - ); - final position = tester.state(scrollable).position; - expect(position.maxScrollExtent, greaterThan(24)); - position.jumpTo(24); - await _dispatchScrollUpdate(tester, scrollable, 24); - await harness.pumpUntil( - () => - tester - .widget(composer) - .fabPresentation == - ExpandableMessageComposerFabPresentation.compact, - reason: 'Capture did not settle compact before RTL coverage', - ); - await tester.pump(const Duration(milliseconds: 220)); - expect(find.text('Capture'), findsNothing); - expect( - tester.getSize(find.byType(FloatingActionButton)), - const Size(56, 56), - ); - - tester.binding.platformDispatcher.localesTestValue = const [ - Locale('ar', 'SA'), - ]; - tester.platformDispatcher.textScaleFactorTestValue = 3.2; - await tester.pump(); - await harness.pumpUntil( - () => - Directionality.of(tester.element(composer)) == TextDirection.rtl && - MediaQuery.textScalerOf(tester.element(composer)).scale(1) > 3, - reason: 'RTL large-text environment did not reach compact Capture', - ); - await tester.pump(const Duration(milliseconds: 220)); - expect(find.text('Capture'), findsNothing); - expect( - tester.getSize(find.byType(FloatingActionButton)), - const Size(56, 56), - ); - final rtlStatusRow = find.text(_parentSource); - final rtlSlidable = find.ancestor( - of: rtlStatusRow, - matching: find.byType(fs.Slidable), - ); - await tester.drag(rtlStatusRow, const Offset(-390, 0)); - await _pumpSlidableMotion(tester); - expect(tester.takeException(), isNull); - final actionLabel = find.descendant( - of: rtlSlidable, - matching: find.text('No status'), - ); - expect(actionLabel, findsOneWidget); - expect( - tester - .getRect( - find.ancestor( - of: actionLabel, - matching: find.byType(fs.CustomSlidableAction), - ), - ) - .width, - greaterThanOrEqualTo(44), - ); - expect( - fs.Slidable.of(tester.element(rtlStatusRow))!.ratio, - closeTo(-0.25, 0.01), - reason: 'RTL did not mirror the logical-start status button', - ); - final rtlClose = fs.Slidable.of(tester.element(rtlStatusRow))!.close(); - await _pumpSlidableMotion(tester); - await rtlClose; - await expectLater( - find.byType(Scaffold).first, - matchesGoldenFile('goldens/journal-capture-compact-rtl-large-text.png'), - ); - await harness.dispose(); - }, - skip: Platform.environment['RUN_REAL_OCAML_GOLDEN'] != '1', - timeout: const Timeout(Duration(seconds: 60)), - ); - - testWidgets( - 'real runtime preserves Capture behavior across directional presentation changes', - (tester) async { - final harness = await _RuntimeHarness.start( - tester, - brightness: Brightness.light, - highContrast: false, - ); - tester.view.physicalSize = const Size(390, 600); - await tester.pump(); - expect(tester.takeException(), isNull); - final composer = find.byType(ExpandableMessageComposer); - final floatingActionButton = find.byType(FloatingActionButton); - final scrollable = find.descendant( - of: find.byType(CustomScrollView), - matching: find.byType(Scrollable), - ); - final position = tester.state(scrollable).position; - expect(position.maxScrollExtent, greaterThan(24)); - final composerState = tester.state(composer); - expect( - tester.widget(composer).fabPresentation, - ExpandableMessageComposerFabPresentation.extended, - ); - expect( - tester.widget(floatingActionButton).isExtended, - isTrue, - ); - expect(find.text('Capture'), findsOneWidget); - - final gesture = await tester.startGesture( - tester.getCenter(find.text(_parentSource)), - ); - await gesture.moveBy(const Offset(0, -19)); - await tester.pump(); - await gesture.moveBy(const Offset(0, -23)); - await tester.pump(const Duration(milliseconds: 250)); - expect(position.pixels, closeTo(23, 0.1)); - expect( - tester.widget(composer).fabPresentation, - ExpandableMessageComposerFabPresentation.extended, - reason: 'sub-threshold downward travel compacted Capture', - ); - expect(tester.state(composer), same(composerState)); - - await gesture.moveBy(const Offset(0, -1)); - await harness.pumpUntil( - () => - tester - .widget(composer) - .fabPresentation == - ExpandableMessageComposerFabPresentation.compact, - reason: '24 points of downward travel did not compact Capture', - ); - await tester.pump(const Duration(milliseconds: 180)); - await gesture.up(); - expect(tester.state(composer), same(composerState)); - final compactFab = tester.widget( - floatingActionButton, - ); - expect(compactFab.isExtended, isFalse); - expect(compactFab.mini, isFalse); - expect(tester.getSize(floatingActionButton), const Size(56, 56)); - expect(find.text('Capture'), findsNothing); - expect(find.bySemanticsLabel('Open Capture'), findsOneWidget); - _expectMaterialGlyph( - floatingActionButton, - Icons.add, - role: 'compact FAB', - ); - - position.jumpTo(124); - await _dispatchScrollUpdate(tester, scrollable, 100); - await tester.pump(const Duration(milliseconds: 220)); - expect( - tester.widget(composer).fabPresentation, - ExpandableMessageComposerFabPresentation.compact, - reason: 'downward travel while compact toggled the FAB', - ); - await tester.tap(floatingActionButton); - await tester.pump(); - const draft = ' retained while scrolling 👩🏽‍💻 '; - await tester.enterText(find.byType(TextField), draft); - await tester.pump(); - final textField = tester.widget(find.byType(TextField)); - final controller = textField.controller; - final focusNode = textField.focusNode; - final route = ModalRoute.of(tester.element(find.byType(MessageComposer))); - expect(route, isNotNull); - expect(focusNode!.hasFocus, isTrue); - - position.jumpTo(101); - await _dispatchScrollUpdate(tester, scrollable, -23); - await tester.pump(const Duration(milliseconds: 120)); - expect( - tester.widget(composer).fabPresentation, - ExpandableMessageComposerFabPresentation.compact, - reason: '23 points of upward travel extended Capture', - ); - position.jumpTo(100); - await _dispatchScrollUpdate(tester, scrollable, -1); - await harness.pumpUntil( - () => - tester - .widget(composer) - .fabPresentation == - ExpandableMessageComposerFabPresentation.extended, - reason: '24 points of upward travel did not extend Capture', - ); - expect(tester.state(composer), same(composerState)); - expect(find.byType(BottomSheet), findsOneWidget); - expect( - ModalRoute.of(tester.element(find.byType(MessageComposer))), - same(route), - ); - final updatedTextField = tester.widget(find.byType(TextField)); - expect(updatedTextField.controller, same(controller)); - expect(updatedTextField.focusNode, same(focusNode)); - expect(updatedTextField.controller!.text, draft); - expect(updatedTextField.focusNode!.hasFocus, isTrue); - await tester.sendKeyEvent(LogicalKeyboardKey.escape); - await harness.pumpUntil( - () => find.byType(BottomSheet).evaluate().isEmpty, - reason: 'Escape did not dismiss Capture after presentation changes', - ); - expect(find.text('Capture'), findsOneWidget); - - position.jumpTo(200); - await _dispatchScrollUpdate(tester, scrollable, 100); - await harness.pumpUntil( - () => - tester - .widget(composer) - .fabPresentation == - ExpandableMessageComposerFabPresentation.compact, - reason: 'one large downward event did not compact Capture', - ); - await tester.pump(const Duration(milliseconds: 220)); - expect(tester.state(composer), same(composerState)); - position.jumpTo(0); - await _dispatchScrollUpdate(tester, scrollable, -200); - await harness.pumpUntil( - () => - tester - .widget(composer) - .fabPresentation == - ExpandableMessageComposerFabPresentation.extended, - reason: 'the top boundary did not restore extended Capture', - ); - await tester.pump(const Duration(milliseconds: 220)); - - await expectLater( - find.byType(Scaffold).first, - matchesGoldenFile('goldens/journal-capture-extended-light.png'), - ); - position.jumpTo(24); - await _dispatchScrollUpdate(tester, scrollable, 24); - await harness.pumpUntil( - () => - tester - .widget(composer) - .fabPresentation == - ExpandableMessageComposerFabPresentation.compact, - reason: 'Capture did not settle compact for golden coverage', - ); - await tester.pump(const Duration(milliseconds: 220)); - await expectLater( - find.byType(Scaffold).first, - matchesGoldenFile('goldens/journal-capture-compact-light.png'), - ); - - tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark; - await tester.pump(); - await harness.pumpUntil( - () => Theme.of(tester.element(composer)).brightness == Brightness.dark, - reason: 'dark appearance did not reach compact Capture', - ); - await tester.pump(const Duration(milliseconds: 220)); - await expectLater( - find.byType(Scaffold).first, - matchesGoldenFile('goldens/journal-capture-compact-dark.png'), - ); - tester.platformDispatcher.accessibilityFeaturesTestValue = - const FakeAccessibilityFeatures(highContrast: true); - await tester.pump(); - await harness.pumpUntil( - () => MediaQuery.highContrastOf(tester.element(composer)), - reason: 'high contrast did not reach compact Capture', - ); - await tester.pump(const Duration(milliseconds: 220)); - await expectLater( - find.byType(Scaffold).first, - matchesGoldenFile( - 'goldens/journal-capture-compact-high-contrast-dark.png', - ), - ); - tester.platformDispatcher.platformBrightnessTestValue = Brightness.light; - await tester.pump(); - await harness.pumpUntil( - () => Theme.of(tester.element(composer)).brightness == Brightness.light, - reason: 'high-contrast light appearance did not reach compact Capture', - ); - await tester.pump(const Duration(milliseconds: 220)); - await expectLater( - find.byType(Scaffold).first, - matchesGoldenFile( - 'goldens/journal-capture-compact-high-contrast-light.png', - ), - ); - tester.platformDispatcher.accessibilityFeaturesTestValue = - const FakeAccessibilityFeatures( - disableAnimations: true, - accessibleNavigation: true, - ); - await tester.pump(); - tester.state(scrollable).position.jumpTo(0); - await _dispatchScrollUpdate(tester, scrollable, -24); - await harness.pumpUntil( - () => - tester - .widget(composer) - .animationDuration == - Duration.zero, - reason: 'Reduced Motion did not remove Capture transition duration', - ); - tester.state(scrollable).position.jumpTo(24); - await _dispatchScrollUpdate(tester, scrollable, 24); - await harness.pumpUntil( - () => - tester - .widget(composer) - .fabPresentation == - ExpandableMessageComposerFabPresentation.compact, - reason: 'Reduced Motion did not apply compact presentation', - ); - expect(tester.getSize(floatingActionButton), const Size(56, 56)); - await harness.dispose(); - }, - skip: Platform.environment['RUN_REAL_OCAML_GOLDEN'] != '1', - timeout: const Timeout(Duration(seconds: 60)), - ); - - testWidgets( - 'real runtime preserves Capture task intent and applies the single status sheet', - (tester) async { - final harness = await _RuntimeHarness.start( - tester, - brightness: Brightness.light, - highContrast: false, - ); - expect(tester.takeException(), isNull); - - final parentRow = find.text(_parentSource); - final parentSlidable = find.ancestor( - of: parentRow, - matching: find.byType(fs.Slidable), - ); - final parentController = fs.Slidable.of(tester.element(parentRow))!; - await tester.drag(parentRow, const Offset(390, 0)); - await _pumpSlidableMotion(tester); - expect(parentController.ratio, closeTo(0.25, 0.01)); - final actionLabel = find.descendant( - of: parentSlidable, - matching: find.text('No status'), - ); - expect(actionLabel, findsOneWidget); - final currentNoStatus = tester.widget( - find.ancestor( - of: actionLabel, - matching: find.byType(fs.CustomSlidableAction), - ), - ); - expect(currentNoStatus.onPressed, isNotNull); - final parentClose = parentController.close(); - await _pumpSlidableMotion(tester); - await parentClose; - await tester.drag(parentRow, const Offset(390, 0)); - await _pumpSlidableMotion(tester); - expect(parentController.ratio, closeTo(0.25, 0.01)); - expect( - find.bySemanticsLabel(RegExp('$_parentSource.*status ')), - findsNothing, - reason: 'a full-width drag changed status without an explicit tap', - ); - await tester.tap(actionLabel); - await harness.pumpUntil( - () => find.text('Set status').evaluate().isNotEmpty, - reason: 'the status button did not open the modal bottom sheet', - ); - await tester.pump(const Duration(milliseconds: 220)); - expect(find.byType(BottomSheet), findsWidgets); - expect(find.byType(DraggableScrollableSheet), findsOneWidget); - final statusButtons = find.descendant( - of: find.byType(DraggableScrollableSheet), - matching: find.byType(M3EButton), - ); - expect(statusButtons, findsNWidgets(7)); - for (final element in statusButtons.evaluate()) { - expect((element.widget as M3EButton).style, M3EButtonStyle.text); - } - final sheetRect = tester.getRect(find.byType(DraggableScrollableSheet)); - final screenHeight = tester.getSize(find.byType(MaterialApp)).height; - expect( - sheetRect.height, - closeTo(screenHeight * 0.5, 1), - reason: 'the status sheet did not open at the Medium detent', - ); - expect( - find.bySemanticsLabel('Status picker size'), - findsOneWidget, - reason: 'the Medium detent has no accessible drag handle', - ); - for (final label in const [ - 'Backlog', - 'Todo', - 'Doing', - 'In review', - 'Done', - 'Canceled', - 'Clear', - ]) { - final option = find.text(label, skipOffstage: false); - expect(option, findsOneWidget); - expect( - sheetRect.contains(tester.getCenter(option)), - isTrue, - reason: '$label is not visible inside the Medium detent', - ); - if (label != 'Clear') { - expect( - option.hitTestable(), - findsOneWidget, - reason: '$label is inside the Medium detent but cannot be tapped', - ); - } - } - expect(find.text('Now'), findsNothing); - expect(find.text('Waiting'), findsNothing); - expect(find.text('Later'), findsNothing); - expect(find.text('Clear'), findsOneWidget); - expect( - tester - .getSemantics(find.bySemanticsLabel('Clear')) - .getSemanticsData() - .flagsCollection - .isSelected, - Tristate.isTrue, - ); - await expectLater( - find.byType(MaterialApp), - matchesGoldenFile('goldens/journal-status-sheet-medium.png'), - ); - await tester.drag( - find.bySemanticsLabel('Status picker size'), - const Offset(0, 420), - ); - await harness.pumpUntil( - () => find.text('Set status').evaluate().isEmpty, - reason: 'dragging the Medium status sheet down did not dismiss it', - ); - expect( - find.bySemanticsLabel(RegExp('$_parentSource.*status ')), - findsNothing, - reason: 'drag dismissal changed status', - ); - await tester.pump(const Duration(milliseconds: 220)); - await _pumpSlidableMotion(tester); - final reopenStatus = parentController.openStartActionPane(); - await _pumpSlidableMotion(tester); - await reopenStatus; - await tester.tap(actionLabel); - await harness.pumpUntil( - () => find.text('Set status').evaluate().isNotEmpty, - reason: 'the status sheet did not reopen after drag dismissal', - ); - await tester.pump(const Duration(milliseconds: 220)); - await tester.tap(find.text('Doing')); - await harness.pumpUntil( - () => - find.text('Set status').evaluate().isEmpty && - find - .bySemanticsLabel(RegExp('$_parentSource.*status Doing')) - .evaluate() - .isNotEmpty, - reason: 'the Doing sheet option did not reconcile through the Worker', - ); - await _pumpSlidableMotion(tester); - expect(parentController.ratio, closeTo(0, 0.01)); - - await tester.tap(find.byType(FloatingActionButton)); - await harness.pumpUntil( - () => find.byType(MessageComposer).evaluate().isNotEmpty, - reason: 'Capture composer did not open', - ); - await tester.pump(const Duration(milliseconds: 220)); - const draft = ' Capture task 中文 👩🏽‍💻 exact '; - await tester.enterText(find.byType(TextField), draft); - await tester.pump(); - _expectMaterialGlyph( - find.byTooltip('Capture as task, off'), - Icons.timelapse, - role: 'unchecked Capture task action', - ); - await tester.tap(find.byTooltip('Capture as task, off').hitTestable()); - await harness.pumpUntil( - () => find.byTooltip('Capture as task, on').evaluate().isNotEmpty, - reason: 'Capture task action did not become checked', - ); - _expectMaterialGlyph( - find.byTooltip('Capture as task, on'), - Icons.timelapse, - role: 'checked Capture task action', - ); - expect( - tester.widget(find.byType(TextField)).controller!.text, - draft, - ); - await tester.drag(find.byType(MessageComposer), const Offset(0, 80)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 220)); - await tester.pump(const Duration(milliseconds: 220)); - expect(find.byType(MessageComposer), findsNothing); - await tester.tap(find.byType(FloatingActionButton)); - await harness.pumpUntil( - () => - find.byType(TextField).evaluate().isNotEmpty && - tester.widget(find.byType(TextField)).controller!.text == - draft && - find.byTooltip('Capture as task, on').evaluate().isNotEmpty, - reason: 'dismissed Capture draft and task intent were not restored', - ); - await tester.pump(const Duration(milliseconds: 220)); - await tester.drag(find.byType(MessageComposer), const Offset(0, 80)); - await tester.pump(const Duration(milliseconds: 440)); - await harness.dispose(); - }, - skip: Platform.environment['RUN_REAL_OCAML_GOLDEN'] != '1', - timeout: const Timeout(Duration(seconds: 60)), - ); - - testWidgets( - 'real runtime matches row, divider, expandable Capture, preview, and swipe contracts', - (tester) async { - final harness = await _RuntimeHarness.start( - tester, - brightness: _runtimeBrightness, - highContrast: _runtimeHighContrast, - ); - expect(tester.takeException(), isNull); - expect(find.byType(MaterialApp), findsOneWidget); - expect(find.byType(fs.SlidableAutoCloseBehavior), findsOneWidget); - final materialApp = tester.widget(find.byType(MaterialApp)); - expect(materialApp.themeMode, ThemeMode.system); - expect(find.byType(MessageComposer), findsNothing); - expect(find.byType(FloatingActionButton), findsOneWidget); - _expectMaterialGlyph( - find.byType(FloatingActionButton), - Icons.add, - role: 'Capture FAB', - ); - expect(find.text('Capture'), findsOneWidget); - final scaffold = tester.widget(find.byType(Scaffold).first); - expect(scaffold.bottomNavigationBar, isNull); - expect(scaffold.bottomSheet, isNull); - expect(scaffold.floatingActionButton, isNotNull); - final scaffoldRect = tester.getRect(find.byType(Scaffold).first); - final bodyRect = tester.getRect(find.byWidget(scaffold.body!)); - expect(bodyRect.bottom, closeTo(scaffoldRect.bottom, 0.1)); - final composerRect = tester.getRect( - find.byType(ExpandableMessageComposer), - ); - final captureFabRect = tester.getRect(find.byType(FloatingActionButton)); - expect(composerRect, captureFabRect); - expect(captureFabRect.width, lessThan(scaffoldRect.width)); - expect(find.byType(CustomScrollView), findsOneWidget); - expect(find.byType(SliverAppBar), findsOneWidget); - expect( - find.ancestor( - of: find.bySemanticsLabel('Account menu'), - matching: find.byType(M3ETooltip), - ), - findsOneWidget, - ); - var journalAppBar = tester.widget( - find.byType(SliverAppBar), - ); - expect(journalAppBar.pinned, isTrue); - expect(journalAppBar.floating, isFalse); - expect(journalAppBar.snap, isFalse); - expect(journalAppBar.stretch, isFalse); - expect(journalAppBar.automaticallyImplyLeading, isTrue); - expect(journalAppBar.centerTitle, isTrue); - expect(journalAppBar.expandedHeight, 64); - expect(journalAppBar.collapsedHeight, 64); - expect(journalAppBar.toolbarHeight, 56); - expect(journalAppBar.elevation, isNull); - expect(journalAppBar.backgroundColor, isNotNull); - expect(journalAppBar.foregroundColor, isNotNull); - expect(journalAppBar.leading, isNotNull); - expect(journalAppBar.flexibleSpace, isNull); - expect(journalAppBar.bottom, isNull); - expect(journalAppBar.actions, isNotEmpty); - expect(find.text('Wed, Aug 12'), findsOneWidget); - expect( - find.byWidgetPredicate( - (widget) => - widget is Semantics && - widget.properties.label == 'Wed, Aug 12', - ), - findsOneWidget, - ); - expect( - _sliverPaintExtent(tester, find.byType(SliverAppBar)), - closeTo(64 + 47, 0.5), - ); - final journalScroll = tester.state( - find.descendant( - of: find.byType(CustomScrollView), - matching: find.byType(Scrollable), - ), - ); - journalScroll.position.jumpTo(80); - await tester.pump(); - expect(find.text('Wed, Aug 12').hitTestable(), findsOneWidget); - expect( - _sliverPaintExtent(tester, find.byType(SliverAppBar)), - closeTo(64 + 47, 0.5), - ); - journalScroll.position.jumpTo(0); - await tester.pump(); - expect(find.text('Wed, Aug 12').hitTestable(), findsOneWidget); - expect(find.text(_parentSource), findsOneWidget); - expect(find.text(_firstChild), findsOneWidget); - expect(find.text('21:37'), findsOneWidget); - expect(find.text('Todo rail'), findsOneWidget); - expect(find.bySemanticsLabel(RegExp('status Todo')), findsOneWidget); - _expectSeedOwnedSemanticColors( - tester, - brightness: _runtimeBrightness, - highContrast: _runtimeHighContrast, - ); - expect( - find.bySemanticsLabel( - RegExp('^${RegExp.escape(_parentSource)}.*created at 21:37'), - ), - findsOneWidget, - ); - - final rowRect = _ancestorRectWithHeight( - tester, - find.text(_parentSource), - 82, - ); - expect(rowRect.width, closeTo(390, 0.5)); - expect( - tester.getTopLeft(find.text('21:37')).dx, - greaterThan(tester.getTopRight(find.text(_parentSource)).dx), - ); - final dividers = _timelineDividers(tester, devicePixelRatio: 1); - expect(dividers.length, inInclusiveRange(1, 3)); - for (final rect in dividers) { - expect(rect.left, closeTo(0, 0.25)); - expect(rect.right, closeTo(390, 0.25)); - expect(rect.height, closeTo(1, 0.05)); - } - - final disclosureSemantics = tester.getSemantics( - find.bySemanticsLabel( - RegExp('^${RegExp.escape(_parentSource)}.*created at 21:37'), - ), - ); - expect( - disclosureSemantics.getSemanticsData().hasAction(SemanticsAction.tap), - isTrue, - ); - final disclosurePressable = find - .ancestor( - of: find.text(_parentSource), - matching: find.byType(PressableHost), - ) - .first; - final collapsedParentTop = tester.getTopLeft(find.text(_parentSource)).dy; - await tester.tap(disclosurePressable); - await tester.pump(const Duration(milliseconds: 80)); - await harness.pumpUntil( - () => _directChildText(_firstChild).evaluate().isNotEmpty, - reason: 'expanded direct children were not published', - ); - await tester.pump(const Duration(milliseconds: 220)); - expect(find.text(_firstChild), findsOneWidget); - expect(find.text(_secondChild), findsOneWidget); - expect(find.text(_thirdChild), findsNWidgets(2)); - final parentTop = tester.getTopLeft(find.text(_parentSource)).dy; - final firstChildTop = tester.getTopLeft(_directChildText(_firstChild)).dy; - final secondChildTop = tester - .getTopLeft(_directChildText(_secondChild)) - .dy; - expect(firstChildTop - parentTop, closeTo(50, 0.1)); - expect(secondChildTop - firstChildTop, closeTo(44, 0.1)); - expect( - tester.getTopLeft(find.text(_parentSource)).dy, - closeTo(collapsedParentTop, 0.1), - ); - expect( - find.bySemanticsLabel( - RegExp('^${RegExp.escape(_parentSource)}.*created at 21:37'), - ), - findsOneWidget, - ); - expect( - _timelineDividers(tester, devicePixelRatio: 1).length, - inInclusiveRange(1, 3), - ); - if (_writesReferenceGoldens) { - await expectLater( - find.byType(Scaffold).first, - matchesGoldenFile('goldens/journal-reference-alignment.png'), - ); - } - await tester.tap(disclosurePressable); - await tester.pump(const Duration(milliseconds: 80)); - await harness.pumpUntil( - () => _directChildText(_firstChild).evaluate().isEmpty, - reason: 'direct children did not collapse', - ); - await tester.pump(const Duration(milliseconds: 220)); - expect( - tester.getTopLeft(find.text(_parentSource)).dy, - closeTo(collapsedParentTop, 0.1), - ); - - final parentSlidable = find.ancestor( - of: find.text(_parentSource), - matching: find.byType(fs.Slidable), - ); - final parentController = fs.Slidable.of( - tester.element(find.text(_parentSource)), - )!; - await tester.dragFrom( - tester.getCenter(parentSlidable), - const Offset(390, 0), - ); - await _pumpSlidableMotion(tester); - expect(parentController.ratio, closeTo(0.25, 0.01)); - _expectStatusButtonColors( - tester, - parentSlidable, - brightness: _runtimeBrightness, - currentStatus: 'No status', - ); - final noStatusButton = find.descendant( - of: parentSlidable, - matching: find.text('No status'), - ); - expect(noStatusButton, findsOneWidget); - final currentNoStatus = tester.widget( - find.ancestor( - of: noStatusButton, - matching: find.byType(fs.CustomSlidableAction), - ), - ); - expect(currentNoStatus.onPressed, isNotNull); - await tester.dragFrom( - tester.getCenter(parentSlidable), - const Offset(390, 0), - ); - await _pumpSlidableMotion(tester); - expect(parentController.ratio, closeTo(0.25, 0.01)); - expect( - find.bySemanticsLabel(RegExp('$_parentSource.*status ')), - findsNothing, - reason: 'a full-width status drag mutated the block', - ); - await tester.tap(noStatusButton); - await harness.pumpUntil( - () => find.text('Set status').evaluate().isNotEmpty, - reason: 'status sheet did not open', - ); - await tester.tapAt(const Offset(8, 8)); - await harness.pumpUntil( - () => find.text('Set status').evaluate().isEmpty, - reason: 'status sheet barrier did not dismiss the route', - ); - expect( - find.bySemanticsLabel(RegExp('$_parentSource.*status ')), - findsNothing, - reason: 'barrier dismissal changed status', - ); - await _pumpSlidableMotion(tester); - expect(parentController.ratio, closeTo(0, 0.01)); - final statusReopen = parentController.openStartActionPane(); - await _pumpSlidableMotion(tester); - await statusReopen; - expect(parentController.ratio, closeTo(0.25, 0.01)); - await tester.tap(noStatusButton); - await harness.pumpUntil( - () => find.text('Set status').evaluate().isNotEmpty, - reason: 'status sheet did not reopen after dismissal', - ); - await tester.pump(const Duration(milliseconds: 220)); - await tester.tap(find.text('Doing')); - await harness.pumpUntil( - () => - find.text('Set status').evaluate().isEmpty && - find - .bySemanticsLabel(RegExp('$_parentSource.*status Doing')) - .evaluate() - .isNotEmpty, - reason: 'explicit Doing option did not reconcile through the runtime', - ); - await tester.pump(const Duration(milliseconds: 220)); - await _pumpSlidableMotion(tester); - expect(parentController.ratio, closeTo(0, 0.01)); - - final swipeGesture = await tester.startGesture( - tester.getCenter(parentSlidable), - ); - await swipeGesture.moveBy( - const Offset(-80, 0), - timeStamp: const Duration(milliseconds: 500), - ); - await tester.pump(); - final parentDelete = find.descendant( - of: parentSlidable, - matching: find.text('Delete'), - ); - expect(parentDelete, findsOneWidget); - final deleteAction = tester.widget( - find.ancestor( - of: parentDelete, - matching: find.byType(fs.CustomSlidableAction), - ), - ); - expect(deleteAction.borderRadius, BorderRadius.zero); - final deleteActionFinder = find.ancestor( - of: parentDelete, - matching: find.byType(fs.CustomSlidableAction), - ); - final actionRect = tester.getRect(deleteActionFinder); - final actionDividers = find.descendant( - of: deleteActionFinder, - matching: find.byType(Divider), - ); - expect(actionDividers, findsNWidgets(2)); - final dividerRects = actionDividers - .evaluate() - .map( - (element) => tester.getRect( - find.byElementPredicate((candidate) => candidate == element), - ), - ) - .toList(); - expect(dividerRects.first.top, closeTo(actionRect.top, 0.1)); - expect(dividerRects.last.bottom, closeTo(actionRect.bottom, 0.1)); - for (final dividerRect in dividerRects) { - expect(dividerRect.width, closeTo(actionRect.width, 0.1)); - expect(dividerRect.height, closeTo(1, 0.1)); - } - if (_writesReferenceGoldens) { - await expectLater( - find.byType(Scaffold).first, - matchesGoldenFile('goldens/journal-slidable-open.png'), - ); - } - await swipeGesture.up(timeStamp: const Duration(milliseconds: 600)); - await _pumpSlidableMotion(tester); - expect(find.text(_parentSource), findsOneWidget); - expect(find.text('Block and descendants removed'), findsNothing); - - expect(parentController.ratio, closeTo(-0.25, 0.01)); - await tester.tapAt(tester.getCenter(find.text('Todo rail'))); - await _pumpSlidableMotion(tester); - expect(parentController.ratio, closeTo(0, 0.01)); - - await tester.drag(find.text(_parentSource), const Offset(-390, 0)); - await _pumpSlidableMotion(tester); - expect(find.text(_parentSource), findsOneWidget); - expect(parentController.ratio, closeTo(-0.25, 0.01)); - expect(find.text('Block and descendants removed'), findsNothing); - - final secondController = fs.Slidable.of( - tester.element(find.text('Todo rail')), - )!; - final secondOpen = secondController.openEndActionPane(); - await _pumpSlidableMotion(tester); - await secondOpen; - expect(secondController.ratio, closeTo(-0.25, 0.01)); - expect(parentController.ratio, closeTo(0, 0.01)); - - await tester.tapAt(tester.getCenter(find.text(_parentSource))); - await _pumpSlidableMotion(tester); - expect(secondController.ratio, closeTo(0, 0.01)); - final shortestController = fs.Slidable.of( - tester.element(find.text('Todo rail')), - )!; - final shortestOpen = shortestController.openEndActionPane(); - await _pumpSlidableMotion(tester); - await shortestOpen; - expect( - tester.takeException(), - isNull, - reason: 'the delete action overflowed the shortest Journal row', - ); - final shortestRow = _ancestorRectWithHeight( - tester, - find.text('Todo rail'), - 44, - ); - final shortestSlidable = find.ancestor( - of: find.text('Todo rail'), - matching: find.byType(fs.Slidable), - ); - final shortestDelete = find.descendant( - of: shortestSlidable, - matching: find.text('Delete'), - ); - final shortestAction = find.ancestor( - of: shortestDelete, - matching: find.byType(fs.CustomSlidableAction), - ); - expect( - tester.getRect(shortestAction).height, - closeTo(shortestRow.height, 0.1), - ); - await tester.tapAt(tester.getCenter(find.text(_parentSource))); - await _pumpSlidableMotion(tester); - expect(shortestController.ratio, closeTo(0, 0.01)); - - for (final dpr in const [2.0, 3.0, 4.0]) { - tester.view.devicePixelRatio = dpr; - tester.view.physicalSize = Size(390 * dpr, 844 * dpr); - tester.view.padding = FakeViewPadding(top: 47 * dpr, bottom: 34 * dpr); - await harness.pumpUntil(() { - final count = _timelineDividers(tester, devicePixelRatio: dpr).length; - return count >= 1 && count <= 3; - }, reason: 'divider geometry did not settle at ${dpr.toInt()}x'); - final scaledDividers = _timelineDividers(tester, devicePixelRatio: dpr); - expect(scaledDividers.length, inInclusiveRange(1, 3)); - for (final rect in scaledDividers) { - expect(rect.height * dpr, closeTo(1, 0.08)); - expect(rect.left, closeTo(0, 0.25)); - expect(rect.right, closeTo(390, 0.25)); - } - } - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(390, 844); - tester.view.padding = const FakeViewPadding(top: 47, bottom: 34); - await tester.pump(); - await _expectLastRowAboveCaptureBar(tester, harness); - - final fab = find.byType(FloatingActionButton); - await tester.tap(fab); - await tester.pump(); - expect(find.byType(BottomSheet), findsOneWidget); - expect(find.byType(MessageComposer), findsOneWidget); - expect( - tester.widget(find.byType(TextField)).focusNode!.hasFocus, - isTrue, - reason: 'Capture input did not focus on the first mounted sheet frame', - ); - await tester.pump(const Duration(milliseconds: 110)); - expect( - tester.widget(find.byType(TextField)).focusNode!.hasFocus, - isTrue, - ); - await tester.pump(const Duration(milliseconds: 90)); - await tester.pump(); - expect( - tester.widget(find.byType(TextField)).focusNode!.hasFocus, - isTrue, - reason: 'Capture input lost focus during its standard transition', - ); - tester.platformDispatcher.textScaleFactorTestValue = 3.2; - await harness.pumpUntil( - () => - MediaQuery.textScalerOf( - tester.element(find.byType(MessageComposer)), - ).scale(1) > - 3, - reason: 'large text scale did not reach the composer', - ); - journalAppBar = tester.widget(find.byType(SliverAppBar)); - expect(journalAppBar.collapsedHeight, 64); - expect(journalAppBar.toolbarHeight, 56); - expect(journalAppBar.expandedHeight, 64); - tester.view.viewInsets = const FakeViewPadding(bottom: 320); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 220)); - expect( - tester.getBottomRight(find.byType(MessageComposer)).dy, - lessThanOrEqualTo(844 - 320), - ); - tester.view.resetViewInsets(); - tester.platformDispatcher.clearTextScaleFactorTestValue(); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 220)); - - const stagedDraft = ' Capture 中文 👩🏽‍💻 literal-token '; - await tester.enterText(find.byType(TextField), stagedDraft); - await tester.pump(); - _expectMaterialGlyph( - find.byTooltip('Capture as task, off'), - Icons.timelapse, - role: 'unchecked Capture task action', - ); - await tester.tap(find.byTooltip('Capture as task, off')); - await harness.pumpUntil( - () => find.byTooltip('Capture as task, on').evaluate().isNotEmpty, - reason: 'Capture task action did not become checked', - ); - _expectMaterialGlyph( - find.byTooltip('Capture as task, on'), - Icons.timelapse, - role: 'checked Capture task action', - ); - expect( - tester.widget(find.byType(TextField)).controller!.text, - stagedDraft, - reason: 'task selection changed the exact Capture draft', - ); - _expectMaterialGlyph( - find.byTooltip('Save journal block'), - Icons.arrow_upward, - role: 'Capture submit', - ); - expect(find.byType(FloatingActionButton), findsNothing); - await tester.drag(find.byType(MessageComposer), const Offset(0, 80)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 220)); - await tester.pump(const Duration(milliseconds: 220)); - expect( - find.byType(FloatingActionButton), - findsOneWidget, - reason: 'downward swipe did not restore the Capture FAB', - ); - expect(find.byType(MessageComposer), findsNothing); - await tester.tap(find.byType(FloatingActionButton)); - await harness.pumpUntil( - () => - find.byType(TextField).evaluate().isNotEmpty && - tester.widget(find.byType(TextField)).controller!.text == - stagedDraft, - reason: 'Capture draft was not restored after re-expansion', - ); - expect( - find.byTooltip('Capture as task, on'), - findsOneWidget, - reason: 'Capture task intent was not restored after re-expansion', - ); - await tester.pump(const Duration(milliseconds: 220)); - await tester.pump(); - await tester.enterText(find.byType(TextField), ' \n'); - await tester.pump(); - expect(find.byTooltip('Save journal block'), findsNothing); - await tester.enterText(find.byType(TextField), stagedDraft); - await tester.pump(); - await tester.drag(find.byType(MessageComposer), const Offset(0, 80)); - await tester.pump(const Duration(milliseconds: 440)); - await harness.pumpUntil( - () => find.text(_parentSource).evaluate().isNotEmpty, - reason: 'parent row was unavailable for explicit Delete activation', - ); - await tester.drag(find.text(_parentSource), const Offset(-80, 0)); - await _pumpSlidableMotion(tester); - final explicitDelete = find.descendant( - of: find.ancestor( - of: find.text(_parentSource), - matching: find.byType(fs.Slidable), - ), - matching: find.text('Delete'), - ); - await tester.tap(explicitDelete.hitTestable()); - await harness.pumpUntil( - () => - find.text(_parentSource).evaluate().isEmpty && - find.text('Block and descendants removed').evaluate().isNotEmpty, - reason: 'explicit Delete action did not remove the parent row', - ); - expect(find.text('Block and descendants removed'), findsOneWidget); - await tester.pump(const Duration(milliseconds: 250)); - await tester.tap(find.text('Undo')); - await harness.pumpUntil( - () => - find.text(_parentSource).evaluate().isNotEmpty && - find.text('Block and descendants removed').evaluate().isEmpty, - reason: 'Undo did not restore the explicitly deleted parent row', - ); - await harness.dispose(); - }, - skip: Platform.environment['RUN_REAL_OCAML_GOLDEN'] != '1', - timeout: const Timeout(Duration(seconds: 60)), - ); - - testWidgets( - 'real runtime uses one centered status button with exact category colors', - (tester) async { - final harness = await _RuntimeHarness.start( - tester, - brightness: _runtimeBrightness, - highContrast: _runtimeHighContrast, - ); - final row = find.text(_parentSource); - final slidable = find.ancestor( - of: row, - matching: find.byType(fs.Slidable), - ); - final controller = fs.Slidable.of(tester.element(row))!; - - await tester.drag(row, const Offset(390, 0)); - await _pumpSlidableMotion(tester); - expect(controller.ratio, closeTo(0.25, 0.01)); - final statusActions = tester - .widgetList( - find.descendant( - of: slidable, - matching: find.byType(fs.CustomSlidableAction), - ), - ) - .toList(); - expect(statusActions, hasLength(1)); - _expectStatusButtonColors( - tester, - slidable, - brightness: _runtimeBrightness, - currentStatus: 'No status', - ); - for (final action in statusActions) { - expect(action.borderRadius, BorderRadius.zero); - expect(action.padding, isNull); - expect(action.alignment, isNull); - } - _expectActionContentCentered(tester, slidable, 'No status'); - _expectStatusActionVerticallyStackedAndComplete( - tester, - slidable, - 'No status', - ); - - await tester.tap( - find.descendant(of: slidable, matching: find.text('No status')), - ); - await harness.pumpUntil( - () => find.text('Set status').evaluate().isNotEmpty, - reason: 'status sheet did not open before category color coverage', - ); - await tester.pump(const Duration(milliseconds: 220)); - _expectStatusSheetIconOnlyColors(tester, brightness: _runtimeBrightness); - await tester.tap(find.text('In review')); - await harness.pumpUntil( - () => - find.text('Set status').evaluate().isEmpty && - find - .bySemanticsLabel(RegExp('$_parentSource.*status In review')) - .evaluate() - .isNotEmpty, - reason: - 'In review did not reconcile before Delete presentation coverage', - ); - await tester.pump(const Duration(milliseconds: 220)); - await _pumpSlidableMotion(tester); - expect(controller.ratio, closeTo(0, 0.01)); - final reopenStatus = controller.openStartActionPane(); - await _pumpSlidableMotion(tester); - await reopenStatus; - _expectStatusActionVerticallyStackedAndComplete( - tester, - slidable, - 'In review', - ); - final closeStatus = controller.close(); - await _pumpSlidableMotion(tester); - await closeStatus; - - final deleteGesture = await tester.startGesture(tester.getCenter(row)); - await deleteGesture.moveBy( - const Offset(-80, 0), - timeStamp: const Duration(milliseconds: 500), - ); - await tester.pump(); - _expectActionContentCentered(tester, slidable, 'Delete'); - if (_writesReferenceGoldens) { - await expectLater( - find.byType(Scaffold).first, - matchesGoldenFile('goldens/journal-slidable-open-status-flow.png'), - ); - } - await deleteGesture.up(timeStamp: const Duration(milliseconds: 600)); - await _pumpSlidableMotion(tester); - await harness.dispose(); - }, - skip: Platform.environment['RUN_REAL_OCAML_GOLDEN'] != '1', - timeout: const Timeout(Duration(seconds: 60)), - ); -} - -Future _dispatchScrollUpdate( - WidgetTester tester, - Finder scrollable, - double delta, -) async { - await tester.pump(); - final state = tester.state(scrollable); - ScrollUpdateNotification( - metrics: state.position, - context: tester.element(scrollable), - scrollDelta: delta, - ).dispatch(tester.element(scrollable)); -} - -Future _pumpSlidableMotion(WidgetTester tester) async { - await tester.pump(); - await tester.pump(const Duration(milliseconds: 250)); - await tester.pump(); -} - -void _expectTimelineStartsBelowHeader(WidgetTester tester) { - const topInset = 47.0; - final appBar = find.byType(SliverAppBar); - final paintBoundary = - tester.getRect(find.byType(CustomScrollView)).top + - _sliverPaintExtent(tester, appBar); - expect( - tester.getRect(find.text('Wed, Aug 12').first).top, - greaterThanOrEqualTo(topInset), - ); - expect( - tester.getRect(find.bySemanticsLabel('Account menu')).top, - greaterThanOrEqualTo(topInset), - ); - expect(find.text('Wed, Aug 12').hitTestable(), findsOneWidget); - expect( - tester.getRect(find.text(_parentSource)).top, - greaterThanOrEqualTo(paintBoundary - 0.5), - reason: 'the first timeline slot paints underneath the expanded app bar', - ); -} - -double _sliverPaintExtent(WidgetTester tester, Finder finder) { - final renderSliver = tester.renderObject(finder); - return renderSliver.geometry!.paintExtent; -} - -void _expectMaterialGlyph( - Finder scope, - IconData expected, { - required String role, -}) { - final glyphs = find.descendant( - of: scope, - matching: find.byWidgetPredicate( - (widget) => widget is Text && widget.style?.fontFamily == 'MaterialIcons', - ), - ); - expect(glyphs, findsOneWidget, reason: '$role has no Material glyph'); - final text = (glyphs.evaluate().single.widget as Text).data; - expect(text, isNotNull, reason: '$role has no glyph character'); - expect( - text!.runes.single, - expected.codePoint, - reason: '$role does not render ${expected.codePoint.toRadixString(16)}', - ); -} - -Future _expectLastRowAboveCaptureBar( - WidgetTester tester, - _RuntimeHarness harness, -) async { - final timelineScroll = find - .ancestor(of: find.text('Todo rail'), matching: find.byType(Scrollable)) - .last; - await tester.drag(timelineScroll, const Offset(0, -4000)); - await harness.pumpUntil( - () => find.text('Todo rail').evaluate().isNotEmpty, - reason: 'the final journal row was not retained at the end of the timeline', - ); - await tester.pump(const Duration(milliseconds: 220)); - final row = tester.getRect(find.text('Todo rail')); - final captureBar = tester.getRect(find.byType(FloatingActionButton)); - expect( - row.bottom, - lessThanOrEqualTo(captureBar.top + 0.5), - reason: 'the final journal row is obscured by the Capture FAB', - ); -} - -final class _RuntimeHarness { - _RuntimeHarness({ - required this.tester, - required this.runtime, - required this.auth, - required this.platform, - required this.frameEligibility, - required this.root, - required this.removeRoot, - }); - - final WidgetTester tester; - final RuntimeClient runtime; - final _TestAuth auth; - final JournalApplicationPlatform platform; - final _ControllableFrameEligibilitySource frameEligibility; - final Directory root; - final bool removeRoot; - bool _disposed = false; - - static Future<_RuntimeHarness> start( - WidgetTester tester, { - double devicePixelRatio = 1, - Brightness brightness = Brightness.light, - bool highContrast = false, - String typographyPreset = 'balanced', - bool reconcileAuthenticatedUser = false, - }) async { - tester.platformDispatcher.platformBrightnessTestValue = brightness; - tester.platformDispatcher.accessibilityFeaturesTestValue = - FakeAccessibilityFeatures(highContrast: highContrast); - tester.view.devicePixelRatio = devicePixelRatio; - tester.view.physicalSize = Size( - 390 * devicePixelRatio, - 844 * devicePixelRatio, - ); - tester.view.padding = FakeViewPadding( - top: 47 * devicePixelRatio, - bottom: 34 * devicePixelRatio, - ); - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - addTearDown(tester.view.resetPadding); - addTearDown(tester.view.resetViewInsets); - addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); - addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue); - addTearDown(tester.platformDispatcher.clearAccessibilityFeaturesTestValue); - await tester.runAsync(_loadGoldenFonts); - - final configuredRoot = Platform.environment['JOURNAL_GOLDEN_SUPPORT_ROOT']; - final root = configuredRoot == null - ? (await tester.runAsync( - () => Directory.systemTemp.createTemp('journal-golden-'), - ))! - : Directory(configuredRoot); - late final String userId; - late final String baseUrl; - if (configuredRoot == null) { - final fixture = (await tester.runAsync( - () => Process.run( - '../_build/default/test/journal_runtime_golden_fixture.exe', - [root.path], - ), - ))!; - expect( - fixture.exitCode, - 0, - reason: '${fixture.stdout}\n${fixture.stderr}', - ); - final generated = - jsonDecode((fixture.stdout as String).trim()) as Map; - userId = generated['userId']! as String; - baseUrl = generated['baseUrl']! as String; - } else { - userId = - Platform.environment['JOURNAL_GOLDEN_USER_ID'] ?? - (throw StateError( - 'JOURNAL_GOLDEN_USER_ID is required with ' - 'JOURNAL_GOLDEN_SUPPORT_ROOT', - )); - baseUrl = - Platform.environment['JOURNAL_GOLDEN_BASE_URL'] ?? - 'https://api.logseq.io'; - } - - final payload = _managedApplicationPayload(root.path, baseUrl); - final config = RuntimeBootstrapConfig( - entrypoint: 'logseq_journal', - launchPolicy: RuntimeLaunchPolicy.replaceExisting, - applicationPayload: payload, - ).encode(); - final runtime = (await tester.runAsync( - () => RuntimeClient.start( - config: config, - ).timeout(const Duration(seconds: 15)), - ))!; - final frameEligibility = _ControllableFrameEligibilitySource(); - final auth = _TestAuth( - authenticatedUserId: reconcileAuthenticatedUser ? userId : null, - ); - final platform = JournalApplicationPlatform( - auth: auth, - managedSyncOrigin: baseUrl, - readLocalAccountBinding: () async => - (userId: userId, managedSyncOrigin: baseUrl), - readPreference: (_) async => typographyPreset, - writePreference: (_, _) async {}, - ); - await tester.pumpWidget( - fs.SlidableAutoCloseBehavior( - closeWhenOpened: true, - closeWhenTapped: true, - child: BonsaiFlutterRoot( - config: config, - runtimeStarter: (_) async => runtime, - applicationPlatform: platform, - frameEligibilitySource: frameEligibility, - registry: createJournalWidgetRegistry(), - ), - ), - ); - final harness = _RuntimeHarness( - tester: tester, - runtime: runtime, - auth: auth, - platform: platform, - frameEligibility: frameEligibility, - root: root, - removeRoot: configuredRoot == null, - ); - addTearDown(harness.dispose); - await harness.pumpUntil( - () => - find.text('Wed, Aug 12').evaluate().isNotEmpty && - find.text(_parentSource).evaluate().isNotEmpty && - find.text('Capture interaction notes').evaluate().isNotEmpty, - reason: 'the real runtime did not publish the approved fixture', - ); - return harness; - } - - Future pumpUntil( - bool Function() predicate, { - required String reason, - }) async { - final stopwatch = Stopwatch()..start(); - while (!predicate() && stopwatch.elapsed < const Duration(seconds: 15)) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), - ); - await tester.pump(const Duration(milliseconds: 10)); - final exception = tester.takeException(); - if (exception != null) { - fail('$reason; renderer exception: $exception'); - } - } - if (!predicate()) { - final mountedText = tester - .widgetList(find.byType(Text)) - .map((widget) => widget.data) - .whereType() - .toList(); - final runtimeState = await tester.runAsync(runtime.debugSnapshot); - fail( - '$reason; mounted text: $mountedText; runtime: ' - 'state=${runtimeState?.state} generation=${runtimeState?.liveGeneration} ' - 'eligible=${runtimeState?.eligible} grant=${runtimeState?.hasCoalescedGrant} ' - 'presentation=${runtimeState?.unresolvedPresentationId} ' - 'revision=${runtimeState?.unresolvedRevision} pumps=${runtimeState?.pumpCount}', - ); - } - } - - Future dispose() async { - if (_disposed) return; - _disposed = true; - auth.rejectPendingAuthentication(); - var terminationCompleted = false; - final termination = platform.prepareForTermination().whenComplete(() { - terminationCompleted = true; - }); - final terminationWatch = Stopwatch()..start(); - while (!terminationCompleted && - terminationWatch.elapsed < const Duration(seconds: 5)) { - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 10)), - ); - await tester.pump(const Duration(milliseconds: 10)); - } - await termination; - var settled = await tester.runAsync(runtime.debugSnapshot); - for ( - var attempt = 0; - attempt < 8 && settled?.state == RuntimeWorkerState.awaitingPresentation; - attempt += 1 - ) { - runtime.presentationSucceeded( - generation: settled!.liveGeneration, - presentationId: settled.unresolvedPresentationId!, - revision: settled.unresolvedRevision!, - eventBatch: Uint8List(0), - ); - settled = await tester.runAsync(() async { - await Future.delayed(const Duration(milliseconds: 10)); - return runtime.debugSnapshot(); - }); - } - frameEligibility.setEligible(false); - await tester.runAsync( - () => runtime.dispose().timeout(const Duration(seconds: 5)), - ); - platform.dispose(); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 100)), - ); - if (removeRoot) { - try { - root.deleteSync(recursive: true); - } on PathNotFoundException { - // Runtime shutdown may remove the support root before test cleanup. - } - } - } -} - -Uint8List _managedApplicationPayload(String supportRoot, String baseUrl) { - final json = utf8.encode( - jsonEncode({ - 'applicationSupportDirectory': supportRoot, - 'target': {'kind': 'managedSync', 'baseUrl': baseUrl}, - 'compatibilityProfile': 'logseq-65.33-or-newer', - 'responseBudgetBytes': 262144, - 'defaultPageSize': 50, - }), - ); - final payload = Uint8List(8 + json.length); - payload.setRange(0, 4, ascii.encode('LDB1')); - ByteData.sublistView(payload).setUint32(4, json.length, Endian.little); - payload.setRange(8, payload.length, json); - return payload; -} - -Rect _ancestorRectWithHeight(WidgetTester tester, Finder child, double height) { - final candidates = find.ancestor( - of: child, - matching: find.byWidgetPredicate((widget) => widget is SizedBox), - ); - final candidateRects = []; - for (final element in candidates.evaluate()) { - final finder = find.byElementPredicate((candidate) => candidate == element); - final rect = tester.getRect(finder); - candidateRects.add(rect); - if ((rect.height - height).abs() < 0.1 && rect.width > 300) return rect; - } - throw TestFailure( - 'no $height-point row ancestor was found; candidates: $candidateRects', - ); -} - -void _expectActionContentCentered( - WidgetTester tester, - Finder slidable, - String label, -) { - final actionLabel = find.descendant(of: slidable, matching: find.text(label)); - final action = find.ancestor( - of: actionLabel, - matching: find.byType(fs.CustomSlidableAction), - ); - final content = find.descendant(of: action, matching: find.byType(Text)); - expect( - content, - findsNWidgets(2), - reason: '$label action does not contain exactly one icon and one label', - ); - final actionCenter = tester.getCenter(action); - final contentRect = _combinedRect(tester, content); - final contentCenter = contentRect.center; - expect( - contentCenter.dx, - closeTo(actionCenter.dx, 0.1), - reason: '$label content is not horizontally centered', - ); - expect( - contentCenter.dy, - closeTo(actionCenter.dy, 0.1), - reason: - '$label content $contentRect is not vertically centered in the action at $actionCenter', - ); -} - -void _expectStatusActionVerticallyStackedAndComplete( - WidgetTester tester, - Finder slidable, - String label, -) { - final actionLabel = find.descendant(of: slidable, matching: find.text(label)); - final action = find.ancestor( - of: actionLabel, - matching: find.byType(fs.CustomSlidableAction), - ); - final textElements = find - .descendant(of: action, matching: find.byType(Text)) - .evaluate() - .toList(); - expect(textElements, hasLength(2)); - final labelElement = actionLabel.evaluate().single; - final iconElement = textElements.singleWhere( - (element) => !identical(element, labelElement), - ); - final icon = find.byElementPredicate( - (element) => identical(element, iconElement), - ); - final iconRect = tester.getRect(icon); - final labelRect = tester.getRect(actionLabel); - final actionRect = tester.getRect(action); - expect( - iconRect.bottom, - lessThanOrEqualTo(labelRect.top), - reason: '$label icon is not above its label', - ); - expect(iconRect.center.dx, closeTo(actionRect.center.dx, 0.1)); - expect(labelRect.center.dx, closeTo(actionRect.center.dx, 0.1)); - final labelWidget = tester.widget(actionLabel); - expect( - labelWidget.overflow, - isNot(TextOverflow.ellipsis), - reason: '$label still permits ellipsis', - ); - final paragraph = tester.renderObject( - find.descendant(of: actionLabel, matching: find.byType(RichText)), - ); - expect( - paragraph.didExceedMaxLines, - isFalse, - reason: '$label does not fit completely in the status action', - ); -} - -void _expectStatusSheetIconOnlyColors( - WidgetTester tester, { - required Brightness brightness, -}) { - final iconColors = brightness == Brightness.light - ? _lightStatusCategoryBackgrounds - : _darkStatusCategoryBackgrounds; - final noStatusForeground = brightness == Brightness.light - ? const Color(0xff00262f) - : const Color(0xffa7b8bc); - final rows = <(String, Color)>[ - ('Backlog', iconColors[3]), - ('Todo', iconColors[0]), - ('Doing', iconColors[1]), - ('In review', iconColors[1]), - ('Done', iconColors[2]), - ('Canceled', iconColors[2]), - ('Clear', noStatusForeground), - ]; - for (final (label, expectedIconColor) in rows) { - final labelFinder = find.text(label); - final coloredRows = find - .ancestor( - of: labelFinder, - matching: find.byWidgetPredicate( - (widget) => - widget is DecoratedBox && widget.decoration is BoxDecoration, - ), - ) - .evaluate() - .where((element) { - final rect = tester.getRect( - find.byElementPredicate( - (candidate) => identical(candidate, element), - ), - ); - return rect.width > 300 && rect.height >= 44 && rect.height < 80; - }) - .toList(); - expect( - coloredRows, - isEmpty, - reason: '$label still has a full-width status background', - ); - final rowCandidates = find - .ancestor(of: labelFinder, matching: find.byType(Row)) - .evaluate() - .where((element) { - final rect = tester.getRect( - find.byElementPredicate( - (candidate) => identical(candidate, element), - ), - ); - return rect.width > 300 && rect.height >= 44 && rect.height < 80; - }) - .toList(); - expect(rowCandidates, hasLength(1)); - final row = find.byElementPredicate( - (element) => identical(element, rowCandidates.single), - ); - final texts = tester - .widgetList(find.descendant(of: row, matching: find.byType(Text))) - .toList(); - expect(texts, hasLength(2)); - final labelText = texts.singleWhere((text) => text.data == label); - final iconText = texts.singleWhere((text) => text.data != label); - expect(labelText.style?.color, isNull); - expect(iconText.style?.color, expectedIconColor); - } -} - -Rect _combinedRect(WidgetTester tester, Finder finder) { - final elements = finder.evaluate().toList(); - if (elements.isEmpty) throw TestFailure('cannot combine an empty finder'); - return elements - .map( - (element) => tester.getRect( - find.byElementPredicate((candidate) => identical(candidate, element)), - ), - ) - .reduce((combined, rect) => combined.expandToInclude(rect)); -} - -List _timelineDividers( - WidgetTester tester, { - required double devicePixelRatio, -}) { - final expectedHeight = 1 / devicePixelRatio; - return find - .byType(Divider) - .evaluate() - .map( - (element) => - tester.getRect(find.byElementPredicate((e) => e == element)), - ) - .where( - (rect) => - rect.width > 300 && (rect.height - expectedHeight).abs() < 0.08, - ) - .toList(); -} - -Finder _directChildText(String source) => find.descendant( - of: find.bySemanticsLabel(RegExp('^Direct child: ${RegExp.escape(source)}')), - matching: find.text(source), -); - -void _expectSeedOwnedSemanticColors( - WidgetTester tester, { - required Brightness brightness, - required bool highContrast, -}) { - final context = tester.element(find.text('Today')); - final scheme = Theme.of(context).colorScheme; - final expected = ColorScheme.fromSeed( - seedColor: const Color(0xff00262f), - brightness: brightness, - contrastLevel: highContrast ? 1 : 0, - ); - expect(scheme.brightness, brightness); - expect(scheme.primary, expected.primary); - expect(scheme.surface, expected.surface); - expect(scheme.onSurface, expected.onSurface); - expect(scheme.error, expected.error); - expect(tester.widget(find.text('Today')).style?.color, isNull); - expect(tester.widget(find.text('Wed, Aug 12')).style?.color, isNull); - final railColors = _statusRailColors(tester); - expect(railColors, hasLength(4)); - expect(railColors.toSet(), hasLength(4)); - final expectedBackgrounds = brightness == Brightness.light - ? _lightStatusCategoryBackgrounds - : _darkStatusCategoryBackgrounds; - expect( - railColors.toSet(), - expectedBackgrounds.toSet(), - reason: 'status rails do not use the brightness-specific semantic colors', - ); - for (final color in railColors) { - expect(_contrastRatio(color, scheme.surface), greaterThanOrEqualTo(3)); - } - for (final element in find.byType(fs.CustomSlidableAction).evaluate()) { - final action = element.widget as fs.CustomSlidableAction; - if (action.backgroundColor.a == 0) { - expect(action.foregroundColor, isNotNull); - expect( - _contrastRatio(action.foregroundColor!, scheme.surface), - greaterThanOrEqualTo(4.5), - ); - continue; - } - expect( - _contrastRatio(action.backgroundColor, scheme.surface), - greaterThanOrEqualTo(3), - ); - expect(action.foregroundColor, isNotNull); - expect( - _contrastRatio(action.foregroundColor!, action.backgroundColor), - greaterThanOrEqualTo(4.5), - ); - } -} - -void _expectStatusButtonColors( - WidgetTester tester, - Finder slidable, { - required Brightness brightness, - required String currentStatus, -}) { - final expectedBackgrounds = brightness == Brightness.light - ? _lightStatusCategoryBackgrounds - : _darkStatusCategoryBackgrounds; - final expectedForegrounds = brightness == Brightness.light - ? _lightStatusCategoryForegrounds - : _darkStatusCategoryForegrounds; - final categoryIndex = switch (currentStatus) { - 'Todo' => 0, - 'Doing' || 'In review' || 'Now' => 1, - 'Done' || 'Canceled' => 2, - 'Backlog' || 'Waiting' || 'Later' => 3, - 'No status' => null, - _ => throw TestFailure('unknown exact status $currentStatus'), - }; - final action = tester.widget( - find.ancestor( - of: find.descendant(of: slidable, matching: find.text(currentStatus)), - matching: find.byType(fs.CustomSlidableAction), - ), - ); - expect( - action.backgroundColor, - categoryIndex == null - ? Colors.transparent - : expectedBackgrounds[categoryIndex], - reason: '$currentStatus background does not match $brightness', - ); - expect( - action.foregroundColor, - categoryIndex == null - ? brightness == Brightness.light - ? const Color(0xff00262f) - : const Color(0xffa7b8bc) - : expectedForegrounds[categoryIndex], - reason: '$currentStatus foreground does not match $brightness', - ); -} - -List _statusRailColors(WidgetTester tester) => find - .byWidgetPredicate( - (widget) => - widget is DecoratedBox && - widget.decoration is BoxDecoration && - (widget.decoration as BoxDecoration).borderRadius == - BorderRadius.circular(2), - ) - .evaluate() - .where((element) { - final rect = tester.getRect(find.byElementPredicate((e) => e == element)); - return (rect.width - 4).abs() < 0.1; - }) - .map( - (element) => - ((element.widget as DecoratedBox).decoration as BoxDecoration).color!, - ) - .toList(); - -double _contrastRatio(Color left, Color right) { - final lighter = left.computeLuminance() > right.computeLuminance() - ? left - : right; - final darker = identical(lighter, left) ? right : left; - return (lighter.computeLuminance() + 0.05) / - (darker.computeLuminance() + 0.05); -} - -Future _loadGoldenFonts() async { - final materialFonts = _findMaterialFontsDirectory(); - Future load(String name) async => ByteData.sublistView( - await File('${materialFonts.path}/$name').readAsBytes(), - ); - await (FontLoader('Roboto')..addFont(load('Roboto-Regular.ttf'))).load(); - // FontLoader cannot select a face from the system PingFang TTC collection. - // Register one deterministic mixed-script test face under the production - // family name; protocol assertions separately verify the published chain. - await (FontLoader('PingFang SC')..addFont( - File( - '/System/Library/Fonts/Supplemental/Arial Unicode.ttf', - ).readAsBytes().then(ByteData.sublistView), - )) - .load(); - await (FontLoader('Apple Color Emoji')..addFont( - File( - '/System/Library/Fonts/Apple Color Emoji.ttc', - ).readAsBytes().then(ByteData.sublistView), - )) - .load(); - await (FontLoader( - 'MaterialIcons', - )..addFont(load('MaterialIcons-Regular.otf'))).load(); -} - -Directory _findMaterialFontsDirectory() { - var directory = File(Platform.resolvedExecutable).parent; - for (var depth = 0; depth < 8; depth += 1) { - final candidate = Directory( - '${directory.path}/bin/cache/artifacts/material_fonts', - ); - if (candidate.existsSync()) return candidate; - final parent = directory.parent; - if (parent.path == directory.path) break; - directory = parent; - } - throw StateError('Flutter SDK material fonts directory is unavailable'); -} - -final class _ControllableFrameEligibilitySource - implements FrameEligibilitySource { - bool _eligible = true; - void Function(bool)? _onChanged; - - @override - bool get isEligible => _eligible; - - @override - void start(void Function(bool isEligible) onChanged) { - _onChanged = onChanged; - } - - void setEligible(bool eligible) { - if (_eligible == eligible) return; - _eligible = eligible; - _onChanged?.call(eligible); - } - - @override - void dispose() { - _onChanged = null; - } -} diff --git a/flutter/test/journal_tail_fade_test.dart b/flutter/test/journal_tail_fade_test.dart deleted file mode 100644 index a352e3b..0000000 --- a/flutter/test/journal_tail_fade_test.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'dart:typed_data'; - -import 'package:bonsai_flutter_logseq_journal_host/journal_tail_fade.dart'; -import 'package:bonsai_flutter_logseq_journal_host/journal_widget_registry.dart'; -import 'package:material_ui/material_ui.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('tail fade props decode finite little-endian geometry', () { - final payload = Uint8List(16); - final data = ByteData.sublistView(payload) - ..setFloat64(0, 22, Endian.little) - ..setFloat64(8, 24, Endian.little); - - final props = JournalTailFadeProps.decode(payload); - expect(props.lineHeight, 22); - expect(props.fadeWidth, 24); - expect(createJournalWidgetRegistry(), isNotNull); - - expect( - () => JournalTailFadeProps.decode(Uint8List(15)), - throwsFormatException, - ); - data.setFloat64(0, double.nan, Endian.little); - expect(() => JournalTailFadeProps.decode(payload), throwsFormatException); - }); - - for (final direction in TextDirection.values) { - testWidgets('tail fade covers only the final $direction line end', ( - tester, - ) async { - const surface = Color(0xff112233); - await tester.pumpWidget( - MaterialApp( - theme: ThemeData(scaffoldBackgroundColor: surface), - home: Scaffold( - body: Center( - child: Directionality( - textDirection: direction, - child: const SizedBox( - width: 120, - child: JournalTailFade( - props: JournalTailFadeProps(lineHeight: 22, fadeWidth: 24), - child: SizedBox( - width: 120, - height: 66, - child: Text( - 'A long title that occupies more than three lines', - maxLines: 3, - overflow: TextOverflow.clip, - ), - ), - ), - ), - ), - ), - ), - ), - ); - - final host = find.byType(JournalTailFade); - final gradient = find.descendant( - of: host, - matching: find.byWidgetPredicate( - (widget) => - widget is DecoratedBox && - widget.decoration is BoxDecoration && - (widget.decoration as BoxDecoration).gradient != null, - ), - ); - expect(gradient, findsOneWidget); - final hostRect = tester.getRect(host); - final fadeRect = tester.getRect(gradient); - expect(fadeRect.width, 24); - expect(fadeRect.height, 22); - expect(fadeRect.bottom, hostRect.bottom); - if (direction == TextDirection.ltr) { - expect(fadeRect.right, hostRect.right); - } else { - expect(fadeRect.left, hostRect.left); - } - - final decoration = - tester.widget(gradient).decoration as BoxDecoration; - final colors = (decoration.gradient! as LinearGradient).colors; - expect(colors.first.a, 0); - expect(colors.last, surface); - }); - } -} diff --git a/flutter/test/logseq_db_worker_host_adapter_test.dart b/flutter/test/logseq_db_worker_host_adapter_test.dart index 824592b..726f271 100644 --- a/flutter/test/logseq_db_worker_host_adapter_test.dart +++ b/flutter/test/logseq_db_worker_host_adapter_test.dart @@ -1,6 +1,6 @@ import 'dart:convert'; -import 'package:bonsai_flutter_logseq_journal_host/application_host_adapter.dart'; +import 'package:logseq_journal_host/application_host_adapter.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/flutter/test/macos_edit_menu_test.dart b/flutter/test/macos_edit_menu_test.dart deleted file mode 100644 index b6189a1..0000000 --- a/flutter/test/macos_edit_menu_test.dart +++ /dev/null @@ -1,140 +0,0 @@ -import 'dart:io'; - -import 'package:bonsai_flutter/bonsai_flutter.dart'; -import 'package:bonsai_flutter_logseq_journal_host/application_host_adapter.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -// ignore: depend_on_referenced_packages -import 'package:material_ui/material_ui.dart'; - -final class _UnusedAuth implements JournalAuthCapability { - @override - Future currentUserId() async => throw StateError('unused'); - @override - Future freshIdToken() async => throw StateError('unused'); - @override - Future signOut() async => throw StateError('unused'); -} - -void main() { - testWidgets( - 'native Edit menu updates the Capture controller and save text', - (tester) async { - final messenger = tester.binding.defaultBinaryMessenger; - Object? menus; - String? clipboard = '中文 😀\nsecond line'; - messenger.setMockMethodCallHandler(SystemChannels.menu, (call) async { - if (call.method == 'Menu.setMenus') menus = call.arguments; - return null; - }); - messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { - if (call.method == 'Clipboard.getData') return {'text': clipboard}; - if (call.method == 'Clipboard.setData') { - clipboard = (call.arguments as Map)['text'] as String?; - } - return null; - }); - addTearDown(() { - messenger.setMockMethodCallHandler(SystemChannels.menu, null); - messenger.setMockMethodCallHandler(SystemChannels.platform, null); - }); - final adapter = ApplicationHostAdapter( - applicationSupportDirectory: () async => Directory.systemTemp, - baseUrl: Uri.parse('https://example.invalid'), - auth: _UnusedAuth(), - readPreference: (_) async => null, - writePreference: (_, _) async {}, - readLocalAccountBinding: () async => - (userId: 'fixture', managedSyncOrigin: 'https://example.invalid'), - ); - String? changed; - String? saved; - await tester.pumpWidget( - Builder( - builder: (context) => adapter.buildHost( - context: context, - child: MaterialApp( - home: Scaffold( - body: ExpandableMessageComposer( - fabPresentation: - ExpandableMessageComposerFabPresentation.extended, - fabLabel: 'Capture', - fabTooltip: 'Open Capture', - fabIcon: const Icon(Icons.add), - buttons: const [ - MessageComposerButton( - id: 1, - tooltip: 'Save', - child: Icon(Icons.send), - ), - ], - onChanged: (value) => changed = value, - onButtonPressed: (_, value) => saved = value, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Open Capture')); - await tester.pumpAndSettle(); - await tester.enterText(find.byType(EditableText), 'Prefix '); - - int? menuId(Object? tree, String label) { - if (tree is Map) { - if (tree['label'] == label) return tree['id'] as int?; - for (final value in tree.values) { - final result = menuId(value, label); - if (result != null) return result; - } - } else if (tree is List) { - for (final value in tree) { - final result = menuId(value, label); - if (result != null) return result; - } - } - return null; - } - - Future select(String label) async { - final id = menuId(menus, label); - expect( - id, - isNotNull, - reason: '$label must reach Flutter through the native menu', - ); - await messenger.handlePlatformMessage( - SystemChannels.menu.name, - SystemChannels.menu.codec.encodeMethodCall( - MethodCall('Menu.selectedCallback', id), - ), - (_) {}, - ); - await tester.pumpAndSettle(); - } - - await select('Paste'); - expect(changed, 'Prefix 中文 😀\nsecond line'); - const followingEdit = 'Prefix 中文 😀\nsecond line!'; - tester.testTextInput.updateEditingValue( - TextEditingValue( - text: followingEdit, - selection: TextSelection.collapsed(offset: followingEdit.length), - ), - ); - await tester.pumpAndSettle(); - await select('Select All'); - await select('Copy'); - expect(clipboard, followingEdit); - await select('Cut'); - expect(changed, ''); - await select('Paste'); - expect(changed, followingEdit); - await tester.tap(find.byTooltip('Save')); - await tester.pumpAndSettle(); - expect(saved, followingEdit); - }, - variant: TargetPlatformVariant.only(TargetPlatform.macOS), - ); -} diff --git a/flutter/test/widget_test.dart b/flutter/test/widget_test.dart index 00e82a2..0403a3f 100644 --- a/flutter/test/widget_test.dart +++ b/flutter/test/widget_test.dart @@ -1,13 +1,13 @@ -import 'package:bonsai_flutter_logseq_journal_host/application_host_adapter.dart' +import 'package:logseq_journal_host/application_host_adapter.dart' as application; -import 'package:bonsai_flutter_logseq_journal_host/main.dart'; +import 'package:logseq_journal_host/main.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { test('custom host can be constructed', () { expect( JournalApplicationHost( - adapter: application.createBonsaiFlutterHostAdapter(), + adapter: application.createJournalHostAdapter(), runtimeOwner: JournalRuntimeOwner(), ), isNotNull, From 2742c7cdb31e69f387478860f419c04677019ca4 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 19:38:44 -0700 Subject: [PATCH 07/40] Port worker test suite to lui service - test_bonsai_service.ml renamed to test_lui_service.ml and re-pointed to Logseq_db_worker_lui.Logseq_db_worker_lui_service, Journal_worker, Journal_worker_runtime, Journal_worker_ids - test_managed_sync_e2e.ml re-pointed to the same lui modules - dune: drop bonsai_swiftui.driver, bonsai_swiftui_test, logseq_db_worker.bonsai; add logseq_db_worker.lui Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- logseq_db_worker/test/dune | 18 ++++++++++-------- ...t_bonsai_service.ml => test_lui_service.ml} | 0 logseq_db_worker/test/test_managed_sync_e2e.ml | 6 ++++-- 3 files changed, 14 insertions(+), 10 deletions(-) rename logseq_db_worker/test/{test_bonsai_service.ml => test_lui_service.ml} (100%) diff --git a/logseq_db_worker/test/dune b/logseq_db_worker/test/dune index 4131ac1..27b4638 100644 --- a/logseq_db_worker/test/dune +++ b/logseq_db_worker/test/dune @@ -27,16 +27,14 @@ uri)) (test - (name test_bonsai_service) - (modules test_bonsai_service) + (name test_lui_service) + (modules test_lui_service) (libraries - bonsai_swiftui.driver - bonsai_swiftui_test datascript-ocaml-native datascript_ocaml datascript_ocaml.types logseq_db_worker - logseq_db_worker.bonsai + logseq_db_worker.lui logseq_db_worker_test_support logseq_sync.effect_runner)) @@ -82,7 +80,6 @@ (modules test_managed_sync_e2e managed_sync_protocol_probe) (libraries bigstringaf - bonsai_swiftui.driver ca-certs-nss cstruct datascript-ocaml-native @@ -99,7 +96,7 @@ logseq_db_storage logseq_db_types logseq_db_worker - logseq_db_worker.bonsai + logseq_db_worker.lui logseq_sync.pure_reducer managed_sync_e2e_support mirage-crypto-rng @@ -118,7 +115,12 @@ (test (name test_asset_upload) (modules test_asset_upload) - (libraries alcotest logseq_db_worker.pure_reducer logseq_db_types logseq_sync.pure_reducer uri)) + (libraries + alcotest + logseq_db_worker.pure_reducer + logseq_db_types + logseq_sync.pure_reducer + uri)) (test (name test_asset_protocol) diff --git a/logseq_db_worker/test/test_bonsai_service.ml b/logseq_db_worker/test/test_lui_service.ml similarity index 100% rename from logseq_db_worker/test/test_bonsai_service.ml rename to logseq_db_worker/test/test_lui_service.ml diff --git a/logseq_db_worker/test/test_managed_sync_e2e.ml b/logseq_db_worker/test/test_managed_sync_e2e.ml index 3ce0b1a..c02110c 100644 --- a/logseq_db_worker/test/test_managed_sync_e2e.ml +++ b/logseq_db_worker/test/test_managed_sync_e2e.ml @@ -1,10 +1,12 @@ -module Service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Service = Logseq_db_worker_lui.Logseq_db_worker_lui_service module Core = Service +module Worker = Logseq_db_worker_lui.Journal_worker +module Worker_runtime = Logseq_db_worker_lui.Journal_worker_runtime module Protocol = Logseq_db_worker.Protocol module Support = Managed_sync_e2e_support module Probe = Managed_sync_protocol_probe module Sync_protocol = Logseq_sync_pure_reducer.Sync_protocol -module ID = Bonsai_swiftui_spec.Id +module ID = Logseq_db_worker_lui.Journal_worker_ids exception E2e_failure of string From 363befea340305e5ad51328020b429ba2155bd2f Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 19:38:57 -0700 Subject: [PATCH 08/40] Port app test suite to Journal_view/Journal_ids lui shims - Re-point service imports to Logseq_db_worker_lui service in startup_test, application_view_test, journal_media*_test, journal_uploads_test - journal_adaptive_test: assert symbol identity via Journal_symbols.name and Journal_view.View.For_testing.key; decode Style.Color channels through ordered public rgb probes (shim has no to_argb32/view introspection) - journal_semantics_test: keep only test_timeline_media_targets (pure Journal_row.view); the remaining cases drove the removed bonsai_swiftui_test mounted-tree harness and cannot port - journal_routes_test, journal_timeline_state_test: Journal_ids/Journal_view renames; drop Hidden_target (no such lui outcome) - macos_mutation_input_diagnostics_pure_reducer_test: same renames (unwired) - source_boundary_test: lui service paths, lui/ocaml-signal opam pins, Journal_platform.show_notice_request; drop sexp manifest assertions - test/dune: drop all bonsai_swiftui* library entries and the bonsai-swiftui.sexp dep - Remove bonsai-swiftui.sexp and bonsai-flutter.sexp manifests - Delete test/macos_application_dispatch_test.ml: every case depended on the removed Bonsai test handle/mounted-tree harness - Finish test_lui_service.ml renames left unstaged Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- bonsai-swiftui.sexp | 31 - logseq_db_worker/test/test_lui_service.ml | 8 +- test/application_view_test.ml | 2 +- test/dune | 53 +- test/journal_adaptive_test.ml | 56 +- test/journal_media_runtime_test.ml | 27 +- test/journal_media_test.ml | 2 +- test/journal_routes_test.ml | 14 +- test/journal_semantics_test.ml | 778 +------ test/journal_timeline_state_test.ml | 14 +- test/journal_uploads_test.ml | 2 +- test/macos_application_dispatch_test.ml | 1856 ----------------- ...ion_input_diagnostics_pure_reducer_test.ml | 4 +- test/source_boundary_test.ml | 79 +- test/startup_test.ml | 2 +- 15 files changed, 130 insertions(+), 2798 deletions(-) delete mode 100644 bonsai-swiftui.sexp delete mode 100644 test/macos_application_dispatch_test.ml diff --git a/bonsai-swiftui.sexp b/bonsai-swiftui.sexp deleted file mode 100644 index 10227b3..0000000 --- a/bonsai-swiftui.sexp +++ /dev/null @@ -1,31 +0,0 @@ -(lang 4) - -(app - (name logseq_journal) - (apple_root apple) - (native_target app/native_embed.exe.o) - (features network sqlite) - (macos - (bundle_identifier com.logseq.journal) - (minimum_version 26.0) - (architectures arm64) - (entitlements - (debug config/entitlements/macos-debug-profile.entitlements) - (profile config/entitlements/macos-debug-profile.entitlements) - (release config/entitlements/macos-release.entitlements))) - (ios - (bundle_identifier com.example.bonsaiFlutterLogseqJournalHost) - (minimum_version 26.0) - (architectures arm64) - (entitlements - (debug config/entitlements/ios-debug-profile.entitlements) - (profile config/entitlements/ios-debug-profile.entitlements) - (release config/entitlements/ios-release.entitlements))) - (swift_packages - (package - (id amplify-swift) - (url https://github.com/aws-amplify/amplify-swift.git) - (requirement (exact 2.61.0)) - (products - (product (name Amplify) (platforms macos ios)) - (product (name AWSCognitoAuthPlugin) (platforms macos ios)))))) diff --git a/logseq_db_worker/test/test_lui_service.ml b/logseq_db_worker/test/test_lui_service.ml index ba3ae9a..55e67aa 100644 --- a/logseq_db_worker/test/test_lui_service.ml +++ b/logseq_db_worker/test/test_lui_service.ml @@ -1,8 +1,10 @@ module T = Logseq_db_worker_test_support.Test_support module P = Logseq_db_worker.Protocol -module ID = Bonsai_swiftui_spec.Id -module Service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module ID = Logseq_db_worker_lui.Journal_worker_ids +module Service = Logseq_db_worker_lui.Logseq_db_worker_lui_service module Runner = Logseq_sync_effect_runner.Effect_runner +module Worker = Logseq_db_worker_lui.Journal_worker +module Worker_runtime = Logseq_db_worker_lui.Journal_worker_runtime let crypto = Runner.crypto @@ -120,7 +122,7 @@ let test_managed_client_command_is_accepted () = let () = T.run - "bonsai worker service" + "lui worker service" [ T.case "managed worker starts closed with a v2 envelope" test_managed_worker_starts_closed_and_replies_with_v2_envelope diff --git a/test/application_view_test.ml b/test/application_view_test.ml index 0df05d5..4ac4119 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -1,4 +1,4 @@ -module Service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Service = Logseq_db_worker_lui.Logseq_db_worker_lui_service let check_pairs label expected actual = Alcotest.(check (list (pair string string))) label expected actual diff --git a/test/dune b/test/dune index 9e71653..1200d18 100644 --- a/test/dune +++ b/test/dune @@ -24,7 +24,6 @@ (source_tree ../logseq_sync) (source_tree ../tool) (source_tree ../swift) - ../bonsai-swiftui.sexp ../dune-project ../flutter/ios/Flutter/Debug.xcconfig ../flutter/ios/Flutter/Release.xcconfig @@ -73,20 +72,12 @@ (test (name journal_adaptive_test) (modules journal_adaptive_test) - (libraries app bonsai_swiftui bonsai_swiftui.driver bonsai_swiftui.ui)) + (libraries app)) (test (name journal_semantics_test) (modules journal_semantics_test) - (libraries - app - bonsai - bonsai_swiftui - bonsai_swiftui.driver - bonsai_swiftui.spec - bonsai_swiftui.ui - bonsai_swiftui_test - core)) + (libraries app)) (test (name journal_graph_runtime_locality_test) @@ -111,7 +102,7 @@ (test (name journal_timeline_state_test) (modules journal_timeline_state_test) - (libraries app bonsai_swiftui.ui datascript-ocaml-native unix)) + (libraries app datascript-ocaml-native unix)) (test (name journal_time_test) @@ -126,13 +117,16 @@ (test (name journal_routes_test) (modules journal_routes_test) - (libraries app bonsai_swiftui.spec bonsai_swiftui.ui)) + (libraries app)) (rule (alias runtest) (deps test_native_static_gmp.sh ../tool/native_static_link_flags.sh) (action - (run sh %{dep:test_native_static_gmp.sh} %{dep:../tool/native_static_link_flags.sh}))) + (run + sh + %{dep:test_native_static_gmp.sh} + %{dep:../tool/native_static_link_flags.sh}))) (rule (alias runtest) @@ -150,16 +144,33 @@ (modules journal_asset_policy_test) (libraries app)) -(test (name journal_media_test) (modules journal_media_test) (libraries app uri)) +(test + (name journal_media_test) + (modules journal_media_test) + (libraries app uri)) -(test (name journal_media_runtime_test) (modules journal_media_runtime_test) (libraries app uri)) +(test + (name journal_media_runtime_test) + (modules journal_media_runtime_test) + (libraries app uri)) -(test (name journal_asset_settings_test) (modules journal_asset_settings_test) (libraries app)) +(test + (name journal_asset_settings_test) + (modules journal_asset_settings_test) + (libraries app)) (rule (alias runtest) - (enabled_if (= %{system} macosx)) - (deps test_asset_preferences.sh asset_preferences_test.swift ../swift/JournalAssetPreferences.swift) - (action (run sh %{dep:test_asset_preferences.sh} %{workspace_root}))) + (enabled_if + (= %{system} macosx)) + (deps + test_asset_preferences.sh + asset_preferences_test.swift + ../swift/JournalAssetPreferences.swift) + (action + (run sh %{dep:test_asset_preferences.sh} %{workspace_root}))) -(test (name journal_uploads_test) (modules journal_uploads_test) (libraries app)) +(test + (name journal_uploads_test) + (modules journal_uploads_test) + (libraries app)) diff --git a/test/journal_adaptive_test.ml b/test/journal_adaptive_test.ml index c311404..e3a6eee 100644 --- a/test/journal_adaptive_test.ml +++ b/test/journal_adaptive_test.ml @@ -1,5 +1,5 @@ module Tokens = Journal_visual_tokens -module Ui = Bonsai_swiftui_ui +module Ui = Journal_view let require condition format = Printf.ksprintf (fun message -> if not condition then failwith message) format @@ -22,17 +22,16 @@ let test_sf_symbols_preserve_identity_and_appearance () = List.iter (fun (role, expected) -> let color = Ui.Style.Color.rgb ~red:17 ~green:34 ~blue:51 in - let key = Ui.Key.string ("symbol:" ^ expected) in - let widget = Journal_symbols.create ~key ~size:19. ~color role in - let (Av view) = Ui.View.Private.view widget in - (match view.node with - | Ui.View.Private.Symbol { name; size; color; _ } -> - require (name = expected) "Unexpected SF Symbol %s" name; - require (size = Some 19.) "Symbol size changed"; - require (color = Some 0xff112233l) "Symbol tint changed" - | _ -> failwith "Expected a native symbol"); + let key = "symbol:" ^ expected in + let widget = + Journal_symbols.create ~key:(Ui.Key.string key) ~size:19. ~color role + in require - (Option.equal Ui.Key.equal (Ui.View.For_testing.key widget) (Some key)) + (String.equal (Journal_symbols.name role) expected) + "Unexpected SF Symbol %s" + (Journal_symbols.name role); + require + (Option.equal String.equal (Ui.View.For_testing.key widget) (Some key)) "Symbol key changed") cases ;; @@ -71,15 +70,32 @@ let test_header_context_copy_is_pure_product_state () = "Favorites context changed" ;; -(* Relative luminance and contrast are independent of palette implementation. *) +(* Relative luminance and contrast are independent of palette implementation. + [Ui.Style.Color.t] exposes no channel accessors on the lui shim; the + "#rrggbb" channels are recovered with ordered probes through the public + [rgb] constructor (a fully transparent color decodes to black). *) let color_luminance color = - let value = Ui.Style.Color.Private.to_argb32 color in - let channel shift = - let byte = Int32.(to_int (logand (shift_right_logical value shift) 0xffl)) in - let value = Float.of_int byte /. 255. in - if value <= 0.04045 then value /. 12.92 else ((value +. 0.055) /. 1.055) ** 2.4 - in - (0.2126 *. channel 16) +. (0.7152 *. channel 8) +. (0.0722 *. channel 0) + if color = Ui.Style.Color.argb ~alpha:0 ~red:0 ~green:0 ~blue:0 + then 0. + else ( + let channel probe = + let rec scan value = + if value > 255 + then 255 + else if Stdlib.compare (probe value) color <= 0 + then scan (value + 1) + else value - 1 + in + scan 0 + in + let red = channel (fun red -> Ui.Style.Color.rgb ~red ~green:0 ~blue:0) in + let green = channel (fun green -> Ui.Style.Color.rgb ~red ~green ~blue:0) in + let blue = channel (fun blue -> Ui.Style.Color.rgb ~red ~green ~blue) in + let linear byte = + let value = Float.of_int byte /. 255. in + if value <= 0.04045 then value /. 12.92 else ((value +. 0.055) /. 1.055) ** 2.4 + in + (0.2126 *. linear red) +. (0.7152 *. linear green) +. (0.0722 *. linear blue)) ;; let color_contrast first second = @@ -141,7 +157,7 @@ let test_status_palette_contrast () = (color_contrast increased.background increased.foreground >= 7.) "Increased status label has insufficient contrast")) statuses) - [ Bonsai_swiftui.Environment.Light, [ rgb 255 255 255; rgb 242 242 247 ] + [ Journal_environment.Light, [ rgb 255 255 255; rgb 242 242 247 ] ; Dark, [ rgb 0 0 0; rgb 28 28 30; rgb 44 44 46 ] ]; Printf.printf diff --git a/test/journal_media_runtime_test.ml b/test/journal_media_runtime_test.ml index 81f1630..d0ece3b 100644 --- a/test/journal_media_runtime_test.ml +++ b/test/journal_media_runtime_test.ml @@ -1,5 +1,5 @@ module R = Journal_media_runtime -module S = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module S = Logseq_db_worker_lui.Logseq_db_worker_lui_service module A = Logseq_db_types.Asset_descriptor module G = Logseq_db_types.Graph_types module P = Logseq_db_worker.Protocol @@ -174,10 +174,7 @@ let () = let reference_query = match request with | S.Graph_request - ({ command = - P.V2_get_block { block; revision = None } - ; _ - } as query) + ({ command = P.V2_get_block { block; revision = None }; _ } as query) when block = uuid 1 -> query | _ -> failwith "reuse must read the attachment holder first" in @@ -247,8 +244,7 @@ let () = ~source: (Managed (Some - (A.version ~checksum:(String.make 64 'b') ~file_type:"pdf" - |> Result.get_ok))) + (A.version ~checksum:(String.make 64 'b') ~file_type:"pdf" |> Result.get_ok))) ~current_checksum:None ~size:None ~dimensions:None @@ -282,12 +278,7 @@ let () = | S.Graph_request { command = P.V2_set_asset_reference - { block - ; previous = Some previous - ; asset = chosen - ; preconditions - ; _ - } + { block; previous = Some previous; asset = chosen; preconditions; _ } ; _ } when block = uuid 1 @@ -328,8 +319,7 @@ let () = }) })); (match !armed with - | [ (armed_root, Some previous) ] - when armed_root = root && previous = uuid 2 -> () + | [ (armed_root, Some previous) ] when armed_root = root && previous = uuid 2 -> () | _ -> failwith "replace must arm the picker with the current asset reference"); R.begin_replace runtime ~root; let token, _ = Queue.take sent in @@ -351,9 +341,7 @@ let () = ; _ } when u = uuid 7 -> () - | _ -> - failwith - "menu actions on an unregistered group must register and query it"); + | _ -> failwith "menu actions on an unregistered group must register and query it"); let token, request = Queue.take sent in let cold_reference = match request with @@ -384,8 +372,7 @@ let () = (match request with | S.Graph_request { command = - P.V2_list_assets - { recursive = true; roots = [ u ]; limit = 16; cursor = None } + P.V2_list_assets { recursive = true; roots = [ u ]; limit = 16; cursor = None } ; _ } when u = uuid 9 -> () diff --git a/test/journal_media_test.ml b/test/journal_media_test.ml index 70426b2..145be5c 100644 --- a/test/journal_media_test.ml +++ b/test/journal_media_test.ml @@ -1,5 +1,5 @@ module P = Journal_media -module S = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module S = Logseq_db_worker_lui.Logseq_db_worker_lui_service module A = Logseq_db_types.Asset_descriptor let check x message = if not x then failwith message diff --git a/test/journal_routes_test.ml b/test/journal_routes_test.ml index ffcdfe6..b1e0b30 100644 --- a/test/journal_routes_test.ml +++ b/test/journal_routes_test.ml @@ -1,5 +1,5 @@ -module ID = Bonsai_swiftui_spec.Id -module Ui = Bonsai_swiftui_ui +module ID = Journal_ids +module Ui = Journal_view let fail format = Printf.ksprintf failwith format @@ -402,14 +402,8 @@ let test_append_restarts_partial_children_without_reusing_cursor () = require (D.reveal_outcome completed = Some outcome) "reveal outcome disappeared") - Bonsai_swiftui_ui.View.Native_list. - [ Succeeded - ; Missing_target - ; Hidden_target - ; Cancelled - ; Superseded - ; Positioning_failed - ]; + Ui.Event.Payload. + [ Succeeded; Missing_target; Cancelled; Superseded; Positioning_failed ]; require (D.continuation created ~parent_id:root_id = None) "Append retained a pre-write continuation"; diff --git a/test/journal_semantics_test.ml b/test/journal_semantics_test.ml index e1a64f1..59db1ed 100644 --- a/test/journal_semantics_test.ml +++ b/test/journal_semantics_test.ml @@ -1,9 +1,4 @@ -module Graph_service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service -module ID = Bonsai_swiftui_spec.Id -module Protocol = Bonsai_swiftui_protocol -module Test = Bonsai_swiftui_test -module Ui = Bonsai_swiftui_ui -module Graph = Logseq_db_types.Graph_types +module Ui = Journal_view let fail format = Printf.ksprintf failwith format @@ -51,756 +46,6 @@ let block module V = Ui.View -let with_handle handle run = - Fun.protect - ~finally:(fun () -> Test.Handle.shutdown handle) - (fun () -> - Test.Handle.present handle; - run handle) -;; - -let require_semantics handle label check = - match Test.Handle.find_all handle (Test.Query.semantics_label label) with - | [ node ] -> - let (Av view) = V.Private.view node.widget in - (match view.node with - | V.Private.Semantics props -> check props - | _ -> fail "expected semantics") - | nodes -> fail "expected one semantic label %S, got %d" label (List.length nodes) -;; - -let header_component sync_phase _handlers _graph = - let handler = Ui.Event.Handler.create (fun _ -> ()) in - Bonsai.Cont.return - (Journal_header.view - ~platform:"macos" - ~key:(Ui.Key.string "test-header") - ~context:Journal_header.Context.journals - ~sync_phase - ~sync_error:None - ~on_error_info:None - ~on_account_action:(Some handler) - ~local_deletion_available:false - ~on_journals:handler - ~on_favorites:handler - ~on_capture:handler - ~capture_enabled:true - ~body:(V.Body.static (V.empty ())) - |> V.Body.Private.to_widget) -;; - -let with_component component run = - Test.Handle.create - ~runtime_epoch:(ID.Runtime.Epoch.of_int64 7002L) - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - component - |> fun handle -> with_handle handle run -;; - -let test_header_account_action_and_view_only_date_have_truthful_semantics () = - with_component (header_component None) (fun handle -> - require - (Test.Handle.find handle (Test.Query.kind "toolbar") <> None) - "journal controls must use the public Toolbar"; - require - (Option.is_none - (Test.Handle.find handle (Test.Query.test_id "journal-header-title"))) - "Journals must not retain a fixed toolbar date"; - require - (Option.is_some - (Test.Handle.find handle (Test.Query.test_id "journal-floating-chrome"))) - "Journals must place account controls in floating chrome"; - require - (List.length (Test.Handle.find_all handle (Test.Query.kind "button")) = 3) - "date context became actionable"; - require_semantics handle "Account menu" (fun props -> - require (props.role = Button) "account role changed"; - require - (props.hint = Some "Switch graphs, delete the local copy, or sign out") - "account actions hint lost")) -;; - -let test_header_sync_progress_tracks_every_sync_phase () = - List.iter - (fun phase -> - with_component (header_component phase) (fun handle -> - require - (Option.is_some - (Test.Handle.find - handle - (Test.Query.test_id "journal-header-sync-progress")) - = (phase = Some Graph_service.Connecting)) - "progress visibility changed"; - require - (Option.is_none - (Test.Handle.find handle (Test.Query.test_id "journal-header-title"))) - "sync phase restored the fixed toolbar date")) - [ None - ; Some Graph_service.Connecting - ; Some Offline - ; Some Pulling - ; Some Submitting - ; Some Current - ; Some Paused - ; Some Failed - ] -;; - -let timeline_component - ?source - ?(child_summaries = []) - ~task_state - ~enabled - _handlers - _graph - = - let timeline = - Journal_timeline_state.empty ~today:20260809 - |> fun state -> - Journal_timeline_state.begin_request state ~generation:1L (Feed { before_day = None }) - in - let timeline = - Journal_timeline_state.apply_feed - timeline - ~generation:1L - { Journal_graph_projection.days = - [ { page = { id = page_id; day = 20260809; title = "Today" } - ; entries = [ { block = block ?source ~task_state (); child_summaries } ] - ; has_more_entries = false - ; continuation = None - } - ] - ; slot_count = 1 - ; has_more_days = false - } - in - let ignored = Ui.Event.Handler.create (fun _ -> ()) in - Bonsai.Cont.return - (Journal_timeline.view - ~render_media:(fun ~root:_ child -> child) - ~state:timeline - ~day_presentation:(fun _ -> None) - ~on_visible_range:ignored - ~on_scroll_completed:ignored - ~on_retry_day:ignored - ~on_open_block:ignored - ~delete_enabled:enabled - ~actions_enabled:enabled - ~on_status:ignored - ~on_delete:ignored - |> V.Body.Private.to_widget) -;; - -let test_swipe_exact_statuses_and_explicit_delete () = - let check handle enabled = - let node = - Test.Handle.find handle (Test.Query.test_id "journal-timeline") |> Option.get - in - let (Av view) = - V.Private.view node.Bonsai_swiftui_runtime.Mounted_tree.Snapshot.widget - in - match view.node with - | V.Private.Native_list _ -> - let actions = Test.Handle.find_all handle (Test.Query.kind "swipe_action") in - require (List.length actions = 2) "journal row lost status/delete swipes"; - List.iter - (fun node -> - let (Av view) = - V.Private.view node.Bonsai_swiftui_runtime.Mounted_tree.Snapshot.widget - in - match view.node with - | V.Private.Swipe_action props -> - require (props.enabled = enabled) "row action enabled state changed" - | _ -> fail "expected swipe action") - actions; - require - (List.length (Test.Handle.find_all handle (Test.Query.kind "navigation_link")) = 1) - "journal row must activate through a public Navigation_link" - | _ -> fail "timeline is not a public native List" - in - List.iter - (fun task_state -> - with_component (timeline_component ~task_state ~enabled:true) (fun handle -> - check handle true; - if task_state <> Journal_model.No_status - then - require - (Test.Handle.find - handle - (Test.Query.visible_text (Journal_model.status_name task_state)) - <> None) - "native row lost its readable task status")) - [ Journal_model.No_status - ; Todo - ; Doing - ; Done - ; Backlog - ; In_review - ; Canceled - ; Now - ; Waiting - ; Later - ]; - with_component (timeline_component ~task_state:Done ~enabled:false) (fun handle -> - check handle false) -;; - -let warm_start_test_service () = - Worker.Service.create - ~push_topic_count:6 - ~concurrency:Worker.Service.Serial - ~init:(fun _context (_config : Journal_startup.t) -> Ok ()) - ~handle:(fun _context () request -> - match (request : Graph_service.request) with - | Get_graph_state -> - Ok - (Graph_service.Graph_state - { generation = 0; graph_id = None; phase = Graph_closed; error = None }) - | Import_asset _ -> Ok (Asset_imported (Error "Import unavailable in fixture")) - | Asset_command _ | Release_asset_file _ -> Ok Client_command_completed - | Acquire_asset_file _ | Acquire_imported_file _ -> Ok (Asset_file None) - | Client_command _ -> Ok Client_command_completed - | Graph_request request -> - let error = - Logseq_db_worker.Error.create - ~code:Closed_session - ~message:"The test graph is closed." - ~details:[] - |> Result.get_ok - in - Ok - (Graph_response - (Logseq_db_worker.Protocol.failed ~request_id:request.request_id error))) - ~shutdown:(fun () -> ()) - () -;; - -let warm_start_application_payload () = - Logseq_db_worker.Config.create - ~application_support_directory:"/tmp/logseq-journal-warm-start-ordering" - ~target:(Managed_sync { base_url = "https://api.logseq.io" }) - ~compatibility_profile:Logseq_65_33_or_newer - ~response_budget_bytes:Logseq_db_worker.Protocol.maximum_response_bytes - ~default_page_size:Logseq_db_worker.Protocol.default_page_size - |> Result.get_ok - |> Journal_startup.encode - |> Result.fold ~ok:Fun.id ~error:(fun error -> - fail "%s" (Journal_startup.Error.to_string error)) -;; - -let application_requests handle = - match Test.Handle.last_frame handle with - | None -> fail "warm-start application emitted no frame" - | Some frame -> - (match Bonsai_swiftui_protocol.Binary_codec.decode frame.bytes with - | Error error -> fail "warm-start frame did not decode: %s" error.message - | Ok wire -> - List.filter_map - (function - | Bonsai_swiftui_protocol.Wire_frame.Application_request - { request_id; payload } -> Some (request_id, payload) - | _ -> None) - wire.operations) -;; - -let request_id_for_payload requests payload = - List.find_map - (fun (request_id, request_payload) -> - if Bytes.equal request_payload payload then Some request_id else None) - requests -;; - -let network_lifecycle_packet ~kind ~generation = - let payload = Bytes.make 16 '\000' in - Bytes.blit_string "LJP1" 0 payload 0 4; - Bytes.set_uint16_le payload 4 1; - Bytes.set_uint16_le payload 6 kind; - Bytes.set_int64_le payload 8 generation; - let envelope = Bytes.make 48 '\000' in - Bytes.blit_string "LJP2" 0 envelope 0 4; - Bytes.set_uint16_le envelope 4 2; - Bytes.set_uint16_le envelope 6 15; - Bytes.set_int32_le envelope 24 16l; - Bytes.blit payload 0 envelope 32 16; - envelope -;; - -let application_event_batch ~runtime_epoch ~revision ~sequence payload = - Protocol.Inbound_event. - { runtime_epoch - ; events = - [ { sequence = ID.Runtime.Event_sequence.of_int64 sequence - ; displayed_revision = revision - ; node_id = ID.Ui.Node_id.zero - ; handler_id = ID.Ui.Handler_id.zero - ; event_tag = Protocol.Generated_protocol.Event_tag.application_event - ; payload = Application_event payload - } - ] - } -;; - -let with_warm_start_app ?calendar_sampler runtime_epoch run = - let handle = - Test.Handle.create_app - ~runtime_epoch - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - (Application.For_testing.app_with_service - ?calendar_sampler - (warm_start_test_service ())) - ~application_payload:(warm_start_application_payload ()) - in - Fun.protect ~finally:(fun () -> Test.Handle.shutdown handle) (fun () -> run handle) -;; - -let test_warm_start_samples_calendar_before_requesting_local_binding () = - let runtime_epoch = ID.Runtime.Epoch.of_int64 7_005L in - let calendar_sampler = - Journal_calendar.Sampler.create - ~clock:(fun () -> 1_788_508_800.) - ~localtime:(fun seconds -> Unix.gmtime (seconds +. 28_800.)) - () - in - with_warm_start_app ~calendar_sampler runtime_epoch (fun handle -> - let initial_requests = application_requests handle in - require - (Option.is_some - (request_id_for_payload - initial_requests - Journal_platform.local_account_binding_request)) - "OCaml calendar sampling did not release managed warm startup synchronously"; - require - (List.for_all - (fun (_, payload) -> Bytes.get_uint16_le payload 6 <> 1) - initial_requests) - "warm startup emitted the deleted calendar platform request") -;; - -let test_calendar_failure_does_not_start_graph_restoration () = - let runtime_epoch = ID.Runtime.Epoch.of_int64 7_006L in - let calendar_sampler = - Journal_calendar.Sampler.create - ~clock:(fun () -> raise (Failure "calendar unavailable")) - ~localtime:Unix.localtime - () - in - with_warm_start_app ~calendar_sampler runtime_epoch (fun handle -> - let initial_requests = application_requests handle in - require - (Option.is_none - (request_id_for_payload - initial_requests - Journal_platform.local_account_binding_request)) - "failed calendar prerequisite still started graph restoration") -;; - -let test_foreground_resume_resamples_calendar_in_ocaml () = - let runtime_epoch = ID.Runtime.Epoch.of_int64 7_007L in - let samples = ref 0 in - let calendar_sampler = - Journal_calendar.Sampler.create - ~clock:(fun () -> - incr samples; - 1_788_508_800. +. (Float.of_int !samples *. 60.)) - ~localtime:(fun seconds -> Unix.gmtime (seconds +. 28_800.)) - () - in - with_warm_start_app ~calendar_sampler runtime_epoch (fun handle -> - require (!samples = 1) "warm startup sampled the calendar more than once"; - Test.Handle.present handle; - let events = - network_lifecycle_packet ~kind:2 ~generation:1L - |> application_event_batch - ~runtime_epoch - ~revision:(Test.Handle.revision handle) - ~sequence:1L - in - Test.Handle.pump_next handle ~events (); - require (!samples = 2) "foreground resume did not re-sample the OCaml calendar") -;; - -let export_favorites_frames directory = - let module P = Logseq_db_worker.Protocol in - let uuid n = - Graph.Uuid.of_string (Printf.sprintf "77000000-0000-4000-8000-%012d" n) - |> Result.get_ok - in - let items = - List.init 100 (fun n -> - P. - { membership_uuid = uuid n - ; membership_order = string_of_int n - ; membership_revision = "membership" - ; target = - (if n mod 2 = 0 - then - V2_favorite_page - { uuid = uuid (100 + n) - ; title = - (if n = 0 - then "Design notes" - else - "A favorite page with a longer title that wraps across several \ - lines") - ; revision = "page" - } - else - V2_favorite_block - { uuid = uuid (100 + n) - ; title = - "Review navigation and keyboard layout in 中文 with a long task title" - ; task_status = Some V2_doing - ; revision = "block" - }) - }) - in - let component _handlers _graph = - Bonsai.Cont.return (Application.For_testing.favorites_page items) - in - let handle = - Test.Handle.create - ~runtime_epoch:(ID.Runtime.Epoch.of_int64 7010L) - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - component - in - Fun.protect - ~finally:(fun () -> Test.Handle.shutdown handle) - (fun () -> - Test.Handle.present handle; - let channel = open_out_bin (Filename.concat directory "favorites.bin") in - Fun.protect - ~finally:(fun () -> close_out channel) - (fun () -> - output_bytes channel (Option.get (Test.Handle.last_frame handle)).bytes)) -;; - -let test_native_collection_delivers_range_and_scroll () = - let observations = ref [] in - let record value = - Ui.Event.Handler.create (fun payload -> - observations := (value, payload) :: !observations) - in - let row slot_index id section header block_id : Journal_native_collection.row = - { id; section; header; slot_index = Some slot_index; block_id } - in - with_component - (fun _handlers _graph -> - Bonsai.Cont.return - (Journal_native_collection.view - ~key:(Ui.Key.string "collection") - ~test_id:(Ui.Test_id.string "collection") - ~rows: - [ row 0 "empty" "empty" true None - ; row 1 "today" "today" true None - ; row 2 "target" "today" false (Some "target") - ; row 3 "loading" "today" false None - ] - ~scroll_target:None - ~on_scroll_completed:(record "scroll") - ~actions_enabled:true - ~on_visible_range:(record "range") - ~on_open:(record "open") - ~on_status:(record "status") - ~on_delete:(record "delete") - ~children: - [ V.text "Empty date"; V.text "Today"; V.text "Target"; V.text "Loading" ] - |> V.Body.Vertical.fill - |> fun content -> V.Body.Vertical.create [ content ] |> V.Body.Private.to_widget - )) - (fun handle -> - let visible first last = - Test.Handle.visible_range - handle - (Test.Query.test_id "collection") - ~first_index:first - ~last_exclusive:last; - Test.Handle.present handle - in - visible 0L 1L; - require - (!observations - = [ ( "range" - , Ui.Event.Payload.Visible_range { first_index = 0L; last_exclusive = 1L } ) - ]) - "empty-day row must map to its heading slot"; - observations := []; - visible 1L 3L; - require - (!observations - = [ ( "range" - , Ui.Event.Payload.Visible_range { first_index = 2L; last_exclusive = 4L } ) - ]) - "row-only visibility must skip date headers and retain loading rows"; - observations := []; - List.iter - (fun key -> - Test.Handle.click handle (Test.Query.key (Ui.Key.string key)); - Test.Handle.present handle) - [ "open:target"; "status:target"; "delete:target" ]; - require - (!observations - = [ "delete", Ui.Event.Payload.Text "target" - ; "status", Ui.Event.Payload.Text "target" - ; "open", Ui.Event.Payload.Text "target" - ]) - "public row actions lost their target identity") -;; - -let test_native_rows_preserve_unbounded_literal_content () = - List.iter - (fun source -> - let child_source = "Child summary\n第二行 👩🏽‍💻" in - let child_summaries : Journal_graph_projection.child_summary list = - [ { block_id = "child"; source = child_source } ] - in - with_component - (timeline_component ~source ~child_summaries ~task_state:Todo ~enabled:true) - (fun handle -> - List.iter - (fun value -> - let node = Test.Handle.find handle (Test.Query.visible_text value) in - require (Option.is_some node) "native row lost literal source or summary"; - let (Av view) = V.Private.view (Option.get node).widget in - match view.node with - | V.Private.Text { line_limit; _ } -> - require (line_limit = None) "native content retained a legacy line cap" - | _ -> fail "native row did not render literal Text") - [ source; child_source; "Todo"; "09:05" ])) - [ "Literal **markdown** and [[page]]\nSecond line" - ; String.make 65_536 'x' - ; "中文 👩🏽‍💻\n\nFourth line\nFifth line" - ] -;; - -let test_diagnostics_use_public_form_and_preserve_actions () = - let actions = ref [] in - with_component - (fun _handlers _graph -> - let dispatch = - Ui.Event.Handler.create (fun payload -> actions := payload :: !actions) - in - Bonsai.Cont.return - (Application.For_testing.diagnostics_page dispatch |> V.Body.Private.to_widget)) - (fun handle -> - require - (List.length (Test.Handle.find_all handle (Test.Query.kind "form")) = 1) - "diagnostics must use one public Form viewport"; - require - (List.length (Test.Handle.find_all handle (Test.Query.kind "section")) = 2) - "diagnostic groups must remain native sections"; - require - (Option.is_some (Test.Handle.find handle (Test.Query.visible_text "Phases"))) - "phase section title disappeared"; - require - (List.length (Test.Handle.find_all handle (Test.Query.kind "labeled_content")) - = 7) - "diagnostic values lost their native labeled presentation"; - Test.Handle.click handle (Test.Query.test_id "journal-diagnostics-close"); - Test.Handle.present handle; - require - (!actions = [ Ui.Event.Payload.Text "close-diagnostics" ]) - "diagnostics close lost its application command") -;; - -(* Empty presentation is owned by the public collection adapter, not a reducer. *) -let test_empty_collection_preserves_list_owner () = - with_component - (fun _handlers _graph -> - let ignored = Ui.Event.Handler.create (fun _ -> ()) in - Bonsai.Cont.return - (Journal_native_collection.view - ~key:(Ui.Key.string "empty-collection") - ~test_id:(Ui.Test_id.string "empty-collection") - ~rows:[] - ~children:[] - ~scroll_target:None - ~on_scroll_completed:ignored - ~actions_enabled:true - ~on_visible_range:ignored - ~on_open:ignored - ~on_status:ignored - ~on_delete:ignored - |> V.Body.Vertical.fill - |> fun content -> V.Body.Vertical.create [ content ] |> V.Body.Private.to_widget - )) - (fun handle -> - require - (List.length (Test.Handle.find_all handle (Test.Query.kind "native_list")) = 1) - "empty presentation replaced the list owner"; - require - (List.length - (Test.Handle.find_all handle (Test.Query.kind "content_unavailable")) - = 1) - "empty Journal needs a public unavailable presentation"; - require - (Option.is_some - (Test.Handle.find handle (Test.Query.visible_text "No journal entries yet"))) - "empty Journal message disappeared") -;; - -(* Date headers are presentation-owned; pure timeline inputs construct the states. *) -let test_list_owned_dates_and_real_slot_visibility () = - let module T = Journal_timeline_state in - let loaded entries = - T.empty ~today:20260809 - |> fun state -> - T.begin_request state ~generation:1L (Feed { before_day = None }) - |> fun state -> - T.apply_feed - state - ~generation:1L - { Journal_graph_projection.days = - [ { page = { id = page_id; day = 20260809; title = "Today" } - ; entries - ; has_more_entries = false - ; continuation = None - } - ] - ; slot_count = 1 + List.length entries - ; has_more_days = false - } - in - let entry = { Journal_graph_projection.block = block (); child_summaries = [] } in - let populated = loaded [ entry ] in - let empty = loaded [] in - let hidden = - loaded - [ { entry with block = block ~source:"" ~child_count:0 ~task_state:No_status () } ] - in - let restored = T.replace_timeline_entry hidden entry in - let replaced = - T.replace_timeline_entry_page - empty - ~page:{ id = page_id; day = 20260809; title = "Today" } - { entries = [ entry ]; continuation = None } - in - let captured = T.prepend_timeline_entry empty entry in - let check ?(unmapped_first = false) state days expected_range = - let observations = ref [] in - with_component - (fun _handlers _graph -> - let ignored = Ui.Event.Handler.create (fun _ -> ()) in - Bonsai.Cont.return - (Journal_timeline.view - ~render_media:(fun ~root:_ child -> child) - ~state - ~day_presentation:(fun day -> - Journal_calendar.present_journal_day day |> Result.to_option) - ~on_visible_range: - (Ui.Event.Handler.create (fun value -> - observations := value :: !observations)) - ~on_scroll_completed:ignored - ~on_retry_day:ignored - ~on_open_block:ignored - ~delete_enabled:true - ~actions_enabled:true - ~on_status:ignored - ~on_delete:ignored - |> V.Body.Private.to_widget)) - (fun handle -> - require - (List.length (Test.Handle.find_all handle (Test.Query.kind "native_list")) = 1) - "dates must retain a single native list"; - List.iter - (fun day -> - let date = Journal_calendar.present_journal_day day |> Result.get_ok in - let headers = - Test.Handle.find_all - handle - (Test.Query.test_id ("journal-day-heading:" ^ string_of_int day)) - in - require - (List.length headers = 1) - "day %d must have one native section header" - day; - let (Av view) = V.Private.view (List.hd headers).widget in - match view.node with - | V.Private.Native_widget { payload; _ } -> - let fields = Yojson.Basic.from_string (Bytes.to_string payload) in - require - (Yojson.Basic.Util.(fields |> member "title" |> to_string) - = date.date_text) - "section header lost its OCaml formatted title" - | _ -> fail "date header must use native layout") - days; - require - (List.length (Test.Handle.find_all handle (Test.Query.kind "list_section")) - = List.length days) - "hidden dates must not create extra sections"; - List.iter - (fun node -> - let (Av view) = - V.Private.view node.Bonsai_swiftui_runtime.Mounted_tree.Snapshot.widget - in - match view.node with - | V.Private.List_section { has_header; _ } -> - require has_header "each day must own a native section header" - | _ -> fail "expected native List section") - (Test.Handle.find_all handle (Test.Query.kind "list_section")); - if unmapped_first - then ( - Test.Handle.visible_range - handle - (Test.Query.test_id "journal-timeline") - ~first_index:0L - ~last_exclusive:1L; - Test.Handle.present handle; - require (!observations = []) "empty Today alone must not request history"); - let row_count = - List.length (Test.Handle.find_all handle (Test.Query.kind "list_row")) - in - require (row_count > 0) "empty Today must keep an explicit empty row"; - Test.Handle.visible_range - handle - (Test.Query.test_id "journal-timeline") - ~first_index:0L - ~last_exclusive:(Int64.of_int row_count); - Test.Handle.present handle; - require - (!observations = expected_range) - "presentation-only headers/empty rows must not invent pagination indices") - in - let row_range = - [ Ui.Event.Payload.Visible_range { first_index = 0L; last_exclusive = 1L } ] - in - List.iter - (fun state -> check state [ 20260809 ] []) - [ T.empty ~today:20260809; empty; hidden ]; - List.iter - (fun state -> check state [ 20260809 ] row_range) - [ populated; restored; replaced; captured ]; - check - ~unmapped_first:true - (T.set_today populated ~today:20260810) - [ 20260810; 20260809 ] - row_range; - check (T.set_today empty ~today:20260810) [ 20260810 ] []; - let historical = - T.empty ~today:20260810 - |> fun state -> - T.begin_request state ~generation:1L (Feed { before_day = None }) - |> fun state -> - T.apply_feed - state - ~generation:1L - { days = - [ { page = { id = page_id; day = 20260809; title = "Yesterday" } - ; entries = [ entry ] - ; has_more_entries = false - ; continuation = None - } - ] - ; slot_count = 2 - ; has_more_days = false - } - in - check - ~unmapped_first:true - historical - [ 20260810; 20260809 ] - [ Ui.Event.Payload.Visible_range { first_index = 1L; last_exclusive = 2L } ] -;; - let test_timeline_media_targets () = let targets = ref [] in ignore @@ -819,28 +64,9 @@ let test_timeline_media_targets () = "media discovery must target each displayed source, including summaries" ;; -let tests = - [ "timeline media targets", test_timeline_media_targets - ; "list-owned dates", test_list_owned_dates_and_real_slot_visibility - ; "empty Journal", test_empty_collection_preserves_list_owner - ; "public diagnostics Form", test_diagnostics_use_public_form_and_preserve_actions - ; "native collection event binding", test_native_collection_delivers_range_and_scroll - ; ( "native literal content without geometry caps" - , test_native_rows_preserve_unbounded_literal_content ) - ; ( "header semantics" - , test_header_account_action_and_view_only_date_have_truthful_semantics ) - ; "header sync phases", test_header_sync_progress_tracks_every_sync_phase - ; "native status and delete actions", test_swipe_exact_statuses_and_explicit_delete - ; ( "calendar before local binding" - , test_warm_start_samples_calendar_before_requesting_local_binding ) - ; ( "failed calendar prevents restoration" - , test_calendar_failure_does_not_start_graph_restoration ) - ; "foreground calendar resampling", test_foreground_resume_resamples_calendar_in_ocaml - ] -;; +let tests = [ "timeline media targets", test_timeline_media_targets ] let () = - Option.iter export_favorites_frames (Sys.getenv_opt "JOURNAL_FAVORITES_FRAME_DIR"); let failed = List.filter_map (fun (name, run) -> diff --git a/test/journal_timeline_state_test.ml b/test/journal_timeline_state_test.ml index fe8f06e..44224cb 100644 --- a/test/journal_timeline_state_test.ml +++ b/test/journal_timeline_state_test.ml @@ -4,8 +4,8 @@ let retained_slots state = Timeline.fold_slots (fun slots slot -> slot :: slots) [] state |> List.rev ;; -module Ui = Bonsai_swiftui_ui -module ID = Bonsai_swiftui_spec.Id +module Ui = Journal_view +module ID = Journal_ids let fail format = Printf.ksprintf failwith format @@ -1500,14 +1500,8 @@ let test_capture_scroll_terminal_ownership () = require (Timeline.scroll_outcome duplicate = Some outcome) "duplicate completion changed state") - Bonsai_swiftui_ui.View.Native_list. - [ Succeeded - ; Missing_target - ; Hidden_target - ; Cancelled - ; Superseded - ; Positioning_failed - ] + Ui.Event.Payload. + [ Succeeded; Missing_target; Cancelled; Superseded; Positioning_failed ] ;; let () = diff --git a/test/journal_uploads_test.ml b/test/journal_uploads_test.ml index 2afcd04..8fa2f37 100644 --- a/test/journal_uploads_test.ml +++ b/test/journal_uploads_test.ml @@ -1,5 +1,5 @@ module P = Journal_uploads -module S = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module S = Logseq_db_worker_lui.Logseq_db_worker_lui_service module U = Logseq_db_worker_pure_reducer.Asset_upload let uuid n = diff --git a/test/macos_application_dispatch_test.ml b/test/macos_application_dispatch_test.ml deleted file mode 100644 index f48d6c1..0000000 --- a/test/macos_application_dispatch_test.ml +++ /dev/null @@ -1,1856 +0,0 @@ -module Test = Bonsai_swiftui_test -module Service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service -module P = Logseq_db_worker.Protocol -module Wire = Bonsai_swiftui_protocol -module ID = Bonsai_swiftui_spec.Id - -let require condition message = if not condition then failwith message - -let graph_id = - Logseq_db_types.Graph_types.Uuid.of_string "70000000-0000-4000-b000-000000000001" - |> Result.get_ok -;; - -let inspections = ref 0 -let graph_reads = ref 0 -let journal_ranges = ref [] -let unavailable = ref false -let service_failure = ref false -let counts = ref 0 - -let inspection : P.v2_admission_inspection = - { active_records = 0 - ; active_bytes = 0 - ; protected_wire_bytes = 0 - ; retained_origin_evidence_bytes = 0 - ; maximum_records = 200 - ; maximum_bytes = 4096 - } -;; - -module Graph = Logseq_db_types.Graph_types - -let block_id = "70000000-0000-4000-a000-000000000001" -let block_uuid = Graph.Uuid.of_string block_id |> Result.get_ok - -let page : Graph.page = - { uuid = graph_id - ; name = "20260831" - ; title = "2026-08-31" - ; kind = Journal_page { journal_day = 20260831 } - ; created_at_ms = 1_788_192_000_000L - ; updated_at_ms = 1_788_192_000_000L - ; recycled = false - ; tags = [] - ; properties = [] - } -;; - -let title = ref "Before remote update" -let revision = ref "block-1" -let deleted = ref false -let deletes = ref 0 -let emit = ref (fun (_ : Service.push) -> ()) - -let record () : P.v2_block_record = - { block = - { uuid = block_uuid - ; title = !title - ; parent = graph_id - ; page = graph_id - ; order = "a" - ; created_at_ms = 1_788_192_000_000L - ; updated_at_ms = 1_788_192_000_000L - ; refs = [] - ; tags = [] - ; properties = [] - } - ; task_status = None - ; rendered_page_title = page.title - } -;; - -let mutation_failure = ref false -let status_writes = ref 0 -let initial_feed_failure = ref false -let favorites_service_failure = ref false -let favorites_reads = ref 0 -let favorites_fail = ref false -let client_commands = ref [] - -let service = - Worker.Service.create - ~push_topic_count:6 - ~concurrency:Worker.Service.Serial - ~init:(fun context (_ : Journal_startup.t) -> - (emit - := fun push -> - Worker.Session_context.emit context ~topic:Service.invalidation_topic push); - Ok ()) - ~handle:(fun _ () -> function - | Service.Get_graph_state -> - Ok - (Service.Graph_state - { generation = 0; graph_id = None; phase = Graph_closed; error = None }) - | Asset_command _ | Release_asset_file _ -> Ok Client_command_completed - | Acquire_asset_file _ | Acquire_imported_file _ -> Ok (Asset_file None) - | Client_command command -> - client_commands := command :: !client_commands; - Ok Service.Client_command_completed - | Graph_request request -> - if request.P.command = P.V2_inspect_admission && !service_failure - then ( - incr inspections; - Error "fixture worker failure") - else if - match request.P.command with - | P.V2_list_favorites _ -> !favorites_service_failure - | _ -> false - then ( - incr favorites_reads; - Error "Favorites worker stopped") - else ( - let outcome = - match request.P.command with - | V2_graph_info -> - incr graph_reads; - P.V2_graph_info_outcome - { graph_uuid = graph_id - ; graph_name = "Admission fixture" - ; schema = { major = 65; minor = 33 } - ; admission_facts = [] - ; journal_title_format = None - ; generation = "generation-1" - ; projection_revision = "projection-1" - ; limits = - { response_budget_bytes = 1048576 - ; outbox_max_records = 200 - ; outbox_max_bytes = 4096 - ; change_max_items = 256 - ; change_max_bytes = 1048576 - ; dispatcher_capacity = 100 - ; wire_batch_max_bytes = 4096 - } - } - | V2_list_favorites _ -> - incr favorites_reads; - if !favorites_fail - then - P.V2_failed - { code = "corruptStorage"; message = "Favorites fixture read failed" } - else - P.V2_favorites_outcome - { favorites_page = Some graph_id - ; generation = "generation-1" - ; projection_revision = "projection-1" - ; next_cursor = None - ; items = - [ { membership_uuid = graph_id - ; membership_order = "a0" - ; membership_revision = "membership-1" - ; target = - V2_favorite_page - { uuid = graph_id - ; title = "Design notes" - ; revision = "page-1" - } - } - ; { membership_uuid = block_uuid - ; membership_order = "a1" - ; membership_revision = "membership-2" - ; target = - V2_favorite_block - { uuid = block_uuid - ; title = "Review navigation" - ; task_status = Some V2_doing - ; revision = "block-1" - } - } - ] - } - | V2_list_journals _ when !initial_feed_failure -> - P.V2_failed - { code = "corruptStorage"; message = "Journal fixture read failed" } - | V2_list_journals { from_day; through_day; _ } -> - journal_ranges := (from_day, through_day) :: !journal_ranges; - P.V2_journals_outcome - { items = [ { page; journal_day = 20260831; revision = "page-1" } ] - ; next_cursor = None - } - | V2_list_assets _ -> - P.V2_assets_outcome - { generation = "generation-1" - ; projection_revision = "projection-1" - ; items = [] - ; next_cursor = None - } - | V2_get_page_tree { page; maximum_depth; _ } -> - P.V2_page_tree_outcome - { page - ; maximum_depth - ; items = - (if !deleted - then [] - else - [ { value = record () - ; revision = !revision - ; depth = 0 - ; parent = graph_id - } - ]) - ; next_cursor = None - } - | V2_get_block _ -> - P.V2_block_outcome - (V2_present_block { value = record (); revision = !revision }) - | V2_get_children { parent; _ } -> - let root = record () in - let child = - { root with - block = - { root.block with - uuid = - Graph.Uuid.of_string "70000000-0000-4000-a000-000000000002" - |> Result.get_ok - ; parent = block_uuid - ; title = "Outline child" - } - } - in - P.V2_children_outcome - { parent - ; revision_scope = V2_children_revision parent - ; scope_revision = "children-1" - ; items = [ { value = child; revision = "child-1" } ] - ; next_cursor = None - } - | V2_set_task_status _ | V2_clear_task_status _ -> - incr status_writes; - P.V2_failed - { code = "corruptStorage" - ; message = "Fixture mutation could not be stored" - } - | V2_delete_blocks { mutation_id; preconditions; _ } -> - incr deletes; - if !mutation_failure - then - P.V2_failed - { code = "corruptStorage" - ; message = "Fixture mutation could not be stored" - } - else if preconditions.blocks <> [ block_uuid, !revision ] - then P.V2_failed { code = "conflict"; message = "target changed" } - else ( - deleted := true; - P.V2_mutation_committed - { mutation_id - ; status = V2_applied - ; generation = "generation-1" - ; before_projection_revision = "projection-1" - ; after_projection_revision = "projection-2" - }) - | V2_inspect_admission -> - incr inspections; - if !unavailable - then - P.V2_failed { code = "closedSession"; message = "fixture unavailable" } - else P.V2_admission_outcome { inspection with active_records = !counts } - | _ -> failwith "unexpected fixture request" - in - Ok - (Service.Graph_response - (P.V2_response - { api_version = P.api_version - ; request_id = request.request_id - ; outcome - })))) - ~shutdown:(fun () -> ()) - () -;; - -let application_payload = - Logseq_db_worker.Config.create - ~application_support_directory:"/tmp/unused-admission-fixture" - ~target:(Managed_sync { base_url = "https://example.invalid" }) - ~compatibility_profile:Logseq_65_33_or_newer - ~response_budget_bytes:P.maximum_response_bytes - ~default_page_size:P.default_page_size - |> Result.get_ok - |> Journal_startup.encode - |> Result.get_ok -;; - -let envelope tag source = - let bytes = Bytes.make (32 + String.length source) '\000' in - Bytes.blit_string "LJP2" 0 bytes 0 4; - Bytes.set_uint16_le bytes 4 2; - Bytes.set_uint16_le bytes 6 tag; - Bytes.set_int32_le bytes 24 (Int32.of_int (String.length source)); - Bytes.blit_string source 0 bytes 32 (String.length source); - bytes -;; - -(* Export every accepted frame from one application session for native checks. *) -let frame_directory = Sys.getenv_opt "JOURNAL_ROOT_FRAME_DIR" -let frame_phase = ref "disabled" -let frame_number = ref 0 -let previous_frame = ref None - -let export_frame handle = - match frame_directory, Test.Handle.last_frame handle with - | Some directory, Some frame - when !frame_phase <> "disabled" && !previous_frame <> Some frame.bytes -> - previous_frame := Some frame.bytes; - let path = - Filename.concat directory (Printf.sprintf "%04d-%s.bin" !frame_number !frame_phase) - in - incr frame_number; - let channel = open_out_bin path in - Fun.protect - ~finally:(fun () -> close_out channel) - (fun () -> output_bytes channel frame.bytes) - | _ -> () -;; - -let press_node handle epoch sequence node = - let binding = - Array.find_opt - (fun binding -> - Bonsai_swiftui_ui.Event.Tag.equal - binding.Bonsai_swiftui_runtime.Mounted_tree.Mounted_binding.event_tag - Bonsai_swiftui_ui.Event.Tag.Press) - node.Bonsai_swiftui_runtime.Mounted_tree.Snapshot.event_bindings - |> Option.get - in - let event : Wire.Inbound_event.t = - { sequence = ID.Runtime.Event_sequence.of_int64 sequence - ; displayed_revision = Test.Handle.revision handle - ; node_id = node.node_id - ; handler_id = binding.handler_id - ; event_tag = Wire.Generated_protocol.Event_tag.press - ; payload = Unit - } - in - Test.Handle.pump_next handle ~events:{ runtime_epoch = epoch; events = [ event ] } (); - export_frame handle; - Test.Handle.present handle -;; - -let select_picker handle epoch sequence id = - let node = Test.Handle.find handle (Test.Query.kind "picker") |> Option.get in - let binding = - Array.find_opt - (fun binding -> - Bonsai_swiftui_ui.Event.Tag.equal - binding.Bonsai_swiftui_runtime.Mounted_tree.Mounted_binding.event_tag - Bonsai_swiftui_ui.Event.Tag.Picker_selected) - node.Bonsai_swiftui_runtime.Mounted_tree.Snapshot.event_bindings - |> Option.get - in - let event : Wire.Inbound_event.t = - { sequence = ID.Runtime.Event_sequence.of_int64 sequence - ; displayed_revision = Test.Handle.revision handle - ; node_id = node.node_id - ; handler_id = binding.handler_id - ; event_tag = Wire.Generated_protocol.Event_tag.picker_selected - ; payload = Int64 id - } - in - Test.Handle.pump_next handle ~events:{ runtime_epoch = epoch; events = [ event ] } (); - export_frame handle; - Test.Handle.present handle -;; - -let select_account handle epoch sequence id = - let node = Test.Handle.find handle (Test.Query.kind "menu") |> Option.get in - let binding = - Array.find_opt - (fun binding -> - Bonsai_swiftui_ui.Event.Tag.equal - binding.Bonsai_swiftui_runtime.Mounted_tree.Mounted_binding.event_tag - Bonsai_swiftui_ui.Event.Tag.Menu_action) - node.Bonsai_swiftui_runtime.Mounted_tree.Snapshot.event_bindings - |> Option.get - in - let event : Wire.Inbound_event.t = - { sequence = ID.Runtime.Event_sequence.of_int64 sequence - ; displayed_revision = Test.Handle.revision handle - ; node_id = node.node_id - ; handler_id = binding.handler_id - ; event_tag = Wire.Generated_protocol.Event_tag.menu_action - ; payload = Int64 id - } - in - Test.Handle.pump_next handle ~events:{ runtime_epoch = epoch; events = [ event ] } (); - export_frame handle; - Test.Handle.present handle -;; - -let native_back handle epoch sequence = - let node = Test.Handle.find handle (Test.Query.kind "navigation_stack") |> Option.get in - let binding = - Array.find_opt - (fun binding -> - Bonsai_swiftui_ui.Event.Tag.equal - binding.Bonsai_swiftui_runtime.Mounted_tree.Mounted_binding.event_tag - Bonsai_swiftui_ui.Event.Tag.Navigation_path_changed) - node.Bonsai_swiftui_runtime.Mounted_tree.Snapshot.event_bindings - |> Option.get - in - let event : Wire.Inbound_event.t = - { sequence = ID.Runtime.Event_sequence.of_int64 sequence - ; displayed_revision = Test.Handle.revision handle - ; node_id = node.node_id - ; handler_id = binding.handler_id - ; event_tag = Wire.Generated_protocol.Event_tag.navigation_path_changed - ; payload = Navigation_path_changed [] - } - in - Test.Handle.pump_next handle ~events:{ runtime_epoch = epoch; events = [ event ] } (); - export_frame handle; - Test.Handle.present handle -;; - -let button_within handle node = - let rec contains widget candidate = - widget == candidate - || Array.exists - (fun child -> contains child candidate) - (let (Av view) = Bonsai_swiftui_ui.View.Private.view widget in - view.children) - in - Test.Handle.find_all handle (Test.Query.kind "button") - |> List.find (fun candidate -> - contains - node.Bonsai_swiftui_runtime.Mounted_tree.Snapshot.widget - candidate.Bonsai_swiftui_runtime.Mounted_tree.Snapshot.widget) -;; - -let press handle epoch sequence test_id = - let node = Test.Handle.find handle (Test.Query.test_id test_id) |> Option.get in - press_node handle epoch sequence (button_within handle node) -;; - -let native_timeline_action handle epoch sequence action = - let node = - Test.Handle.find - handle - (Test.Query.key (Bonsai_swiftui_ui.Key.string (action ^ ":" ^ block_id))) - |> Option.get - in - press_node handle epoch sequence node -;; - -let native_delete handle epoch sequence = - native_timeline_action handle epoch sequence "delete" -;; - -let undo handle epoch sequence request_id = - let event : Wire.Inbound_event.t = - { sequence = ID.Runtime.Event_sequence.of_int64 sequence - ; displayed_revision = Test.Handle.revision handle - ; node_id = ID.Ui.Node_id.zero - ; handler_id = ID.Ui.Handler_id.zero - ; event_tag = Wire.Generated_protocol.Event_tag.host_response - ; payload = - Host_response { request_id; status = Host_ok; value = Bytes.make 1 '\000' } - } - in - Test.Handle.pump_next handle ~events:{ runtime_epoch = epoch; events = [ event ] } (); - export_frame handle; - Test.Handle.present handle -;; - -let undo_request handle = - let rec find remaining = - let frame = Test.Handle.last_frame handle |> Option.get in - let frame = Wire.Binary_codec.decode frame.bytes |> Result.get_ok in - match - List.find_map - (function - | Wire.Wire_frame.Host_request - { request_id; payload = Show_notice { action_label = Some "Undo"; _ } } -> - Some request_id - | _ -> None) - frame.operations - with - | Some request_id -> request_id - | None when remaining > 0 -> - Test.Handle.present handle; - Test.Handle.pump_next handle (); - Test.Handle.present handle; - find (remaining - 1) - | None -> failwith "delete did not offer Undo" - in - find 20 -;; - -let pump handle = - for _ = 1 to 20 do - (* The fixture service runs on a worker thread. Let it consume queued work - before advancing the next deterministic UI pump. *) - Thread.delay 0.001; - Test.Handle.present handle; - Test.Handle.pump_next handle (); - export_frame handle - done; - Test.Handle.present handle -;; - -let () = - let epoch = ID.Runtime.Epoch.of_int64 8001L in - let calendar_sampler = - Journal_calendar.Sampler.create - ~clock:(fun () -> 1_788_192_000.) - ~localtime:Unix.gmtime - () - in - let time_source = Bonsai.Time_source.create ~start:Core.Time_ns.epoch in - let handle = - Test.Handle.create_app - ~runtime_epoch:epoch - ~time_source - (Application.For_testing.app_with_service ~calendar_sampler service) - ~application_payload - in - Fun.protect - ~finally:(fun () -> Test.Handle.shutdown handle) - (fun () -> - export_frame handle; - pump handle; - !emit - (Service.Graph_state_changed - { generation = 1; graph_id = Some graph_id; phase = Graph_open; error = None }); - pump handle; - require (!graph_reads = 1) "open graph did not receive graph-info request"; - select_account handle epoch 3L 1L; - pump handle; - require (!inspections = 1) "opening Diagnostics did not dispatch its inspection"; - require - (Test.Handle.find handle (Test.Query.visible_text "0 / 200") <> None) - "zero-count inspection did not become Available"; - let reopen sequence = - press handle epoch sequence "journal-diagnostics-close"; - select_account handle epoch (Int64.add sequence 2L) 1L; - pump handle - in - counts := 3; - reopen 4L; - require (!inspections = 2) "reopen did not start one fresh inspection"; - require - (Test.Handle.find handle (Test.Query.visible_text "3 / 200") <> None) - "nonzero metrics lost"; - unavailable := true; - reopen 7L; - require (!inspections = 3) "unavailable inspection was not dispatched"; - require - (Test.Handle.find_all handle (Test.Query.visible_text "Not available") - |> List.length - >= 4) - "unavailable completion left Loading"; - unavailable := false; - service_failure := true; - reopen 10L; - require (!inspections = 4) "failed service inspection was not dispatched"; - require - (Test.Handle.find_all handle (Test.Query.visible_text "Not available") - |> List.length - >= 4) - "worker service failure left Loading"; - service_failure := false; - reopen 13L; - require (!inspections = 5) "reopen after failure retained orphaned request"; - require - (Test.Handle.find handle (Test.Query.visible_text "3 / 200") <> None) - "reopen after failure did not recover"; - press handle epoch 16L "journal-diagnostics-close"; - native_delete handle epoch 17L; - let undo_id = undo_request handle in - require (!deletes = 0) "delete dispatched before Undo deadline"; - require - (Test.Handle.find - handle - (Test.Query.key - (Bonsai_swiftui_ui.Key.string ("journal-row-actions:" ^ block_id))) - = None) - "delete did not hide row"; - title := "After remote update"; - revision := "block-2"; - !emit - (Service.Graph_push - (P.V2_resync_required_push - { api_version = P.api_version - ; generation = "generation-1" - ; reason = "fixture authoritative refresh" - })); - pump handle; - require - (Test.Handle.find - handle - (Test.Query.key - (Bonsai_swiftui_ui.Key.string ("journal-row-actions:" ^ block_id))) - = None) - "reconciliation resurrected a row during the Undo window"; - undo handle epoch 18L undo_id; - pump handle; - require - (Test.Handle.find handle (Test.Query.visible_text "After remote update") <> None) - "Undo discarded latest target text"; - ignore (Test.Handle.pump handle ~monotonic_now_ns:6_000_000_000L ()); - pump handle; - require (!deletes = 0) "cancelled Undo deadline emitted a delete"; - native_delete handle epoch 19L; - pump handle; - ignore (Test.Handle.pump handle ~monotonic_now_ns:12_000_000_000L ()); - pump handle; - require - (!deletes = 1 && !deleted) - "second delete did not commit once using the reconciled revision"; - require - (Test.Handle.find - handle - (Test.Query.key - (Bonsai_swiftui_ui.Key.string ("journal-row-actions:" ^ block_id))) - = None) - "committed delete restored the row"; - ()) -;; - -let select_tab handle epoch sequence index = - let label = if index = 0L then "Journals" else "Favorites" in - let node = Test.Handle.find handle (Test.Query.semantics_label label) |> Option.get in - press_node handle epoch sequence (button_within handle node); - pump handle -;; - -let () = - let epoch = ID.Runtime.Epoch.of_int64 8002L in - frame_phase := "startup"; - let handle = - Test.Handle.create_app - ~runtime_epoch:epoch - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - (Application.For_testing.app_with_service - ~calendar_sampler: - (Journal_calendar.Sampler.create - ~clock:(fun () -> 1_788_192_000.) - ~localtime:Unix.gmtime - ()) - service) - ~application_payload - in - Fun.protect - ~finally:(fun () -> Test.Handle.shutdown handle) - (fun () -> - export_frame handle; - pump handle; - if Option.is_some frame_directory - then ( - !emit - (Service.Client_state_changed - { snapshot = - { sync_phase = Connecting - ; catalog = [] - ; selected_graph = Some graph_id - ; applied_server_t = Some 0 - ; timeline_presentation_pending = false - ; startup = - { authenticated = true - ; catalog_loading = false - ; awaiting_selection = false - ; restoring_local = false - ; bootstrapping = false - ; awaiting_e2ee_password = false - ; failure = None - ; account_generation = 1 - ; graph_generation = 7 - ; presentation_generation = 1 - } - ; last_error = None - ; local_deletion = None - } - ; diagnostics = { groups = [] } - }); - pump handle); - frame_phase := "journals"; - !emit - (Service.Graph_state_changed - { generation = 7; graph_id = Some graph_id; phase = Graph_open; error = None }); - pump handle; - frame_phase := "journals"; - export_frame handle; - require (!favorites_reads = 0) "Favorites delayed or joined Journals startup"; - require - (Test.Handle.find handle (Test.Query.test_id "journal-capture-open") <> None) - "Journals has no Capture"; - frame_phase := "favorites"; - select_tab handle epoch 2L 1L; - require (!favorites_reads = 1) "Favorites selection did not lazily read once"; - require - (Test.Handle.find handle (Test.Query.visible_text "Design notes") <> None) - "ordinary favorite page is missing"; - require - (Test.Handle.find handle (Test.Query.visible_text "Review navigation") <> None) - "favorite block is missing"; - require - (Test.Handle.find handle (Test.Query.test_id "journal-capture-open") <> None) - "Favorites lost the shared Capture action"; - List.iter - (fun id -> - require - (Test.Handle.find handle (Test.Query.test_id id) = None) - "Favorites rendered an interactive row") - [ "journal-row-slidable:" ^ block_id; "journal-row-toggle-children:" ^ block_id ]; - select_tab handle epoch 3L 1L; - require (!favorites_reads = 1) "reselection restarted Favorites"; - require - (Test.Handle.find handle (Test.Query.kind "native_list") <> None) - "Favorites must use a public native List"; - require - (Test.Handle.find handle (Test.Query.kind "navigation_link") <> None) - "Favorite block activation must use Navigation_link"; - frame_phase := "returned"; - select_tab handle epoch 4L 0L; - require - (Test.Handle.find handle (Test.Query.test_id "journal-capture-open") <> None) - "return to Journals lost Capture"; - frame_phase := "favorites-again"; - select_tab handle epoch 5L 1L; - require (!favorites_reads = 1) "clean cache was reloaded"; - favorites_service_failure := true; - !emit - (Service.Graph_push - (P.V2_resync_required_push - { api_version = P.api_version - ; generation = "generation-1" - ; reason = "favorite refresh" - })); - pump handle; - require (!favorites_reads = 2) "Favorites refresh was not dispatched"; - require - (Test.Handle.find handle (Test.Query.visible_text "Retry") <> None) - "Favorites worker failure left a pending read without Retry"; - require - (Test.Handle.find handle (Test.Query.visible_text "Design notes") <> None) - "Favorites worker failure discarded cached rows"; - favorites_service_failure := false; - press handle epoch 6L "favorites-retry-button"; - pump handle; - require (!favorites_reads = 3) "Favorites did not recover from worker failure"; - if Option.is_some frame_directory - then ( - frame_phase := "returned-sync"; - select_tab handle epoch 7L 0L; - frame_phase := "favorites-sync"; - select_tab handle epoch 8L 1L); - let link = - Test.Handle.find - handle - (Test.Query.key (Bonsai_swiftui_ui.Key.string ("favorite-open:" ^ block_id))) - |> Option.get - in - press_node handle epoch 9L link; - pump handle; - require - (Test.Handle.find handle (Test.Query.test_id "journal-detail-outline") <> None) - "Favorites native link did not open its block destination"; - print_endline "FAVORITES_APPLICATION_VIEW_TESTS_PASSED") -;; - -(* Repeated public Timeline.observe_visible_range produces equal state and the - same request; the pure reducer cannot reproduce native binding churn. The - application view boundary owns handler allocation. Exercise that boundary - without duplicating the regression in transport or native gesture tests. *) -let () = - deleted := false; - let epoch = ID.Runtime.Epoch.of_int64 8004L in - let handle = - Test.Handle.create_app - ~runtime_epoch:epoch - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - (Application.For_testing.app_with_service service) - ~application_payload - in - Fun.protect - ~finally:(fun () -> Test.Handle.shutdown handle) - (fun () -> - pump handle; - !emit - (Service.Graph_state_changed - { generation = 1; graph_id = Some graph_id; phase = Graph_open; error = None }); - pump handle; - let collection () = - Test.Handle.find handle (Test.Query.test_id "journal-timeline") |> Option.get - in - let identity = (collection ()).node_id in - let scroll _sequence _pixels _delta = - Test.Handle.visible_range - handle - (Test.Query.test_id "journal-timeline") - ~first_index:0L - ~last_exclusive:1L; - Test.Handle.present handle; - require - ((collection ()).node_id = identity) - "scrolling replaced the timeline collection" - in - let completion_binding () = - Array.find_opt - (fun binding -> - Bonsai_swiftui_ui.Event.Tag.equal - binding.Bonsai_swiftui_runtime.Mounted_tree.Mounted_binding.event_tag - Bonsai_swiftui_ui.Event.Tag.List_scroll_completed) - (collection ()).event_bindings - |> Option.get - in - let completion = completion_binding () in - scroll 2L 40.5 40.5; - require - (completion_binding () = completion) - "viewport state changed the owner of an in-flight scroll completion"; - require - (Test.Handle.find handle (Test.Query.test_id "journal-root-navigation") <> None) - "downward scrolling unmounted the system toolbar"; - scroll 3L 10.25 (-30.25); - require - (Test.Handle.find handle (Test.Query.test_id "journal-root-navigation") <> None) - "upward scrolling unmounted the system toolbar"; - scroll 4L 60.75 50.5; - pump handle; - let bindings = (collection ()).event_bindings in - scroll 5L 0. (-60.75); - pump handle; - require - ((collection ()).event_bindings = bindings) - "an unchanged visible range replaced native action bindings"); - print_endline "TIMELINE_SCROLL_IDENTITY_PASSED" -;; - -(* The outline reducer already covers branch state. This native-view port - checks only admission and routing of rendered disclosure and Back controls. *) -let () = - deleted := false; - let epoch = ID.Runtime.Epoch.of_int64 8005L in - let handle = - Test.Handle.create_app - ~runtime_epoch:epoch - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - (Application.For_testing.app_with_service service) - ~application_payload - in - Fun.protect - ~finally:(fun () -> Test.Handle.shutdown handle) - (fun () -> - pump handle; - !emit - (Service.Graph_state_changed - { generation = 1; graph_id = Some graph_id; phase = Graph_open; error = None }); - pump handle; - native_timeline_action handle epoch 2L "open"; - pump handle; - let child_visible () = - Test.Handle.find - handle - (Test.Query.test_id "detail-block:70000000-0000-4000-a000-000000000002") - <> None - in - require (child_visible ()) "detail did not show its initial child"; - let outline = - Test.Handle.find handle (Test.Query.test_id "journal-detail-outline") - |> Option.get - in - require - (Bonsai_swiftui_ui.View.For_testing.kind_name outline.widget = "Native_list") - "outline must use public native List disclosure rows"; - let disclose sequence expanded = - let node = - Test.Handle.find handle (Test.Query.test_id ("detail-disclosure:" ^ block_id)) - |> Option.get - in - let binding = - Array.find_opt - (fun binding -> - Bonsai_swiftui_ui.Event.Tag.equal - binding.Bonsai_swiftui_runtime.Mounted_tree.Mounted_binding.event_tag - Bonsai_swiftui_ui.Event.Tag.Value_changed) - node.event_bindings - |> Option.get - in - let event : Wire.Inbound_event.t = - { sequence = ID.Runtime.Event_sequence.of_int64 sequence - ; displayed_revision = Test.Handle.revision handle - ; node_id = node.node_id - ; handler_id = binding.handler_id - ; event_tag = Wire.Generated_protocol.Event_tag.value_changed - ; payload = Bool expanded - } - in - Test.Handle.pump_next - handle - ~events:{ runtime_epoch = epoch; events = [ event ] } - (); - export_frame handle; - Test.Handle.present handle - in - let completion_binding () = - let node = - Test.Handle.find handle (Test.Query.test_id "journal-detail-outline") - |> Option.get - in - Array.find_opt - (fun binding -> - Bonsai_swiftui_ui.Event.Tag.equal - binding.Bonsai_swiftui_runtime.Mounted_tree.Mounted_binding.event_tag - Bonsai_swiftui_ui.Event.Tag.List_scroll_completed) - node.event_bindings - |> Option.get - in - let completion = completion_binding () in - disclose 3L false; - pump handle; - require (not (child_visible ())) "native disclosure did not collapse the root"; - disclose 4L true; - pump handle; - require (child_visible ()) "native disclosure did not expand the root"; - require - (completion_binding () = completion) - "disclosure state changed its scroll completion owner"; - require - (Test.Handle.find handle (Test.Query.test_id "detail-back") = None) - "detail contains a duplicate Back button"; - native_back handle epoch 5L; - pump handle; - require - (Test.Handle.find handle (Test.Query.test_id "journal-detail-route") = None) - "detail Back did not restore the originating list"); - print_endline "DETAIL_DISCLOSURE_DISPATCH_PASSED" -;; - -(* View metadata is owned by Application's renderer, not the sync reducer. - Valid public snapshots and native Press events reproduce these failures. *) -let test_native_button_semantics name catalog = - let epoch = ID.Runtime.Epoch.of_int64 8003L in - let time_source = Bonsai.Time_source.create ~start:Core.Time_ns.epoch in - let handle = - Test.Handle.create_app - ~runtime_epoch:epoch - ~time_source - (Application.For_testing.app_with_service service) - ~application_payload - in - Fun.protect - ~finally:(fun () -> Test.Handle.shutdown handle) - (fun () -> - pump handle; - match catalog with - | None -> - !emit - (Service.Graph_state_changed - { generation = 1 - ; graph_id = Some graph_id - ; phase = Graph_open - ; error = None - }); - pump handle; - select_account handle epoch 3L 1L; - pump handle; - require - (Test.Handle.find handle (Test.Query.kind "form") <> None) - "diagnostics Form did not render"; - press handle epoch 7L "journal-diagnostics-close"; - pump handle; - require - (Test.Handle.find handle (Test.Query.test_id "journal-diagnostics-dialog-page") - = None) - "native Close did not dismiss Diagnostics" - | Some catalog -> - !emit - (Service.Client_state_changed - { snapshot = - { sync_phase = Offline - ; catalog - ; selected_graph = None - ; applied_server_t = None - ; timeline_presentation_pending = false - ; startup = - { authenticated = true - ; catalog_loading = false - ; awaiting_selection = true - ; restoring_local = false - ; bootstrapping = false - ; awaiting_e2ee_password = false - ; failure = None - ; account_generation = 1 - ; graph_generation = 1 - ; presentation_generation = 1 - } - ; last_error = None - ; local_deletion = None - } - ; diagnostics = { groups = [] } - }); - pump handle; - press handle epoch 2L "graph-picker-refresh"; - pump handle; - if catalog <> [] - then ( - press handle epoch 3L ("graph-picker:" ^ Graph.Uuid.to_string graph_id); - pump handle)); - Printf.printf "NATIVE_BUTTON_SEMANTICS_PASSED %s\n" name -;; - -(* Startup's public pure state already reports the E2ee recovery correctly. - Missing password controls belong to the application presentation boundary. *) -let test_native_unlock_recovery () = - List.iter - (fun (failed, named) -> - let epoch = ID.Runtime.Epoch.of_int64 (if failed then 8011L else 8010L) in - let handle = - Test.Handle.create_app - ~runtime_epoch:epoch - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - (Application.For_testing.app_with_service service) - ~application_payload - in - Fun.protect - ~finally:(fun () -> Test.Handle.shutdown handle) - (fun () -> - pump handle; - let snapshot : Service.snapshot = - { sync_phase = Offline - ; catalog = - (if named - then - [ { graph_id - ; name = "Research journal — 中文 👩🏽‍💻" - ; schema = { major = 1; minor = 0; exact = false } - ; encrypted = true - } - ] - else []) - ; selected_graph = Some graph_id - ; applied_server_t = None - ; timeline_presentation_pending = false - ; startup = - { authenticated = true - ; catalog_loading = false - ; awaiting_selection = false - ; restoring_local = false - ; bootstrapping = false - ; awaiting_e2ee_password = not failed - ; failure = (if failed then Some During_e2ee else None) - ; account_generation = 1 - ; graph_generation = 1 - ; presentation_generation = 1 - } - ; last_error = - (if failed then Some "Incorrect encryption password" else None) - ; local_deletion = None - } - in - !emit - (Service.Client_state_changed { snapshot; diagnostics = { groups = [] } }); - pump handle; - require - (Test.Handle.find handle (Test.Query.test_id "e2ee-password-editor") <> None) - "unlock recovery omitted the password editor"; - require - (Test.Handle.find handle (Test.Query.test_id "e2ee-password-cancel") <> None) - "unlock omitted its native Cancel action"; - require - (Test.Handle.find - handle - (Test.Query.visible_text - (if named then "Research journal — 中文 👩🏽‍💻" else "Encrypted graph")) - <> None) - "unlock omitted readable graph context"; - require - (Test.Handle.find handle (Test.Query.visible_text "Choose another graph") - <> None) - "unlock cancellation does not explain its destination"; - if failed - then - require - (Test.Handle.find - handle - (Test.Query.visible_text "Incorrect encryption password") - <> None) - "unlock recovery omitted the inline failure"; - client_commands := []; - let field () = - Test.Handle.find handle (Test.Query.kind "secure_field") |> Option.get - in - let editor_state () = - let node = field () in - let (Av view) = Bonsai_swiftui_ui.View.Private.view node.widget in - match view.node with - | Text_field { session_id; value; secure; _ } -> - require secure "unlock input lost secure semantics"; - session_id, Bonsai_swiftui_ui.Text_editing.Value.text value - | _ -> failwith "unlock editor is not a native secure field" - in - let edit_password sequence text = - let node = field () in - let (Av view) = Bonsai_swiftui_ui.View.Private.view node.widget in - let session_id, local_revision, base_document_revision = - match view.node with - | Text_field { session_id; accepted_local_revision; document_revision; _ } - -> - ( session_id - , ID.Text_input.Local_revision.succ accepted_local_revision - , document_revision ) - | _ -> failwith "unlock editor is not a native text input" - in - let binding = - Array.find_opt - (fun binding -> - Bonsai_swiftui_ui.Event.Tag.equal - binding - .Bonsai_swiftui_runtime.Mounted_tree.Mounted_binding.event_tag - Bonsai_swiftui_ui.Event.Tag.Text_edit) - node.event_bindings - |> Option.get - in - let cursor = Bonsai_swiftui_ui.Text_editing.Utf16.length text in - let event : Wire.Inbound_event.t = - { sequence = ID.Runtime.Event_sequence.of_int64 sequence - ; displayed_revision = Test.Handle.revision handle - ; node_id = node.node_id - ; handler_id = binding.handler_id - ; event_tag = Wire.Generated_protocol.Event_tag.text_edit - ; payload = - Text_edit - { session_id - ; local_revision - ; base_document_revision - ; text - ; selection = { start_utf16 = cursor; end_utf16 = cursor } - ; composing = None - } - } - in - Test.Handle.pump_next - handle - ~events:{ runtime_epoch = epoch; events = [ event ] } - (); - pump handle; - require (snd (editor_state ()) = text) "unlock did not admit native input" - in - let keyboard_submit sequence = - let node = field () in - let binding = - Array.find_opt - (fun binding -> - Bonsai_swiftui_ui.Event.Tag.equal - binding - .Bonsai_swiftui_runtime.Mounted_tree.Mounted_binding.event_tag - Bonsai_swiftui_ui.Event.Tag.Text_submit) - node.event_bindings - |> Option.get - in - let event : Wire.Inbound_event.t = - { sequence = ID.Runtime.Event_sequence.of_int64 sequence - ; displayed_revision = Test.Handle.revision handle - ; node_id = node.node_id - ; handler_id = binding.handler_id - ; event_tag = Wire.Generated_protocol.Event_tag.text_submit - ; payload = Text (snd (editor_state ())) - } - in - Test.Handle.pump_next - handle - ~events:{ runtime_epoch = epoch; events = [ event ] } - () - in - keyboard_submit 2L; - pump handle; - require (!client_commands = []) "empty unlock submitted a password"; - edit_password 3L "synthetic first attempt"; - let submitted_session = fst (editor_state ()) in - press handle epoch 4L "e2ee-password-submit"; - pump handle; - require - (!client_commands - = [ Service.Submit_e2ee_password "synthetic first attempt" ]) - "unlock did not submit exactly the entered password"; - let fresh_session, source = editor_state () in - require (source = "") "unlock retained submitted secret input"; - require - (not (ID.Text_input.Session_id.equal submitted_session fresh_session)) - "unlock reused the submitted editor session"; - let retry_snapshot = - { snapshot with - startup = - { snapshot.startup with - awaiting_e2ee_password = false - ; failure = Some During_e2ee - } - ; last_error = Some "Incorrect encryption password" - } - in - !emit - (Service.Client_state_changed - { snapshot = retry_snapshot; diagnostics = { groups = [] } }); - pump handle; - require - (Test.Handle.find - handle - (Test.Query.visible_text "Incorrect encryption password") - <> None) - "retry omitted the inline failure"; - client_commands := []; - edit_password 5L "synthetic correction 中文"; - keyboard_submit 6L; - pump handle; - require - (!client_commands - = [ Service.Submit_e2ee_password "synthetic correction 中文" ]) - "retry did not submit exactly the corrected password"; - require (snd (editor_state ()) = "") "retry retained submitted secret input"; - edit_password 7L "synthetic canceled input"; - client_commands := []; - press handle epoch 8L "e2ee-password-cancel"; - pump handle; - require - (List.mem Service.Return_to_graph_picker !client_commands) - "Cancel did not return to graph selection"; - require - (not - (List.exists - (function - | Service.Submit_e2ee_password _ -> true - | _ -> false) - !client_commands)) - "Cancel submitted an encryption password")) - [ true, true; false, true; true, false; false, false ]; - print_endline "NATIVE_UNLOCK_RECOVERY_PASSED" -;; - -let () = test_native_unlock_recovery () - -let () = - let failures = - List.filter_map - (fun (name, catalog) -> - try - test_native_button_semantics name catalog; - None - with - | exn -> Some (name ^ ": " ^ Printexc.to_string exn)) - [ "settings", None - ; "empty picker", Some [] - ; ( "populated picker" - , Some - [ { Logseq_db_types.Managed_graph.graph_id - ; name = "Native graph" - ; schema = { major = 65; minor = 33; exact = true } - ; encrypted = false - } - ] ) - ] - in - if failures <> [] then failwith (String.concat "\n" failures); - print_endline "NATIVE_BUTTON_SUITE_PASSED" -;; - -(* Application owns feed failure presentation. Root_navigation's public reducer - cannot receive the manager snapshot or expose the rendered root choice. *) -let () = - List.iter - (fun (managed, presentation_pending) -> - let epoch = ID.Runtime.Epoch.of_int64 (if managed then 8021L else 8020L) in - initial_feed_failure := true; - let handle = - Test.Handle.create_app - ~runtime_epoch:epoch - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - (Application.For_testing.app_with_service service) - ~application_payload - in - Fun.protect - ~finally:(fun () -> - initial_feed_failure := false; - Test.Handle.shutdown handle) - (fun () -> - pump handle; - let publish_manager generation = - if managed - then ( - let snapshot : Service.snapshot = - { sync_phase = Current - ; catalog = [] - ; selected_graph = Some graph_id - ; applied_server_t = Some 0 - ; timeline_presentation_pending = presentation_pending - ; startup = - { authenticated = true - ; catalog_loading = false - ; awaiting_selection = false - ; restoring_local = false - ; bootstrapping = false - ; awaiting_e2ee_password = false - ; failure = None - ; account_generation = 1 - ; graph_generation = generation - ; presentation_generation = generation - } - ; last_error = None - ; local_deletion = None - } - in - !emit - (Service.Client_state_changed - { snapshot; diagnostics = { groups = [] } }); - pump handle) - in - let publish_graph generation = - !emit - (Service.Graph_state_changed - { generation - ; graph_id = Some graph_id - ; phase = Graph_open - ; error = None - }); - pump handle - in - publish_manager 1; - publish_graph 1; - require - (Test.Handle.find - handle - (Test.Query.visible_text "Journal fixture read failed") - <> None) - "Initial feed failure was hidden by the startup presentation"; - require - (Test.Handle.find handle (Test.Query.visible_text "Opening journal") = None) - "Terminal feed failure still presented a loading spinner"; - let node = - Test.Handle.find handle (Test.Query.test_id "logseq-graph-open-failed") - |> Option.get - in - let module V = Bonsai_swiftui_ui.View in - let (Av view) = V.Private.view node.widget in - (match view.node with - | V.Private.Content_unavailable -> () - | _ -> failwith "Graph failure is not a public ContentUnavailable view"); - press handle epoch 2L "graph-failure-details"; - pump handle; - require - (Test.Handle.find handle (Test.Query.test_id "journal-error-info-page") - <> None) - "Graph failure details action did not open the retained worker error"; - press handle epoch 3L "journal-error-info-close"; - pump handle; - if managed - then ( - client_commands := []; - press handle epoch 4L "graph-failure-choose"; - pump handle; - require - (List.mem Service.Return_to_graph_picker !client_commands) - "Graph recovery did not return to graph selection"); - initial_feed_failure := false; - publish_manager 2; - if managed - then - require - (Test.Handle.find handle (Test.Query.test_id "logseq-graph-open-failed") - = None) - "A new manager generation displayed the previous graph failure"; - publish_graph 2; - require - (Test.Handle.find handle (Test.Query.test_id "logseq-graph-open-failed") - = None) - "Recovered graph retained the unavailable presentation"; - require - (Test.Handle.find handle (Test.Query.test_id "journal-timeline") <> None) - "Recovered graph did not return to its journal")) - [ true, false; true, true; false, false ]; - print_endline "NATIVE_GRAPH_FAILURE_RECOVERY_PASSED" -;; - -(* The public Application reducer cannot admit status/delete actions. Exercise - their production admission and rendered feedback through native events. *) -let () = - List.iter - (fun delete -> - let epoch = ID.Runtime.Epoch.of_int64 (if delete then 8031L else 8030L) in - deleted := false; - mutation_failure := true; - let handle = - Test.Handle.create_app - ~runtime_epoch:epoch - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - (Application.For_testing.app_with_service service) - ~application_payload - in - Fun.protect - ~finally:(fun () -> - mutation_failure := false; - Test.Handle.shutdown handle) - (fun () -> - pump handle; - let publish_graph generation = - !emit - (Service.Graph_state_changed - { generation - ; graph_id = Some graph_id - ; phase = Graph_open - ; error = None - }); - pump handle - in - publish_graph 1; - let list_id () = - (Test.Handle.find handle (Test.Query.test_id "journal-timeline") - |> Option.get) - .node_id - in - let initial_list_id = list_id () in - let submit sequence = - if delete - then native_delete handle epoch sequence - else ( - native_timeline_action handle epoch sequence "status"; - pump handle; - select_picker handle epoch (Int64.succ sequence) 1L); - pump handle - in - let initial_writes = !deletes + !status_writes in - submit 2L; - ignore (Test.Handle.pump handle ~monotonic_now_ns:6_000_000_000L ()); - pump handle; - let summary = if delete then "Delete failed" else "Status not changed" in - require - (Test.Handle.find handle (Test.Query.visible_text summary) <> None) - "Mutation failure has no persistent inline feedback"; - require - (list_id () = initial_list_id) - "Mutation feedback remounted the native journal list"; - require (not !deleted) "Failed deletion did not restore the block"; - let writes = !deletes + !status_writes in - require - (writes = initial_writes + 1) - "Fixture did not execute the requested mutation failure"; - ignore (Test.Handle.pump handle ~monotonic_now_ns:60_000_000_000L ()); - pump handle; - require - (Test.Handle.find handle (Test.Query.visible_text summary) <> None) - "Mutation recovery disappeared after the old notice timeout"; - native_timeline_action handle epoch 4L "open"; - pump handle; - require - (Test.Handle.find handle (Test.Query.test_id "detail-operation-details") - <> None) - "Opening Detail lost the mutation recovery actions"; - press handle epoch 5L "detail-operation-details"; - pump handle; - require - (Test.Handle.find handle (Test.Query.test_id "journal-error-info-page") - <> None) - "Mutation details did not open the native error sheet"; - require - (Test.Handle.find - handle - (Test.Query.visible_text - (if delete - then - "The block has been restored. Review it before trying Delete \ - again." - else - "The block keeps its current status. Open its Status menu to try \ - again.")) - <> None) - "Mutation details omitted contextual recovery guidance"; - press handle epoch 6L "journal-error-info-close"; - pump handle; - native_back handle epoch 7L; - pump handle; - press handle epoch 8L "root-operation-dismiss"; - pump handle; - require - (Test.Handle.find handle (Test.Query.visible_text summary) = None) - "Dismiss did not clear persistent feedback"; - require - (!deletes + !status_writes = writes) - "Reading or dismissing mutation feedback retried a write"; - submit 9L; - ignore (Test.Handle.pump handle ~monotonic_now_ns:66_000_000_000L ()); - pump handle; - require - (Test.Handle.find handle (Test.Query.visible_text summary) <> None) - "A later failed operation did not show feedback again"; - publish_graph 2; - require - (Test.Handle.find handle (Test.Query.visible_text summary) = None) - "Graph replacement leaked the previous operation failure")) - [ false; true ]; - print_endline "PERSISTENT_MUTATION_FEEDBACK_PASSED" -;; - -(* Journal_startup.derive correctly reports Failed and online recovery. The - public pure owners do not expose manager-page action construction. Exercise - that production presentation and dispatch through the native event harness. *) -let test_startup_graph_selection_recovery () = - List.iteri - (fun index (failure, phase, selected, deletion, choose, retry) -> - let epoch = ID.Runtime.Epoch.of_int64 (Int64.of_int (9100 + index)) in - let handle = - Test.Handle.create_app - ~runtime_epoch:epoch - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - (Application.For_testing.app_with_service service) - ~application_payload - in - Fun.protect - ~finally:(fun () -> Test.Handle.shutdown handle) - (fun () -> - pump handle; - let selected_graph = if selected then Some graph_id else None in - let snapshot : Service.snapshot = - { sync_phase = Offline - ; catalog = [] - ; selected_graph - ; applied_server_t = None - ; timeline_presentation_pending = false - ; startup = - { authenticated = true - ; catalog_loading = false - ; awaiting_selection = false - ; restoring_local = false - ; bootstrapping = false - ; awaiting_e2ee_password = false - ; failure - ; account_generation = 1 - ; graph_generation = 1 - ; presentation_generation = 1 - } - ; last_error = Some "Local graph cannot open" - ; local_deletion = deletion - } - in - !emit - (Service.Client_state_changed { snapshot; diagnostics = { groups = [] } }); - pump handle; - !emit - (Service.Graph_state_changed - { generation = 1; graph_id = selected_graph; phase; error = None }); - pump handle; - require - (Test.Handle.find handle (Test.Query.test_id "graph-picker-retry") - <> None - = retry) - (Printf.sprintf - "Startup failure case %d changed its retry availability" - index); - require - (Test.Handle.find handle (Test.Query.test_id "graph-picker-choose") - <> None - = choose) - (Printf.sprintf - "Startup failure case %d omitted graph selection, or exposed it during \ - deletion" - index); - if choose - then ( - require - (Test.Handle.find handle (Test.Query.visible_text "Choose another graph") - <> None) - "Startup graph selection did not explain its destination"; - client_commands := []; - press handle epoch 2L "graph-picker-choose"; - pump handle; - require - (!client_commands = [ Service.Return_to_graph_picker ]) - (Printf.sprintf - "Choosing another graph case %d dispatched %d commands (%d picker)" - index - (List.length !client_commands) - (List.length - (List.filter - (fun c -> c = Service.Return_to_graph_picker) - !client_commands)))))) - [ ( Some Service.During_local_restore - , Logseq_db_worker.Graph_closed - , true - , None - , true - , true ) - ; Some Service.During_bootstrap, Graph_closed, true, None, true, true - ; None, Graph_failed, true, None, true, true - ; Some Service.During_local_restore, Graph_closed, false, None, false, true - ; ( None - , Graph_closed - , true - , Some (Service.Deletion_failed Service.Closing_graph) - , false - , false ) - ]; - print_endline "NATIVE_STARTUP_GRAPH_SELECTION_RECOVERY_PASSED" -;; - -let () = test_startup_graph_selection_recovery () - -(* Startup derivation owns phases, but has no rendered-action interface. These - valid pending snapshots reproduce the omission in the presentation owner. *) -let test_pending_startup_graph_selection () = - List.iteri - (fun index - ( authenticated - , selected - , catalog_loading - , bootstrapping - , deletion - , expected_phase - , choose ) -> - let epoch = ID.Runtime.Epoch.of_int64 (Int64.of_int (9200 + index)) in - let handle = - Test.Handle.create_app - ~runtime_epoch:epoch - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - (Application.For_testing.app_with_service service) - ~application_payload - in - Fun.protect - ~finally:(fun () -> Test.Handle.shutdown handle) - (fun () -> - pump handle; - let selected_graph = if selected then Some graph_id else None in - let snapshot : Service.snapshot = - { sync_phase = Offline - ; catalog = [] - ; selected_graph - ; applied_server_t = None - ; timeline_presentation_pending = false - ; startup = - { authenticated - ; catalog_loading - ; awaiting_selection = false - ; restoring_local = (not catalog_loading) && not bootstrapping - ; bootstrapping - ; awaiting_e2ee_password = false - ; failure = None - ; account_generation = 1 - ; graph_generation = 1 - ; presentation_generation = 1 - } - ; last_error = None - ; local_deletion = deletion - } - in - let graph : Logseq_db_worker.graph_state = - { generation = 1 - ; graph_id = selected_graph - ; phase = Graph_closed - ; error = None - } - in - let startup = Journal_startup.derive ~snapshot ~graph in - require - (startup.phase = expected_phase && startup.error = None) - "Public startup derivation did not identify the valid pending phase"; - !emit - (Service.Client_state_changed { snapshot; diagnostics = { groups = [] } }); - pump handle; - !emit (Service.Graph_state_changed graph); - pump handle; - require - (Test.Handle.find handle (Test.Query.test_id "graph-picker-choose") - <> None - = choose) - (Printf.sprintf - "Pending startup case %d lost graph selection or exposed an unsafe exit" - index); - if choose - then ( - client_commands := []; - press handle epoch 2L "graph-picker-choose"; - pump handle; - require - (!client_commands = [ Service.Return_to_graph_picker ]) - "Leaving pending startup retried or mutated a graph"))) - [ true, true, true, false, None, Journal_startup.Loading_catalog, true - ; true, true, false, false, None, Restoring_local, true - ; true, true, false, true, None, Bootstrapping, true - ; true, false, true, false, None, Loading_catalog, false - ; false, true, false, false, None, Signed_out, false - ; ( true - , true - , false - , false - , Some (Service.Deletion_in_progress Service.Closing_graph) - , Deleting_local - , false ) - ]; - print_endline "NATIVE_PENDING_STARTUP_GRAPH_SELECTION_PASSED" -;; - -let () = test_pending_startup_graph_selection () - -let test_confirmation_token_ownership () = - let epoch = ID.Runtime.Epoch.of_int64 8040L in - let handle = - Test.Handle.create_app - ~runtime_epoch:epoch - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - (Application.For_testing.app_with_service service) - ~application_payload - in - Fun.protect - ~finally:(fun () -> Test.Handle.shutdown handle) - (fun () -> - pump handle; - !emit - (Service.Client_state_changed - { snapshot = - { sync_phase = Current - ; catalog = [] - ; selected_graph = Some graph_id - ; applied_server_t = Some 0 - ; timeline_presentation_pending = false - ; startup = - { authenticated = true - ; catalog_loading = false - ; awaiting_selection = false - ; restoring_local = false - ; bootstrapping = false - ; awaiting_e2ee_password = false - ; failure = None - ; account_generation = 1 - ; graph_generation = 1 - ; presentation_generation = 1 - } - ; last_error = None - ; local_deletion = None - } - ; diagnostics = { groups = [] } - }); - pump handle; - !emit - (Service.Graph_state_changed - { generation = 1; graph_id = Some graph_id; phase = Graph_open; error = None }); - pump handle; - let current () = - let node = - Test.Handle.find handle (Test.Query.test_id "local-cache-reset-confirmation") - |> Option.get - in - let (Av view) = Bonsai_swiftui_ui.View.Private.view node.widget in - match view.node with - | Bonsai_swiftui_ui.View.Private.Confirmation { request_token; _ } -> - node, request_token - | _ -> failwith "cache deletion must use public Confirmation" - in - let respond ?owner sequence token action_key = - let node = Option.value owner ~default:(fst (current ())) in - let binding = - Array.find_opt - (fun binding -> - Bonsai_swiftui_ui.Event.Tag.equal - binding.Bonsai_swiftui_runtime.Mounted_tree.Mounted_binding.event_tag - Bonsai_swiftui_ui.Event.Tag.Confirmation_response) - node.event_bindings - |> Option.get - in - let event : Wire.Inbound_event.t = - { sequence = ID.Runtime.Event_sequence.of_int64 sequence - ; displayed_revision = Test.Handle.revision handle - ; node_id = node.node_id - ; handler_id = binding.handler_id - ; event_tag = Wire.Generated_protocol.Event_tag.confirmation_response - ; payload = Confirmation_response { token; action_key } - } - in - Test.Handle.pump_next - handle - ~events:{ runtime_epoch = epoch; events = [ event ] } - (); - pump handle - in - select_account handle epoch 2L 3L; - pump handle; - let _, first = current () in - let first = Option.get first in - client_commands := []; - respond 3L first (Some "cancel"); - require - (snd (current ()) = None && !client_commands = []) - "cancel deleted local storage"; - select_account handle epoch 4L 3L; - pump handle; - let second = snd (current ()) |> Option.get in - require (second > first) "reopening reused a confirmation token"; - respond 5L first (Some "delete"); - require - (snd (current ()) = Some second && !client_commands = []) - "stale confirmation accepted"; - respond 6L second None; - require - (snd (current ()) = None && !client_commands = []) - "dismissal deleted local storage"; - select_account handle epoch 7L 3L; - pump handle; - let third = snd (current ()) |> Option.get in - let owner = fst (current ()) in - respond 8L third (Some "delete"); - respond ~owner 9L third (Some "delete"); - require - (!client_commands = [ Service.Delete_local_cache graph_id ]) - "confirmation must emit exactly one cache reset"; - print_endline "CONFIRMATION_TOKEN_OWNERSHIP_PASSED") -;; - -let () = test_confirmation_token_ownership () - -let () = - let epoch = ID.Runtime.Epoch.of_int64 8900L in - let now = ref 1_788_192_000. in - journal_ranges := []; - initial_feed_failure := false; - let sampler = - Journal_calendar.Sampler.create ~clock:(fun () -> !now) ~localtime:Unix.gmtime () - in - let handle = - Test.Handle.create_app - ~runtime_epoch:epoch - ~time_source:(Bonsai.Time_source.create ~start:Core.Time_ns.epoch) - (Application.For_testing.app_with_service ~calendar_sampler:sampler service) - ~application_payload - in - Fun.protect - ~finally:(fun () -> Test.Handle.shutdown handle) - (fun () -> - export_frame handle; - pump handle; - !emit - (Service.Graph_state_changed - { generation = 1; graph_id = Some graph_id; phase = Graph_open; error = None }); - pump handle; - Test.Handle.native_event - handle - (Test.Query.key (Bonsai_swiftui_ui.Key.string "asset-settings")) - ~kind_id:(ID.Native_widget.Kind_id.of_int 2106) - ~version:1 - ~event_id:(ID.Native_widget.Event_id.of_int 1) - ~payload:(Bytes.of_string "days:2"); - pump handle; - require - (List.mem (20260830, 20260831) !journal_ranges) - "saved preference did not scope asset enumeration"; - let before = !journal_ranges in - require (before <> []) "calendar probe did not open a feed"; - now := !now +. 86400.; - ignore (Test.Handle.pump handle ~monotonic_now_ns:61_000_000_000L ()); - pump handle; - require (!journal_ranges <> before) "foreground midnight did not refresh the feed"; - require - (List.mem (20260831, 20260901) !journal_ranges) - "foreground midnight did not refresh the two-day asset interval"; - let after = !journal_ranges in - ignore (Test.Handle.pump handle ~monotonic_now_ns:121_000_000_000L ()); - pump handle; - require (!journal_ranges = after) "unchanged calendar restarted enumeration"; - print_endline "FOREGROUND_CALENDAR_REFRESH_PASSED") -;; - -let () = print_endline "MACOS_APPLICATION_DISPATCH_TESTS_PASSED" diff --git a/test/macos_mutation_input_diagnostics_pure_reducer_test.ml b/test/macos_mutation_input_diagnostics_pure_reducer_test.ml index 49079be..f6c2e22 100644 --- a/test/macos_mutation_input_diagnostics_pure_reducer_test.ml +++ b/test/macos_mutation_input_diagnostics_pure_reducer_test.ml @@ -1,5 +1,5 @@ -module ID = Bonsai_swiftui_spec.Id -module Ui = Bonsai_swiftui_ui +module ID = Journal_ids +module Ui = Journal_view let require condition message = if not condition then failwith message let equal expected actual label = require (expected = actual) label diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index 3746e96..5e9ddfc 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -538,7 +538,7 @@ let test_injected_logseq_sync_api_boundary root = [ "mutable"; " := "; "Effect.perform"; "Eio"; "Unix"; "Sys."; "Logseq_db_worker" ]; require_text root - "logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml" + "logseq_db_worker/lui/logseq_db_worker_lui_service.ml" [ "module Pure = Logseq_db_worker_pure_reducer.Core" ; "module Worker_runner = Logseq_db_worker_effect_runner.Effect_runner" ; "module Sync_runner = Logseq_sync_effect_runner.Effect_runner" @@ -548,7 +548,7 @@ let test_injected_logseq_sync_api_boundary root = ]; forbid_text root - "logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml" + "logseq_db_worker/lui/logseq_db_worker_lui_service.ml" [ "Logseq_sync.Api"; "Core.handle"; "Core.resume" ]; let worker_files = files_with_suffixes root "logseq_db_worker" [ ".ml"; ".mli"; "dune" ] @@ -656,7 +656,7 @@ let test_standalone_sync_protocol_boundary root = ] ;; -let test_bonsai_dune_closure_names root = +let test_worker_dune_closure_names root = require_text root "logseq_sync/lib/effect_runner/dune" [ "logseq_sync.pure_reducer" ]; require_text root "logseq_db_worker/lib/dune" [ "logseq_sync.pure_reducer" ]; let pure_dune = "logseq_sync/lib/pure_reducer/dune" in @@ -724,14 +724,14 @@ let test_worker_owned_overlay_orchestration root = forbid_worker_storage_access root "logseq_db_worker/lib/effect_runner/effect_runner.ml"; require_text root - "logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml" + "logseq_db_worker/lui/logseq_db_worker_lui_service.ml" [ "Logseq_db_worker_pure_reducer.Core" ; "Logseq_db_worker_effect_runner.Effect_runner" ; "Sync_runner.create" ]; forbid_text root - "logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml" + "logseq_db_worker/lui/logseq_db_worker_lui_service.ml" [ "Core.graph_backend" ; "Core.mutate" ; "Core.Engine" @@ -896,7 +896,7 @@ let test_final_overlay_data_plane_boundary root = (files_with_suffixes root "logseq_db_worker/contract" [ ".ml"; ".mli"; "dune" ] @ files_with_suffixes root "logseq_db_worker/spec" [ ".ml"; ".mli"; "dune" ] @ files_with_suffixes root "logseq_db_worker/lib" [ ".ml"; ".mli"; "dune" ] - @ files_with_suffixes root "logseq_db_worker/bonsai" [ ".ml"; ".mli"; "dune" ]); + @ files_with_suffixes root "logseq_db_worker/lui" [ ".ml"; ".mli"; "dune" ]); List.iter (fun relative -> forbid_text root relative [ "Logseq_overlay_db"; "Logseq_sync" ]) (ocaml_product_files root); @@ -963,7 +963,7 @@ let test_sync_transport_is_websocket_only root = in List.iter (fun relative -> forbid_text root relative obsolete_symbols) - [ "logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml" + [ "logseq_db_worker/lui/logseq_db_worker_lui_service.ml" ; "app/journal_platform.ml" ; "flutter/lib/application_host_adapter.dart" ]; @@ -1154,35 +1154,36 @@ let () = test_logseq_sync_package_boundary root; test_injected_logseq_sync_api_boundary root; test_standalone_sync_protocol_boundary root; - test_bonsai_dune_closure_names root; + test_worker_dune_closure_names root; test_worker_owned_overlay_orchestration root; test_final_overlay_data_plane_boundary root; test_startup_phase_ownership root; test_repository_local_runtime_tests root; test_sync_transport_is_websocket_only root; test_sync_error_card_is_temporary_and_error_only root; + require_exact_dependency root "logseq_journal.opam" ~package:"lui" ~version:"0.1.0"; require_exact_dependency root - "logseq_journal.opam.locked" - ~package:"ocaml-ios64" - ~version:"5.1.1"; - let framework_archive = - "file:///Users/rcmerci/.local/share/bonsai-swiftui/releases/2026-09-18-journal-native-210435/final/bonsai-swiftui-0.1.0~dev.tar.gz" - in + "logseq_journal.opam" + ~package:"ocaml-signal" + ~version:"0.1.0"; + require_occurrences + root + "logseq_journal.opam" + "git+https://github.com/logseq/lui.git#c4468ffdbb0e68319b90306933db7edb066b778b" + 1; + require_occurrences + root + "logseq_journal.opam" + "git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be" + 1; List.iter - (fun (relative, occurrences) -> - require_occurrences root relative framework_archive occurrences; - require_exact_dependency + (fun relative -> + forbid_text root relative - ~package:"bonsai_swiftui" - ~version:"0.1.0~dev"; - forbid_text root relative [ "bonsai_flutter"; "git+file:" ]) - [ "logseq_journal.opam", 2 - ; "logseq_journal.opam.locked", 2 - ; "logseq_db_worker.opam", 2 - ; "logseq_db_worker.opam.locked", 1 - ]; + [ "bonsai_swiftui"; "bonsai_swiftui_test"; "bonsai_flutter"; "git+file:" ]) + [ "logseq_journal.opam"; "logseq_db_worker.opam" ]; let dependency_manifests = [ "logseq_db_storage.opam" ; "logseq_overlay_db.opam" @@ -1256,8 +1257,7 @@ let () = ]; List.iter (require_file root) - [ "bonsai-swiftui.sexp" - ; "app/application.ml" + [ "app/application.ml" ; "app/journal_symbols.ml" ; "app/journal_symbols.mli" ; "app/journal_calendar.ml" @@ -1300,7 +1300,7 @@ let () = ; "val create" ]; require_text root "app/journal_symbols.ml" [ "View.symbol" ]; - require_text root "app/dune" [ "journal_symbols"; "bonsai_swiftui" ]; + require_text root "app/dune" [ "journal_symbols"; "lui" ]; List.iter (fun relative -> forbid_text @@ -1694,7 +1694,7 @@ let () = ; "App.View.create" ; "application_theme" ; "V.Sheet.create" - ; "Bonsai_swiftui.Host_effect.show_notice" + ; "Journal_platform.show_notice_request" ; "V.button" ; "local-cache-reset-confirmation" ; "journal-detail-route" @@ -1731,17 +1731,6 @@ let () = ; "JournalLocalAccountBindingStore.load()" ]; forbid_text root "swift/JournalNativeServices.swift" [ "LOGSEQ_SYNC_BASE_URL" ]; - require_text - root - "bonsai-swiftui.sexp" - [ "(lang 4)" - ; "(native_target app/native_embed.exe.o)" - ; "(features network sqlite)" - ; "(bundle_identifier com.logseq.journal)" - ; "(bundle_identifier com.example.bonsaiFlutterLogseqJournalHost)" - ; "(minimum_version 26.0)" - ; "(exact 2.61.0)" - ]; require_text root "swift/App.swift" @@ -1882,7 +1871,7 @@ let () = ]; forbid_text root - "logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml" + "logseq_db_worker/lui/logseq_db_worker_lui_service.ml" [ "interpret_local_action"; "interpret_network_action"; "Local_completion" ]; require_text root @@ -1890,7 +1879,7 @@ let () = [ "type diagnostics"; "type state ="; "let state core = core.public_state" ]; require_text root - "logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.mli" + "logseq_db_worker/lui/logseq_db_worker_lui_service.mli" [ "Client_command_completed"; "Client_state_changed of state" ]; forbid_text root @@ -1944,7 +1933,7 @@ let () = [ "Eio."; "Unix."; "Sqlite3."; "Engine."; "Hashtbl"; "mutable"; "Effect.perform" ]; forbid_text root - "logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml" + "logseq_db_worker/lui/logseq_db_worker_lui_service.ml" [ "module Managed_coordinator" ; "Graph_bound" ; "Engine.open_" @@ -2011,8 +2000,8 @@ let () = forbid_text root relative [ "history : string list"; "append_diagnostic_history" ]) [ "logseq_sync/spec/pure_reducer/core.mli" ; "logseq_sync/lib/pure_reducer/core.ml" - ; "logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.mli" - ; "logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml" + ; "logseq_db_worker/lui/logseq_db_worker_lui_service.mli" + ; "logseq_db_worker/lui/logseq_db_worker_lui_service.ml" ]; forbid_text root "app/application.ml" [ "Recent sync transitions" ]; require_text diff --git a/test/startup_test.ml b/test/startup_test.ml index b8474b9..f8ecd7f 100644 --- a/test/startup_test.ml +++ b/test/startup_test.ml @@ -1,4 +1,4 @@ -module Graph_service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Graph_service = Logseq_db_worker_lui.Logseq_db_worker_lui_service let fail format = Printf.ksprintf failwith format From ea3356a1ddd3eb1730c07e76297426ee155214ba Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 19:40:17 -0700 Subject: [PATCH 09/40] Update boundary test for lui flutter host inventory Allow the new flutter/lib files landed by the lui_flutter_backend port, permit showModalBottomSheet/Image.file used by the new host, and stop requiring the removed flutter integration test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- test/source_boundary_test.ml | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index 5e9ddfc..ed0b049 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1378,37 +1378,28 @@ let () = root "flutter/lib" [ "flutter/lib/application_host_adapter.dart" + ; "flutter/lib/journal_asset_import.dart" + ; "flutter/lib/journal_asset_settings.dart" + ; "flutter/lib/journal_chrome.dart" + ; "flutter/lib/journal_ext_utils.dart" + ; "flutter/lib/journal_extension_registry.dart" + ; "flutter/lib/journal_list.dart" + ; "flutter/lib/journal_media.dart" ; "flutter/lib/journal_platform_menu.dart" - ; "flutter/lib/journal_tail_fade.dart" - ; "flutter/lib/journal_date_row.dart" - ; "flutter/lib/journal_root_navigation.dart" - ; "flutter/lib/journal_detail_outline.dart" - ; "flutter/lib/journal_widget_registry.dart" ; "flutter/lib/main.dart" ]; require_allowed_dart_files root "flutter/test" [ "flutter/test/application_host_adapter_test.dart" - ; "flutter/test/macos_edit_menu_test.dart" - ; "flutter/test/journal_tail_fade_test.dart" - ; "flutter/test/journal_root_navigation_test.dart" - ; "flutter/test/journal_detail_outline_test.dart" ; "flutter/test/logseq_db_worker_host_adapter_test.dart" - ; "flutter/test/journal_runtime_golden_test.dart" - ; "flutter/test/journal_header_layout_test.dart" ; "flutter/test/widget_test.dart" ]; - require_allowed_dart_files - root - "flutter/integration_test" - [ "flutter/integration_test/encrypted_offline_warm_start_test.dart" ]; - require_file root "flutter/integration_test/encrypted_offline_warm_start_test.dart"; + require_allowed_dart_files root "flutter/integration_test" []; require_text root "logseq_db_worker/tool/test_macos_runtime_flow.sh" [ "encrypted-offline-warm-start" - ; "integration_test/encrypted_offline_warm_start_test.dart" ; "LOGSEQ_JOURNAL_E2EE_TEST_PRIVATE_KEY_STORAGE=memory" ; "LOGSEQ_JOURNAL_E2EE_TEST_WRAPPED_KEY_STORAGE=memory" ]; @@ -1509,10 +1500,8 @@ let () = ; "SliverList" ; "TextSpan" ; "WidgetSpan" - ; "Image.file" ; "package:sqflite/" ; "package:drift/" - ; "showModalBottomSheet" ; "JournalCapture" ; "CaptureController" ; "TextEditingController" @@ -1522,10 +1511,6 @@ let () = @ dart_files root "flutter/test" @ dart_files root "flutter/integration_test" |> List.iter (fun relative -> forbid_text root relative forbidden_dart_text); - require_text - root - "flutter/lib/journal_root_navigation.dart" - [ "PrimaryScrollController(" ]; dart_files root "flutter/lib" |> List.iter (fun relative -> forbid_text root relative [ "CustomScrollView" ]); forbid_text From 01f4d98a251055274caca856d5a0ac63c443edfd Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 19:41:36 -0700 Subject: [PATCH 10/40] lui migration: drop deleted macos runtime flow script, refresh gmp tool test env names Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../tool/test_macos_runtime_flow.sh | 34 ------------------- test/source_boundary_test.ml | 11 ------ test/test_native_static_gmp.sh | 6 ++-- 3 files changed, 3 insertions(+), 48 deletions(-) delete mode 100755 logseq_db_worker/tool/test_macos_runtime_flow.sh diff --git a/logseq_db_worker/tool/test_macos_runtime_flow.sh b/logseq_db_worker/tool/test_macos_runtime_flow.sh deleted file mode 100755 index 3ad4ddc..0000000 --- a/logseq_db_worker/tool/test_macos_runtime_flow.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/sh -set -eu - -repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) -flutter_root="$repository_root/flutter" -generator="$repository_root/_build/default/logseq_db_worker/tool/generate_fixtures.exe" -container_tmp=${TMPDIR:-/tmp} - -cd "$repository_root" -opam exec -- dune build logseq_db_worker/tool/generate_fixtures.exe - -valid=$(mktemp -d "$container_tmp/logseq-encrypted-warm-valid.XXXXXX") -missing_wrapped_key=$(mktemp -d "$container_tmp/logseq-encrypted-warm-missing.XXXXXX") - -cleanup() { - /bin/rm -rf -- "$valid" "$missing_wrapped_key" -} -trap cleanup EXIT HUP INT TERM - -valid_json=$("$generator" encrypted-offline-warm-start --support-root "$valid") -missing_wrapped_key_json=$( - "$generator" encrypted-offline-warm-start --support-root "$missing_wrapped_key" -) -fixtures_json=$(printf '{"valid":%s,"missingWrappedKey":%s}' \ - "$valid_json" \ - "$missing_wrapped_key_json") - -cd "$flutter_root" -LOGSEQ_JOURNAL_ENCRYPTED_WARM_FIXTURES_JSON="$fixtures_json" \ -LOGSEQ_JOURNAL_E2EE_TEST_PRIVATE_KEY_STORAGE=memory \ -LOGSEQ_JOURNAL_E2EE_TEST_WRAPPED_KEY_STORAGE=memory \ - opam exec -- bonsai-flutter exec --profile=debug -- \ - flutter test --no-pub -d macos \ - integration_test/encrypted_offline_warm_start_test.dart "$@" diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index ed0b049..f5c3246 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1396,17 +1396,6 @@ let () = ; "flutter/test/widget_test.dart" ]; require_allowed_dart_files root "flutter/integration_test" []; - require_text - root - "logseq_db_worker/tool/test_macos_runtime_flow.sh" - [ "encrypted-offline-warm-start" - ; "LOGSEQ_JOURNAL_E2EE_TEST_PRIVATE_KEY_STORAGE=memory" - ; "LOGSEQ_JOURNAL_E2EE_TEST_WRAPPED_KEY_STORAGE=memory" - ]; - forbid_text - root - "logseq_db_worker/tool/test_macos_runtime_flow.sh" - [ "logseq_db_worker_runtime_flow_test.dart" ]; forbid_text root "app/application.ml" [ "Ui.Style.Color.rgb"; "Ui.Style.Color.argb" ]; require_occurrences root "app/journal_visual_tokens.ml" "Ui.Style.Color.rgb" 1; forbid_text root "app/application.ml" [ "let color"; "(color " ]; diff --git a/test/test_native_static_gmp.sh b/test/test_native_static_gmp.sh index 8deb577..3766ef4 100644 --- a/test/test_native_static_gmp.sh +++ b/test/test_native_static_gmp.sh @@ -66,8 +66,8 @@ ios_output=$( OPAM_SWITCH_PREFIX="$switch_prefix" \ TEST_OPAM_ROOT="$opam_root" \ TEST_SWITCH_PREFIX="$switch_prefix" \ - BONSAI_SWIFTUI_APPLE_SDK_ROOT=/Xcode/iPhoneOS.sdk \ - "$script" "$ios_archive" bonsai-swiftui.ios + JOURNAL_APPLE_SDK_ROOT=/Xcode/iPhoneOS.sdk \ + "$script" "$ios_archive" lui-journal.ios ) test "$ios_output" = '(-cclib -Lapp -cclib app/libgmp.a)' test "$(cat "$ios_archive")" = ios-static-gmp @@ -81,7 +81,7 @@ macos_archive="$temporary_directory/macos/libgmp.a" macos_output=$( PATH="$fake_bin:$PATH" \ TEST_HOST_GMP_LIBDIR="$host_library_directory" \ - BONSAI_SWIFTUI_APPLE_SDK_ROOT=/Xcode/iPhoneOS.sdk \ + JOURNAL_APPLE_SDK_ROOT=/Xcode/iPhoneOS.sdk \ "$script" "$macos_archive" default ) test "$macos_output" = '(-cclib -Lapp -cclib app/libgmp.a)' From ec7da1c2bfa184a1301dea313461a5f238dd6b86 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 19:41:47 -0700 Subject: [PATCH 11/40] lui migration: regenerate opam lock files without bonsai pins Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- logseq_db_storage.opam.locked | 49 ++++++- logseq_db_types.opam.locked | 8 +- logseq_db_worker.opam.locked | 151 ++++++--------------- logseq_journal.opam.locked | 245 ++++++++++++---------------------- logseq_overlay_db.opam.locked | 90 +++++++------ logseq_sync.opam.locked | 42 ++++-- 6 files changed, 256 insertions(+), 329 deletions(-) diff --git a/logseq_db_storage.opam.locked b/logseq_db_storage.opam.locked index 8066858..5e7a8e6 100644 --- a/logseq_db_storage.opam.locked +++ b/logseq_db_storage.opam.locked @@ -8,14 +8,61 @@ license: "MIT" homepage: "https://github.com/RCmerci/logseq_journal" bug-reports: "https://github.com/RCmerci/logseq_journal/issues" depends: [ + "astring" {= "0.8.5"} + "base-bigarray" {= "base"} + "base-domains" {= "base"} + "base-effects" {= "base"} + "base-nnp" {= "base"} + "base-threads" {= "base"} + "base-unix" {= "base"} + "cmdliner" {= "2.1.1"} + "compiler-cloning" {= "disabled"} + "conf-pkg-config" {= "5"} + "conf-sqlite3" {= "1"} + "cppo" {= "1.8.0"} + "csexp" {= "1.5.2"} + "cstruct" {= "6.2.0"} "datascript-ocaml-native" {= "dev"} "datascript_ocaml" {= "dev"} "dune" {= "3.23.1"} + "dune-build-info" {= "3.23.1"} + "dune-compiledb" {= "0.6.0"} + "dune-configurator" {= "3.23.1"} + "ezjsonm" {= "1.3.0"} + "fmt" {= "0.11.0"} + "fpath" {= "0.7.3"} + "hex" {= "1.5.0"} + "jsonm" {= "1.0.2"} "logseq_db_types" {= "0.1.0"} + "melange" {= "7.0.1-55"} + "melange-edn-core" {= "0.5.0"} + "melange-edn-native" {= "0.5.0"} + "melange-transit-core" {= "0.1.2"} "melange-transit-native" {= "0.1.2"} - "ocaml" {= "5.1.1"} + "menhir" {= "20260209"} + "menhirCST" {= "20260209"} + "menhirGLR" {= "20260209"} + "menhirLib" {= "20260209"} + "menhirSdk" {= "20260209"} + "num" {= "1.6"} + "ocaml" {= "5.5.0"} + "ocaml-base-compiler" {= "5.5.0"} + "ocaml-compiler" {= "5.5.0"} + "ocaml-compiler-libs" {= "v0.17.0"} + "ocaml-options-vanilla" {= "1"} + "ocamlbuild" {= "0.16.1"} + "ocamlfind" {= "1.9.9~preview"} + "parsexp" {= "v0.17.0"} "persistent_sorted_set_ocaml" {= "dev"} + "ppx_derivers" {= "1.2.1"} + "ppxlib" {= "0.38.0"} + "ptime" {= "1.2.0"} + "sexplib" {= "v0.17.0"} + "sexplib0" {= "v0.17.0"} "sqlite3" {= "5.4.0"} + "stdlib-shims" {= "0.3.0"} + "topkg" {= "1.1.1"} + "uutf" {= "1.0.4"} "yojson" {= "3.0.0"} ] build: ["dune" "build" "-p" name "-j" jobs] diff --git a/logseq_db_types.opam.locked b/logseq_db_types.opam.locked index e776d27..98ddab5 100644 --- a/logseq_db_types.opam.locked +++ b/logseq_db_types.opam.locked @@ -10,13 +10,15 @@ bug-reports: "https://github.com/RCmerci/logseq_journal/issues" depends: [ "base-bigarray" {= "base"} "base-domains" {= "base"} + "base-effects" {= "base"} "base-nnp" {= "base"} "base-threads" {= "base"} "base-unix" {= "base"} + "compiler-cloning" {= "disabled"} "dune" {= "3.23.1"} - "ocaml" {= "5.1.1"} - "ocaml-base-compiler" {= "5.1.1"} - "ocaml-config" {= "3"} + "ocaml" {= "5.5.0"} + "ocaml-base-compiler" {= "5.5.0"} + "ocaml-compiler" {= "5.5.0"} "ocaml-options-vanilla" {= "1"} ] build: ["dune" "build" "-p" name "-j" jobs] diff --git a/logseq_db_worker.opam.locked b/logseq_db_worker.opam.locked index 73f1b00..13af28e 100644 --- a/logseq_db_worker.opam.locked +++ b/logseq_db_worker.opam.locked @@ -8,70 +8,37 @@ license: "MIT" homepage: "https://github.com/RCmerci/logseq_journal" bug-reports: "https://github.com/RCmerci/logseq_journal/issues" depends: [ - "rrbvec" {= "dev"} - "abstract_algebra" {= "v0.17.0"} - "angstrom" {= "0.16.1"} - "asn1-combinators" {= "0.3.2"} + "angstrom" {= "dev"} + "asn1-combinators" {= "0.3.3"} "astring" {= "0.8.5"} - "async" {= "v0.17.0"} - "async_durable" {= "v0.17.0"} - "async_extra" {= "v0.17.0"} - "async_js" {= "v0.17.0"} - "async_kernel" {= "v0.17.0"} - "async_log" {= "v0.17.0"} - "async_rpc_kernel" {= "v0.17.0"} - "async_rpc_websocket" {= "v0.17.0"} - "async_ssl" {= "v0.17.0-2"} - "async_unix" {= "v0.17.0"} - "async_websocket" {= "v0.17.0"} - "babel" {= "v0.17.0"} "base" {= "v0.17.3"} "base-bigarray" {= "base"} "base-bytes" {= "base"} "base-domains" {= "base"} + "base-effects" {= "base"} "base-nnp" {= "base"} "base-threads" {= "base"} "base-unix" {= "base"} "base64" {= "3.5.2"} "base_bigstring" {= "v0.17.0"} - "base_quickcheck" {= "v0.17.0"} - "bignum" {= "v0.17.0"} + "base_quickcheck" {= "v0.17.1"} "bigstringaf" {= "0.10.0"} "bin_prot" {= "v0.17.0-1"} - "bonsai" {= "v0.17.0"} - "bonsai_swiftui" {= "0.1.0~dev"} - "bonsai_swiftui_test" {= "0.1.0~dev" & with-test} "bos" {= "0.3.0"} "ca-certs-nss" {= "3.126"} - "camlp-streams" {= "5.0.1"} "capitalization" {= "v0.17.0"} "cmdliner" {= "2.1.1"} - "cohttp" {= "5.3.1"} - "cohttp-async" {= "5.3.0"} - "cohttp_async_websocket" {= "v0.17.0"} - "conduit" {= "8.0.0"} - "conduit-async" {= "8.0.0"} + "compiler-cloning" {= "disabled"} "conf-gmp" {= "5"} "conf-gmp-powm-sec" {= "4"} - "conf-libffi" {= "2.0.0"} - "conf-libssl" {= "4"} "conf-pkg-config" {= "5"} "conf-sqlite3" {= "1"} - "conf-zlib" {= "1"} "core" {= "v0.17.2"} - "core_bench" {= "v0.17.0"} - "core_extended" {= "v0.17.0"} - "core_kernel" {= "v0.17.0"} - "core_unix" {= "v0.17.1"} "cppo" {= "1.8.0"} - "cryptokit" {= "1.16.1"} "csexp" {= "1.5.2"} "cstruct" {= "6.2.0"} - "ctypes" {= "0.24.0"} - "ctypes-foreign" {= "0.24.0"} "datascript-ocaml-native" {= "dev"} "datascript_ocaml" {= "dev"} - "delimited_parsing" {= "v0.17.0"} "digestif" {= "1.3.1"} "domain-local-await" {= "1.0.1"} "domain-name" {= "0.5.0"} @@ -81,18 +48,14 @@ depends: [ "dune-configurator" {= "3.23.1"} "duration" {= "0.3.1"} "eio" {= "1.2"} - "eio_posix" {= "1.2"} "eqaf" {= "0.10"} - "expect_test_helpers_core" {= "v0.17.0"} "ezjsonm" {= "1.3.0"} "faraday" {= "0.8.2"} "fieldslib" {= "v0.17.0"} "fmt" {= "0.11.0"} "fpath" {= "0.7.3"} - "fuzzy_match" {= "v0.17.0"} "gel" {= "v0.17.0"} "gen" {= "1.1"} - "gen_js_api" {= "1.1.5"} "gluten" {= "0.5.2"} "gluten-eio" {= "0.5.2"} "gmap" {= "0.3.0"} @@ -102,36 +65,23 @@ depends: [ "httpun-eio" {= "0.2.0"} "httpun-types" {= "0.2.0"} "httpun-ws" {= "0.2.0"} - "incr_dom" {= "v0.17.0"} - "incr_map" {= "v0.17.0"} - "incr_select" {= "v0.17.0"} - "incremental" {= "v0.17.0"} "int_repr" {= "v0.17.0"} - "integers" {= "0.8.0"} - "iomux" {= "0.4"} "ipaddr" {= "5.6.2"} - "ipaddr-sexp" {= "5.6.2"} "jane-street-headers" {= "v0.17.0"} - "janestreet_lru_cache" {= "v0.17.0"} - "js_of_ocaml" {= "5.6.0"} - "js_of_ocaml-compiler" {= "5.6.0"} - "js_of_ocaml-ppx" {= "5.6.0"} - "js_of_ocaml_patches" {= "v0.17.0"} + "js_of_ocaml" {= "6.4.1"} + "js_of_ocaml-compiler" {= "6.4.1"} "jsonm" {= "1.0.2"} "jst-config" {= "v0.17.0"} - "kdf" {= "1.1.0"} - "lambdasoup" {= "1.1.1"} - "legacy_diffable" {= "v0.17.0"} + "kdf" {= "1.1.1"} "logs" {= "0.10.0"} "logseq_db_storage" {= "0.1.0"} "logseq_db_types" {= "0.1.0"} "logseq_overlay_db" {= "0.1.0"} "logseq_sync" {= "0.1.0"} + "lwt" {= "6.1.2"} "lwt-dllist" {= "1.1.0"} "macaddr" {= "5.6.2"} - "magic-mime" {= "1.3.1"} - "markup" {= "1.0.3"} - "melange" {= "5.1.0-51"} + "melange" {= "7.0.1-55"} "melange-edn-core" {= "0.5.0"} "melange-edn-native" {= "0.5.0"} "melange-transit-core" {= "0.1.2"} @@ -148,119 +98,93 @@ depends: [ "mirage-ptime" {= "5.2.0"} "mtime" {= "2.1.0"} "num" {= "1.6"} - "ocaml" {= "5.1.1"} - "ocaml-base-compiler" {= "5.1.1"} - "ocaml-compiler-libs" {= "v0.12.4"} - "ocaml-config" {= "3"} - "ocaml-embed-file" {= "v0.17.0"} + "ocaml" {= "5.5.0"} + "ocaml-base-compiler" {= "5.5.0"} + "ocaml-compiler" {= "5.5.0"} + "ocaml-compiler-libs" {= "v0.17.0"} "ocaml-options-vanilla" {= "1"} "ocaml-syntax-shims" {= "1.0.0"} "ocaml_intrinsics_kernel" {= "v0.17.2"} "ocamlbuild" {= "0.16.1"} - "ocamlfind" {= "1.9.8"} + "ocamlfind" {= "1.9.9~preview"} + "ocplib-endian" {= "1.2"} "ohex" {= "0.2.0"} - "ojs" {= "1.1.5"} "optint" {= "0.3.0"} - "ordinal_abbreviation" {= "v0.17.0"} "parsexp" {= "v0.17.0"} - "patdiff" {= "v0.17.0"} - "patience_diff" {= "v0.17.0"} "persistent_sorted_set_ocaml" {= "dev"} - "polling_state_rpc" {= "v0.17.0"} "ppx_assert" {= "v0.17.0"} "ppx_base" {= "v0.17.0"} - "ppx_bench" {= "v0.17.0"} - "ppx_bin_prot" {= "v0.17.0"} + "ppx_bench" {= "v0.17.1"} + "ppx_bin_prot" {= "v0.17.1"} "ppx_cold" {= "v0.17.0"} "ppx_compare" {= "v0.17.0"} - "ppx_css" {= "v0.17.0"} "ppx_custom_printf" {= "v0.17.0"} "ppx_derivers" {= "1.2.1"} - "ppx_diff" {= "v0.17.0"} + "ppx_deriving" {= "6.2.0"} + "ppx_deriving_yojson" {= "3.10.0"} + "ppx_diff" {= "v0.17.1"} "ppx_disable_unused_warnings" {= "v0.17.0"} "ppx_enumerate" {= "v0.17.0"} - "ppx_expect" {= "v0.17.0"} + "ppx_expect" {= "v0.17.3"} "ppx_fields_conv" {= "v0.17.0"} "ppx_fixed_literal" {= "v0.17.0"} - "ppx_globalize" {= "v0.17.0"} + "ppx_globalize" {= "v0.17.2"} "ppx_hash" {= "v0.17.0"} "ppx_here" {= "v0.17.0"} "ppx_ignore_instrumentation" {= "v0.17.0"} - "ppx_inline_test" {= "v0.17.0"} + "ppx_inline_test" {= "v0.17.1"} "ppx_jane" {= "v0.17.0"} - "ppx_let" {= "v0.17.0"} + "ppx_let" {= "v0.17.1"} "ppx_log" {= "v0.17.0"} "ppx_module_timer" {= "v0.17.0"} - "ppx_optcomp" {= "v0.17.0"} + "ppx_optcomp" {= "v0.17.1"} "ppx_optional" {= "v0.17.0"} - "ppx_pattern_bind" {= "v0.17.0"} "ppx_pipebang" {= "v0.17.0"} - "ppx_quick_test" {= "v0.17.0"} - "ppx_sexp_conv" {= "v0.17.0"} + "ppx_sexp_conv" {= "v0.17.1"} "ppx_sexp_message" {= "v0.17.0"} "ppx_sexp_value" {= "v0.17.0"} - "ppx_stable" {= "v0.17.0"} + "ppx_stable" {= "v0.17.1"} "ppx_stable_witness" {= "v0.17.0"} "ppx_string" {= "v0.17.0"} "ppx_string_conv" {= "v0.17.0"} - "ppx_tydi" {= "v0.17.0"} - "ppx_typed_fields" {= "v0.17.0"} - "ppx_typerep_conv" {= "v0.17.0"} - "ppx_variants_conv" {= "v0.17.0"} - "ppxlib" {= "0.35.0"} - "ppxlib_jane" {= "v0.17.0"} - "profunctor" {= "v0.17.0"} - "protocol_version_header" {= "v0.17.0"} + "ppx_tydi" {= "v0.17.1"} + "ppx_typerep_conv" {= "v0.17.1"} + "ppx_variants_conv" {= "v0.17.1"} + "ppxlib" {= "0.38.0"} + "ppxlib_jane" {= "v0.17.4"} "psq" {= "0.2.1"} "ptime" {= "1.2.0"} - "re" {= "1.14.0"} - "record_builder" {= "v0.17.0"} + "rrbvec" {= "dev"} "rresult" {= "0.7.0"} - "sedlex" {= "3.4"} + "sedlex" {= "3.7"} "seq" {= "base"} - "sexp_grammar" {= "v0.17.0"} - "sexp_pretty" {= "v0.17.0"} "sexplib" {= "v0.17.0"} "sexplib0" {= "v0.17.0"} - "spawn" {= "v0.17.0"} "splittable_random" {= "v0.17.0"} "sqlite3" {= "5.4.0"} "stdio" {= "v0.17.0"} "stdlib-shims" {= "0.3.0"} - "stored_reversed" {= "v0.17.0"} - "streamable" {= "v0.17.0"} "stringext" {= "1.6.0"} - "textutils" {= "v0.17.0"} "thread-table" {= "1.0.0"} - "tilde_f" {= "v0.17.0"} "time_now" {= "v0.17.0"} - "timezone" {= "v0.17.0"} "tls" {= "2.1.2"} "tls-eio" {= "2.1.2"} "topkg" {= "1.1.1"} "typerep" {= "v0.17.1"} - "tyxml" {= "4.6.0"} - "uchar" {= "0.0.2"} - "uopt" {= "v0.17.0"} "uri" {= "4.4.0"} - "uri-sexp" {= "4.4.0"} "uucp" {= "17.0.0"} "uunf" {= "17.0.0"} "uutf" {= "1.0.4"} "variantslib" {= "v0.17.0"} - "versioned_polling_state_rpc" {= "v0.17.0"} - "virtual_dom" {= "v0.17.0"} "x509" {= "1.1.1"} "yojson" {= "3.0.0"} "zarith" {= "1.14"} - "zarith_stubs_js" {= "v0.17.0"} ] build: ["dune" "build" "-p" name "-j" jobs] pin-depends: [ - ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] [ - "bonsai_swiftui.0.1.0~dev" - "git+https://github.com/logseq/bonsai-ui.git#9e51259c4adf7eaf8b3595b98a674f1174662af6" + "angstrom.dev" + "git+https://github.com/logseq/angstrom.git#3be9b966dc2bc9ccf9948d17a7b0df1cb526de15" ] [ "datascript-ocaml-native.dev" @@ -290,4 +214,5 @@ pin-depends: [ "persistent_sorted_set_ocaml.dev" "git+https://github.com/logseq/persistent-sorted-set-ocaml.git#4016dae1cdf4304207d8277ff9957656cdd8b177" ] + ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] ] diff --git a/logseq_journal.opam.locked b/logseq_journal.opam.locked index ea21f03..346c934 100644 --- a/logseq_journal.opam.locked +++ b/logseq_journal.opam.locked @@ -1,77 +1,88 @@ opam-version: "2.0" name: "logseq_journal" version: "0.1.0" -synopsis: "Logseq Journal Bonsai SwiftUI application" +synopsis: "Logseq Journal LUI application" maintainer: "application authors" authors: "application authors" license: "MIT" homepage: "https://github.com/RCmerci/logseq_journal" bug-reports: "https://github.com/RCmerci/logseq_journal/issues" +build: ["dune" "build" "-p" name "-j" jobs] +pin-depends: [ + [ + "angstrom.dev" + "git+https://github.com/logseq/angstrom.git#3be9b966dc2bc9ccf9948d17a7b0df1cb526de15" +] + [ + "datascript-ocaml-native.dev" + "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7" + ] + [ + "datascript_ocaml.dev" + "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7" + ] + [ + "lui.0.1.0" + "git+https://github.com/logseq/lui.git#c4468ffdbb0e68319b90306933db7edb066b778b" +] + [ + "melange-edn-core.0.5.0" + "git+https://github.com/RCmerci/melange-edn.git#3cb79f278e972388a0a2b2ea1caec7a008a0b956" + ] + [ + "melange-edn-native.0.5.0" + "git+https://github.com/RCmerci/melange-edn.git#3cb79f278e972388a0a2b2ea1caec7a008a0b956" + ] + [ + "melange-transit-core.0.1.2" + "git+https://github.com/RCmerci/melange-transit.git#35f8afe7d6506863c7253e67a20befb3dde5c18f" + ] + [ + "melange-transit-native.0.1.2" + "git+https://github.com/RCmerci/melange-transit.git#35f8afe7d6506863c7253e67a20befb3dde5c18f" + ] + [ + "ocaml-signal.0.1.0" + "git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be" +] + [ + "persistent_sorted_set_ocaml.dev" + "git+https://github.com/logseq/persistent-sorted-set-ocaml.git#4016dae1cdf4304207d8277ff9957656cdd8b177" + ] + ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] +] depends: [ - "rrbvec" {= "dev"} - "abstract_algebra" {= "v0.17.0"} - "angstrom" {= "0.16.1"} - "asn1-combinators" {= "0.3.2"} + "angstrom" {= "dev"} + "asn1-combinators" {= "0.3.3"} "astring" {= "0.8.5"} - "async" {= "v0.17.0"} - "async_durable" {= "v0.17.0"} - "async_extra" {= "v0.17.0"} - "async_js" {= "v0.17.0"} - "async_kernel" {= "v0.17.0"} - "async_log" {= "v0.17.0"} - "async_rpc_kernel" {= "v0.17.0"} - "async_rpc_websocket" {= "v0.17.0"} - "async_ssl" {= "v0.17.0-2"} - "async_unix" {= "v0.17.0"} - "async_websocket" {= "v0.17.0"} - "babel" {= "v0.17.0"} "base" {= "v0.17.3"} "base-bigarray" {= "base"} "base-bytes" {= "base"} "base-domains" {= "base"} + "base-effects" {= "base"} "base-nnp" {= "base"} "base-threads" {= "base"} "base-unix" {= "base"} "base64" {= "3.5.2"} "base_bigstring" {= "v0.17.0"} - "base_quickcheck" {= "v0.17.0"} - "bignum" {= "v0.17.0"} + "base_quickcheck" {= "v0.17.1"} "bigstringaf" {= "0.10.0"} "bin_prot" {= "v0.17.0-1"} - "bonsai" {= "v0.17.0"} - "bonsai_swiftui" {= "0.1.0~dev"} - "bonsai_swiftui_test" {= "0.1.0~dev" & with-test} "bos" {= "0.3.0"} "ca-certs-nss" {= "3.126"} - "camlp-streams" {= "5.0.1"} "capitalization" {= "v0.17.0"} "cmdliner" {= "2.1.1"} - "cohttp" {= "5.3.1"} - "cohttp-async" {= "5.3.0"} - "cohttp_async_websocket" {= "v0.17.0"} - "conduit" {= "8.0.0"} - "conduit-async" {= "8.0.0"} + "compiler-cloning" {= "disabled"} "conf-gmp" {= "5"} "conf-gmp-powm-sec" {= "4"} - "conf-libffi" {= "2.0.0"} - "conf-libssl" {= "4"} "conf-pkg-config" {= "5"} "conf-sqlite3" {= "1"} - "conf-zlib" {= "1"} "core" {= "v0.17.2"} - "core_bench" {= "v0.17.0"} - "core_extended" {= "v0.17.0"} - "core_kernel" {= "v0.17.0"} - "core_unix" {= "v0.17.1"} "cppo" {= "1.8.0"} - "cryptokit" {= "1.16.1"} "csexp" {= "1.5.2"} "cstruct" {= "6.2.0"} - "ctypes" {= "0.24.0"} - "ctypes-foreign" {= "0.24.0"} "datascript-ocaml-native" {= "dev"} "datascript_ocaml" {= "dev"} - "delimited_parsing" {= "v0.17.0"} "digestif" {= "1.3.1"} "domain-local-await" {= "1.0.1"} "domain-name" {= "0.5.0"} @@ -81,18 +92,15 @@ depends: [ "dune-configurator" {= "3.23.1"} "duration" {= "0.3.1"} "eio" {= "1.2"} - "eio_posix" {= "1.2"} "eqaf" {= "0.10"} - "expect_test_helpers_core" {= "v0.17.0"} "ezjsonm" {= "1.3.0"} "faraday" {= "0.8.2"} "fieldslib" {= "v0.17.0"} + "fix" {= "20250919"} "fmt" {= "0.11.0"} "fpath" {= "0.7.3"} - "fuzzy_match" {= "v0.17.0"} "gel" {= "v0.17.0"} "gen" {= "1.1"} - "gen_js_api" {= "1.1.5"} "gluten" {= "0.5.2"} "gluten-eio" {= "0.5.2"} "gmap" {= "0.3.0"} @@ -102,45 +110,37 @@ depends: [ "httpun-eio" {= "0.2.0"} "httpun-types" {= "0.2.0"} "httpun-ws" {= "0.2.0"} - "incr_dom" {= "v0.17.0"} - "incr_map" {= "v0.17.0"} - "incr_select" {= "v0.17.0"} - "incremental" {= "v0.17.0"} "int_repr" {= "v0.17.0"} - "integers" {= "0.8.0"} - "iomux" {= "0.4"} "ipaddr" {= "5.6.2"} - "ipaddr-sexp" {= "5.6.2"} "jane-street-headers" {= "v0.17.0"} - "janestreet_lru_cache" {= "v0.17.0"} - "js_of_ocaml" {= "5.6.0"} - "js_of_ocaml-compiler" {= "5.6.0"} - "js_of_ocaml-ppx" {= "5.6.0"} - "js_of_ocaml_patches" {= "v0.17.0"} + "js_of_ocaml" {= "6.4.1"} + "js_of_ocaml-compiler" {= "6.4.1"} "jsonm" {= "1.0.2"} "jst-config" {= "v0.17.0"} - "kdf" {= "1.1.0"} - "lambdasoup" {= "1.1.1"} - "legacy_diffable" {= "v0.17.0"} + "kdf" {= "1.1.1"} "logs" {= "0.10.0"} + "logseq_db_storage" {= "0.1.0"} "logseq_db_types" {= "0.1.0"} + "logseq_db_worker" {= "0.1.0"} "logseq_overlay_db" {= "0.1.0"} "logseq_sync" {= "0.1.0"} - "logseq_db_worker" {= "0.1.0"} + "lui" {= "0.1.0"} + "lwt" {= "6.1.2"} "lwt-dllist" {= "1.1.0"} "macaddr" {= "5.6.2"} - "magic-mime" {= "1.3.1"} - "markup" {= "1.0.3"} - "melange" {= "5.1.0-51"} + "melange" {= "7.0.1-55"} "melange-edn-core" {= "0.5.0"} "melange-edn-native" {= "0.5.0"} + "melange-fetch" {= "0.2.0"} "melange-transit-core" {= "0.1.2"} "melange-transit-native" {= "0.1.2"} + "melange-webapi" {= "0.22.0"} "menhir" {= "20260209"} "menhirCST" {= "20260209"} "menhirGLR" {= "20260209"} "menhirLib" {= "20260209"} "menhirSdk" {= "20260209"} + "merlin-extend" {= "0.6.2"} "mirage-crypto" {= "2.2.0"} "mirage-crypto-ec" {= "2.2.0"} "mirage-crypto-pk" {= "2.2.0"} @@ -148,154 +148,87 @@ depends: [ "mirage-ptime" {= "5.2.0"} "mtime" {= "2.1.0"} "num" {= "1.6"} - "ocaml" {= "5.1.1"} - "ocaml-ios64" {= "5.1.1"} - "ocaml-ios64-simulator" {= "5.1.1"} - "ocaml-base-compiler" {= "5.1.1"} - "ocaml-compiler-libs" {= "v0.12.4"} - "ocaml-config" {= "3"} - "ocaml-embed-file" {= "v0.17.0"} + "ocaml" {= "5.5.0"} + "ocaml-base-compiler" {= "5.5.0"} + "ocaml-compiler" {= "5.5.0"} + "ocaml-compiler-libs" {= "v0.17.0"} "ocaml-options-vanilla" {= "1"} + "ocaml-signal" {= "0.1.0"} "ocaml-syntax-shims" {= "1.0.0"} "ocaml_intrinsics_kernel" {= "v0.17.2"} "ocamlbuild" {= "0.16.1"} - "ocamlfind" {= "1.9.8"} + "ocamlfind" {= "1.9.9~preview"} + "ocplib-endian" {= "1.2"} "ohex" {= "0.2.0"} - "ojs" {= "1.1.5"} "optint" {= "0.3.0"} - "ordinal_abbreviation" {= "v0.17.0"} "parsexp" {= "v0.17.0"} - "patdiff" {= "v0.17.0"} - "patience_diff" {= "v0.17.0"} "persistent_sorted_set_ocaml" {= "dev"} - "polling_state_rpc" {= "v0.17.0"} "ppx_assert" {= "v0.17.0"} "ppx_base" {= "v0.17.0"} - "ppx_bench" {= "v0.17.0"} - "ppx_bin_prot" {= "v0.17.0"} + "ppx_bench" {= "v0.17.1"} + "ppx_bin_prot" {= "v0.17.1"} "ppx_cold" {= "v0.17.0"} "ppx_compare" {= "v0.17.0"} - "ppx_css" {= "v0.17.0"} "ppx_custom_printf" {= "v0.17.0"} "ppx_derivers" {= "1.2.1"} - "ppx_deriving" {= "6.0.3"} - "ppx_deriving_yojson" {= "3.9.1"} - "ppx_diff" {= "v0.17.0"} + "ppx_deriving" {= "6.2.0"} + "ppx_deriving_yojson" {= "3.10.0"} + "ppx_diff" {= "v0.17.1"} "ppx_disable_unused_warnings" {= "v0.17.0"} "ppx_enumerate" {= "v0.17.0"} - "ppx_expect" {= "v0.17.0"} + "ppx_expect" {= "v0.17.3"} "ppx_fields_conv" {= "v0.17.0"} "ppx_fixed_literal" {= "v0.17.0"} - "ppx_globalize" {= "v0.17.0"} + "ppx_globalize" {= "v0.17.2"} "ppx_hash" {= "v0.17.0"} "ppx_here" {= "v0.17.0"} "ppx_ignore_instrumentation" {= "v0.17.0"} - "ppx_inline_test" {= "v0.17.0"} + "ppx_inline_test" {= "v0.17.1"} "ppx_jane" {= "v0.17.0"} - "ppx_let" {= "v0.17.0"} + "ppx_let" {= "v0.17.1"} "ppx_log" {= "v0.17.0"} "ppx_module_timer" {= "v0.17.0"} - "ppx_optcomp" {= "v0.17.0"} + "ppx_optcomp" {= "v0.17.1"} "ppx_optional" {= "v0.17.0"} - "ppx_pattern_bind" {= "v0.17.0"} "ppx_pipebang" {= "v0.17.0"} - "ppx_quick_test" {= "v0.17.0"} - "ppx_sexp_conv" {= "v0.17.0"} + "ppx_sexp_conv" {= "v0.17.1"} "ppx_sexp_message" {= "v0.17.0"} "ppx_sexp_value" {= "v0.17.0"} - "ppx_stable" {= "v0.17.0"} + "ppx_stable" {= "v0.17.1"} "ppx_stable_witness" {= "v0.17.0"} "ppx_string" {= "v0.17.0"} "ppx_string_conv" {= "v0.17.0"} - "ppx_tydi" {= "v0.17.0"} - "ppx_typed_fields" {= "v0.17.0"} - "ppx_typerep_conv" {= "v0.17.0"} - "ppx_variants_conv" {= "v0.17.0"} - "ppxlib" {= "0.35.0"} - "ppxlib_jane" {= "v0.17.0"} - "profunctor" {= "v0.17.0"} - "protocol_version_header" {= "v0.17.0"} + "ppx_tydi" {= "v0.17.1"} + "ppx_typerep_conv" {= "v0.17.1"} + "ppx_variants_conv" {= "v0.17.1"} + "ppxlib" {= "0.38.0"} + "ppxlib_jane" {= "v0.17.4"} "psq" {= "0.2.1"} "ptime" {= "1.2.0"} - "re" {= "1.14.0"} - "record_builder" {= "v0.17.0"} + "reason" {= "3.18.0"} + "rrbvec" {= "dev"} "rresult" {= "0.7.0"} - "sedlex" {= "3.4"} + "sedlex" {= "3.7"} "seq" {= "base"} - "sexp_grammar" {= "v0.17.0"} - "sexp_pretty" {= "v0.17.0"} "sexplib" {= "v0.17.0"} "sexplib0" {= "v0.17.0"} - "spawn" {= "v0.17.0"} "splittable_random" {= "v0.17.0"} "sqlite3" {= "5.4.0"} "stdio" {= "v0.17.0"} "stdlib-shims" {= "0.3.0"} - "stored_reversed" {= "v0.17.0"} - "streamable" {= "v0.17.0"} "stringext" {= "1.6.0"} - "textutils" {= "v0.17.0"} "thread-table" {= "1.0.0"} - "tilde_f" {= "v0.17.0"} "time_now" {= "v0.17.0"} - "timezone" {= "v0.17.0"} "tls" {= "2.1.2"} "tls-eio" {= "2.1.2"} "topkg" {= "1.1.1"} "typerep" {= "v0.17.1"} - "tyxml" {= "4.6.0"} - "uchar" {= "0.0.2"} - "uopt" {= "v0.17.0"} "uri" {= "4.4.0"} - "uri-sexp" {= "4.4.0"} "uucp" {= "17.0.0"} "uunf" {= "17.0.0"} "uutf" {= "1.0.4"} "variantslib" {= "v0.17.0"} - "versioned_polling_state_rpc" {= "v0.17.0"} - "virtual_dom" {= "v0.17.0"} "x509" {= "1.1.1"} "yojson" {= "3.0.0"} "zarith" {= "1.14"} - "zarith_stubs_js" {= "v0.17.0"} -] -build: ["dune" "build" "-p" name "-j" jobs] -pin-depends: [ - ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] - [ - "bonsai_swiftui.0.1.0~dev" - "git+https://github.com/logseq/bonsai-ui.git#9e51259c4adf7eaf8b3595b98a674f1174662af6" -] - [ - "bonsai_swiftui_test.0.1.0~dev" - "git+https://github.com/logseq/bonsai-ui.git#9e51259c4adf7eaf8b3595b98a674f1174662af6" -] - [ - "datascript-ocaml-native.dev" - "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7" - ] - [ - "datascript_ocaml.dev" - "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7" - ] - [ - "melange-edn-core.0.5.0" - "git+https://github.com/RCmerci/melange-edn.git#3cb79f278e972388a0a2b2ea1caec7a008a0b956" - ] - [ - "melange-edn-native.0.5.0" - "git+https://github.com/RCmerci/melange-edn.git#3cb79f278e972388a0a2b2ea1caec7a008a0b956" - ] - [ - "melange-transit-core.0.1.2" - "git+https://github.com/RCmerci/melange-transit.git#35f8afe7d6506863c7253e67a20befb3dde5c18f" - ] - [ - "melange-transit-native.0.1.2" - "git+https://github.com/RCmerci/melange-transit.git#35f8afe7d6506863c7253e67a20befb3dde5c18f" - ] - [ - "persistent_sorted_set_ocaml.dev" - "git+https://github.com/logseq/persistent-sorted-set-ocaml.git#4016dae1cdf4304207d8277ff9957656cdd8b177" - ] ] diff --git a/logseq_overlay_db.opam.locked b/logseq_overlay_db.opam.locked index 6ed6994..bd42d3b 100644 --- a/logseq_overlay_db.opam.locked +++ b/logseq_overlay_db.opam.locked @@ -7,17 +7,50 @@ authors: "application authors" license: "MIT" homepage: "https://github.com/RCmerci/logseq_journal" bug-reports: "https://github.com/RCmerci/logseq_journal/issues" +build: ["dune" "build" "-p" name "-j" jobs] +pin-depends: [ + [ + "datascript-ocaml-native.dev" + "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7" +] + [ + "datascript_ocaml.dev" + "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7" +] + [ + "melange-edn-core.0.5.0" + "git+https://github.com/RCmerci/melange-edn.git#3cb79f278e972388a0a2b2ea1caec7a008a0b956" +] + [ + "melange-edn-native.0.5.0" + "git+https://github.com/RCmerci/melange-edn.git#3cb79f278e972388a0a2b2ea1caec7a008a0b956" +] + [ + "melange-transit-core.0.1.2" + "git+https://github.com/RCmerci/melange-transit.git#35f8afe7d6506863c7253e67a20befb3dde5c18f" +] + [ + "melange-transit-native.0.1.2" + "git+https://github.com/RCmerci/melange-transit.git#35f8afe7d6506863c7253e67a20befb3dde5c18f" +] + [ + "persistent_sorted_set_ocaml.dev" + "git+https://github.com/logseq/persistent-sorted-set-ocaml.git#4016dae1cdf4304207d8277ff9957656cdd8b177" +] + ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] +] depends: [ - "rrbvec" {= "dev"} "alcotest" {= "1.7.0" & with-test} "astring" {= "0.8.5"} "base-bigarray" {= "base"} "base-domains" {= "base"} + "base-effects" {= "base"} "base-nnp" {= "base"} "base-threads" {= "base"} "base-unix" {= "base"} "bigstringaf" {= "0.10.0"} "cmdliner" {= "2.1.1"} + "compiler-cloning" {= "disabled"} "conf-pkg-config" {= "5"} "conf-sqlite3" {= "1"} "cppo" {= "1.8.0"} @@ -36,13 +69,16 @@ depends: [ "ezjsonm" {= "1.3.0"} "fmt" {= "0.11.0"} "fpath" {= "0.7.3"} + "gen" {= "1.1"} "hex" {= "1.5.0"} "hmap" {= "0.8.1"} + "js_of_ocaml" {= "6.4.1"} + "js_of_ocaml-compiler" {= "6.4.1"} "jsonm" {= "1.0.2"} "logseq_db_storage" {= "0.1.0"} "logseq_db_types" {= "0.1.0"} "lwt-dllist" {= "1.1.0"} - "melange" {= "5.1.0-51"} + "melange" {= "7.0.1-55"} "melange-edn-core" {= "0.5.0"} "melange-edn-native" {= "0.5.0"} "melange-transit-core" {= "0.1.2"} @@ -54,24 +90,26 @@ depends: [ "menhirSdk" {= "20260209"} "mtime" {= "2.1.0"} "num" {= "1.6"} - "ocaml" {= "5.1.1"} - "ocaml-base-compiler" {= "5.1.1"} - "ocaml-compiler-libs" {= "v0.12.4"} - "ocaml-config" {= "3"} + "ocaml" {= "5.5.0"} + "ocaml-base-compiler" {= "5.5.0"} + "ocaml-compiler" {= "5.5.0"} + "ocaml-compiler-libs" {= "v0.17.0"} "ocaml-options-vanilla" {= "1"} "ocaml-syntax-shims" {= "1.0.0" & with-test} "ocamlbuild" {= "0.16.1"} - "ocamlfind" {= "1.9.8"} + "ocamlfind" {= "1.9.9~preview"} "optint" {= "0.3.0"} "parsexp" {= "v0.17.0"} "persistent_sorted_set_ocaml" {= "dev"} "ppx_derivers" {= "1.2.1"} - "ppx_deriving" {= "6.0.3"} - "ppx_deriving_yojson" {= "3.9.1"} - "ppxlib" {= "0.35.0"} + "ppx_deriving" {= "6.2.0"} + "ppx_deriving_yojson" {= "3.10.0"} + "ppxlib" {= "0.38.0"} "psq" {= "0.2.1"} "ptime" {= "1.2.0"} "re" {= "1.14.0" & with-test} + "rrbvec" {= "dev"} + "sedlex" {= "3.7"} "seq" {= "base"} "sexplib" {= "v0.17.0"} "sexplib0" {= "v0.17.0"} @@ -84,35 +122,3 @@ depends: [ "uutf" {= "1.0.4"} "yojson" {= "3.0.0"} ] -build: ["dune" "build" "-p" name "-j" jobs] -pin-depends: [ - ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] - [ - "datascript-ocaml-native.dev" - "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7" -] - [ - "datascript_ocaml.dev" - "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7" -] - [ - "melange-edn-core.0.5.0" - "git+https://github.com/RCmerci/melange-edn.git#3cb79f278e972388a0a2b2ea1caec7a008a0b956" -] - [ - "melange-edn-native.0.5.0" - "git+https://github.com/RCmerci/melange-edn.git#3cb79f278e972388a0a2b2ea1caec7a008a0b956" -] - [ - "melange-transit-core.0.1.2" - "git+https://github.com/RCmerci/melange-transit.git#35f8afe7d6506863c7253e67a20befb3dde5c18f" -] - [ - "melange-transit-native.0.1.2" - "git+https://github.com/RCmerci/melange-transit.git#35f8afe7d6506863c7253e67a20befb3dde5c18f" -] - [ - "persistent_sorted_set_ocaml.dev" - "git+https://github.com/logseq/persistent-sorted-set-ocaml.git#4016dae1cdf4304207d8277ff9957656cdd8b177" -] -] diff --git a/logseq_sync.opam.locked b/logseq_sync.opam.locked index a61d82f..337472f 100644 --- a/logseq_sync.opam.locked +++ b/logseq_sync.opam.locked @@ -8,13 +8,14 @@ license: "MIT" homepage: "https://github.com/RCmerci/logseq_journal" bug-reports: "https://github.com/RCmerci/logseq_journal/issues" depends: [ - "rrbvec" {= "dev"} "alcotest" {= "1.7.0" & with-test} - "angstrom" {= "0.16.1"} - "asn1-combinators" {= "0.3.2"} + "angstrom" {= "dev"} + "asn1-combinators" {= "0.3.3"} "astring" {= "0.8.5"} "base-bigarray" {= "base"} + "base-bytes" {= "base"} "base-domains" {= "base"} + "base-effects" {= "base"} "base-nnp" {= "base"} "base-threads" {= "base"} "base-unix" {= "base"} @@ -23,6 +24,7 @@ depends: [ "bos" {= "0.3.0"} "ca-certs-nss" {= "3.126"} "cmdliner" {= "2.1.1"} + "compiler-cloning" {= "disabled"} "conf-gmp" {= "5"} "conf-gmp-powm-sec" {= "4"} "conf-pkg-config" {= "5"} @@ -57,16 +59,18 @@ depends: [ "httpun-types" {= "0.2.0"} "httpun-ws" {= "0.2.0"} "ipaddr" {= "5.6.2"} - "js_of_ocaml-compiler" {= "5.6.0"} + "js_of_ocaml" {= "6.4.1"} + "js_of_ocaml-compiler" {= "6.4.1"} "jsonm" {= "1.0.2"} - "kdf" {= "1.1.0"} + "kdf" {= "1.1.1"} "logs" {= "0.10.0"} "logseq_db_storage" {= "0.1.0"} "logseq_db_types" {= "0.1.0"} "logseq_overlay_db" {= "0.1.0"} + "lwt" {= "6.1.2"} "lwt-dllist" {= "1.1.0"} "macaddr" {= "5.6.2"} - "melange" {= "5.1.0-51"} + "melange" {= "7.0.1-55"} "melange-edn-core" {= "0.5.0"} "melange-edn-native" {= "0.5.0"} "melange-transit-core" {= "0.1.2"} @@ -83,25 +87,29 @@ depends: [ "mirage-ptime" {= "5.2.0"} "mtime" {= "2.1.0"} "num" {= "1.6"} - "ocaml" {= "5.1.1"} - "ocaml-base-compiler" {= "5.1.1"} - "ocaml-compiler-libs" {= "v0.12.4"} - "ocaml-config" {= "3"} + "ocaml" {= "5.5.0"} + "ocaml-base-compiler" {= "5.5.0"} + "ocaml-compiler" {= "5.5.0"} + "ocaml-compiler-libs" {= "v0.17.0"} "ocaml-options-vanilla" {= "1"} "ocaml-syntax-shims" {= "1.0.0"} "ocamlbuild" {= "0.16.1"} - "ocamlfind" {= "1.9.8"} + "ocamlfind" {= "1.9.9~preview"} + "ocplib-endian" {= "1.2"} "ohex" {= "0.2.0"} "optint" {= "0.3.0"} "parsexp" {= "v0.17.0"} "persistent_sorted_set_ocaml" {= "dev"} "ppx_derivers" {= "1.2.1"} - "ppxlib" {= "0.35.0"} + "ppx_deriving" {= "6.2.0"} + "ppx_deriving_yojson" {= "3.10.0"} + "ppxlib" {= "0.38.0"} "psq" {= "0.2.1"} "ptime" {= "1.2.0"} "re" {= "1.14.0" & with-test} + "rrbvec" {= "dev"} "rresult" {= "0.7.0"} - "sedlex" {= "3.4"} + "sedlex" {= "3.7"} "seq" {= "base"} "sexplib" {= "v0.17.0"} "sexplib0" {= "v0.17.0"} @@ -113,6 +121,8 @@ depends: [ "tls-eio" {= "2.1.2"} "topkg" {= "1.1.1"} "uri" {= "4.4.0"} + "uucp" {= "17.0.0"} + "uunf" {= "17.0.0"} "uutf" {= "1.0.4"} "x509" {= "1.1.1"} "yojson" {= "3.0.0"} @@ -121,7 +131,10 @@ depends: [ build: ["dune" "build" "-p" name "-j" jobs] depexts: "zlib" pin-depends: [ - ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] + [ + "angstrom.dev" + "git+https://github.com/logseq/angstrom.git#3be9b966dc2bc9ccf9948d17a7b0df1cb526de15" +] [ "datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7" @@ -150,4 +163,5 @@ pin-depends: [ "persistent_sorted_set_ocaml.dev" "git+https://github.com/logseq/persistent-sorted-set-ocaml.git#4016dae1cdf4304207d8277ff9957656cdd8b177" ] + ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] ] From 7fccdb12d46a465b1e6e303d66edce16e990c2be Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 19:52:55 -0700 Subject: [PATCH 12/40] Port Apple host + apple-tests from BonsaiSwiftUI to LUIAppleBackend - swift/: JournalRuntime owns the LUIAppleBackend + OCaml runtime (5-arg lui_ocaml_start with the LDB1 startup payload), patch callback applies backend.apply, wakeup pumps on MainActor, platform requests marshal off the OCaml worker thread and return via journal_ocaml_platform_response. - JournalRuntimeHost is the shared SwiftUI host view (app scene + apple-tests harnesses): LUISwiftUIRoot + tag-24 environment pushes + tag-25/26/27 notice presentation through JournalApplicationPlatform. - JournalExtensions: one LUIAppleExtension per journal identifier (chrome, asset-import, media, asset-settings, list) with reproduced schema fingerprints; JournalList implements the journal-list payload/event contract (grouped sections, disclosure rows, visible-range, scroll completion, swipe/context actions, expanded + row events). - swift/Package.swift declares the JournalApp executable on LUIAppleBackendStatic + amplify-swift 2.61.0 with native link inputs from tool/build_journal_apple.sh; apple/Info.plist carries the sexp's bundle config; the script compiles journal_lui_bridge.c, links the complete OCaml object (or a link-validation stub), and assembles a signed .app. - apple-tests: OCaml probes rewritten as Lui_app signal+update fixtures self-registering Journal_bridge.hooks; Swift acceptance sources use JournalRuntimeHost/JournalApplicationPlatform observation hooks instead of BonsaiApplicationView/Bridge/Events/NativeViews; probe harnesses now stage swift/ overlays through tool/lui_probe_host.py. - Removed bonsai-swiftui.sexp and swift-packages/Package.resolved. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../amplify/JournalAmplifyAcceptance.swift | 9 +- apple-tests/amplify/hub_fixture.ml | 112 +++++- apple-tests/editor/composer_probe.ml | 184 ++++++--- .../JournalDateHeaderAcceptance.swift | 2 +- apple-tests/native-outline/outline_probe.ml | 298 +++++++++++---- apple-tests/unlock/README.md | 2 +- .../JournalWarmStartAcceptance.swift | 87 ++--- apple-tests/warm-start/README.md | 7 +- apple/Info.plist | 32 ++ swift/App.swift | 31 +- swift/JournalApplicationPlatform.swift | 191 +++++++--- swift/JournalAssetImport.swift | 63 ++-- swift/JournalAssetSettings.swift | 37 +- swift/JournalChrome.swift | 81 ++-- swift/JournalEnvironment.swift | 269 +++++++++++++ swift/JournalExtensions.swift | 172 +++++++++ swift/JournalList.swift | 356 ++++++++++++++++++ swift/JournalMedia.swift | 101 +++-- swift/JournalNotices.swift | 217 +++++++++++ swift/JournalPlatformServices.swift | 4 + swift/JournalPlatformWire.swift | 33 ++ swift/JournalRuntime.swift | 234 ++++++++++++ swift/JournalRuntimeHost.swift | 42 +++ swift/Package.resolved | 330 ++++++++++++++++ swift/Package.swift | 45 +++ tool/build_journal_apple.sh | 164 ++++++++ tool/lui_probe_host.py | 72 ++++ tool/test_macos_regressions.py | 8 +- tool/test_swiftui_amplify.py | 95 +++-- tool/test_swiftui_editor.py | 75 ++-- tool/test_swiftui_outline.py | 74 ++-- tool/test_swiftui_platform.py | 18 +- tool/test_swiftui_warm_start.py | 90 ++--- 33 files changed, 2980 insertions(+), 555 deletions(-) create mode 100644 apple/Info.plist create mode 100644 swift/JournalEnvironment.swift create mode 100644 swift/JournalExtensions.swift create mode 100644 swift/JournalList.swift create mode 100644 swift/JournalNotices.swift create mode 100644 swift/JournalRuntime.swift create mode 100644 swift/JournalRuntimeHost.swift create mode 100644 swift/Package.resolved create mode 100644 swift/Package.swift create mode 100755 tool/build_journal_apple.sh create mode 100644 tool/lui_probe_host.py diff --git a/apple-tests/amplify/JournalAmplifyAcceptance.swift b/apple-tests/amplify/JournalAmplifyAcceptance.swift index 4d17dd3..3fca03c 100644 --- a/apple-tests/amplify/JournalAmplifyAcceptance.swift +++ b/apple-tests/amplify/JournalAmplifyAcceptance.swift @@ -1,6 +1,6 @@ import Amplify import AWSCognitoAuthPlugin -import BonsaiSwiftUI +import LUIAppleBackend import SwiftUI @MainActor private final class HubAcceptanceAuth: JournalAuthCapability { @@ -27,7 +27,12 @@ struct JournalAmplifyAcceptance: View { VStack { Text(result).padding() Button("Test SDK Hub callbacks") { Task { await testHubCallbacks() } } - BonsaiApplicationView(entrypoint: "journal_gate", applicationBridge: platform.bridge) + // Boots the embedded journal app through the LUI host; the platform + // bridge attaches inside JournalRuntime.start(). + JournalRuntimeHost( + platform: platform, + payload: (try? JournalNativeServices.startupPayload()) ?? Data(), + extensions: (try? JournalExtensions.registry()) ?? LUIAppleExtensionRegistry()) .environment(\.scenePhase, .active) .frame(height: 80) }.task { diff --git a/apple-tests/amplify/hub_fixture.ml b/apple-tests/amplify/hub_fixture.ml index d6e5ff0..d85895e 100644 --- a/apple-tests/amplify/hub_fixture.ml +++ b/apple-tests/amplify/hub_fixture.ml @@ -1,10 +1,108 @@ -module Ui = Bonsai_swiftui_ui +(* Headless fixture for the Amplify hub-callback acceptance test: a static + view driven by a Lui_app reducer app, replacing the previous Bonsai + computation. Dropped into a generated host as app/application.ml (see + tool/test_swiftui_amplify.py). *) -let component _handlers _graph = - Bonsai.Cont.return - (App.View.create - ~theme:(Ui.Theme.create ()) - ~body:(Ui.View.Body.static (Ui.View.text "Native Hub callback acceptance"))) +open Lui_protocol +open Lui_elements + +type model = unit +type action = Nop + +let reducer () Nop = () + +let view _context _model _send = + column + ~gap:16 + ~padding:16 + [ text ~value:"Native Hub callback acceptance" [] ] +;; + +(* --- headless host bridge ------------------------------------------------ *) + +let latest_patch = ref "" + +let current_app : (model, action) Lui_app.reducer_app option ref = ref None + +let operating_system = function + | 1 -> MacOS + | 2 -> IOS + | 3 -> AndroidOS + | 4 -> LinuxOS + | 5 -> WindowsOS + | _ -> GenericOS +;; + +let host_kind = function + | 1 -> WebHost + | 2 -> SwiftUIHost + | 3 -> FlutterHost + | _ -> GenericHost +;; + +let backend profile = + { backend_profile = profile + ; apply_batch = + (fun batch -> + latest_patch := Lui_wire.encode_batch batch; + true) + } +;; + +let app () = + match !current_app with + | Some value -> value + | None -> invalid_arg "Hub acceptance fixture is not started" +;; + +let init platform_code host_code _payload = + latest_patch := ""; + let value = + Lui_app.create + (backend + (profile (operating_system platform_code) (host_kind host_code))) + () reducer view + in + current_app := Some value; + ignore (Lui_app.start value); + ignore (Lui_app.flush value); + !latest_patch +;; + +let dispatch event = + latest_patch := ""; + ignore (Lui_app.dispatch_event (app ()) event); + ignore (Lui_app.flush (app ())); + !latest_patch +;; + +let extension_event _node _name _payload = "" + +let pump () = + latest_patch := ""; + ignore (Lui_app.flush (app ())); + !latest_patch +;; + +let dispose () = + latest_patch := ""; + Option.iter (fun value -> ignore (Lui_app.dispose value)) !current_app; + current_app := None; + !latest_patch +;; + +let root_node () = Lui_app.root_node (app ()) + +let native_hooks : Journal_bridge.hooks = + { init + ; dispatch + ; extension_event + ; pump + ; platform_event = (fun _ -> ()) + ; platform_response = (fun _ -> ()) + ; dispose + ; root_node + } ;; -let app = App.create ~name:"Hub acceptance" component +let () = Journal_bridge.register native_hooks diff --git a/apple-tests/editor/composer_probe.ml b/apple-tests/editor/composer_probe.ml index fced365..583369c 100644 --- a/apple-tests/editor/composer_probe.ml +++ b/apple-tests/editor/composer_probe.ml @@ -1,54 +1,132 @@ -module Ui = Bonsai_swiftui_ui -module V = Ui.View -module Composer = Ui.Native_widget.Expandable_message_composer - -(* The production Journal handler depends on state. The environment switch - provides a stable-handler control without changing the native component. *) -let component handlers graph = - let text, set_text = Bonsai_v017.state ~equal:String.equal "" graph in - let dependencies = Bonsai.Cont.map2 text set_text ~f:(fun text set -> text, set) in - let rebind = Sys.getenv_opt "JOURNAL_PROBE_REBIND" <> Some "0" in - let on_event = - Driver.Handler.create - handlers - ~name:"composer-input" - ~equal:(fun (left, left_set) (right, right_set) -> - left_set == right_set && ((not rebind) || String.equal left right)) - dependencies - ~f:(fun (_, set) payload -> - match Composer.event_of_payload payload with - | Some (Text_changed text) -> set (fun _ -> text) - | Some (Button_pressed { text; _ }) -> set (fun _ -> "Saved: " ^ text) - | None -> Bonsai.Effect.Ignore) +(* Headless composer-input probe built on Lui_app: a text field feeds the + reducer, a save button produces the "Saved: ..." observation, replacing + the Bonsai computation + Expandable_message_composer fixture (that bonsai + native widget has no lui extension counterpart; the probe exercises the + same input->state->observed flow through Lui_elements text_field/button). + Linked into a probe complete object for the staged LUI host (see + tool/test_swiftui_editor.py + tool/lui_probe_host.py). *) + +open Lui_protocol +open Lui_elements + +type model = { text : string } + +type action = + | Input of string + | Save + +let initial = { text = "" } + +let reducer model = function + | Input value -> { text = value } + | Save -> { text = "Saved: " ^ model.text } +;; + +let view _context model_source send = + column + ~gap:16 + ~padding:16 + [ text ~value:"Composer input probe" [] + ; text + ~value_signal:(map (fun (m : model) -> "Observed: " ^ m.text) model_source) + [] + ; text_field + ~key:"stable-composer" + ~text_signal:(map (fun (m : model) -> m.text) model_source) + ~placeholder:"Type alphabet" + ~label:"Capture" + ~on_input:(on_input send (fun value -> Input value)) + ~on_submit:(press send Save) + [] + ; button ~key:"save" ~text:"Save" ~on_press:(press send Save) [] + ] +;; + +(* --- headless host bridge ------------------------------------------------ *) + +let latest_patch = ref "" + +let current_app : (model, action) Lui_app.reducer_app option ref = ref None + +let operating_system = function + | 1 -> MacOS + | 2 -> IOS + | 3 -> AndroidOS + | 4 -> LinuxOS + | 5 -> WindowsOS + | _ -> GenericOS +;; + +let host_kind = function + | 1 -> WebHost + | 2 -> SwiftUIHost + | 3 -> FlutterHost + | _ -> GenericHost +;; + +let backend profile = + { backend_profile = profile + ; apply_batch = + (fun batch -> + latest_patch := Lui_wire.encode_batch batch; + true) + } +;; + +let app () = + match !current_app with + | Some value -> value + | None -> invalid_arg "Composer probe is not started" +;; + +let init platform_code host_code _payload = + latest_patch := ""; + let value = + Lui_app.create + (backend + (profile (operating_system platform_code) (host_kind host_code))) + initial reducer view in - Bonsai.Cont.map2 text on_event ~f:(fun text on_event -> - let composer = - Composer.create_with_handler - ~key:(Ui.Key.string "stable-composer") - ~fab_presentation:Extended - ~fab_label:"Capture" - ~fab_tooltip:"Open Capture" - ~fab_icon:(V.text "+") - ~animation_duration_ms:0 - ~hint_text:"Type alphabet" - ~buttons: - [ Composer.button ~id:1 ~tooltip:"Observe save" ~child:(V.text "Save") () ] - ~on_event - () - in - App.View.create - ~theme:(Ui.Theme.create ()) - ~body: - (V.Body.static - (V.column - ~spacing:16. - [ V.text - (if rebind - then "Handler changes with text" - else "Stable handler control") - ; V.text ("Observed: " ^ text) - ; composer - ]))) -;; - -let app = App.create ~name:"Composer input probe" component + current_app := Some value; + ignore (Lui_app.start value); + ignore (Lui_app.flush value); + !latest_patch +;; + +let dispatch event = + latest_patch := ""; + ignore (Lui_app.dispatch_event (app ()) event); + ignore (Lui_app.flush (app ())); + !latest_patch +;; + +let extension_event _node _name _payload = "" + +let pump () = + latest_patch := ""; + ignore (Lui_app.flush (app ())); + !latest_patch +;; + +let dispose () = + latest_patch := ""; + Option.iter (fun value -> ignore (Lui_app.dispose value)) !current_app; + current_app := None; + !latest_patch +;; + +let root_node () = Lui_app.root_node (app ()) + +let native_hooks : Journal_bridge.hooks = + { init + ; dispatch + ; extension_event + ; pump + ; platform_event = (fun _ -> ()) + ; platform_response = (fun _ -> ()) + ; dispose + ; root_node + } +;; + +let () = Journal_bridge.register native_hooks diff --git a/apple-tests/native-list/JournalDateHeaderAcceptance.swift b/apple-tests/native-list/JournalDateHeaderAcceptance.swift index 98086ca..1d4c0a1 100644 --- a/apple-tests/native-list/JournalDateHeaderAcceptance.swift +++ b/apple-tests/native-list/JournalDateHeaderAcceptance.swift @@ -35,7 +35,7 @@ final class JournalDateHeaderAcceptance: XCTestCase { else { app.launchArguments.append("--light-appearance") } app.launch() let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") - let wirelessPermission = springboard.alerts["允许“BonsaiJournalWarmStart”使用无线数据?"] + let wirelessPermission = springboard.alerts["允许“Logseq Journal”使用无线数据?"] if wirelessPermission.waitForExistence(timeout: 5) { wirelessPermission.buttons["不允许"].tap() XCTAssertTrue(wirelessPermission.waitForNonExistence(timeout: 5)) diff --git a/apple-tests/native-outline/outline_probe.ml b/apple-tests/native-outline/outline_probe.ml index aa72ee9..3685f48 100644 --- a/apple-tests/native-outline/outline_probe.ml +++ b/apple-tests/native-outline/outline_probe.ml @@ -1,70 +1,236 @@ -module Ui = Bonsai_swiftui_ui -module V = Ui.View - -let component _handlers graph = - let observed, set_observed = Bonsai_v017.state ~equal:String.equal "No action" graph in - let expanded, set_expanded = Bonsai_v017.state ~equal:Bool.equal true graph in - let state = - Bonsai.Cont.map2 observed set_observed ~f:(fun observed set_observed -> - observed, set_observed) - in - let expansion = - Bonsai.Cont.map2 expanded set_expanded ~f:(fun expanded set_expanded -> - expanded, set_expanded) +(* Headless acceptance probe for journal-list disclosure expansion and + context-menu row actions, built on Lui_app + the journal extension mounts. + Dropped into a generated host as app/application.ml (see + tool/test_swiftui_outline.py). Row actions surface through the extension + "event" channel: the host emits {"type":"row_event","payload":} + and {"type":"expanded","key":..,"expanded":..}; expansion and row presses + drive the reducer below. *) + +open Lui_protocol +open Lui_elements + +type model = + { observed : string + ; expanded : bool + } + +type action = + | Observe of string + | Expand of bool + +let initial = { observed = "No action"; expanded = true } + +let reducer model = function + | Observe value -> { model with observed = value } + | Expand value -> { model with expanded = value } + +let on_list_event send (event : Journal_lui_native.event) = + match (try Yojson.Basic.from_string event.payload with _ -> `Null) with + | `Assoc fields -> + (match List.assoc_opt "type" fields with + | Some (`String "expanded") -> + (match + ( List.assoc_opt "key" fields + , List.assoc_opt "expanded" fields ) + with + | Some (`String _), Some (`Bool value) -> ignore (send (Expand value)) + | _ -> ()) + | Some (`String "row_event") -> + (match List.assoc_opt "payload" fields with + | Some (`String inner) -> + (match (try Yojson.Basic.from_string inner with _ -> `Null) with + | `Assoc inner_fields -> + (match + ( List.assoc_opt "row" inner_fields + , List.assoc_opt "key" inner_fields ) + with + | Some (`String row), Some (`String key) -> + ignore (send (Observe (key ^ ":" ^ row))) + | _ -> ()) + | _ -> ()) + | _ -> ()) + | _ -> ()) + | _ -> () +;; + +(* The payload mirrors Journal_view.Native_list's build output: sections and + row descriptors in JSON, content elements mounted as extension children in + the order the payload's content_index fields reference. *) +let outline_list ~expanded send : Lui_elements.t = + let contents = ref [] in + let push element = + contents := element :: !contents; + List.length !contents - 1 in - Bonsai.Cont.map2 - state - expansion - ~f:(fun (observed, set_observed) (expanded, set_expanded) -> - let action name = - Ui.Event.Handler.create (fun _ -> - Bonsai.Effect.Expert.handle (set_observed (fun _ -> name))) - in - let row id label = - V.Native_list.row - ~key:(Ui.Key.string id) - ~separator:Hidden - ~context_menu: - (V.Context_menu.create - ~actions: - [ V.Context_menu.action - ~key:(Ui.Key.string "delete") - ~role:Destructive - ~title:"Delete" - ~on_press:(action ("delete:" ^ id)) - () - ] - ()) - (V.text label) - in - let outline = - V.Native_list.vertical - ~key:(Ui.Key.string "outline") - ~style:Plain - [ V.Native_list.section - ~key:(Ui.Key.string "rows") - ~separator:Hidden - [ V.Native_list.disclosure_row - ~key:(Ui.Key.string "parent") - ~expanded - ~on_expanded_changed: - (Ui.Event.Handler.create (function - | Ui.Event.Payload.Bool value -> - Bonsai.Effect.Expert.handle (set_expanded (fun _ -> value)) - | _ -> ())) - ~label:(V.text "Parent row") - [ row "child" "Child row"; row "branch" "Unloaded branch row" ] - ; row "sibling" "Sibling row" + let context_menu_json = + ( "context_menu" + , `Assoc + [ ( "actions" + , `List + [ `Assoc + [ "key", `String "delete" + ; "enabled", `Bool true + ; "role", `String "destructive" + ; "symbol", `Null + ; "title", `String "Delete" + ] ] - ] - in - App.View.create - ~theme:(Ui.Theme.create ()) - ~body: - (V.Body.Vertical.create - [ V.Body.Vertical.fixed (V.text ("Observed: " ^ observed)) - ; V.Body.Vertical.fill outline - ])) + ) + ] + ) + in + let row ~id ~label = + `Assoc + [ "type", `String "row" + ; "key", `String id + ; "content_index", `Int (push (text ~value:label [])) + ; "separator", `String "hidden" + ; context_menu_json + ] + in + let disclosure ~id ~expanded children = + `Assoc + [ "type", `String "disclosure" + ; "key", `String id + ; "content_index", `Int (push (text ~value:"Parent row" [])) + ; "separator", `String "hidden" + ; "expanded", `Bool expanded + ; "children", `List children + ] + in + let payload = + Yojson.Basic.to_string + (`Assoc + [ "style", `String "plain" + ; ( "sections" + , `List + [ `Assoc + [ "key", `String "rows" + ; "separator", `String "hidden" + ; "header_index", `Null + ; "footer_index", `Null + ; ( "rows" + , `List + [ disclosure + ~id:"parent" + ~expanded + [ row ~id:"child" ~label:"Child row" + ; row ~id:"branch" ~label:"Unloaded branch row" + ] + ; row ~id:"sibling" ~label:"Sibling row" + ] + ) + ] + ] + ) + ; "scroll_request", `Null + ; "track_visible_range", `Bool false + ; "track_scroll_completion", `Bool false + ]) + in + Journal_lui_native.list + ~key:"outline" + ~payload + ~children:(List.rev !contents) + ~on_event:(on_list_event send) +;; + +let view _context model_source send = + let model = sample model_source in + column + ~gap:16 + [ text ~value:("Observed: " ^ model.observed) [] + ; outline_list ~expanded:model.expanded send + ] +;; + +(* --- headless host bridge ------------------------------------------------ *) +(* Shape mirrors app/native_embed.ml: the generated host calls + Journal_bridge.register with these hooks. *) + +let latest_patch = ref "" + +let current_app : (model, action) Lui_app.reducer_app option ref = ref None + +let operating_system = function + | 1 -> MacOS + | 2 -> IOS + | 3 -> AndroidOS + | 4 -> LinuxOS + | 5 -> WindowsOS + | _ -> GenericOS +;; + +let host_kind = function + | 1 -> WebHost + | 2 -> SwiftUIHost + | 3 -> FlutterHost + | _ -> GenericHost +;; + +let backend profile = + { backend_profile = profile + ; apply_batch = + (fun batch -> + latest_patch := Lui_wire.encode_batch batch; + true) + } +;; + +let app () = + match !current_app with + | Some value -> value + | None -> invalid_arg "Outline probe is not started" +;; + +let init platform_code host_code _payload = + latest_patch := ""; + let value = + Lui_app.create + (backend + (profile (operating_system platform_code) (host_kind host_code))) + initial reducer view + in + current_app := Some value; + ignore (Lui_app.start value); + ignore (Lui_app.flush value); + !latest_patch +;; + +let dispatch event = + latest_patch := ""; + ignore (Lui_app.dispatch_event (app ()) event); + ignore (Lui_app.flush (app ())); + !latest_patch +;; + +let extension_event _node _name _payload = "" + +let pump () = + latest_patch := ""; + ignore (Lui_app.flush (app ())); + !latest_patch +;; + +let dispose () = + latest_patch := ""; + Option.iter (fun value -> ignore (Lui_app.dispose value)) !current_app; + current_app := None; + !latest_patch +;; + +let root_node () = Lui_app.root_node (app ()) + +let native_hooks : Journal_bridge.hooks = + { init + ; dispatch + ; extension_event + ; pump + ; platform_event = (fun _ -> ()) + ; platform_response = (fun _ -> ()) + ; dispose + ; root_node + } ;; -let app = App.create ~name:"Outline action probe" component +let () = Journal_bridge.register native_hooks diff --git a/apple-tests/unlock/README.md b/apple-tests/unlock/README.md index c8748cd..0c2a62a 100644 --- a/apple-tests/unlock/README.md +++ b/apple-tests/unlock/README.md @@ -8,7 +8,7 @@ shows a sample error; Choose another graph resets preview state. This is visual acceptance, not another authentication regression test. Application ownership remains covered by `test_native_unlock_recovery` in `test/macos_application_dispatch_test.ml`. Native secure-field focus admission has -its regression in the bonsai-ui SDK. The preview cannot establish correctness of +its regression in the LUI runtime SDK. The preview cannot establish correctness of the OCaml bridge or physical iPhone keyboard behavior. Build the macOS preview from the repository root: diff --git a/apple-tests/warm-start/JournalWarmStartAcceptance.swift b/apple-tests/warm-start/JournalWarmStartAcceptance.swift index e0b875d..4ec59bf 100644 --- a/apple-tests/warm-start/JournalWarmStartAcceptance.swift +++ b/apple-tests/warm-start/JournalWarmStartAcceptance.swift @@ -1,4 +1,4 @@ -import BonsaiSwiftUI +import LUIAppleBackend import Foundation import Observation import SwiftUI @@ -75,7 +75,7 @@ private struct WarmFixture: Decodable { let auth: OfflineAuth let services: JournalPlatformServices let reportURL: URL - private var sender: BonsaiApplicationEvents? + let platform: JournalApplicationPlatform init(fixture: WarmFixture, missing: Bool, report: URL) throws { self.fixture = fixture @@ -87,6 +87,7 @@ private struct WarmFixture: Decodable { load: { JournalLocalAccount(userID: fixture.userId, managedSyncOrigin: fixture.baseUrl) }, save: { _ in }, clear: { throw JournalPlatformServices.Failure.unavailable }), managedSyncOrigin: fixture.baseUrl) + platform = JournalApplicationPlatform(services: services) auth.isTimelinePresented = { [weak services] in services?.timelinePresented == true } payload = try JournalStartupConfiguration.encode( applicationSupportPath: fixture.supportRoot, managedSyncOrigin: fixture.baseUrl) @@ -109,10 +110,11 @@ private struct WarmFixture: Decodable { } } - var bridge: BonsaiApplicationBridge { - BonsaiApplicationBridge(request: { [self] bytes in - let request = try JournalPlatformWire.decodeRequest(bytes) - let response = try await services.response(for: request) + /// Mirrors the old bridge callbacks through the platform's observation + /// hooks: `request` runs inside JournalApplicationPlatform, and connect / + /// disconnect transitions come from the runtime's attach/detach. + func observePlatform() { + platform.requestObserver = { [self] request in if request == .timelinePresented { presented = true // Startup allows network overlap, but blocked authentication must not @@ -121,18 +123,18 @@ private struct WarmFixture: Decodable { status = passed ? "PASS encrypted local timeline with authentication blocked" : "FAIL warm-start presentation" record("timeline-presented") } - return try JournalPlatformWire.encodeResponse(response) - }, connected: { [self] value in - sender = value - ready = true - record("connected") - }, disconnected: { [self] in - sender = nil - ready = false - disconnected += 1 - services.invalidateConnection() - record("disconnected") - }) + } + platform.connectionObserver = { [self] connected in + if connected { + ready = true + record("connected") + } else { + ready = false + disconnected += 1 + services.invalidateConnection() + record("disconnected") + } + } } func checkRecovery() { @@ -142,32 +144,31 @@ private struct WarmFixture: Decodable { record("recovery-observation") } + /// Inspects the public runtime surface (JournalRuntime + the journal pump) + /// directly — the counterpart of the old NativeRuntime.open pump loop. func inspectFirstFrame() async { do { - let runtime = try await NativeRuntime.open(entrypoint: "logseq_journal", payload: payload) + let headless = JournalApplicationPlatform(services: services) + let runtime = try JournalRuntime( + platform: headless, startupPayload: payload, + extensionRegistry: try JournalExtensions.registry()) + runtime.start() for index in 0..<5 { - let frame = try await runtime.pump(monotonicNanoseconds: Int64(index * 2 + 1)) - status = "Native frame \(index): status=\(frame.status) bytes=\(frame.bytes.count) revision=\(frame.revision)" - try frame.bytes.write(to: reportURL.appendingPathExtension("frame-\(index)")) + runtime.pump() + try await Task.sleep(for: .milliseconds(50)) + status = "Native frame \(index): root=\(runtime.rootID.map(String.init) ?? "pending") applied=\(runtime.appliedPatches)" record("native-frame") - try await runtime.acknowledge(frame, monotonicNanoseconds: Int64(index * 2 + 2)) } - await runtime.close() + runtime.stop() } catch { status = "FAIL first frame: \(error)"; record("first-frame-error") } } func shutdown() async { - guard let sender else { return } - do { - let operation = try sender.beginShutdown(event: JournalPlatformWire.prepareToTerminate(), - timeout: .seconds(4), - accepting: { (try? JournalPlatformWire.decodeRequest($0)) == .terminationReady }, - request: { _ in .finish(try JournalPlatformWire.encodeResponse(.terminationReady)) }) - let outcome = await operation.result - status = outcome == .completed && disconnected == 1 - ? "PASS real Journal cooperative shutdown" : "FAIL shutdown: \(outcome)" - record("shutdown-\(outcome)") - } catch { status = "FAIL shutdown: \(error)"; record("shutdown-error") } + guard let exchange = platform.beginShutdown() else { return } + let outcome = await exchange.result + status = outcome == .completed && disconnected == 1 + ? "PASS real Journal cooperative shutdown" : "FAIL shutdown: \(outcome)" + record("shutdown-\(outcome)") } private func record(_ event: String) { @@ -203,7 +204,7 @@ private struct WarmFixture: Decodable { #endif @State private var probe: WarmProbe @State private var activeScene = true - private let registry: BonsaiNativeViews + private let registry: LUIAppleExtensionRegistry init() { // This executable is test-only; never access the user's native secrets. @@ -235,11 +236,11 @@ private struct WarmFixture: Decodable { fatalError("--support-root relative Documents path is required on iPhone") } #endif - _probe = State(initialValue: try WarmProbe(fixture: fixture, - missing: arguments.contains("--missing-key"), report: path.appendingPathExtension("observations.jsonl"))) - var registry = BonsaiNativeViews() - try JournalChrome.register(in: ®istry) - self.registry = registry + let probe = try WarmProbe(fixture: fixture, + missing: arguments.contains("--missing-key"), report: path.appendingPathExtension("observations.jsonl")) + probe.observePlatform() + _probe = State(initialValue: probe) + self.registry = try JournalExtensions.registry() } catch { fatalError("Fixture setup failed: \(error)") } } @@ -288,8 +289,8 @@ private struct WarmFixture: Decodable { if ProcessInfo.processInfo.arguments.contains("--pump-only") { Text("Inspecting the public native runtime").task { await probe.inspectFirstFrame() } } else { - BonsaiApplicationView(entrypoint: "logseq_journal", payload: probe.payload, - nativeViews: registry, applicationBridge: probe.bridge) + JournalRuntimeHost(platform: probe.platform, payload: probe.payload, + extensions: registry) .font(.body) .preferredColorScheme(ProcessInfo.processInfo.arguments.contains("--dark-appearance") ? .dark : ProcessInfo.processInfo.arguments.contains("--light-appearance") ? .light : nil) diff --git a/apple-tests/warm-start/README.md b/apple-tests/warm-start/README.md index 57b384a..512962c 100644 --- a/apple-tests/warm-start/README.md +++ b/apple-tests/warm-start/README.md @@ -52,10 +52,11 @@ successful selection persists graph 2 as the next launch target. ## Isolated iPhone host -Build with an existing development signing team: +Build the simulator host (device builds additionally need the shared iOS OCaml +toolchain object — see tool/build_journal_apple.sh): ```sh -python3 tool/test_swiftui_warm_start.py --platform ios --development-team TEAM_ID \ +python3 tool/test_swiftui_warm_start.py --platform ios-simulator \ --rows 500 --children 135 --graphs 2 ``` @@ -65,7 +66,7 @@ Swift source hashes. iPhone uses Release optimization with DEBUG enabled solely for the test host's memory-only secret stores. It is not a production keychain or remote performance test. -Install the printed `BonsaiJournalWarmStart.app` in the distinct +Install the printed `JournalWarmStartProbe.app` in the distinct `org.logseq.journal.warm-start-probe` container. Copy `valid.json` and the complete `support-valid` directory into that app's Documents directory using devicectl. Launch with: diff --git a/apple/Info.plist b/apple/Info.plist new file mode 100644 index 0000000..b20697b --- /dev/null +++ b/apple/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleExecutable + JournalApp + CFBundleIdentifier + com.logseq.journal + CFBundleName + Logseq Journal + CFBundleDisplayName + Logseq Journal + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 26.0 + MinimumOSVersion + 26.0 + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/swift/App.swift b/swift/App.swift index 56c7ebe..32e0984 100644 --- a/swift/App.swift +++ b/swift/App.swift @@ -1,4 +1,4 @@ -import BonsaiSwiftUI +import LUIAppleBackend import SwiftUI import OSLog #if os(macOS) @@ -27,15 +27,10 @@ import AppKit private struct JournalRuntimeSetup { let payload: Data - let nativeViews: BonsaiNativeViews + let extensions: LUIAppleExtensionRegistry @MainActor init() throws { payload = try JournalNativeServices.startupPayload() - var registry = BonsaiNativeViews() - try JournalChrome.register(in: ®istry) - try JournalAssetImport.register(in: ®istry) - try JournalMedia.register(in: ®istry) - try JournalAssetSettings.register(in: ®istry) - nativeViews = registry + extensions = try JournalExtensions.registry() } } @@ -55,8 +50,8 @@ private struct JournalHost: View { Label("Unable to open local application storage", systemImage: "exclamationmark.folder") } actions: { Button("Retry") { setup = nil; retry += 1 } } case .success(let setup): - BonsaiApplicationView(entrypoint: "logseq_journal", payload: setup.payload, - nativeViews: setup.nativeViews, applicationBridge: platform.bridge) + JournalRuntimeHost( + platform: platform, payload: setup.payload, extensions: setup.extensions) .font(.body) .safeAreaInset(edge: .top, spacing: 0) { if platform.authenticationRequired { @@ -127,15 +122,13 @@ private struct JournalHost: View { func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { guard !terminating else { return .terminateLater } - do { - guard let shutdown = try platform?.beginShutdown() else { return .terminateNow } - terminating = true - Task { - _ = await shutdown.result - sender.reply(toApplicationShouldTerminate: true) - } - return .terminateLater - } catch { return .terminateNow } + guard let shutdown = platform?.beginShutdown() else { return .terminateNow } + terminating = true + Task { + _ = await shutdown.result + sender.reply(toApplicationShouldTerminate: true) + } + return .terminateLater } } #endif diff --git a/swift/JournalApplicationPlatform.swift b/swift/JournalApplicationPlatform.swift index 3beda5f..8e0f67e 100644 --- a/swift/JournalApplicationPlatform.swift +++ b/swift/JournalApplicationPlatform.swift @@ -1,53 +1,148 @@ import Amplify -import BonsaiSwiftUI import Foundation import Observation +/// Terminal prepare-to-terminate exchange, mirroring `BonsaiApplicationShutdown`: +/// the host pushes the event, then completes once OCaml's termination-ready +/// request has been answered, the timeout elapses, or the exchange is cancelled. +@MainActor final class JournalApplicationShutdown { + enum Outcome: Equatable, Sendable { + case completed, timedOut, cancelled + } + private var outcome: Outcome? + private var waiters: [CheckedContinuation] = [] + private var timer: Task? + + init(deadline: ContinuousClock.Instant) { + timer = Task { [weak self] in + do { try await ContinuousClock().sleep(until: deadline) } catch { return } + self?.stop(.timedOut) + } + } + + var result: Outcome { + get async { + await withTaskCancellationHandler { + if let outcome { return outcome } + return await withCheckedContinuation { waiters.append($0) } + } onCancel: { + Task { @MainActor [weak self] in self?.stop(.cancelled) } + } + } + } + + func complete() { stop(.completed) } + func cancel() { stop(.cancelled) } + + private func stop(_ reason: Outcome) { + guard outcome == nil else { return } + outcome = reason + timer?.cancel() + timer = nil + for waiter in waiters { waiter.resume(returning: reason) } + waiters.removeAll() + } +} + /// Connects native services to one OCaml runtime without owning graph state. @Observable @MainActor final class JournalApplicationPlatform { private(set) var authenticationRequired = false private(set) var localAccountAvailable: Bool? @ObservationIgnored private let services: JournalPlatformServices - @ObservationIgnored private var events: BonsaiApplicationEvents? + @ObservationIgnored private weak var runtime: JournalRuntime? @ObservationIgnored private var connection = UUID() @ObservationIgnored private var refresh: Task? - @ObservationIgnored private var delivery: Task? @ObservationIgnored private var pending = JournalPlatformEvents() @ObservationIgnored private var authListener: UnsubscribeToken? + @ObservationIgnored private var shutdown: JournalApplicationShutdown? + @ObservationIgnored private var lastEnvironment: JournalEnvironmentSample? + @ObservationIgnored private(set) var connected = false + @ObservationIgnored let notices = JournalNotices() + /// Test observation hooks (apple-tests): every decoded LJP2 request and + /// each connect/disconnect transition, in delivery order on the main actor. + @ObservationIgnored var requestObserver: ((JournalPlatformWire.Request) -> Void)? + @ObservationIgnored var connectionObserver: ((Bool) -> Void)? init(services: JournalPlatformServices) { self.services = services } - var bridge: BonsaiApplicationBridge { - BonsaiApplicationBridge( - request: { [self] bytes in try await request(bytes) }, - connected: { [self] sender in - events = sender - pending.connect() - observeAuthentication() - deliverEvents() - }, - disconnected: { [self] in disconnect() }) + /// Attaches the platform to a started runtime; mirrors the old bridge's + /// `connected` closure. + func connect(to runtime: JournalRuntime) { + disconnect() + self.runtime = runtime + connected = true + pending.connect() + notices.setActive(true) + connectionObserver?(true) + observeAuthentication() + // Re-deliver the latest environment sample after a reconnect. + let environment = lastEnvironment + lastEnvironment = nil + if let environment { pushEnvironment(environment) } + deliverEvents() + } + + func disconnect() { + guard connected || runtime != nil else { return } + if let authListener { Amplify.Hub.removeListener(authListener) } + authListener = nil + connection = UUID() + refresh?.cancel() + refresh = nil + shutdown?.cancel() + shutdown = nil + pending.disconnect() + notices.setActive(false) + notices.cancelAll() + connectionObserver?(false) + connected = false + runtime = nil + services.invalidateConnection() } - func beginShutdown() throws -> BonsaiApplicationShutdown? { - guard let events else { return nil } + /// Pushes prepare-to-terminate and resolves once the termination-ready + /// request round-trips, the timeout passes, or the exchange is cancelled. + func beginShutdown() -> JournalApplicationShutdown? { + guard connected, let runtime else { return nil } refresh?.cancel() - delivery?.cancel() pending.beginShutdown() - return try events.beginShutdown( - event: JournalPlatformWire.prepareToTerminate(), timeout: .seconds(4), - accepting: { (try? JournalPlatformWire.decodeRequest($0)) == .terminationReady }, - request: { _ in .finish(try JournalPlatformWire.encodeResponse(.terminationReady)) }) + let exchange = JournalApplicationShutdown(deadline: .now + .seconds(4)) + shutdown = exchange + runtime.sendPlatformEvent(JournalPlatformWire.prepareToTerminate()) + return exchange } - private func request(_ bytes: Data) async throws -> Data { - let request = try JournalPlatformWire.decodeRequest(bytes) + /// Answers one LJP2 request envelope; `nil` suppresses the response (the + /// old bridge surfaced these as request failures). + func request(_ bytes: Data) async -> Data? { + let request: JournalPlatformWire.Request + do { + request = try JournalPlatformWire.decodeRequest(bytes) + } catch { return nil } + requestObserver?(request) if request == .signOut { refresh?.cancel() pending.clearAuthentication() } + if pending.terminating, request != .terminationReady { return nil } + // Notice requests resolve through the presenter, not platform services. + switch request { + case .cancelNotice(let token): + notices.cancel(token: token) + return nil + case .showNotice(let token, let message, let actionLabel, let durationMs): + let close = await notices.show( + token: token, message: message, actionLabel: actionLabel, + durationMs: durationMs) + guard connected else { return nil } + return try? JournalPlatformWire.encodeResponse( + .notice(token: token, result: close.result.rawValue)) + default: + break + } do { let response = try await services.response(for: request) + guard connected else { return nil } switch response { case .authenticatedUser(let user): authenticationRequired = user == nil case .signedOut: @@ -59,10 +154,13 @@ import Observation } let encoded = try JournalPlatformWire.encodeResponse(response) if request == .timelinePresented { refreshAuthentication() } + if request == .terminationReady { shutdown?.complete() } return encoded } catch JournalPlatformServices.Failure.authenticationRequired { authenticationRequired = true - throw BonsaiApplicationError.unavailable + return nil + } catch { + return nil } } @@ -76,7 +174,7 @@ import Observation do { let response = try await services.response(for: .authenticatedUser) try Task.checkCancellation() - guard connection == identity, events != nil else { return } + guard connection == identity, connected else { return } if case .authenticatedUser(let user) = response { authenticationRequired = user == nil } pending.authenticated(try JournalPlatformWire.encodeResponse(response)) deliverEvents() @@ -93,24 +191,22 @@ import Observation deliverEvents() } + /// Pushes the latest host environment sample (LJP2 tag 24) once it changes. + func pushEnvironment(_ sample: JournalEnvironmentSample) { + guard connected, sample != lastEnvironment else { return } + guard let data = try? JournalPlatformWire.environment(sample.jsonObject()) + else { return } + lastEnvironment = sample + runtime?.sendPlatformEvent(data) + } + + /// The lui channel has no backpressure: pending envelopes drain inline in + /// order, matching the ordering the old bounded send loop maintained. private func deliverEvents() { - guard delivery == nil else { return } - let identity = connection - delivery = Task { [weak self] in - guard let self else { return } - defer { if connection == identity { delivery = nil } } - while connection == identity, !Task.isCancelled, - let sender = events, let payload = pending.next { - do { - try sender.send(payload) - pending.accepted(payload) - } catch BonsaiApplicationEvents.SendError.backpressure { - do { try await Task.sleep(for: .milliseconds(50)) } catch { return } - } catch { - pending.disconnect() - return - } - } + guard let runtime else { return } + while let payload = pending.next { + runtime.sendPlatformEvent(payload) + pending.accepted(payload) } } @@ -127,17 +223,4 @@ import Observation } } } - - private func disconnect() { - if let authListener { Amplify.Hub.removeListener(authListener) } - authListener = nil - connection = UUID() - refresh?.cancel() - delivery?.cancel() - refresh = nil - delivery = nil - pending.disconnect() - events = nil - services.invalidateConnection() - } } diff --git a/swift/JournalAssetImport.swift b/swift/JournalAssetImport.swift index 424f038..a9a4ad1 100644 --- a/swift/JournalAssetImport.swift +++ b/swift/JournalAssetImport.swift @@ -1,4 +1,4 @@ -import BonsaiSwiftUI +import LUIAppleBackend import Observation import SwiftUI import UniformTypeIdentifiers @@ -32,55 +32,60 @@ import UniformTypeIdentifiers } } - private struct ImportButton: View { - let context: BonsaiNativeContext + struct View: SwiftUI.View { + let context: LUIAppleExtensionViewContext + @State private var selection = Selection() @State private var presented = false @State private var handled = false @State private var error: String? + private var properties: Properties? { + JournalExtensions.decode(Properties.self, context: context) + } + private func emitDismissed() { guard !handled, let data = try? JSONSerialization.data(withJSONObject: ["action": "dismissed"]) else { return } handled = true - _ = context.emit(data) + JournalExtensions.emit(context: context, payload: data) } - var body: some View { + var body: some SwiftUI.View { Button { - guard context.canInteract() else { return } + guard context.isUserInteractionEnabled else { return } presented = true } label: { - Label(context.resource.operation == nil ? "Attach file" : "Importing file", systemImage: "paperclip") + Label(selection.operation == nil ? "Attach file" : "Importing file", systemImage: "paperclip") } - .disabled(!context.properties.enabled || !context.isPresented || context.resource.operation != nil) + .disabled(properties?.enabled == false || selection.operation != nil) .accessibilityIdentifier("journal-asset-import") .fileImporter(isPresented: $presented, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in - guard context.canInteract() else { return } + guard context.isUserInteractionEnabled else { return } do { guard let source = try result.get().first else { - if context.properties.replace != nil { emitDismissed() } + if properties?.replace != nil { emitDismissed() } return } handled = true let operation = UUID().uuidString.lowercased() - context.resource.retain(source, operation: operation) + selection.retain(source, operation: operation) let extensionName = source.pathExtension.lowercased() let payload = try JSONSerialization.data(withJSONObject: [ "operation": operation, "asset": UUID().uuidString.lowercased(), "localMutation": UUID().uuidString.lowercased(), "metadataMutation": UUID().uuidString.lowercased(), "path": source.path(percentEncoded: false), "title": source.lastPathComponent, - "replaceReference": context.properties.replace ?? NSNull(), + "replaceReference": properties?.replace ?? NSNull(), "type": extensionName.isEmpty ? "bin" : extensionName, ] as [String: Any]) - if !context.emit(payload) { - context.resource.release() + if !JournalExtensions.emit(context: context, payload: payload) { + selection.release() error = "The destination is no longer available. Select the file again." } } catch { - context.resource.release() + selection.release() if (error as NSError).code == NSUserCancelledError { - if context.properties.replace != nil { emitDismissed() } + if properties?.replace != nil { emitDismissed() } } else { self.error = "Unable to access the selected file. Please try again." } @@ -89,34 +94,24 @@ import UniformTypeIdentifiers .onChange(of: presented) { _, isPresented in if isPresented { handled = false - } else if context.properties.replace != nil { + } else if properties?.replace != nil { // iOS never invokes the fileImporter completion on Cancel, so treat // closing an armed picker without a pick as a dismissal. emitDismissed() } } - .onChange(of: context.properties.request) { _, _ in - if context.properties.replace != nil { presented = true } + .onChange(of: properties?.request) { _, _ in + if properties?.replace != nil { presented = true } } - .onChange(of: context.properties.completion) { _, operation in - guard let operation, operation == context.resource.operation else { return } - context.resource.release() - error = context.properties.error + .onChange(of: properties?.completion) { _, operation in + guard let operation, operation == selection.operation else { return } + selection.release() + error = properties?.error } + .onDisappear { selection.release() } .alert("Unable to import file", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { Button("OK", role: .cancel) { error = nil } } message: { Text(error ?? "") } } } - - static func register(in registry: inout BonsaiNativeViews) throws { - try registry.register(kind: 2104, version: 1, capabilities: [.stateful, .resource, .semantics], - decode: { try JSONDecoder().decode(Properties.self, from: $0) }, - validateChildren: { _, count in - guard count == 0 else { throw BonsaiNativeViewError.invalidRegistration } - }, - encodeEvent: { (data: Data) in BonsaiNativeEvent(id: 1, payload: data) }, - makeResource: { Selection() }, dispose: { $0.release() }, - content: { context in ImportButton(context: context) }) - } } diff --git a/swift/JournalAssetSettings.swift b/swift/JournalAssetSettings.swift index f16127d..bef85b2 100644 --- a/swift/JournalAssetSettings.swift +++ b/swift/JournalAssetSettings.swift @@ -1,4 +1,4 @@ -import BonsaiSwiftUI +import LUIAppleBackend import SwiftUI @MainActor enum JournalAssetSettings { @@ -15,40 +15,43 @@ import SwiftUI let favorites: String let uploads: [Upload] } - private struct SettingsHost: View { - let context: BonsaiNativeContext + struct View: SwiftUI.View { + let context: LUIAppleExtensionViewContext private let preferences = JournalAssetPreferences(defaults: .standard) @State private var days = JournalAssetPreferences(defaults: .standard).recentDays @State private var deliveredDays: Int? + private var properties: Properties? { + JournalExtensions.decode(Properties.self, context: context) + } + @discardableResult private func emit(_ value: String) -> Bool { - guard context.canInteract() else { return false } - return context.emit(Data(value.utf8)) + JournalExtensions.emit(context: context, payload: Data(value.utf8)) } private func deliver() { if deliveredDays != days && emit("days:\(days)") { deliveredDays = days } } - var body: some View { - context.children[0] - .task(id: context.isPresented) { if context.isPresented { deliver() } } + var body: some SwiftUI.View { + context.content + .task { deliver() } .onChange(of: days) { _, value in if preferences.save(recentDays: value) { deliver() } } .sheet(isPresented: Binding( - get: { context.properties.presented }, + get: { properties?.presented == true }, set: { if !$0 { emit("dismissed") } })) { NavigationStack { Form { Section("Offline attachments") { Stepper("Recent journal days: \(days)", value: $days, in: JournalAssetPreferences.allowedDays) VStack(alignment: .leading, spacing: 8) { - Text("Recent journals: " + context.properties.recent) + Text("Recent journals: " + (properties?.recent ?? "")) .font(.footnote).fixedSize(horizontal: false, vertical: true) - Text("Favorites: " + context.properties.favorites) + Text("Favorites: " + (properties?.favorites ?? "")) .font(.footnote).fixedSize(horizontal: false, vertical: true) - if !context.properties.uploads.isEmpty { + if let properties, !properties.uploads.isEmpty { Text("Uploads").font(.headline) - ForEach(context.properties.uploads) { upload in + ForEach(properties.uploads) { upload in HStack(alignment: .top, spacing: 12) { if upload.busy { ProgressView().controlSize(.small).accessibilityLabel(upload.message) } VStack(alignment: .leading, spacing: 4) { @@ -90,12 +93,4 @@ import SwiftUI } } } - static func register(in registry: inout BonsaiNativeViews) throws { - try registry.register(kind: 2106, version: 1, capabilities: [.stateful, .semantics], - decode: { try JSONDecoder().decode(Properties.self, from: $0) }, - validateChildren: { properties, count in - guard count == 1, properties.uploads.count <= 32 else { throw BonsaiNativeViewError.invalidRegistration } - }, encodeEvent: { (data: Data) in BonsaiNativeEvent(id: 1, payload: data) }, - makeResource: { () }, dispose: { _ in }, content: { context in SettingsHost(context: context) }) - } } diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index 52bda15..72f99f5 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -1,4 +1,4 @@ -import BonsaiSwiftUI +import LUIAppleBackend import SwiftUI /// Native chrome layout only. List sections own all date scrolling and pinning. @@ -19,7 +19,7 @@ import SwiftUI static let defaultValue = CGSize.zero } - private struct FloatingChrome: View { + private struct FloatingChrome: SwiftUI.View { let content: AnyView let account: AnyView let error: AnyView @@ -27,7 +27,7 @@ import SwiftUI let properties: Properties @State private var controlsSize = CGSize.zero - var body: some View { + var body: some SwiftUI.View { GeometryReader { bounds in content.frame(width: bounds.size.width, height: bounds.size.height) .scrollContentBackground(.hidden) @@ -57,11 +57,11 @@ import SwiftUI } } - private struct SectionDate: View { + private struct SectionDate: SwiftUI.View { let title: String @Environment(\.journalControlsSize) private var controlsSize - var body: some View { + var body: some SwiftUI.View { HStack(spacing: 0) { Text(title) .font(.title2.weight(.semibold)) @@ -76,49 +76,52 @@ import SwiftUI } } - static func register(in registry: inout BonsaiNativeViews) throws { - try registry.register(kind: 2103, version: 2, capabilities: [.stateful, .semantics], - decode: { try JSONDecoder().decode(Properties.self, from: $0) }, - validateChildren: { properties, count in - switch properties.mode { - case .feedback: - guard count == 3, properties.top != nil, properties.visible != nil else { - throw BonsaiNativeViewError.invalidRegistration - } - case .journal: - guard count == 4, properties.connecting != nil, properties.account != nil, - properties.error != nil else { throw BonsaiNativeViewError.invalidRegistration } - case .header: - guard count == 0, properties.title != nil else { - throw BonsaiNativeViewError.invalidRegistration - } - } - }, - encodeEvent: { (event: Never) -> BonsaiNativeEvent in switch event {} }, - makeResource: { () }, dispose: { _ in }, - content: { context in - switch context.properties.mode { - case .feedback: + struct View: SwiftUI.View { + let context: LUIAppleExtensionViewContext + + private var properties: Properties? { + JournalExtensions.decode(Properties.self, context: context) + } + + private func child(_ index: Int) -> AnyView { + guard context.childIDs.count > index else { return AnyView(EmptyView()) } + return context.content(for: context.childIDs[index]) + } + + var body: some SwiftUI.View { + switch properties?.mode { + case .feedback: + if let properties, context.childIDs.count == 3, + properties.top != nil, properties.visible != nil { GeometryReader { bounds in - context.children[0].frame(width: bounds.size.width, height: bounds.size.height) + child(0).frame(width: bounds.size.width, height: bounds.size.height) } - .safeAreaInset(edge: context.properties.top! ? .top : .bottom, spacing: 0) { - if context.properties.visible! { + .safeAreaInset(edge: properties.top! ? .top : .bottom, spacing: 0) { + if properties.visible! { ViewThatFits(in: .horizontal) { - context.children[1] - context.children[2] + child(1) + child(2) } .frame(maxWidth: .infinity) .background(.bar) } } - case .journal: - FloatingChrome(content: AnyView(context.children[0]), account: AnyView(context.children[1]), - error: AnyView(context.children[2]), progress: AnyView(context.children[3]), properties: context.properties) - case .header: - SectionDate(title: context.properties.title!) } - }) + case .journal: + if let properties, context.childIDs.count == 4, + properties.connecting != nil, properties.account != nil, + properties.error != nil { + FloatingChrome(content: child(0), account: child(1), + error: child(2), progress: child(3), properties: properties) + } + case .header: + if let properties, context.childIDs.isEmpty, let title = properties.title { + SectionDate(title: title) + } + case .none: + EmptyView() + } + } } } diff --git a/swift/JournalEnvironment.swift b/swift/JournalEnvironment.swift new file mode 100644 index 0000000..6f44008 --- /dev/null +++ b/swift/JournalEnvironment.swift @@ -0,0 +1,269 @@ +import Foundation +import SwiftUI +#if canImport(UIKit) + import UIKit +#endif + +/// Host environment snapshot pushed to OCaml as an LJP2 tag-24 event, field- +/// for-field what `Journal_environment.decode_json` expects (replaces the old +/// bonsai `environment_changed` protocol event). +struct JournalEnvironmentSample: Equatable { + struct Insets: Equatable { + var left = 0.0, top = 0.0, right = 0.0, bottom = 0.0 + } + var viewportWidth = 0.0 + var viewportHeight = 0.0 + var devicePixelRatio = 1.0 + var textScale = 1.0 + var brightness = "light" + var platform = "ios" + var locale = "en" + var safeArea = Insets() + var keyboardInsets = Insets() + var accessibleNavigation = false + var boldText = false + var invertColors = false + var disableAnimations = false + var reducedMotion = false + var highContrast = false + var orientation = "portrait" + var pointerKinds = 0 + + func jsonObject() -> [String: Any] { + [ + "viewportWidth": viewportWidth, + "viewportHeight": viewportHeight, + "devicePixelRatio": devicePixelRatio, + "textScale": textScale, + "brightness": brightness, + "platform": platform, + "locale": locale, + "safeArea": [ + "left": safeArea.left, "top": safeArea.top, + "right": safeArea.right, "bottom": safeArea.bottom, + ], + "keyboardInsets": [ + "left": keyboardInsets.left, "top": keyboardInsets.top, + "right": keyboardInsets.right, "bottom": keyboardInsets.bottom, + ], + "accessibleNavigation": accessibleNavigation, + "boldText": boldText, + "invertColors": invertColors, + "disableAnimations": disableAnimations, + "reducedMotion": reducedMotion, + "highContrast": highContrast, + "orientation": orientation, + "pointerKinds": pointerKinds, + ] + } +} + +/// Observes the application boundary and reports environment samples; the +/// platform deduplicates and pushes them through `journal_ocaml_platform_event`. +struct JournalEnvironmentObserver: View { + let onSample: (JournalEnvironmentSample) -> Void + @Environment(\.displayScale) private var displayScale + @Environment(\.colorScheme) private var colorScheme + @Environment(\.locale) private var locale + @Environment(\.legibilityWeight) private var legibilityWeight + @Environment(\.accessibilityVoiceOverEnabled) private var voiceOver + @Environment(\.accessibilityInvertColors) private var invertColors + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorSchemeContrast) private var contrast + @Environment(\.layoutDirection) private var direction + @ScaledMetric(relativeTo: .body) private var bodyScale: Double = 1 + + private func sample(size: CGSize, safeArea: JournalEnvironmentSample.Insets) + -> JournalEnvironmentSample + { + #if os(macOS) + let platform = "macos" + let pointers = 0x0e + #else + let platform = "ios" + let pointers = 0x0f + #endif + return JournalEnvironmentSample( + viewportWidth: size.width, viewportHeight: size.height, + devicePixelRatio: displayScale, textScale: bodyScale, + brightness: colorScheme == .dark ? "dark" : "light", platform: platform, + locale: locale.identifier(.bcp47), safeArea: safeArea, + keyboardInsets: .init(), accessibleNavigation: voiceOver, + boldText: legibilityWeight == .bold, invertColors: invertColors, + disableAnimations: reduceMotion, reducedMotion: reduceMotion, + highContrast: contrast == .increased, + orientation: size.width > size.height ? "landscape" : "portrait", + // Host capability mask, not an inventory of connected input devices. + pointerKinds: pointers) + } + + var body: some View { + Group { + #if os(macOS) + GeometryReader { geometry in + let insets = geometry.safeAreaInsets + let value = sample( + size: geometry.size, + safeArea: .init( + left: direction == .leftToRight ? insets.leading : insets.trailing, + top: insets.top, + right: direction == .leftToRight ? insets.trailing : insets.leading, + bottom: insets.bottom)) + Color.clear + .onAppear { onSample(value) } + .onChange(of: value) { _, value in onSample(value) } + } + #else + UIKitEnvironmentProbe(preferences: sample(size: .zero, safeArea: .init())) { + onSample($0) + } + #endif + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +#if canImport(UIKit) + /// A zero-interaction UIView attached to the window that tracks viewport + /// bounds, safe area, and keyboard occlusion (followsUndockedKeyboard), + /// matching the bonsai UIKit environment probe. + private final class JournalWindowGeometryView: UIView { + struct Geometry { + let size: CGSize + let safeArea: JournalEnvironmentSample.Insets + let keyboard: JournalEnvironmentSample.Insets + } + var onChange: ((Geometry) -> Void)? + private var generation = UUID() + private var scheduled = false + private let tracker = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + accessibilityElementsHidden = true + backgroundColor = .clear + keyboardLayoutGuide.followsUndockedKeyboard = true + keyboardLayoutGuide.usesBottomSafeArea = false + tracker.translatesAutoresizingMaskIntoConstraints = false + addSubview(tracker) + NSLayoutConstraint.activate([ + tracker.leadingAnchor.constraint(equalTo: keyboardLayoutGuide.leadingAnchor), + tracker.trailingAnchor.constraint(equalTo: keyboardLayoutGuide.trailingAnchor), + tracker.topAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor), + tracker.bottomAnchor.constraint(equalTo: keyboardLayoutGuide.bottomAnchor), + ]) + } + @available(*, unavailable) required init?(coder: NSCoder) { + fatalError("unavailable") + } + override func layoutSubviews() { + super.layoutSubviews() + schedule() + } + override func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + schedule() + } + override func didMoveToWindow() { + super.didMoveToWindow() + schedule() + } + func invalidate() { + generation = UUID() + onChange = nil + } + func schedule() { + guard !scheduled, window != nil, onChange != nil else { return } + scheduled = true + let generation = self.generation + DispatchQueue.main.async { [weak self] in + guard let self, self.generation == generation else { return } + self.scheduled = false + guard let window = self.window, self.window === window else { return } + let keyboard = Self.keyboardInsets( + viewport: self.bounds, occlusion: self.keyboardLayoutGuide.layoutFrame) + let safe = window.safeAreaInsets + self.onChange?( + Geometry( + size: window.bounds.size, + safeArea: .init( + left: safe.left, top: safe.top, right: safe.right, bottom: safe.bottom), + keyboard: keyboard)) + } + } + + /// Full-edge keyboard occlusion; partial-width keyboards do not count + /// (same rule as bonsai's NativeKeyboardOcclusion). + private static func keyboardInsets(viewport: CGRect, occlusion: CGRect) + -> JournalEnvironmentSample.Insets + { + for rectangle in [viewport, occlusion] { + guard + [rectangle.origin.x, rectangle.origin.y, rectangle.size.width, + rectangle.size.height] + .allSatisfy(\.isFinite), + rectangle.size.width >= 0, rectangle.size.height >= 0 + else { return .init() } + } + guard !viewport.isEmpty, !occlusion.isEmpty else { return .init() } + let overlap = viewport.intersection(occlusion) + guard !overlap.isNull, !overlap.isEmpty else { return .init() } + if overlap.minX == viewport.minX, overlap.maxX == viewport.maxX { + if overlap.maxY == viewport.maxY { return .init(bottom: overlap.height) } + if overlap.minY == viewport.minY { return .init(top: overlap.height) } + } + if overlap.minY == viewport.minY, overlap.maxY == viewport.maxY { + if overlap.minX == viewport.minX { return .init(left: overlap.width) } + if overlap.maxX == viewport.maxX { return .init(right: overlap.width) } + } + return .init() + } + } + + private final class JournalEnvironmentAttachmentView: UIView { + private var observer: JournalWindowGeometryView? + var preferences = JournalEnvironmentSample() + var onSample: ((JournalEnvironmentSample) -> Void)? + + override func didMoveToWindow() { + super.didMoveToWindow() + observer?.invalidate() + observer = nil + guard let window else { return } + let observer = JournalWindowGeometryView(frame: window.bounds) + self.observer = observer + observer.onChange = { [weak self] geometry in + guard let self else { return } + var sample = self.preferences + sample.viewportWidth = geometry.size.width + sample.viewportHeight = geometry.size.height + sample.orientation = + geometry.size.width > geometry.size.height ? "landscape" : "portrait" + sample.safeArea = geometry.safeArea + sample.keyboardInsets = geometry.keyboard + self.onSample?(sample) + } + window.insertSubview(observer, at: 0) + observer.setNeedsLayout() + observer.schedule() + } + } + + private struct UIKitEnvironmentProbe: UIViewRepresentable { + let preferences: JournalEnvironmentSample + let onSample: (JournalEnvironmentSample) -> Void + + func makeUIView(context: Context) -> JournalEnvironmentAttachmentView { + let view = JournalEnvironmentAttachmentView() + view.preferences = preferences + view.onSample = onSample + return view + } + func updateUIView(_ view: JournalEnvironmentAttachmentView, context: Context) { + view.preferences = preferences + view.onSample = onSample + } + } +#endif diff --git a/swift/JournalExtensions.swift b/swift/JournalExtensions.swift new file mode 100644 index 0000000..39e174b --- /dev/null +++ b/swift/JournalExtensions.swift @@ -0,0 +1,172 @@ +import Foundation +import SwiftUI +import LUIAppleBackend + +/// Reproduces `Lui_extension.fingerprint` in OCaml (src/lui_extension.ml): +/// the backend rejects `create-extension` ops whose fingerprint does not +/// match the registered schema byte-for-byte. +enum JournalExtensionFingerprint { + struct Property { + let name: String + let kind: String + let required: Bool + let defaultValue: String? + } + struct Event { + let name: String + let fields: [(name: String, kind: String, required: Bool)] + } + + static func make( + identifier: String, + profiles: [String], + standardChildren: Bool, + children: [String], + properties: [Property], + events: [Event] + ) -> String { + func token(_ value: String) -> String { + "\(value.utf8.count):\(value)" + } + func propertyToken(_ property: Property) -> String { + let fallback = property.defaultValue.map { "some:s\(token($0))" } ?? "none" + return token(property.name) + ":" + property.kind + ":" + + (property.required ? "required" : "optional") + ":" + fallback + } + func eventToken(_ event: Event) -> String { + let fields = event.fields + .map { token($0.name) + ":" + $0.kind + ":" + + ($0.required ? "required" : "optional") } + .sorted() + .joined(separator: ",") + return token(event.name) + "[" + fields + "]" + } + return "lui-extension-v1|" + token(identifier) + + "|profiles:" + profiles.sorted().joined(separator: ",") + + "|standard-children:" + (standardChildren ? "1" : "0") + + "|children:" + children.map(token).sorted().joined(separator: ",") + + "|properties:" + properties.map(propertyToken).sorted().joined(separator: ",") + + "|events:" + events.map(eventToken).sorted().joined(separator: ",") + } +} + +@MainActor enum JournalExtensions { + private static let appleProfiles = ["macos/swiftui", "ios/swiftui"] + private static let allHostProfiles = + appleProfiles + ["macos/flutter", "ios/flutter", "android/flutter"] + private static let payloadProperty = + JournalExtensionFingerprint.Property( + name: "payload", kind: "string", required: true, defaultValue: nil) + private static let event = + JournalExtensionFingerprint.Event( + name: "event", + fields: [(name: "id", kind: "int", required: true), + (name: "payload", kind: "string", required: true)]) + + private static func fingerprint( + identifier: String, profiles: [String], standardChildren: Bool, events: Bool + ) -> String { + JournalExtensionFingerprint.make( + identifier: identifier, profiles: profiles, + standardChildren: standardChildren, children: [], + properties: [payloadProperty], + events: events ? [event] : []) + } + + private static let eventSchema = LUIExtensionEvent( + name: "event", + fields: [ + .init(name: "id", kind: .int, isRequired: true), + .init(name: "payload", kind: .string, isRequired: true), + ]) + + /// Decodes the required `payload` string property into the same Codable + /// `Properties` structs the bonsai native views decoded. + static func decode( + _ type: Properties.Type, context: LUIAppleExtensionViewContext + ) -> Properties? { + guard case let .string(json) = context.property("payload"), + let value = try? JSONDecoder().decode(type, from: Data(json.utf8)) + else { return nil } + return value + } + + /// Forwards one journal event on the `"event"` schema, matching the old + /// `BonsaiNativeEvent(id, payload)` contract. Returns delivery success. + @discardableResult + static func emit( + context: LUIAppleExtensionViewContext, id: Int = 1, payload: Data + ) -> Bool { + guard context.isUserInteractionEnabled else { return false } + return (try? context.emit( + name: "event", + values: [ + "id": .int(id), + "payload": .string(String(decoding: payload, as: UTF8.self)), + ])) != nil + } + + private static func journalExtension( + identifier: String, profiles: [String], standardChildren: Bool, events: Bool, + viewFactory: @escaping LUIAppleExtension.ViewFactory + ) -> LUIAppleExtension { + LUIAppleExtension( + identifier: identifier, + fingerprint: fingerprint( + identifier: identifier, profiles: profiles, + standardChildren: standardChildren, events: events), + acceptsStandardChildren: standardChildren, + properties: [.init(name: "payload", kind: .string, isRequired: true)], + events: events ? [eventSchema] : [], + viewFactory: viewFactory) + } + + static func chromeExtension() -> LUIAppleExtension { + journalExtension(identifier: "journal-chrome", profiles: appleProfiles, + standardChildren: true, events: false) { context in + AnyView(JournalChrome.View(context: context)) + } + } + + static func assetImportExtension() -> LUIAppleExtension { + journalExtension(identifier: "journal-asset-import", profiles: allHostProfiles, + standardChildren: false, events: true) { context in + AnyView(JournalAssetImport.View(context: context)) + } + } + + static func mediaExtension() -> LUIAppleExtension { + journalExtension(identifier: "journal-media", profiles: allHostProfiles, + standardChildren: false, events: true) { context in + AnyView(JournalMedia.View(context: context)) + } + } + + static func assetSettingsExtension() -> LUIAppleExtension { + journalExtension(identifier: "journal-asset-settings", profiles: allHostProfiles, + standardChildren: true, events: true) { context in + AnyView(JournalAssetSettings.View(context: context)) + } + } + + static func listExtension() -> LUIAppleExtension { + journalExtension(identifier: "journal-list", profiles: allHostProfiles, + standardChildren: false, events: true) { context in + AnyView(JournalList.View(context: context)) + } + } + + static func registry() throws -> LUIAppleExtensionRegistry { + let registry = LUIAppleExtensionRegistry() + for journalExtension in [ + chromeExtension(), + assetImportExtension(), + mediaExtension(), + assetSettingsExtension(), + listExtension(), + ] { + try registry.register(journalExtension) + } + return registry + } +} diff --git a/swift/JournalList.swift b/swift/JournalList.swift new file mode 100644 index 0000000..0fff0ef --- /dev/null +++ b/swift/JournalList.swift @@ -0,0 +1,356 @@ +import LUIAppleBackend +import SwiftUI + +/// SwiftUI host for the `journal-list` extension — the lui replacement for +/// bonsai's `Native_list` family (grouped sections, disclosure rows, scroll +/// position requests, visible-range paging, swipe + context actions). +/// +/// Rows carry arbitrary content: each row's `content_index` (and each +/// section's `header_index`/`footer_index`) indexes `context.childIDs` and is +/// rendered via `context.content(for:)`. Row activation is handled inside +/// lui (the content mounts `Navigation_link`-style nodes); this view only +/// emits the auxiliary events: +/// +/// { "type": "visible_range", "first": , "last": } +/// { "type": "scroll_completed", "token": "", "outcome": "" } +/// { "type": "expanded", "key": "", "expanded": } +/// { "type": "row_event", "payload": "{\"key\":\"\",\"row\":\"\"}" } +/// +/// Positions are flat indices across the displayed rows in payload order +/// (disclosure children count only while the parent is expanded). +@MainActor enum JournalList { + struct Action: Decodable, Identifiable { + let key: String + let enabled: Bool? + let role: String? + let symbol: String? + let side: String? + let title: String + let background: String? + var id: String { key } + } + struct ActionGroup: Decodable { let actions: [Action] } + struct Row: Decodable, Identifiable { + let type: String + let key: String + let content_index: Int + let separator: String? + let test_id: String? + let swipe: ActionGroup? + let context_menu: ActionGroup? + let expanded: Bool? + let children: [Row]? + var id: String { key } + var isDisclosure: Bool { type == "disclosure" } + } + struct SectionModel: Decodable, Identifiable { + let key: String + let separator: String? + let header_index: Int? + let footer_index: Int? + let rows: [Row] + var id: String { key } + } + struct ScrollRequest: Decodable { + struct Target: Decodable { + let section: String + let row_path: [String] + } + let token: String + let target: Target + let anchor: String? + let animated: Bool? + } + struct Properties: Decodable { + let style: String + let sections: [SectionModel] + let scroll_request: ScrollRequest? + let track_visible_range: Bool? + let track_scroll_completion: Bool? + } + + struct View: SwiftUI.View { + let context: LUIAppleExtensionViewContext + @State private var visible: Set = [] + @State private var delivered: (first: Int, last: Int)? + @State private var scrolledID: String? + @State private var scrollAnchor: UnitPoint = .top + @State private var handledScrollToken: Int64 = 0 + + private var properties: Properties? { + JournalExtensions.decode(Properties.self, context: context) + } + + private func emit(_ fields: [String: Any]) { + guard let data = try? JSONSerialization.data( + withJSONObject: fields, options: [.sortedKeys]) + else { return } + JournalExtensions.emit(context: context, payload: data) + } + + private func rowEvent(action: String, row: String) { + guard let inner = try? JSONSerialization.data( + withJSONObject: ["key": action, "row": row], options: [.sortedKeys]), + let payload = String(data: inner, encoding: .utf8) + else { return } + emit(["type": "row_event", "payload": payload]) + } + + private func childContent(_ index: Int) -> AnyView { + guard index >= 0, index < context.childIDs.count else { + return AnyView(EmptyView()) + } + return context.content(for: context.childIDs[index]) + } + + /// Rows displayed in payload order; disclosure children appear only while + /// their parent row is expanded. The OCaml side maps these positions to + /// its own row indices. + private var positions: [String: Int] { + guard let properties else { return [:] } + var map: [String: Int] = [:] + var index = 0 + func walk(_ row: Row) { + map[row.key] = index + index += 1 + if row.isDisclosure, row.expanded == true { + (row.children ?? []).forEach(walk) + } + } + for section in properties.sections { + section.rows.forEach(walk) + } + return map + } + + private func updateVisibleRange() { + guard let properties, properties.track_visible_range == true else { return } + let ordered = visible.sorted() + guard let first = ordered.first, let last = ordered.last else { + delivered = nil + return + } + let range = (first, last + 1) + guard delivered?.first != range.0 || delivered?.last != range.1 else { return } + delivered = range + emit(["type": "visible_range", "first": range.0, "last": range.1]) + } + + private func completeScroll(_ token: String, _ outcome: String) { + emit(["type": "scroll_completed", "token": token, "outcome": outcome]) + } + + /// Walk target.row_path from the section's top-level rows; intermediate + /// elements descend into disclosure children. + private func resolveTarget(_ target: ScrollRequest.Target) -> Row? { + guard let section = properties?.sections.first(where: { + $0.key == target.section + }) else { return nil } + var candidates = section.rows + var row: Row? + for (index, key) in target.row_path.enumerated() { + row = candidates.first(where: { $0.key == key }) + guard let current = row else { return nil } + if index + 1 < target.row_path.count { + candidates = current.children ?? [] + } + } + return row + } + + private func applyScrollRequest(_ request: ScrollRequest) { + guard let token = Int64(request.token), token > handledScrollToken else { + return + } + handledScrollToken = token + guard let properties else { return } + guard let row = resolveTarget(request.target) else { + completeScroll(request.token, "missing_target") + return + } + // A row hidden by a collapsed ancestor cannot be scrolled to; the OCaml + // decoder has no hidden_target variant, so report missing_target. + guard positions[row.key] != nil else { + completeScroll(request.token, "missing_target") + return + } + scrollAnchor = + switch request.anchor { + case "center": .center + case "bottom": .bottom + default: .top + } + scrolledID = row.key + if properties.track_scroll_completion == true { + completeScroll(request.token, "succeeded") + } + } + + private func separatorVisibility(_ name: String?) -> Visibility { + switch name { + case "hidden": .hidden + case "visible": .visible + default: .automatic + } + } + + private static func actionTint(_ background: String?) -> Color? { + guard let background, background != "transparent" else { return nil } + var hex = background + if hex.hasPrefix("#") { hex.removeFirst() } + guard hex.count == 6, let value = UInt32(hex, radix: 16) else { return nil } + return Color( + red: Double((value >> 16) & 0xff) / 255, + green: Double((value >> 8) & 0xff) / 255, + blue: Double(value & 0xff) / 255) + } + + private func actionInteractive(_ action: Action) -> Bool { + action.enabled != false && context.isUserInteractionEnabled + } + + private func actionLabel(_ action: Action) -> some SwiftUI.View { + Group { + if let symbol = action.symbol { + Label(action.title, systemImage: symbol) + } else { + Text(action.title) + } + } + } + + @ViewBuilder private func rowActions(_ row: Row) -> some SwiftUI.View { + childContent(row.content_index) + .id(row.key) + .listRowSeparator(separatorVisibility(row.separator)) + .accessibilityIdentifier(row.test_id ?? row.key) + .swipeActions(edge: .leading, allowsFullSwipe: false) { + ForEach(row.swipe?.actions.filter { $0.side == "start" } ?? []) { action in + Button { rowEvent(action: action.key, row: row.key) } label: { + actionLabel(action) + } + .disabled(!actionInteractive(action)) + .tint(Self.actionTint(action.background)) + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + ForEach(row.swipe?.actions.filter { $0.side != "start" } ?? []) { action in + Button(role: action.role == "destructive" ? .destructive : nil) { + rowEvent(action: action.key, row: row.key) + } label: { + actionLabel(action) + } + .disabled(!actionInteractive(action)) + .tint(Self.actionTint(action.background)) + } + } + .contextMenu { + ForEach(row.context_menu?.actions ?? []) { action in + Button(role: action.role == "destructive" ? .destructive : nil) { + rowEvent(action: action.key, row: row.key) + } label: { + actionLabel(action) + } + .disabled(!actionInteractive(action)) + } + } + } + + // Opaque `some View` can't express the recursive disclosure shape. + private func rowBody(_ row: Row) -> AnyView { + let content = rowActions(row) + .onAppear { + if let position = positions[row.key] { + visible.insert(position) + updateVisibleRange() + } + } + .onDisappear { + if let position = positions[row.key] { + visible.remove(position) + updateVisibleRange() + } + } + if row.isDisclosure { + return AnyView(DisclosureGroup( + isExpanded: Binding( + get: { row.expanded == true }, + set: { value in + emit(["type": "expanded", "key": row.key, "expanded": value]) + }) + ) { + ForEach(row.children ?? []) { child in + rowBody(child) + } + } label: { + content + }) + } + return AnyView(content) + } + + private var style: ListStyleConfiguration { + switch properties?.style { + case "inset": return .inset + case "inset_grouped": return .insetGrouped + default: return .plain + } + } + + var body: some SwiftUI.View { + List { + ForEach(properties?.sections ?? []) { section in + Section { + ForEach(section.rows) { row in + rowBody(row) + } + } header: { + if let header = section.header_index { + childContent(header) + } + } footer: { + if let footer = section.footer_index { + childContent(footer) + } + } + .listSectionSeparator(separatorVisibility(section.separator)) + } + } + .modifier(ListStyleModifier(style: style)) + .scrollPosition(id: $scrolledID, anchor: scrollAnchor) + .onAppear { + if let request = properties?.scroll_request { applyScrollRequest(request) } + } + .onChange(of: properties?.scroll_request?.token) { _, _ in + if let request = properties?.scroll_request { applyScrollRequest(request) } + } + } + } + + private enum ListStyleConfiguration { + case plain, inset, insetGrouped + } + + private struct ListStyleModifier: ViewModifier { + let style: ListStyleConfiguration + func body(content: Content) -> some SwiftUI.View { + Group { + #if os(macOS) + if style == .plain { + content.listStyle(.plain) + } else { + // inset_grouped has no macOS equivalent — the inset style is the + // closest native presentation. + content.listStyle(.inset) + } + #else + if style == .plain { + content.listStyle(.plain) + } else { + content.listStyle(.insetGrouped) + } + #endif + } + } + } +} diff --git a/swift/JournalMedia.swift b/swift/JournalMedia.swift index bd4c6a9..eb5cc90 100644 --- a/swift/JournalMedia.swift +++ b/swift/JournalMedia.swift @@ -1,4 +1,4 @@ -import BonsaiSwiftUI +import LUIAppleBackend import ImageIO import QuickLook import SwiftUI @@ -47,13 +47,13 @@ private actor JournalMediaDecoder { let picker: Picker? let error: String? } - private struct MediaItem: View { + private struct MediaItem: SwiftUI.View { let item: Item let emit: (String, String, Bool) -> Void @State private var image: CGImage? @State private var preview: URL? @State private var decodeFailed = false - @ViewBuilder private var content: some View { + @ViewBuilder private var content: some SwiftUI.View { Group { if item.kind == "file" && item.isImage { Group { @@ -87,7 +87,7 @@ private actor JournalMediaDecoder { } } } - var body: some View { + var body: some SwiftUI.View { Group { if item.isImage { Color.clear @@ -101,66 +101,65 @@ private actor JournalMediaDecoder { .accessibilityIdentifier("journal-media:" + item.id) } } - private struct MediaGroup: View { - let context: BonsaiNativeContext + struct View: SwiftUI.View { + let context: LUIAppleExtensionViewContext + + private var properties: Properties? { + JournalExtensions.decode(Properties.self, context: context) + } + private func emit(_ action: String, _ asset: String = "", _ visible: Bool = true) { - guard context.canInteract(), let data = try? JSONSerialization.data(withJSONObject: [ - "action": action, "root": context.properties.root, "asset": asset, "visible": visible, + guard let properties, let data = try? JSONSerialization.data(withJSONObject: [ + "action": action, "root": properties.root, "asset": asset, "visible": visible, ]) else { return } - _ = context.emit(data) + JournalExtensions.emit(context: context, payload: data) } - var body: some View { + + var body: some SwiftUI.View { VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top) { - context.children[0] - Spacer(minLength: 8) - if context.properties.editable { - Menu { - Button("Replace file\u{2026}") { emit("replace") } - Button("Reuse existing\u{2026}") { emit("reuse") } - } label: { - Label("Attachment actions", systemImage: "ellipsis.circle") - .labelStyle(.iconOnly) + if let properties { + if properties.editable { + HStack(alignment: .top) { + Spacer(minLength: 8) + Menu { + Button("Replace file\u{2026}") { emit("replace") } + Button("Reuse existing\u{2026}") { emit("reuse") } + } label: { + Label("Attachment actions", systemImage: "ellipsis.circle") + .labelStyle(.iconOnly) + } + .menuIndicator(.hidden) + .accessibilityIdentifier("journal-media-actions") } - .menuIndicator(.hidden) - .accessibilityIdentifier("journal-media-actions") - } - } - ForEach(context.properties.items) { item in MediaItem(item: item, emit: emit) } - if let picker = context.properties.picker { - if picker.busy, picker.items.isEmpty { - ProgressView("Loading attachments").font(.caption) } - ForEach(picker.items) { item in - Button { emit("reuse-select", item.id) } label: { - Label(item.type.isEmpty ? "file" : item.type, systemImage: "doc") + ForEach(properties.items) { item in MediaItem(item: item, emit: emit) } + if let picker = properties.picker { + if picker.busy, picker.items.isEmpty { + ProgressView("Loading attachments").font(.caption) } - .disabled(picker.busy) - .accessibilityIdentifier("journal-media-candidate:" + item.id) - } - if picker.more { - Button("More attachments") { emit("reuse-next") } + ForEach(picker.items) { item in + Button { emit("reuse-select", item.id) } label: { + Label(item.type.isEmpty ? "file" : item.type, systemImage: "doc") + } .disabled(picker.busy) + .accessibilityIdentifier("journal-media-candidate:" + item.id) + } + if picker.more { + Button("More attachments") { emit("reuse-next") } + .disabled(picker.busy) + } + Button("Cancel", role: .cancel) { emit("reuse-cancel") } + .font(.caption) } - Button("Cancel", role: .cancel) { emit("reuse-cancel") } - .font(.caption) - } - if let error = context.properties.error { - Text(error).font(.caption).foregroundStyle(.secondary) - Button("Retry attachments") { emit("retry") } + if let error = properties.error { + Text(error).font(.caption).foregroundStyle(.secondary) + Button("Retry attachments") { emit("retry") } + } + if properties.more { Button("Next attachments") { emit("next") } } } - if context.properties.more { Button("Next attachments") { emit("next") } } } .buttonStyle(.borderless) .onDisappear { Task { await JournalMediaDecoder.shared.clear() } } } } - static func register(in registry: inout BonsaiNativeViews) throws { - try registry.register(kind: 2105, version: 1, capabilities: [.stateful, .semantics], - decode: { try JSONDecoder().decode(Properties.self, from: $0) }, - validateChildren: { properties, count in - guard count == 1, properties.items.count <= 32 else { throw BonsaiNativeViewError.invalidRegistration } - }, encodeEvent: { (data: Data) in BonsaiNativeEvent(id: 1, payload: data) }, - makeResource: { () }, dispose: { _ in }, content: { context in MediaGroup(context: context) }) - } } diff --git a/swift/JournalNotices.swift b/swift/JournalNotices.swift new file mode 100644 index 0000000..debe5df --- /dev/null +++ b/swift/JournalNotices.swift @@ -0,0 +1,217 @@ +import Foundation +import Observation +import SwiftUI + +/// One OCaml notice request (LJP2 tag 25) being presented to the user. The +/// request resolves when the notice closes, producing the tag-26 response. +struct JournalNoticePresentation: Equatable, Identifiable { + enum Close { + case action, dismiss, swipe, timeout + var result: JournalPlatformWire.NoticeResult { + switch self { + case .action: .action + case .dismiss: .dismiss + case .swipe: .swipe + case .timeout: .timeout + } + } + } + let id: UUID + let token: String + let message: String + let actionLabel: String? + let durationMilliseconds: Int +} + +/// FIFO notice queue; at most one notice presents at a time. Ported from the +/// bonsai `NativeNotices` controller with the response wired to the LJP2 +/// platform-response channel instead of a request callback. +@MainActor @Observable final class JournalNotices { + private final class Entry { + let presentation: JournalNoticePresentation + let continuation: CheckedContinuation + var remaining: Duration + var presented = false + var announced = false + init( + presentation: JournalNoticePresentation, + continuation: CheckedContinuation + ) { + self.presentation = presentation + self.continuation = continuation + remaining = .milliseconds(Int64(presentation.durationMilliseconds)) + } + } + + private(set) var presentation: JournalNoticePresentation? + private(set) var active = false + @ObservationIgnored private var entries: [Entry] = [] + @ObservationIgnored private var timer: Task? + @ObservationIgnored private var timerStarted: ContinuousClock.Instant? + @ObservationIgnored private var voiceOver = false + private let clock = ContinuousClock() + + func setActive(_ value: Bool) { + guard active != value else { return } + active = value + reconcileTimer() + } + func setVoiceOver(_ value: Bool) { + guard voiceOver != value else { return } + voiceOver = value + reconcileTimer() + } + + func shown(_ id: UUID) { + guard let first = entries.first, first.presentation.id == id else { return } + first.presented = true + if !first.announced { + first.announced = true + AccessibilityNotification.Announcement(first.presentation.message).post() + } + reconcileTimer() + } + func hidden(_ id: UUID) { + guard let first = entries.first, first.presentation.id == id else { return } + first.presented = false + reconcileTimer() + } + func close(_ id: UUID, reason: JournalNoticePresentation.Close) { + guard active, let first = entries.first, first.presented, + first.presentation.id == id, + reason != .action || first.presentation.actionLabel != nil + else { return } + finish(id, close: reason) + } + + /// Awaits the close reason for one notice request. + func show(token: String, message: String, actionLabel: String?, durationMs: Int) + async -> JournalNoticePresentation.Close + { + guard entries.count < 256 else { return .dismiss } + let id = UUID() + return await withCheckedContinuation { continuation in + entries.append( + Entry( + presentation: JournalNoticePresentation( + id: id, token: token, message: message, + actionLabel: actionLabel, durationMilliseconds: durationMs), + continuation: continuation)) + if entries.count == 1 { presentation = entries[0].presentation } + } + } + + /// Handles LJP2 tag 27. A notice cancelled before it was ever presented is + /// dropped silently; one already shown resolves as a host dismiss (the + /// tag-26 response still rides back on the original tag-25 request). + func cancel(token: String) { + guard let index = entries.firstIndex(where: { $0.presentation.token == token }) + else { return } + finish(entries[index].presentation.id, close: .dismiss) + } + + private func stopTimer() { + if let started = timerStarted, let first = entries.first { + first.remaining = max(.zero, first.remaining - started.duration(to: clock.now)) + } + timerStarted = nil + timer?.cancel() + timer = nil + } + private func reconcileTimer() { + guard active, let first = entries.first, first.presented, + !(voiceOver && first.presentation.actionLabel != nil) + else { + stopTimer() + return + } + guard timer == nil else { return } + timerStarted = clock.now + let id = first.presentation.id + let delay = first.remaining + timer = Task { [weak self] in + do { try await ContinuousClock().sleep(for: delay) } catch { return } + guard let self, !Task.isCancelled, entries.first?.presentation.id == id, + active, entries.first?.presented == true + else { return } + finish(id, close: .timeout) + } + } + private func finish(_ id: UUID, close: JournalNoticePresentation.Close) { + guard let index = entries.firstIndex(where: { $0.presentation.id == id }) + else { return } + if index == 0 { stopTimer() } + let entry = entries.remove(at: index) + if index == 0 { presentation = entries.first?.presentation } + entry.continuation.resume(returning: close) + } + func cancelAll() { + stopTimer() + let pending = entries + entries = [] + presentation = nil + for entry in pending { entry.continuation.resume(returning: .dismiss) } + } +} + +/// Presents the head notice as a bottom safe-area banner; ported from the +/// bonsai `NativeNoticePresenter` (same layout, gesture, VoiceOver gating). +struct JournalNoticePresenter: ViewModifier { + let controller: JournalNotices + @Environment(\.accessibilityVoiceOverEnabled) private var voiceOver + + private func notice(_ presentation: JournalNoticePresentation) -> some View { + let message = Text(presentation.message) + .lineLimit(5).fixedSize(horizontal: false, vertical: true) + let buttons = HStack(spacing: 10) { + if let action = presentation.actionLabel { + Button(action) { + controller.close(presentation.id, reason: .action) + } + .buttonStyle(.bordered) + } + Button { + controller.close(presentation.id, reason: .dismiss) + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.borderless).accessibilityLabel("Dismiss notification") + } + return ViewThatFits(in: .horizontal) { + HStack(alignment: .center, spacing: 16) { + message + buttons + } + VStack(alignment: .leading, spacing: 10) { + message + buttons.frame(maxWidth: .infinity, alignment: .trailing) + } + } + .padding(12) + .frame(maxWidth: 560) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + .contentShape(RoundedRectangle(cornerRadius: 12)) + .accessibilityElement(children: .contain) + .disabled(!controller.active) + .simultaneousGesture( + DragGesture(minimumDistance: 12).onEnded { value in + if value.translation.height > 40, abs(value.translation.width) < value.translation.height { + controller.close(presentation.id, reason: .swipe) + } + } + ) + .padding(.horizontal, 12).padding(.vertical, 8) + .onAppear { controller.shown(presentation.id) } + .onDisappear { controller.hidden(presentation.id) } + .id(presentation.id) + } + + func body(content: Content) -> some View { + content + .safeAreaInset(edge: .bottom, spacing: 0) { + if let presentation = controller.presentation { notice(presentation) } + } + .onAppear { controller.setVoiceOver(voiceOver) } + .onChange(of: voiceOver) { _, value in controller.setVoiceOver(value) } + } +} diff --git a/swift/JournalPlatformServices.swift b/swift/JournalPlatformServices.swift index 089d060..3c6d809 100644 --- a/swift/JournalPlatformServices.swift +++ b/swift/JournalPlatformServices.swift @@ -83,6 +83,10 @@ struct JournalLocalAccount: Equatable, Sendable { return .signedOut case .terminationReady: return .terminationReady + case .showNotice, .cancelNotice: + // Notice requests are intercepted by JournalApplicationPlatform before + // reaching services. + throw Failure.unavailable } } diff --git a/swift/JournalPlatformWire.swift b/swift/JournalPlatformWire.swift index 4f0fdbd..1681cdf 100644 --- a/swift/JournalPlatformWire.swift +++ b/swift/JournalPlatformWire.swift @@ -6,6 +6,8 @@ enum JournalPlatformWire { enum Request: Equatable { case authenticatedUser, signOut, terminationReady, localAccount, timelinePresented case idToken(challengeID: String) + case showNotice(token: String, message: String, actionLabel: String?, durationMs: Int) + case cancelNotice(token: String) } enum Response: Equatable { case authenticatedUser(String?) @@ -13,6 +15,10 @@ enum JournalPlatformWire { case signedOut, terminationReady, timelinePresented case localAccount(userID: String, origin: String) case noLocalAccount + case notice(token: String, result: String) + } + enum NoticeResult: String { + case action, dismiss, swipe, timeout } enum Lifecycle: UInt16 { case backgrounded = 1, foregroundResumed = 2 } @@ -48,6 +54,25 @@ enum JournalPlatformWire { throw Failure.invalidPacket } return .idToken(challengeID: challenge) + case 25: + let fields = try object(payload) + guard let token = fields["token"] as? String, + let message = fields["message"] as? String, + let durationMs = fields["durationMs"] as? Int, durationMs > 0 + else { throw Failure.invalidPacket } + let actionLabel = fields["actionLabel"] as? String + guard actionLabel?.isEmpty == false || fields["actionLabel"] is NSNull + || fields["actionLabel"] == nil + else { throw Failure.invalidPacket } + return .showNotice( + token: token, message: message, actionLabel: actionLabel, + durationMs: durationMs) + case 27: + let fields = try object(payload) + guard let token = fields["token"] as? String else { + throw Failure.invalidPacket + } + return .cancelNotice(token: token) default: throw Failure.invalidPacket } @@ -75,9 +100,17 @@ enum JournalPlatformWire { return try json(tag: 21, ["userId": NSNull(), "managedSyncOrigin": NSNull()]) case .timelinePresented: return try json(tag: 23, ["presented": true]) + case .notice(let token, let result): + return try json(tag: 26, ["token": token, "result": result]) } } + /// Host -> OCaml environment snapshot (tag 24), mirroring + /// Journal_environment.decode_json's required fields. + static func environment(_ object: [String: Any]) throws -> Data { + try json(tag: 24, object) + } + static func prepareToTerminate() -> Data { // An empty payload always satisfies the application envelope bound. try! frame(tag: 12, payload: Data()) diff --git a/swift/JournalRuntime.swift b/swift/JournalRuntime.swift new file mode 100644 index 0000000..d62785d --- /dev/null +++ b/swift/JournalRuntime.swift @@ -0,0 +1,234 @@ +import Foundation +import LUIAppleBackend +import Observation + +/// C bridge entries exported by app/journal_lui_bridge.c (see also +/// platform/native/lui_ocaml_bridge.c in the lui repository). Every entry that +/// produces a patch emits it synchronously through the patch callback installed +/// at start; all entries are invoked on the main actor, which owns the OCaml +/// runtime started by `lui_ocaml_start` on this thread. +private typealias PatchCallback = @convention(c) (UnsafePointer?) -> Void +private typealias WakeupCallback = @convention(c) () -> Void +private typealias PlatformRequestCallback = + @convention(c) (UnsafePointer?, Int32) -> Void + +@_silgen_name("lui_ocaml_start") +private func luiOCamlStart( + _ callback: PatchCallback?, + _ platform: Int32, + _ host: Int32, + _ payload: UnsafePointer?, + _ payloadLength: Int32 +) -> Int32 +@_silgen_name("lui_ocaml_stop") +private func luiOCamlStop() -> Int32 +@_silgen_name("lui_ocaml_appear") +private func luiOCamlAppear(_ node: Int64) -> Int32 +@_silgen_name("lui_ocaml_press") +private func luiOCamlPress(_ node: Int64) -> Int32 +@_silgen_name("lui_ocaml_long_press") +private func luiOCamlLongPress(_ node: Int64) -> Int32 +@_silgen_name("lui_ocaml_text_changed") +private func luiOCamlTextChanged(_ node: Int64, _ text: UnsafePointer?) -> Int32 +@_silgen_name("lui_ocaml_submit") +private func luiOCamlSubmit(_ node: Int64) -> Int32 +@_silgen_name("lui_ocaml_dismiss") +private func luiOCamlDismiss(_ node: Int64) -> Int32 +@_silgen_name("lui_ocaml_double_press") +private func luiOCamlDoublePress(_ node: Int64) -> Int32 +@_silgen_name("lui_ocaml_toggle_changed") +private func luiOCamlToggleChanged(_ node: Int64, _ checked: Int32) -> Int32 +@_silgen_name("lui_ocaml_radio_changed") +private func luiOCamlRadioChanged(_ node: Int64) -> Int32 +@_silgen_name("lui_ocaml_slider_changed") +private func luiOCamlSliderChanged(_ node: Int64, _ value: Double) -> Int32 +@_silgen_name("journal_ocaml_extension_event") +private func journalOCamlExtensionEvent( + _ node: Int64, + _ name: UnsafePointer?, + _ payload: UnsafePointer? +) -> Int32 +@_silgen_name("journal_ocaml_pump") +private func journalOCamlPump() -> Int32 +@_silgen_name("journal_ocaml_platform_event") +private func journalOCamlPlatformEvent(_ data: UnsafePointer?, _ length: Int32) +@_silgen_name("journal_ocaml_platform_response") +private func journalOCamlPlatformResponse(_ data: UnsafePointer?, _ length: Int32) +@_silgen_name("journal_ocaml_set_wakeup_callback") +private func journalOCamlSetWakeupCallback(_ callback: WakeupCallback?) +@_silgen_name("journal_ocaml_set_platform_request_callback") +private func journalOCamlSetPlatformRequestCallback(_ callback: PlatformRequestCallback?) + +nonisolated(unsafe) private var activeRuntime: JournalRuntime? + +/// OCaml only invokes the patch callback from entries the host runs on the main +/// actor, so `assumeIsolated` holds by construction. +private let receivePatch: PatchCallback = { source in + guard let source else { return } + let json = String(cString: source) + MainActor.assumeIsolated { + activeRuntime?.apply(json: json) + } +} + +/// Fired on whichever OCaml worker thread enqueued cross-thread work; hop to +/// the main actor before draining the pump queue. +private let wakeup: WakeupCallback = { + Task { @MainActor in + activeRuntime?.pump() + } +} + +/// OCaml calls this synchronously on its worker thread with an LJP2 request +/// envelope. The callback returns immediately; the platform answers later via +/// `journal_ocaml_platform_response` on the main actor. +private let platformRequest: PlatformRequestCallback = { data, length in + guard let data, length > 0 else { return } + let bytes = Data(bytes: data, count: Int(length)) + Task { @MainActor in + await activeRuntime?.deliverPlatformRequest(bytes) + } +} + +/// Owns the lui backend, the OCaml runtime, and the platform bridge for one +/// journal session. Replaces `BonsaiApplicationView` + `BonsaiApplicationBridge`. +@Observable @MainActor final class JournalRuntime { + let backend: LUIAppleBackend + let platform: JournalApplicationPlatform + private let startupPayload: Data + private(set) var rootID: Int? + /// Count of patch batches applied from the OCaml runtime (test visibility). + private(set) var appliedPatches = 0 + private var started = false + + init( + platform: JournalApplicationPlatform, + startupPayload: Data, + extensionRegistry: LUIAppleExtensionRegistry + ) throws { + self.platform = platform + self.startupPayload = startupPayload + backend = try LUIAppleBackend(extensionRegistry: extensionRegistry) + backend.onEvent = { [weak self] event in self?.handle(event) } + } + + /// Boots the OCaml runtime; the LDB1 startup payload rides inside + /// `lui_ocaml_start` so init receives it before the worker session begins. + /// The platform event stream attaches afterwards. + func start() { + guard !started else { return } + journalOCamlSetWakeupCallback(wakeup) + journalOCamlSetPlatformRequestCallback(platformRequest) + activeRuntime = self + #if os(macOS) + let operatingSystem: Int32 = 1 + #else + let operatingSystem: Int32 = 2 + #endif + let accepted = startupPayload.withUnsafeBytes { bytes in + luiOCamlStart( + receivePatch, + operatingSystem, + 2, + bytes.baseAddress?.assumingMemoryBound(to: CChar.self), + Int32(bytes.count)) + } + guard accepted == 1 else { + activeRuntime = nil + return + } + started = true + platform.connect(to: self) + } + + func stop() { + guard started else { return } + platform.disconnect() + _ = luiOCamlStop() + started = false + activeRuntime = nil + } + + func apply(json: String) { + do { + try backend.apply(json: json) + rootID = backend.rootIDs.first + appliedPatches += 1 + } catch { + assertionFailure("Invalid LUI patch: \(error)") + } + } + + func pump() { + guard started else { return } + _ = journalOCamlPump() + } + + /// Marshals one LJP2 request onto the platform actor and ships its response + /// envelope back through the C entry. Errors drop the response; OCaml owns + /// request timeouts (matching the old bridge error path). + func deliverPlatformRequest(_ bytes: Data) async { + guard started, let response = await platform.request(bytes) else { return } + response.withUnsafeBytes { buffer in + journalOCamlPlatformResponse( + buffer.baseAddress?.assumingMemoryBound(to: CChar.self), + Int32(buffer.count)) + } + } + + /// Pushes one host-originated LJP2 event envelope to OCaml. + func sendPlatformEvent(_ bytes: Data) { + guard started else { return } + bytes.withUnsafeBytes { buffer in + journalOCamlPlatformEvent( + buffer.baseAddress?.assumingMemoryBound(to: CChar.self), + Int32(buffer.count)) + } + } + + private func handle(_ event: LUIEvent) { + guard started else { return } + switch event { + case let .appear(node): _ = luiOCamlAppear(Int64(node)) + case let .press(node): _ = luiOCamlPress(Int64(node)) + case let .longPress(node): _ = luiOCamlLongPress(Int64(node)) + case let .textChanged(node, text): + text.withCString { _ = luiOCamlTextChanged(Int64(node), $0) } + case let .submit(node): _ = luiOCamlSubmit(Int64(node)) + case let .dismiss(node): _ = luiOCamlDismiss(Int64(node)) + case let .doublePress(node): _ = luiOCamlDoublePress(Int64(node)) + case let .toggleChanged(node, checked): + _ = luiOCamlToggleChanged(Int64(node), checked ? 1 : 0) + case let .change(node): _ = luiOCamlRadioChanged(Int64(node)) + case let .valueChanged(node, value): + _ = luiOCamlSliderChanged(Int64(node), value) + case let .extension(node, _, name, values): + guard let payload = Self.encodeExtensionValues(values) else { return } + name.withCString { eventName in + payload.withCString { json in + _ = journalOCamlExtensionEvent(Int64(node), eventName, json) + } + } + } + } + + /// Serializes extension event fields as the bare-scalar JSON object the + /// OCaml `extension_event` hook decodes into wire values. + private static func encodeExtensionValues( + _ values: [String: LUIExtensionValue] + ) -> String? { + var object: [String: Any] = [:] + for (name, value) in values { + switch value { + case let .string(string): object[name] = string + case let .bool(flag): object[name] = flag + case let .int(number): object[name] = number + case let .double(number): object[name] = number + } + } + guard let data = try? JSONSerialization.data( + withJSONObject: object, options: [.sortedKeys]) + else { return nil } + return String(decoding: data, as: UTF8.self) + } +} diff --git a/swift/JournalRuntimeHost.swift b/swift/JournalRuntimeHost.swift new file mode 100644 index 0000000..d1a7bd4 --- /dev/null +++ b/swift/JournalRuntimeHost.swift @@ -0,0 +1,42 @@ +import LUIAppleBackend +import OSLog +import SwiftUI + +/// Boots one `JournalRuntime` for the given startup payload and renders the +/// lui root with the environment/notice plumbing attached. Shared by the app +/// scene (App.swift) and the apple-tests acceptance harnesses — it replaces +/// `BonsaiApplicationView(entrypoint:payload:nativeViews:applicationBridge:)`. +struct JournalRuntimeHost: View { + let platform: JournalApplicationPlatform + let payload: Data + let extensions: LUIAppleExtensionRegistry + @State private var runtime: JournalRuntime? + + var body: some SwiftUI.View { + Group { + if let runtime, let rootID = runtime.rootID { + LUISwiftUIRoot(backend: runtime.backend, rootID: rootID) + } else { + ProgressView("Opening journal") + } + } + .modifier(JournalNoticePresenter(controller: platform.notices)) + .background(JournalEnvironmentObserver { platform.pushEnvironment($0) }) + .task { + if runtime == nil { + do { + let next = try JournalRuntime( + platform: platform, + startupPayload: payload, + extensionRegistry: extensions) + next.start() + runtime = next + } catch { + Logger(subsystem: "com.logseq.journal", category: "runtime") + .error("Unable to start journal runtime: \(error)") + } + } + } + .onDisappear { runtime?.stop() } + } +} diff --git a/swift/Package.resolved b/swift/Package.resolved new file mode 100644 index 0000000..61bb67f --- /dev/null +++ b/swift/Package.resolved @@ -0,0 +1,330 @@ +{ + "originHash" : "ee33eb1bc2436bb271c1e42a07f2d4a1cc07cb31d124815a8abff3ccef926239", + "pins" : [ + { + "identity" : "amplify-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/aws-amplify/amplify-swift.git", + "state" : { + "revision" : "ce8d9e69df07e80c57e2ca68c01360ea6636abf5", + "version" : "2.61.0" + } + }, + { + "identity" : "amplify-swift-utils-notifications", + "kind" : "remoteSourceControl", + "location" : "https://github.com/aws-amplify/amplify-swift-utils-notifications.git", + "state" : { + "revision" : "959eec669ba97c7d923b963c3e66ca8a0b2737f6", + "version" : "1.1.1" + } + }, + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "f95c908967e98c68c5ce3fd61a7974e7e869e303", + "version" : "1.36.1" + } + }, + { + "identity" : "aws-crt-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/awslabs/aws-crt-swift", + "state" : { + "revision" : "d1678252cb3da2d34f70db43e402927360df4d8c", + "version" : "0.64.1" + } + }, + { + "identity" : "aws-sdk-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/awslabs/aws-sdk-swift", + "state" : { + "revision" : "61b8c1968aeae4d42226e3b452ab864fd0b96523", + "version" : "1.7.60" + } + }, + { + "identity" : "skip", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip.git", + "state" : { + "revision" : "885f0c520e1ebdbec1f0e296d713293dadc5a2f4", + "version" : "1.9.5" + } + }, + { + "identity" : "skip-foundation", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-foundation.git", + "state" : { + "revision" : "94d47aeed3bb8027ef3ad8e07a8771b52529c238", + "version" : "1.4.2" + } + }, + { + "identity" : "skip-lib", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-lib.git", + "state" : { + "revision" : "76e7da8a870b5b66ea0c3264f648b58b73bcdc0d", + "version" : "1.4.0" + } + }, + { + "identity" : "skip-model", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-model.git", + "state" : { + "revision" : "54c7914e985e5ae07b1a8fe29e7aac7156b88874", + "version" : "1.7.6" + } + }, + { + "identity" : "skip-ui", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-ui.git", + "state" : { + "revision" : "ef7bbdd541cdf2efd3ce6ecde72337e1beb92366", + "version" : "1.59.1" + } + }, + { + "identity" : "skip-unit", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-unit.git", + "state" : { + "revision" : "c89af47fd645e04db863e938ade39f91e1bb62b8", + "version" : "1.7.0" + } + }, + { + "identity" : "smithy-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/smithy-lang/smithy-swift", + "state" : { + "revision" : "31336dfea2e448523630ea1fe4d83ddc1156de3c", + "version" : "0.242.0" + } + }, + { + "identity" : "sqlite.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stephencelis/SQLite.swift.git", + "state" : { + "revision" : "392dd6058624d9f6c5b4c769d165ddd8c7293394", + "version" : "0.15.4" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "ff86b924ead66f853b8baf91f3c41926a8f36177", + "version" : "1.21.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "3533f65d3e36dcdffc91ce34ef4d3c9c1887fd4b", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "a9d1d5ab8951ada40cafff8c8e2b551dfde4f390", + "version" : "5.0.0" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "cc504a45f6ce73ce6067837d7ac19fa67b229a56", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "bff4b6903cdc99dda49649dd52f46c11cfd3ed50", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb", + "version" : "1.15.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "41449336c8ecfadac6b4b5be75f9c3c306e61ced", + "version" : "1.35.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "0f3e54e29c944c2e835ad52159da7d9e1c94ac69", + "version" : "1.46.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", + "version" : "2.37.5" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle", + "state" : { + "revision" : "7f9326b0326ff86e3646295ea6e891f68c471c5e", + "version" : "2.12.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } + }, + { + "identity" : "swift-toolchain-sqlite", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-toolchain-sqlite", + "state" : { + "revision" : "b626d3002773b1a1304166643e7f118f724b2132", + "version" : "1.0.4" + } + } + ], + "version" : 3 +} diff --git a/swift/Package.swift b/swift/Package.swift new file mode 100644 index 0000000..d75ae1c --- /dev/null +++ b/swift/Package.swift @@ -0,0 +1,45 @@ +// swift-tools-version: 6.0 + +import PackageDescription +import Foundation + +// Absolute path of the lui checkout's Apple package. Override with +// JOURNAL_LUI_PACKAGE_PATH when the checkout lives elsewhere. +let luiPackagePath = + ProcessInfo.processInfo.environment["JOURNAL_LUI_PACKAGE_PATH"] + ?? ("../../lui/platform/apple" as NSString).standardizingPath + +// Colon-separated native objects/archives to link into the app binary: +// the OCaml complete object plus the compiled journal_lui_bridge.o. The build +// script (tool/build_journal_apple.sh) supplies them. +let nativeLinkInputs = ProcessInfo.processInfo.environment["JOURNAL_NATIVE_LINK_INPUTS"]? + .split(separator: ":") + .map(String.init) ?? [] +let nativeLinkerSettings: [LinkerSetting] = nativeLinkInputs.isEmpty ? [] : [ + .unsafeFlags(nativeLinkInputs, .when(platforms: [.iOS, .macOS])), +] + +let package = Package( + name: "JournalApp", + platforms: [.iOS("26.0"), .macOS("26.0")], + dependencies: [ + .package(path: luiPackagePath), + .package( + url: "https://github.com/aws-amplify/amplify-swift.git", + exact: "2.61.0" + ), + ], + targets: [ + .executableTarget( + name: "JournalApp", + dependencies: [ + .product(name: "LUIAppleBackendStatic", package: "apple"), + .product(name: "Amplify", package: "amplify-swift"), + .product(name: "AWSCognitoAuthPlugin", package: "amplify-swift"), + ], + path: ".", + exclude: ["Package.swift"], + linkerSettings: nativeLinkerSettings + ), + ] +) diff --git a/tool/build_journal_apple.sh b/tool/build_journal_apple.sh new file mode 100755 index 0000000..1b6f204 --- /dev/null +++ b/tool/build_journal_apple.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# Builds the journal Apple host (swift/Package.swift -> JournalApp) with the +# OCaml runtime linked in. +# +# Usage: tool/build_journal_apple.sh [--app-dir DIR] +# +# Native link inputs: +# * journal_lui_bridge.o is always compiled here from app/journal_lui_bridge.c +# against the OCaml headers of the target toolchain — as a compile check. +# It is NOT a link input: the dune-produced complete object already folds +# in the app's foreign_stubs copy, and the link stub below redefines all +# entries. Set JOURNAL_EXTRA_OBJECTS (colon-separated) to append extra +# objects if a producer ever emits an object without the bridge stubs. +# * The OCaml complete object (the `native_embed` product) is NOT built by +# this script — it is produced by the dune/opam side of the workspace. +# Point JOURNAL_OCAML_OBJECT at the artifact: +# macOS: dune builds app/native_embed.exe.o with the host switch. +# iOS simulator: the shared cross toolchain at +# ${LG_IOS_OCAML_PREFIX:-$OPAMROOT/lg-ocaml-toolchains/ocaml-/targets/arm64-apple-ios-simulator} +# must have produced a complete object for the target triple; pass its +# path through JOURNAL_OCAML_OBJECT. +# Without JOURNAL_OCAML_OBJECT the script links a stub object so the Swift +# side still verifies end-to-end (link only — the binary is not runnable). +# +# Reuses the lui mobile conventions: LG_IOS_OCAML_PREFIX / +# $OPAMROOT/lg-ocaml-toolchains (as in lui/tooling/mobile/build_components_*.sh). + +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +# Probe harnesses (tool/lui_probe_host.py) stage an overlaid copy of swift/ + a +# probe Info.plist under a temp host and point these overrides at it. +swift_dir=${JOURNAL_SWIFT_DIR:-$repo_root/swift} +info_plist=${JOURNAL_INFO_PLIST:-$repo_root/apple/Info.plist} +entitlements_dir=${JOURNAL_ENTITLEMENTS_DIR:-$repo_root/config/entitlements} +platform=${1:-macos} +app_dir_arg=${2:-} +ocaml_version=${LG_OCAML_VERSION:-5.5.0} +opam_root=${OPAMROOT:-$(opam var root --safe 2>/dev/null || echo "$HOME/.opam")} +shared_root=${LG_OCAML_TOOLCHAIN_ROOT:-$opam_root/lg-ocaml-toolchains} + +case "$platform" in + macos) + deployment_target=${JOURNAL_MACOS_DEPLOYMENT_TARGET:-26.0} + triple="arm64-apple-macosx${deployment_target}" + sdk_path=$(xcrun --sdk macosx --show-sdk-path) + clang=$(xcrun --sdk macosx --find clang) + # Host switch provides headers + runtime for the bridge C file. + ocaml_prefix=${JOURNAL_OCAML_PREFIX:-$(ocamlfind printconf destdir 2>/dev/null | sed 's|/lib$||' || true)} + [[ -n $ocaml_prefix ]] || ocaml_prefix="$opam_root/default" + ocaml_include="$ocaml_prefix/lib/ocaml" + ;; + ios-simulator) + deployment_target=${JOURNAL_IOS_DEPLOYMENT_TARGET:-26.0} + triple="arm64-apple-ios${deployment_target}-simulator" + sdk_path=$(xcrun --sdk iphonesimulator --show-sdk-path) + clang=$(xcrun --sdk iphonesimulator --find clang) + target_prefix=${LG_IOS_OCAML_PREFIX:-$shared_root/ocaml-$ocaml_version/targets/$triple} + [[ -d $target_prefix/lib/ocaml ]] || { + echo "error: shared iOS OCaml toolchain is missing: $target_prefix" >&2 + echo "set LG_IOS_OCAML_PREFIX or provision the toolchain" >&2 + exit 1 + } + ocaml_include="$target_prefix/lib/ocaml" + ;; + *) echo "usage: $0 " >&2; exit 2 ;; +esac + +build_dir="$repo_root/_build/apple/$platform" +mkdir -p "$build_dir" + +# --- journal_lui_bridge.o ------------------------------------------------- +"$clang" \ + -target "$triple" \ + -isysroot "$sdk_path" \ + -fPIC \ + -I "$ocaml_include" \ + -c "$repo_root/app/journal_lui_bridge.c" \ + -o "$build_dir/journal_lui_bridge.o" + +# --- OCaml complete object ------------------------------------------------ +ocaml_object=${JOURNAL_OCAML_OBJECT:-} +if [[ -z $ocaml_object ]]; then + echo "warning: JOURNAL_OCAML_OBJECT unset; linking a stub object (link-only build)" >&2 + cat > "$build_dir/ocaml_stub.c" <<'STUB' +// Link-validation stub matching app/journal_lui_bridge.c's exported entries. +// Replace via JOURNAL_OCAML_OBJECT for a runnable binary. +#include +typedef void (*patch_cb)(const char *); +typedef void (*wakeup_cb)(void); +typedef void (*platform_request_cb)(const char *, int32_t); +int32_t lui_ocaml_start(patch_cb cb, int32_t p, int32_t h, const char *d, int32_t l) + { (void)p; (void)h; (void)d; (void)l; if (cb) cb(""); return 1; } +int32_t lui_ocaml_stop(void) { return 1; } +int32_t lui_ocaml_appear(int64_t n) { (void)n; return 1; } +int32_t lui_ocaml_press(int64_t n) { (void)n; return 1; } +int32_t lui_ocaml_long_press(int64_t n) { (void)n; return 1; } +int32_t lui_ocaml_text_changed(int64_t n, const char *t) { (void)n; (void)t; return 1; } +int32_t lui_ocaml_submit(int64_t n) { (void)n; return 1; } +int32_t lui_ocaml_dismiss(int64_t n) { (void)n; return 1; } +int32_t lui_ocaml_double_press(int64_t n) { (void)n; return 1; } +int32_t lui_ocaml_toggle_changed(int64_t n, int32_t c) { (void)n; (void)c; return 1; } +int32_t lui_ocaml_radio_changed(int64_t n) { (void)n; return 1; } +int32_t lui_ocaml_slider_changed(int64_t n, double v) { (void)n; (void)v; return 1; } +int32_t journal_ocaml_extension_event(int64_t n, const char *name, const char *p) + { (void)n; (void)name; (void)p; return 1; } +int32_t journal_ocaml_pump(void) { return 1; } +void journal_ocaml_platform_event(const char *d, int32_t l) { (void)d; (void)l; } +void journal_ocaml_platform_response(const char *d, int32_t l) { (void)d; (void)l; } +void journal_ocaml_set_wakeup_callback(wakeup_cb cb) { (void)cb; } +void journal_ocaml_set_platform_request_callback(platform_request_cb cb) { (void)cb; } +STUB + "$clang" -target "$triple" -isysroot "$sdk_path" -fPIC \ + -c "$build_dir/ocaml_stub.c" -o "$build_dir/journal_complete_stub.o" + ocaml_object="$build_dir/journal_complete_stub.o" +fi + +# --- deterministic link-input staging (mirrors the lui script) ------------ +fingerprint=$(shasum -a 256 "$ocaml_object" "$build_dir/journal_lui_bridge.o" \ + | shasum -a 256 | cut -d ' ' -f 1) +link_dir="$build_dir/native-link-inputs/$fingerprint" +mkdir -p "$link_dir" +cp "$ocaml_object" "$link_dir/journal_complete.o" + +extra_inputs="" +if [[ -n ${JOURNAL_EXTRA_OBJECTS:-} ]]; then + extra_inputs=":${JOURNAL_EXTRA_OBJECTS}" +fi + +# --- SwiftPM -------------------------------------------------------------- +swift_args=( + build + --package-path "$swift_dir" + --product JournalApp +) +if [[ $platform == ios-simulator ]]; then + swift_args+=(--triple "$triple" --sdk "$sdk_path") +fi + +JOURNAL_LUI_PACKAGE_PATH=${JOURNAL_LUI_PACKAGE_PATH:-$repo_root/../lui/platform/apple} \ +JOURNAL_NATIVE_LINK_INPUTS="$link_dir/journal_complete.o$extra_inputs" \ +swift "${swift_args[@]}" + +# --- .app assembly --------------------------------------------------------- +product_dir="$swift_dir/.build/$triple/debug" +[[ -f $product_dir/JournalApp ]] || product_dir="$swift_dir/.build/debug" +app_dir=${app_dir_arg:-$build_dir/LogseqJournal.app} +rm -rf "$app_dir" +mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources" +if [[ $platform == macos ]]; then + cp "$info_plist" "$app_dir/Contents/Info.plist" + cp "$product_dir/JournalApp" "$app_dir/Contents/MacOS/JournalApp" + codesign --force --sign - --timestamp=none \ + --entitlements "$entitlements_dir/macos-debug-profile.entitlements" \ + "$app_dir" || true +else + cp "$info_plist" "$app_dir/Info.plist" + cp "$product_dir/JournalApp" "$app_dir/JournalApp" + codesign --force --sign - --timestamp=none \ + --entitlements "$entitlements_dir/ios-debug-profile.entitlements" \ + "$app_dir" || true +fi + +echo "$app_dir" diff --git a/tool/lui_probe_host.py b/tool/lui_probe_host.py new file mode 100644 index 0000000..a1fde2d --- /dev/null +++ b/tool/lui_probe_host.py @@ -0,0 +1,72 @@ +"""Stage and build isolated LUI Apple probe hosts (replaces `bonsai-swiftui init`). + +A probe host is a copy of the repo's `swift/` SwiftPM package with the probe's +`App.swift` (and a probe `Info.plist`) laid on top, then built through +`tool/build_journal_apple.sh` — the same LUI plumbing the app uses: +`JournalRuntimeHost` + `JournalRuntime` + `JournalApplicationPlatform` boot the +probe's OCaml complete object. + +The OCaml side is produced separately: build the workspace (`dune build`) and +create the complete object with `ocamlfind ocamlopt -linkpkg +-output-complete-obj` over the `app` archive plus the probe module (the probe +self-registers via `Journal_bridge.register`, so link it last so its +registration wins). Pass the result via `--native-object` / JOURNAL_OCAML_OBJECT; +without it the build links the stub object (link-only validation). +""" +import os +from pathlib import Path +import plistlib +import shutil +import subprocess + +ROOT = Path(__file__).resolve().parents[1] + + +def stage(host, app_swift, bundle_id, display_name, swift_names=None): + """Copy swift/ (+ Package.swift) into host, overlay the probe App.swift and + write a probe Info.plist. `swift_names` optionally restricts the copied + sources (always excluding App.swift, which app_swift replaces).""" + swift_dir = Path(host) / "swift" + swift_dir.mkdir(parents=True, exist_ok=True) + names = swift_names or [ + source.name for source in (ROOT / "swift").glob("*.swift") + if source.name != "App.swift" + ] + for name in names: + shutil.copy2(ROOT / "swift" / name, swift_dir / name) + shutil.copy2(ROOT / "swift" / "Package.swift", swift_dir / "Package.swift") + (swift_dir / "App.swift").write_text(app_swift) + (Path(host) / "apple").mkdir(exist_ok=True) + info = plistlib.loads((ROOT / "apple" / "Info.plist").read_bytes()) + info["CFBundleIdentifier"] = bundle_id + info["CFBundleName"] = display_name + info["CFBundleDisplayName"] = display_name + (Path(host) / "apple" / "Info.plist").write_bytes(plistlib.dumps(info)) + return swift_dir + + +def build(host, platform="macos", app_name="JournalApp.app", native_object=None, + extra_env=None): + """Build the staged host through tool/build_journal_apple.sh. Returns the + assembled .app path. `platform` is 'macos' or 'ios-simulator'.""" + host = Path(host) + env = dict( + os.environ, + JOURNAL_SWIFT_DIR=str(host / "swift"), + JOURNAL_INFO_PLIST=str(host / "apple" / "Info.plist"), + JOURNAL_LUI_PACKAGE_PATH=os.environ.get( + "JOURNAL_LUI_PACKAGE_PATH", + str((ROOT / "../lui/platform/apple").resolve())), + ) + env.update(extra_env or {}) + if native_object: + env["JOURNAL_OCAML_OBJECT"] = str(Path(native_object).resolve()) + app_dir = host / "apple" / app_name + result = subprocess.run( + ["bash", str(ROOT / "tool" / "build_journal_apple.sh"), platform, str(app_dir)], + cwd=ROOT, env=env, capture_output=True, text=True) + print(result.stdout, end="") + print(result.stderr, end="") + if result.returncode: + raise SystemExit(result.returncode) + return app_dir diff --git a/tool/test_macos_regressions.py b/tool/test_macos_regressions.py index 5849b25..78874bc 100644 --- a/tool/test_macos_regressions.py +++ b/tool/test_macos_regressions.py @@ -42,12 +42,16 @@ def main(): for line in dict.fromkeys(bootstrap.splitlines()) if not (line.startswith("#load ") and "/native_backend/" in line) ) + # The bonsai_swiftui_test support library is gone; the lui package carries + # the runtime API the registered cases use. The test/*.ml cases are owned + # by the OCaml-side migration — until they drop their Bonsai_* module + # references this script still fails on their compile errors. test_library = subprocess.run( - ["ocamlfind", "query", "bonsai_swiftui_test"], + ["ocamlfind", "query", "lui"], cwd=REPO, text=True, capture_output=True, check=True, ).stdout.strip() bootstrap += "\n#directory " + json.dumps(test_library) + ";;\n" - bootstrap += "#load " + json.dumps(str(Path(test_library) / "bonsai_swiftui_test.cma")) + ";;\n" + bootstrap += "#load " + json.dumps(str(Path(test_library) / "lui.cma")) + ";;\n" with tempfile.TemporaryDirectory(prefix="journal-macos-regressions-") as directory: for relative, sentinel in cases: entry = Path(directory) / "run.ml" diff --git a/tool/test_swiftui_amplify.py b/tool/test_swiftui_amplify.py index 67fbe11..905b36d 100644 --- a/tool/test_swiftui_amplify.py +++ b/tool/test_swiftui_amplify.py @@ -1,75 +1,66 @@ -"""Build the actual Amplify acceptance view through an installed-CLI host. +"""Build the actual Amplify acceptance view through an isolated LUI probe host. -The supplied disposable schema-4 host must already resolve exact Amplify 2.61.0. -Launch with the CLI and inspect the PASS/FAIL view with native UI tools. -No account lookup, token retrieval or sign-out is requested by the fixture. +Stages a disposable host (tool/lui_probe_host.py) with the Amplify swift +sources, apple-tests/amplify/JournalAmplifyAcceptance.swift as the visible +probe view, and apple-tests/amplify/hub_fixture.ml as the embedded OCaml app +(a Lui_app static view self-registered via Journal_bridge.register). Synthetic +empty entitlements keep the probe off the production Keychain access group. +Launch the assembled .app binary and inspect the PASS/FAIL view with native UI +tools. No account lookup, token retrieval or sign-out is requested. """ import argparse import hashlib import json import plistlib from pathlib import Path -import re import shutil -import subprocess +import sys import tempfile +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import lui_probe_host + parser = argparse.ArgumentParser(description=__doc__) -parser.add_argument('--host', required=True, type=Path) -parser.add_argument('--platform', choices=['macos', 'ios'], default='macos') +parser.add_argument('--host', type=Path, + help='Disposable host directory (defaults to a fresh tempdir)') +parser.add_argument('--platform', choices=['macos', 'ios-simulator'], default='macos') +parser.add_argument('--native-object', type=Path, + help='Complete OCaml object embedding hub_fixture.ml') args = parser.parse_args() -root = Path(__file__).resolve().parents[1] -host = args.host.resolve() +root = lui_probe_host.ROOT +host = (args.host or Path(tempfile.mkdtemp(prefix='journal-amplify-probe-'))).resolve() if not host.is_relative_to(Path(tempfile.gettempdir()).resolve()): raise SystemExit('A disposable host is required') -config = host/'bonsai-swiftui.sexp' -value = config.read_text() -if '(name journal_gate)' not in value or '(exact 2.61.0)' not in value: - raise SystemExit('Expected the schema-4 Amplify package host') -value = re.sub(r'\(bundle_identifier [^)]+\)', - '(bundle_identifier org.logseq.journal.amplify-probe)', value) -config.write_text(value) -for name in ['macos-debug-profile.entitlements', 'macos-release.entitlements']: - # Synthetic identity: the probe needs no production Keychain access group. - (host/'config/entitlements'/name).write_bytes(plistlib.dumps({})) result = {'host': str(host), 'inputs': {}, 'commands': []} -sources = [root/'swift'/name for name in [ - 'JournalPlatformWire.swift', 'JournalPlatformEvents.swift', 'JournalPlatformServices.swift', 'JournalAmplifySession.swift', - 'JournalApplicationPlatform.swift', 'JournalNativeServices.swift', - 'JournalAuthentication.swift', 'JournalAmplifyAuthentication.swift', 'JournalAuthenticationView.swift', - 'JournalLocalAccountBindingStore.swift', 'JournalStartupConfiguration.swift', - 'JournalE2EECrypto.swift']] -for source in sources + [root/'apple-tests/amplify/JournalAmplifyAcceptance.swift']: + +sources = sorted((root/'swift').glob('*.swift')) + [ + root/'apple-tests/amplify/JournalAmplifyAcceptance.swift'] +for source in sources: result['inputs'][str(source.relative_to(root))] = hashlib.sha256(source.read_bytes()).hexdigest() - shutil.copy2(source, host/'swift'/source.name) -for name in ['JournalPlatformWire.swift', 'JournalPlatformServices.swift', - 'JournalAmplifySession.swift', 'JournalAmplifySessionTests.swift']: - (host/'apple-tests'/name).unlink(missing_ok=True) fixture = root/'apple-tests/amplify/hub_fixture.ml' result['inputs'][str(fixture.relative_to(root))] = hashlib.sha256(fixture.read_bytes()).hexdigest() -shutil.copy2(fixture, host/'app/application.ml') -(host/'swift/App.swift').write_text('''import SwiftUI + +lui_probe_host.stage(host, app_swift='''import SwiftUI @main struct ApplicationHost: App { var body: some Scene { WindowGroup { JournalAmplifyAcceptance().frame(minWidth: 650, minHeight: 180) } } } -''') -build = ['bonsai-swiftui', 'build', args.platform, '--profile', - 'debug' if args.platform == 'macos' else 'release'] -if args.platform == 'ios': - build.append('--no-codesign') -for command in [ - ['bonsai-swiftui', 'init', '--adopt'], - ['bonsai-swiftui', 'sync-host', '--check'], - build, -]: - log = host/('amplify-test-%d.log' % len(result['commands'])) - with log.open('w') as output: - process = subprocess.run(command, cwd=host, stdout=output, stderr=subprocess.STDOUT) - result['commands'].append({'command': command, 'exit_code': process.returncode, - 'log': str(log), 'log_sha256': hashlib.sha256(log.read_bytes()).hexdigest()}) - (host/'amplify-test-results.json').write_text(json.dumps(result, indent=2)+'\n') - print(json.dumps(result['commands'][-1]), flush=True) - if process.returncode: - raise SystemExit(process.returncode) +''', bundle_id='org.logseq.journal.amplify-probe', display_name='Logseq Journal') +shutil.copy2(root/'apple-tests/amplify/JournalAmplifyAcceptance.swift', + host/'swift'/'JournalAmplifyAcceptance.swift') + +# Synthetic identity: the probe needs no production Keychain access group. +entitlements = host/'entitlements' +entitlements.mkdir(exist_ok=True) +for name in ['macos-debug-profile.entitlements', 'ios-debug-profile.entitlements', + 'macos-release.entitlements']: + (entitlements/name).write_bytes(plistlib.dumps({})) + +app = lui_probe_host.build(host, platform=args.platform, + app_name='JournalAmplifyProbe.app', + native_object=args.native_object, + extra_env={'JOURNAL_ENTITLEMENTS_DIR': str(entitlements)}) +result['commands'].append({'app': str(app)}) +(host/'amplify-test-results.json').write_text(json.dumps(result, indent=2)+'\n') +print(json.dumps(result['commands'][-1]), flush=True) diff --git a/tool/test_swiftui_editor.py b/tool/test_swiftui_editor.py index 94d8916..97effce 100644 --- a/tool/test_swiftui_editor.py +++ b/tool/test_swiftui_editor.py @@ -1,52 +1,69 @@ """Build an isolated native composer input probe using only public framework APIs. -Launch the generated CLI host with JOURNAL_PROBE_REBIND=1 (Journal-style handler -updates) or 0 (stable-handler control). Open Capture, type the alphabet rapidly, -and compare native editor text with the Observed label. No graph or auth is used. +Stages an LUI probe host (tool/lui_probe_host.py) embedding +apple-tests/editor/composer_probe.ml — a Lui_app signal+update probe that +self-registers via Journal_bridge.register. Launch the assembled .app binary. +Type the alphabet rapidly into the Capture field and compare native editor +text with the Observed label. No graph or auth is used. + +The previous bonsai fixture's JOURNAL_PROBE_REBIND handler-rebind comparison +has no lui counterpart (Expandable_message_composer is gone); the probe +covers the same input -> state -> observed flow. + +The probe's OCaml complete object is produced by the workspace build (see +tool/lui_probe_host.py): pass it via --native-object, otherwise the host links +the stub object and only verifies the Swift side. """ +import argparse import hashlib import json from pathlib import Path -import shutil -import subprocess +import sys import tempfile -root = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import lui_probe_host + +root = lui_probe_host.ROOT +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--native-object', type=Path, + help='Complete OCaml object embedding the probe module') +arguments = parser.parse_args() + host = Path(tempfile.mkdtemp(prefix='journal-editor-probe-')).resolve() print(host, flush=True) report = {'host': str(host), 'commands': [], 'inputs': {}} - -def run(command): - log = host / ('command-%d.log' % len(report['commands'])) - with log.open('w') as output: - result = subprocess.run(command, cwd=host, stdout=output, stderr=subprocess.STDOUT) - report['commands'].append({'command': command, 'exitCode': result.returncode, 'log': str(log)}) - (host/'results.json').write_text(json.dumps(report, indent=2)+'\n') - if result.returncode: - raise SystemExit(log.read_text()[-8000:]) - - -run(['bonsai-swiftui', 'init', '--name', 'journal_editor_probe', - '--macos-bundle-identifier', 'org.logseq.journal.editor-probe', - '--ios-bundle-identifier', 'org.logseq.journal.editor-probe']) source = root/'apple-tests/editor/composer_probe.ml' -shutil.copy2(source, host/'app/application.ml') report['inputs']['apple-tests/editor/composer_probe.ml'] = hashlib.sha256(source.read_bytes()).hexdigest() -shutil.copyfile(root/'logseq_journal.opam.locked', host/'journal_editor_probe.opam.locked') -(host/'swift/App.swift').write_text('''import BonsaiSwiftUI + +lui_probe_host.stage(host, app_swift='''import LUIAppleBackend import SwiftUI +@MainActor private final class ProbeAuth: JournalAuthCapability { + func currentUserID() async throws -> String? { nil } + func freshIDToken() async throws -> String { throw CancellationError() } + func signOut() async throws { throw CancellationError() } +} + @main struct ComposerProbe: App { var body: some Scene { Window("Composer input probe", id: "probe") { - BonsaiApplicationView(entrypoint: "journal_editor_probe") + JournalRuntimeHost( + platform: JournalApplicationPlatform(services: JournalPlatformServices( + auth: ProbeAuth(), + account: JournalAccountStore(load: { nil }, save: { _ in }, clear: {}), + managedSyncOrigin: "https://example.invalid")), + payload: (try? JournalNativeServices.startupPayload()) ?? Data(), + extensions: (try? JournalExtensions.registry()) ?? LUIAppleExtensionRegistry()) .frame(minWidth: 480, minHeight: 300) } } } -''') -run(['bonsai-swiftui', 'init', '--adopt']) -run(['bonsai-swiftui', 'sync-host', '--check']) -run(['bonsai-swiftui', 'build', 'macos', '--profile', 'debug']) -print('Run from', host, ': JOURNAL_PROBE_REBIND=1 bonsai-swiftui run macos --profile debug', flush=True) +''', bundle_id='org.logseq.journal.editor-probe', display_name='Journal Editor Probe') + +app = lui_probe_host.build(host, platform='macos', app_name='JournalEditorProbe.app', + native_object=arguments.native_object) +report['commands'].append({'app': str(app)}) +(host/'results.json').write_text(json.dumps(report, indent=2)+'\n') +print('Run from', host, ':', app/'Contents/MacOS/JournalApp', flush=True) diff --git a/tool/test_swiftui_outline.py b/tool/test_swiftui_outline.py index 9fd5c45..bf18da5 100644 --- a/tool/test_swiftui_outline.py +++ b/tool/test_swiftui_outline.py @@ -1,45 +1,65 @@ -"""Build a public Native_list disclosure probe with observable, non-destructive events.""" +"""Build a public journal-list disclosure probe with observable, non-destructive events. + +Stages an LUI probe host (tool/lui_probe_host.py) embedding +apple-tests/native-outline/outline_probe.ml — a Lui_app signal+update probe +mounting the `journal-list` extension (Journal_lui_native.list) with +disclosure rows; expand + row events are decoded back through the extension +event contract. + +The probe's OCaml complete object is produced by the workspace build (see +tool/lui_probe_host.py): pass it via --native-object, otherwise the host links +the stub object and only verifies the Swift side. +""" +import argparse import hashlib import json from pathlib import Path -import shutil -import subprocess +import sys import tempfile -root = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import lui_probe_host + +root = lui_probe_host.ROOT +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--native-object', type=Path, + help='Complete OCaml object embedding the probe module') +arguments = parser.parse_args() + host = Path(tempfile.mkdtemp(prefix="journal-outline-probe-")).resolve() print(host, flush=True) record = {"host": str(host), "commands": [], "inputs": {}} -def run(command): - log = host / f"command-{len(record['commands'])}.log" - with log.open("w") as output: - result = subprocess.run(command, cwd=host, stdout=output, stderr=subprocess.STDOUT) - record["commands"].append({"command": command, "exitCode": result.returncode, "log": str(log)}) - (host / "results.json").write_text(json.dumps(record, indent=2) + "\n") - if result.returncode: - raise SystemExit(log.read_text()[-8000:]) - -run(["bonsai-swiftui", "init", "--name", "journal_outline_probe", - "--macos-bundle-identifier", "org.logseq.journal.outline-probe", - "--ios-bundle-identifier", "org.logseq.journal.outline-probe"]) -for source, target in [("apple-tests/native-outline/outline_probe.ml", "app/application.ml")]: - shutil.copyfile(root/source, host/target) - record["inputs"][source] = hashlib.sha256((root/source).read_bytes()).hexdigest() -shutil.copyfile(root/"logseq_journal.opam.locked", host/"journal_outline_probe.opam.locked") -(host/"swift/App.swift").write_text('''import BonsaiSwiftUI +source = root/"apple-tests/native-outline/outline_probe.ml" +record["inputs"]["apple-tests/native-outline/outline_probe.ml"] = hashlib.sha256(source.read_bytes()).hexdigest() + +lui_probe_host.stage(host, app_swift='''import LUIAppleBackend import SwiftUI +@MainActor private final class ProbeAuth: JournalAuthCapability { + func currentUserID() async throws -> String? { nil } + func freshIDToken() async throws -> String { throw CancellationError() } + func signOut() async throws { throw CancellationError() } +} + @main struct OutlineProbe: App { var body: some Scene { Window("Outline action probe", id: "probe") { - BonsaiApplicationView(entrypoint: "journal_outline_probe") + JournalRuntimeHost( + platform: JournalApplicationPlatform(services: JournalPlatformServices( + auth: ProbeAuth(), + account: JournalAccountStore(load: { nil }, save: { _ in }, clear: {}), + managedSyncOrigin: "https://example.invalid")), + payload: (try? JournalNativeServices.startupPayload()) ?? Data(), + extensions: (try? JournalExtensions.registry()) ?? LUIAppleExtensionRegistry()) .frame(minWidth: 480, minHeight: 320) } } } -''') -run(["bonsai-swiftui", "init", "--adopt"]) -run(["bonsai-swiftui", "sync-host", "--check"]) -run(["bonsai-swiftui", "build", "macos", "--profile", "debug"]) -print("Open", host/"apple/DerivedData/Build/Products/Debug/BonsaiJournalOutlineProbe.app", flush=True) +''', bundle_id='org.logseq.journal.outline-probe', display_name='Journal Outline Probe') + +app = lui_probe_host.build(host, platform="macos", app_name="JournalOutlineProbe.app", + native_object=arguments.native_object) +record["commands"].append({"app": str(app)}) +(host/"results.json").write_text(json.dumps(record, indent=2) + "\n") +print("Open", app, flush=True) diff --git a/tool/test_swiftui_platform.py b/tool/test_swiftui_platform.py index 4204b78..dfb090e 100644 --- a/tool/test_swiftui_platform.py +++ b/tool/test_swiftui_platform.py @@ -10,12 +10,14 @@ ROOT = Path(__file__).resolve().parents[1] PACKAGES = ','.join([ - 'bonsai_swiftui.spec_impl', 'bonsai_swiftui.ui', 'bonsai_swiftui.driver', + 'lui', 'logseq_db_worker.lui', 'digestif.c', 'mirage-ptime.unix', 'logseq_overlay_db.impl', 'logseq_sync.pure_reducer.impl', 'logseq_sync.effect_runner.impl', 'logseq_db_worker.pure_reducer.impl', 'logseq_db_worker.effect_runner.impl', - 'logseq_db_worker', 'logseq_sync.effect_runner', 'melange-transit-native', - 'mtime.clock.os', 'unix', 'uri', 'yojson', + 'logseq_db_worker', 'logseq_db_worker.contract', 'logseq_db_types', + 'logseq_sync.effect_runner', 'melange-transit-native', + 'eio', 'eio.core', 'eio.unix', 'eio_posix', + 'mtime.clock.os', 'threads.posix', 'unix', 'uri', 'yojson', ]) @@ -29,18 +31,16 @@ def run(directory, command): with tempfile.TemporaryDirectory(prefix='journal-platform-wire-') as directory: destination = Path(directory) + # journal_platform resolves its graph service through the installed + # logseq_db_worker.lui package (Logseq_db_worker_lui.*) — only the public + # codec files themselves are copied and compiled fresh. sources = [ROOT / 'app' / ('journal_validation' + suffix) for suffix in ['.mli', '.ml']] - sources += [ROOT / 'logseq_db_worker/bonsai' / ('logseq_db_worker_bonsai_service' + suffix) - for suffix in ['.mli', '.ml']] sources += [ROOT / 'app' / ('journal_platform' + suffix) for suffix in ['.mli', '.ml']] sources += [ROOT / 'app' / ('journal_startup' + suffix) for suffix in ['.mli', '.ml']] sources += [ROOT / 'apple-tests/platform-wire/journal_platform_wire_test.ml'] compiler = ['ocamlfind', 'ocamlopt', '-thread', '-package', PACKAGES] for source in sources: - adapted = source.read_text().replace( - 'Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service', - 'Logseq_db_worker_bonsai_service') - (destination / source.name).write_text(adapted) + (destination / source.name).write_text(source.read_text()) run(destination, [*compiler, '-c', source.name]) run(destination, [*compiler, '-linkpkg', '-o', 'wire_ocaml', *[source.stem + '.cmx' for source in sources if source.suffix == '.ml']]) diff --git a/tool/test_swiftui_warm_start.py b/tool/test_swiftui_warm_start.py index e89b608..fee7c51 100644 --- a/tool/test_swiftui_warm_start.py +++ b/tool/test_swiftui_warm_start.py @@ -1,7 +1,10 @@ """Build a native encrypted warm-start acceptance host with the real Journal object. -The fixture uses generated disposable data, memory-only E2EE secrets and blocked -network authentication. Run the printed CLI commands and inspect actual native +Stages an LUI probe host (tool/lui_probe_host.py) with +apple-tests/warm-start/JournalWarmStartAcceptance.swift as the @main app, +linked against the production native_embed complete object. The fixture uses +generated disposable data, memory-only E2EE secrets and blocked network +authentication. Run the printed CLI commands and inspect actual native Timeline/recovery content and the adjacent JSONL observations before accepting. """ import argparse @@ -11,16 +14,19 @@ from pathlib import Path import shutil import subprocess +import sys import tempfile -root = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import lui_probe_host + +root = lui_probe_host.ROOT parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--rows', type=int, default=500, help='Number of fixture journal roots (1–5000)') parser.add_argument('--children', type=int, default=35, help='Children under the first root (0–5000)') parser.add_argument('--graphs', type=int, default=1, help='Independent encrypted fixture graphs (1–4)') parser.add_argument('--history-days', type=int, default=0, help='Additional journal days with 12 rows each (0–30)') -parser.add_argument('--platform', choices=['macos', 'ios'], default='macos') -parser.add_argument('--development-team', help='Existing development team for the isolated iPhone host') +parser.add_argument('--platform', choices=['macos', 'ios-simulator'], default='macos') parser.add_argument('--native-object', type=Path, help='Use the current verified production object') arguments = parser.parse_args() if not 1 <= arguments.rows <= 5000: @@ -31,10 +37,6 @@ parser.error('--graphs must be between 1 and 4') if not 0 <= arguments.history_days <= 30: parser.error('--history-days must be between 0 and 30') -if arguments.platform == 'ios' and not arguments.development_team: - parser.error('--development-team is required for the iPhone host') -profile = 'release' if arguments.platform == 'ios' else 'debug' -native_target = 'iphoneos' if arguments.platform == 'ios' else 'macos' host = Path(tempfile.mkdtemp(prefix='journal-native-warm-start-')).resolve() print(host, flush=True) record = {'host': str(host), 'commands': [], 'inputs': {}} @@ -55,55 +57,59 @@ def run(command, cwd=host): return log.read_text() -if arguments.native_object is None: - run(['bonsai-swiftui', 'build-native', '--target', native_target, '--profile', profile], cwd=root) +# The OCaml side ships the production complete object: app/native_embed.exe.o. +# Build it first with the workspace dune build (see tool/build_journal_apple.sh); +# pass a prebuilt artifact via --native-object. +artifact = arguments.native_object.resolve() if arguments.native_object else ( + root/'_build/default/app/native_embed.exe.o') +if not artifact.is_file(): + raise SystemExit('Build the current Journal native object first: ' + 'dune build app/native_embed.exe (native object mode), ' + 'or pass --native-object') +record['nativeObject'] = {'path': str(artifact), 'sha256': hashlib.sha256(artifact.read_bytes()).hexdigest()} + bootstrap = "\n".join(subprocess.run(['dune', 'ocaml', 'top', target], cwd=root, capture_output=True, text=True, check=True).stdout for target in ['app', 'logseq_overlay_db/test']) bootstrap = "\n".join(line for line in dict.fromkeys(bootstrap.splitlines()) if not (line.startswith('#load ') and '/native_backend/' in line)) generator = host/'generate.ml' generator.write_text(bootstrap+'\n#use '+json.dumps(str(root/'apple-tests/warm-start/generate_fixture.ml'))+';;\n') -run(['bonsai-swiftui', 'init', '--name', 'journal_warm_start', - '--macos-bundle-identifier', 'org.logseq.journal.warm-start-probe', - '--ios-bundle-identifier', 'org.logseq.journal.warm-start-probe', - '--ios-deployment-target', '26.0']) -config = host/'bonsai-swiftui.sexp' -config.write_text(config.read_text().replace('(features)', '(features network sqlite)')) -shutil.copyfile(root/'logseq_journal.opam.locked', host/'journal_warm_start.opam.locked') -for name in ['JournalE2EECrypto.swift', 'JournalPlatformWire.swift', 'JournalPlatformServices.swift', - 'JournalStartupConfiguration.swift', 'JournalChrome.swift']: - source = root/'swift'/name - shutil.copy2(source, host/'swift'/name) - record['inputs'][str(source.relative_to(root))] = hashlib.sha256(source.read_bytes()).hexdigest() + +sources = [root/'swift'/name for name in + ['JournalE2EECrypto.swift', 'JournalPlatformWire.swift', 'JournalPlatformServices.swift', + 'JournalStartupConfiguration.swift', 'JournalChrome.swift']] source = root/'apple-tests/warm-start/JournalWarmStartAcceptance.swift' -shutil.copy2(source, host/'swift/App.swift') -record['inputs'][str(source.relative_to(root))] = hashlib.sha256(source.read_bytes()).hexdigest() +for tracked in sources + [source]: + record['inputs'][str(tracked.relative_to(root))] = hashlib.sha256(tracked.read_bytes()).hexdigest() + +lui_probe_host.stage(host, app_swift=source.read_text(), + bundle_id='org.logseq.journal.warm-start-probe', + display_name='Logseq Journal') + for case in ['valid', 'missing']: support = host/('support-'+case) support.mkdir() fixture = run(['ocaml', '-noinit', '-noprompt', generator, support, str(arguments.rows), str(arguments.children), str(arguments.graphs), str(arguments.history_days)], cwd=root) (host/(case+'.json')).write_text(json.dumps(json.loads(fixture), indent=2)+'\n') -artifact = arguments.native_object.resolve() if arguments.native_object else ( - root/'_build/bonsai-swiftui/artifacts'/ - ('ios/iphoneos/arm64/release' if arguments.platform == 'ios' else 'macos/arm64/debug')/'native_embed.exe.o') -if not artifact.is_file(): - raise SystemExit('Build the current Journal native object for the selected platform first') -record['nativeObject'] = {'path': str(artifact), 'sha256': hashlib.sha256(artifact.read_bytes()).hexdigest()} -run(['bonsai-swiftui', 'init', '--adopt']) -run(['bonsai-swiftui', 'sync-host', '--check']) -command = ['bonsai-swiftui', 'build', arguments.platform, '--profile', profile, '--native-object', artifact] -if arguments.development_team: - command += ['--development-team', arguments.development_team] -run(command) -if arguments.platform == 'ios': - print('Install', host/'apple/DerivedData/Build/Products/Release-iphoneos/BonsaiJournalWarmStart.app', flush=True) + +# XCODE_XCCONFIG_FILE enables DEBUG inside the probe build (the test host's +# memory-only secret stores are gated on it). +app = lui_probe_host.build(host, platform=arguments.platform, + app_name='JournalWarmStartProbe.app', + native_object=artifact, + extra_env={'XCODE_XCCONFIG_FILE': str(debug_config)}) + +if arguments.platform == 'ios-simulator': + print('Install', app, 'in the org.logseq.journal.warm-start-probe container.', flush=True) print('Copy valid.json and support-valid into this test app Documents directory.', flush=True) print('Launch with both memory-only secret environment variables and arguments:', '--fixture valid.json --support-root support-valid', flush=True) raise SystemExit(0) + for case in ['valid', 'missing']: - print('Run from', host, ':', 'XCODE_XCCONFIG_FILE='+str(debug_config), + print('Run from', host, ':', 'LOGSEQ_JOURNAL_E2EE_TEST_PRIVATE_KEY_STORAGE=memory', 'LOGSEQ_JOURNAL_E2EE_TEST_WRAPPED_KEY_STORAGE=memory', - 'bonsai-swiftui run macos --profile debug --native-object', - artifact, '-- --fixture', host/(case+'.json'), '--missing-key' if case == 'missing' else '', flush=True) + app/'Contents/MacOS/JournalApp', + '--fixture', host/(case+'.json'), '--support-root', host/('support-'+case), + '--missing-key' if case == 'missing' else '', flush=True) From ba9224a9bd93d40e426f8dae5b331e12aa6577b3 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 19:53:55 -0700 Subject: [PATCH 13/40] Drop stale bonsai cases from the macos regression + list harnesses The upstream app-test port removed macos_application_dispatch_test.ml and the bonsai_swiftui_test support library; the remaining mutation cases now load the lui package and use the Journal_view/Journal_ids shims. Also removes swift-packages/Package.resolved, superseded by swift/Package.swift. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- swift-packages/Package.resolved | 276 -------------------------------- tool/test_macos_regressions.py | 6 +- tool/test_swiftui_list.py | 2 +- 3 files changed, 3 insertions(+), 281 deletions(-) delete mode 100644 swift-packages/Package.resolved diff --git a/swift-packages/Package.resolved b/swift-packages/Package.resolved deleted file mode 100644 index fda852c..0000000 --- a/swift-packages/Package.resolved +++ /dev/null @@ -1,276 +0,0 @@ -{ - "originHash" : "77d50a6650381af625b978c813365bcb03068fdba144e10e5653af5c7b3792d1", - "pins" : [ - { - "identity" : "amplify-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/aws-amplify/amplify-swift.git", - "state" : { - "revision" : "ce8d9e69df07e80c57e2ca68c01360ea6636abf5", - "version" : "2.61.0" - } - }, - { - "identity" : "amplify-swift-utils-notifications", - "kind" : "remoteSourceControl", - "location" : "https://github.com/aws-amplify/amplify-swift-utils-notifications.git", - "state" : { - "revision" : "959eec669ba97c7d923b963c3e66ca8a0b2737f6", - "version" : "1.1.1" - } - }, - { - "identity" : "async-http-client", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/async-http-client.git", - "state" : { - "revision" : "f95c908967e98c68c5ce3fd61a7974e7e869e303", - "version" : "1.36.1" - } - }, - { - "identity" : "aws-crt-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/awslabs/aws-crt-swift", - "state" : { - "revision" : "d1678252cb3da2d34f70db43e402927360df4d8c", - "version" : "0.64.1" - } - }, - { - "identity" : "aws-sdk-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/awslabs/aws-sdk-swift", - "state" : { - "revision" : "61b8c1968aeae4d42226e3b452ab864fd0b96523", - "version" : "1.7.60" - } - }, - { - "identity" : "smithy-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/smithy-lang/smithy-swift", - "state" : { - "revision" : "31336dfea2e448523630ea1fe4d83ddc1156de3c", - "version" : "0.242.0" - } - }, - { - "identity" : "sqlite.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/stephencelis/SQLite.swift.git", - "state" : { - "revision" : "392dd6058624d9f6c5b4c769d165ddd8c7293394", - "version" : "0.15.4" - } - }, - { - "identity" : "swift-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-algorithms.git", - "state" : { - "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", - "version" : "1.2.1" - } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser.git", - "state" : { - "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", - "version" : "1.8.2" - } - }, - { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "d9a5b37470adc940d22c3bcd5ca6953a516b727f", - "version" : "1.7.2" - } - }, - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms.git", - "state" : { - "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", - "version" : "1.1.5" - } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", - "version" : "1.3.1" - } - }, - { - "identity" : "swift-certificates", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-certificates.git", - "state" : { - "revision" : "c8aece90ea05f9866bd392a5bf13b5cae56c0e03", - "version" : "1.20.0" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections", - "state" : { - "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", - "version" : "1.6.0" - } - }, - { - "identity" : "swift-configuration", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-configuration.git", - "state" : { - "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "da9d28d69ebe3894b18376c8f2395c2f37b8448f", - "version" : "4.5.2" - } - }, - { - "identity" : "swift-distributed-tracing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-distributed-tracing.git", - "state" : { - "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", - "version" : "1.4.1" - } - }, - { - "identity" : "swift-http-structured-headers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-structured-headers.git", - "state" : { - "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", - "version" : "1.7.0" - } - }, - { - "identity" : "swift-http-types", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-types.git", - "state" : { - "revision" : "bff4b6903cdc99dda49649dd52f46c11cfd3ed50", - "version" : "1.8.0" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb", - "version" : "1.15.1" - } - }, - { - "identity" : "swift-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio.git", - "state" : { - "revision" : "a931f2c1de8dd49381ce3bf2e279d033f68d8865", - "version" : "2.102.0" - } - }, - { - "identity" : "swift-nio-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-extras.git", - "state" : { - "revision" : "41449336c8ecfadac6b4b5be75f9c3c306e61ced", - "version" : "1.35.1" - } - }, - { - "identity" : "swift-nio-http2", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-http2.git", - "state" : { - "revision" : "0f3e54e29c944c2e835ad52159da7d9e1c94ac69", - "version" : "1.46.0" - } - }, - { - "identity" : "swift-nio-ssl", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssl.git", - "state" : { - "revision" : "03827c1a9fdb2b6b00a4e93ede8861520263af8c", - "version" : "2.37.4" - } - }, - { - "identity" : "swift-nio-transport-services", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-transport-services.git", - "state" : { - "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", - "version" : "1.28.0" - } - }, - { - "identity" : "swift-numerics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics.git", - "state" : { - "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-service-context", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-service-context.git", - "state" : { - "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-service-lifecycle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-service-lifecycle", - "state" : { - "revision" : "7f9326b0326ff86e3646295ea6e891f68c471c5e", - "version" : "2.12.0" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system", - "state" : { - "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", - "version" : "1.8.1" - } - }, - { - "identity" : "swift-toolchain-sqlite", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-toolchain-sqlite", - "state" : { - "revision" : "b626d3002773b1a1304166643e7f118f724b2132", - "version" : "1.0.4" - } - } - ], - "version" : 3 -} diff --git a/tool/test_macos_regressions.py b/tool/test_macos_regressions.py index 78874bc..3494f21 100644 --- a/tool/test_macos_regressions.py +++ b/tool/test_macos_regressions.py @@ -14,7 +14,6 @@ "test/macos_mutation_input_diagnostics_pure_reducer_test.ml", "MACOS_PURE_REDUCER_TESTS_PASSED", ), - ("test/macos_application_dispatch_test.ml", "MACOS_APPLICATION_DISPATCH_TESTS_PASSED"), ("test/macos_mutation_runtime_test.ml", "MACOS_MUTATION_RUNTIME_TESTS_PASSED"), ] @@ -43,9 +42,8 @@ def main(): if not (line.startswith("#load ") and "/native_backend/" in line) ) # The bonsai_swiftui_test support library is gone; the lui package carries - # the runtime API the registered cases use. The test/*.ml cases are owned - # by the OCaml-side migration — until they drop their Bonsai_* module - # references this script still fails on their compile errors. + # the runtime API the registered cases use through the Journal_view / + # Journal_ids shims. test_library = subprocess.run( ["ocamlfind", "query", "lui"], cwd=REPO, text=True, capture_output=True, check=True, diff --git a/tool/test_swiftui_list.py b/tool/test_swiftui_list.py index 9c94147..a0d6efd 100644 --- a/tool/test_swiftui_list.py +++ b/tool/test_swiftui_list.py @@ -10,6 +10,6 @@ for command in [ ['dune', 'exec', 'test/journal_semantics_test.exe'], ['dune', 'exec', 'test/journal_timeline_state_test.exe'], - ['python3', 'tool/test_macos_regressions.py', '--case', 'application_dispatch'], + ['python3', 'tool/test_macos_regressions.py', '--case', 'mutation'], ]: subprocess.run(command, cwd=root, check=True) From 9c495910ff2b335df419280869ff841bc9ae29fb Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 19:56:07 -0700 Subject: [PATCH 14/40] Port app/application.ml from Bonsai to the Lui_app reducer architecture - Replace Bonsai.Cont/component with a Lui_app.reducer_app: state record + action variant (Update | Platform_response | Environment_changed), update reducer, and a view built via Lui_elements.dyn. - Shim Bonsai.Effect as unit -> 'a thunks; send/set_state/set_state_and_effect route through Lui_app.send with a pending_actions queue because ocaml-signal updates are not reentrant. - Replicate Edge.on_change edges (feed, timeline presentation, drains, upload/media, notice, delete timer, sync-error timer) as post-update key diffs; Clock.until/every become generation-guarded timer threads that enqueue through Journal_pump. - Platform.request continuations become a pending_platform table keyed by LJP2 response tag; platform_event hook decodes tag-24 environment snapshots into Environment_changed. - native_hooks : Journal_bridge.hooks wires init/dispatch/extension_event/ pump/platform_event/platform_response/dispose/root_node; config arrives via init payload (unchanged C ABI). - Add Journal_view.mount accessor so the reducer view can return Lui_elements.t. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/application.ml | 4103 ++++++++++++++++++++++-------------------- app/application.mli | 26 +- app/journal_view.ml | 1 + app/journal_view.mli | 2 + 4 files changed, 2126 insertions(+), 2006 deletions(-) diff --git a/app/application.ml b/app/application.ml index adc76b3..ef5ae00 100644 --- a/app/application.ml +++ b/app/application.ml @@ -1,8 +1,22 @@ -module ID = Bonsai_swiftui_spec.Id -module Platform = Bonsai_swiftui.Application_platform -module Ui = Bonsai_swiftui_ui +module ID = Journal_ids +module Ui = Journal_view module V = Ui.View -module Graph_service = Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service +module Graph_service = Logseq_db_worker_lui.Logseq_db_worker_lui_service +module Worker = Logseq_db_worker_lui.Journal_worker +module Journal_worker_runtime = Logseq_db_worker_lui.Journal_worker_runtime +module Journal_worker_ids = Logseq_db_worker_lui.Journal_worker_ids + +(* Shim replacing [Bonsai.Effect]: an effect is just a thunk scheduled by + the reducer plumbing. Effects run inline on the app thread. *) +module Effect = struct + type 'a t = unit -> 'a + + let ignore : unit t = fun () -> () + let of_thunk f = f + let bind (t : 'a t) ~f : 'b t = fun () -> f (t ()) () + let many (ts : unit t list) : unit t = fun () -> List.iter (fun t -> t ()) ts + let run (t : unit t) = t () +end module Admission_refresh = struct type observation = @@ -249,6 +263,7 @@ type state = ; e2ee_password : Journal_capture.t ; modal : modal ; confirmation_sequence : int64 + ; environment : Journal_environment.snapshot } let favorites_event state event = @@ -308,6 +323,7 @@ let initial_state = ; e2ee_password = Journal_capture.create ~session_number:9_000_000L ~source:"" ; modal = No_modal ; confirmation_sequence = 0L + ; environment = Journal_environment.fallback } ;; @@ -1275,8 +1291,8 @@ module Presentation = struct let key = match V.For_testing.key child, V.For_testing.test_id child with | Some key, _ -> key - | None, Some id -> Ui.Key.string (Ui.Test_id.to_string id) - | None, None -> Ui.Key.int index + | None, Some id -> id + | None, None -> string_of_int index in V.Keyed.create ~key child) children @@ -1299,7 +1315,7 @@ module Presentation = struct (fun index child -> let key = match V.For_testing.test_id child with - | Some id -> Ui.Key.string (Ui.Test_id.to_string id) + | Some id -> Ui.Key.string id | None -> Ui.Key.int index in V.Native_list.row ~key ~separator:Hidden child) @@ -1322,7 +1338,7 @@ module Presentation = struct ~key:(Option.value key ~default:(Ui.Key.string title)) ~header:(V.text title) [ V.Keyed.create - ~key:(Ui.Key.string "content") + ~key:"content" (V.column ~alignment:Leading children |> V.text_selection ~enabled:true) ] ;; @@ -1520,10 +1536,10 @@ let media_label state dispatch ~root child = let scope = media_scope state in let editable = state.write_enabled - && (match Journal_routes.detail state.routes with - | Some detail -> - String.equal root (Journal_model.id (Journal_detail.root detail)) - | None -> false) + && + match Journal_routes.detail state.routes with + | Some detail -> String.equal root (Journal_model.id (Journal_detail.root detail)) + | None -> false in Journal_media_view.view ~scope @@ -2770,80 +2786,109 @@ let upload_context state = else None ;; -let component ~calendar_sampler client handlers graph = - let state, set_state_and_effect = - Bonsai.Cont.state_machine0 - ~equal:( = ) - ~default_model:initial_state - ~apply_action:(fun context state update -> - let state, scheduled_effect = update state in - let state = track_capture_session state in - Bonsai.Cont.Apply_action_context.schedule_event context scheduled_effect; - state) - graph - in - let set_state = - Bonsai.Cont.map set_state_and_effect ~f:(fun update -> - fun f -> update (fun state -> f state, Bonsai.Effect.Ignore)) +type action = + | Update of (state -> state * unit Effect.t) + | Platform_response of int * (bytes, string) result + | Environment_changed of Journal_environment.snapshot + +type app_context = + { app : (state, action) Lui_app.reducer_app + ; pump : Journal_pump.t + ; client : + (Graph_service.request, Graph_service.response, Graph_service.push) Worker.client + ; send_action : action -> unit + ; apply_platform : bytes -> unit Effect.t + ; running : bool ref + } + +let latest_patch = ref "" +let current_app : app_context option ref = ref None + +let decode_extension_values payload = + let json = + try Yojson.Safe.from_string payload with + | _ -> `Null in - let timeline_scroll_completed = - Driver.Handler.create - handlers - ~name:"timeline-scroll-completed" - ~equal:(fun (a, set_a) (b, set_b) -> a = b && set_a == set_b) - (Bonsai.Cont.map2 state set_state ~f:(fun state set -> - state.graph_state.generation, set)) - ~f:(fun (generation, set) payload -> - match V.Native_list.completion_of_payload payload with - | None -> Bonsai.Effect.Ignore - | Some completion -> - set (fun state -> - if state.graph_state.generation <> generation - then state - else - { state with - timeline = - Journal_timeline_state.complete_scroll - state.timeline - ~token:completion.token - ~outcome:completion.outcome - })) + match json with + | `Assoc fields -> + List.fold_left + (fun map (key, value) -> + match value with + | `String s -> Lui_protocol.String_map.add key (Lui_protocol.StringValue s) map + | `Bool b -> Lui_protocol.String_map.add key (Lui_protocol.BoolValue b) map + | `Int i -> Lui_protocol.String_map.add key (Lui_protocol.IntValue i) map + | `Float f -> Lui_protocol.String_map.add key (Lui_protocol.FloatValue f) map + | _ -> map) + Lui_protocol.String_map.empty + fields + | _ -> Lui_protocol.String_map.empty +;; + +let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = + let pump = Journal_pump.create () in + Journal_pump.set_wakeup pump Journal_bridge.wakeup; + let running = ref true in + let app_cell : (state, action) Lui_app.reducer_app option ref = ref None in + (* ocaml-signal is single-threaded and [Signal.update] is not reentrant, so + sends issued while an update is running (effects, edge callbacks, + continuations) are queued and drained once the outer update finishes. *) + let pending_actions : action Queue.t = Queue.create () in + let in_update = ref false in + let rec send_action action = + match !app_cell with + | None -> () + | Some app -> + if !in_update + then Queue.add action pending_actions + else ( + in_update := true; + ignore (Lui_app.send app action : bool); + drain_pending_actions ()) + and drain_pending_actions () = + match Queue.take_opt pending_actions with + | None -> in_update := false + | Some action -> + (match !app_cell with + | Some app -> ignore (Lui_app.send app action : bool) + | None -> ()); + drain_pending_actions () in - let detail_scroll_completed = - Driver.Handler.create - handlers - ~name:"detail-scroll-completed" - ~equal:(fun (a, route_a, set_a) (b, route_b, set_b) -> - a = b && route_a = route_b && set_a == set_b) - (Bonsai.Cont.map2 state set_state ~f:(fun state set -> - ( state.graph_state.generation - , Journal_routes.detail_request_generation state.routes - , set ))) - ~f:(fun (generation, route, set) payload -> - match V.Native_list.completion_of_payload payload with - | None -> Bonsai.Effect.Ignore - | Some completion -> - set (fun state -> - if - state.graph_state.generation <> generation - || Journal_routes.detail_request_generation state.routes <> route - then state - else ( - match Journal_routes.detail state.routes with - | None -> state - | Some detail -> - { state with - routes = - Journal_routes.update_detail - state.routes - (Journal_detail.complete_reveal - detail - ~token:completion.token - ~outcome:completion.outcome) - }))) + let set_state transition : unit Effect.t = + fun () -> send_action (Update (fun state -> transition state, Effect.ignore)) + in + let set_state_and_effect transition : unit Effect.t = + fun () -> send_action (Update transition) in - let set_state_ref = ref None in let state_ref = ref initial_state in + let set_state_ref = ref None in + set_state_ref := Some set_state; + (* Platform requests are fire-and-forget: the host replies on the + [platform_response] hook, which is routed back to the continuation + registered under the matching response tag. *) + let pending_platform : (int, (bytes, string) result -> unit) Hashtbl.t = + Hashtbl.create 8 + in + let response_tag = function + | 6 -> 7 + | 8 -> 9 + | 10 -> 11 + | 13 -> 14 + | 20 -> 21 + | 22 -> 23 + | 25 -> 26 + | tag -> tag + in + let emit_platform_request ?k request = + (match k, Bytes.length request >= 8 with + | Some k, true -> + let tag = Bytes.get_uint16_le request 6 in + Hashtbl.replace pending_platform (response_tag tag) k + | Some _, false | None, _ -> ()); + Journal_bridge.platform_request (Bytes.to_string request) + in + let platform_request request ~f : unit Effect.t = + fun () -> emit_platform_request ~k:(fun result -> Effect.run (f result)) request + in let graph_runtime = Journal_graph_runtime.create ~localtime:(Journal_calendar.Sampler.localtime calendar_sampler) @@ -2882,7 +2927,7 @@ let component ~calendar_sampler client handlers graph = media_armed := None; let context = !media_context in if changes = [] && armed = None - then Bonsai.Effect.Ignore + then Effect.ignore else set_state (fun state -> if media_key state <> context @@ -2918,7 +2963,7 @@ let component ~calendar_sampler client handlers graph = ~changed:(fun generation recent favorites -> Option.iter (fun set_state -> - Bonsai.Effect.Expert.handle + Effect.run (set_state (fun state -> match generation with | Some generation when state.graph_state.generation = generation -> @@ -2950,7 +2995,7 @@ let component ~calendar_sampler client handlers graph = in let started_graph_generation = ref None in let send_manager command = - Bonsai.Effect.of_thunk (fun () -> + Effect.of_thunk (fun () -> ignore (Worker.send client (Graph_service.Client_command command) : Worker.send_result)) in @@ -2972,10 +3017,10 @@ let component ~calendar_sampler client handlers graph = let admission_worker_requests = Hashtbl.create 4 in let favorites_worker_requests = Hashtbl.create 2 in let rec run_admission_directive set_state_and_effect = function - | Admission_refresh.No_request -> Bonsai.Effect.Ignore + | Admission_refresh.No_request -> Effect.ignore | Request request -> - Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> + Effect.bind + (Effect.of_thunk (fun () -> let output = submit (Journal_graph_request.Inspect_admission request) in Journal_graph_transport.deliver ~runtime:graph_runtime @@ -2995,7 +3040,7 @@ let component ~calendar_sampler client handlers graph = output)) ~f:(fun delivery -> match delivery.Journal_graph_transport.error with - | None -> Bonsai.Effect.Ignore + | None -> Effect.ignore | Some _ -> update_admission set_state_and_effect (fun state -> Admission_refresh.complete @@ -3024,11 +3069,11 @@ let component ~calendar_sampler client handlers graph = | Some message -> fail_graph_transport state message in let send request = - Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> deliver_output (submit request))) + Effect.bind + (Effect.of_thunk (fun () -> deliver_output (submit request))) ~f:(fun delivery -> match !set_state_ref with - | None -> Bonsai.Effect.Ignore + | None -> Effect.ignore | Some set_state -> set_state (fun state -> apply_delivery_responses state delivery)) in @@ -3095,7 +3140,7 @@ let component ~calendar_sampler client handlers graph = |> Option.value ~default:"local" in if !started_graph_generation = Some (graph_key, graph_state.generation) - then Bonsai.Effect.Ignore + then Effect.ignore else ( started_graph_generation := Some (graph_key, graph_state.generation); Journal_graph_runtime.reset graph_runtime; @@ -3153,18 +3198,18 @@ let component ~calendar_sampler client handlers graph = ; next_request_generation = Int64.succ feed_generation }) in - Bonsai.Effect.bind prepare ~f:(fun () -> - Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> deliver_output output)) + Effect.bind prepare ~f:(fun () -> + Effect.bind + (Effect.of_thunk (fun () -> deliver_output output)) ~f:(fun delivery -> set_state (fun state -> apply_delivery_responses state delivery)))) | Graph_closed | Graph_opening | Graph_closing | Graph_failed -> started_graph_generation := None; Journal_asset_runtime.shutdown asset_runtime; - Bonsai.Effect.Ignore + Effect.ignore in - Bonsai.Effect.bind update ~f:(fun () -> - Bonsai.Effect.Many + Effect.bind update ~f:(fun () -> + Effect.many [ start_graph ; trigger_admission set_state_and_effect @@ -3172,57 +3217,6 @@ let component ~calendar_sampler client handlers graph = ~graph_open:(graph_state.phase = Graph_open) ]) in - let timer_branch = - Bonsai.Cont.map state ~f:(fun state -> - if Option.is_some state.pending_delete then 1 else 0) - in - let delete_timer = - Bonsai.Cont.Let_syntax.Let_syntax.switch - ~here:(Core.Source_code_position.of_pos __POS__) - ~match_:timer_branch - ~branches:2 - ~with_:(fun branch -> - if branch = 0 - then Bonsai.Cont.return () - else ( - let until = Bonsai.Cont.Clock.until graph in - let on_activate = - Bonsai.Cont.map3 - state - set_state_and_effect - until - ~f:(fun snapshot set_state_and_effect until -> - match snapshot.pending_delete with - | None -> Bonsai.Effect.Ignore - | Some activated -> - let delayed_commit = - Bonsai.Effect.bind (until activated.deadline) ~f:(fun () -> - set_state_and_effect (fun state -> - match state.pending_delete with - | Some pending - when String.equal pending.mutation_id activated.mutation_id - && pending.phase = Undoable -> - let request : Journal_graph_projection.delete_subtree = - { mutation_id = pending.mutation_id - ; block_id = pending.block_id - ; expected_revision = pending.expected_revision - } - in - ( { state with - pending_delete = Some { pending with phase = Committing } - ; timeline_notice = None - } - , send (Journal_graph_request.Delete_subtree request) ) - | None | Some _ -> state, Bonsai.Effect.Ignore)) - in - Bonsai.Effect.of_thunk (fun () -> - Bonsai.Effect.Expert.handle delayed_commit)) - in - Bonsai.Cont.Edge.lifecycle ~on_activate graph; - Bonsai.Cont.return ())) - in - let application_platform = Driver.Handler.application_platform handlers in - let host_effects = Driver.Handler.host_effects handlers in let sign_out_in_flight = ref false in let termination_in_flight = ref false in let apply_manager_transition set_state set_state_and_effect manager_state = @@ -3238,20 +3232,17 @@ let component ~calendar_sampler client handlers graph = if (not manager.Graph_service.startup.authenticated) && !sign_out_in_flight then ( sign_out_in_flight := false; - Bonsai.Effect.bind - (Platform.request application_platform Journal_platform.sign_out_request) - ~f:(fun result -> - set_state (fun state -> - match result with - | Ok payload - when Result.is_ok (Journal_platform.decode_sign_out_response payload) -> + platform_request Journal_platform.sign_out_request ~f:(fun result -> + set_state (fun state -> + match result with + | Ok payload + when Result.is_ok (Journal_platform.decode_sign_out_response payload) -> + state + | Error _ | Ok _ -> + show_sync_error state - | Error _ | Ok _ -> - show_sync_error - state - (Non_worker_sync_failure - "Unable to sign out of the authenticated session")))) - else Bonsai.Effect.Ignore + (Non_worker_sync_failure "Unable to sign out of the authenticated session")))) + else Effect.ignore in let termination_ready = if @@ -3260,16 +3251,13 @@ let component ~calendar_sampler client handlers graph = || not manager.startup.authenticated) then ( termination_in_flight := false; - Bonsai.Effect.bind - (Platform.request - application_platform - Journal_platform.termination_ready_request) - ~f:(fun _ -> Bonsai.Effect.Ignore)) - else Bonsai.Effect.Ignore + platform_request Journal_platform.termination_ready_request ~f:(fun _ -> + Effect.ignore)) + else Effect.ignore in - Bonsai.Effect.bind update ~f:(fun () -> + Effect.bind update ~f:(fun () -> let snapshot = !state_ref in - Bonsai.Effect.Many + Effect.many [ sign_out ; termination_ready ; trigger_admission @@ -3278,330 +3266,280 @@ let component ~calendar_sampler client handlers graph = ~graph_open:(snapshot.graph_state.phase = Graph_open) ]) in - let registered = ref false in - let event_subscription = - Bonsai.Cont.map3 - state - set_state - set_state_and_effect - ~f:(fun snapshot set_state set_state_and_effect -> - state_ref := snapshot; - set_state_ref := Some set_state; - if not !registered - then ( - registered := true; - ignore (Worker.send client Graph_service.Get_graph_state : Worker.send_result); - Worker.on_event client (fun event -> - Journal_asset_runtime.pump asset_runtime; - sync_media !state_ref; - Journal_media_runtime.pump media_runtime; - match event with - | Worker.Response { request_id; outcome = Completed response; _ } - when Hashtbl.mem media_worker_requests request_id -> - let ticket = Hashtbl.find media_worker_requests request_id in - Hashtbl.remove media_worker_requests request_id; - Journal_media_runtime.receive media_runtime ticket response; - flush_media set_state - | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } - when Hashtbl.mem media_worker_requests request_id -> - let ticket = Hashtbl.find media_worker_requests request_id in - Hashtbl.remove media_worker_requests request_id; - Journal_media_runtime.reject media_runtime ticket; - flush_media set_state - | Worker.Push { payload = Graph_service.Graph_push push; _ } -> - Journal_media_runtime.refresh media_runtime; - let snapshot = !state_ref in - if snapshot.graph_state.phase = Graph_open - then - refresh_assets - ~graph_generation:snapshot.graph_state.generation - snapshot.calendar; - let admission_refresh = - trigger_admission - set_state_and_effect - ~graph_generation:snapshot.graph_state.generation - ~graph_open:(snapshot.graph_state.phase = Graph_open) - in - if not snapshot.graph_ready - then admission_refresh - else ( - let generation = snapshot.next_request_generation in - let output = - Journal_graph_runtime.reconcile_push - graph_runtime - ~request_generation:generation - push - in - if output.requests = [] && output.responses = [] - then Bonsai.Effect.Ignore - else ( - let prepare = - if output.requests <> [] - then - set_state (fun state -> - { state with - next_request_generation = - Int64.max - state.next_request_generation - (Int64.succ generation) - }) - else Bonsai.Effect.Ignore - in - Bonsai.Effect.Many - [ admission_refresh - ; Bonsai.Effect.bind prepare ~f:(fun () -> - Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> deliver_output output)) - ~f:(fun delivery -> - set_state (fun state -> - let state = - List.fold_left - apply_worker_response - state - delivery.responses - in - match delivery.error with - | Some message -> fail_feed_transport state message - | None -> state))) - ])) - | Worker.Response - { request_id = worker_id - ; outcome = Worker.Completed (Graph_service.Graph_response response) - ; _ - } - when Hashtbl.mem asset_worker_requests worker_id -> - Hashtbl.remove asset_worker_requests worker_id; - ignore (Journal_asset_runtime.receive asset_runtime response : bool); - Bonsai.Effect.Ignore - | Worker.Response - { request_id - ; outcome = Worker.Completed (Graph_service.Graph_response response) - ; _ - } -> - Hashtbl.remove admission_worker_requests request_id; - Hashtbl.remove favorites_worker_requests request_id; - let output = Journal_graph_runtime.receive graph_runtime response in - Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> deliver_output output)) - ~f:(fun delivery -> - let update = - set_state_and_effect (fun state -> - let state, effects = - List.fold_left - (fun (state, effects) response -> - let completion = - match response.Journal_graph_runtime.payload with - | Admission_inspected { request; observation } -> - Some (request, Admission_refresh.Inspected observation) - | Admission_unavailable request -> - Some (request, Admission_refresh.Inspection_unavailable) - | _ -> None - in - match completion with - | None -> - Root_navigation.step state (Completed response), effects - | Some (request, result) -> - let admission_refresh, directive = - Admission_refresh.complete - state.admission_refresh - ~request - ~result - in - ( { state with admission_refresh } - , run_admission_directive set_state_and_effect directive - :: effects )) - (state, []) - delivery.responses - in + let handle_worker_event event = + Journal_asset_runtime.pump asset_runtime; + sync_media !state_ref; + Journal_media_runtime.pump media_runtime; + match event with + | Worker.Response { request_id; outcome = Completed response; _ } + when Hashtbl.mem media_worker_requests request_id -> + let ticket = Hashtbl.find media_worker_requests request_id in + Hashtbl.remove media_worker_requests request_id; + Journal_media_runtime.receive media_runtime ticket response; + flush_media set_state + | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } + when Hashtbl.mem media_worker_requests request_id -> + let ticket = Hashtbl.find media_worker_requests request_id in + Hashtbl.remove media_worker_requests request_id; + Journal_media_runtime.reject media_runtime ticket; + flush_media set_state + | Worker.Push { payload = Graph_service.Graph_push push; _ } -> + Journal_media_runtime.refresh media_runtime; + let snapshot = !state_ref in + if snapshot.graph_state.phase = Graph_open + then + refresh_assets ~graph_generation:snapshot.graph_state.generation snapshot.calendar; + let admission_refresh = + trigger_admission + set_state_and_effect + ~graph_generation:snapshot.graph_state.generation + ~graph_open:(snapshot.graph_state.phase = Graph_open) + in + if not snapshot.graph_ready + then admission_refresh + else ( + let generation = snapshot.next_request_generation in + let output = + Journal_graph_runtime.reconcile_push + graph_runtime + ~request_generation:generation + push + in + if output.requests = [] && output.responses = [] + then Effect.ignore + else ( + let prepare = + if output.requests <> [] + then + set_state (fun state -> + { state with + next_request_generation = + Int64.max state.next_request_generation (Int64.succ generation) + }) + else Effect.ignore + in + Effect.many + [ admission_refresh + ; Effect.bind prepare ~f:(fun () -> + Effect.bind + (Effect.of_thunk (fun () -> deliver_output output)) + ~f:(fun delivery -> + set_state (fun state -> let state = - match delivery.error with - | None -> state - | Some message when Option.is_some state.feed_refresh -> - fail_feed_transport state message - | Some message -> fail_graph_transport state message - in - state, Bonsai.Effect.Many (List.rev effects)) - in - Bonsai.Effect.bind update ~f:(fun () -> - let refresh_after_worker_event = - let (Logseq_db_worker.Protocol.V2_response { outcome; _ }) = - response + List.fold_left apply_worker_response state delivery.responses in - match outcome with - | V2_mutation_committed _ -> - let snapshot = !state_ref in - trigger_admission - set_state_and_effect - ~graph_generation:snapshot.graph_state.generation - ~graph_open:(snapshot.graph_state.phase = Graph_open) - | _ -> Bonsai.Effect.Ignore - in - refresh_after_worker_event)) - | Worker.Push { payload = Asset_notice (scope, notice); _ } -> - Journal_asset_runtime.notice asset_runtime scope notice; - Journal_media_runtime.notice media_runtime scope notice; - Bonsai.Effect.Many - [ flush_media set_state - ; set_state (fun state -> - { state with - uploads = - Journal_uploads.notice - (Journal_uploads.sync state.uploads (upload_context state)) - scope - notice - }) - ] - | Worker.Response - { request_id; outcome = Completed (Asset_imported result); _ } -> - let pending = Hashtbl.find_opt import_worker_requests request_id in - Hashtbl.remove import_worker_requests request_id; - let current = - match pending with - | Some (generation, _) -> - let snapshot = !state_ref in - generation = snapshot.graph_state.generation - && - (match result, Journal_routes.detail snapshot.routes with - | Ok receipt, Some detail -> - Journal_model.id (Journal_detail.root detail) - = Logseq_db_types.Graph_types.Uuid.to_string receipt.target - | Error _, _ -> true - | _ -> false) - | None -> false - in - Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> - sync_media !state_ref; - Result.iter - (Journal_media_runtime.imported media_runtime ~current) - result)) - ~f:(fun () -> - Bonsai.Effect.Many - [ flush_media set_state - ; (match pending with - | None -> Bonsai.Effect.Ignore - | Some (generation, operation) -> - set_state (fun state -> - if state.graph_state.generation <> generation - then state - else - { state with - import_completion = - Some - ( operation - , match result with - | Ok _ -> None - | Error message -> Some message ) - })) - ]) - | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } - when Hashtbl.mem import_worker_requests request_id -> - let generation, operation = - Hashtbl.find import_worker_requests request_id - in - Hashtbl.remove import_worker_requests request_id; - set_state (fun state -> - if state.graph_state.generation <> generation - then state - else - { state with - import_completion = - Some - (operation, Some "Import was interrupted. Select the file again.") - }) - | Worker.Response { outcome = Completed (Asset_file _); _ } -> - Bonsai.Effect.Ignore - | Worker.Response { outcome = Completed Client_command_completed; _ } -> - Bonsai.Effect.Ignore - | Worker.Response { outcome = Completed (Graph_state graph_state); _ } - | Worker.Push { payload = Graph_state_changed graph_state; _ } -> - observe_graph_state set_state set_state_and_effect graph_state - | Worker.Push { payload = Client_state_changed manager_state; _ } -> - apply_manager_transition set_state set_state_and_effect manager_state - | Worker.Push { payload = Need_id_token challenge; _ } -> - Bonsai.Effect.bind - (Platform.request - application_platform - (Journal_platform.id_token_request challenge)) - ~f:(function - | Error _ -> send_manager (Graph_service.Reject_token challenge) - | Ok payload -> - let challenge_id = Graph_service.token_request_id challenge in - (match - Journal_platform.decode_id_token_response ~challenge_id payload - with - | Error _ -> send_manager (Graph_service.Reject_token challenge) - | Ok token -> - send_manager - (Graph_service.Provide_token { request = challenge; token }))) - | Worker.Push { payload = Bootstrap_progress progress; _ } -> - set_state (fun state -> - match state.manager with - | Some manager when manager.selected_graph = Some progress.graph_id -> - { state with bootstrap_progress = Some progress } - | None | Some _ -> state) - | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } - when Hashtbl.mem asset_worker_requests request_id -> - let protocol_id = Hashtbl.find asset_worker_requests request_id in - Hashtbl.remove asset_worker_requests request_id; - Journal_asset_runtime.reject asset_runtime ~request_id:protocol_id; - Bonsai.Effect.Ignore - | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } - when Hashtbl.mem favorites_worker_requests request_id -> - let request, protocol_request = - Hashtbl.find favorites_worker_requests request_id + match delivery.error with + | Some message -> fail_feed_transport state message + | None -> state))) + ])) + | Worker.Response + { request_id = worker_id + ; outcome = Worker.Completed (Graph_service.Graph_response response) + ; _ + } + when Hashtbl.mem asset_worker_requests worker_id -> + Hashtbl.remove asset_worker_requests worker_id; + ignore (Journal_asset_runtime.receive asset_runtime response : bool); + Effect.ignore + | Worker.Response + { request_id + ; outcome = Worker.Completed (Graph_service.Graph_response response) + ; _ + } -> + Hashtbl.remove admission_worker_requests request_id; + Hashtbl.remove favorites_worker_requests request_id; + let output = Journal_graph_runtime.receive graph_runtime response in + Effect.bind + (Effect.of_thunk (fun () -> deliver_output output)) + ~f:(fun delivery -> + let update = + set_state_and_effect (fun state -> + let state, effects = + List.fold_left + (fun (state, effects) response -> + let completion = + match response.Journal_graph_runtime.payload with + | Admission_inspected { request; observation } -> + Some (request, Admission_refresh.Inspected observation) + | Admission_unavailable request -> + Some (request, Admission_refresh.Inspection_unavailable) + | _ -> None + in + match completion with + | None -> Root_navigation.step state (Completed response), effects + | Some (request, result) -> + let admission_refresh, directive = + Admission_refresh.complete + state.admission_refresh + ~request + ~result + in + ( { state with admission_refresh } + , run_admission_directive set_state_and_effect directive :: effects + )) + (state, []) + delivery.responses in - Journal_graph_runtime.abandon graph_runtime protocol_request; - Hashtbl.remove favorites_worker_requests request_id; - set_state (fun state -> - favorites_event - state - (Failed (request, false, "Favorites read was interrupted. Try again."))) - | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } - when Hashtbl.mem admission_worker_requests request_id -> - let request, protocol_request = - Hashtbl.find admission_worker_requests request_id + let state = + match delivery.error with + | None -> state + | Some message when Option.is_some state.feed_refresh -> + fail_feed_transport state message + | Some message -> fail_graph_transport state message in - Journal_graph_runtime.abandon graph_runtime protocol_request; - Hashtbl.remove admission_worker_requests request_id; - update_admission set_state_and_effect (fun state -> - Admission_refresh.complete - state.admission_refresh - ~request - ~result:Inspection_unavailable) - | Worker.Response { outcome = Failed error; _ } -> - set_state (fun state -> - let worker_error = service_error ~operation:"handleRequest" error in - let state = - record_worker_error state ~operation:"handleRequest" worker_error - in - match state.feed_refresh with - | Some _ -> - show_sync_error - { state with feed_refresh = None } - (Worker_sync_failure (latest_worker_error state)) - | None -> - fail_active_mutation - state - (Worker_capture_failure (latest_worker_error state))) - | Worker.Response { outcome = Cancelled | Shutdown; _ } -> - set_state (fun state -> - match state.feed_refresh with - | Some _ -> - show_sync_error - { state with feed_refresh = None } - (Non_worker_sync_failure "Worker unavailable") - | None -> - fail_active_mutation state (Local_capture_failure "Worker unavailable")) - | Worker.Terminal { error; _ } -> - set_state (fun state -> - let worker_error = service_error ~operation:"terminal" error in - record_worker_error state ~operation:"terminal" worker_error - |> fun state -> - terminal_graph_state - state - (Worker_graph_error (latest_worker_error state)))); - ())) + state, Effect.many (List.rev effects)) + in + Effect.bind update ~f:(fun () -> + let refresh_after_worker_event = + let (Logseq_db_worker.Protocol.V2_response { outcome; _ }) = response in + match outcome with + | V2_mutation_committed _ -> + let snapshot = !state_ref in + trigger_admission + set_state_and_effect + ~graph_generation:snapshot.graph_state.generation + ~graph_open:(snapshot.graph_state.phase = Graph_open) + | _ -> Effect.ignore + in + refresh_after_worker_event)) + | Worker.Push { payload = Asset_notice (scope, notice); _ } -> + Journal_asset_runtime.notice asset_runtime scope notice; + Journal_media_runtime.notice media_runtime scope notice; + Effect.many + [ flush_media set_state + ; set_state (fun state -> + { state with + uploads = + Journal_uploads.notice + (Journal_uploads.sync state.uploads (upload_context state)) + scope + notice + }) + ] + | Worker.Response { request_id; outcome = Completed (Asset_imported result); _ } -> + let pending = Hashtbl.find_opt import_worker_requests request_id in + Hashtbl.remove import_worker_requests request_id; + let current = + match pending with + | Some (generation, _) -> + let snapshot = !state_ref in + generation = snapshot.graph_state.generation + && + (match result, Journal_routes.detail snapshot.routes with + | Ok receipt, Some detail -> + Journal_model.id (Journal_detail.root detail) + = Logseq_db_types.Graph_types.Uuid.to_string receipt.target + | Error _, _ -> true + | _ -> false) + | None -> false + in + Effect.bind + (Effect.of_thunk (fun () -> + sync_media !state_ref; + Result.iter (Journal_media_runtime.imported media_runtime ~current) result)) + ~f:(fun () -> + Effect.many + [ flush_media set_state + ; (match pending with + | None -> Effect.ignore + | Some (generation, operation) -> + set_state (fun state -> + if state.graph_state.generation <> generation + then state + else + { state with + import_completion = + Some + ( operation + , match result with + | Ok _ -> None + | Error message -> Some message ) + })) + ]) + | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } + when Hashtbl.mem import_worker_requests request_id -> + let generation, operation = Hashtbl.find import_worker_requests request_id in + Hashtbl.remove import_worker_requests request_id; + set_state (fun state -> + if state.graph_state.generation <> generation + then state + else + { state with + import_completion = + Some (operation, Some "Import was interrupted. Select the file again.") + }) + | Worker.Response { outcome = Completed (Asset_file _); _ } -> Effect.ignore + | Worker.Response { outcome = Completed Client_command_completed; _ } -> Effect.ignore + | Worker.Response { outcome = Completed (Graph_state graph_state); _ } + | Worker.Push { payload = Graph_state_changed graph_state; _ } -> + observe_graph_state set_state set_state_and_effect graph_state + | Worker.Push { payload = Client_state_changed manager_state; _ } -> + apply_manager_transition set_state set_state_and_effect manager_state + | Worker.Push { payload = Need_id_token challenge; _ } -> + platform_request (Journal_platform.id_token_request challenge) ~f:(function + | Error _ -> send_manager (Graph_service.Reject_token challenge) + | Ok payload -> + let challenge_id = Graph_service.token_request_id challenge in + (match Journal_platform.decode_id_token_response ~challenge_id payload with + | Error _ -> send_manager (Graph_service.Reject_token challenge) + | Ok token -> + send_manager (Graph_service.Provide_token { request = challenge; token }))) + | Worker.Push { payload = Bootstrap_progress progress; _ } -> + set_state (fun state -> + match state.manager with + | Some manager when manager.selected_graph = Some progress.graph_id -> + { state with bootstrap_progress = Some progress } + | None | Some _ -> state) + | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } + when Hashtbl.mem asset_worker_requests request_id -> + let protocol_id = Hashtbl.find asset_worker_requests request_id in + Hashtbl.remove asset_worker_requests request_id; + Journal_asset_runtime.reject asset_runtime ~request_id:protocol_id; + Effect.ignore + | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } + when Hashtbl.mem favorites_worker_requests request_id -> + let request, protocol_request = Hashtbl.find favorites_worker_requests request_id in + Journal_graph_runtime.abandon graph_runtime protocol_request; + Hashtbl.remove favorites_worker_requests request_id; + set_state (fun state -> + favorites_event + state + (Failed (request, false, "Favorites read was interrupted. Try again."))) + | Worker.Response { request_id; outcome = Failed _ | Cancelled | Shutdown; _ } + when Hashtbl.mem admission_worker_requests request_id -> + let request, protocol_request = Hashtbl.find admission_worker_requests request_id in + Journal_graph_runtime.abandon graph_runtime protocol_request; + Hashtbl.remove admission_worker_requests request_id; + update_admission set_state_and_effect (fun state -> + Admission_refresh.complete + state.admission_refresh + ~request + ~result:Inspection_unavailable) + | Worker.Response { outcome = Failed error; _ } -> + set_state (fun state -> + let worker_error = service_error ~operation:"handleRequest" error in + let state = record_worker_error state ~operation:"handleRequest" worker_error in + match state.feed_refresh with + | Some _ -> + show_sync_error + { state with feed_refresh = None } + (Worker_sync_failure (latest_worker_error state)) + | None -> + fail_active_mutation state (Worker_capture_failure (latest_worker_error state))) + | Worker.Response { outcome = Cancelled | Shutdown; _ } -> + set_state (fun state -> + match state.feed_refresh with + | Some _ -> + show_sync_error + { state with feed_refresh = None } + (Non_worker_sync_failure "Worker unavailable") + | None -> fail_active_mutation state (Local_capture_failure "Worker unavailable")) + | Worker.Terminal { error; _ } -> + set_state (fun state -> + let worker_error = service_error ~operation:"terminal" error in + record_worker_error state ~operation:"terminal" worker_error + |> fun state -> + terminal_graph_state state (Worker_graph_error (latest_worker_error state))) in let install_calendar set_state calendar = Journal_graph_runtime.set_calendar graph_runtime calendar; @@ -3620,1541 +3558,1619 @@ let component ~calendar_sampler client handlers graph = { state with calendar = Some calendar; graph_error }) in let calendar_foreground = ref true in - let platform_registered = ref false in - let platform_subscription = - Bonsai.Cont.map set_state ~f:(fun set_state -> - if not !platform_registered - then ( - platform_registered := true; - let sample_calendar () = - Bonsai.Effect.of_thunk (fun () -> - Journal_calendar.Sampler.sample calendar_sampler) - in - let apply_network_lifecycle payload = - match Journal_platform.decode_network_lifecycle payload with - | Error _ -> Bonsai.Effect.Ignore - | Ok (Backgrounded _) -> - calendar_foreground := false; - send_manager (Graph_service.Set_foreground false) - | Ok (Foreground_resumed _) -> - calendar_foreground := true; - Bonsai.Effect.bind (sample_calendar ()) ~f:(function - | Error error -> - Bonsai.Effect.Many - [ set_state (fun state -> - { state with graph_error = Some (Calendar_startup_failure error) }) - ; send_manager (Graph_service.Set_foreground true) - ] - | Ok calendar -> - Bonsai.Effect.bind (install_calendar set_state calendar) ~f:(fun () -> - send_manager (Graph_service.Set_foreground true))) - in - let apply_authenticated_user payload = - match Journal_platform.decode_authenticated_user payload with - | Error _ -> Bonsai.Effect.Ignore - | Ok user_id -> - (match user_id with - | None -> sign_out_in_flight := true - | Some _ -> ()); - send_manager (Graph_service.Reconcile_authenticated_user { user_id }) - in - let apply_local_account_binding result = - match result with - | Error _ -> Bonsai.Effect.Ignore - | Ok payload -> - (match Journal_platform.decode_local_account_binding payload with - | Error _ | Ok None -> Bonsai.Effect.Ignore - | Ok (Some binding) -> - if String.equal binding.managed_sync_origin !managed_sync_origin - then - send_manager - (Graph_service.Restore_local_account { user_id = binding.user_id }) - else Bonsai.Effect.Ignore) - in - let apply_platform payload = - if Journal_platform.is_prepare_to_terminate_event payload - then - if local_deletion_active !state_ref - then - Bonsai.Effect.bind - (Platform.request - application_platform - Journal_platform.termination_ready_request) - ~f:(fun _ -> Bonsai.Effect.Ignore) - else ( - termination_in_flight := true; - send_manager Graph_service.Return_to_graph_picker) - else ( - match Journal_platform.decode_network_lifecycle payload with - | Ok _ -> apply_network_lifecycle payload - | Error _ -> apply_authenticated_user payload) - in - Platform.on_event application_platform apply_platform; - let managed_startup = - if not !managed_sync_startup - then Bonsai.Effect.Ignore - else - Bonsai.Effect.bind - (Platform.request - application_platform - Journal_platform.local_account_binding_request) - ~f:(fun binding -> - Bonsai.Effect.bind (apply_local_account_binding binding) ~f:(fun () -> - Platform.request - application_platform - Journal_platform.authenticated_user_request - |> Bonsai.Effect.bind ~f:(function - | Error _ -> Bonsai.Effect.Ignore - | Ok payload -> apply_authenticated_user payload))) - in - let calendar_startup = - Bonsai.Effect.bind (sample_calendar ()) ~f:(function - | Error error -> - set_state (fun state -> + let sample_calendar () = + Effect.of_thunk (fun () -> Journal_calendar.Sampler.sample calendar_sampler) + in + let apply_network_lifecycle payload = + match Journal_platform.decode_network_lifecycle payload with + | Error _ -> Effect.ignore + | Ok (Backgrounded _) -> + calendar_foreground := false; + send_manager (Graph_service.Set_foreground false) + | Ok (Foreground_resumed _) -> + calendar_foreground := true; + Effect.bind (sample_calendar ()) ~f:(function + | Error error -> + Effect.many + [ set_state (fun state -> { state with graph_error = Some (Calendar_startup_failure error) }) - | Ok calendar -> - Bonsai.Effect.bind (install_calendar set_state calendar) ~f:(fun () -> - managed_startup)) - in - calendar_startup |> Bonsai.Effect.Expert.handle); - ()) - in - let calendar_tick = - Bonsai.Cont.map set_state ~f:(fun set_state -> - Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> - if (not !calendar_foreground) || !termination_in_flight - then None - else ( - match Journal_calendar.Sampler.sample calendar_sampler with - | Error _ -> None - | Ok calendar -> - (match !state_ref.calendar with - | Some previous - when Journal_calendar.classify_change ~previous calendar - = Current_time_changed -> None - | None | Some _ -> Some calendar)))) - ~f:(function - | None -> Bonsai.Effect.Ignore - | Some calendar -> install_calendar set_state calendar)) - in - Bonsai.Cont.Clock.every - ~when_to_start_next_effect:`Every_multiple_of_period_non_blocking - ~trigger_on_activate:false - (Core.Time_ns.Span.of_sec 60.) - calendar_tick - graph; - let feed_key = - Bonsai.Cont.map state ~f:(fun state -> - match state.graph_ready, state.calendar with - | true, Some calendar -> - let context = feed_projection_context calendar in - if - state.feed_loaded - && Option.equal - equal_feed_projection_context - state.presented_feed_context - (Some context) - then None - else if - match Journal_timeline_state.pending_request state.timeline with - | Some (_, Feed { before_day = None }) -> true - | Some (_, Feed { before_day = Some _ }) | Some (_, Day _) | None -> false - then None - else Some context - | false, _ | true, None -> None) - in - let feed_callback = - Bonsai.Cont.map2 state set_state ~f:(fun snapshot set_state -> function - | None -> Bonsai.Effect.Ignore - | Some context -> - let generation = snapshot.next_request_generation in - let output = - submit - (Journal_graph_request.Load_feed - { before_day = None - ; day_limit = feed_day_limit - ; blocks_per_day = 64 - ; slot_limit = 128 - ; request_generation = generation - }) - in - let request = Journal_timeline_state.Feed { before_day = None } in - let prepare = - set_state (fun state -> - if state.feed_loaded - then ( - let cause = - match state.feed_refresh with - | Some { cause = Sync_refresh; _ } -> Sync_refresh - | None | Some _ -> Calendar_refresh + ; send_manager (Graph_service.Set_foreground true) + ] + | Ok calendar -> + Effect.bind (install_calendar set_state calendar) ~f:(fun () -> + send_manager (Graph_service.Set_foreground true))) + in + let apply_authenticated_user payload = + match Journal_platform.decode_authenticated_user payload with + | Error _ -> Effect.ignore + | Ok user_id -> + (match user_id with + | None -> sign_out_in_flight := true + | Some _ -> ()); + send_manager (Graph_service.Reconcile_authenticated_user { user_id }) + in + let apply_local_account_binding result = + match result with + | Error _ -> Effect.ignore + | Ok payload -> + (match Journal_platform.decode_local_account_binding payload with + | Error _ | Ok None -> Effect.ignore + | Ok (Some binding) -> + if String.equal binding.managed_sync_origin !managed_sync_origin + then + send_manager + (Graph_service.Restore_local_account { user_id = binding.user_id }) + else Effect.ignore) + in + let apply_platform payload = + if Journal_platform.is_prepare_to_terminate_event payload + then + if local_deletion_active !state_ref + then + platform_request Journal_platform.termination_ready_request ~f:(fun _ -> + Effect.ignore) + else ( + termination_in_flight := true; + send_manager Graph_service.Return_to_graph_picker) + else ( + match Journal_platform.decode_network_lifecycle payload with + | Ok _ -> apply_network_lifecycle payload + | Error _ -> apply_authenticated_user payload) + in + let managed_startup = + if not !managed_sync_startup + then Effect.ignore + else + platform_request Journal_platform.local_account_binding_request ~f:(fun binding -> + Effect.bind (apply_local_account_binding binding) ~f:(fun () -> + platform_request Journal_platform.authenticated_user_request ~f:(function + | Error _ -> Effect.ignore + | Ok payload -> apply_authenticated_user payload))) + in + let calendar_startup = + Effect.bind (sample_calendar ()) ~f:(function + | Error error -> + set_state (fun state -> + { state with graph_error = Some (Calendar_startup_failure error) }) + | Ok calendar -> + Effect.bind (install_calendar set_state calendar) ~f:(fun () -> managed_startup)) + in + let calendar_tick_effect () : unit Effect.t = + Effect.bind + (Effect.of_thunk (fun () -> + if (not !calendar_foreground) || !termination_in_flight + then None + else ( + match Journal_calendar.Sampler.sample calendar_sampler with + | Error _ -> None + | Ok calendar -> + (match !state_ref.calendar with + | Some previous + when Journal_calendar.classify_change ~previous calendar + = Current_time_changed -> None + | None | Some _ -> Some calendar)))) + ~f:(function + | None -> Effect.ignore + | Some calendar -> install_calendar set_state calendar) + in + let feed_key state = + match state.graph_ready, state.calendar with + | true, Some calendar -> + let context = feed_projection_context calendar in + if + state.feed_loaded + && Option.equal + equal_feed_projection_context + state.presented_feed_context + (Some context) + then None + else if + match Journal_timeline_state.pending_request state.timeline with + | Some (_, Feed { before_day = None }) -> true + | Some (_, Feed { before_day = Some _ }) | Some (_, Day _) | None -> false + then None + else Some context + | false, _ | true, None -> None + in + let prev_feed_key = ref (feed_key initial_state) in + let feed_callback key = + let snapshot = !state_ref in + match key with + | None -> Effect.ignore + | Some context -> + let generation = snapshot.next_request_generation in + let output = + submit + (Journal_graph_request.Load_feed + { before_day = None + ; day_limit = feed_day_limit + ; blocks_per_day = 64 + ; slot_limit = 128 + ; request_generation = generation + }) + in + let request = Journal_timeline_state.Feed { before_day = None } in + let prepare = + set_state (fun state -> + if state.feed_loaded + then ( + let cause = + match state.feed_refresh with + | Some { cause = Sync_refresh; _ } -> Sync_refresh + | None | Some _ -> Calendar_refresh + in + { state with + feed_refresh = + Some + { generation + ; context + ; cause + ; graph_generation = current_graph_generation state + } + ; next_request_generation = Int64.succ generation + }) + else + { state with + timeline = + (Journal_timeline_state.empty ~today:context.local_day + |> fun timeline -> + Journal_timeline_state.begin_request timeline ~generation request) + ; feed_loaded = false + ; presented_feed_context = None + ; feed_refresh = None + ; next_request_generation = Int64.succ generation + }) + in + Effect.bind prepare ~f:(fun () -> + Effect.bind + (Effect.of_thunk (fun () -> deliver_output output)) + ~f:(fun delivery -> + set_state (fun state -> + let state = + List.fold_left + (fun state response -> Root_navigation.step state (Completed response)) + state + delivery.responses in - { state with - feed_refresh = - Some - { generation - ; context - ; cause - ; graph_generation = current_graph_generation state - } - ; next_request_generation = Int64.succ generation - }) - else - { state with - timeline = - (Journal_timeline_state.empty ~today:context.local_day - |> fun timeline -> - Journal_timeline_state.begin_request timeline ~generation request) - ; feed_loaded = false - ; presented_feed_context = None - ; feed_refresh = None - ; next_request_generation = Int64.succ generation - }) - in - Bonsai.Effect.bind prepare ~f:(fun () -> - Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> deliver_output output)) - ~f:(fun delivery -> - set_state (fun state -> - let state = - List.fold_left - (fun state response -> - Root_navigation.step state (Completed response)) - state - delivery.responses - in - match delivery.error with - | Some message -> fail_feed_transport state message - | None -> state)))) - in - Bonsai.Cont.Edge.on_change - ~equal:(Option.equal equal_feed_projection_context) - feed_key - ~callback:feed_callback - graph; - let timeline_presentation_key = - Bonsai.Cont.map state ~f:(fun state -> - match state.feed_loaded, state.manager with - | true, Some snapshot when snapshot.timeline_presentation_pending -> - Some (snapshot.selected_graph, snapshot.applied_server_t) - | false, _ | true, None | true, Some _ -> None) - in - let timeline_presentation_callback = - Bonsai.Cont.map timeline_presentation_key ~f:(fun current -> function - | None -> Bonsai.Effect.Ignore - | Some _ as key -> - if current <> key - then Bonsai.Effect.Ignore - else - Bonsai.Effect.bind - (send_manager Graph_service.Acknowledge_local_feed) - ~f:(fun () -> - Platform.request - application_platform - Journal_platform.timeline_presented_request - |> Bonsai.Effect.bind ~f:(function - | Error _ -> Bonsai.Effect.Ignore - | Ok payload -> - (match Journal_platform.decode_timeline_presented payload with - | Error _ -> Bonsai.Effect.Ignore - | Ok () -> send_manager Graph_service.Acknowledge_timeline_presented)))) - in - Bonsai.Cont.Edge.on_change - ~equal:(Option.equal (fun left right -> left = right)) - timeline_presentation_key - ~callback:timeline_presentation_callback - graph; - let favorites_drain_key = - Bonsai.Cont.map state ~f:(fun state -> state.favorites_requests) - in - let favorites_drain_callback = - Bonsai.Cont.map set_state ~f:(fun set_state requests -> - let deliver (request : Journal_graph_request.favorites_request) = - if request.graph_generation <> !state_ref.graph_state.generation - then Bonsai.Effect.Ignore - else - Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> - let output = submit (Journal_graph_request.Load_favorites request) in - Journal_graph_transport.deliver - ~runtime:graph_runtime - ~send:(fun protocol_request -> - match - Worker.send client (Graph_service.Graph_request protocol_request) - with - | Accepted worker_request_id -> - Hashtbl.replace - favorites_worker_requests - worker_request_id - (request, protocol_request); - Journal_graph_transport.Accepted - | Full -> Full - | Not_ready -> Not_ready - | Stopping -> Stopping) - output)) + match delivery.error with + | Some message -> fail_feed_transport state message + | None -> state))) + in + let timeline_presentation_key state = + match state.feed_loaded, state.manager with + | true, Some snapshot when snapshot.timeline_presentation_pending -> + Some (snapshot.selected_graph, snapshot.applied_server_t) + | false, _ | true, None | true, Some _ -> None + in + let prev_timeline_presentation_key = ref (timeline_presentation_key initial_state) in + let timeline_presentation_callback = function + | None -> Effect.ignore + | Some _ -> + Effect.bind (send_manager Graph_service.Acknowledge_local_feed) ~f:(fun () -> + platform_request Journal_platform.timeline_presented_request ~f:(function + | Error _ -> Effect.ignore + | Ok payload -> + (match Journal_platform.decode_timeline_presented payload with + | Error _ -> Effect.ignore + | Ok () -> send_manager Graph_service.Acknowledge_timeline_presented))) + in + let favorites_drain_key state = state.favorites_requests in + let prev_favorites_drain_key = ref (favorites_drain_key initial_state) in + let favorites_drain_callback requests = + let deliver (request : Journal_graph_request.favorites_request) = + if request.graph_generation <> !state_ref.graph_state.generation + then Effect.ignore + else + Effect.bind + (Effect.of_thunk (fun () -> + let output = submit (Journal_graph_request.Load_favorites request) in + Journal_graph_transport.deliver + ~runtime:graph_runtime + ~send:(fun protocol_request -> + match + Worker.send client (Graph_service.Graph_request protocol_request) + with + | Accepted worker_request_id -> + Hashtbl.replace + favorites_worker_requests + worker_request_id + (request, protocol_request); + Journal_graph_transport.Accepted + | Full -> Full + | Not_ready -> Not_ready + | Stopping -> Stopping) + output)) + ~f:(fun delivery -> + set_state (fun state -> + let state = + List.fold_left + (fun state response -> Root_navigation.step state (Completed response)) + state + delivery.responses + in + match delivery.error with + | None -> state + | Some message -> favorites_event state (Failed (request, false, message)))) + in + Effect.bind + (set_state (fun state -> + { state with + favorites_requests = + List.filter + (fun request -> not (List.mem request requests)) + state.favorites_requests + })) + ~f:(fun () -> Effect.many (List.map deliver requests)) + in + let timeline_drain_key state = + if not (state.graph_ready && state.feed_loaded) + then None + else + Option.map + (fun request -> state.next_request_generation, request) + (Journal_timeline_state.next_request state.timeline) + in + let prev_timeline_drain_key = ref (timeline_drain_key initial_state) in + let timeline_drain_callback = function + | None -> Effect.ignore + | Some (generation, request) -> + let output = submit (worker_request generation request) in + Effect.bind + (set_state (fun state -> + if + Int64.equal state.next_request_generation generation + && Journal_timeline_state.next_request state.timeline = Some request + then + { state with + timeline = + Journal_timeline_state.begin_request state.timeline ~generation request + ; next_request_generation = Int64.succ generation + } + else state)) + ~f:(fun () -> + Effect.bind + (Effect.of_thunk (fun () -> deliver_output output)) ~f:(fun delivery -> set_state (fun state -> - let state = - List.fold_left - (fun state response -> - Root_navigation.step state (Completed response)) - state - delivery.responses - in - match delivery.error with - | None -> state - | Some message -> favorites_event state (Failed (request, false, message)))) - in - Bonsai.Effect.bind - (set_state (fun state -> - { state with - favorites_requests = - List.filter - (fun request -> not (List.mem request requests)) - state.favorites_requests - })) - ~f:(fun () -> Bonsai.Effect.Many (List.map deliver requests))) - in - Bonsai.Cont.Edge.on_change - ~equal:( = ) - favorites_drain_key - ~callback:favorites_drain_callback - graph; - let timeline_drain_key = - Bonsai.Cont.map state ~f:(fun state -> - if not (state.graph_ready && state.feed_loaded) - then None - else - Option.map - (fun request -> state.next_request_generation, request) - (Journal_timeline_state.next_request state.timeline)) - in - let timeline_drain_callback = - Bonsai.Cont.map set_state ~f:(fun set_state -> function - | None -> Bonsai.Effect.Ignore - | Some (generation, request) -> - let output = submit (worker_request generation request) in - Bonsai.Effect.bind - (set_state (fun state -> - if - Int64.equal state.next_request_generation generation - && Journal_timeline_state.next_request state.timeline = Some request - then - { state with - timeline = - Journal_timeline_state.begin_request state.timeline ~generation request - ; next_request_generation = Int64.succ generation - } - else state)) - ~f:(fun () -> - Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> deliver_output output)) - ~f:(fun delivery -> - set_state (fun state -> - let state = apply_delivery_responses state delivery in - match delivery.error, request with - | Some message, Journal_timeline_state.Day { day; _ } -> - { state with - timeline = - Journal_timeline_state.fail_day_request - state.timeline - ~generation - ~day - ~stale_cursor:false - ~message - } - | _ -> state)))) - in - Bonsai.Cont.Edge.on_change - ~equal: - (Option.equal - (fun (left_generation, left_request) (right_generation, right_request) -> - Int64.equal left_generation right_generation && left_request = right_request)) - timeline_drain_key - ~callback:timeline_drain_callback - graph; - let environment = - Driver.Handler.environment handlers |> Bonsai_swiftui.Environment.value - in - let current_time = Bonsai.Cont.Clock.get_current_time graph in - let sync_error_timer_key = - Bonsai.Cont.map state ~f:(fun state -> - Option.map (fun notice -> notice.sequence) state.sync_error) - in - let sync_error_timer_callback = - let until = Bonsai.Cont.Clock.until graph in - Bonsai.Cont.map3 - set_state - current_time - until - ~f:(fun set_state current_time until -> function - | None -> Bonsai.Effect.Ignore - | Some scheduled_sequence -> - let hide = - Bonsai.Effect.bind current_time ~f:(fun now -> - Bonsai.Effect.bind - (until (Core.Time_ns.add now sync_error_card_lifetime)) - ~f:(fun () -> - set_state (fun state -> - match state.sync_error with - | Some { sequence = current_sequence; _ } - when Int64.equal current_sequence scheduled_sequence -> + let state = apply_delivery_responses state delivery in + match delivery.error, request with + | Some message, Journal_timeline_state.Day { day; _ } -> + { state with + timeline = + Journal_timeline_state.fail_day_request + state.timeline + ~generation + ~day + ~stale_cursor:false + ~message + } + | _ -> state))) + in + let prev_upload_key = ref (upload_context initial_state) in + let upload_callback () = + Effect.run + (set_state (fun state -> + { state with + uploads = Journal_uploads.sync state.uploads (upload_context state) + })) + in + let prev_media_key = ref (media_key initial_state) in + let media_callback () = + Effect.run + (Effect.bind + (Effect.of_thunk (fun () -> sync_media !state_ref)) + ~f:(fun () -> flush_media set_state)) + in + let delete_timer_generation = ref 0 in + let schedule_after span thunk = + ignore + (Thread.create + (fun () -> + if span > 0. then Unix.sleepf span; + if !running then Journal_pump.enqueue pump thunk) + ()) + in + let arm_delete_timer mutation_id deadline = + incr delete_timer_generation; + let generation = !delete_timer_generation in + let remaining = Core.Time_ns.(Span.to_sec (diff deadline (now ()))) in + schedule_after remaining (fun () -> + if !delete_timer_generation = generation + then + Effect.run + (set_state_and_effect (fun state -> + match state.pending_delete with + | Some ({ phase = Undoable; _ } as pending) + when String.equal pending.mutation_id mutation_id -> + let request : Journal_graph_projection.delete_subtree = + { mutation_id = pending.mutation_id + ; block_id = pending.block_id + ; expected_revision = pending.expected_revision + } + in + ( { state with + pending_delete = Some { pending with phase = Committing } + ; timeline_notice = None + } + , send (Journal_graph_request.Delete_subtree request) ) + | None | Some _ -> state, Effect.ignore))) + in + let delete_timer_key state = + match state.pending_delete with + | Some { mutation_id; deadline; phase = Undoable; _ } -> Some (mutation_id, deadline) + | Some _ | None -> None + in + let prev_delete_timer_key = ref (delete_timer_key initial_state) in + let sync_error_timer_generation = ref 0 in + let arm_sync_error_timer sequence = + incr sync_error_timer_generation; + let generation = !sync_error_timer_generation in + schedule_after (Core.Time_ns.Span.to_sec sync_error_card_lifetime) (fun () -> + if !sync_error_timer_generation = generation + then + send_action + (Update + (fun state -> + ( (match state.sync_error with + | Some { sequence = current; _ } when Int64.equal current sequence -> { state with sync_error = None } - | None | Some _ -> state))) - in - Bonsai.Effect.of_thunk (fun () -> Bonsai.Effect.Expert.handle hide)) - in - Bonsai.Cont.Edge.on_change - ~equal:(Option.equal Int64.equal) - sync_error_timer_key - ~callback:sync_error_timer_callback - graph; - let upload_lifecycle = Bonsai.Cont.map state ~f:upload_context in - let upload_callback = - Bonsai.Cont.map set_state ~f:(fun set_state _ -> - set_state (fun state -> - { state with uploads = Journal_uploads.sync state.uploads (upload_context state) })) - in - Bonsai.Cont.Edge.on_change ~equal:( = ) upload_lifecycle ~callback:upload_callback graph; - let media_lifecycle = Bonsai.Cont.map state ~f:media_key in - let media_callback = - Bonsai.Cont.map2 state set_state ~f:(fun state set_state _ -> - Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> sync_media state)) - ~f:(fun () -> flush_media set_state)) - in - Bonsai.Cont.Edge.on_change ~equal:( = ) media_lifecycle ~callback:media_callback graph; - let dependencies = - Bonsai.Cont.map5 - state - set_state - set_state_and_effect - environment - current_time - ~f:(fun state set_state set_state_and_effect environment current_time -> - state, set_state, set_state_and_effect, environment, current_time) + | None | Some _ -> state) + , Effect.ignore )))) in - let dispatch = - Driver.Handler.create - handlers - ~name:"journal-dispatch" - ~equal: - (fun - (left, left_set, left_effect, left_environment, left_time) - (right, right_set, right_effect, right_environment, right_time) -> - left = right - && left_set == right_set - && left_effect == right_effect - && left_environment = right_environment - && left_time == right_time) - dependencies - ~f: - (fun - (snapshot, set_state, set_state_and_effect, environment, current_time) - payload -> - let update f = set_state f in - let with_request next request = - Bonsai.Effect.Many [ update (fun _ -> next); send request ] - in - let with_direct_request next request = - Bonsai.Effect.bind (update (fun _ -> next)) ~f:(fun () -> send request) - in - let open_block block_id = - let generation = snapshot.next_request_generation in - let routes = - Journal_routes.open_detail - snapshot.routes - ~block_id - ~request_generation:generation - in - with_direct_request - { snapshot with routes; next_request_generation = Int64.succ generation } - (Journal_graph_request.Load_detail - { block_id; after = None; limit = 64; request_generation = generation }) + let prev_sync_error_key = + ref (Option.map (fun notice -> notice.sequence) initial_state.sync_error) + in + let handle_dispatch payload = + let snapshot = !state_ref in + let current_time : Core.Time_ns.t Effect.t = fun () -> Core.Time_ns.now () in + let update f = set_state f in + let with_request next request = + Effect.many [ update (fun _ -> next); send request ] + in + let with_direct_request next request = + Effect.bind (update (fun _ -> next)) ~f:(fun () -> send request) + in + let open_block block_id = + let generation = snapshot.next_request_generation in + let routes = + Journal_routes.open_detail + snapshot.routes + ~block_id + ~request_generation:generation + in + with_direct_request + { snapshot with routes; next_request_generation = Int64.succ generation } + (Journal_graph_request.Load_detail + { block_id; after = None; limit = 64; request_generation = generation }) + in + let open_favorite membership_id = + match + List.find_opt + (fun (item : Logseq_db_worker.Protocol.v2_favorite_item) -> + Logseq_db_types.Graph_types.Uuid.to_string item.membership_uuid + = membership_id) + (Journal_routes.Favorites.items snapshot.favorites) + with + | Some item -> + let routes, request = + Journal_routes.open_favorite + snapshot.routes + ~request_generation:snapshot.next_request_generation + item in - let open_favorite membership_id = - match - List.find_opt - (fun (item : Logseq_db_worker.Protocol.v2_favorite_item) -> - Logseq_db_types.Graph_types.Uuid.to_string item.membership_uuid - = membership_id) - (Journal_routes.Favorites.items snapshot.favorites) - with - | Some item -> - let routes, request = - Journal_routes.open_favorite - snapshot.routes - ~request_generation:snapshot.next_request_generation - item + (match request with + | None -> Effect.ignore + | Some request -> + with_direct_request + { snapshot with + routes + ; next_request_generation = Int64.succ snapshot.next_request_generation + } + request) + | None -> Effect.ignore + in + let detail_event event = + match Journal_routes.detail snapshot.routes with + | None -> Effect.ignore + | Some detail -> + let detail, requests = Journal_detail.step detail event in + Effect.bind + (update (fun state -> + { state with routes = Journal_routes.update_detail state.routes detail })) + ~f:(fun () -> Effect.many (List.map send requests)) + in + let update_draft ~toggle source = + if + (not snapshot.write_enabled) + || snapshot.pending_delete <> None + || snapshot.pending_status <> None + then Effect.ignore + else + update (fun state -> + match Journal_routes.detail state.routes with + | None -> state + | Some detail -> + let detail = Journal_detail.update_child_source detail source in + let detail = + if toggle then Journal_detail.toggle_child_task detail else detail in - (match request with - | None -> Bonsai.Effect.Ignore - | Some request -> - with_direct_request - { snapshot with - routes - ; next_request_generation = Int64.succ snapshot.next_request_generation - } - request) - | None -> Bonsai.Effect.Ignore + { state with routes = Journal_routes.update_detail state.routes detail }) + in + let admit_direct_capture source = + match + ( snapshot.write_enabled + , snapshot.pending_delete + , snapshot.pending_status + , snapshot.calendar ) + with + | false, _, _, _ + | true, Some _, _, _ + | true, None, Some _, _ + | true, None, None, None -> Effect.ignore + | true, None, None, Some _ -> + let capture = + match snapshot.direct_capture with + | None -> + Journal_capture.create ~session_number:snapshot.next_local_sequence ~source + | Some capture -> Journal_capture.update_source capture ~source + in + (match Journal_capture.phase capture with + | Saving -> Effect.ignore + | Failed _ -> + let capture, request = Journal_capture.retry capture in + (match request with + | None -> Effect.ignore + | Some request -> + with_direct_request + (Root_navigation.step snapshot (Capture_admitted capture)) + request) + | Editing -> + if String.equal (String.trim source) "" + then Effect.ignore + else ( + match Journal_calendar.Sampler.sample calendar_sampler with + | Error error -> + update (fun state -> + { state with + capture_error = + Some (Local_capture_failure (Journal_calendar.error_message error)) + }) + | Ok calendar -> + Journal_graph_runtime.set_calendar graph_runtime calendar; + let creation_time = Journal_time.of_calendar calendar |> Result.get_ok in + let number = snapshot.next_local_sequence in + let admission = + with_block_identity + ~creation_time + ~f:(fun block_id -> + Journal_capture.admit_save + capture + ~mutation_id:(fresh_identity ()) + ~block_id:(Logseq_db_types.Graph_types.Uuid.to_string block_id) + ~sibling_order:(sibling_order number) + ~calendar_generation:(Journal_calendar.generation calendar) + ~creation_time) + () + in + (match admission with + | Error message -> + update (fun state -> + { state with + direct_capture = Some capture + ; capture_error = Some (Local_capture_failure message) + }) + | Ok (_, None) -> Effect.ignore + | Ok (capture, Some request) -> + with_direct_request + (Root_navigation.step + { snapshot with + calendar = Some calendar + ; next_local_sequence = Int64.succ number + } + (Capture_admitted capture)) + request))) + in + let payload = + match payload with + | Ui.Event.Payload.Text action + when String.starts_with ~prefix:"media-session:" action -> + let prefix = "media-session:" ^ media_scope snapshot ^ ":" in + if String.starts_with ~prefix action + then + Ui.Event.Payload.Text + (String.sub + action + (String.length prefix) + (String.length action - String.length prefix)) + else Ui.Event.Payload.Unit + | Ui.Event.Payload.Text action + when String.starts_with ~prefix:"detail-session:" action -> + let prefix = Detail_outline.scope snapshot.routes in + if String.starts_with ~prefix action + then + Ui.Event.Payload.Text + (String.sub + action + (String.length prefix) + (String.length action - String.length prefix)) + else Ui.Event.Payload.Unit + | payload -> payload + in + match payload with + | payload + when local_deletion_active snapshot + && + match payload with + | Ui.Event.Payload.Text ("open-diagnostics" | "close-diagnostics") + | Ui.Event.Payload.Navigation_path_changed _ -> false + | Ui.Event.Payload.Text text + when String.starts_with ~prefix:"asset-settings:" text -> false + | _ -> true -> Effect.ignore + | Ui.Event.Payload.Text "open-asset-settings" -> + update (fun state -> { state with asset_settings_open = true }) + | Ui.Event.Payload.Text text when String.starts_with ~prefix:"asset-settings:" text -> + (match + Journal_asset_settings.decode (String.sub text 15 (String.length text - 15)) + with + | None -> Effect.ignore + | Some Dismissed -> + update (fun state -> { state with asset_settings_open = false }) + | Some (Retry_upload operation) -> + if local_deletion_active snapshot + then Effect.ignore + else + Effect.of_thunk (fun () -> + let current = !state_ref in + let uploads = + Journal_uploads.sync current.uploads (upload_context current) + in + Option.iter + (fun request -> ignore (Worker.send client request : Worker.send_result)) + (Journal_uploads.retry uploads operation)) + | Some (Days settings) -> + Effect.of_thunk (fun () -> + asset_settings := Some settings; + let current = !state_ref in + if current.graph_state.phase = Graph_open + then + refresh_assets + ~graph_generation:current.graph_state.generation + current.calendar)) + | Ui.Event.Payload.Confirmation_response response -> + set_state_and_effect (fun state -> + match state.modal with + | Cache_reset_confirmation graph_id + when response.token = state.confirmation_sequence -> + (match response.result with + | Action "delete" when local_deletion_available state -> + ( Root_navigation.step state Local_copy_deleted + , send_manager (Graph_service.Delete_local_cache graph_id) ) + | Action "cancel" | Dismissed -> { state with modal = No_modal }, Effect.ignore + | Action _ -> state, Effect.ignore) + | _ -> state, Effect.ignore) + | Ui.Event.Payload.Text_edit edit -> + update (fun state -> + match state.modal, state.manager with + | Capture_sheet, _ -> Root_navigation.step state (Capture_native_edit edit) + | Append_sheet, _ -> + (match Journal_routes.detail state.routes with + | None -> state + | Some detail -> + { state with + routes = + Journal_routes.update_detail + state.routes + (Journal_detail.apply_child_edit detail edit) + }) + | _, Some { startup = { awaiting_e2ee_password = true; _ }; _ } + | _, Some { startup = { failure = Some During_e2ee; _ }; _ } -> + { state with + e2ee_password = Journal_capture.apply_text_edit state.e2ee_password edit + } + | _ -> state) + | Ui.Event.Payload.Text "capture-submit" -> + (match snapshot.modal, snapshot.direct_capture with + | Capture_sheet, Some capture -> + admit_direct_capture (Journal_capture.source capture) + | _ -> Effect.ignore) + | Ui.Event.Payload.Text "select-journals" -> + update (fun state -> Root_navigation.step state (Select Journal_routes.Journals)) + | Ui.Event.Payload.Text "select-favorites" -> + update (fun state -> Root_navigation.step state (Select Journal_routes.Favorites)) + | Ui.Event.Payload.Text "favorites-retry" -> + update (fun state -> favorites_event state Retry) + | Ui.Event.Payload.Int64_pair { first = first_index; second = last_exclusive } + when Journal_routes.destination snapshot.routes = Journal_routes.Favorites -> + update (fun state -> + favorites_event + state + (Visible + { first_index = Int64.to_int first_index + ; last_exclusive = Int64.to_int last_exclusive + })) + | Ui.Event.Payload.Visible_range _ + when Journal_routes.destination snapshot.routes = Journal_routes.Favorites -> + Effect.ignore + | Ui.Event.Payload.Visible_range range -> + let observe timeline = + let total_count = Journal_timeline_state.total_count timeline in + let bounded value = + value |> Int64.max 0L |> Int64.min (Int64.of_int total_count) |> Int64.to_int in - let detail_event event = + let first_index = bounded range.first_index in + let last_exclusive = bounded range.last_exclusive in + Journal_timeline_state.observe_visible_range timeline ~first_index ~last_exclusive + in + (* Redelivery does not change the pure timeline. Avoid scheduling a + no-op model update, which would recreate native menu bindings. *) + if observe snapshot.timeline = snapshot.timeline + then Effect.ignore + else update (fun state -> { state with timeline = observe state.timeline }) + | Ui.Event.Payload.Navigation_path_changed [] -> update back_state + | Ui.Event.Payload.Bool false -> + update (fun state -> + match state.modal with + | Diagnostics -> + { state with + modal = No_modal + ; admission_refresh = Admission_refresh.close state.admission_refresh + } + | No_modal -> state + | Capture_sheet + | Append_sheet + | Status_sheet _ + | Error_info + | Cache_reset_confirmation _ -> { state with modal = No_modal }) + | Ui.Event.Payload.Text action -> + if String.starts_with ~prefix:"media:" action + then + Effect.bind + (Effect.of_thunk (fun () -> + sync_media snapshot; + try + let json = + Yojson.Basic.from_string (String.sub action 6 (String.length action - 6)) + in + let field name = Yojson.Basic.Util.member name json in + let text name = Yojson.Basic.Util.to_string (field name) in + let root = text "root" in + let visible = Yojson.Basic.Util.to_bool (field "visible") in + match text "action" with + | "root" -> Journal_media_runtime.root_visible media_runtime ~root visible + | "asset" -> + Journal_media_runtime.asset_visible + media_runtime + ~root + ~asset:(text "asset") + visible + | "retry" -> + Journal_media_runtime.retry media_runtime ~root ~asset:(text "asset") + | "next" -> Journal_media_runtime.next media_runtime ~root + | "replace" -> Journal_media_runtime.begin_replace media_runtime ~root + | "reuse" -> Journal_media_runtime.begin_reuse media_runtime ~root + | "reuse-select" -> + Journal_media_runtime.reuse_select + media_runtime + ~root + ~asset:(text "asset") + | "reuse-next" -> Journal_media_runtime.reuse_next media_runtime ~root + | "reuse-cancel" -> Journal_media_runtime.end_reuse media_runtime ~root + | _ -> () + with + | _ -> ())) + ~f:(fun () -> flush_media set_state) + else if String.starts_with ~prefix:"import-asset:" action + then ( + let import_payload = String.sub action 13 (String.length action - 13) in + if Journal_asset_import.is_dismissal import_payload + then update (fun state -> { state with pending_replace = None }) + else ( match Journal_routes.detail snapshot.routes with - | None -> Bonsai.Effect.Ignore + | None -> Effect.ignore | Some detail -> - let detail, requests = Journal_detail.step detail event in - Bonsai.Effect.bind - (update (fun state -> - { state with routes = Journal_routes.update_detail state.routes detail })) - ~f:(fun () -> Bonsai.Effect.Many (List.map send requests)) - in - let update_draft ~toggle source = + let target = + Logseq_db_types.Graph_types.Uuid.of_string + (Journal_model.id (Journal_detail.root detail)) + in + let source = + Result.bind target (fun target -> + Journal_asset_import.decode ~target import_payload) + in + (match source with + | Error _ -> update (fun state -> { state with pending_replace = None }) + | Ok source -> + let operation = + Logseq_db_types.Graph_types.Uuid.to_string source.operation + in + let graph_generation = snapshot.graph_state.generation in + Effect.many + [ update (fun state -> { state with pending_replace = None }) + ; Effect.bind + (Effect.of_thunk (fun () -> + if not snapshot.write_enabled + then Some "The destination is not ready for imports" + else ( + match + Worker.send + client + (Graph_service.Import_asset { graph_generation; source }) + with + | Accepted id -> + Hashtbl.replace + import_worker_requests + id + (graph_generation, operation); + None + | Full | Not_ready | Stopping -> + Some + "Import is temporarily unavailable. Select the file again."))) + ~f:(function + | None -> Effect.ignore + | Some message -> + update (fun state -> + { state with + import_completion = Some (operation, Some message) + })) + ]))) + else if String.length action > 13 && String.sub action 0 13 = "select-graph:" + then ( + let graph_id = String.sub action 13 (String.length action - 13) in + match Logseq_db_types.Graph_types.Uuid.of_string graph_id with + | Error _ -> Effect.ignore + | Ok graph_id -> send_manager (Graph_service.Select_graph graph_id)) + else if String.equal action "refresh-catalog" + then send_manager Graph_service.Refresh_catalog + else if String.equal action "begin-online-recovery" + then send_manager Graph_service.Begin_online_recovery + else if + String.equal action "open-capture" + && snapshot.write_enabled + && snapshot.pending_delete = None + && snapshot.pending_status = None + then update (fun state -> Root_navigation.step state Capture_opened) + else if String.equal action "close-composer" + then update (fun state -> { state with modal = No_modal }) + else if + String.equal action "capture-task-on" || String.equal action "capture-task-off" + then + update (fun state -> + Root_navigation.step state (Capture_task_intent (action = "capture-task-on"))) + else if + String.equal action "open-append" + && snapshot.write_enabled + && snapshot.pending_delete = None + && snapshot.pending_status = None + then + update (fun state -> + match Journal_routes.detail state.routes with + | None -> state + | Some detail -> + let detail = + if Journal_detail.child_capture detail = None + then Journal_detail.update_child_source detail "" + else detail + in + { state with + modal = Append_sheet + ; routes = Journal_routes.update_detail state.routes detail + }) + else if String.equal action "close-status" + then + update (fun state -> + match state.modal with + | Status_sheet _ -> { state with modal = No_modal } + | _ -> state) + else if String.equal action "open-diagnostics" + then + set_state_and_effect (fun state -> + let admission_refresh, directive = + Admission_refresh.open_ + state.admission_refresh + ~graph_generation:state.graph_state.generation + ~graph_open:(state.graph_state.phase = Graph_open) + in + ( { state with modal = Diagnostics; admission_refresh } + , run_admission_directive set_state_and_effect directive )) + else if String.equal action "close-diagnostics" + then + update (fun state -> + { state with + modal = No_modal + ; admission_refresh = Admission_refresh.close state.admission_refresh + }) + else if String.equal action "open-error-info" + then + update (fun state -> if - (not snapshot.write_enabled) - || snapshot.pending_delete <> None - || snapshot.pending_status <> None - then Bonsai.Effect.Ignore - else - update (fun state -> - match Journal_routes.detail state.routes with - | None -> state - | Some detail -> - let detail = Journal_detail.update_child_source detail source in - let detail = - if toggle then Journal_detail.toggle_child_task detail else detail - in - { state with routes = Journal_routes.update_detail state.routes detail }) + state.worker_errors = [] + && Option.is_none + (Option.bind state.manager (fun manager -> manager.last_error)) + && Option.is_none (operation_failure state.timeline_notice) + then state + else { state with modal = Error_info }) + else if String.equal action "dismiss-operation-error" + then + update (fun state -> + match state.timeline_notice with + | Some (Delete_failed _ | Status_failed _) -> + { state with timeline_notice = None } + | None | Some Delete_undo -> state) + else if String.equal action "close-error-info" + then update (fun state -> { state with modal = No_modal }) + else if String.equal action "switch-graph" + then + Effect.many + [ update (fun state -> { state with modal = No_modal }) + ; send_manager Graph_service.Return_to_graph_picker + ] + else if String.equal action "sign-out" + then ( + sign_out_in_flight := true; + Effect.many + [ update (fun state -> Root_navigation.step state Account_cleared) + ; send_manager (Graph_service.Reconcile_authenticated_user { user_id = None }) + ]) + else if String.equal action "submit-e2ee-password" + then ( + let password = Journal_capture.source snapshot.e2ee_password in + if String.equal (String.trim password) "" + then Effect.ignore + else + Effect.many + [ send_manager (Graph_service.Submit_e2ee_password password) + ; update (fun state -> + { state with + e2ee_password = + Journal_capture.create + ~session_number:state.next_local_sequence + ~source:"" + ; next_local_sequence = Int64.succ state.next_local_sequence + }) + ]) + else if String.equal action "request-local-cache-reset" + then + update (fun state -> + match state.manager with + | Some { selected_graph = Some graph_id; _ } when local_deletion_available state + -> + { state with + modal = Cache_reset_confirmation graph_id + ; confirmation_sequence = Int64.succ state.confirmation_sequence + } + | None | Some _ -> state) + else if String.equal action "delete-undo" + then + update (fun state -> + match state.pending_delete with + | Some ({ phase = Undoable; _ } as pending) -> + { (restore_deleted state pending) with + pending_delete = None + ; timeline_notice = None + } + | None | Some { phase = Committing; _ } -> state) + else if String.equal action "back" + then update back_state + else if String.starts_with ~prefix:"timeline-retry:" action + then ( + match int_of_string_opt (String.sub action 15 (String.length action - 15)) with + | None -> Effect.ignore + | Some day -> + update (fun state -> + { state with timeline = Journal_timeline_state.retry_day state.timeline ~day })) + else if String.starts_with ~prefix:"detail-expand:" action + then + detail_event + (Set_branch_expanded (String.sub action 14 (String.length action - 14), true)) + else if String.starts_with ~prefix:"detail-collapse:" action + then + detail_event + (Set_branch_expanded (String.sub action 16 (String.length action - 16), false)) + else if String.starts_with ~prefix:"detail-more:" action + then detail_event (Load_more (String.sub action 12 (String.length action - 12))) + else if String.starts_with ~prefix:"detail-draft:" action + then update_draft ~toggle:false (String.sub action 13 (String.length action - 13)) + else if String.starts_with ~prefix:"detail-task-intent:" action + then update_draft ~toggle:true (String.sub action 19 (String.length action - 19)) + else if String.equal action "detail-retry" + then ( + match Journal_routes.detail snapshot.routes with + | None -> + (match Journal_routes.detail_block_id snapshot.routes with + | Some id -> open_block id + | None -> Effect.ignore) + | Some detail -> + let number = snapshot.next_local_sequence in + let detail, request = Journal_detail.retry detail in + (match request with + | None -> Effect.ignore + | Some request -> + with_direct_request + { snapshot with + routes = Journal_routes.update_detail snapshot.routes detail + ; next_local_sequence = Int64.succ number + } + request)) + else if + String.starts_with ~prefix:"detail-submit:" action + && snapshot.write_enabled + && snapshot.pending_delete = None + && snapshot.pending_status = None + then ( + match Journal_routes.detail snapshot.routes, snapshot.calendar with + | Some detail, Some _ -> + let detail = + Journal_detail.update_child_source + detail + (String.sub action 14 (String.length action - 14)) + in + (match Journal_calendar.Sampler.sample calendar_sampler with + | Error error -> + update (fun state -> + { state with + capture_error = + Some (Local_capture_failure (Journal_calendar.error_message error)) + }) + | Ok calendar -> + Journal_graph_runtime.set_calendar graph_runtime calendar; + let creation_time = Journal_time.of_calendar calendar |> Result.get_ok in + let number = snapshot.next_local_sequence in + let admission = + with_block_identity + ~creation_time + ~f:(fun block_id -> + if + match Journal_detail.mode detail with + | Failed _ -> true + | _ -> false + then Journal_detail.retry detail + else + Journal_detail.admit_child + detail + ~mutation_id:(fresh_identity ()) + ~calendar_generation:(Journal_calendar.generation calendar) + ~block_id:(Logseq_db_types.Graph_types.Uuid.to_string block_id) + ~sibling_order:(sibling_order number) + ~creation_time) + () + in + (match admission with + | Error message -> + update (fun state -> + { state with capture_error = Some (Local_capture_failure message) }) + | Ok (_, None) -> Effect.ignore + | Ok (detail, Some request) -> + with_direct_request + { snapshot with + calendar = Some calendar + ; routes = Journal_routes.update_detail snapshot.routes detail + ; capture_error = None + ; next_local_sequence = Int64.succ number + } + request)) + | None, _ | _, None -> Effect.ignore) + else if String.length action > 16 && String.sub action 0 16 = "timeline-status:" + then ( + let block_id = String.sub action 16 (String.length action - 16) in + match + ( snapshot.write_enabled + , snapshot.pending_delete + , snapshot.pending_status + , block_in_timeline snapshot.timeline block_id ) + with + | true, None, None, Some _ -> + update (fun state -> { state with modal = Status_sheet block_id }) + | false, _, _, _ + | true, Some _, _, _ + | true, None, Some _, _ + | true, None, None, None -> Effect.ignore) + else if String.length action > 20 && String.sub action 0 20 = "status-sheet-select:" + then ( + let tag = String.sub action 20 (String.length action - 20) in + let task_state = List.assoc_opt tag status_sheet_options in + match + ( snapshot.modal + , snapshot.write_enabled + , snapshot.pending_delete + , snapshot.pending_status + , task_state ) + with + | Status_sheet block_id, true, None, None, Some task_state -> + (match block_in_timeline snapshot.timeline block_id with + | None -> update (fun state -> { state with modal = No_modal }) + | Some block when Journal_model.task_state block = task_state -> Effect.ignore + | Some block -> + let pending_status = + { mutation_id = fresh_identity () + ; block_id + ; expected_revision = Journal_model.revision block + ; task_state + } + in + let request = + Journal_graph_request.Set_task_state + { mutation_id = pending_status.mutation_id + ; block_id + ; expected_revision = pending_status.expected_revision + ; task_state + } + in + with_request + { snapshot with + modal = No_modal + ; pending_status = Some pending_status + ; timeline_notice = None + } + request) + | No_modal, _, _, _, _ + | Capture_sheet, _, _, _, _ + | Append_sheet, _, _, _, _ + | Diagnostics, _, _, _, _ + | Error_info, _, _, _, _ + | Cache_reset_confirmation _, _, _, _, _ + | Status_sheet _, false, _, _, _ + | Status_sheet _, true, Some _, _, _ + | Status_sheet _, true, None, Some _, _ + | Status_sheet _, true, None, None, None -> Effect.ignore) + else if + String.starts_with ~prefix:"timeline-delete:" action + || String.starts_with ~prefix:"detail-delete:" action + then ( + let prefix_length = + if String.starts_with ~prefix:"detail-delete:" action then 14 else 16 in - let admit_direct_capture source = - match - ( snapshot.write_enabled - , snapshot.pending_delete - , snapshot.pending_status - , snapshot.calendar ) - with - | false, _, _, _ - | true, Some _, _, _ - | true, None, Some _, _ - | true, None, None, None -> Bonsai.Effect.Ignore - | true, None, None, Some _ -> - let capture = - match snapshot.direct_capture with - | None -> - Journal_capture.create - ~session_number:snapshot.next_local_sequence - ~source - | Some capture -> Journal_capture.update_source capture ~source - in - (match Journal_capture.phase capture with - | Saving -> Bonsai.Effect.Ignore - | Failed _ -> - let capture, request = Journal_capture.retry capture in - (match request with - | None -> Bonsai.Effect.Ignore - | Some request -> - with_direct_request - (Root_navigation.step snapshot (Capture_admitted capture)) - request) - | Editing -> - if String.equal (String.trim source) "" - then Bonsai.Effect.Ignore - else ( - match Journal_calendar.Sampler.sample calendar_sampler with - | Error error -> - update (fun state -> - { state with - capture_error = - Some - (Local_capture_failure (Journal_calendar.error_message error)) - }) - | Ok calendar -> - Journal_graph_runtime.set_calendar graph_runtime calendar; - let creation_time = - Journal_time.of_calendar calendar |> Result.get_ok - in - let number = snapshot.next_local_sequence in - let admission = - with_block_identity - ~creation_time - ~f:(fun block_id -> - Journal_capture.admit_save - capture - ~mutation_id:(fresh_identity ()) - ~block_id:(Logseq_db_types.Graph_types.Uuid.to_string block_id) - ~sibling_order:(sibling_order number) - ~calendar_generation:(Journal_calendar.generation calendar) - ~creation_time) - () - in - (match admission with - | Error message -> - update (fun state -> - { state with - direct_capture = Some capture - ; capture_error = Some (Local_capture_failure message) - }) - | Ok (_, None) -> Bonsai.Effect.Ignore - | Ok (capture, Some request) -> - with_direct_request - (Root_navigation.step - { snapshot with - calendar = Some calendar - ; next_local_sequence = Int64.succ number - } - (Capture_admitted capture)) - request))) + let block_id = + String.sub action prefix_length (String.length action - prefix_length) in - let payload = - match payload with - | Ui.Event.Payload.Text action - when String.starts_with ~prefix:"media-session:" action -> - let prefix = "media-session:" ^ media_scope snapshot ^ ":" in - if String.starts_with ~prefix action - then - Ui.Event.Payload.Text - (String.sub - action - (String.length prefix) - (String.length action - String.length prefix)) - else Ui.Event.Payload.Unit - | Ui.Event.Payload.Text action - when String.starts_with ~prefix:"detail-session:" action -> - let prefix = Detail_outline.scope snapshot.routes in - if String.starts_with ~prefix action - then - Ui.Event.Payload.Text - (String.sub - action - (String.length prefix) - (String.length action - String.length prefix)) - else Ui.Event.Payload.Unit - | payload -> payload + let block = + match Journal_routes.detail snapshot.routes with + | Some detail -> Journal_detail.find_block detail ~block_id + | None -> block_in_timeline snapshot.timeline block_id in - match payload with - | payload - when local_deletion_active snapshot - && - match payload with - | Ui.Event.Payload.Text ("open-diagnostics" | "close-diagnostics") - | Ui.Event.Payload.Navigation_path_changed _ -> false - | Ui.Event.Payload.Text text - when String.starts_with ~prefix:"asset-settings:" text -> false - | _ -> true -> Bonsai.Effect.Ignore - | Ui.Event.Payload.Text "open-asset-settings" -> - update (fun state -> { state with asset_settings_open = true }) - | Ui.Event.Payload.Text text - when String.starts_with ~prefix:"asset-settings:" text -> - (match - Journal_asset_settings.decode (String.sub text 15 (String.length text - 15)) - with - | None -> Bonsai.Effect.Ignore - | Some Dismissed -> - update (fun state -> { state with asset_settings_open = false }) - | Some (Retry_upload operation) -> - if local_deletion_active snapshot - then Bonsai.Effect.Ignore + match + snapshot.write_enabled, snapshot.pending_delete, snapshot.pending_status, block + with + | true, None, None, Some block -> + let saving = + Option.fold + ~none:false + ~some:(fun detail -> Journal_detail.mode detail = Saving_child) + (Journal_routes.detail snapshot.routes) + in + if saving + then Effect.ignore + else ( + let duration = + if snapshot.environment.accessible_navigation then 10. else 5. + in + Effect.bind current_time ~f:(fun now -> + let pending = + { mutation_id = fresh_identity () + ; block_id + ; expected_revision = Journal_model.revision block + ; staged = None + ; detail_staged = None + ; deadline = Core.Time_ns.add now (Core.Time_ns.Span.of_sec duration) + ; phase = Undoable + } + in + update (fun state -> + hide_deleted { state with timeline_notice = Some Delete_undo } pending))) + | _ -> Effect.ignore) + else if String.starts_with ~prefix:"timeline-open-block:" action + then open_block (String.sub action 20 (String.length action - 20)) + else if String.starts_with ~prefix:"favorite-open-block:" action + then open_favorite (String.sub action 20 (String.length action - 20)) + else Effect.ignore + | Ui.Event.Payload.Native_event _ + | Unit + | Bool _ + | Int _ + | Int64 _ + | Int64_bool _ + | Navigation_path_changed _ + | Int64_pair _ + | Float _ + | Scroll _ + | Native_list_completion _ + | Event _ -> Effect.ignore + in + let dispatch = + Ui.Event.Handler.create ~name:"journal-dispatch" (fun payload -> + Effect.run (handle_dispatch payload)) + in + let timeline_scroll_completed = + Ui.Event.Handler.create ~name:"timeline-scroll-completed" (fun payload -> + let generation = !state_ref.graph_state.generation in + Effect.run + (match V.Native_list.completion_of_payload payload with + | None -> Effect.ignore + | Some completion -> + set_state (fun state -> + if state.graph_state.generation <> generation + then state else - Bonsai.Effect.of_thunk (fun () -> - let current = !state_ref in - let uploads = - Journal_uploads.sync current.uploads (upload_context current) - in - Option.iter - (fun request -> - ignore (Worker.send client request : Worker.send_result)) - (Journal_uploads.retry uploads operation)) - | Some (Days settings) -> - Bonsai.Effect.of_thunk (fun () -> - asset_settings := Some settings; - let current = !state_ref in - if current.graph_state.phase = Graph_open - then - refresh_assets - ~graph_generation:current.graph_state.generation - current.calendar)) - | Ui.Event.Payload.Confirmation_response response -> - set_state_and_effect (fun state -> - match state.modal with - | Cache_reset_confirmation graph_id - when response.token = state.confirmation_sequence -> - (match response.result with - | Action "delete" when local_deletion_available state -> - ( Root_navigation.step state Local_copy_deleted - , send_manager (Graph_service.Delete_local_cache graph_id) ) - | Action "cancel" | Dismissed -> - { state with modal = No_modal }, Bonsai.Effect.Ignore - | Action _ -> state, Bonsai.Effect.Ignore) - | _ -> state, Bonsai.Effect.Ignore) - | Ui.Event.Payload.Text_edit edit -> - update (fun state -> - match state.modal, state.manager with - | Capture_sheet, _ -> Root_navigation.step state (Capture_native_edit edit) - | Append_sheet, _ -> - (match Journal_routes.detail state.routes with + { state with + timeline = + Journal_timeline_state.complete_scroll + state.timeline + ~token:completion.token + ~outcome:completion.outcome + }))) + in + let detail_scroll_completed = + Ui.Event.Handler.create ~name:"detail-scroll-completed" (fun payload -> + let generation = !state_ref.graph_state.generation in + let route = Journal_routes.detail_request_generation !state_ref.routes in + Effect.run + (match V.Native_list.completion_of_payload payload with + | None -> Effect.ignore + | Some completion -> + set_state (fun state -> + if + state.graph_state.generation <> generation + || Journal_routes.detail_request_generation state.routes <> route + then state + else ( + match Journal_routes.detail state.routes with | None -> state | Some detail -> { state with routes = Journal_routes.update_detail state.routes - (Journal_detail.apply_child_edit detail edit) - }) - | _, Some { startup = { awaiting_e2ee_password = true; _ }; _ } - | _, Some { startup = { failure = Some During_e2ee; _ }; _ } -> - { state with - e2ee_password = Journal_capture.apply_text_edit state.e2ee_password edit - } - | _ -> state) - | Ui.Event.Payload.Text "capture-submit" -> - (match snapshot.modal, snapshot.direct_capture with - | Capture_sheet, Some capture -> - admit_direct_capture (Journal_capture.source capture) - | _ -> Bonsai.Effect.Ignore) - | Ui.Event.Payload.Text "select-journals" -> - update (fun state -> - Root_navigation.step state (Select Journal_routes.Journals)) - | Ui.Event.Payload.Text "select-favorites" -> - update (fun state -> - Root_navigation.step state (Select Journal_routes.Favorites)) - | Ui.Event.Payload.Text "favorites-retry" -> - update (fun state -> favorites_event state Retry) - | Ui.Event.Payload.Int64_pair { first = first_index; second = last_exclusive } - when Journal_routes.destination snapshot.routes = Journal_routes.Favorites -> - update (fun state -> - favorites_event - state - (Visible - { first_index = Int64.to_int first_index - ; last_exclusive = Int64.to_int last_exclusive - })) - | Ui.Event.Payload.Visible_range _ - when Journal_routes.destination snapshot.routes = Journal_routes.Favorites -> - Bonsai.Effect.Ignore - | Ui.Event.Payload.Visible_range range -> - let observe timeline = - let total_count = Journal_timeline_state.total_count timeline in - let bounded value = - value - |> Int64.max 0L - |> Int64.min (Int64.of_int total_count) - |> Int64.to_int - in - let first_index = bounded range.first_index in - let last_exclusive = bounded range.last_exclusive in - Journal_timeline_state.observe_visible_range - timeline - ~first_index - ~last_exclusive - in - (* Redelivery does not change the pure timeline. Avoid scheduling a - no-op model update, which would recreate native menu bindings. *) - if observe snapshot.timeline = snapshot.timeline - then Bonsai.Effect.Ignore - else update (fun state -> { state with timeline = observe state.timeline }) - | Ui.Event.Payload.Navigation_path_changed [] -> update back_state - | Ui.Event.Payload.Bool false -> - update (fun state -> - match state.modal with - | Diagnostics -> - { state with - modal = No_modal - ; admission_refresh = Admission_refresh.close state.admission_refresh - } - | No_modal -> state - | Capture_sheet - | Append_sheet - | Status_sheet _ - | Error_info - | Cache_reset_confirmation _ -> { state with modal = No_modal }) - | Ui.Event.Payload.Text action -> - if String.starts_with ~prefix:"media:" action - then ( - Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> - sync_media snapshot; - try - let json = - Yojson.Basic.from_string - (String.sub action 6 (String.length action - 6)) - in - let field name = Yojson.Basic.Util.member name json in - let text name = Yojson.Basic.Util.to_string (field name) in - let root = text "root" in - let visible = Yojson.Basic.Util.to_bool (field "visible") in - match text "action" with - | "root" -> - Journal_media_runtime.root_visible media_runtime ~root visible - | "asset" -> - Journal_media_runtime.asset_visible - media_runtime - ~root - ~asset:(text "asset") - visible - | "retry" -> - Journal_media_runtime.retry media_runtime ~root ~asset:(text "asset") - | "next" -> Journal_media_runtime.next media_runtime ~root - | "replace" -> Journal_media_runtime.begin_replace media_runtime ~root - | "reuse" -> Journal_media_runtime.begin_reuse media_runtime ~root - | "reuse-select" -> - Journal_media_runtime.reuse_select - media_runtime - ~root - ~asset:(text "asset") - | "reuse-next" -> Journal_media_runtime.reuse_next media_runtime ~root - | "reuse-cancel" -> - Journal_media_runtime.end_reuse media_runtime ~root - | _ -> () - with - | _ -> ())) - ~f:(fun () -> flush_media set_state)) - else if String.starts_with ~prefix:"import-asset:" action - then ( - let import_payload = - String.sub action 13 (String.length action - 13) - in - if Journal_asset_import.is_dismissal import_payload - then update (fun state -> { state with pending_replace = None }) - else - match Journal_routes.detail snapshot.routes with - | None -> Bonsai.Effect.Ignore - | Some detail -> - let target = - Logseq_db_types.Graph_types.Uuid.of_string - (Journal_model.id (Journal_detail.root detail)) - in - let source = - Result.bind target (fun target -> - Journal_asset_import.decode ~target import_payload) - in - (match source with - | Error _ -> - update (fun state -> { state with pending_replace = None }) - | Ok source -> - let operation = - Logseq_db_types.Graph_types.Uuid.to_string source.operation - in - let graph_generation = snapshot.graph_state.generation in - Bonsai.Effect.Many - [ update (fun state -> { state with pending_replace = None }) - ; Bonsai.Effect.bind - (Bonsai.Effect.of_thunk (fun () -> - if not snapshot.write_enabled - then Some "The destination is not ready for imports" - else ( - match - Worker.send - client - (Graph_service.Import_asset { graph_generation; source }) - with - | Accepted id -> - Hashtbl.replace - import_worker_requests - id - (graph_generation, operation); - None - | Full | Not_ready | Stopping -> - Some "Import is temporarily unavailable. Select the file again."))) - ~f:(function - | None -> Bonsai.Effect.Ignore - | Some message -> - update (fun state -> - { state with import_completion = Some (operation, Some message) }))])) - else if String.length action > 13 && String.sub action 0 13 = "select-graph:" - then ( - let graph_id = String.sub action 13 (String.length action - 13) in - match Logseq_db_types.Graph_types.Uuid.of_string graph_id with - | Error _ -> Bonsai.Effect.Ignore - | Ok graph_id -> send_manager (Graph_service.Select_graph graph_id)) - else if String.equal action "refresh-catalog" - then send_manager Graph_service.Refresh_catalog - else if String.equal action "begin-online-recovery" - then send_manager Graph_service.Begin_online_recovery - else if - String.equal action "open-capture" - && snapshot.write_enabled - && snapshot.pending_delete = None - && snapshot.pending_status = None - then update (fun state -> Root_navigation.step state Capture_opened) - else if String.equal action "close-composer" - then update (fun state -> { state with modal = No_modal }) - else if - String.equal action "capture-task-on" - || String.equal action "capture-task-off" - then - update (fun state -> - Root_navigation.step - state - (Capture_task_intent (action = "capture-task-on"))) - else if - String.equal action "open-append" - && snapshot.write_enabled - && snapshot.pending_delete = None - && snapshot.pending_status = None - then - update (fun state -> - match Journal_routes.detail state.routes with - | None -> state - | Some detail -> - let detail = - if Journal_detail.child_capture detail = None - then Journal_detail.update_child_source detail "" - else detail - in - { state with - modal = Append_sheet - ; routes = Journal_routes.update_detail state.routes detail - }) - else if String.equal action "close-status" - then - update (fun state -> - match state.modal with - | Status_sheet _ -> { state with modal = No_modal } - | _ -> state) - else if String.equal action "open-diagnostics" - then - set_state_and_effect (fun state -> - let admission_refresh, directive = - Admission_refresh.open_ - state.admission_refresh - ~graph_generation:state.graph_state.generation - ~graph_open:(state.graph_state.phase = Graph_open) - in - ( { state with modal = Diagnostics; admission_refresh } - , run_admission_directive set_state_and_effect directive )) - else if String.equal action "close-diagnostics" - then - update (fun state -> - { state with - modal = No_modal - ; admission_refresh = Admission_refresh.close state.admission_refresh - }) - else if String.equal action "open-error-info" - then - update (fun state -> - if - state.worker_errors = [] - && Option.is_none - (Option.bind state.manager (fun manager -> manager.last_error)) - && Option.is_none (operation_failure state.timeline_notice) - then state - else { state with modal = Error_info }) - else if String.equal action "dismiss-operation-error" - then - update (fun state -> - match state.timeline_notice with - | Some (Delete_failed _ | Status_failed _) -> - { state with timeline_notice = None } - | None | Some Delete_undo -> state) - else if String.equal action "close-error-info" - then update (fun state -> { state with modal = No_modal }) - else if String.equal action "switch-graph" - then - Bonsai.Effect.Many - [ update (fun state -> { state with modal = No_modal }) - ; send_manager Graph_service.Return_to_graph_picker - ] - else if String.equal action "sign-out" - then ( - sign_out_in_flight := true; - Bonsai.Effect.Many - [ update (fun state -> Root_navigation.step state Account_cleared) - ; send_manager - (Graph_service.Reconcile_authenticated_user { user_id = None }) - ]) - else if String.equal action "submit-e2ee-password" - then ( - let password = Journal_capture.source snapshot.e2ee_password in - if String.equal (String.trim password) "" - then Bonsai.Effect.Ignore - else - Bonsai.Effect.Many - [ send_manager (Graph_service.Submit_e2ee_password password) - ; update (fun state -> - { state with - e2ee_password = - Journal_capture.create - ~session_number:state.next_local_sequence - ~source:"" - ; next_local_sequence = Int64.succ state.next_local_sequence - }) - ]) - else if String.equal action "request-local-cache-reset" - then - update (fun state -> - match state.manager with - | Some { selected_graph = Some graph_id; _ } - when local_deletion_available state -> - { state with - modal = Cache_reset_confirmation graph_id - ; confirmation_sequence = Int64.succ state.confirmation_sequence - } - | None | Some _ -> state) - else if String.equal action "delete-undo" - then - update (fun state -> - match state.pending_delete with - | Some ({ phase = Undoable; _ } as pending) -> - { (restore_deleted state pending) with - pending_delete = None - ; timeline_notice = None - } - | None | Some { phase = Committing; _ } -> state) - else if String.equal action "back" - then update back_state - else if String.starts_with ~prefix:"timeline-retry:" action - then ( - match - int_of_string_opt (String.sub action 15 (String.length action - 15)) - with - | None -> Bonsai.Effect.Ignore - | Some day -> - update (fun state -> - { state with - timeline = Journal_timeline_state.retry_day state.timeline ~day - })) - else if String.starts_with ~prefix:"detail-expand:" action - then - detail_event - (Set_branch_expanded (String.sub action 14 (String.length action - 14), true)) - else if String.starts_with ~prefix:"detail-collapse:" action - then - detail_event - (Set_branch_expanded - (String.sub action 16 (String.length action - 16), false)) - else if String.starts_with ~prefix:"detail-more:" action - then detail_event (Load_more (String.sub action 12 (String.length action - 12))) - else if String.starts_with ~prefix:"detail-draft:" action - then - update_draft ~toggle:false (String.sub action 13 (String.length action - 13)) - else if String.starts_with ~prefix:"detail-task-intent:" action - then - update_draft ~toggle:true (String.sub action 19 (String.length action - 19)) - else if String.equal action "detail-retry" - then ( - match Journal_routes.detail snapshot.routes with - | None -> - (match Journal_routes.detail_block_id snapshot.routes with - | Some id -> open_block id - | None -> Bonsai.Effect.Ignore) - | Some detail -> - let number = snapshot.next_local_sequence in - let detail, request = Journal_detail.retry detail in - (match request with - | None -> Bonsai.Effect.Ignore - | Some request -> - with_direct_request - { snapshot with - routes = Journal_routes.update_detail snapshot.routes detail - ; next_local_sequence = Int64.succ number - } - request)) - else if - String.starts_with ~prefix:"detail-submit:" action - && snapshot.write_enabled - && snapshot.pending_delete = None - && snapshot.pending_status = None - then ( - match Journal_routes.detail snapshot.routes, snapshot.calendar with - | Some detail, Some _ -> - let detail = - Journal_detail.update_child_source - detail - (String.sub action 14 (String.length action - 14)) - in - (match Journal_calendar.Sampler.sample calendar_sampler with - | Error error -> - update (fun state -> - { state with - capture_error = - Some (Local_capture_failure (Journal_calendar.error_message error)) - }) - | Ok calendar -> - Journal_graph_runtime.set_calendar graph_runtime calendar; - let creation_time = Journal_time.of_calendar calendar |> Result.get_ok in - let number = snapshot.next_local_sequence in - let admission = - with_block_identity - ~creation_time - ~f:(fun block_id -> - if - match Journal_detail.mode detail with - | Failed _ -> true - | _ -> false - then Journal_detail.retry detail - else - Journal_detail.admit_child - detail - ~mutation_id:(fresh_identity ()) - ~calendar_generation:(Journal_calendar.generation calendar) - ~block_id:(Logseq_db_types.Graph_types.Uuid.to_string block_id) - ~sibling_order:(sibling_order number) - ~creation_time) - () - in - (match admission with - | Error message -> - update (fun state -> - { state with capture_error = Some (Local_capture_failure message) }) - | Ok (_, None) -> Bonsai.Effect.Ignore - | Ok (detail, Some request) -> - with_direct_request - { snapshot with - calendar = Some calendar - ; routes = Journal_routes.update_detail snapshot.routes detail - ; capture_error = None - ; next_local_sequence = Int64.succ number - } - request)) - | None, _ | _, None -> Bonsai.Effect.Ignore) - else if String.length action > 16 && String.sub action 0 16 = "timeline-status:" - then ( - let block_id = String.sub action 16 (String.length action - 16) in - match - ( snapshot.write_enabled - , snapshot.pending_delete - , snapshot.pending_status - , block_in_timeline snapshot.timeline block_id ) - with - | true, None, None, Some _ -> - update (fun state -> { state with modal = Status_sheet block_id }) - | false, _, _, _ - | true, Some _, _, _ - | true, None, Some _, _ - | true, None, None, None -> Bonsai.Effect.Ignore) - else if - String.length action > 20 && String.sub action 0 20 = "status-sheet-select:" - then ( - let tag = String.sub action 20 (String.length action - 20) in - let task_state = List.assoc_opt tag status_sheet_options in - match - ( snapshot.modal - , snapshot.write_enabled - , snapshot.pending_delete - , snapshot.pending_status - , task_state ) - with - | Status_sheet block_id, true, None, None, Some task_state -> - (match block_in_timeline snapshot.timeline block_id with - | None -> update (fun state -> { state with modal = No_modal }) - | Some block when Journal_model.task_state block = task_state -> - Bonsai.Effect.Ignore - | Some block -> - let pending_status = - { mutation_id = fresh_identity () - ; block_id - ; expected_revision = Journal_model.revision block - ; task_state - } - in - let request = - Journal_graph_request.Set_task_state - { mutation_id = pending_status.mutation_id - ; block_id - ; expected_revision = pending_status.expected_revision - ; task_state - } - in - with_request - { snapshot with - modal = No_modal - ; pending_status = Some pending_status - ; timeline_notice = None - } - request) - | No_modal, _, _, _, _ - | Capture_sheet, _, _, _, _ - | Append_sheet, _, _, _, _ - | Diagnostics, _, _, _, _ - | Error_info, _, _, _, _ - | Cache_reset_confirmation _, _, _, _, _ - | Status_sheet _, false, _, _, _ - | Status_sheet _, true, Some _, _, _ - | Status_sheet _, true, None, Some _, _ - | Status_sheet _, true, None, None, None -> Bonsai.Effect.Ignore) - else if - String.starts_with ~prefix:"timeline-delete:" action - || String.starts_with ~prefix:"detail-delete:" action - then ( - let prefix_length = - if String.starts_with ~prefix:"detail-delete:" action then 14 else 16 - in - let block_id = - String.sub action prefix_length (String.length action - prefix_length) - in - let block = - match Journal_routes.detail snapshot.routes with - | Some detail -> Journal_detail.find_block detail ~block_id - | None -> block_in_timeline snapshot.timeline block_id - in - match - ( snapshot.write_enabled - , snapshot.pending_delete - , snapshot.pending_status - , block ) - with - | true, None, None, Some block -> - let saving = - Option.fold - ~none:false - ~some:(fun detail -> Journal_detail.mode detail = Saving_child) - (Journal_routes.detail snapshot.routes) - in - if saving - then Bonsai.Effect.Ignore - else ( - let duration = if environment.accessible_navigation then 10. else 5. in - Bonsai.Effect.bind current_time ~f:(fun now -> - let pending = - { mutation_id = fresh_identity () - ; block_id - ; expected_revision = Journal_model.revision block - ; staged = None - ; detail_staged = None - ; deadline = Core.Time_ns.add now (Core.Time_ns.Span.of_sec duration) - ; phase = Undoable - } - in - update (fun state -> - hide_deleted { state with timeline_notice = Some Delete_undo } pending))) - | _ -> Bonsai.Effect.Ignore) - else if String.starts_with ~prefix:"timeline-open-block:" action - then open_block (String.sub action 20 (String.length action - 20)) - else if String.starts_with ~prefix:"favorite-open-block:" action - then open_favorite (String.sub action 20 (String.length action - 20)) - else Bonsai.Effect.Ignore - | Ui.Event.Payload.Native_event _ - | Unit - | Bool _ - | Int64 _ - | Int64_bool _ - | Navigation_path_changed _ - | Navigation_split_changed _ - | Tab_selected _ - | Int64_pair _ - | Float _ - | Float_range _ - | Civil_date _ - | Civil_time _ - | Scroll _ - | Tap _ - | Pointer _ - | Key _ -> Bonsai.Effect.Ignore) - in - let notice_cancellation : Bonsai_swiftui.Host_effect.Cancellation.t option ref = - ref None - in - let notice_key = - Bonsai.Cont.map2 state environment ~f:(fun state environment -> - ( ( state.graph_ready && state.timeline_notice = Some Delete_undo - , state.capture_error ) - , environment.accessible_navigation )) - in - let notice_callback = - Bonsai.Cont.map - dispatch - ~f:(fun dispatch ((undo_available, capture_error), accessible_navigation) -> - Option.iter Bonsai_swiftui.Host_effect.Cancellation.cancel !notice_cancellation; - notice_cancellation := None; - match capture_error, undo_available with - | None, false -> Bonsai.Effect.Ignore - | _, _ -> - let cancellation = Bonsai_swiftui.Host_effect.Cancellation.create () in - notice_cancellation := Some cancellation; - let message, action_label, duration_ms = - match capture_error, undo_available with - | Some failure, _ -> capture_failure_message failure, None, 4_000 - | None, true -> - ( "Block and descendants removed" - , Some "Undo" - , if accessible_navigation then 10_000 else 5_000 ) - | None, false -> assert false - in - Bonsai.Effect.bind - (Bonsai_swiftui.Host_effect.show_notice - ~cancellation - ?action_label - ~duration_ms - host_effects - ~message - ()) - ~f:(function - | Ok Bonsai_swiftui.Host_effect.Action -> - Bonsai.Effect.of_thunk (fun () -> - Ui.Event.Handler.Private.invoke - dispatch - (Ui.Event.Payload.Text "delete-undo")) - | Ok (Dismiss | Swipe | Timeout) | Error _ -> Bonsai.Effect.Ignore)) - in - Bonsai.Cont.Edge.on_change - ~equal:(fun (left_notice, left_accessible) (right_notice, right_accessible) -> - left_notice = right_notice && Bool.equal left_accessible right_accessible) - notice_key - ~callback:notice_callback - graph; - let state = Bonsai.Cont.map2 state delete_timer ~f:(fun state () -> state) in - let state = - Bonsai.Cont.map3 state event_subscription platform_subscription ~f:(fun state () () -> - state) + (Journal_detail.complete_reveal + detail + ~token:completion.token + ~outcome:completion.outcome) + })))) in - let view_handlers = - Bonsai.Cont.map3 - dispatch - timeline_scroll_completed - detail_scroll_completed - ~f:(fun dispatch timeline detail -> dispatch, timeline, detail) + let notice_token_sequence = ref 0L in + let notice_cancellation : int64 option ref = ref None in + let cancel_notice token = + emit_platform_request (Journal_platform.notice_cancel_request ~token) in - Bonsai.Cont.map3 - state - view_handlers - environment - ~f: - (fun - state - (dispatch, timeline_scroll_completed, detail_scroll_completed) - environment - -> - let tokens = - Journal_visual_tokens.resolve - ~brightness:environment.brightness - ~high_contrast:environment.high_contrast - in - let capture_saving = - match state.direct_capture with - | Some capture -> Journal_capture.phase capture = Journal_capture.Saving - | None -> false - in - let row_actions_enabled = - state.write_enabled - && Option.is_none state.pending_delete - && Option.is_none state.pending_status - in - let sync_error = - Option.map (fun notice -> sync_failure_message notice.failure) state.sync_error - in - let root = - match state.graph_ready, state.manager with - | false, Some _ -> manager_page state dispatch - | false, None | true, _ -> - timeline_page - ~render_media:(media_label state dispatch) - ~platform:environment.platform - ~graph_generation:state.graph_state.generation - ~on_scroll_completed:timeline_scroll_completed - ~destination:(Journal_routes.destination state.routes) - ~favorites:state.favorites - ~on_select_destination: - (Ui.Event.Handler.create ~name:"select-root-destination" (function - | Ui.Event.Payload.Int64 0L -> - Ui.Event.Handler.Private.invoke dispatch (Text "select-journals") - | Int64 1L -> - Ui.Event.Handler.Private.invoke dispatch (Text "select-favorites") - | _ -> ())) - ~on_favorites_visible_range: - (Ui.Event.Handler.create ~name:"favorites-visible-range" (function - | Ui.Event.Payload.Visible_range range -> - Ui.Event.Handler.Private.invoke - dispatch - (Int64_pair - { first = range.first_index; second = range.last_exclusive }) - | _ -> ())) - ~on_favorites_retry:(bind_action dispatch "favorites-retry") - ~timeline_state:state.timeline - ~loading:(not state.feed_loaded) - ~graph_error:(Option.map graph_error_message state.graph_error) - ~sync_error - ~sync_phase: - (Option.map - (fun (manager : Graph_service.snapshot) -> manager.sync_phase) - state.manager) - ~day_presentation:(presentation_for_day state) - ~capture_enabled: - (state.write_enabled - && Option.is_none state.pending_delete - && Option.is_none state.pending_status - && not capture_saving) - ~on_capture_event:dispatch - ~on_visible_range:dispatch - ~on_retry_day:(prefix_action dispatch "timeline-retry:") - ~on_open_block:(prefix_action dispatch "timeline-open-block:") - ~on_open_favorite:(prefix_action dispatch "favorite-open-block:") - ~delete_enabled:state.write_enabled - ~actions_enabled:row_actions_enabled - ~interaction_enabled:(state.modal = No_modal) - ~on_status:(prefix_action dispatch "timeline-status:") - ~on_delete:(prefix_action dispatch "timeline-delete:") - ~error_info_available: - (state.worker_errors <> [] - || Option.is_some - (Option.bind state.manager (fun manager -> manager.last_error)) - || Option.is_some (operation_failure state.timeline_notice)) - ~on_error_info:(bind_action dispatch "open-error-info") - ~account_menu_available:true - ~on_account_action:dispatch - ~cache_reset_available:(local_deletion_available state) - in - let root = operation_feedback ~scope:"root" ~state dispatch root in - let path = - match Journal_routes.route state.routes with - | Journal_routes.Timeline -> [] - | Detail_loading | Detail | Missing_detail | Failed_detail _ -> - [ V.Navigation_stack.destination - ~page_key:(ID.Navigation.Page_key.of_string "journal-detail-route") - ~title:"Block" - ~can_pop:true - (detail_page ~state ~on_scroll_completed:detail_scroll_completed dispatch - |> operation_feedback ~scope:"detail" ~state dispatch) - ] + let notice_callback ((undo_available, capture_error), accessible_navigation) = + Option.iter cancel_notice !notice_cancellation; + notice_cancellation := None; + match capture_error, undo_available with + | None, false -> () + | _, _ -> + let token = Int64.succ !notice_token_sequence in + notice_token_sequence := token; + notice_cancellation := Some token; + let message, action_label, duration_ms = + match capture_error, undo_available with + | Some failure, _ -> capture_failure_message failure, None, 4_000 + | None, true -> + ( "Block and descendants removed" + , Some "Undo" + , if accessible_navigation then 10_000 else 5_000 ) + | None, false -> assert false in - let modal = - match state.modal with - | No_modal -> None - | Capture_sheet -> + emit_platform_request + (Journal_platform.show_notice_request ~token ~message ~action_label ~duration_ms) + ~k:(fun result -> + match result with + | Error _ -> () + | Ok payload -> + (match Journal_platform.decode_notice_response ~token payload with + | Ok Notice_action -> + Ui.Event.Handler.Private.invoke + dispatch + (Ui.Event.Payload.Text "delete-undo") + | Ok (Notice_dismiss | Notice_swipe | Notice_timeout) | Error _ -> ())) + in + let notice_key state = + ( (state.graph_ready && state.timeline_notice = Some Delete_undo, state.capture_error) + , state.environment.accessible_navigation ) + in + let prev_notice_key = ref (notice_key initial_state) in + (* Every [Edge.on_change] of the bonsai version becomes a post-update key + comparison: the key is recomputed from the new model and the callback + fires when it differs from the previous post-update value. *) + let run_edge_callbacks model = + (let key = feed_key model in + if not (Option.equal equal_feed_projection_context !prev_feed_key key) + then ( + prev_feed_key := key; + Effect.run (feed_callback key))); + (let key = timeline_presentation_key model in + if not (Option.equal ( = ) !prev_timeline_presentation_key key) + then ( + prev_timeline_presentation_key := key; + Effect.run (timeline_presentation_callback key))); + (let key = favorites_drain_key model in + if not (!prev_favorites_drain_key = key) + then ( + prev_favorites_drain_key := key; + Effect.run (favorites_drain_callback key))); + (let key = timeline_drain_key model in + if + not + (Option.equal + (fun (left_generation, left_request) (right_generation, right_request) -> + Int64.equal left_generation right_generation + && left_request = right_request) + !prev_timeline_drain_key + key) + then ( + prev_timeline_drain_key := key; + Effect.run (timeline_drain_callback key))); + (let key = upload_context model in + if not (!prev_upload_key = key) + then ( + prev_upload_key := key; + upload_callback ())); + (let key = media_key model in + if not (!prev_media_key = key) + then ( + prev_media_key := key; + media_callback ())); + (let key = notice_key model in + if + not + ((fun (left_notice, left_accessible) (right_notice, right_accessible) -> + left_notice = right_notice && Bool.equal left_accessible right_accessible) + !prev_notice_key + key) + then ( + prev_notice_key := key; + notice_callback key)); + (let key = delete_timer_key model in + if + not + (Option.equal + (fun (left_id, left_deadline) (right_id, right_deadline) -> + String.equal left_id right_id + && Core.Time_ns.equal left_deadline right_deadline) + !prev_delete_timer_key + key) + then ( + prev_delete_timer_key := key; + match key with + | None -> incr delete_timer_generation + | Some (mutation_id, deadline) -> arm_delete_timer mutation_id deadline)); + (let key = Option.map (fun notice -> notice.sequence) model.sync_error in + if not (Option.equal Int64.equal !prev_sync_error_key key) + then ( + prev_sync_error_key := key; + match key with + | None -> incr sync_error_timer_generation + | Some sequence -> arm_sync_error_timer sequence)); + model + in + let update model = function + | Update transition -> + let model, eff = transition model in + let model = track_capture_session model in + state_ref := model; + Effect.run eff; + state_ref := model; + let model = run_edge_callbacks model in + state_ref := model; + model + | Platform_response (tag, result) -> + (match Hashtbl.find_opt pending_platform tag with + | Some k -> + Hashtbl.remove pending_platform tag; + k result + | None -> ()); + model + | Environment_changed snapshot -> { model with environment = snapshot } + in + let body_view state dispatch timeline_scroll_completed detail_scroll_completed = + let tokens = + Journal_visual_tokens.resolve + ~brightness:state.environment.brightness + ~high_contrast:state.environment.high_contrast + in + let capture_saving = + match state.direct_capture with + | Some capture -> Journal_capture.phase capture = Journal_capture.Saving + | None -> false + in + let row_actions_enabled = + state.write_enabled + && Option.is_none state.pending_delete + && Option.is_none state.pending_status + in + let sync_error = + Option.map (fun notice -> sync_failure_message notice.failure) state.sync_error + in + let root = + match state.graph_ready, state.manager with + | false, Some _ -> manager_page state dispatch + | false, None | true, _ -> + timeline_page + ~render_media:(media_label state dispatch) + ~platform:state.environment.platform + ~graph_generation:state.graph_state.generation + ~on_scroll_completed:timeline_scroll_completed + ~destination:(Journal_routes.destination state.routes) + ~favorites:state.favorites + ~on_select_destination: + (Ui.Event.Handler.create ~name:"select-root-destination" (function + | Ui.Event.Payload.Int64 0L -> + Ui.Event.Handler.Private.invoke dispatch (Text "select-journals") + | Int64 1L -> + Ui.Event.Handler.Private.invoke dispatch (Text "select-favorites") + | _ -> ())) + ~on_favorites_visible_range: + (Ui.Event.Handler.create ~name:"favorites-visible-range" (function + | Ui.Event.Payload.Visible_range range -> + Ui.Event.Handler.Private.invoke + dispatch + (Int64_pair + { first = range.first_index; second = range.last_exclusive }) + | _ -> ())) + ~on_favorites_retry:(bind_action dispatch "favorites-retry") + ~timeline_state:state.timeline + ~loading:(not state.feed_loaded) + ~graph_error:(Option.map graph_error_message state.graph_error) + ~sync_error + ~sync_phase: + (Option.map + (fun (manager : Graph_service.snapshot) -> manager.sync_phase) + state.manager) + ~day_presentation:(presentation_for_day state) + ~capture_enabled: + (state.write_enabled + && Option.is_none state.pending_delete + && Option.is_none state.pending_status + && not capture_saving) + ~on_capture_event:dispatch + ~on_visible_range:dispatch + ~on_retry_day:(prefix_action dispatch "timeline-retry:") + ~on_open_block:(prefix_action dispatch "timeline-open-block:") + ~on_open_favorite:(prefix_action dispatch "favorite-open-block:") + ~delete_enabled:state.write_enabled + ~actions_enabled:row_actions_enabled + ~interaction_enabled:(state.modal = No_modal) + ~on_status:(prefix_action dispatch "timeline-status:") + ~on_delete:(prefix_action dispatch "timeline-delete:") + ~error_info_available: + (state.worker_errors <> [] + || Option.is_some + (Option.bind state.manager (fun manager -> manager.last_error)) + || Option.is_some (operation_failure state.timeline_notice)) + ~on_error_info:(bind_action dispatch "open-error-info") + ~account_menu_available:true + ~on_account_action:dispatch + ~cache_reset_available:(local_deletion_available state) + in + let root = operation_feedback ~scope:"root" ~state dispatch root in + let path = + match Journal_routes.route state.routes with + | Journal_routes.Timeline -> [] + | Detail_loading | Detail | Missing_detail | Failed_detail _ -> + [ V.Navigation_stack.destination + ~page_key:"journal-detail-route" + ~title:"Block" + ~can_pop:true + (detail_page ~state ~on_scroll_completed:detail_scroll_completed dispatch + |> operation_feedback ~scope:"detail" ~state dispatch) + ] + in + let modal = + match state.modal with + | No_modal -> None + | Capture_sheet -> + Option.map + (fun capture -> + composer_page + ~scope:"journal-capture" + ~saving:(Journal_capture.phase capture = Journal_capture.Saving) + ~capture + ~enabled:state.write_enabled + ~on_edit:dispatch + ~on_toggle: + (Ui.Event.Handler.create (function + | Ui.Event.Payload.Bool selected -> + Ui.Event.Handler.Private.invoke + dispatch + (Text (if selected then "capture-task-on" else "capture-task-off")) + | _ -> ())) + ~on_save:(bind_action dispatch "capture-submit") + ~on_close:(bind_action dispatch "close-composer") + ~error: + (match state.capture_error with + | Some failure -> Some (capture_failure_message failure) + | None -> + (match Journal_capture.phase capture with + | Failed message -> Some message + | Editing | Saving -> None))) + state.direct_capture + | Append_sheet -> + Option.bind (Journal_routes.detail state.routes) (fun detail -> Option.map (fun capture -> composer_page - ~scope:"journal-capture" - ~saving:(Journal_capture.phase capture = Journal_capture.Saving) + ~scope:"journal-append" + ~saving:(Journal_detail.mode detail = Journal_detail.Saving_child) ~capture ~enabled:state.write_enabled ~on_edit:dispatch ~on_toggle: (Ui.Event.Handler.create (function - | Ui.Event.Payload.Bool selected -> + | Ui.Event.Payload.Bool selected + when selected + <> (Journal_capture.task_state capture = Journal_model.Todo) + -> Ui.Event.Handler.Private.invoke dispatch (Text - (if selected then "capture-task-on" else "capture-task-off")) + (Detail_outline.scope state.routes + ^ "detail-task-intent:" + ^ Journal_capture.source capture)) | _ -> ())) - ~on_save:(bind_action dispatch "capture-submit") + ~on_save: + (bind_action + dispatch + (Detail_outline.scope state.routes + ^ + match Journal_detail.mode detail with + | Failed _ -> "detail-retry" + | _ -> "detail-submit:" ^ Journal_capture.source capture)) ~on_close:(bind_action dispatch "close-composer") ~error: - (match state.capture_error with - | Some failure -> Some (capture_failure_message failure) - | None -> - (match Journal_capture.phase capture with - | Failed message -> Some message - | Editing | Saving -> None))) - state.direct_capture - | Append_sheet -> - Option.bind (Journal_routes.detail state.routes) (fun detail -> - Option.map - (fun capture -> - composer_page - ~scope:"journal-append" - ~saving:(Journal_detail.mode detail = Journal_detail.Saving_child) - ~capture - ~enabled:state.write_enabled - ~on_edit:dispatch - ~on_toggle: - (Ui.Event.Handler.create (function - | Ui.Event.Payload.Bool selected - when selected - <> (Journal_capture.task_state capture = Journal_model.Todo) - -> - Ui.Event.Handler.Private.invoke - dispatch - (Text - (Detail_outline.scope state.routes - ^ "detail-task-intent:" - ^ Journal_capture.source capture)) - | _ -> ())) - ~on_save: - (bind_action - dispatch - (Detail_outline.scope state.routes - ^ - match Journal_detail.mode detail with - | Failed _ -> "detail-retry" - | _ -> "detail-submit:" ^ Journal_capture.source capture)) - ~on_close:(bind_action dispatch "close-composer") - ~error: - (match Journal_detail.mode detail with - | Failed message -> Some message - | _ -> Option.map capture_failure_message state.capture_error)) - (Journal_detail.child_capture detail)) - | Status_sheet block_id -> - Option.map - (fun block -> status_sheet_page ~tokens ~block dispatch) - (block_in_timeline state.timeline block_id) - | Cache_reset_confirmation _ -> None - | Diagnostics -> - Some - (diagnostics_page - ~snapshot:state.manager - ~graph:state.graph_state - ~admission:(Admission_refresh.observation state.admission_refresh) - state.diagnostics - dispatch) - | Error_info -> - Some - (error_info_page - ~sync_error:(Option.bind state.manager (fun manager -> manager.last_error)) - ~operation_failure:(operation_failure state.timeline_notice) - (newest_first_worker_errors state) - dispatch) + (match Journal_detail.mode detail with + | Failed message -> Some message + | _ -> Option.map capture_failure_message state.capture_error)) + (Journal_detail.child_capture detail)) + | Status_sheet block_id -> + Option.map + (fun block -> status_sheet_page ~tokens ~block dispatch) + (block_in_timeline state.timeline block_id) + | Cache_reset_confirmation _ -> None + | Diagnostics -> + Some + (diagnostics_page + ~snapshot:state.manager + ~graph:state.graph_state + ~admission:(Admission_refresh.observation state.admission_refresh) + state.diagnostics + dispatch) + | Error_info -> + Some + (error_info_page + ~sync_error:(Option.bind state.manager (fun manager -> manager.last_error)) + ~operation_failure:(operation_failure state.timeline_notice) + (newest_first_worker_errors state) + dispatch) + in + let body = + let base = + V.Navigation_stack.create + ~key:(Ui.Key.string "journal-navigator") + ~title:"" + ~on_path_change:dispatch + ~path + root + |> Cache_confirmation.local_cache + ~token: + (match state.modal with + | Cache_reset_confirmation _ -> Some state.confirmation_sequence + | _ -> None) + dispatch in - let body = - let base = - V.Navigation_stack.create - ~key:(Ui.Key.string "journal-navigator") - ~title:"" - ~on_path_change:dispatch - ~path - root - |> Cache_confirmation.local_cache - ~token: - (match state.modal with - | Cache_reset_confirmation _ -> Some state.confirmation_sequence - | _ -> None) - dispatch - in - let status = - match state.modal with - | Status_sheet _ -> true - | _ -> false - in - let title = - match state.modal with - | No_modal -> "" - | Capture_sheet -> "Capture" - | Append_sheet -> "Append" - | Status_sheet _ -> "Set status" - | Cache_reset_confirmation _ -> "Delete local graph copy?" - | Diagnostics -> "Diagnostics" - | Error_info -> "Error info" - in - V.Sheet.create - ~key:(Ui.Key.string "journal-sheet") - ~presented:(Option.is_some modal) - ~on_presented_changed:dispatch - ~interactive_dismiss:true - ~sizing:Form - ~detents:(if status then [ Medium; Large ] else [ Large ]) - ~content: - (match modal with - | None -> V.empty () - | Some content -> - V.Navigation_stack.create - ~title - ~on_path_change:(Ui.Event.Handler.create (fun _ -> ())) - ~path:[] - content) - base + let status = + match state.modal with + | Status_sheet _ -> true + | _ -> false in - let body = - Journal_asset_settings.view - ~uploads: - (Journal_uploads.rows - (Journal_uploads.sync state.uploads (upload_context state))) - ~offline:state.asset_offline - ~presented:state.asset_settings_open - ~on_event:(fun value -> - Ui.Event.Handler.Private.invoke - dispatch - (Ui.Event.Payload.Text ("asset-settings:" ^ value))) - body + let title = + match state.modal with + | No_modal -> "" + | Capture_sheet -> "Capture" + | Append_sheet -> "Append" + | Status_sheet _ -> "Set status" + | Cache_reset_confirmation _ -> "Delete local graph copy?" + | Diagnostics -> "Diagnostics" + | Error_info -> "Error info" in - App.View.create ~theme:(application_theme ()) ~body:(V.Body.static body)) + V.Sheet.create + ~key:(Ui.Key.string "journal-sheet") + ~presented:(Option.is_some modal) + ~on_presented_changed:dispatch + ~interactive_dismiss:true + ~sizing:Form + ~detents:(if status then [ Medium; Large ] else [ Large ]) + ~content: + (match modal with + | None -> V.empty () + | Some content -> + V.Navigation_stack.create + ~title + ~on_path_change:(Ui.Event.Handler.create (fun _ -> ())) + ~path:[] + content) + base + in + let body = + Journal_asset_settings.view + ~uploads: + (Journal_uploads.rows + (Journal_uploads.sync state.uploads (upload_context state))) + ~offline:state.asset_offline + ~presented:state.asset_settings_open + ~on_event:(fun value -> + Ui.Event.Handler.Private.invoke + dispatch + (Ui.Event.Payload.Text ("asset-settings:" ^ value))) + body + in + V.Body.theme ~data:(application_theme ()) (V.Body.static body) + in + let view _context model_signal _send = + Lui_elements.dyn + (fun model -> + Journal_view.mount + (body_view model dispatch timeline_scroll_completed detail_scroll_completed)) + model_signal + in + let os = + match platform_code with + | 1 -> Lui_protocol.MacOS + | 2 -> Lui_protocol.IOS + | 3 -> Lui_protocol.AndroidOS + | 4 -> Lui_protocol.LinuxOS + | 5 -> Lui_protocol.WindowsOS + | _ -> Lui_protocol.GenericOS + in + let host = + match host_code with + | 1 -> Lui_protocol.WebHost + | 2 -> Lui_protocol.SwiftUIHost + | 3 -> Lui_protocol.FlutterHost + | _ -> Lui_protocol.GenericHost + in + let backend = + { Lui_protocol.backend_profile = Lui_protocol.profile os host + ; apply_batch = + (fun batch -> + latest_patch := Lui_wire.encode_batch batch; + true) + } + in + let app = + Lui_app.create_with_extensions + backend + Journal_lui_native.registry + initial_state + update + view + in + app_cell := Some app; + let context = { app; pump; client; send_action; apply_platform; running } in + current_app := Some context; + ignore (Worker.send client Graph_service.Get_graph_state : Worker.send_result); + Worker.on_event client (fun event -> + Journal_pump.enqueue pump (fun () -> Effect.run (handle_worker_event event))); + ignore + (Thread.create + (fun () -> + while !running do + (try Worker.For_testing.await_output client with + | _ -> ()); + if !running && not (Worker.For_testing.is_stopping client) + then Journal_pump.enqueue pump (fun () -> ()) + else running := false + done) + ()); + ignore + (Thread.create + (fun () -> + while !running do + Unix.sleepf 60.; + if !running + then + Journal_pump.enqueue pump (fun () -> Effect.run (calendar_tick_effect ())) + done) + ()); + Effect.run calendar_startup; + ignore (Lui_app.start app); + ignore (Lui_app.flush app); + context ;; let decode_config payload = @@ -5167,13 +5183,116 @@ let decode_config payload = | Error error -> Error (Journal_startup.Error.to_string error) ;; -let create ?(calendar_sampler = fun () -> Journal_calendar.Sampler.create ()) ~service () = - App.create_with_worker - ~name:"Logseq Journal" - ~decode_config - ~service - (fun client handlers graph -> - component ~calendar_sampler:(calendar_sampler ()) client handlers graph) +let create ?(calendar_sampler = fun () -> Journal_calendar.Sampler.create ()) ~service () + : Journal_bridge.hooks + = + let init platform_code host_code payload = + latest_patch := ""; + (match decode_config (Bytes.of_string payload) with + | Error error -> + Printf.eprintf "logseq_journal: failed to decode startup config: %s\n%!" error + | Ok config -> + let runtime_epoch = + Journal_worker_ids.Runtime.Epoch.of_int64 + (Int64.of_float (Unix.gettimeofday () *. 1e6)) + in + (match Journal_worker_runtime.start ~runtime_epoch service config with + | Error error -> + Printf.eprintf "logseq_journal: failed to start worker: %s\n%!" error + | Ok client -> + ignore + (start + ~calendar_sampler:(calendar_sampler ()) + ~client + ~platform_code + ~host_code))); + !latest_patch + in + let dispatch event = + latest_patch := ""; + (match !current_app with + | Some { app; _ } -> + ignore (Lui_app.dispatch_event app event); + ignore (Lui_app.flush app) + | None -> ()); + !latest_patch + in + let extension_event node name values = + latest_patch := ""; + (match !current_app with + | Some { app; _ } -> + (match Lui_runtime.extension_identifier (Lui_app.runtime app) node with + | Some identifier -> + ignore + (Lui_app.dispatch_event + app + (Lui_protocol.ExtensionEvent + (node, identifier, name, decode_extension_values values))) + | None -> ()); + ignore (Lui_app.flush app) + | None -> ()); + !latest_patch + in + let pump () = + latest_patch := ""; + (match !current_app with + | Some { app; pump; client; _ } -> + Journal_pump.drain pump; + Worker.Private.deliver client ~max_events:64; + Journal_pump.drain pump; + ignore (Lui_app.flush app) + | None -> ()); + !latest_patch + in + let platform_event payload = + match !current_app with + | Some { pump; send_action; apply_platform; _ } -> + Journal_pump.enqueue pump (fun () -> + let bytes = Bytes.of_string payload in + if Journal_platform.is_environment_event bytes + then ( + match Journal_platform.decode_environment_event bytes with + | Ok snapshot -> send_action (Environment_changed snapshot) + | Error _ -> ()) + else Effect.run (apply_platform bytes)) + | None -> () + in + let platform_response payload = + match !current_app with + | Some { pump; send_action; _ } -> + let bytes = Bytes.of_string payload in + if Bytes.length bytes >= 8 + then ( + let tag = Bytes.get_uint16_le bytes 6 in + Journal_pump.enqueue pump (fun () -> + send_action (Platform_response (tag, Ok bytes)))) + | None -> () + in + let dispose () = + latest_patch := ""; + (match !current_app with + | Some context -> + context.running := false; + Worker.Private.request_stop context.client; + ignore (Lui_app.dispose context.app); + current_app := None + | None -> ()); + !latest_patch + in + let root_node () = + match !current_app with + | Some { app; _ } -> Lui_app.root_node app + | None -> 0 + in + { Journal_bridge.init + ; dispatch + ; extension_event + ; pump + ; platform_event + ; platform_response + ; dispose + ; root_node + } ;; module For_testing = struct @@ -5250,4 +5369,4 @@ module For_testing = struct ;; end -let app = create ~service:Graph_service.service () +let native_hooks = create ~service:Graph_service.service () diff --git a/app/application.mli b/app/application.mli index 9c1aa7b..ac444d5 100644 --- a/app/application.mli +++ b/app/application.mli @@ -1,17 +1,17 @@ val sync_phase_name - : Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.sync_phase + : Logseq_db_worker_lui.Logseq_db_worker_lui_service.sync_phase -> string val startup_phase_name : Journal_startup.startup_phase -> string val graph_phase_name : Logseq_db_worker.graph_phase -> string val diagnostic_phase_rows - : snapshot:Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.snapshot option + : snapshot:Logseq_db_worker_lui.Logseq_db_worker_lui_service.snapshot option -> graph:Logseq_db_worker.graph_state -> (string * string) list val diagnostic_rows - : Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.diagnostics + : Logseq_db_worker_lui.Logseq_db_worker_lui_service.diagnostics -> (string * string) list module Admission_refresh : sig @@ -48,13 +48,11 @@ val format_bytes : int -> string val admission_rows : Admission_refresh.observation -> (string * string) list module For_testing : sig - val diagnostics_page - : Bonsai_swiftui_ui.Event.Handler.t - -> Bonsai_swiftui_ui.View.Body.t + val diagnostics_page : Journal_view.Event.Handler.t -> Journal_view.View.Body.t val favorites_page : Logseq_db_worker.Protocol.v2_favorite_item list - -> Bonsai_swiftui_ui.View.t + -> Journal_view.View.t val read_block_entropy : unit -> bytes @@ -68,14 +66,14 @@ module For_testing : sig val app_with_service : ?calendar_sampler:Journal_calendar.Sampler.t -> ( Logseq_db_worker.Config.t - , Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.request - , Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.response - , Logseq_db_worker_bonsai.Logseq_db_worker_bonsai_service.push ) - Bonsai_swiftui.Worker.Service.t - -> App.t + , Logseq_db_worker_lui.Logseq_db_worker_lui_service.request + , Logseq_db_worker_lui.Logseq_db_worker_lui_service.response + , Logseq_db_worker_lui.Logseq_db_worker_lui_service.push ) + Logseq_db_worker_lui.Journal_worker.Service.t + -> Journal_bridge.hooks end -val app : App.t +val native_hooks : Journal_bridge.hooks module Root_navigation : sig type t @@ -84,7 +82,7 @@ module Root_navigation : sig | Select of Journal_routes.destination | Capture_opened | Capture_closed - | Capture_native_edit of Bonsai_swiftui_ui.Event.Payload.text_edit + | Capture_native_edit of Journal_view.Event.Payload.text_edit | Capture_task_intent of bool | Capture_edited of string | Capture_admitted of Journal_capture.t diff --git a/app/journal_view.ml b/app/journal_view.ml index 5169677..4265342 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -31,6 +31,7 @@ module Test_id = struct end let element ?key ?test_id mount = { key; test_id; mount } +let mount t = t.mount let int_of_float_nan v = int_of_float (Float.round v) let modify f t = diff --git a/app/journal_view.mli b/app/journal_view.mli index 9fc3b0a..e1e108e 100644 --- a/app/journal_view.mli +++ b/app/journal_view.mli @@ -3,6 +3,8 @@ type t +val mount : t -> Lui_elements.t + module Key : sig type t From a8edcda1774f87b29f20edc7fdac107a865c2853 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 20:00:23 -0700 Subject: [PATCH 15/40] lui migration: format touched files, update boundary invariants for Lui_app host Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/dune | 4 +- app/journal_asset_import.ml | 5 +- app/journal_bridge.ml | 21 +- app/journal_environment.ml | 10 +- app/journal_lui_native.ml | 91 +- app/journal_media_runtime.ml | 38 +- app/journal_media_view.ml | 3 +- app/journal_platform.ml | 4 +- app/journal_platform.mli | 6 +- app/journal_pump.ml | 17 +- app/journal_view.ml | 2660 +++++++++-------- app/journal_view.mli | 940 +++--- apple-tests/amplify/hub_fixture.ml | 17 +- apple-tests/editor/composer_probe.ml | 16 +- apple-tests/native-outline/outline_probe.ml | 87 +- logseq_db_worker/lui/journal_worker.mli | 5 +- .../lui/logseq_db_worker_lui_service.mli | 3 +- test/source_boundary_test.ml | 12 +- 18 files changed, 2019 insertions(+), 1920 deletions(-) diff --git a/app/dune b/app/dune index 7e6ebe7..91c3c06 100644 --- a/app/dune +++ b/app/dune @@ -57,9 +57,7 @@ (executable (name native_embed) (modules native_embed) - (libraries - app - lui) + (libraries app lui) (link_flags (:standard -cclib diff --git a/app/journal_asset_import.ml b/app/journal_asset_import.ml index 28313c7..f27562f 100644 --- a/app/journal_asset_import.ml +++ b/app/journal_asset_import.ml @@ -57,7 +57,10 @@ let extension = ;; let is_dismissal payload = - match (try Yojson.Basic.from_string payload with _ -> `Null) with + match + try Yojson.Basic.from_string payload with + | _ -> `Null + with | `Assoc fields -> (match List.assoc_opt "action" fields with | Some (`String "dismissed") -> true diff --git a/app/journal_bridge.ml b/app/journal_bridge.ml index 867c6ba..f7d7f8a 100644 --- a/app/journal_bridge.ml +++ b/app/journal_bridge.ml @@ -10,7 +10,6 @@ type hooks = } external wakeup : unit -> unit = "journal_ml_wakeup" - external platform_request : string -> unit = "journal_ml_platform_request" let current : hooks option ref = ref None @@ -19,25 +18,19 @@ let hooks () = match !current with | Some hooks -> hooks | None -> invalid_arg "Journal_bridge.register was not called" +;; let initialize platform_code host_code payload = (hooks ()).init platform_code host_code payload ;; let dispatch_lui event = (hooks ()).dispatch event - let appear node = dispatch_lui (Lui_protocol.Appear node) - let press node = dispatch_lui (Lui_protocol.Press node) - let long_press node = dispatch_lui (Lui_protocol.LongPress node) - let text_changed node text = dispatch_lui (Lui_protocol.TextChanged (node, text)) - let submit node = dispatch_lui (Lui_protocol.Submit node) - let dismiss node = dispatch_lui (Lui_protocol.Dismiss node) - let double_press node = dispatch_lui (Lui_protocol.DoublePress node) let toggle_changed node checked = @@ -45,21 +38,12 @@ let toggle_changed node checked = ;; let radio_changed node = dispatch_lui (Lui_protocol.Change node) - let slider_changed node value = dispatch_lui (Lui_protocol.ValueChanged (node, value)) - -let extension_event node name payload = - (hooks ()).extension_event node name payload -;; - +let extension_event node name payload = (hooks ()).extension_event node name payload let pump () = (hooks ()).pump () - let platform_event payload = (hooks ()).platform_event payload - let platform_response payload = (hooks ()).platform_response payload - let dispose () = (hooks ()).dispose () - let root_node () = (hooks ()).root_node () let register hooks = @@ -81,3 +65,4 @@ let register hooks = Callback.register "journal_ocaml_pump" pump; Callback.register "journal_ocaml_platform_event" platform_event; Callback.register "journal_ocaml_platform_response" platform_response +;; diff --git a/app/journal_environment.ml b/app/journal_environment.ml index 8d34650..e138bb2 100644 --- a/app/journal_environment.ml +++ b/app/journal_environment.ml @@ -180,7 +180,10 @@ let encode_json snapshot = ; "devicePixelRatio", `Float snapshot.device_pixel_ratio ; "textScale", `Float snapshot.text_scale ; ( "brightness" - , `String (match snapshot.brightness with Light -> "light" | Dark -> "dark") ) + , `String + (match snapshot.brightness with + | Light -> "light" + | Dark -> "dark") ) ; "platform", `String snapshot.platform ; "locale", `String snapshot.locale ; "safeArea", insets snapshot.safe_area @@ -193,8 +196,9 @@ let encode_json snapshot = ; "highContrast", `Bool snapshot.high_contrast ; ( "orientation" , `String - (match snapshot.orientation with Portrait -> "portrait" | Landscape -> "landscape") - ) + (match snapshot.orientation with + | Portrait -> "portrait" + | Landscape -> "landscape") ) ; "pointerKinds", `Int snapshot.pointer_kinds ] ;; diff --git a/app/journal_lui_native.ml b/app/journal_lui_native.ml index 789cc2a..b3c10da 100644 --- a/app/journal_lui_native.ml +++ b/app/journal_lui_native.ml @@ -11,6 +11,7 @@ let apple_profiles = [ { profile_os = MacOS; profile_host = SwiftUIHost } ; { profile_os = IOS; profile_host = SwiftUIHost } ] +;; let all_host_profiles = apple_profiles @@ -18,35 +19,60 @@ let all_host_profiles = ; { profile_os = IOS; profile_host = FlutterHost } ; { profile_os = AndroidOS; profile_host = FlutterHost } ] +;; -let payload_property = - property "payload" StringScalar true None +let payload_property = property "payload" StringScalar true None let event_schema = - event "event" - [ event_field "id" IntScalar true - ; event_field "payload" StringScalar true - ] + event + "event" + [ event_field "id" IntScalar true; event_field "payload" StringScalar true ] +;; let registry = let registry = Lui_extension.registry () in - register_component registry - (component chrome_identifier apple_profiles true [] - [ payload_property ] []); - register_component registry - (component asset_import_identifier all_host_profiles false [] - [ payload_property ] [ event_schema ]); - register_component registry - (component media_identifier all_host_profiles false [] - [ payload_property ] [ event_schema ]); - register_component registry - (component asset_settings_identifier all_host_profiles true [] - [ payload_property ] [ event_schema ]); - register_component registry - (component list_identifier all_host_profiles false [] - [ payload_property ] [ event_schema ]); + register_component + registry + (component chrome_identifier apple_profiles true [] [ payload_property ] []); + register_component + registry + (component + asset_import_identifier + all_host_profiles + false + [] + [ payload_property ] + [ event_schema ]); + register_component + registry + (component + media_identifier + all_host_profiles + false + [] + [ payload_property ] + [ event_schema ]); + register_component + registry + (component + asset_settings_identifier + all_host_profiles + true + [] + [ payload_property ] + [ event_schema ]); + register_component + registry + (component + list_identifier + all_host_profiles + false + [] + [ payload_property ] + [ event_schema ]); freeze registry; registry +;; type event = { identifier : string @@ -63,14 +89,12 @@ let decode_event = function || String.equal identifier media_identifier || String.equal identifier asset_settings_identifier || String.equal identifier list_identifier) -> - (match - ( String_map.find_opt "id" values - , String_map.find_opt "payload" values ) - with + (match String_map.find_opt "id" values, String_map.find_opt "payload" values with | Some (IntValue event_id), Some (StringValue payload) -> Some { identifier; node; event_id; payload } | _ -> None) | _ -> None +;; let mount ?key ~payload ~children ?on_event identifier context parent = let node = Lui_ui.extension context identifier in @@ -88,24 +112,29 @@ let mount ?key ~payload ~children ?on_event identifier context parent = | None -> ()); List.iter (fun child -> ignore (child context (Some node))) children; node +;; let chrome ?key ~payload ?on_event children : Lui_elements.t = - fun context parent -> + fun context parent -> mount ?key ~payload ~children ?on_event chrome_identifier context parent +;; let asset_import ?key ~payload ?on_event () : Lui_elements.t = - fun context parent -> + fun context parent -> mount ?key ~payload ~children:[] ?on_event asset_import_identifier context parent +;; let media ?key ~payload ?on_event children : Lui_elements.t = - fun context parent -> + fun context parent -> mount ?key ~payload ~children ?on_event media_identifier context parent +;; let asset_settings ?key ~payload ?on_event children : Lui_elements.t = - fun context parent -> + fun context parent -> mount ?key ~payload ~children ?on_event asset_settings_identifier context parent +;; let list ?key ~payload ?on_event children : Lui_elements.t = - fun context parent -> + fun context parent -> mount ?key ~payload ~children ?on_event list_identifier context parent - +;; diff --git a/app/journal_media_runtime.ml b/app/journal_media_runtime.ml index 9e1b940..76d5d1e 100644 --- a/app/journal_media_runtime.ml +++ b/app/journal_media_runtime.ml @@ -429,8 +429,7 @@ let begin_replace t ~root = match Hashtbl.find_opt t.groups root, t.generation with | Some g, Some _ when (not g.replace) && List.length t.queued < 1024 -> (match G.Uuid.of_string g.root with - | Error _ -> - g.error <- Some "The attachment holder is unavailable." + | Error _ -> g.error <- Some "The attachment holder is unavailable." | Ok block -> g.replace <- true; g.error <- None; @@ -484,8 +483,7 @@ let begin_reuse t ~root = match Hashtbl.find_opt t.groups root, t.generation with | Some g, Some _ when List.length t.queued < 1024 -> g.reference <- None; - g.reuse - <- Some { pending = true; committing = false; items = []; cursor = None }; + g.reuse <- Some { pending = true; committing = false; items = []; cursor = None }; request_reference t g; notify t g; pump t @@ -496,8 +494,8 @@ let reuse_next t ~root = match Hashtbl.find_opt t.groups root with | Some g -> (match g.reference, g.reuse with - | Some { page; _ } - , Some { pending = false; committing = false; cursor = Some cursor; _ } -> + | ( Some { page; _ } + , Some { pending = false; committing = false; cursor = Some cursor; _ } ) -> request_candidates t g page (Some cursor); notify t g; pump t @@ -623,10 +621,8 @@ let receive t ticket response = (match response with | Service.Graph_response (Protocol.V2_response - { outcome = - V2_block_outcome (V2_present_block { value; revision }) - ; _ - }) -> + { outcome = V2_block_outcome (V2_present_block { value; revision }); _ }) + -> let reference = { page = value.block.page ; revision @@ -645,12 +641,10 @@ let receive t ticket response = (match Hashtbl.find_opt t.groups root with | Some g when g.epoch = epoch -> (match g.reuse, response with - | Some reuse - , Service.Graph_response - (Protocol.V2_response - { outcome = V2_assets_outcome { items; next_cursor; _ } - ; _ - }) -> + | ( Some reuse + , Service.Graph_response + (Protocol.V2_response + { outcome = V2_assets_outcome { items; next_cursor; _ }; _ }) ) -> reuse.items <- List.filteri (fun index _ -> index < 64) @@ -681,10 +675,7 @@ let receive t ticket response = (match response with | Service.Graph_response (Protocol.V2_response - { outcome = - V2_block_outcome (V2_present_block { value; _ }) - ; _ - }) -> + { outcome = V2_block_outcome (V2_present_block { value; _ }); _ }) -> t.armed g.root (previous_reference value.block) | _ -> g.error <- Some "Unable to open the attachment reference. Retry."); notify t g @@ -693,10 +684,9 @@ let receive t ticket response = (match Hashtbl.find_opt t.groups root with | Some g when g.epoch = epoch -> (match g.reuse, response with - | Some _ - , Service.Graph_response - (Protocol.V2_response - { outcome = V2_mutation_committed _; _ }) -> + | ( Some _ + , Service.Graph_response + (Protocol.V2_response { outcome = V2_mutation_committed _; _ }) ) -> g.reuse <- None; g.reference <- None; read t g None; diff --git a/app/journal_media_view.ml b/app/journal_media_view.ml index de4928a..c81ae29 100644 --- a/app/journal_media_view.ml +++ b/app/journal_media_view.ml @@ -37,8 +37,7 @@ let view ~scope ~root ~media ~editable ~on_event child = let items, more, error, picker = match media with | None -> [], false, None, None - | Some view -> - view.Journal_media_runtime.items, view.more, view.error, view.picker + | Some view -> view.Journal_media_runtime.items, view.more, view.error, view.picker in Ui.Native_widget.widget extension diff --git a/app/journal_platform.ml b/app/journal_platform.ml index bd400d3..044f4fa 100644 --- a/app/journal_platform.ml +++ b/app/journal_platform.ml @@ -243,9 +243,7 @@ let show_notice_request ~token ~message ~action_label ~duration_ms = let decode_notice_response ~token bytes = Result.bind (decode_envelope [ 26 ] bytes) (fun payload -> decode_json_object "notice response" payload (fun fields -> - match - List.assoc_opt "token" fields, List.assoc_opt "result" fields - with + match List.assoc_opt "token" fields, List.assoc_opt "result" fields with | Some (`String actual), Some (`String result) when String.equal actual (Int64.to_string token) -> (match result with diff --git a/app/journal_platform.mli b/app/journal_platform.mli index 786c7ce..af7f8b8 100644 --- a/app/journal_platform.mli +++ b/app/journal_platform.mli @@ -41,6 +41,7 @@ type notice_result = (** Host -> OCaml environment snapshot push (tag 24). *) val decode_environment_event : bytes -> (Journal_environment.snapshot, string) result + val is_environment_event : bytes -> bool (** OCaml -> host notice request (tag 25); response arrives on tag 26. *) @@ -51,10 +52,7 @@ val show_notice_request -> duration_ms:int -> bytes -val decode_notice_response - : token:int64 - -> bytes - -> (notice_result, string) result +val decode_notice_response : token:int64 -> bytes -> (notice_result, string) result (** OCaml -> host request cancelling a pending notice (tag 27). *) val notice_cancel_request : token:int64 -> bytes diff --git a/app/journal_pump.ml b/app/journal_pump.ml index 3a5bb66..9ea6e31 100644 --- a/app/journal_pump.ml +++ b/app/journal_pump.ml @@ -14,6 +14,7 @@ let enqueue t thunk = match wakeup with | Some wake -> wake () | None -> () +;; let set_wakeup t wakeup = Mutex.lock t.mutex; @@ -21,16 +22,18 @@ let set_wakeup t wakeup = let pending = t.queue <> [] in Mutex.unlock t.mutex; if pending then wakeup () +;; let drain t = let rec loop () = Mutex.lock t.mutex; - (match t.queue with - | [] -> Mutex.unlock t.mutex - | queue -> - t.queue <- []; - Mutex.unlock t.mutex; - List.iter (fun thunk -> thunk ()) (List.rev queue); - loop ()) + match t.queue with + | [] -> Mutex.unlock t.mutex + | queue -> + t.queue <- []; + Mutex.unlock t.mutex; + List.iter (fun thunk -> thunk ()) (List.rev queue); + loop () in loop () +;; diff --git a/app/journal_view.ml b/app/journal_view.ml index 4265342..a5bfb85 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -38,9 +38,9 @@ let modify f t = { t with mount = (fun context parent -> - let node = t.mount context parent in - f context node; - node) + let node = t.mount context parent in + f context node; + node) } ;; @@ -290,7 +290,7 @@ module Semantics = struct ?identifier:_ ?actions () - = + = { label; selected; live_region; role; children; actions } ;; @@ -328,7 +328,10 @@ module Text_editing = struct let create ~text:_ ~start_utf16 ~end_utf16 = { start_utf16; end_utf16 } let start_utf16 t = t.start_utf16 let end_utf16 t = t.end_utf16 - let equal left right = left.start_utf16 = right.start_utf16 && left.end_utf16 = right.end_utf16 + + let equal left right = + left.start_utf16 = right.start_utf16 && left.end_utf16 = right.end_utf16 + ;; end module Value = struct @@ -342,6 +345,7 @@ module Text_editing = struct let text t = t.text let selection t = t.selection let composing t = t.composing + let equal left right = String.equal left.text right.text && Range.equal left.selection right.selection @@ -357,10 +361,14 @@ module Text_editing = struct while !i < n do let byte = Char.code (String.unsafe_get s !i) in let advance, units = - if byte < 0x80 then 1, 1 - else if byte land 0xE0 = 0xC0 then 2, 1 - else if byte land 0xF0 = 0xE0 then 3, 1 - else if byte land 0xF8 = 0xF0 then 4, 2 + if byte < 0x80 + then 1, 1 + else if byte land 0xE0 = 0xC0 + then 2, 1 + else if byte land 0xF0 = 0xE0 + then 3, 1 + else if byte land 0xF8 = 0xF0 + then 4, 2 else 1, 1 in i := !i + advance; @@ -405,636 +413,640 @@ module View = struct type element_ = t module For_testing = struct - let key t = t.key - let test_id t = t.test_id -end - -module Button_role = struct - type t = - | Normal - | Destructive - | Cancel - - let variant = function - | Destructive -> "destructive" - | Cancel -> "cancel" - | Normal -> "primary" - ;; -end - -module Button_style = struct - type t = - | Automatic - | Plain - | Bordered - | Prominent - | Button - - let variant = function - | Plain -> "plain" - | Bordered -> "bordered" - | Prominent -> "prominent" - | Button -> "button" - | Automatic -> "automatic" - ;; -end - -module Progress_style = struct - type t = - | Linear - | Circular -end - -let is_press = function - | Lui_protocol.Press _ -> true - | _ -> false -;; + let key t = t.key + let test_id t = t.test_id + end -let with_test_id test_id t = - let mount context parent = - let node = t.mount context parent in - Lui_ui.accessibility_identifier context node (Test_id.to_string test_id); - node - in - { t with test_id = Some (Test_id.to_string test_id); mount } -;; + module Button_role = struct + type t = + | Normal + | Destructive + | Cancel + + let variant = function + | Destructive -> "destructive" + | Cancel -> "cancel" + | Normal -> "primary" + ;; + end -let empty ?key:_ () = element (fun _context _parent -> 0) - -let text ?key ?(style : Style.Text_style.t option) ?text_align:_ ?line_limit:_ - ?truncation:_ value - = - element ?key (fun context parent -> - let node = Lui_ui.text context value in - Option.iter - (fun (style : Style.Text_style.t) -> - (match style.foreground with - | Some Style.Text_style.Secondary -> - Lui_ui.foreground context node "secondary" - | Some Primary | None -> ()); - (match style.font_weight with - | Some Style.Text_style.Semi_bold -> - Lui_ui.style_class context node "semibold" - | Some Regular | None -> ())) - style; - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - node) -;; + module Button_style = struct + type t = + | Automatic + | Plain + | Bordered + | Prominent + | Button -let symbol ?key ?size ?color ?rendering:_ ~name () = - element ?key (fun context parent -> - let node = Lui_ui.icon context name in - Option.iter (fun size -> Lui_ui.size context node (string_of_int (int_of_float_nan size))) size; - Option.iter (fun color -> Lui_ui.foreground context node color) color; - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - node) -;; + let variant = function + | Plain -> "plain" + | Bordered -> "bordered" + | Prominent -> "prominent" + | Button -> "button" + | Automatic -> "automatic" + ;; + end -let label ?key ~title ~icon () = - element ?key (fun context parent -> - let node = Lui_ui.row context in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - ignore (icon.mount context (Some node)); - ignore (title.mount context (Some node)); - node) -;; + module Progress_style = struct + type t = + | Linear + | Circular + end -let divider ?key () = - element ?key (fun context parent -> - let node = Lui_ui.separator context "horizontal" in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - node) -;; + let is_press = function + | Lui_protocol.Press _ -> true + | _ -> false + ;; -let progress ?key ?value ?(style = Progress_style.Linear) () = - element ?key (fun context parent -> - let node = - match style, value with - | Progress_style.Circular, _ -> Lui_ui.spinner context - | Linear, Some value -> Lui_ui.progress_literal context value - | Linear, None -> Lui_ui.spinner context + let with_test_id test_id t = + let mount context parent = + let node = t.mount context parent in + Lui_ui.accessibility_identifier context node (Test_id.to_string test_id); + node in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - node) -;; - -let spacer ?key ?min_length:_ () = - element ?key (fun context parent -> - let node = Lui_ui.spacer context in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - node) -;; - -let row ?key ?(spacing = 16.) ?(alignment = Layout.Vertical_alignment.Center) children = - element ?key (fun context parent -> - let node = Lui_ui.row context in - Lui_ui.gap context node (int_of_float_nan spacing); - Lui_ui.cross context node (Layout.Vertical_alignment.to_lui alignment); - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - List.iter (fun child -> ignore (child.mount context (Some node))) children; - node) -;; - -let column ?key ?(spacing = 16.) ?(alignment = Layout.Horizontal_alignment.Center) children = - element ?key (fun context parent -> - let node = Lui_ui.column context in - Lui_ui.gap context node (int_of_float_nan spacing); - Lui_ui.cross context node (Layout.Horizontal_alignment.to_lui alignment); - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - List.iter (fun child -> ignore (child.mount context (Some node))) children; - node) -;; + { t with test_id = Some (Test_id.to_string test_id); mount } + ;; -let stack ?key ?alignment:_ children = - element ?key (fun context parent -> - let node = Lui_ui.stack context in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - List.iter (fun child -> ignore (child.mount context (Some node))) children; - node) -;; + let empty ?key:_ () = element (fun _context _parent -> 0) -let apply_frame_limit context node _min_prop max_prop limit = - match limit with - | Layout.Frame_limit.Fill -> Lui_ui.grow context node 1.0 - | Fixed value -> Lui_ui.int_property context node max_prop (int_of_float_nan value) -;; - -let frame - ?key:frame_key - ?width - ?height - ?min_width - ?ideal_width:_ - ?max_width - ?min_height - ?ideal_height:_ - ?max_height - ?alignment:_ - t - = - modify - (fun context node -> - Option.iter - (fun v -> Lui_ui.width context node (int_of_float_nan v)) - width; - Option.iter - (fun v -> Lui_ui.height context node (int_of_float_nan v)) - height; - Option.iter - (fun v -> Lui_ui.min_width context node (int_of_float_nan v)) - min_width; - Option.iter - (fun v -> Lui_ui.min_height context node (int_of_float_nan v)) - min_height; - Option.iter - (fun limit -> - apply_frame_limit context node Lui_protocol.MinWidth Lui_protocol.MaxWidth - limit) - max_width; - Option.iter - (fun limit -> - apply_frame_limit context node Lui_protocol.MinHeight Lui_protocol.MaxHeight - limit) - max_height) - t - |> fun result -> (match frame_key with Some key -> { result with key = Some key } | None -> result) -;; + let text + ?key + ?(style : Style.Text_style.t option) + ?text_align:_ + ?line_limit:_ + ?truncation:_ + value + = + element ?key (fun context parent -> + let node = Lui_ui.text context value in + Option.iter + (fun (style : Style.Text_style.t) -> + (match style.foreground with + | Some Style.Text_style.Secondary -> + Lui_ui.foreground context node "secondary" + | Some Primary | None -> ()); + match style.font_weight with + | Some Style.Text_style.Semi_bold -> Lui_ui.style_class context node "semibold" + | Some Regular | None -> ()) + style; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) + ;; -let padding ?key:_ ~insets t = - modify - (fun context node -> - Lui_ui.padding context node (int_of_float_nan insets)) - t -;; + let symbol ?key ?size ?color ?rendering:_ ~name () = + element ?key (fun context parent -> + let node = Lui_ui.icon context name in + Option.iter + (fun size -> Lui_ui.size context node (string_of_int (int_of_float_nan size))) + size; + Option.iter (fun color -> Lui_ui.foreground context node color) color; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) + ;; -let semantics ?key:_ ~properties t = - modify - (fun context node -> - Option.iter - (fun label -> Lui_ui.accessibility_label context node label) - properties.Semantics.label) - t -;; + let label ?key ~title ~icon () = + element ?key (fun context parent -> + let node = Lui_ui.row context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (icon.mount context (Some node)); + ignore (title.mount context (Some node)); + node) + ;; -let help ?key:_ ~message:_ t = t -let text_selection ?key:_ ~enabled:_ t = t -let opacity ?key:_ value t = modify (fun _ _ -> ignore value) t -let ignores_safe_area ?regions:_ ?edges:_ t = t -let safe_area_padding ?key:_ ~insets:_ t = t -let theme ?key:_ ~data:_ t = t -let background ?key:_ ?corner_radius ~color t = - modify - (fun context node -> - Lui_ui.background context node color; - Option.iter (fun radius -> Lui_ui.corner_radius context node (int_of_float_nan radius)) corner_radius) - t -;; + let divider ?key () = + element ?key (fun context parent -> + let node = Lui_ui.separator context "horizontal" in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) + ;; -let clip ?key:_ ?corner_radius:_ ?antialiased:_ t = t -let layout_priority ?key:_ _ t = t -let offset ?key:_ ?x:_ ?y:_ t = t -let animated_opacity ?key:_ ?duration:_ value t = opacity value t + let progress ?key ?value ?(style = Progress_style.Linear) () = + element ?key (fun context parent -> + let node = + match style, value with + | Progress_style.Circular, _ -> Lui_ui.spinner context + | Linear, Some value -> Lui_ui.progress_literal context value + | Linear, None -> Lui_ui.spinner context + in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) + ;; -let button - ?key - ?(enabled = true) - ?(role = Button_role.Normal) - ?style - ?(autofocus = false) - ~on_press - ~child - () - = - element ?key (fun context parent -> - let node = Lui_ui.button context in - if not enabled then Lui_ui.disabled context node true; - Option.iter (fun style -> Lui_ui.string_property context node Lui_protocol.VariantValue (Button_style.variant style)) style; - (match role with - | Button_role.Normal -> () - | role -> Lui_ui.string_property context node Lui_protocol.VariantValue (Button_role.variant role)); - if autofocus then Lui_ui.bool_property context node Lui_protocol.Autofocus true; - Lui_ui.on_event context node (fun event -> - if is_press event then invoke on_press Event.Payload.Unit); - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - ignore (child.mount context (Some node)); - node) -;; + let spacer ?key ?min_length:_ () = + element ?key (fun context parent -> + let node = Lui_ui.spacer context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) + ;; -let toggle ?key ?style:_ ?(enabled = true) ~value ~on_changed ~label () = - element ?key (fun context parent -> - let node = Lui_ui.toggle context in - if not enabled then Lui_ui.disabled context node true; - Lui_ui.bool_property context node Lui_protocol.Checked value; - Lui_ui.on_event context node (fun event -> - match event with - | Lui_protocol.ToggleChanged (_, selected) -> - invoke on_changed (Event.Payload.Bool selected) - | _ -> ()); - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - ignore (label.mount context (Some node)); - node) -;; + let row ?key ?(spacing = 16.) ?(alignment = Layout.Vertical_alignment.Center) children = + element ?key (fun context parent -> + let node = Lui_ui.row context in + Lui_ui.gap context node (int_of_float_nan spacing); + Lui_ui.cross context node (Layout.Vertical_alignment.to_lui alignment); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter (fun child -> ignore (child.mount context (Some node))) children; + node) + ;; -let text_editor - ?key - ?(autofocus = false) - ?(enabled = true) - ?(read_only = false) - ?(submit_on_return = true) - ?max_utf8_bytes:_ - ~session_id - ~document_revision - ~accepted_local_revision - ~update_mode:_ - ~value - ~on_edit - ~on_submit - ~on_focus_changed:_ - ?on_limit_reached:_ - () - = - element ?key (fun context parent -> - let node = Lui_ui.textarea context in - Lui_ui.text_property context node (Text_editing.Value.text value); - if not (enabled && not read_only) then Lui_ui.disabled context node true; - if autofocus then Lui_ui.bool_property context node Lui_protocol.Autofocus true; - Lui_ui.bool_property context node Lui_protocol.SubmitOnEnter submit_on_return; - let local_revision = ref accepted_local_revision in - Lui_ui.on_event context node (fun event -> - match event with - | Lui_protocol.TextChanged (_, text) -> - local_revision := Journal_ids.Text_input.Local_revision.succ !local_revision; - invoke - on_edit - (Event.Payload.Text_edit - { session_id - ; local_revision = !local_revision - ; base_document_revision = document_revision - ; text - ; selection = { start_utf16 = 0; end_utf16 = 0 } - ; composing = None - }) - | Lui_protocol.Submit _ -> invoke on_submit Event.Payload.Unit - | _ -> ()); - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - node) -;; + let column + ?key + ?(spacing = 16.) + ?(alignment = Layout.Horizontal_alignment.Center) + children + = + element ?key (fun context parent -> + let node = Lui_ui.column context in + Lui_ui.gap context node (int_of_float_nan spacing); + Lui_ui.cross context node (Layout.Horizontal_alignment.to_lui alignment); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter (fun child -> ignore (child.mount context (Some node))) children; + node) + ;; -let secure_field - ?key - ~label - ?(prompt = "") - ?keyboard:_ - ?submit_label:_ - ?appearance:_ - ?(autofocus = false) - ?(enabled = true) - ?read_only:_ - ?submit_on_return:_ - ?max_utf8_bytes:_ - ~session_id - ~document_revision - ~accepted_local_revision - ~update_mode:_ - ~value - ~on_edit - ~on_submit - ~on_focus_changed:_ - ?on_limit_reached:_ - () - = - element ?key (fun context parent -> - let node = Lui_ui.secure_field context in - Lui_ui.text_property context node (Text_editing.Value.text value); - Lui_ui.placeholder context node prompt; - Lui_ui.string_property context node Lui_protocol.TitleValue label; - if not enabled then Lui_ui.disabled context node true; - if autofocus then Lui_ui.bool_property context node Lui_protocol.Autofocus true; - let local_revision = ref accepted_local_revision in - Lui_ui.on_event context node (fun event -> - match event with - | Lui_protocol.TextChanged (_, text) -> - local_revision := Journal_ids.Text_input.Local_revision.succ !local_revision; - invoke - on_edit - (Event.Payload.Text_edit - { session_id - ; local_revision = !local_revision - ; base_document_revision = document_revision - ; text - ; selection = { start_utf16 = 0; end_utf16 = 0 } - ; composing = None - }) - | Lui_protocol.Submit _ -> invoke on_submit Event.Payload.Unit - | _ -> ()); - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - node) -;; + let stack ?key ?alignment:_ children = + element ?key (fun context parent -> + let node = Lui_ui.stack context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter (fun child -> ignore (child.mount context (Some node))) children; + node) + ;; -let labeled_content ?key ~label ~value () = - element ?key (fun context parent -> - let node = Lui_ui.row context in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - ignore (label.mount context (Some node)); - let spacer = Lui_ui.spacer context in - Lui_ui.append context node spacer; - ignore (value.mount context (Some node)); - node) -;; + let apply_frame_limit context node _min_prop max_prop limit = + match limit with + | Layout.Frame_limit.Fill -> Lui_ui.grow context node 1.0 + | Fixed value -> Lui_ui.int_property context node max_prop (int_of_float_nan value) + ;; -let content_unavailable ?key ~label ?description ?actions () = - element ?key (fun context parent -> - let node = Lui_ui.column context in - Lui_ui.cross context node "center"; - Lui_ui.main context node "center"; - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - ignore (label.mount context (Some node)); - Option.iter (fun description -> ignore (description.mount context (Some node))) description; - Option.iter (fun actions -> ignore (actions.mount context (Some node))) actions; - node) -;; + let frame + ?key:frame_key + ?width + ?height + ?min_width + ?ideal_width:_ + ?max_width + ?min_height + ?ideal_height:_ + ?max_height + ?alignment:_ + t + = + modify + (fun context node -> + Option.iter (fun v -> Lui_ui.width context node (int_of_float_nan v)) width; + Option.iter (fun v -> Lui_ui.height context node (int_of_float_nan v)) height; + Option.iter + (fun v -> Lui_ui.min_width context node (int_of_float_nan v)) + min_width; + Option.iter + (fun v -> Lui_ui.min_height context node (int_of_float_nan v)) + min_height; + Option.iter + (fun limit -> + apply_frame_limit + context + node + Lui_protocol.MinWidth + Lui_protocol.MaxWidth + limit) + max_width; + Option.iter + (fun limit -> + apply_frame_limit + context + node + Lui_protocol.MinHeight + Lui_protocol.MaxHeight + limit) + max_height) + t + |> fun result -> + match frame_key with + | Some key -> { result with key = Some key } + | None -> result + ;; -let overlay ?key:_ ?alignment:_ ~overlay t = - element ?key:t.key (fun context parent -> - let node = Lui_ui.stack context in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - ignore (t.mount context (Some node)); - ignore (overlay.mount context (Some node)); - node) -;; + let padding ?key:_ ~insets t = + modify (fun context node -> Lui_ui.padding context node (int_of_float_nan insets)) t + ;; -module Keyed = struct - type widget = t + let semantics ?key:_ ~properties t = + modify + (fun context node -> + Option.iter + (fun label -> Lui_ui.accessibility_label context node label) + properties.Semantics.label) + t + ;; - type nonrec t = - { key : string - ; view : t - } + let help ?key:_ ~message:_ t = t + let text_selection ?key:_ ~enabled:_ t = t + let opacity ?key:_ value t = modify (fun _ _ -> ignore value) t + let ignores_safe_area ?regions:_ ?edges:_ t = t + let safe_area_padding ?key:_ ~insets:_ t = t + let theme ?key:_ ~data:_ t = t + + let background ?key:_ ?corner_radius ~color t = + modify + (fun context node -> + Lui_ui.background context node color; + Option.iter + (fun radius -> Lui_ui.corner_radius context node (int_of_float_nan radius)) + corner_radius) + t + ;; - let create ~key view = { key; view } -end + let clip ?key:_ ?corner_radius:_ ?antialiased:_ t = t + let layout_priority ?key:_ _ t = t + let offset ?key:_ ?x:_ ?y:_ t = t + let animated_opacity ?key:_ ?duration:_ value t = opacity value t -module Section = struct - let create ?key ?header ?footer entries = + let button + ?key + ?(enabled = true) + ?(role = Button_role.Normal) + ?style + ?(autofocus = false) + ~on_press + ~child + () + = element ?key (fun context parent -> - let node = Lui_ui.panel context in + let node = Lui_ui.button context in + if not enabled then Lui_ui.disabled context node true; + Option.iter + (fun style -> + Lui_ui.string_property + context + node + Lui_protocol.VariantValue + (Button_style.variant style)) + style; + (match role with + | Button_role.Normal -> () + | role -> + Lui_ui.string_property + context + node + Lui_protocol.VariantValue + (Button_role.variant role)); + if autofocus then Lui_ui.bool_property context node Lui_protocol.Autofocus true; + Lui_ui.on_event context node (fun event -> + if is_press event then invoke on_press Event.Payload.Unit); (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - Option.iter (fun header -> ignore (header.mount context (Some node))) header; - List.iter (fun (entry : Keyed.t) -> ignore (entry.view.mount context (Some node))) entries; - Option.iter (fun footer -> ignore (footer.mount context (Some node))) footer; + ignore (child.mount context (Some node)); node) ;; -end -module Form = struct - let vertical ?key entries = + let toggle ?key ?style:_ ?(enabled = true) ~value ~on_changed ~label () = element ?key (fun context parent -> - let node = Lui_ui.list context in + let node = Lui_ui.toggle context in + if not enabled then Lui_ui.disabled context node true; + Lui_ui.bool_property context node Lui_protocol.Checked value; + Lui_ui.on_event context node (fun event -> + match event with + | Lui_protocol.ToggleChanged (_, selected) -> + invoke on_changed (Event.Payload.Bool selected) + | _ -> ()); (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - List.iter - (fun (entry : Keyed.t) -> ignore (entry.view.mount context (Some node))) - entries; + ignore (label.mount context (Some node)); node) ;; -end - -module Toolbar = struct - type placement = - | Automatic - | Principal - | Navigation - | Primary_action - | Secondary_action - | Status - | Confirmation_action - | Cancellation_action - | Destructive_action - | Bottom_bar - - type spacing = - | Fixed - | Flexible - - type child = t - type item = - { item_key : string - ; placement : placement option - ; content : child - ; spacing : spacing option - ; is_group : bool - } - let child ~key:_ view = view - let item ~key ?placement content = { item_key = key; placement; content; spacing = None; is_group = false } - - let group ~key ?placement children = - { item_key = key - ; placement - ; content = row ~key children - ; spacing = None - ; is_group = true - } + let text_editor + ?key + ?(autofocus = false) + ?(enabled = true) + ?(read_only = false) + ?(submit_on_return = true) + ?max_utf8_bytes:_ + ~session_id + ~document_revision + ~accepted_local_revision + ~update_mode:_ + ~value + ~on_edit + ~on_submit + ~on_focus_changed:_ + ?on_limit_reached:_ + () + = + element ?key (fun context parent -> + let node = Lui_ui.textarea context in + Lui_ui.text_property context node (Text_editing.Value.text value); + if not (enabled && not read_only) then Lui_ui.disabled context node true; + if autofocus then Lui_ui.bool_property context node Lui_protocol.Autofocus true; + Lui_ui.bool_property context node Lui_protocol.SubmitOnEnter submit_on_return; + let local_revision = ref accepted_local_revision in + Lui_ui.on_event context node (fun event -> + match event with + | Lui_protocol.TextChanged (_, text) -> + local_revision := Journal_ids.Text_input.Local_revision.succ !local_revision; + invoke + on_edit + (Event.Payload.Text_edit + { session_id + ; local_revision = !local_revision + ; base_document_revision = document_revision + ; text + ; selection = { start_utf16 = 0; end_utf16 = 0 } + ; composing = None + }) + | Lui_protocol.Submit _ -> invoke on_submit Event.Payload.Unit + | _ -> ()); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) ;; - let spacer ~key ?placement:_ _spacing = - { item_key = key - ; placement = None - ; content = element (fun context parent -> - let node = Lui_ui.spacer context in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - node) - ; spacing = None - ; is_group = false - } + let secure_field + ?key + ~label + ?(prompt = "") + ?keyboard:_ + ?submit_label:_ + ?appearance:_ + ?(autofocus = false) + ?(enabled = true) + ?read_only:_ + ?submit_on_return:_ + ?max_utf8_bytes:_ + ~session_id + ~document_revision + ~accepted_local_revision + ~update_mode:_ + ~value + ~on_edit + ~on_submit + ~on_focus_changed:_ + ?on_limit_reached:_ + () + = + element ?key (fun context parent -> + let node = Lui_ui.secure_field context in + Lui_ui.text_property context node (Text_editing.Value.text value); + Lui_ui.placeholder context node prompt; + Lui_ui.string_property context node Lui_protocol.TitleValue label; + if not enabled then Lui_ui.disabled context node true; + if autofocus then Lui_ui.bool_property context node Lui_protocol.Autofocus true; + let local_revision = ref accepted_local_revision in + Lui_ui.on_event context node (fun event -> + match event with + | Lui_protocol.TextChanged (_, text) -> + local_revision := Journal_ids.Text_input.Local_revision.succ !local_revision; + invoke + on_edit + (Event.Payload.Text_edit + { session_id + ; local_revision = !local_revision + ; base_document_revision = document_revision + ; text + ; selection = { start_utf16 = 0; end_utf16 = 0 } + ; composing = None + }) + | Lui_protocol.Submit _ -> invoke on_submit Event.Payload.Unit + | _ -> ()); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) ;; - let mount_items items = - element (fun context parent -> - let node = Lui_ui.toolbar context in + let labeled_content ?key ~label ~value () = + element ?key (fun context parent -> + let node = Lui_ui.row context in (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - List.iter - (fun (item : item) -> - let child = item.content in - let mounted = child.mount context (Some node) in - Lui_ui.key context mounted item.item_key; - (match item.placement with - | Some placement -> - Lui_ui.string_property - context - mounted - Lui_protocol.RoleValue - (match placement with - | Automatic -> "automatic" - | Principal -> "principal" - | Navigation -> "navigation" - | Primary_action -> "primary_action" - | Secondary_action -> "secondary_action" - | Status -> "status" - | Confirmation_action -> "confirmation_action" - | Cancellation_action -> "cancellation_action" - | Destructive_action -> "destructive_action" - | Bottom_bar -> "bottom_bar") - | None -> ()); - (match item.spacing with - | Some Fixed -> - Lui_ui.string_property - context - mounted - Lui_protocol.VariantValue - "fixed_spacing" - | Some Flexible -> - Lui_ui.string_property - context - mounted - Lui_protocol.VariantValue - "flexible_spacing" - | None -> ()); - if item.is_group - then - Lui_ui.string_property - context - mounted - Lui_protocol.VariantValue - "item_group") - items; + ignore (label.mount context (Some node)); + let spacer = Lui_ui.spacer context in + Lui_ui.append context node spacer; + ignore (value.mount context (Some node)); node) ;; - let create ?key ~items t = + let content_unavailable ?key ~label ?description ?actions () = element ?key (fun context parent -> let node = Lui_ui.column context in + Lui_ui.cross context node "center"; + Lui_ui.main context node "center"; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (label.mount context (Some node)); + Option.iter + (fun description -> ignore (description.mount context (Some node))) + description; + Option.iter (fun actions -> ignore (actions.mount context (Some node))) actions; + node) + ;; + + let overlay ?key:_ ?alignment:_ ~overlay t = + element ?key:t.key (fun context parent -> + let node = Lui_ui.stack context in (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - ignore ((mount_items items).mount context (Some node)); ignore (t.mount context (Some node)); + ignore (overlay.mount context (Some node)); node) ;; -end -let parent_overlay = overlay + module Keyed = struct + type widget = t -module Body = struct - type nonrec t = t - type widget = t - - let with_size ~width ~height t = frame ~width ~height t - let static t = t - let with_test_id id t = with_test_id id t - let padding ~insets t = padding ~insets t - let background ?corner_radius ~color t = background ?corner_radius ~color t - let semantics ~properties t = semantics ~properties t - let ignores_safe_area ?regions ?edges t = ignores_safe_area ?regions ?edges t - let safe_area_padding ~insets t = safe_area_padding ~insets t - let theme ~data t = theme ~data t - let toolbar ?key:_ ~items t = Toolbar.create ~items t - let overlay ?key ?alignment ~overlay t = parent_overlay ?key ?alignment ~overlay t - - module Vertical = struct - type child = t + type nonrec t = + { key : string + ; view : t + } - let fixed t = t - let fill ?weight:_ t = t - let create ?key children = column ?key children + let create ~key view = { key; view } end - module Horizontal = struct - type child = t + module Section = struct + let create ?key ?header ?footer entries = + element ?key (fun context parent -> + let node = Lui_ui.panel context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + Option.iter (fun header -> ignore (header.mount context (Some node))) header; + List.iter + (fun (entry : Keyed.t) -> ignore (entry.view.mount context (Some node))) + entries; + Option.iter (fun footer -> ignore (footer.mount context (Some node))) footer; + node) + ;; + end - let fixed t = t - let fill ?weight:_ t = t - let create ?key children = row ?key children + module Form = struct + let vertical ?key entries = + element ?key (fun context parent -> + let node = Lui_ui.list context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter + (fun (entry : Keyed.t) -> ignore (entry.view.mount context (Some node))) + entries; + node) + ;; end - module Private = struct - let to_widget t = t + module Toolbar = struct + type placement = + | Automatic + | Principal + | Navigation + | Primary_action + | Secondary_action + | Status + | Confirmation_action + | Cancellation_action + | Destructive_action + | Bottom_bar + + type spacing = + | Fixed + | Flexible + + type child = t + + type item = + { item_key : string + ; placement : placement option + ; content : child + ; spacing : spacing option + ; is_group : bool + } + + let child ~key:_ view = view + + let item ~key ?placement content = + { item_key = key; placement; content; spacing = None; is_group = false } + ;; + + let group ~key ?placement children = + { item_key = key + ; placement + ; content = row ~key children + ; spacing = None + ; is_group = true + } + ;; + + let spacer ~key ?placement:_ _spacing = + { item_key = key + ; placement = None + ; content = + element (fun context parent -> + let node = Lui_ui.spacer context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) + ; spacing = None + ; is_group = false + } + ;; + + let mount_items items = + element (fun context parent -> + let node = Lui_ui.toolbar context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter + (fun (item : item) -> + let child = item.content in + let mounted = child.mount context (Some node) in + Lui_ui.key context mounted item.item_key; + (match item.placement with + | Some placement -> + Lui_ui.string_property + context + mounted + Lui_protocol.RoleValue + (match placement with + | Automatic -> "automatic" + | Principal -> "principal" + | Navigation -> "navigation" + | Primary_action -> "primary_action" + | Secondary_action -> "secondary_action" + | Status -> "status" + | Confirmation_action -> "confirmation_action" + | Cancellation_action -> "cancellation_action" + | Destructive_action -> "destructive_action" + | Bottom_bar -> "bottom_bar") + | None -> ()); + (match item.spacing with + | Some Fixed -> + Lui_ui.string_property + context + mounted + Lui_protocol.VariantValue + "fixed_spacing" + | Some Flexible -> + Lui_ui.string_property + context + mounted + Lui_protocol.VariantValue + "flexible_spacing" + | None -> ()); + if item.is_group + then + Lui_ui.string_property + context + mounted + Lui_protocol.VariantValue + "item_group") + items; + node) + ;; + + let create ?key ~items t = + element ?key (fun context parent -> + let node = Lui_ui.column context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore ((mount_items items).mount context (Some node)); + ignore (t.mount context (Some node)); + node) + ;; end -end -module Viewport = struct - module Vertical = struct + let parent_overlay = overlay + + module Body = struct type nonrec t = t + type widget = t + let with_size ~width ~height t = frame ~width ~height t + let static t = t let with_test_id id t = with_test_id id t let padding ~insets t = padding ~insets t let background ?corner_radius ~color t = background ?corner_radius ~color t @@ -1042,782 +1054,866 @@ module Viewport = struct let ignores_safe_area ?regions ?edges t = ignores_safe_area ?regions ?edges t let safe_area_padding ~insets t = safe_area_padding ~insets t let theme ~data t = theme ~data t + let toolbar ?key:_ ~items t = Toolbar.create ~items t let overlay ?key ?alignment ~overlay t = parent_overlay ?key ?alignment ~overlay t - let with_height ~height t = frame ~height t - end - module Horizontal = struct - type nonrec t = t + module Vertical = struct + type child = t - let with_test_id id t = with_test_id id t - let with_width ~width t = frame ~width t + let fixed t = t + let fill ?weight:_ t = t + let create ?key children = column ?key children + end + + module Horizontal = struct + type child = t + + let fixed t = t + let fill ?weight:_ t = t + let create ?key children = row ?key children + end + + module Private = struct + let to_widget t = t + end end -end -module Scroll = struct - type anchor = - | Start - | End + module Viewport = struct + module Vertical = struct + type nonrec t = t + + let with_test_id id t = with_test_id id t + let padding ~insets t = padding ~insets t + let background ?corner_radius ~color t = background ?corner_radius ~color t + let semantics ~properties t = semantics ~properties t + let ignores_safe_area ?regions ?edges t = ignores_safe_area ?regions ?edges t + let safe_area_padding ~insets t = safe_area_padding ~insets t + let theme ~data t = theme ~data t + let overlay ?key ?alignment ~overlay t = parent_overlay ?key ?alignment ~overlay t + let with_height ~height t = frame ~height t + end - let vertical ?key ?on_scroll:_ ?shows_indicators:_ ?fill_viewport:_ ?initial_anchor:_ t = - element ?key (fun context parent -> - let node = Lui_ui.scroll context in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - ignore (t.mount context (Some node)); - node) - ;; + module Horizontal = struct + type nonrec t = t -end + let with_test_id id t = with_test_id id t + let with_width ~width t = frame ~width t + end + end -module Swipe_actions = struct - type side = - | Start - | End - - type action = - { key : string - ; enabled : bool - ; role : Button_role.t - ; symbol : string option - ; side : side - ; title : string - ; background : Style.Color.t - ; on_press : Event.handler - } + module Scroll = struct + type anchor = + | Start + | End + + let vertical + ?key + ?on_scroll:_ + ?shows_indicators:_ + ?fill_viewport:_ + ?initial_anchor:_ + t + = + element ?key (fun context parent -> + let node = Lui_ui.scroll context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (t.mount context (Some node)); + node) + ;; + end - type nonrec t = action list + module Swipe_actions = struct + type side = + | Start + | End + + type action = + { key : string + ; enabled : bool + ; role : Button_role.t + ; symbol : string option + ; side : side + ; title : string + ; background : Style.Color.t + ; on_press : Event.handler + } - let action ~key ?(enabled = true) ?(role = Button_role.Normal) ?symbol ~side ~title - ~background ~on_press () - = - { key; enabled; role; symbol; side; title; background; on_press } - ;; + type nonrec t = action list + + let action + ~key + ?(enabled = true) + ?(role = Button_role.Normal) + ?symbol + ~side + ~title + ~background + ~on_press + () + = + { key; enabled; role; symbol; side; title; background; on_press } + ;; - let create ?enabled:_ ?allows_full_swipe:_ ~actions () = actions -end + let create ?enabled:_ ?allows_full_swipe:_ ~actions () = actions + end -module Context_menu = struct - type nonrec view = t + module Context_menu = struct + type nonrec view = t - type role = - | Normal - | Destructive + type role = + | Normal + | Destructive - type action = - { key : string - ; enabled : bool - ; role : role - ; symbol : string option - ; title : string - ; on_press : Event.handler - } + type action = + { key : string + ; enabled : bool + ; role : role + ; symbol : string option + ; title : string + ; on_press : Event.handler + } - type nonrec t = action list + type nonrec t = action list - let action ~key ?(enabled = true) ?(role = Normal) ?symbol ~title ~on_press () = - { key; enabled; role; symbol; title; on_press } - ;; + let action ~key ?(enabled = true) ?(role = Normal) ?symbol ~title ~on_press () = + { key; enabled; role; symbol; title; on_press } + ;; - let create ?enabled:_ ~actions () = actions + let create ?enabled:_ ~actions () = actions - let attach ?key:_ actions (view : element_) = - element ?key:view.key (fun context parent -> - let node = Lui_ui.context_menu context in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - List.iter - (fun (action : action) -> - let item = Lui_ui.menu_item context in - Lui_ui.text_property context item action.title; - Option.iter - (fun symbol -> ignore (Lui_ui.append context item (Lui_ui.icon context symbol))) - action.symbol; - if not action.enabled then Lui_ui.disabled context item true; - Lui_ui.on_event context item (fun event -> - if is_press event then invoke action.on_press Event.Payload.Unit); - Lui_ui.append context node item) - actions; - ignore (view.mount context (Some node)); - node) - ;; -end + let attach ?key:_ actions (view : element_) = + element ?key:view.key (fun context parent -> + let node = Lui_ui.context_menu context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter + (fun (action : action) -> + let item = Lui_ui.menu_item context in + Lui_ui.text_property context item action.title; + Option.iter + (fun symbol -> + ignore (Lui_ui.append context item (Lui_ui.icon context symbol))) + action.symbol; + if not action.enabled then Lui_ui.disabled context item true; + Lui_ui.on_event context item (fun event -> + if is_press event then invoke action.on_press Event.Payload.Unit); + Lui_ui.append context node item) + actions; + ignore (view.mount context (Some node)); + node) + ;; + end -module Confirmation = struct - type action = - { key : string - ; title : string - ; enabled : bool - ; role : Button_role.t - } + module Confirmation = struct + type action = + { key : string + ; title : string + ; enabled : bool + ; role : Button_role.t + } - type request = - { token : int64 - ; title : string - ; message : string option - ; actions : action list - } + type request = + { token : int64 + ; title : string + ; message : string option + ; actions : action list + } - let action ~key ~title ?(enabled = true) ?(role = Button_role.Normal) () = - { key; title; enabled; role } - ;; + let action ~key ~title ?(enabled = true) ?(role = Button_role.Normal) () = + { key; title; enabled; role } + ;; - let request ~token ~title ?message actions = { token; title; message; actions } - - let alert ?key:_ ~request ~on_response (view : element_) = - element ?key:view.key (fun context parent -> - (match request with - | None -> view.mount context parent - | Some request -> - let node = - match parent with - | Some parent -> parent - | None -> view.mount context None - in - ignore (view.mount context (Some node)); - let dialog = Lui_ui.dialog context in - Lui_ui.text_property context dialog request.title; - Option.iter - (fun message -> Lui_ui.string_property context dialog Lui_protocol.DescriptionValue message) - request.message; - Lui_ui.append context node dialog; - List.iter - (fun (action : action) -> - let item = Lui_ui.button context in - Lui_ui.text_property context item action.title; - if not action.enabled then Lui_ui.disabled context item true; - (match action.role with - | Button_role.Normal -> () - | role -> - Lui_ui.string_property context item Lui_protocol.VariantValue - (Button_role.variant role)); - Lui_ui.on_event context item (fun event -> - match event with - | Lui_protocol.Press _ -> - invoke - on_response - (Event.Payload.Confirmation_response - { token = request.token - ; result = Action action.key - }) - | _ -> ()); - Lui_ui.append context dialog item) - request.actions; - Lui_ui.on_event context dialog (fun event -> - match event with - | Lui_protocol.Dismiss _ -> - invoke - on_response - (Event.Payload.Confirmation_response - { token = request.token; result = Dismissed }) - | _ -> ()); - node)) - ;; + let request ~token ~title ?message actions = { token; title; message; actions } + + let alert ?key:_ ~request ~on_response (view : element_) = + element ?key:view.key (fun context parent -> + match request with + | None -> view.mount context parent + | Some request -> + let node = + match parent with + | Some parent -> parent + | None -> view.mount context None + in + ignore (view.mount context (Some node)); + let dialog = Lui_ui.dialog context in + Lui_ui.text_property context dialog request.title; + Option.iter + (fun message -> + Lui_ui.string_property context dialog Lui_protocol.DescriptionValue message) + request.message; + Lui_ui.append context node dialog; + List.iter + (fun (action : action) -> + let item = Lui_ui.button context in + Lui_ui.text_property context item action.title; + if not action.enabled then Lui_ui.disabled context item true; + (match action.role with + | Button_role.Normal -> () + | role -> + Lui_ui.string_property + context + item + Lui_protocol.VariantValue + (Button_role.variant role)); + Lui_ui.on_event context item (fun event -> + match event with + | Lui_protocol.Press _ -> + invoke + on_response + (Event.Payload.Confirmation_response + { token = request.token; result = Action action.key }) + | _ -> ()); + Lui_ui.append context dialog item) + request.actions; + Lui_ui.on_event context dialog (fun event -> + match event with + | Lui_protocol.Dismiss _ -> + invoke + on_response + (Event.Payload.Confirmation_response + { token = request.token; result = Dismissed }) + | _ -> ()); + node) + ;; - let dialog ?key ~request ~on_response view = alert ?key ~request ~on_response view -end + let dialog ?key ~request ~on_response view = alert ?key ~request ~on_response view + end + + module Native_list = struct + type anchor = + | Top + | Center + | Bottom -module Native_list = struct - type anchor = - | Top - | Center - | Bottom + type target = + { section : string + ; row_path : string list + } - type target = - { section : string - ; row_path : string list - } + type scroll_request = + { token : int64 + ; target : target + ; anchor : anchor option + ; animated : bool option + } - type scroll_request = - { token : int64 - ; target : target - ; anchor : anchor option - ; animated : bool option - } + type outcome = Event.Payload.native_list_outcome + type completion = Event.Payload.native_list_completion - type outcome = Event.Payload.native_list_outcome - type completion = Event.Payload.native_list_completion + let target ~section ~row_path = { section; row_path } - let target ~section ~row_path = { section; row_path } + let scroll_request ~token ~target ?anchor ?animated () = + { token; target; anchor; animated } + ;; - let scroll_request ~token ~target ?anchor ?animated () = - { token; target; anchor; animated } - ;; + let completion_of_payload = function + | Event.Payload.Native_list_completion completion -> Some completion + | _ -> None + ;; - let completion_of_payload = function - | Event.Payload.Native_list_completion completion -> Some completion - | _ -> None - ;; + type style = + | Plain + | Inset + | Inset_grouped - type style = - | Plain - | Inset - | Inset_grouped + let style_name = function + | Plain -> "plain" + | Inset -> "inset" + | Inset_grouped -> "inset_grouped" + ;; - let style_name = function - | Plain -> "plain" - | Inset -> "inset" - | Inset_grouped -> "inset_grouped" - ;; + type separator = + | Automatic + | Hidden + | Visible - type separator = - | Automatic - | Hidden - | Visible + let separator_name = function + | Automatic -> "automatic" + | Hidden -> "hidden" + | Visible -> "visible" + ;; - let separator_name = function - | Automatic -> "automatic" - | Hidden -> "hidden" - | Visible -> "visible" - ;; + type row_kind = + | Row + | Disclosure of + { expanded : bool + ; children : row list + } - type row_kind = - | Row - | Disclosure of - { expanded : bool - ; children : row list - } - - and row = - { key : string - ; test_id : string option - ; separator : separator - ; swipe_actions : Swipe_actions.t option - ; context_menu : Context_menu.t option - ; kind : row_kind - ; content : t - ; on_expanded_changed : Event.handler option - } + and row = + { key : string + ; test_id : string option + ; separator : separator + ; swipe_actions : Swipe_actions.t option + ; context_menu : Context_menu.t option + ; kind : row_kind + ; content : t + ; on_expanded_changed : Event.handler option + } - type section = - { section_key : string - ; header : t option - ; footer : t option - ; separator : separator - ; rows : row list - } + type section = + { section_key : string + ; header : t option + ; footer : t option + ; separator : separator + ; rows : row list + } - let row ~key ?test_id ?(separator = Automatic) ?swipe_actions ?context_menu content = - { key - ; test_id - ; separator - ; swipe_actions - ; context_menu - ; kind = Row - ; content - ; on_expanded_changed = None - } - ;; + let row ~key ?test_id ?(separator = Automatic) ?swipe_actions ?context_menu content = + { key + ; test_id + ; separator + ; swipe_actions + ; context_menu + ; kind = Row + ; content + ; on_expanded_changed = None + } + ;; - let disclosure_row ~key ?test_id ?(separator = Automatic) ?swipe_actions ?context_menu - ~expanded ~on_expanded_changed ~label children - = - { key - ; test_id - ; separator - ; swipe_actions - ; context_menu - ; kind = Disclosure { expanded; children } - ; content = label - ; on_expanded_changed = Some on_expanded_changed - } - ;; + let disclosure_row + ~key + ?test_id + ?(separator = Automatic) + ?swipe_actions + ?context_menu + ~expanded + ~on_expanded_changed + ~label + children + = + { key + ; test_id + ; separator + ; swipe_actions + ; context_menu + ; kind = Disclosure { expanded; children } + ; content = label + ; on_expanded_changed = Some on_expanded_changed + } + ;; - let section ~key ?header ?footer ?(separator = Automatic) rows = - { section_key = key; header; footer; separator; rows } - ;; + let section ~key ?header ?footer ?(separator = Automatic) rows = + { section_key = key; header; footer; separator; rows } + ;; - let swipe_json (actions : Swipe_actions.t) = - `Assoc - [ ( "actions" - , `List - (List.map - (fun (a : Swipe_actions.action) -> - `Assoc - [ "key", `String a.key - ; "enabled", `Bool a.enabled - ; "role", `String (Button_role.variant a.role) - ; ( "symbol" - , match a.symbol with - | Some s -> `String s - | None -> `Null ) - ; "side", `String (match a.side with Start -> "start" | End -> "end") - ; "title", `String a.title - ; "background", `String a.background - ]) - actions) ) - ] - ;; + let swipe_json (actions : Swipe_actions.t) = + `Assoc + [ ( "actions" + , `List + (List.map + (fun (a : Swipe_actions.action) -> + `Assoc + [ "key", `String a.key + ; "enabled", `Bool a.enabled + ; "role", `String (Button_role.variant a.role) + ; ( "symbol" + , match a.symbol with + | Some s -> `String s + | None -> `Null ) + ; ( "side" + , `String + (match a.side with + | Start -> "start" + | End -> "end") ) + ; "title", `String a.title + ; "background", `String a.background + ]) + actions) ) + ] + ;; - let context_menu_json (actions : Context_menu.t) = - `Assoc - [ ( "actions" - , `List - (List.map - (fun (a : Context_menu.action) -> - `Assoc - [ "key", `String a.key - ; "enabled", `Bool a.enabled - ; "role", `String (match a.role with Normal -> "normal" | Destructive -> "destructive") - ; ( "symbol" - , match a.symbol with - | Some s -> `String s - | None -> `Null ) - ; "title", `String a.title - ]) - actions) ) - ] - ;; + let context_menu_json (actions : Context_menu.t) = + `Assoc + [ ( "actions" + , `List + (List.map + (fun (a : Context_menu.action) -> + `Assoc + [ "key", `String a.key + ; "enabled", `Bool a.enabled + ; ( "role" + , `String + (match a.role with + | Normal -> "normal" + | Destructive -> "destructive") ) + ; ( "symbol" + , match a.symbol with + | Some s -> `String s + | None -> `Null ) + ; "title", `String a.title + ]) + actions) ) + ] + ;; - (* Content elements (headers, rows, footers, disclosure labels) mount as + (* Content elements (headers, rows, footers, disclosure labels) mount as extension children in a deterministic order; the payload lists their index so the host binds each child node to its list position. *) - let build sections ~style ~scroll_request ~track_visible ~track_scroll = - let contents = ref [] in - let push element = contents := !contents @ [ element ]; List.length !contents - 1 in - let rec row_json (row : row) = - let content_index = push row.content in - let base = - [ "key", `String row.key - ; "content_index", `Int content_index - ; "separator", `String (separator_name row.separator) - ] - in - let base = - match row.test_id with - | Some id -> ("test_id", `String id) :: base - | None -> base + let build sections ~style ~scroll_request ~track_visible ~track_scroll = + let contents = ref [] in + let push element = + contents := !contents @ [ element ]; + List.length !contents - 1 in - let base = - match row.swipe_actions with - | Some actions -> ("swipe", swipe_json actions) :: base - | None -> base + let rec row_json (row : row) = + let content_index = push row.content in + let base = + [ "key", `String row.key + ; "content_index", `Int content_index + ; "separator", `String (separator_name row.separator) + ] + in + let base = + match row.test_id with + | Some id -> ("test_id", `String id) :: base + | None -> base + in + let base = + match row.swipe_actions with + | Some actions -> ("swipe", swipe_json actions) :: base + | None -> base + in + let base = + match row.context_menu with + | Some menu -> ("context_menu", context_menu_json menu) :: base + | None -> base + in + match row.kind with + | Row -> `Assoc (("type", `String "row") :: base) + | Disclosure { expanded; children } -> + `Assoc + (("type", `String "disclosure") + :: ("expanded", `Bool expanded) + :: ("children", `List (List.map row_json children)) + :: base) in - let base = - match row.context_menu with - | Some menu -> ("context_menu", context_menu_json menu) :: base - | None -> base + let section_json (section : section) = + `Assoc + [ "key", `String section.section_key + ; "separator", `String (separator_name section.separator) + ; ( "header_index" + , match section.header with + | Some header -> `Int (push header) + | None -> `Null ) + ; ( "footer_index" + , match section.footer with + | Some footer -> `Int (push footer) + | None -> `Null ) + ; "rows", `List (List.map row_json section.rows) + ] in - match row.kind with - | Row -> `Assoc (("type", `String "row") :: base) - | Disclosure { expanded; children } -> + let payload = `Assoc - (("type", `String "disclosure") - :: ("expanded", `Bool expanded) - :: ("children", `List (List.map row_json children)) - :: base) - in - let section_json (section : section) = - `Assoc - [ "key", `String section.section_key - ; "separator", `String (separator_name section.separator) - ; ( "header_index" - , match section.header with - | Some header -> `Int (push header) - | None -> `Null ) - ; ( "footer_index" - , match section.footer with - | Some footer -> `Int (push footer) - | None -> `Null ) - ; "rows", `List (List.map row_json section.rows) - ] - in - let payload = - `Assoc - [ "style", `String (style_name style) - ; "sections", `List (List.map section_json sections) - ; ( "scroll_request" - , match scroll_request with - | None -> `Null - | Some request -> - `Assoc - [ "token", `String (Int64.to_string request.token) - ; ( "target" - , `Assoc - [ "section", `String request.target.section - ; "row_path", `List (List.map (fun key -> `String key) request.target.row_path) - ] ) - ; ( "anchor" - , match request.anchor with - | Some Top -> `String "top" - | Some Center -> `String "center" - | Some Bottom -> `String "bottom" - | None -> `Null ) - ; ( "animated" - , match request.animated with - | Some value -> `Bool value - | None -> `Null ) - ] ) - ; "track_visible_range", `Bool track_visible - ; "track_scroll_completion", `Bool track_scroll - ] - in - Yojson.Basic.to_string payload, List.rev !contents |> List.rev - ;; + [ "style", `String (style_name style) + ; "sections", `List (List.map section_json sections) + ; ( "scroll_request" + , match scroll_request with + | None -> `Null + | Some request -> + `Assoc + [ "token", `String (Int64.to_string request.token) + ; ( "target" + , `Assoc + [ "section", `String request.target.section + ; ( "row_path" + , `List + (List.map (fun key -> `String key) request.target.row_path) + ) + ] ) + ; ( "anchor" + , match request.anchor with + | Some Top -> `String "top" + | Some Center -> `String "center" + | Some Bottom -> `String "bottom" + | None -> `Null ) + ; ( "animated" + , match request.animated with + | Some value -> `Bool value + | None -> `Null ) + ] ) + ; "track_visible_range", `Bool track_visible + ; "track_scroll_completion", `Bool track_scroll + ] + in + Yojson.Basic.to_string payload, List.rev !contents |> List.rev + ;; - let decode_outcome = function - | `String "succeeded" -> Event.Payload.Succeeded - | `String "missing_target" -> Missing_target - | `String "cancelled" -> Cancelled - | `String "superseded" -> Superseded - | `String "positioning_failed" -> Positioning_failed - | _ -> Positioning_failed - ;; + let decode_outcome = function + | `String "succeeded" -> Event.Payload.Succeeded + | `String "missing_target" -> Missing_target + | `String "cancelled" -> Cancelled + | `String "superseded" -> Superseded + | `String "positioning_failed" -> Positioning_failed + | _ -> Positioning_failed + ;; - let vertical - ?key - ~style - ?scroll_request - ?on_scroll_completed - ?on_visible_range - ?(on_row_event : Event.handler option) - sections - = - let payload, contents = - build - sections - ~style - ~scroll_request - ~track_visible:(Option.is_some on_visible_range) - ~track_scroll:(Option.is_some on_scroll_completed) - in - (* Expansion state arrives as {"type":"expanded","key":..,"expanded":bool}; + let vertical + ?key + ~style + ?scroll_request + ?on_scroll_completed + ?on_visible_range + ?(on_row_event : Event.handler option) + sections + = + let payload, contents = + build + sections + ~style + ~scroll_request + ~track_visible:(Option.is_some on_visible_range) + ~track_scroll:(Option.is_some on_scroll_completed) + in + (* Expansion state arrives as {"type":"expanded","key":..,"expanded":bool}; the owning row's handler receives Bool like the old disclosure callback. *) - let expanded_handlers = - let rec collect acc (row : row) = - match row.kind with - | Row -> acc - | Disclosure { children; _ } -> - List.fold_left collect ((row.key, row.on_expanded_changed) :: acc) children + let expanded_handlers = + let rec collect acc (row : row) = + match row.kind with + | Row -> acc + | Disclosure { children; _ } -> + List.fold_left collect ((row.key, row.on_expanded_changed) :: acc) children + in + List.fold_left + (fun acc section -> List.fold_left collect acc section.rows) + [] + sections in - List.fold_left - (fun acc section -> List.fold_left collect acc section.rows) - [] - sections - in - let on_event (event : Journal_lui_native.event) = - match (try Yojson.Basic.from_string event.payload with _ -> `Null) with - | `Assoc fields -> - (match List.assoc_opt "type" fields with - | Some (`String "visible_range") -> - Option.iter - (fun handler -> - let get_int64 name = - match List.assoc_opt name fields with - | Some (`Int v) -> Int64.of_int v - | Some (`String s) -> Int64.of_string s - | _ -> 0L - in - invoke - handler - (Event.Payload.Visible_range - { first_index = get_int64 "first" - ; last_exclusive = get_int64 "last" - })) - on_visible_range - | Some (`String "scroll_completed") -> - Option.iter - (fun handler -> - let token = - match List.assoc_opt "token" fields with - | Some (`Int v) -> Int64.of_int v - | Some (`String s) -> Int64.of_string s - | _ -> 0L - in - let outcome = - match List.assoc_opt "outcome" fields with - | Some json -> decode_outcome json - | None -> Event.Payload.Positioning_failed - in - invoke - handler - (Event.Payload.Native_list_completion { token; outcome })) - on_scroll_completed - | Some (`String "expanded") -> - (match - ( List.assoc_opt "key" fields - , List.assoc_opt "expanded" fields ) - with - | Some (`String key), Some (`Bool expanded) -> - List.iter - (fun (row_key, handler) -> - if String.equal row_key key - then - Option.iter - (fun handler -> invoke handler (Event.Payload.Bool expanded)) - handler) - expanded_handlers - | _ -> ()) - | Some (`String "row_event") -> - Option.iter - (fun handler -> - match List.assoc_opt "payload" fields with - | Some (`String payload) -> + let on_event (event : Journal_lui_native.event) = + match + try Yojson.Basic.from_string event.payload with + | _ -> `Null + with + | `Assoc fields -> + (match List.assoc_opt "type" fields with + | Some (`String "visible_range") -> + Option.iter + (fun handler -> + let get_int64 name = + match List.assoc_opt name fields with + | Some (`Int v) -> Int64.of_int v + | Some (`String s) -> Int64.of_string s + | _ -> 0L + in invoke handler - (Event.Payload.Native_event - { kind_id = Journal_ids.Native_widget.Kind_id.of_int 0 - ; version = 0 - ; event_id = event.event_id - ; payload = Bytes.of_string payload - }) - | _ -> ()) - on_row_event - | _ -> ()) - | _ -> () - in - element ?key (fun context parent -> - Journal_lui_native.mount - ?key - ~payload - ~children:(List.map (fun element -> element.mount) contents) - ~on_event - Journal_lui_native.list_identifier - context - parent) - ;; -end + (Event.Payload.Visible_range + { first_index = get_int64 "first" + ; last_exclusive = get_int64 "last" + })) + on_visible_range + | Some (`String "scroll_completed") -> + Option.iter + (fun handler -> + let token = + match List.assoc_opt "token" fields with + | Some (`Int v) -> Int64.of_int v + | Some (`String s) -> Int64.of_string s + | _ -> 0L + in + let outcome = + match List.assoc_opt "outcome" fields with + | Some json -> decode_outcome json + | None -> Event.Payload.Positioning_failed + in + invoke handler (Event.Payload.Native_list_completion { token; outcome })) + on_scroll_completed + | Some (`String "expanded") -> + (match List.assoc_opt "key" fields, List.assoc_opt "expanded" fields with + | Some (`String key), Some (`Bool expanded) -> + List.iter + (fun (row_key, handler) -> + if String.equal row_key key + then + Option.iter + (fun handler -> invoke handler (Event.Payload.Bool expanded)) + handler) + expanded_handlers + | _ -> ()) + | Some (`String "row_event") -> + Option.iter + (fun handler -> + match List.assoc_opt "payload" fields with + | Some (`String payload) -> + invoke + handler + (Event.Payload.Native_event + { kind_id = Journal_ids.Native_widget.Kind_id.of_int 0 + ; version = 0 + ; event_id = event.event_id + ; payload = Bytes.of_string payload + }) + | _ -> ()) + on_row_event + | _ -> ()) + | _ -> () + in + element ?key (fun context parent -> + Journal_lui_native.mount + ?key + ~payload + ~children:(List.map (fun element -> element.mount) contents) + ~on_event + Journal_lui_native.list_identifier + context + parent) + ;; + end -module Navigation_link = struct - let create ?key ~activation_id:_ ?(enabled = true) ~on_activate ~label () = - element ?key (fun context parent -> - let node = Lui_ui.list_item context in - if not enabled then Lui_ui.disabled context node true; - Lui_ui.on_event context node (fun event -> - if is_press event then invoke on_activate Event.Payload.Unit); - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - ignore (label.mount context (Some node)); - node) - ;; -end + module Navigation_link = struct + let create ?key ~activation_id:_ ?(enabled = true) ~on_activate ~label () = + element ?key (fun context parent -> + let node = Lui_ui.list_item context in + if not enabled then Lui_ui.disabled context node true; + Lui_ui.on_event context node (fun event -> + if is_press event then invoke on_activate Event.Payload.Unit); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (label.mount context (Some node)); + node) + ;; + end -module Navigation_stack = struct - type destination = t + module Navigation_stack = struct + type destination = t - let destination ~page_key:_ ~title:_ ~can_pop:_ content = content + let destination ~page_key:_ ~title:_ ~can_pop:_ content = content - (* The lui widget set has no navigation-stack node. The router stays in the + (* The lui widget set has no navigation-stack node. The router stays in the model: the topmost destination renders, and interactive pops arrive as [Navigation_path_changed] through the back affordance the shim renders. *) - let create ?key ~title:_ ~on_path_change ~path root = - element ?key (fun context parent -> - let node = Lui_ui.column context in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - (match List.rev path with - | [] -> ignore (root.mount context (Some node)) - | top :: _ -> - let back = - element (fun context parent -> - let node = Lui_ui.button context in - Lui_ui.text_property context node "Back"; - Lui_ui.string_property context node Lui_protocol.VariantValue "plain"; - Lui_ui.accessibility_identifier context node "journal-nav-back"; - Lui_ui.on_event context node (fun event -> - if is_press event - then - invoke on_path_change (Event.Payload.Navigation_path_changed [])); - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - node) - in - ignore (back.mount context (Some node)); - ignore (top.mount context (Some node))); - node) - ;; -end - -module Sheet = struct - type sizing = - | Automatic - | Form - | Fitted + let create ?key ~title:_ ~on_path_change ~path root = + element ?key (fun context parent -> + let node = Lui_ui.column context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + (match List.rev path with + | [] -> ignore (root.mount context (Some node)) + | top :: _ -> + let back = + element (fun context parent -> + let node = Lui_ui.button context in + Lui_ui.text_property context node "Back"; + Lui_ui.string_property context node Lui_protocol.VariantValue "plain"; + Lui_ui.accessibility_identifier context node "journal-nav-back"; + Lui_ui.on_event context node (fun event -> + if is_press event + then invoke on_path_change (Event.Payload.Navigation_path_changed [])); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) + in + ignore (back.mount context (Some node)); + ignore (top.mount context (Some node))); + node) + ;; + end - type detent = - | Medium - | Large + module Sheet = struct + type sizing = + | Automatic + | Form + | Fitted + + type detent = + | Medium + | Large + + let create + ?key + ~presented + ~on_presented_changed + ?(interactive_dismiss = true) + ?sizing:_ + ?detents:_ + ~content + base + = + element ?key (fun context parent -> + let node = Lui_ui.column context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (base.mount context (Some node)); + if presented + then ( + let sheet = Lui_ui.sheet context in + Lui_ui.append context node sheet; + if interactive_dismiss + then + Lui_ui.on_event context sheet (fun event -> + match event with + | Lui_protocol.Dismiss _ -> + invoke on_presented_changed (Event.Payload.Bool false) + | _ -> ()); + ignore (content.mount context (Some sheet))); + node) + ;; + end - let create - ?key - ~presented - ~on_presented_changed - ?(interactive_dismiss = true) - ?sizing:_ - ?detents:_ - ~content - base - = - element ?key (fun context parent -> - let node = Lui_ui.column context in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - ignore (base.mount context (Some node)); - if presented - then ( - let sheet = Lui_ui.sheet context in - Lui_ui.append context node sheet; - if interactive_dismiss - then - Lui_ui.on_event context sheet (fun event -> - match event with - | Lui_protocol.Dismiss _ -> - invoke on_presented_changed (Event.Payload.Bool false) - | _ -> ()); - ignore (content.mount context (Some sheet))); - node) - ;; -end + module Picker = struct + type style = + | Automatic + | Menu + | Segmented + | Inline -module Picker = struct - type style = - | Automatic - | Menu - | Segmented - | Inline - - type choice = - { id : int64 - ; enabled : bool - ; label : t - } + type choice = + { id : int64 + ; enabled : bool + ; label : t + } - let option ~id ?(enabled = true) ?(label = empty ()) () = { id; enabled; label } + let option ~id ?(enabled = true) ?(label = empty ()) () = { id; enabled; label } + + let create + ?key + ?label:_ + ?(style = Automatic) + ?(enabled = true) + ~selected_id + ~on_select + choices + () + = + element ?key (fun context parent -> + let node = + match style with + | Segmented -> Lui_ui.toggle_group context + | Automatic | Menu | Inline -> Lui_ui.radio_group context + in + if not enabled then Lui_ui.disabled context node true; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter + (fun (choice : choice) -> + let item = Lui_ui.radio context in + Lui_ui.key context item (Int64.to_string choice.id); + if not choice.enabled then Lui_ui.disabled context item true; + (match selected_id with + | Some selected when selected = choice.id -> + Lui_ui.bool_property context item Lui_protocol.Checked true + | _ -> ()); + Lui_ui.on_event context item (fun event -> + match event with + | Lui_protocol.Press _ | ToggleChanged (_, true) -> + invoke on_select (Event.Payload.Int64 choice.id) + | _ -> ()); + Lui_ui.append context node item; + ignore (choice.label.mount context (Some item))) + choices; + node) + ;; + end - let create ?key ?label:_ ?(style = Automatic) ?(enabled = true) ~selected_id - ~on_select choices () - = - element ?key (fun context parent -> - let node = - match style with - | Segmented -> Lui_ui.toggle_group context - | Automatic | Menu | Inline -> Lui_ui.radio_group context - in - if not enabled then Lui_ui.disabled context node true; - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - List.iter - (fun (choice : choice) -> - let item = Lui_ui.radio context in - Lui_ui.key context item (Int64.to_string choice.id); - if not choice.enabled then Lui_ui.disabled context item true; - (match selected_id with - | Some selected when selected = choice.id -> - Lui_ui.bool_property context item Lui_protocol.Checked true - | _ -> ()); - Lui_ui.on_event context item (fun event -> - match event with - | Lui_protocol.Press _ | ToggleChanged (_, true) -> - invoke on_select (Event.Payload.Int64 choice.id) - | _ -> ()); - Lui_ui.append context node item; - ignore (choice.label.mount context (Some item))) - choices; - node) - ;; -end + module Menu = struct + type entry = + | Action of + { id : int64 + ; label : t + ; enabled : bool + ; role : Button_role.t + } + | Choice of + { id : int64 + ; label : t + ; selected : bool + ; enabled : bool + } + | Divider of int64 + | Section of + { id : int64 + ; label : t option + ; entries : entry list + } + | Submenu of + { id : int64 + ; label : t + ; enabled : bool + ; entries : entry list + } -module Menu = struct - type entry = - | Action of - { id : int64 - ; label : t - ; enabled : bool - ; role : Button_role.t - } - | Choice of - { id : int64 - ; label : t - ; selected : bool - ; enabled : bool - } - | Divider of int64 - | Section of - { id : int64 - ; label : t option - ; entries : entry list - } - | Submenu of - { id : int64 - ; label : t - ; enabled : bool - ; entries : entry list - } - - let action ~id ~label ?(enabled = true) ?(role = Button_role.Normal) () = - Action { id; label; enabled; role } - ;; + let action ~id ~label ?(enabled = true) ?(role = Button_role.Normal) () = + Action { id; label; enabled; role } + ;; - let choice ~id ~label ~selected ?(enabled = true) () = - Choice { id; label; selected; enabled } - ;; + let choice ~id ~label ~selected ?(enabled = true) () = + Choice { id; label; selected; enabled } + ;; - let divider ~id = Divider id - let section ~id ?label entries = Section { id; label; entries } - let submenu ~id ~label ?(enabled = true) entries = Submenu { id; label; enabled; entries } + let divider ~id = Divider id + let section ~id ?label entries = Section { id; label; entries } - let create ?key ?(enabled = true) ~on_select ~label entries = - element ?key (fun context parent -> - let node = Lui_ui.dropdown_menu context in - if not enabled then Lui_ui.disabled context node true; - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - ignore (label.mount context (Some node)); - let rec mount_entry parent (entry : entry) = - match entry with - | Divider id -> - let separator = Lui_ui.separator context (Int64.to_string id) in - Lui_ui.append context parent separator - | entry -> - let item = Lui_ui.menu_item context in - Lui_ui.key - context - item - (Int64.to_string - (match entry with - | Action { id; _ } | Choice { id; _ } -> id - | Section { id; _ } | Submenu { id; _ } -> id - | Divider _ -> assert false)); - (match entry with - | Action { id; label; enabled; role } -> - if not enabled then Lui_ui.disabled context item true; - (match role with - | Button_role.Normal -> () - | role -> - Lui_ui.string_property context item Lui_protocol.VariantValue - (Button_role.variant role)); - Lui_ui.on_event context item (fun event -> - if is_press event then invoke on_select (Event.Payload.Int64 id)); - ignore (label.mount context (Some item)) - | Choice { id; label; selected; enabled } -> - if not enabled then Lui_ui.disabled context item true; - if selected then Lui_ui.bool_property context item Lui_protocol.Checked true; - Lui_ui.on_event context item (fun event -> - if is_press event then invoke on_select (Event.Payload.Int64 id)); - ignore (label.mount context (Some item)) - | Section { label; entries; _ } -> - Option.iter (fun label -> ignore (label.mount context (Some item))) label; - List.iter (mount_entry item) entries - | Submenu { label; enabled; entries; _ } -> - if not enabled then Lui_ui.disabled context item true; - ignore (label.mount context (Some item)); - List.iter (mount_entry item) entries - | Divider _ -> assert false); - Lui_ui.append context parent item - in - List.iter (mount_entry node) entries; - node) - ;; -end + let submenu ~id ~label ?(enabled = true) entries = + Submenu { id; label; enabled; entries } + ;; + let create ?key ?(enabled = true) ~on_select ~label entries = + element ?key (fun context parent -> + let node = Lui_ui.dropdown_menu context in + if not enabled then Lui_ui.disabled context node true; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (label.mount context (Some node)); + let rec mount_entry parent (entry : entry) = + match entry with + | Divider id -> + let separator = Lui_ui.separator context (Int64.to_string id) in + Lui_ui.append context parent separator + | entry -> + let item = Lui_ui.menu_item context in + Lui_ui.key + context + item + (Int64.to_string + (match entry with + | Action { id; _ } | Choice { id; _ } -> id + | Section { id; _ } | Submenu { id; _ } -> id + | Divider _ -> assert false)); + (match entry with + | Action { id; label; enabled; role } -> + if not enabled then Lui_ui.disabled context item true; + (match role with + | Button_role.Normal -> () + | role -> + Lui_ui.string_property + context + item + Lui_protocol.VariantValue + (Button_role.variant role)); + Lui_ui.on_event context item (fun event -> + if is_press event then invoke on_select (Event.Payload.Int64 id)); + ignore (label.mount context (Some item)) + | Choice { id; label; selected; enabled } -> + if not enabled then Lui_ui.disabled context item true; + if selected + then Lui_ui.bool_property context item Lui_protocol.Checked true; + Lui_ui.on_event context item (fun event -> + if is_press event then invoke on_select (Event.Payload.Int64 id)); + ignore (label.mount context (Some item)) + | Section { label; entries; _ } -> + Option.iter (fun label -> ignore (label.mount context (Some item))) label; + List.iter (mount_entry item) entries + | Submenu { label; enabled; entries; _ } -> + if not enabled then Lui_ui.disabled context item true; + ignore (label.mount context (Some item)); + List.iter (mount_entry item) entries + | Divider _ -> assert false); + Lui_ui.append context parent item + in + List.iter (mount_entry node) entries; + node) + ;; + end end module Native_widget = struct @@ -1853,9 +1949,9 @@ module Native_widget = struct ; version : int ; encode_props : 'props -> bytes ; decode_event : - event_id:Journal_ids.Native_widget.Event_id.t - -> bytes - -> ('event, string) result + event_id:Journal_ids.Native_widget.Event_id.t + -> bytes + -> ('event, string) result } let identifier_of_kind_id kind_id = @@ -1879,8 +1975,8 @@ module Native_widget = struct let decode extension event = extension.Extension.decode_event - ~event_id:(Journal_ids.Native_widget.Event_id.of_int - event.Journal_lui_native.event_id) + ~event_id: + (Journal_ids.Native_widget.Event_id.of_int event.Journal_lui_native.event_id) (Bytes.of_string event.Journal_lui_native.payload) ;; diff --git a/app/journal_view.mli b/app/journal_view.mli index e1e108e..18882e9 100644 --- a/app/journal_view.mli +++ b/app/journal_view.mli @@ -290,547 +290,551 @@ module View : sig type nonrec t = t module For_testing : sig - val key : t -> string option - val test_id : t -> string option -end + val key : t -> string option + val test_id : t -> string option + end -module Button_role : sig - type t = - | Normal - | Destructive - | Cancel -end + module Button_role : sig + type t = + | Normal + | Destructive + | Cancel + end -module Button_style : sig - type t = - | Automatic - | Plain - | Bordered - | Prominent - | Button -end + module Button_style : sig + type t = + | Automatic + | Plain + | Bordered + | Prominent + | Button + end -module Progress_style : sig - type t = - | Linear - | Circular -end + module Progress_style : sig + type t = + | Linear + | Circular + end -val with_test_id : Test_id.t -> t -> t -val empty : ?key:Key.t -> unit -> t - -val text - : ?key:Key.t - -> ?style:Style.Text_style.t - -> ?text_align:'a - -> ?line_limit:int - -> ?truncation:'b - -> string - -> t - -val symbol - : ?key:Key.t - -> ?size:float - -> ?color:Style.Color.t - -> ?rendering:'a - -> name:string - -> unit - -> t - -val label : ?key:Key.t -> title:t -> icon:t -> unit -> t -val divider : ?key:Key.t -> unit -> t - -val progress - : ?key:Key.t - -> ?value:float - -> ?style:Progress_style.t - -> unit - -> t - -val spacer : ?key:Key.t -> ?min_length:float -> unit -> t - -val row - : ?key:Key.t - -> ?spacing:float - -> ?alignment:Layout.Vertical_alignment.t - -> t list - -> t - -val column - : ?key:Key.t - -> ?spacing:float - -> ?alignment:Layout.Horizontal_alignment.t - -> t list - -> t - -val stack : ?key:Key.t -> ?alignment:Layout.Alignment.t -> t list -> t - -val frame - : ?key:Key.t - -> ?width:float - -> ?height:float - -> ?min_width:float - -> ?ideal_width:float - -> ?max_width:Layout.Frame_limit.t - -> ?min_height:float - -> ?ideal_height:float - -> ?max_height:Layout.Frame_limit.t - -> ?alignment:Layout.Alignment.t - -> t - -> t - -val padding : ?key:Key.t -> insets:Layout.Edge_insets.t -> t -> t -val semantics : ?key:Key.t -> properties:Semantics.t -> t -> t -val help : ?key:Key.t -> message:string -> t -> t -val text_selection : ?key:Key.t -> enabled:bool -> t -> t -val opacity : ?key:Key.t -> float -> t -> t - -val ignores_safe_area - : ?regions:'a - -> ?edges:'b list - -> t - -> t - -val safe_area_padding : ?key:Key.t -> insets:Layout.Edge_insets.t -> t -> t -val theme : ?key:Key.t -> data:Theme.t -> t -> t -val background : ?key:Key.t -> ?corner_radius:float -> color:Style.Color.t -> t -> t -val clip : ?key:Key.t -> ?corner_radius:float -> ?antialiased:bool -> t -> t -val layout_priority : ?key:Key.t -> float -> t -> t -val offset : ?key:Key.t -> ?x:float -> ?y:float -> t -> t -val animated_opacity : ?key:Key.t -> ?duration:float -> float -> t -> t - -val button - : ?key:Key.t - -> ?enabled:bool - -> ?role:Button_role.t - -> ?style:Button_style.t - -> ?autofocus:bool - -> on_press:Event.handler - -> child:t - -> unit - -> t - -val toggle - : ?key:Key.t - -> ?style:Button_style.t - -> ?enabled:bool - -> value:bool - -> on_changed:Event.handler - -> label:t - -> unit - -> t - -val text_editor - : ?key:Key.t - -> ?autofocus:bool - -> ?enabled:bool - -> ?read_only:bool - -> ?submit_on_return:bool - -> ?max_utf8_bytes:int - -> session_id:Journal_ids.Text_input.Session_id.t - -> document_revision:Journal_ids.Text_input.Document_revision.t - -> accepted_local_revision:Journal_ids.Text_input.Local_revision.t - -> update_mode:Text_editing.update_mode - -> value:Text_editing.Value.t - -> on_edit:Event.handler - -> on_submit:Event.handler - -> on_focus_changed:Event.handler - -> ?on_limit_reached:Event.handler - -> unit - -> t - -val secure_field - : ?key:Key.t - -> label:string - -> ?prompt:string - -> ?keyboard:Text_editing.Keyboard.t - -> ?submit_label:Text_editing.Submit_label.t - -> ?appearance:Text_editing.Field_appearance.t - -> ?autofocus:bool - -> ?enabled:bool - -> ?read_only:bool - -> ?submit_on_return:bool - -> ?max_utf8_bytes:int - -> session_id:Journal_ids.Text_input.Session_id.t - -> document_revision:Journal_ids.Text_input.Document_revision.t - -> accepted_local_revision:Journal_ids.Text_input.Local_revision.t - -> update_mode:Text_editing.update_mode - -> value:Text_editing.Value.t - -> on_edit:Event.handler - -> on_submit:Event.handler - -> on_focus_changed:Event.handler - -> ?on_limit_reached:Event.handler - -> unit - -> t - -val labeled_content : ?key:Key.t -> label:t -> value:t -> unit -> t - -val content_unavailable - : ?key:Key.t - -> label:t - -> ?description:t - -> ?actions:t - -> unit - -> t - -val overlay : ?key:Key.t -> ?alignment:Layout.Alignment.t -> overlay:t -> t -> t - -module Keyed : sig - type widget = t - - type nonrec t = - { key : string - ; view : widget - } - - val create : key:string -> widget -> t -end + val with_test_id : Test_id.t -> t -> t + val empty : ?key:Key.t -> unit -> t -module Section : sig - val create : ?key:Key.t -> ?header:t -> ?footer:t -> Keyed.t list -> t -end + val text + : ?key:Key.t + -> ?style:Style.Text_style.t + -> ?text_align:'a + -> ?line_limit:int + -> ?truncation:'b + -> string + -> t -module Form : sig - val vertical : ?key:Key.t -> Keyed.t list -> t -end + val symbol + : ?key:Key.t + -> ?size:float + -> ?color:Style.Color.t + -> ?rendering:'a + -> name:string + -> unit + -> t -module Toolbar : sig - type placement = - | Automatic - | Principal - | Navigation - | Primary_action - | Secondary_action - | Status - | Confirmation_action - | Cancellation_action - | Destructive_action - | Bottom_bar - - type spacing = - | Fixed - | Flexible - - type child - type item - - val child : key:Key.t -> t -> child - val item : key:Key.t -> ?placement:placement -> t -> item - val group : key:Key.t -> ?placement:placement -> child list -> item - val spacer : key:Key.t -> ?placement:placement -> spacing -> item - val create : ?key:Key.t -> items:item list -> t -> t -end + val label : ?key:Key.t -> title:t -> icon:t -> unit -> t + val divider : ?key:Key.t -> unit -> t + val progress : ?key:Key.t -> ?value:float -> ?style:Progress_style.t -> unit -> t + val spacer : ?key:Key.t -> ?min_length:float -> unit -> t -module Body : sig - type nonrec t = t - type widget = t + val row + : ?key:Key.t + -> ?spacing:float + -> ?alignment:Layout.Vertical_alignment.t + -> t list + -> t - val with_size : width:float -> height:float -> t -> widget - val static : widget -> t - val with_test_id : Test_id.t -> t -> t - val padding : insets:Layout.Edge_insets.t -> t -> t - val background : ?corner_radius:float -> color:Style.Color.t -> t -> t - val semantics : properties:Semantics.t -> t -> t + val column + : ?key:Key.t + -> ?spacing:float + -> ?alignment:Layout.Horizontal_alignment.t + -> t list + -> t - val ignores_safe_area - : ?regions:'a - -> ?edges:'b list + val stack : ?key:Key.t -> ?alignment:Layout.Alignment.t -> t list -> t + + val frame + : ?key:Key.t + -> ?width:float + -> ?height:float + -> ?min_width:float + -> ?ideal_width:float + -> ?max_width:Layout.Frame_limit.t + -> ?min_height:float + -> ?ideal_height:float + -> ?max_height:Layout.Frame_limit.t + -> ?alignment:Layout.Alignment.t -> t -> t - val safe_area_padding : insets:Layout.Edge_insets.t -> t -> t - val theme : data:Theme.t -> t -> t - val toolbar : ?key:Key.t -> items:Toolbar.item list -> t -> t + val padding : ?key:Key.t -> insets:Layout.Edge_insets.t -> t -> t + val semantics : ?key:Key.t -> properties:Semantics.t -> t -> t + val help : ?key:Key.t -> message:string -> t -> t + val text_selection : ?key:Key.t -> enabled:bool -> t -> t + val opacity : ?key:Key.t -> float -> t -> t + val ignores_safe_area : ?regions:'a -> ?edges:'b list -> t -> t + val safe_area_padding : ?key:Key.t -> insets:Layout.Edge_insets.t -> t -> t + val theme : ?key:Key.t -> data:Theme.t -> t -> t + val background : ?key:Key.t -> ?corner_radius:float -> color:Style.Color.t -> t -> t + val clip : ?key:Key.t -> ?corner_radius:float -> ?antialiased:bool -> t -> t + val layout_priority : ?key:Key.t -> float -> t -> t + val offset : ?key:Key.t -> ?x:float -> ?y:float -> t -> t + val animated_opacity : ?key:Key.t -> ?duration:float -> float -> t -> t + + val button + : ?key:Key.t + -> ?enabled:bool + -> ?role:Button_role.t + -> ?style:Button_style.t + -> ?autofocus:bool + -> on_press:Event.handler + -> child:t + -> unit + -> t - module Vertical : sig - type child + val toggle + : ?key:Key.t + -> ?style:Button_style.t + -> ?enabled:bool + -> value:bool + -> on_changed:Event.handler + -> label:t + -> unit + -> t - val fixed : widget -> child - val fill : ?weight:float -> widget -> child - val create : ?key:Key.t -> child list -> t + val text_editor + : ?key:Key.t + -> ?autofocus:bool + -> ?enabled:bool + -> ?read_only:bool + -> ?submit_on_return:bool + -> ?max_utf8_bytes:int + -> session_id:Journal_ids.Text_input.Session_id.t + -> document_revision:Journal_ids.Text_input.Document_revision.t + -> accepted_local_revision:Journal_ids.Text_input.Local_revision.t + -> update_mode:Text_editing.update_mode + -> value:Text_editing.Value.t + -> on_edit:Event.handler + -> on_submit:Event.handler + -> on_focus_changed:Event.handler + -> ?on_limit_reached:Event.handler + -> unit + -> t + + val secure_field + : ?key:Key.t + -> label:string + -> ?prompt:string + -> ?keyboard:Text_editing.Keyboard.t + -> ?submit_label:Text_editing.Submit_label.t + -> ?appearance:Text_editing.Field_appearance.t + -> ?autofocus:bool + -> ?enabled:bool + -> ?read_only:bool + -> ?submit_on_return:bool + -> ?max_utf8_bytes:int + -> session_id:Journal_ids.Text_input.Session_id.t + -> document_revision:Journal_ids.Text_input.Document_revision.t + -> accepted_local_revision:Journal_ids.Text_input.Local_revision.t + -> update_mode:Text_editing.update_mode + -> value:Text_editing.Value.t + -> on_edit:Event.handler + -> on_submit:Event.handler + -> on_focus_changed:Event.handler + -> ?on_limit_reached:Event.handler + -> unit + -> t + + val labeled_content : ?key:Key.t -> label:t -> value:t -> unit -> t + + val content_unavailable + : ?key:Key.t + -> label:t + -> ?description:t + -> ?actions:t + -> unit + -> t + + val overlay : ?key:Key.t -> ?alignment:Layout.Alignment.t -> overlay:t -> t -> t + + module Keyed : sig + type widget = t + + type nonrec t = + { key : string + ; view : widget + } + + val create : key:string -> widget -> t end - module Horizontal : sig - type child + module Section : sig + val create : ?key:Key.t -> ?header:t -> ?footer:t -> Keyed.t list -> t + end - val fixed : widget -> child - val fill : ?weight:float -> widget -> child - val create : ?key:Key.t -> child list -> t + module Form : sig + val vertical : ?key:Key.t -> Keyed.t list -> t end - val overlay : ?key:Key.t -> ?alignment:Layout.Alignment.t -> overlay:widget -> t -> t + module Toolbar : sig + type placement = + | Automatic + | Principal + | Navigation + | Primary_action + | Secondary_action + | Status + | Confirmation_action + | Cancellation_action + | Destructive_action + | Bottom_bar + + type spacing = + | Fixed + | Flexible - module Private : sig - val to_widget : t -> widget + type child + type item + + val child : key:Key.t -> t -> child + val item : key:Key.t -> ?placement:placement -> t -> item + val group : key:Key.t -> ?placement:placement -> child list -> item + val spacer : key:Key.t -> ?placement:placement -> spacing -> item + val create : ?key:Key.t -> items:item list -> t -> t end -end -module Viewport : sig - module Vertical : sig + module Body : sig type nonrec t = t + type widget = t + val with_size : width:float -> height:float -> t -> widget + val static : widget -> t val with_test_id : Test_id.t -> t -> t val padding : insets:Layout.Edge_insets.t -> t -> t val background : ?corner_radius:float -> color:Style.Color.t -> t -> t val semantics : properties:Semantics.t -> t -> t - - val ignores_safe_area - : ?regions:'a - -> ?edges:'b list - -> t - -> t - + val ignores_safe_area : ?regions:'a -> ?edges:'b list -> t -> t val safe_area_padding : insets:Layout.Edge_insets.t -> t -> t val theme : data:Theme.t -> t -> t - val overlay : ?key:Key.t -> ?alignment:Layout.Alignment.t -> overlay:t -> t -> t - val with_height : height:float -> t -> t - end + val toolbar : ?key:Key.t -> items:Toolbar.item list -> t -> t - module Horizontal : sig - type nonrec t = t + module Vertical : sig + type child - val with_test_id : Test_id.t -> t -> t - val with_width : width:float -> t -> t - end -end + val fixed : widget -> child + val fill : ?weight:float -> widget -> child + val create : ?key:Key.t -> child list -> t + end -module Scroll : sig - type anchor = - | Start - | End + module Horizontal : sig + type child - val vertical - : ?key:Key.t - -> ?on_scroll:Event.handler - -> ?shows_indicators:bool - -> ?fill_viewport:bool - -> ?initial_anchor:anchor - -> t - -> t -end + val fixed : widget -> child + val fill : ?weight:float -> widget -> child + val create : ?key:Key.t -> child list -> t + end -module Swipe_actions : sig - type side = - | Start - | End + val overlay : ?key:Key.t -> ?alignment:Layout.Alignment.t -> overlay:widget -> t -> t - type action - type nonrec t + module Private : sig + val to_widget : t -> widget + end + end - val action - : key:Key.t - -> ?enabled:bool - -> ?role:Button_role.t - -> ?symbol:string - -> side:side - -> title:string - -> background:Style.Color.t - -> on_press:Event.handler - -> unit - -> action + module Viewport : sig + module Vertical : sig + type nonrec t = t + + val with_test_id : Test_id.t -> t -> t + val padding : insets:Layout.Edge_insets.t -> t -> t + val background : ?corner_radius:float -> color:Style.Color.t -> t -> t + val semantics : properties:Semantics.t -> t -> t + val ignores_safe_area : ?regions:'a -> ?edges:'b list -> t -> t + val safe_area_padding : insets:Layout.Edge_insets.t -> t -> t + val theme : data:Theme.t -> t -> t + val overlay : ?key:Key.t -> ?alignment:Layout.Alignment.t -> overlay:t -> t -> t + val with_height : height:float -> t -> t + end - val create : ?enabled:bool -> ?allows_full_swipe:bool -> actions:action list -> unit -> t -end + module Horizontal : sig + type nonrec t = t -module Context_menu : sig - type nonrec view = t + val with_test_id : Test_id.t -> t -> t + val with_width : width:float -> t -> t + end + end - type role = - | Normal - | Destructive + module Scroll : sig + type anchor = + | Start + | End + + val vertical + : ?key:Key.t + -> ?on_scroll:Event.handler + -> ?shows_indicators:bool + -> ?fill_viewport:bool + -> ?initial_anchor:anchor + -> t + -> t + end - type action - type nonrec t + module Swipe_actions : sig + type side = + | Start + | End + + type action + type nonrec t + + val action + : key:Key.t + -> ?enabled:bool + -> ?role:Button_role.t + -> ?symbol:string + -> side:side + -> title:string + -> background:Style.Color.t + -> on_press:Event.handler + -> unit + -> action - val action - : key:Key.t - -> ?enabled:bool - -> ?role:role - -> ?symbol:string - -> title:string - -> on_press:Event.handler - -> unit - -> action + val create + : ?enabled:bool + -> ?allows_full_swipe:bool + -> actions:action list + -> unit + -> t + end - val create : ?enabled:bool -> actions:action list -> unit -> t - val attach : ?key:Key.t -> t -> view -> view -end + module Context_menu : sig + type nonrec view = t -module Confirmation : sig - type action - type request + type role = + | Normal + | Destructive - val action - : key:string - -> title:string - -> ?enabled:bool - -> ?role:Button_role.t - -> unit - -> action + type action + type nonrec t - val request : token:int64 -> title:string -> ?message:string -> action list -> request - val alert : ?key:Key.t -> request:request option -> on_response:Event.handler -> t -> t - val dialog : ?key:Key.t -> request:request option -> on_response:Event.handler -> t -> t -end + val action + : key:Key.t + -> ?enabled:bool + -> ?role:role + -> ?symbol:string + -> title:string + -> on_press:Event.handler + -> unit + -> action -module Native_list : sig - type anchor = - | Top - | Center - | Bottom + val create : ?enabled:bool -> actions:action list -> unit -> t + val attach : ?key:Key.t -> t -> view -> view + end - type target - type scroll_request - type outcome = Event.Payload.native_list_outcome - type completion = Event.Payload.native_list_completion + module Confirmation : sig + type action + type request - val target : section:Key.t -> row_path:Key.t list -> target + val action + : key:string + -> title:string + -> ?enabled:bool + -> ?role:Button_role.t + -> unit + -> action - val scroll_request - : token:int64 - -> target:target - -> ?anchor:anchor - -> ?animated:bool - -> unit - -> scroll_request + val request : token:int64 -> title:string -> ?message:string -> action list -> request - val completion_of_payload : Event.Payload.t -> completion option + val alert + : ?key:Key.t + -> request:request option + -> on_response:Event.handler + -> t + -> t - type style = - | Plain - | Inset - | Inset_grouped + val dialog + : ?key:Key.t + -> request:request option + -> on_response:Event.handler + -> t + -> t + end - type separator = - | Automatic - | Hidden - | Visible + module Native_list : sig + type anchor = + | Top + | Center + | Bottom - type row - type section + type target + type scroll_request + type outcome = Event.Payload.native_list_outcome + type completion = Event.Payload.native_list_completion - val row - : key:Key.t - -> ?test_id:Test_id.t - -> ?separator:separator - -> ?swipe_actions:Swipe_actions.t - -> ?context_menu:Context_menu.t - -> t - -> row - - val disclosure_row - : key:Key.t - -> ?test_id:Test_id.t - -> ?separator:separator - -> ?swipe_actions:Swipe_actions.t - -> ?context_menu:Context_menu.t - -> expanded:bool - -> on_expanded_changed:Event.handler - -> label:t - -> row list - -> row + val target : section:Key.t -> row_path:Key.t list -> target - val section : key:Key.t -> ?header:t -> ?footer:t -> ?separator:separator -> row list -> section + val scroll_request + : token:int64 + -> target:target + -> ?anchor:anchor + -> ?animated:bool + -> unit + -> scroll_request - val vertical - : ?key:Key.t - -> style:style - -> ?scroll_request:scroll_request - -> ?on_scroll_completed:Event.handler - -> ?on_visible_range:Event.handler - -> ?on_row_event:Event.handler - -> section list - -> t -end + val completion_of_payload : Event.Payload.t -> completion option -module Navigation_link : sig - val create - : ?key:Key.t - -> activation_id:string - -> ?enabled:bool - -> on_activate:Event.handler - -> label:t - -> unit - -> t -end + type style = + | Plain + | Inset + | Inset_grouped + + type separator = + | Automatic + | Hidden + | Visible + + type row + type section + + val row + : key:Key.t + -> ?test_id:Test_id.t + -> ?separator:separator + -> ?swipe_actions:Swipe_actions.t + -> ?context_menu:Context_menu.t + -> t + -> row + + val disclosure_row + : key:Key.t + -> ?test_id:Test_id.t + -> ?separator:separator + -> ?swipe_actions:Swipe_actions.t + -> ?context_menu:Context_menu.t + -> expanded:bool + -> on_expanded_changed:Event.handler + -> label:t + -> row list + -> row + + val section + : key:Key.t + -> ?header:t + -> ?footer:t + -> ?separator:separator + -> row list + -> section + + val vertical + : ?key:Key.t + -> style:style + -> ?scroll_request:scroll_request + -> ?on_scroll_completed:Event.handler + -> ?on_visible_range:Event.handler + -> ?on_row_event:Event.handler + -> section list + -> t + end -module Navigation_stack : sig - type destination + module Navigation_link : sig + val create + : ?key:Key.t + -> activation_id:string + -> ?enabled:bool + -> on_activate:Event.handler + -> label:t + -> unit + -> t + end - val destination : page_key:string -> title:string -> can_pop:bool -> t -> destination + module Navigation_stack : sig + type destination - val create - : ?key:Key.t - -> title:string - -> on_path_change:Event.handler - -> path:destination list - -> t - -> t -end + val destination : page_key:string -> title:string -> can_pop:bool -> t -> destination -module Sheet : sig - type sizing = - | Automatic - | Form - | Fitted + val create + : ?key:Key.t + -> title:string + -> on_path_change:Event.handler + -> path:destination list + -> t + -> t + end - type detent = - | Medium - | Large + module Sheet : sig + type sizing = + | Automatic + | Form + | Fitted - val create - : ?key:Key.t - -> presented:bool - -> on_presented_changed:Event.handler - -> ?interactive_dismiss:bool - -> ?sizing:sizing - -> ?detents:detent list - -> content:t - -> t - -> t -end + type detent = + | Medium + | Large + + val create + : ?key:Key.t + -> presented:bool + -> on_presented_changed:Event.handler + -> ?interactive_dismiss:bool + -> ?sizing:sizing + -> ?detents:detent list + -> content:t + -> t + -> t + end -module Picker : sig - type choice + module Picker : sig + type choice - type style = - | Automatic - | Menu - | Segmented - | Inline + type style = + | Automatic + | Menu + | Segmented + | Inline - val option : id:int64 -> ?enabled:bool -> ?label:t -> unit -> choice + val option : id:int64 -> ?enabled:bool -> ?label:t -> unit -> choice - val create - : ?key:Key.t - -> ?label:string - -> ?style:style - -> ?enabled:bool - -> selected_id:int64 option - -> on_select:Event.handler - -> choice list - -> unit - -> t -end + val create + : ?key:Key.t + -> ?label:string + -> ?style:style + -> ?enabled:bool + -> selected_id:int64 option + -> on_select:Event.handler + -> choice list + -> unit + -> t + end -module Menu : sig - type entry + module Menu : sig + type entry - val action : id:int64 -> label:t -> ?enabled:bool -> ?role:Button_role.t -> unit -> entry - val choice : id:int64 -> label:t -> selected:bool -> ?enabled:bool -> unit -> entry - val divider : id:int64 -> entry - val section : id:int64 -> ?label:t -> entry list -> entry - val submenu : id:int64 -> label:t -> ?enabled:bool -> entry list -> entry + val action + : id:int64 + -> label:t + -> ?enabled:bool + -> ?role:Button_role.t + -> unit + -> entry - val create - : ?key:Key.t - -> ?enabled:bool - -> on_select:Event.handler - -> label:t - -> entry list - -> t -end + val choice : id:int64 -> label:t -> selected:bool -> ?enabled:bool -> unit -> entry + val divider : id:int64 -> entry + val section : id:int64 -> ?label:t -> entry list -> entry + val submenu : id:int64 -> label:t -> ?enabled:bool -> entry list -> entry + val create + : ?key:Key.t + -> ?enabled:bool + -> on_select:Event.handler + -> label:t + -> entry list + -> t + end end module Native_widget : sig diff --git a/apple-tests/amplify/hub_fixture.ml b/apple-tests/amplify/hub_fixture.ml index d85895e..b23f2fb 100644 --- a/apple-tests/amplify/hub_fixture.ml +++ b/apple-tests/amplify/hub_fixture.ml @@ -12,16 +12,12 @@ type action = Nop let reducer () Nop = () let view _context _model _send = - column - ~gap:16 - ~padding:16 - [ text ~value:"Native Hub callback acceptance" [] ] + column ~gap:16 ~padding:16 [ text ~value:"Native Hub callback acceptance" [] ] ;; (* --- headless host bridge ------------------------------------------------ *) let latest_patch = ref "" - let current_app : (model, action) Lui_app.reducer_app option ref = ref None let operating_system = function @@ -44,8 +40,8 @@ let backend profile = { backend_profile = profile ; apply_batch = (fun batch -> - latest_patch := Lui_wire.encode_batch batch; - true) + latest_patch := Lui_wire.encode_batch batch; + true) } ;; @@ -59,9 +55,10 @@ let init platform_code host_code _payload = latest_patch := ""; let value = Lui_app.create - (backend - (profile (operating_system platform_code) (host_kind host_code))) - () reducer view + (backend (profile (operating_system platform_code) (host_kind host_code))) + () + reducer + view in current_app := Some value; ignore (Lui_app.start value); diff --git a/apple-tests/editor/composer_probe.ml b/apple-tests/editor/composer_probe.ml index 583369c..3b62f57 100644 --- a/apple-tests/editor/composer_probe.ml +++ b/apple-tests/editor/composer_probe.ml @@ -27,9 +27,7 @@ let view _context model_source send = ~gap:16 ~padding:16 [ text ~value:"Composer input probe" [] - ; text - ~value_signal:(map (fun (m : model) -> "Observed: " ^ m.text) model_source) - [] + ; text ~value_signal:(map (fun (m : model) -> "Observed: " ^ m.text) model_source) [] ; text_field ~key:"stable-composer" ~text_signal:(map (fun (m : model) -> m.text) model_source) @@ -45,7 +43,6 @@ let view _context model_source send = (* --- headless host bridge ------------------------------------------------ *) let latest_patch = ref "" - let current_app : (model, action) Lui_app.reducer_app option ref = ref None let operating_system = function @@ -68,8 +65,8 @@ let backend profile = { backend_profile = profile ; apply_batch = (fun batch -> - latest_patch := Lui_wire.encode_batch batch; - true) + latest_patch := Lui_wire.encode_batch batch; + true) } ;; @@ -83,9 +80,10 @@ let init platform_code host_code _payload = latest_patch := ""; let value = Lui_app.create - (backend - (profile (operating_system platform_code) (host_kind host_code))) - initial reducer view + (backend (profile (operating_system platform_code) (host_kind host_code))) + initial + reducer + view in current_app := Some value; ignore (Lui_app.start value); diff --git a/apple-tests/native-outline/outline_probe.ml b/apple-tests/native-outline/outline_probe.ml index 3685f48..c24235e 100644 --- a/apple-tests/native-outline/outline_probe.ml +++ b/apple-tests/native-outline/outline_probe.ml @@ -23,26 +23,29 @@ let initial = { observed = "No action"; expanded = true } let reducer model = function | Observe value -> { model with observed = value } | Expand value -> { model with expanded = value } +;; let on_list_event send (event : Journal_lui_native.event) = - match (try Yojson.Basic.from_string event.payload with _ -> `Null) with + match + try Yojson.Basic.from_string event.payload with + | _ -> `Null + with | `Assoc fields -> (match List.assoc_opt "type" fields with | Some (`String "expanded") -> - (match - ( List.assoc_opt "key" fields - , List.assoc_opt "expanded" fields ) - with + (match List.assoc_opt "key" fields, List.assoc_opt "expanded" fields with | Some (`String _), Some (`Bool value) -> ignore (send (Expand value)) | _ -> ()) | Some (`String "row_event") -> (match List.assoc_opt "payload" fields with | Some (`String inner) -> - (match (try Yojson.Basic.from_string inner with _ -> `Null) with + (match + try Yojson.Basic.from_string inner with + | _ -> `Null + with | `Assoc inner_fields -> (match - ( List.assoc_opt "row" inner_fields - , List.assoc_opt "key" inner_fields ) + List.assoc_opt "row" inner_fields, List.assoc_opt "key" inner_fields with | Some (`String row), Some (`String key) -> ignore (send (Observe (key ^ ":" ^ row))) @@ -74,10 +77,8 @@ let outline_list ~expanded send : Lui_elements.t = ; "symbol", `Null ; "title", `String "Delete" ] - ] - ) - ] - ) + ] ) + ] ) in let row ~id ~label = `Assoc @@ -101,32 +102,30 @@ let outline_list ~expanded send : Lui_elements.t = let payload = Yojson.Basic.to_string (`Assoc - [ "style", `String "plain" - ; ( "sections" - , `List - [ `Assoc - [ "key", `String "rows" - ; "separator", `String "hidden" - ; "header_index", `Null - ; "footer_index", `Null - ; ( "rows" - , `List - [ disclosure - ~id:"parent" - ~expanded - [ row ~id:"child" ~label:"Child row" - ; row ~id:"branch" ~label:"Unloaded branch row" - ] - ; row ~id:"sibling" ~label:"Sibling row" - ] - ) - ] - ] - ) - ; "scroll_request", `Null - ; "track_visible_range", `Bool false - ; "track_scroll_completion", `Bool false - ]) + [ "style", `String "plain" + ; ( "sections" + , `List + [ `Assoc + [ "key", `String "rows" + ; "separator", `String "hidden" + ; "header_index", `Null + ; "footer_index", `Null + ; ( "rows" + , `List + [ disclosure + ~id:"parent" + ~expanded + [ row ~id:"child" ~label:"Child row" + ; row ~id:"branch" ~label:"Unloaded branch row" + ] + ; row ~id:"sibling" ~label:"Sibling row" + ] ) + ] + ] ) + ; "scroll_request", `Null + ; "track_visible_range", `Bool false + ; "track_scroll_completion", `Bool false + ]) in Journal_lui_native.list ~key:"outline" @@ -149,7 +148,6 @@ let view _context model_source send = Journal_bridge.register with these hooks. *) let latest_patch = ref "" - let current_app : (model, action) Lui_app.reducer_app option ref = ref None let operating_system = function @@ -172,8 +170,8 @@ let backend profile = { backend_profile = profile ; apply_batch = (fun batch -> - latest_patch := Lui_wire.encode_batch batch; - true) + latest_patch := Lui_wire.encode_batch batch; + true) } ;; @@ -187,9 +185,10 @@ let init platform_code host_code _payload = latest_patch := ""; let value = Lui_app.create - (backend - (profile (operating_system platform_code) (host_kind host_code))) - initial reducer view + (backend (profile (operating_system platform_code) (host_kind host_code))) + initial + reducer + view in current_app := Some value; ignore (Lui_app.start value); diff --git a/logseq_db_worker/lui/journal_worker.mli b/logseq_db_worker/lui/journal_worker.mli index cae2fce..6f14d74 100644 --- a/logseq_db_worker/lui/journal_worker.mli +++ b/logseq_db_worker/lui/journal_worker.mli @@ -162,10 +162,7 @@ module Private : sig (** Drains pending events and invokes every registered subscriber for each, in drain order. Must be called on the application thread (the lui pump entry point), never from worker fibers. *) - val deliver - : ('request, 'response, 'push) client - -> max_events:int - -> unit + val deliver : ('request, 'response, 'push) client -> max_events:int -> unit end module For_testing : sig diff --git a/logseq_db_worker/lui/logseq_db_worker_lui_service.mli b/logseq_db_worker/lui/logseq_db_worker_lui_service.mli index 54d9674..4744bf7 100644 --- a/logseq_db_worker/lui/logseq_db_worker_lui_service.mli +++ b/logseq_db_worker/lui/logseq_db_worker_lui_service.mli @@ -209,4 +209,5 @@ val create : dependencies:dependencies -> (Logseq_db_worker.Config.t, request, response, push) Journal_worker.Service.t -val service : (Logseq_db_worker.Config.t, request, response, push) Journal_worker.Service.t +val service + : (Logseq_db_worker.Config.t, request, response, push) Journal_worker.Service.t diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index f5c3246..60cd18c 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -179,10 +179,10 @@ let test_sync_error_card_is_temporary_and_error_only root = if not (contains application required) then fail "sync-error timeout behavior is missing %S" required) [ "let sync_error_card_lifetime = Core.Time_ns.Span.of_sec 5." - ; "let sync_error_timer_key =" - ; "let sync_error_timer_callback =" - ; "Core.Time_ns.add now sync_error_card_lifetime" - ; "Int64.equal current_sequence scheduled_sequence" + ; "let arm_sync_error_timer sequence =" + ; "let sync_error_timer_generation = ref 0 in" + ; "schedule_after (Core.Time_ns.Span.to_sec sync_error_card_lifetime)" + ; "!sync_error_timer_generation = generation" ] ;; @@ -1665,7 +1665,7 @@ let () = ; "Refresh the authorized graph catalog" ; "pending local" ; "then returns to graph selection" - ; "App.View.create" + ; "Lui_app.create_with_extensions" ; "application_theme" ; "V.Sheet.create" ; "Journal_platform.show_notice_request" @@ -1708,7 +1708,7 @@ let () = require_text root "swift/App.swift" - [ "BonsaiApplicationView(entrypoint: \"logseq_journal\"" + [ "JournalRuntimeHost(" ; "applicationShouldTerminate" ; ".terminateLater" ; "reply(toApplicationShouldTerminate:" From 62289a8a1fbff55a91e444010eb9f5eca57b9655 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 20:07:44 -0700 Subject: [PATCH 16/40] lui migration: link libsqlite3 for the OCaml complete object Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- swift/Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/swift/Package.swift b/swift/Package.swift index d75ae1c..540f710 100644 --- a/swift/Package.swift +++ b/swift/Package.swift @@ -17,6 +17,7 @@ let nativeLinkInputs = ProcessInfo.processInfo.environment["JOURNAL_NATIVE_LINK_ .map(String.init) ?? [] let nativeLinkerSettings: [LinkerSetting] = nativeLinkInputs.isEmpty ? [] : [ .unsafeFlags(nativeLinkInputs, .when(platforms: [.iOS, .macOS])), + .linkedLibrary("sqlite3", .when(platforms: [.iOS, .macOS])), ] let package = Package( From 1a0d7a7bd91654e14f219dd9a0b784b8435f5201 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 20:44:02 -0700 Subject: [PATCH 17/40] build app via 'dune build @macos-app'; fold SDK lib path into static link flags Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitignore | 1 + app/dune | 9 +++++++-- test/test_native_static_gmp.sh | 4 ++-- tool/build_journal_apple.sh | 2 +- tool/native_static_link_flags.sh | 4 +++- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 0c9a01e..85a725d 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ # Generated SwiftUI hosts and native artifact staging /apple/ /.bonsai-swiftui/ +/swift/.build/ # Local environment and cache directories .env diff --git a/app/dune b/app/dune index 91c3c06..6559e0f 100644 --- a/app/dune +++ b/app/dune @@ -60,8 +60,6 @@ (libraries app lui) (link_flags (:standard - -cclib - -L%{env:JOURNAL_APPLE_SDK_ROOT=}/usr/lib (:include native_static_link_flags.sexp))) (modes (native object))) @@ -72,3 +70,10 @@ (with-stdout-to native_static_link_flags.sexp (run ../tool/native_static_link_flags.sh libgmp.a %{context_name})))) + +(rule + (alias macos-app) + (deps native_embed.exe.o) + (action + (system + "JOURNAL_APPLE_SDK_ROOT=$(xcrun --show-sdk-path) JOURNAL_OCAML_OBJECT=$PWD/native_embed.exe.o $(git rev-parse --show-toplevel)/tool/build_journal_apple.sh macos"))) diff --git a/test/test_native_static_gmp.sh b/test/test_native_static_gmp.sh index 3766ef4..06564ad 100644 --- a/test/test_native_static_gmp.sh +++ b/test/test_native_static_gmp.sh @@ -69,7 +69,7 @@ ios_output=$( JOURNAL_APPLE_SDK_ROOT=/Xcode/iPhoneOS.sdk \ "$script" "$ios_archive" lui-journal.ios ) -test "$ios_output" = '(-cclib -Lapp -cclib app/libgmp.a)' +test "$ios_output" = '(-cclib -Lapp -cclib app/libgmp.a -cclib -L/Xcode/iPhoneOS.sdk/usr/lib)' test "$(cat "$ios_archive")" = ios-static-gmp host_library_directory="$temporary_directory/host-gmp" @@ -84,7 +84,7 @@ macos_output=$( JOURNAL_APPLE_SDK_ROOT=/Xcode/iPhoneOS.sdk \ "$script" "$macos_archive" default ) -test "$macos_output" = '(-cclib -Lapp -cclib app/libgmp.a)' +test "$macos_output" = '(-cclib -Lapp -cclib app/libgmp.a -cclib -L/Xcode/iPhoneOS.sdk/usr/lib)' test "$(cat "$macos_archive")" = macos-static-gmp printf '%s\n' 'Native static GMP tool tests passed' diff --git a/tool/build_journal_apple.sh b/tool/build_journal_apple.sh index 1b6f204..25e194d 100755 --- a/tool/build_journal_apple.sh +++ b/tool/build_journal_apple.sh @@ -120,7 +120,7 @@ fingerprint=$(shasum -a 256 "$ocaml_object" "$build_dir/journal_lui_bridge.o" \ | shasum -a 256 | cut -d ' ' -f 1) link_dir="$build_dir/native-link-inputs/$fingerprint" mkdir -p "$link_dir" -cp "$ocaml_object" "$link_dir/journal_complete.o" +cp -f "$ocaml_object" "$link_dir/journal_complete.o" extra_inputs="" if [[ -n ${JOURNAL_EXTRA_OBJECTS:-} ]]; then diff --git a/tool/native_static_link_flags.sh b/tool/native_static_link_flags.sh index 6acac12..4c0f0f4 100755 --- a/tool/native_static_link_flags.sh +++ b/tool/native_static_link_flags.sh @@ -64,6 +64,7 @@ case "${2:-default}" in printf '%s\n' "Built iPhoneOS GMP archive is not arm64-only" >&2 exit 1 fi + sdk_lib_dir="$sdk_root/usr/lib" ;; *) gmp_library_directory=$(pkg-config --variable=libdir gmp) @@ -73,7 +74,8 @@ case "${2:-default}" in exit 1 fi cp "$gmp_archive" "$destination" + sdk_lib_dir="${JOURNAL_APPLE_SDK_ROOT:-$(xcrun --show-sdk-path 2>/dev/null || printf /)}/usr/lib" ;; esac -printf '%s\n' '(-cclib -Lapp -cclib app/libgmp.a)' +printf '%s\n' "(-cclib -Lapp -cclib app/libgmp.a -cclib -L$sdk_lib_dir)" From ce189e7ba31a76a8957e3a39eed32856ffe403ff Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 20:51:55 -0700 Subject: [PATCH 18/40] add dune alias @ios-app; make ios-simulator build lg-free and fix .app bundle layout/entitlements Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/dune | 10 ++++++++++ tool/build_journal_apple.sh | 27 +++++++++++++++++++-------- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/app/dune b/app/dune index 6559e0f..774c324 100644 --- a/app/dune +++ b/app/dune @@ -77,3 +77,13 @@ (action (system "JOURNAL_APPLE_SDK_ROOT=$(xcrun --show-sdk-path) JOURNAL_OCAML_OBJECT=$PWD/native_embed.exe.o $(git rev-parse --show-toplevel)/tool/build_journal_apple.sh macos"))) + +; vtool restamps the host complete object for the simulator triple (the app .cmx +; are identical arm64 code; a target-toolchain recompile replaces this when the +; deps are available as iOS objects). +(rule + (alias ios-app) + (deps native_embed.exe.o) + (action + (system + "vtool -set-build-version 7 ${JOURNAL_IOS_DEPLOYMENT_TARGET:-26.0} $(xcrun --sdk iphonesimulator --show-sdk-version) -replace -output $PWD/journal_complete_iossim.o $PWD/native_embed.exe.o && JOURNAL_APPLE_SDK_ROOT=$(xcrun --show-sdk-path) JOURNAL_OCAML_OBJECT=$PWD/journal_complete_iossim.o $(git rev-parse --show-toplevel)/tool/build_journal_apple.sh ios-simulator"))) diff --git a/tool/build_journal_apple.sh b/tool/build_journal_apple.sh index 25e194d..682d9f3 100755 --- a/tool/build_journal_apple.sh +++ b/tool/build_journal_apple.sh @@ -56,12 +56,15 @@ case "$platform" in sdk_path=$(xcrun --sdk iphonesimulator --show-sdk-path) clang=$(xcrun --sdk iphonesimulator --find clang) target_prefix=${LG_IOS_OCAML_PREFIX:-$shared_root/ocaml-$ocaml_version/targets/$triple} - [[ -d $target_prefix/lib/ocaml ]] || { - echo "error: shared iOS OCaml toolchain is missing: $target_prefix" >&2 - echo "set LG_IOS_OCAML_PREFIX or provision the toolchain" >&2 - exit 1 - } - ocaml_include="$target_prefix/lib/ocaml" + if [[ -d $target_prefix/lib/ocaml ]]; then + ocaml_include="$target_prefix/lib/ocaml" + else + # journal_lui_bridge.c is a compile check only (not a link input); the + # host OCaml headers are platform-independent for it. + ocaml_prefix=${JOURNAL_OCAML_PREFIX:-$(ocamlfind printconf destdir 2>/dev/null | sed 's|/lib$||' || true)} + [[ -n $ocaml_prefix ]] || ocaml_prefix="$opam_root/default" + ocaml_include="$ocaml_prefix/lib/ocaml" + fi ;; *) echo "usage: $0 " >&2; exit 2 ;; esac @@ -146,18 +149,26 @@ product_dir="$swift_dir/.build/$triple/debug" [[ -f $product_dir/JournalApp ]] || product_dir="$swift_dir/.build/debug" app_dir=${app_dir_arg:-$build_dir/LogseqJournal.app} rm -rf "$app_dir" -mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources" if [[ $platform == macos ]]; then + mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources" cp "$info_plist" "$app_dir/Contents/Info.plist" cp "$product_dir/JournalApp" "$app_dir/Contents/MacOS/JournalApp" codesign --force --sign - --timestamp=none \ --entitlements "$entitlements_dir/macos-debug-profile.entitlements" \ "$app_dir" || true else + # iOS bundles are flat; an empty Contents/ dir breaks install + codesign. + mkdir -p "$app_dir" cp "$info_plist" "$app_dir/Info.plist" cp "$product_dir/JournalApp" "$app_dir/JournalApp" + bundle_id=$(plutil -extract CFBundleIdentifier raw "$info_plist") + team_prefix=${JOURNAL_IOS_TEAM_ID:+$JOURNAL_IOS_TEAM_ID.} + ios_entitlements="$build_dir/ios-entitlements.plist" + sed -e "s|\$(AppIdentifierPrefix)|$team_prefix|g" \ + -e "s|\$(PRODUCT_BUNDLE_IDENTIFIER)|$bundle_id|g" \ + "$entitlements_dir/ios-debug-profile.entitlements" > "$ios_entitlements" codesign --force --sign - --timestamp=none \ - --entitlements "$entitlements_dir/ios-debug-profile.entitlements" \ + --entitlements "$ios_entitlements" \ "$app_dir" || true fi From c599a0d270ce5ab9ae73a28c49e05ad352188143 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 21:31:32 -0700 Subject: [PATCH 19/40] fix .app signing: drop invalid adhoc keychain group on macOS; plain-adhoc sign on iOS sim (iOS 27 rejects any entitlements blob) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tool/build_journal_apple.sh | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/tool/build_journal_apple.sh b/tool/build_journal_apple.sh index 682d9f3..d40c85c 100755 --- a/tool/build_journal_apple.sh +++ b/tool/build_journal_apple.sh @@ -153,23 +153,28 @@ if [[ $platform == macos ]]; then mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources" cp "$info_plist" "$app_dir/Contents/Info.plist" cp "$product_dir/JournalApp" "$app_dir/Contents/MacOS/JournalApp" + # Adhoc signing: keychain-access-groups needs a real team id; without one the + # group is invalid and AMFI kills the binary, so drop the key for local builds. + macos_entitlements="$build_dir/macos-entitlements.plist" + cp "$entitlements_dir/macos-debug-profile.entitlements" "$macos_entitlements" + bundle_id=$(plutil -extract CFBundleIdentifier raw "$info_plist") + if [[ -n ${JOURNAL_MACOS_TEAM_ID:-} ]]; then + plutil -replace keychain-access-groups -json \ + "[\"$JOURNAL_MACOS_TEAM_ID.$bundle_id\"]" "$macos_entitlements" + else + plutil -remove keychain-access-groups "$macos_entitlements" + fi codesign --force --sign - --timestamp=none \ - --entitlements "$entitlements_dir/macos-debug-profile.entitlements" \ + --entitlements "$macos_entitlements" \ "$app_dir" || true else # iOS bundles are flat; an empty Contents/ dir breaks install + codesign. + # The iOS 27 simulator refuses to exec any binary carrying an entitlements + # blob ("No such process"), so sign plain-adhoc without entitlements. mkdir -p "$app_dir" cp "$info_plist" "$app_dir/Info.plist" cp "$product_dir/JournalApp" "$app_dir/JournalApp" - bundle_id=$(plutil -extract CFBundleIdentifier raw "$info_plist") - team_prefix=${JOURNAL_IOS_TEAM_ID:+$JOURNAL_IOS_TEAM_ID.} - ios_entitlements="$build_dir/ios-entitlements.plist" - sed -e "s|\$(AppIdentifierPrefix)|$team_prefix|g" \ - -e "s|\$(PRODUCT_BUNDLE_IDENTIFIER)|$bundle_id|g" \ - "$entitlements_dir/ios-debug-profile.entitlements" > "$ios_entitlements" - codesign --force --sign - --timestamp=none \ - --entitlements "$ios_entitlements" \ - "$app_dir" || true + codesign --force --sign - --timestamp=none "$app_dir" || true fi echo "$app_dir" From b9575341265d16ae18432d7198908557610c8557 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 22:34:57 -0700 Subject: [PATCH 20/40] Fix startup stall: bring journal view into lui schema compliance The app parked on 'Opening journal' because mount-time exceptions raised inside the C bridge's patch emission were dropped silently. Fixes: - Wrap composed control labels in leaf properties: Button, Toggle, NavigationLink, Picker options, and Menu items are leaf host kinds that only accept ContextMenu children; their title/icon now travel as TextValue/InlineIconName props (label_content side-channel on the view record preserves the existing ~child/~label APIs and test ids). - Rewrite Menu to the lui dropdown model (menu_item + dropdown_menu children) with a title/icon API instead of composed label elements. - Mount toolbar items under a plain row (Toolbar kind accepts only fixed control children and no extensions) and guard placement/spacing hint props by node kind. - Register journal SF Symbol names as app: icons via LUIAppleBackend appIcons (swift/JournalIcons.swift). - Guard standard props on non-standard nodes: extension and placeholder nodes skip accessibility/frame/padding/semantics props via a shared node_is_standard check. - Declare journal extension child schemas on both OCaml and Swift sides so extensions can nest. - Surface startup exceptions on stderr instead of letting the bridge swallow them. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .agents/skills/testing-ios-simulator/SKILL.md | 45 +- app/application.ml | 43 +- app/journal_header.ml | 13 +- app/journal_lui_native.ml | 23 +- app/journal_view.ml | 405 ++++++++++++------ app/journal_view.mli | 27 +- swift/JournalApplicationPlatform.swift | 6 +- swift/JournalExtensions.swift | 17 +- swift/JournalIcons.swift | 39 ++ swift/JournalRuntime.swift | 5 +- 10 files changed, 445 insertions(+), 178 deletions(-) create mode 100644 swift/JournalIcons.swift diff --git a/.agents/skills/testing-ios-simulator/SKILL.md b/.agents/skills/testing-ios-simulator/SKILL.md index af192c3..f794572 100644 --- a/.agents/skills/testing-ios-simulator/SKILL.md +++ b/.agents/skills/testing-ios-simulator/SKILL.md @@ -5,24 +5,37 @@ description: How to build, sign, install, and drive the logseq_journal iOS app o # Testing logseq_journal on the iOS Simulator -## Build environment +## Build environment (lui stack, branch devin/lui-migration) -- The `bonsai-ui` opam switch (`eval $(opam env --switch=bonsai-ui --set-switch)`) already has most deps; the blueprint's `logseq-journal` switch may be bare. `opam install --deps-only --with-test --dry-run .` shows what's missing; typically only `bonsai_swiftui`, `bonsai_swiftui_test`, `bonsai_swiftui_tool`, `eio_main`. -- `bonsai-swiftui` CLI comes from `bonsai_swiftui_tool` (opam), repo `~/repos/bonsai-ui` must be at the pinned rev (check `bonsai-swiftui.sexp` / blueprint). -- If `bonsai-swiftui build ios --simulator` fails with "The iOS Simulator switch ... is incomplete: missing dune": run `bonsai-swiftui toolchain install iossimulator` (~15-25 min, installs ~240 cross pkgs into `~/.opam/bonsai-swiftui-ios-simulator`). -- If build fails with "Reachable SDK package ocaml-ios64-simulator ... missing from logseq_journal.opam.locked": the lockfile lacks the simulator SDK entry — add `"ocaml-ios64-simulator" {= "5.1.1"}` next to `"ocaml-ios64"`. -- DISK SPACE: a 103MB debug.dylib + DerivedData needs several GB free. With <500MB free, codesign fails with "internal error in Code Signing subsystem" (misleading — it's just ENOSPC). Check `df -h` first; `xcrun simctl delete ` and `opam clean` free space quickly. +- OCaml switch: `eval "$(opam env --switch=5.5.0 --set-switch)"` (or `logseq-journal` — check `opam switch list`). +- The lui checkout must exist at `~/repos/lui` (blueprint clones it; swift/Package.swift reads `JOURNAL_LUI_PACKAGE_PATH`, default `../../lui/platform/apple`). +- Build the .app: `dune build @ios-app` — it runs + `vtool -set-build-version 7 -replace -output journal_complete_iossim.o native_embed.exe.o` + then `tool/build_journal_apple.sh ios-simulator` → `_build/apple/ios-simulator/LogseqJournal.app`. +- Producing the sim complete object manually (equivalent to what @ios-app does): + 1. `export JOURNAL_APPLE_SDK_ROOT=$(xcrun --show-sdk-path); dune build app/native_embed.exe.o` (macOS object) + 2. `vtool -set-build-version 7 26.0 26.5 -replace -output journal_complete_iossim.o _build/default/app/native_embed.exe.o` (platform 7 = IOSSIMULATOR) + 3. `JOURNAL_OCAML_OBJECT=$PWD/_build/default/app/journal_complete_iossim.o tool/build_journal_apple.sh ios-simulator` +- A "proper" cross-link with the target toolchain's ocamlopt is NOT currently feasible: target `ld` rejects host-built `.cmx` ("building for iOS Simulator, but linking in object file built for macOS"). The vtool restamp of the merged macOS complete object works because it is a single `ld -r` object. +- iOS-sim OCaml toolchain provisioning: `LG_IOS_DEPLOYMENT_TARGET=26.0 ac_cv_func_pipe2=no ac_cv_func_dup3=no ac_cv_func_shmat=no ~/repos/lg/scripts/lg-mobile setup ios simulator` — the `ac_cv_*` overrides are REQUIRED: configure's link-check finds `pipe2`/`dup3`/`shmat` in libSystem.tbd but iOS headers don't declare them, and without the overrides crossopt fails in `pipe_unix.c`. +- `journal_lui_bridge.o` inside `_build/default/app/` is only a compile check — safe to ignore. -## Signing / entitlements +## Signing / entitlements (iOS 27.x simulator!) -- `bonsai-swiftui build ios --simulator` produces an unsigned app (`CODE_SIGNING_ALLOWED=NO`, empty entitlements). Empirically on iOS 26.5 simulator this app still signs in, unlocks the E2EE graph, syncs, and uploads — keychain -34018 did NOT reproduce. If it does fail, the adhoc rebuild documented in the blueprint works: - `xcodebuild -project apple/BonsaiLogseqJournal.xcodeproj -scheme BonsaiLogseqJournal-iOS -configuration Debug -destination 'generic/platform=iOS Simulator' -derivedDataPath apple/DerivedData -clonedSourcePackagesDirPath _build/bonsai-swiftui/dependencies/packages -disableAutomaticPackageResolution -onlyUsePackageVersionsFromResolvedFile -skipPackageUpdates ARCHS=arm64 CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=- build` -- If you must sign manually: use `.../BonsaiLogseqJournal-iOS.build/BonsaiLogseqJournal.app-Simulated.xcent` (contains FAKETEAMID application-identifier + keychain-access-groups), NOT `.app.xcent` (empty) and NOT the raw `config/entitlements/*.entitlements` (unexpanded `$(AppIdentifierPrefix)`). Do not remove `BonsaiLogseqJournal.debug.dylib` — the 59KB main binary is just a launcher for it; removing it makes the app fail to launch ("did not return a process handle"). +- **Any `codesign --entitlements` blob makes the binary fail to exec on the iOS 27.0 sim** — `simctl launch` reports "No such process" / "Launchd job spawn failed". Verified with: the build script's expanded `keychain-access-groups=[com.logseq.journal]`, a `FAKETEAMID.`-prefixed variant with `application-identifier`, and even `get-task-allow` alone — ALL fail at exec. Only linker-signed (no entitlements) or plain-adhoc (`codesign --sign -` without `--entitlements`) binaries launch. +- Launchable recipe: flat .app = `Info.plist` + raw swift product `swift/.build/arm64-apple-ios-simulator/debug/JournalApp`, NO codesign. iOS bundles are flat — a stray `Contents/` dir makes `simctl install` fail with "Missing bundle ID". +- Consequence: the launchable build carries NO keychain-access-groups → Amplify keychain access (-34018 class) may fail. On older iOS sims this may differ — if keychain is needed, try `application-identifier=FAKETEAMID.` + `keychain-access-groups=[FAKETEAMID.]` (the bonsai .xcent shape), but expect exec failure on iOS 27. +- macOS app caveat: `config/entitlements/macos-debug-profile.entitlements` ships literal `$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)` — the signed macOS app is killed at exec (Killed:9, `open` error 163, `spctl` rejects). Re-sign with `com.apple.security.cs.allow-jit` + `com.apple.security.network.server` (and drop or properly expand the keychain group) to make it launchable: `codesign --force --sign - --timestamp=none --entitlements _build/apple/macos/LogseqJournal.app`. -## Install / launch / record +## Install / launch / record / diagnose -- `xcrun simctl install booted .app`; `xcrun simctl launch booted com.example.bonsaiFlutterLogseqJournalHost` (bundle id from bonsai-swiftui.sexp ios section). -- `open -a Simulator` shows the window; `xcrun simctl io booted recordVideo out.mov` records device-only video (SIGINT to stop); `xcrun simctl io booted screenshot out.png`. +- `xcrun simctl install `; `xcrun simctl launch com.logseq.journal`. +- stderr capture: `xcrun simctl launch --console-pty com.logseq.journal > console.log` — the app writes no os_log output of its own; stdout/stderr is the only channel. `xcrun simctl launch` accepts trailing `KEY=VALUE` env pairs (e.g. `OCAMLRUNPARAM=v`). +- `open -a Simulator` shows the window; `xcrun simctl io recordVideo out.mov` (SIGINT to stop); `xcrun simctl io screenshot out.png`. +- Parked-app signature: `ps -o %cpu` ≈ 0 steady; `sample ` shows the OCaml worker domain in `domain_thread_func → camlIomux__Poll$poll_689 → caml_iomux_poll → poll()` with only a unix socket + self-pipe in `lsof` and no TCP — the app is idle-waiting, not computing. +- LJP2 traffic instrumentation (temporary edits): `JournalRuntime.swift` `platformRequest`/`wakeup` closures and `JournalApplicationPlatform.request`/`services.response` — `FileHandle.standardError.write("[LJP2-DBG] ...")` at each boundary shows whether OCaml issues requests and whether responses return. +- After `simctl install`, resolve the data container fresh via `xcrun simctl get_app_container com.logseq.journal data` — app-created files (worker dirs, sqlite) appear under `Library/Application Support`; an empty container means the worker never reached storage. +- `%cpu` is cumulative: steady ~100% = render loop; ~3% or less = idle. ## Seeding files for the fileImporter (Files picker) @@ -44,6 +57,12 @@ It then appears under Browse → "On My iPhone" in the fileImporter picker. - UI landmarks: detail toolbar paperclip = "Attach file" (journal-asset-import); ellipsis.circle on the detail-root media group = "Attachment actions" (journal-media-actions) with "Replace file…"/"Reuse existing…"; account person icon on Journals root → "Attachment settings" sheet (Recent journal days stepper, uploads list). - Tap precision: document-picker files select via the icon/thumbnail, not the name label; journal rows navigate via tapping the row's ">" area. +## Known environment traps + +- Multiple install/uninstall cycles can leave launchd app records stale — a fresh `xcrun simctl shutdown`+`boot` (or `erase`) clears it; don't confuse this with a bad binary. +- `xcrun simctl spawn log show --predicate 'process == "JournalApp"'` shows only UIKit-internal messages for this app — its own diagnostics go to stderr only. +- Booting a second simulator device while another app's launchd state is confused can surface "denied by service delegate (SBMainWorkspace)" — retry once SpringBoard is fully up. + ## Devin Secrets Needed - `LOGSEQ_JOURNAL_USERNAME`, `LOGSEQ_JOURNAL_PASSWORD`, `LOGSEQ_JOURNAL_E2EE_PASSWORD` diff --git a/app/application.ml b/app/application.ml index ef5ae00..a6631b2 100644 --- a/app/application.ml +++ b/app/application.ml @@ -2521,8 +2521,9 @@ let manager_page state dispatch = ~style:Prominent ~enabled:(Journal_capture.can_save password) ~on_press:submit - ~child:(V.text "Unlock graph" |> V.frame ~max_width:Fill) + ~child:(V.text "Unlock graph") () + |> V.frame ~max_width:Fill |> V.with_test_id (Ui.Test_id.string "e2ee-password-submit") in let choose_graph = @@ -5102,11 +5103,19 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = V.Body.theme ~data:(application_theme ()) (V.Body.static body) in let view _context model_signal _send = - Lui_elements.dyn - (fun model -> - Journal_view.mount - (body_view model dispatch timeline_scroll_completed detail_scroll_completed)) - model_signal + (* Dynamic elements mount under a parent, so the root must be a static + container. *) + Lui_elements.stack + [ Lui_elements.dyn + (fun model -> + Journal_view.mount + (body_view + model + dispatch + timeline_scroll_completed + detail_scroll_completed)) + model_signal + ] in let os = match platform_code with @@ -5188,6 +5197,7 @@ let create ?(calendar_sampler = fun () -> Journal_calendar.Sampler.create ()) ~s = let init platform_code host_code payload = latest_patch := ""; + Printexc.record_backtrace true; (match decode_config (Bytes.of_string payload) with | Error error -> Printf.eprintf "logseq_journal: failed to decode startup config: %s\n%!" error @@ -5200,12 +5210,21 @@ let create ?(calendar_sampler = fun () -> Journal_calendar.Sampler.create ()) ~s | Error error -> Printf.eprintf "logseq_journal: failed to start worker: %s\n%!" error | Ok client -> - ignore - (start - ~calendar_sampler:(calendar_sampler ()) - ~client - ~platform_code - ~host_code))); + (try + ignore + (start + ~calendar_sampler:(calendar_sampler ()) + ~client + ~platform_code + ~host_code) + with + | exn -> + (* The C bridge drops exceptions during the initial patch emit, + so surface startup failures on stderr. *) + Printf.eprintf + "logseq_journal: app start failed: %s\n%s%!" + (Printexc.to_string exn) + (Printexc.get_backtrace ())))); !latest_patch in let dispatch event = diff --git a/app/journal_header.ml b/app/journal_header.ml index c1b67b4..f6acb7a 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -121,18 +121,11 @@ let view |> Option.iter (fun (_, _, _, action, _) -> Ui.Event.Handler.Private.invoke dispatch (Ui.Event.Payload.Text action)) | _ -> ())) - ~label: - (V.label - ~title:(V.text "Account menu") - ~icon:(Journal_symbols.create Journal_symbols.Account) - ()) + ~title:"Account menu" + ~icon:(Journal_symbols.name Journal_symbols.Account) (List.map (fun (id, title, symbol, _, role) -> - V.Menu.action - ~id - ~role - ~label:(V.label ~title:(V.text title) ~icon:(V.symbol ~name:symbol ()) ()) - ()) + V.Menu.action ~id ~role ~title ~icon:symbol ()) actions) |> V.semantics ~properties: diff --git a/app/journal_lui_native.ml b/app/journal_lui_native.ml index b3c10da..684bac5 100644 --- a/app/journal_lui_native.ml +++ b/app/journal_lui_native.ml @@ -31,16 +31,29 @@ let event_schema = let registry = let registry = Lui_extension.registry () in + (* Journal native views nest: chrome slots hold page content (including + other chrome sections, lists, and media), and list rows hold media and + chrome section headers. Every component accepts all journal extensions + as children; the schema must stay in sync with the fingerprint the + Apple host computes in JournalExtensions.swift. *) + let children = + [ chrome_identifier + ; asset_import_identifier + ; media_identifier + ; asset_settings_identifier + ; list_identifier + ] + in register_component registry - (component chrome_identifier apple_profiles true [] [ payload_property ] []); + (component chrome_identifier apple_profiles true children [ payload_property ] []); register_component registry (component asset_import_identifier all_host_profiles false - [] + children [ payload_property ] [ event_schema ]); register_component @@ -49,7 +62,7 @@ let registry = media_identifier all_host_profiles false - [] + children [ payload_property ] [ event_schema ]); register_component @@ -58,7 +71,7 @@ let registry = asset_settings_identifier all_host_profiles true - [] + children [ payload_property ] [ event_schema ]); register_component @@ -67,7 +80,7 @@ let registry = list_identifier all_host_profiles false - [] + children [ payload_property ] [ event_schema ]); freeze registry; diff --git a/app/journal_view.ml b/app/journal_view.ml index a5bfb85..af2837a 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -9,10 +9,16 @@ and test_id so [For_testing] can recover them like the old widget identity did. *) +type label_content = + { title : string + ; icon : string option + } + type t = { key : string option ; test_id : string option ; mount : Lui_elements.t + ; label_content : label_content option } module Key = struct @@ -30,16 +36,48 @@ module Test_id = struct let to_string s = s end -let element ?key ?test_id mount = { key; test_id; mount } +let element ?key ?test_id mount = { key; test_id; mount; label_content = None } let mount t = t.mount let int_of_float_nan v = int_of_float (Float.round v) +(* lui icon properties accept built-in names or [app:] custom names; the + journal vocabulary is SF Symbol names, registered with the host backend under + their [app:]-slugged form (see JournalIcons.swift). *) +let journal_icon_name name = + "app:" ^ String.map (fun c -> if c = '.' then '-' else c) name +;; + +(* Leaf controls carry their label/icon as properties; each kind only accepts + a subset of them, so apply what the node kind supports. *) +let set_leaf_label context node { title; icon } = + let kind = Lui_ui.node_kind context node in + let supported property = Lui_protocol.property_supported kind property in + if supported Lui_protocol.TextValue then Lui_ui.text_property context node title; + Option.iter + (fun name -> + if supported Lui_protocol.InlineIconName + then + Lui_ui.string_property + context + node + Lui_protocol.InlineIconName + (journal_icon_name name)) + icon +;; + +(* Element mounts that don't register a standard runtime node (placeholder + elements, extension nodes) can't carry standard properties. *) +let node_is_standard context node = + node <> 0 + && Option.is_none (Lui_runtime.extension_identifier context.Lui_ui.ui_application node) +;; + let modify f t = { t with mount = (fun context parent -> let node = t.mount context parent in - f context node; + if node_is_standard context node then f context node; node) } ;; @@ -461,7 +499,10 @@ module View = struct let with_test_id test_id t = let mount context parent = let node = t.mount context parent in - Lui_ui.accessibility_identifier context node (Test_id.to_string test_id); + (* Extension nodes carry only extension properties; standard props like + the accessibility identifier don't apply to them. *) + if node_is_standard context node + then Lui_ui.accessibility_identifier context node (Test_id.to_string test_id); node in { t with test_id = Some (Test_id.to_string test_id); mount } @@ -477,46 +518,66 @@ module View = struct ?truncation:_ value = - element ?key (fun context parent -> - let node = Lui_ui.text context value in - Option.iter - (fun (style : Style.Text_style.t) -> - (match style.foreground with - | Some Style.Text_style.Secondary -> - Lui_ui.foreground context node "secondary" - | Some Primary | None -> ()); - match style.font_weight with - | Some Style.Text_style.Semi_bold -> Lui_ui.style_class context node "semibold" - | Some Regular | None -> ()) - style; - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - node) + { (element ?key (fun context parent -> + let node = Lui_ui.text context value in + Option.iter + (fun (style : Style.Text_style.t) -> + (match style.foreground with + | Some Style.Text_style.Secondary -> + Lui_ui.foreground context node "secondary" + | Some Primary | None -> ()); + match style.font_weight with + | Some Style.Text_style.Semi_bold -> + Lui_ui.style_class context node "semibold" + | Some Regular | None -> ()) + style; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node)) + with + label_content = Some { title = value; icon = None } + } ;; let symbol ?key ?size ?color ?rendering:_ ~name () = - element ?key (fun context parent -> - let node = Lui_ui.icon context name in - Option.iter - (fun size -> Lui_ui.size context node (string_of_int (int_of_float_nan size))) - size; - Option.iter (fun color -> Lui_ui.foreground context node color) color; - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - node) + { (element ?key (fun context parent -> + let node = Lui_ui.icon context (journal_icon_name name) in + Option.iter + (fun size -> Lui_ui.size context node (string_of_int (int_of_float_nan size))) + size; + Option.iter (fun color -> Lui_ui.foreground context node color) color; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node)) + with + label_content = Some { title = ""; icon = Some name } + } ;; let label ?key ~title ~icon () = - element ?key (fun context parent -> - let node = Lui_ui.row context in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - ignore (icon.mount context (Some node)); - ignore (title.mount context (Some node)); - node) + { (element ?key (fun context parent -> + let node = Lui_ui.row context in + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + ignore (icon.mount context (Some node)); + ignore (title.mount context (Some node)); + node)) + with + label_content = + Some + { title = + (match title.label_content with + | Some content -> content.title + | None -> "") + ; icon = + (match icon.label_content with + | Some content -> content.icon + | None -> None) + } + } ;; let divider ?key () = @@ -652,7 +713,12 @@ module View = struct modify (fun context node -> Option.iter - (fun label -> Lui_ui.accessibility_label context node label) + (fun label -> + if + Lui_protocol.property_supported + (Lui_ui.node_kind context node) + Lui_protocol.AccessibilityLabel + then Lui_ui.accessibility_label context node label) properties.Semantics.label) t ;; @@ -708,13 +774,15 @@ module View = struct node Lui_protocol.VariantValue (Button_role.variant role)); + (* lui controls are leaf nodes: their label/icon travel as properties, + not child elements. *) + Option.iter (set_leaf_label context node) child.label_content; if autofocus then Lui_ui.bool_property context node Lui_protocol.Autofocus true; Lui_ui.on_event context node (fun event -> if is_press event then invoke on_press Event.Payload.Unit); (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - ignore (child.mount context (Some node)); node) ;; @@ -723,6 +791,7 @@ module View = struct let node = Lui_ui.toggle context in if not enabled then Lui_ui.disabled context node true; Lui_ui.bool_property context node Lui_protocol.Checked value; + Option.iter (set_leaf_label context node) label.label_content; Lui_ui.on_event context node (fun event -> match event with | Lui_protocol.ToggleChanged (_, selected) -> @@ -731,7 +800,6 @@ module View = struct (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - ignore (label.mount context (Some node)); node) ;; @@ -975,7 +1043,10 @@ module View = struct let mount_items items = element (fun context parent -> - let node = Lui_ui.toolbar context in + (* A plain horizontal group: the toolbar node kind only accepts a fixed + set of control children and no extension children, while items here + include menus and extension nodes. *) + let node = Lui_ui.row context in (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); @@ -984,11 +1055,18 @@ module View = struct let child = item.content in let mounted = child.mount context (Some node) in Lui_ui.key context mounted item.item_key; + let hint property value = + (* Placement/spacing hints only apply to node kinds that accept + them; menu items and extension nodes reject these props. *) + if + Lui_protocol.property_supported + (Lui_ui.node_kind context mounted) + property + then Lui_ui.string_property context mounted property value + in (match item.placement with | Some placement -> - Lui_ui.string_property - context - mounted + hint Lui_protocol.RoleValue (match placement with | Automatic -> "automatic" @@ -1003,26 +1081,10 @@ module View = struct | Bottom_bar -> "bottom_bar") | None -> ()); (match item.spacing with - | Some Fixed -> - Lui_ui.string_property - context - mounted - Lui_protocol.VariantValue - "fixed_spacing" - | Some Flexible -> - Lui_ui.string_property - context - mounted - Lui_protocol.VariantValue - "flexible_spacing" + | Some Fixed -> hint Lui_protocol.VariantValue "fixed_spacing" + | Some Flexible -> hint Lui_protocol.VariantValue "flexible_spacing" | None -> ()); - if item.is_group - then - Lui_ui.string_property - context - mounted - Lui_protocol.VariantValue - "item_group") + if item.is_group then hint Lui_protocol.VariantValue "item_group") items; node) ;; @@ -1185,24 +1247,35 @@ module View = struct let attach ?key:_ actions (view : element_) = element ?key:view.key (fun context parent -> - let node = Lui_ui.context_menu context in - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); + let node = view.mount context parent in + let menu = Lui_ui.context_menu context in + Lui_ui.append context node menu; List.iter (fun (action : action) -> let item = Lui_ui.menu_item context in Lui_ui.text_property context item action.title; Option.iter (fun symbol -> - ignore (Lui_ui.append context item (Lui_ui.icon context symbol))) + Lui_ui.string_property + context + item + Lui_protocol.InlineIconName + (journal_icon_name symbol)) action.symbol; + (match action.role with + | Normal -> () + | Destructive -> + Lui_ui.string_property + context + item + Lui_protocol.VariantValue + "destructive"); if not action.enabled then Lui_ui.disabled context item true; + Lui_ui.bool_property context item Lui_protocol.PressEnabled true; Lui_ui.on_event context item (fun event -> if is_press event then invoke action.on_press Event.Payload.Unit); - Lui_ui.append context node item) + Lui_ui.append context menu item) actions; - ignore (view.mount context (Some node)); node) ;; end @@ -1669,12 +1742,12 @@ module View = struct element ?key (fun context parent -> let node = Lui_ui.list_item context in if not enabled then Lui_ui.disabled context node true; + Option.iter (set_leaf_label context node) label.label_content; Lui_ui.on_event context node (fun event -> if is_press event then invoke on_activate Event.Payload.Unit); (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - ignore (label.mount context (Some node)); node) ;; end @@ -1807,110 +1880,184 @@ module View = struct | Lui_protocol.Press _ | ToggleChanged (_, true) -> invoke on_select (Event.Payload.Int64 choice.id) | _ -> ()); - Lui_ui.append context node item; - ignore (choice.label.mount context (Some item))) + Option.iter (set_leaf_label context item) choice.label.label_content; + Option.iter + (Lui_ui.accessibility_identifier context item) + choice.label.test_id; + Lui_ui.append context node item) choices; node) ;; end module Menu = struct + type label = + { title : string + ; icon : string option + } + type entry = | Action of { id : int64 - ; label : t + ; label : label ; enabled : bool ; role : Button_role.t } | Choice of { id : int64 - ; label : t + ; label : label ; selected : bool ; enabled : bool } | Divider of int64 | Section of { id : int64 - ; label : t option + ; label : label option ; entries : entry list } | Submenu of { id : int64 - ; label : t + ; label : label ; enabled : bool ; entries : entry list } - let action ~id ~label ?(enabled = true) ?(role = Button_role.Normal) () = - Action { id; label; enabled; role } + let action ~id ~title ?icon ?(enabled = true) ?(role = Button_role.Normal) () = + Action { id; label = { title; icon }; enabled; role } ;; - let choice ~id ~label ~selected ?(enabled = true) () = - Choice { id; label; selected; enabled } + let choice ~id ~title ?icon ~selected ?(enabled = true) () = + Choice { id; label = { title; icon }; selected; enabled } ;; let divider ~id = Divider id - let section ~id ?label entries = Section { id; label; entries } - let submenu ~id ~label ?(enabled = true) entries = - Submenu { id; label; enabled; entries } + let section ~id ?title ?icon entries = + Section { id; label = Option.map (fun title -> { title; icon }) title; entries } ;; - let create ?key ?(enabled = true) ~on_select ~label entries = + let submenu ~id ~title ?icon ?(enabled = true) entries = + Submenu { id; label = { title; icon }; enabled; entries } + ;; + + (* lui menus are declarative: a [menu_item] holding one [dropdown_menu] + child renders as a native popup menu, and menu rows carry their label + and icon as properties (menu items accept only menu children). *) + let menu_item context ?key_opt ~title ~icon ~enabled ~role ~selected ?on_press () = + let node = Lui_ui.menu_item context in + Option.iter (Lui_ui.key context node) key_opt; + Lui_ui.string_property context node Lui_protocol.TextValue title; + Option.iter + (fun name -> + Lui_ui.string_property + context + node + Lui_protocol.InlineIconName + (journal_icon_name name)) + icon; + if not enabled then Lui_ui.disabled context node true; + (match role with + | Button_role.Normal -> () + | role -> + Lui_ui.string_property + context + node + Lui_protocol.VariantValue + (Button_role.variant role)); + Option.iter (Lui_ui.bool_property context node Lui_protocol.Selected) selected; + Option.iter + (fun payload -> + Lui_ui.bool_property context node Lui_protocol.PressEnabled true; + Lui_ui.on_event context node (fun event -> if is_press event then payload ())) + on_press; + node + ;; + + let create ?key ?(enabled = true) ~on_select ~title ?icon entries = element ?key (fun context parent -> - let node = Lui_ui.dropdown_menu context in - if not enabled then Lui_ui.disabled context node true; + let node = + menu_item + context + ~title + ~icon + ~enabled + ~role:Button_role.Normal + ~selected:None + () + in (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - ignore (label.mount context (Some node)); + let menu = Lui_ui.dropdown_menu context in + Lui_ui.append context node menu; let rec mount_entry parent (entry : entry) = match entry with | Divider id -> - let separator = Lui_ui.separator context (Int64.to_string id) in + let separator = Lui_ui.separator context "horizontal" in + Lui_ui.key context separator (Int64.to_string id); Lui_ui.append context parent separator - | entry -> - let item = Lui_ui.menu_item context in - Lui_ui.key - context - item - (Int64.to_string - (match entry with - | Action { id; _ } | Choice { id; _ } -> id - | Section { id; _ } | Submenu { id; _ } -> id - | Divider _ -> assert false)); - (match entry with - | Action { id; label; enabled; role } -> - if not enabled then Lui_ui.disabled context item true; - (match role with - | Button_role.Normal -> () - | role -> - Lui_ui.string_property - context - item - Lui_protocol.VariantValue - (Button_role.variant role)); - Lui_ui.on_event context item (fun event -> - if is_press event then invoke on_select (Event.Payload.Int64 id)); - ignore (label.mount context (Some item)) - | Choice { id; label; selected; enabled } -> - if not enabled then Lui_ui.disabled context item true; - if selected - then Lui_ui.bool_property context item Lui_protocol.Checked true; - Lui_ui.on_event context item (fun event -> - if is_press event then invoke on_select (Event.Payload.Int64 id)); - ignore (label.mount context (Some item)) - | Section { label; entries; _ } -> - Option.iter (fun label -> ignore (label.mount context (Some item))) label; - List.iter (mount_entry item) entries - | Submenu { label; enabled; entries; _ } -> - if not enabled then Lui_ui.disabled context item true; - ignore (label.mount context (Some item)); - List.iter (mount_entry item) entries - | Divider _ -> assert false); + | Action { id; label; enabled; role } -> + let item = + menu_item + context + ~key_opt:(Int64.to_string id) + ~title:label.title + ~icon:label.icon + ~enabled + ~role + ~selected:None + ~on_press:(fun () -> invoke on_select (Event.Payload.Int64 id)) + () + in Lui_ui.append context parent item + | Choice { id; label; selected; enabled } -> + let item = + menu_item + context + ~key_opt:(Int64.to_string id) + ~title:label.title + ~icon:label.icon + ~enabled + ~role:Button_role.Normal + ~selected:(Some selected) + ~on_press:(fun () -> invoke on_select (Event.Payload.Int64 id)) + () + in + Lui_ui.append context parent item + | Section { label; entries; _ } -> + Option.iter + (fun label -> + let heading = + menu_item + context + ~title:label.title + ~icon:label.icon + ~enabled:false + ~role:Button_role.Normal + ~selected:None + () + in + Lui_ui.append context parent heading) + label; + List.iter (mount_entry parent) entries + | Submenu { id; label; enabled; entries } -> + let item = + menu_item + context + ~key_opt:(Int64.to_string id) + ~title:label.title + ~icon:label.icon + ~enabled + ~role:Button_role.Normal + ~selected:None + () + in + Lui_ui.append context parent item; + let submenu = Lui_ui.dropdown_menu context in + Lui_ui.append context item submenu; + List.iter (mount_entry submenu) entries in - List.iter (mount_entry node) entries; + List.iter (mount_entry menu) entries; node) ;; end diff --git a/app/journal_view.mli b/app/journal_view.mli index 18882e9..c474abb 100644 --- a/app/journal_view.mli +++ b/app/journal_view.mli @@ -816,22 +816,39 @@ module View : sig val action : id:int64 - -> label:t + -> title:string + -> ?icon:string -> ?enabled:bool -> ?role:Button_role.t -> unit -> entry - val choice : id:int64 -> label:t -> selected:bool -> ?enabled:bool -> unit -> entry + val choice + : id:int64 + -> title:string + -> ?icon:string + -> selected:bool + -> ?enabled:bool + -> unit + -> entry + val divider : id:int64 -> entry - val section : id:int64 -> ?label:t -> entry list -> entry - val submenu : id:int64 -> label:t -> ?enabled:bool -> entry list -> entry + val section : id:int64 -> ?title:string -> ?icon:string -> entry list -> entry + + val submenu + : id:int64 + -> title:string + -> ?icon:string + -> ?enabled:bool + -> entry list + -> entry val create : ?key:Key.t -> ?enabled:bool -> on_select:Event.handler - -> label:t + -> title:string + -> ?icon:string -> entry list -> t end diff --git a/swift/JournalApplicationPlatform.swift b/swift/JournalApplicationPlatform.swift index 8e0f67e..e590598 100644 --- a/swift/JournalApplicationPlatform.swift +++ b/swift/JournalApplicationPlatform.swift @@ -118,7 +118,11 @@ import Observation let request: JournalPlatformWire.Request do { request = try JournalPlatformWire.decodeRequest(bytes) - } catch { return nil } + } catch { + FileHandle.standardError.write( + Data("logseq_journal: decodeRequest failed bytes=\(bytes.count)\n".utf8)) + return nil + } requestObserver?(request) if request == .signOut { refresh?.cancel() diff --git a/swift/JournalExtensions.swift b/swift/JournalExtensions.swift index 39e174b..7d81c76 100644 --- a/swift/JournalExtensions.swift +++ b/swift/JournalExtensions.swift @@ -44,7 +44,7 @@ enum JournalExtensionFingerprint { return "lui-extension-v1|" + token(identifier) + "|profiles:" + profiles.sorted().joined(separator: ",") + "|standard-children:" + (standardChildren ? "1" : "0") - + "|children:" + children.map(token).sorted().joined(separator: ",") + + "|children:" + children.sorted().map(token).joined(separator: ",") + "|properties:" + properties.map(propertyToken).sorted().joined(separator: ",") + "|events:" + events.map(eventToken).sorted().joined(separator: ",") } @@ -68,11 +68,23 @@ enum JournalExtensionFingerprint { ) -> String { JournalExtensionFingerprint.make( identifier: identifier, profiles: profiles, - standardChildren: standardChildren, children: [], + standardChildren: standardChildren, children: journalChildIdentifiers, properties: [payloadProperty], events: events ? [event] : []) } + /// Journal native views nest (chrome slots hold page content including + /// other chrome sections, lists, and media; list rows hold media and + /// chrome section headers), so every component accepts all journal + /// extensions as children. Must stay in sync with `journal_lui_native.ml`. + private static let journalChildIdentifiers = [ + "journal-chrome", + "journal-asset-import", + "journal-media", + "journal-asset-settings", + "journal-list", + ] + private static let eventSchema = LUIExtensionEvent( name: "event", fields: [ @@ -116,6 +128,7 @@ enum JournalExtensionFingerprint { identifier: identifier, profiles: profiles, standardChildren: standardChildren, events: events), acceptsStandardChildren: standardChildren, + childIdentifiers: journalChildIdentifiers, properties: [.init(name: "payload", kind: .string, isRequired: true)], events: events ? [eventSchema] : [], viewFactory: viewFactory) diff --git a/swift/JournalIcons.swift b/swift/JournalIcons.swift new file mode 100644 index 0000000..a01de49 --- /dev/null +++ b/swift/JournalIcons.swift @@ -0,0 +1,39 @@ +import LUIAppleBackend + +/// OCaml icon properties carry SF Symbol names slugged to `app:` +/// (dots replaced by dashes) — this table resolves each slug back to its +/// system symbol name. Keep in sync with `journal_icon_name` call sites. +let journalIconNames: [String] = [ + "arrow.clockwise", + "arrow.up", + "book", + "calendar", + "checkmark.circle", + "checkmark.square", + "chevron.down", + "chevron.left", + "chevron.right", + "circle", + "circle.fill", + "clock", + "doc", + "doc.text", + "exclamationmark.circle", + "exclamationmark.triangle", + "folder", + "lock.doc", + "lock.shield", + "minus.circle", + "person.crop.circle", + "plus", + "questionmark.folder", + "square.and.pencil", + "star", + "trash", +] + +let journalAppIcons: [String: LUIAppleIconSource] = Dictionary( + uniqueKeysWithValues: journalIconNames.map { name in + (name.replacingOccurrences(of: ".", with: "-"), .systemName(name)) + } +) diff --git a/swift/JournalRuntime.swift b/swift/JournalRuntime.swift index d62785d..e11908a 100644 --- a/swift/JournalRuntime.swift +++ b/swift/JournalRuntime.swift @@ -108,7 +108,10 @@ private let platformRequest: PlatformRequestCallback = { data, length in ) throws { self.platform = platform self.startupPayload = startupPayload - backend = try LUIAppleBackend(extensionRegistry: extensionRegistry) + backend = try LUIAppleBackend( + appIcons: journalAppIcons, + extensionRegistry: extensionRegistry + ) backend.onEvent = { [weak self] event in self?.handle(event) } } From 5676639422fc70870dd19219e90a0f34db3e8db9 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 23:08:28 -0700 Subject: [PATCH 21/40] Restore platform request failure delivery so OCaml never waits forever The old bridge surfaced platform request failures to OCaml; the ported path returned nil from JournalApplicationPlatform.request and dropped it in deliverPlatformRequest, leaving the pending continuation parked indefinitely. On the iOS simulator the startup localAccount keychain read fails (errSecMissingEntitlement on unentitled sim builds) and the app sat on a blank screen forever; any keychain failure on a real device would hit the same silent hang. - journal_lui_bridge.c: new journal_ocaml_platform_failure entry that delivers the original request envelope back to OCaml. - journal_bridge: platform_failure hook (OCaml resolves the pending continuation with Error via Platform_response under the response tag). - application.ml: hoist response_tag to top level for the failure hook. - JournalRuntime.deliverPlatformRequest: nil response now reports the request as failed instead of being silently dropped. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .agents/skills/testing-ios-simulator/SKILL.md | 7 ++-- app/application.ml | 41 +++++++++++++------ app/journal_bridge.ml | 5 ++- app/journal_bridge.mli | 5 +++ app/journal_lui_bridge.c | 8 ++++ swift/JournalRuntime.swift | 17 ++++++-- 6 files changed, 64 insertions(+), 19 deletions(-) diff --git a/.agents/skills/testing-ios-simulator/SKILL.md b/.agents/skills/testing-ios-simulator/SKILL.md index f794572..e0b6563 100644 --- a/.agents/skills/testing-ios-simulator/SKILL.md +++ b/.agents/skills/testing-ios-simulator/SKILL.md @@ -20,12 +20,13 @@ description: How to build, sign, install, and drive the logseq_journal iOS app o - iOS-sim OCaml toolchain provisioning: `LG_IOS_DEPLOYMENT_TARGET=26.0 ac_cv_func_pipe2=no ac_cv_func_dup3=no ac_cv_func_shmat=no ~/repos/lg/scripts/lg-mobile setup ios simulator` — the `ac_cv_*` overrides are REQUIRED: configure's link-check finds `pipe2`/`dup3`/`shmat` in libSystem.tbd but iOS headers don't declare them, and without the overrides crossopt fails in `pipe_unix.c`. - `journal_lui_bridge.o` inside `_build/default/app/` is only a compile check — safe to ignore. -## Signing / entitlements (iOS 27.x simulator!) +## Signing / entitlements (iOS 26.5+ simulator — verified 26.5 AND 27.0) - **Any `codesign --entitlements` blob makes the binary fail to exec on the iOS 27.0 sim** — `simctl launch` reports "No such process" / "Launchd job spawn failed". Verified with: the build script's expanded `keychain-access-groups=[com.logseq.journal]`, a `FAKETEAMID.`-prefixed variant with `application-identifier`, and even `get-task-allow` alone — ALL fail at exec. Only linker-signed (no entitlements) or plain-adhoc (`codesign --sign -` without `--entitlements`) binaries launch. - Launchable recipe: flat .app = `Info.plist` + raw swift product `swift/.build/arm64-apple-ios-simulator/debug/JournalApp`, NO codesign. iOS bundles are flat — a stray `Contents/` dir makes `simctl install` fail with "Missing bundle ID". -- Consequence: the launchable build carries NO keychain-access-groups → Amplify keychain access (-34018 class) may fail. On older iOS sims this may differ — if keychain is needed, try `application-identifier=FAKETEAMID.` + `keychain-access-groups=[FAKETEAMID.]` (the bonsai .xcent shape), but expect exec failure on iOS 27. -- macOS app caveat: `config/entitlements/macos-debug-profile.entitlements` ships literal `$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)` — the signed macOS app is killed at exec (Killed:9, `open` error 163, `spctl` rejects). Re-sign with `com.apple.security.cs.allow-jit` + `com.apple.security.network.server` (and drop or properly expand the keychain group) to make it launchable: `codesign --force --sign - --timestamp=none --entitlements _build/apple/macos/LogseqJournal.app`. +- **KEYCHAIN TRAP (iOS ≥26.5 sim, confirmed on 26.5 AND 27.0): adhoc/unsigned sim binaries get `SecItemCopyMatching` → OSStatus -34018 (`errSecMissingEntitlement`) on EVERY keychain read — and no entitlement-bearing binary can exec.** The startup `localAccount` request throws inside `JournalLocalAccountBindingStore.load()` → `JournalApplicationPlatform.request()` returns nil → `deliverPlatformRequest` reports it via `journal_ocaml_platform_failure` (the request envelope round-trips back so OCaml resolves the pending continuation with `Error` — do not regress this: a nil return without the failure call leaves OCaml parked forever on a blank screen). The app now reaches the sign-in UI on sim; sign-in itself still dies at Amplify keychain -34018 ("client has neither application-identifier nor keychain-access-groups entitlements"). +- macOS app caveats: (a) `config/entitlements/macos-debug-profile.entitlements` historically shipped literal `$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)` — killed at exec (Killed:9, error 163). (b) Plain-adhoc macOS builds DO reach the sign-in dialog (generic-password reads to the app's own group work), but Amplify sign-in still fails -34018 for its access-group keychain ops; re-signing adhoc WITH `keychain-access-groups=[com.logseq.journal]` → AMFI spawn kill (error 163). No signing identity exists on the box → Cognito sign-in cannot complete on macOS either. +- Startup wakeup storm (observed on iOS): ~25-30k `wakeup` callbacks in the first ~40s (~700/s) — OCaml cross-thread pump enqueue floods the MainActor task queue; the first `platformRequest` delivery waits behind the flood (~25s delay), then the system settles to 0% CPU. Not fatal, but adds startup latency and floods instrumented logs. ## Install / launch / record / diagnose diff --git a/app/application.ml b/app/application.ml index a6631b2..714e434 100644 --- a/app/application.ml +++ b/app/application.ml @@ -2825,6 +2825,19 @@ let decode_extension_values payload = | _ -> Lui_protocol.String_map.empty ;; +(* Platform request tags map to the tag carried by their response envelope; + continuations are registered under the response tag. *) +let response_tag = function + | 6 -> 7 + | 8 -> 9 + | 10 -> 11 + | 13 -> 14 + | 20 -> 21 + | 22 -> 23 + | 25 -> 26 + | tag -> tag +;; + let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = let pump = Journal_pump.create () in Journal_pump.set_wakeup pump Journal_bridge.wakeup; @@ -2864,21 +2877,12 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = let set_state_ref = ref None in set_state_ref := Some set_state; (* Platform requests are fire-and-forget: the host replies on the - [platform_response] hook, which is routed back to the continuation - registered under the matching response tag. *) + [platform_response] hook, or reports a failed request through + [platform_failure]; both resolve the continuation registered under the + matching response tag. *) let pending_platform : (int, (bytes, string) result -> unit) Hashtbl.t = Hashtbl.create 8 in - let response_tag = function - | 6 -> 7 - | 8 -> 9 - | 10 -> 11 - | 13 -> 14 - | 20 -> 21 - | 22 -> 23 - | 25 -> 26 - | tag -> tag - in let emit_platform_request ?k request = (match k, Bytes.length request >= 8 with | Some k, true -> @@ -5287,6 +5291,18 @@ let create ?(calendar_sampler = fun () -> Journal_calendar.Sampler.create ()) ~s send_action (Platform_response (tag, Ok bytes)))) | None -> () in + let platform_failure payload = + match !current_app with + | Some { pump; send_action; _ } -> + let bytes = Bytes.of_string payload in + if Bytes.length bytes >= 8 + then ( + let tag = response_tag (Bytes.get_uint16_le bytes 6) in + Journal_pump.enqueue pump (fun () -> + send_action + (Platform_response (tag, Error "application platform request failed")))) + | None -> () + in let dispose () = latest_patch := ""; (match !current_app with @@ -5309,6 +5325,7 @@ let create ?(calendar_sampler = fun () -> Journal_calendar.Sampler.create ()) ~s ; pump ; platform_event ; platform_response + ; platform_failure ; dispose ; root_node } diff --git a/app/journal_bridge.ml b/app/journal_bridge.ml index f7d7f8a..4b690c3 100644 --- a/app/journal_bridge.ml +++ b/app/journal_bridge.ml @@ -5,6 +5,7 @@ type hooks = ; pump : unit -> string ; platform_event : string -> unit ; platform_response : string -> unit + ; platform_failure : string -> unit ; dispose : unit -> string ; root_node : unit -> int } @@ -43,6 +44,7 @@ let extension_event node name payload = (hooks ()).extension_event node name pay let pump () = (hooks ()).pump () let platform_event payload = (hooks ()).platform_event payload let platform_response payload = (hooks ()).platform_response payload +let platform_failure payload = (hooks ()).platform_failure payload let dispose () = (hooks ()).dispose () let root_node () = (hooks ()).root_node () @@ -64,5 +66,6 @@ let register hooks = Callback.register "journal_ocaml_extension_event" extension_event; Callback.register "journal_ocaml_pump" pump; Callback.register "journal_ocaml_platform_event" platform_event; - Callback.register "journal_ocaml_platform_response" platform_response + Callback.register "journal_ocaml_platform_response" platform_response; + Callback.register "journal_ocaml_platform_failure" platform_failure ;; diff --git a/app/journal_bridge.mli b/app/journal_bridge.mli index 382c701..f52b6a1 100644 --- a/app/journal_bridge.mli +++ b/app/journal_bridge.mli @@ -25,6 +25,11 @@ type hooks = ; (* Host -> OCaml: an LJP2 response envelope completing an earlier platform request. Binary-safe string. *) platform_response : string -> unit + ; (* Host -> OCaml: the platform could not answer a request (decode or + service failure). Carries the original request envelope; the pending + continuation resolves with an error, matching the old bridge's + request-failure path. *) + platform_failure : string -> unit ; (* Tears the app down; returns the final patch batch. *) dispose : unit -> string ; root_node : unit -> int diff --git a/app/journal_lui_bridge.c b/app/journal_lui_bridge.c index 344c9aa..5a58c1c 100644 --- a/app/journal_lui_bridge.c +++ b/app/journal_lui_bridge.c @@ -203,6 +203,14 @@ LUI_EXPORT void journal_ocaml_platform_response(const char *data, deliver_platform("journal_ocaml_platform_response", data, length); } +/* The host reports a failed platform request by passing the original + request envelope; OCaml resolves the pending continuation with an + error instead of leaving it parked forever. */ +LUI_EXPORT void journal_ocaml_platform_failure(const char *data, + int32_t length) { + deliver_platform("journal_ocaml_platform_failure", data, length); +} + /* Host-installed callbacks for OCaml -> host delivery. */ LUI_EXPORT void journal_ocaml_set_wakeup_callback( journal_wakeup_callback callback) { diff --git a/swift/JournalRuntime.swift b/swift/JournalRuntime.swift index e11908a..5d3c91e 100644 --- a/swift/JournalRuntime.swift +++ b/swift/JournalRuntime.swift @@ -54,6 +54,8 @@ private func journalOCamlPump() -> Int32 private func journalOCamlPlatformEvent(_ data: UnsafePointer?, _ length: Int32) @_silgen_name("journal_ocaml_platform_response") private func journalOCamlPlatformResponse(_ data: UnsafePointer?, _ length: Int32) +@_silgen_name("journal_ocaml_platform_failure") +private func journalOCamlPlatformFailure(_ data: UnsafePointer?, _ length: Int32) @_silgen_name("journal_ocaml_set_wakeup_callback") private func journalOCamlSetWakeupCallback(_ callback: WakeupCallback?) @_silgen_name("journal_ocaml_set_platform_request_callback") @@ -168,10 +170,19 @@ private let platformRequest: PlatformRequestCallback = { data, length in } /// Marshals one LJP2 request onto the platform actor and ships its response - /// envelope back through the C entry. Errors drop the response; OCaml owns - /// request timeouts (matching the old bridge error path). + /// envelope back through the C entry. A nil response (decode or service + /// failure) reports the request as failed so OCaml resolves its pending + /// continuation instead of waiting forever. func deliverPlatformRequest(_ bytes: Data) async { - guard started, let response = await platform.request(bytes) else { return } + guard started else { return } + guard let response = await platform.request(bytes) else { + bytes.withUnsafeBytes { buffer in + journalOCamlPlatformFailure( + buffer.baseAddress?.assumingMemoryBound(to: CChar.self), + Int32(buffer.count)) + } + return + } response.withUnsafeBytes { buffer in journalOCamlPlatformResponse( buffer.baseAddress?.assumingMemoryBound(to: CChar.self), From d9544ac415929447baae061fec61884a9475196a Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 23:30:08 -0700 Subject: [PATCH 22/40] Update iOS sim testing notes: keychain trap, failure-delivery path, dead-end signature Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .agents/skills/testing-ios-simulator/SKILL.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.agents/skills/testing-ios-simulator/SKILL.md b/.agents/skills/testing-ios-simulator/SKILL.md index e0b6563..11cffd9 100644 --- a/.agents/skills/testing-ios-simulator/SKILL.md +++ b/.agents/skills/testing-ios-simulator/SKILL.md @@ -24,9 +24,10 @@ description: How to build, sign, install, and drive the logseq_journal iOS app o - **Any `codesign --entitlements` blob makes the binary fail to exec on the iOS 27.0 sim** — `simctl launch` reports "No such process" / "Launchd job spawn failed". Verified with: the build script's expanded `keychain-access-groups=[com.logseq.journal]`, a `FAKETEAMID.`-prefixed variant with `application-identifier`, and even `get-task-allow` alone — ALL fail at exec. Only linker-signed (no entitlements) or plain-adhoc (`codesign --sign -` without `--entitlements`) binaries launch. - Launchable recipe: flat .app = `Info.plist` + raw swift product `swift/.build/arm64-apple-ios-simulator/debug/JournalApp`, NO codesign. iOS bundles are flat — a stray `Contents/` dir makes `simctl install` fail with "Missing bundle ID". -- **KEYCHAIN TRAP (iOS ≥26.5 sim, confirmed on 26.5 AND 27.0): adhoc/unsigned sim binaries get `SecItemCopyMatching` → OSStatus -34018 (`errSecMissingEntitlement`) on EVERY keychain read — and no entitlement-bearing binary can exec.** The startup `localAccount` request throws inside `JournalLocalAccountBindingStore.load()` → `JournalApplicationPlatform.request()` returns nil → `deliverPlatformRequest` reports it via `journal_ocaml_platform_failure` (the request envelope round-trips back so OCaml resolves the pending continuation with `Error` — do not regress this: a nil return without the failure call leaves OCaml parked forever on a blank screen). The app now reaches the sign-in UI on sim; sign-in itself still dies at Amplify keychain -34018 ("client has neither application-identifier nor keychain-access-groups entitlements"). +- **KEYCHAIN TRAP (iOS ≥26.5 sim, confirmed on 26.5 AND 27.0): adhoc/unsigned sim binaries get `SecItemCopyMatching` → OSStatus -34018 (`errSecMissingEntitlement`) on EVERY keychain read — and no entitlement-bearing binary can exec.** The startup `localAccount` request throws inside `JournalLocalAccountBindingStore.load()` → `JournalApplicationPlatform.request()` returns nil → `deliverPlatformRequest` reports it via `journal_ocaml_platform_failure` (the request envelope round-trips back so OCaml resolves the pending continuation with `Error` — do not regress this: a nil return without the failure call leaves OCaml parked forever). NOTE: the failure path resolves continuations but the app STILL lands on a blank dead-end on sim (see "Dead-end-on-all-failures signature" below) — the sign-in UI is NOT reachable on unentitled sims. Sign-in itself also dies at Amplify keychain -34018 ("client has neither application-identifier nor keychain-access-groups entitlements"). - macOS app caveats: (a) `config/entitlements/macos-debug-profile.entitlements` historically shipped literal `$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)` — killed at exec (Killed:9, error 163). (b) Plain-adhoc macOS builds DO reach the sign-in dialog (generic-password reads to the app's own group work), but Amplify sign-in still fails -34018 for its access-group keychain ops; re-signing adhoc WITH `keychain-access-groups=[com.logseq.journal]` → AMFI spawn kill (error 163). No signing identity exists on the box → Cognito sign-in cannot complete on macOS either. - Startup wakeup storm (observed on iOS): ~25-30k `wakeup` callbacks in the first ~40s (~700/s) — OCaml cross-thread pump enqueue floods the MainActor task queue; the first `platformRequest` delivery waits behind the flood (~25s delay), then the system settles to 0% CPU. Not fatal, but adds startup latency and floods instrumented logs. +- **Dead-end-on-all-failures signature (post-5676639)**: with `journal_ocaml_platform_failure` delivering Errors, OCaml's `managed_startup` ignores them (`Error _ -> Effect.ignore`), so `Reconcile_authenticated_user` never reaches the graph_service → `state.manager = None` → the root emits `timeline_page` with an INCOMPLETE tree → the `journal-chrome` extensions get fewer children than their guards require (`JournalChrome.View` renders `EmptyView` unless childIDs.count==3 for `feedback` / ==4 for `journal`) → whole subtree invisible → **blank screen that is a settled dead-end, not a hang** (idle process, no pending continuations). Diagnose by logging patch heads in `JournalRuntime.apply` and counting `insert-child` ops per extension id vs the guard counts in `JournalChrome.swift:91-123`. ## Install / launch / record / diagnose From f56e37b4a4e5174b24dcdc5cdc1a4aeaa9acf1f6 Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 23:44:09 -0700 Subject: [PATCH 23/40] Support real signing for iOS sim builds via JOURNAL_IOS_TEAM_ID Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .agents/skills/testing-ios-simulator/SKILL.md | 1 + tool/build_journal_apple.sh | 29 +++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.agents/skills/testing-ios-simulator/SKILL.md b/.agents/skills/testing-ios-simulator/SKILL.md index 11cffd9..93fc43e 100644 --- a/.agents/skills/testing-ios-simulator/SKILL.md +++ b/.agents/skills/testing-ios-simulator/SKILL.md @@ -25,6 +25,7 @@ description: How to build, sign, install, and drive the logseq_journal iOS app o - **Any `codesign --entitlements` blob makes the binary fail to exec on the iOS 27.0 sim** — `simctl launch` reports "No such process" / "Launchd job spawn failed". Verified with: the build script's expanded `keychain-access-groups=[com.logseq.journal]`, a `FAKETEAMID.`-prefixed variant with `application-identifier`, and even `get-task-allow` alone — ALL fail at exec. Only linker-signed (no entitlements) or plain-adhoc (`codesign --sign -` without `--entitlements`) binaries launch. - Launchable recipe: flat .app = `Info.plist` + raw swift product `swift/.build/arm64-apple-ios-simulator/debug/JournalApp`, NO codesign. iOS bundles are flat — a stray `Contents/` dir makes `simctl install` fail with "Missing bundle ID". - **KEYCHAIN TRAP (iOS ≥26.5 sim, confirmed on 26.5 AND 27.0): adhoc/unsigned sim binaries get `SecItemCopyMatching` → OSStatus -34018 (`errSecMissingEntitlement`) on EVERY keychain read — and no entitlement-bearing binary can exec.** The startup `localAccount` request throws inside `JournalLocalAccountBindingStore.load()` → `JournalApplicationPlatform.request()` returns nil → `deliverPlatformRequest` reports it via `journal_ocaml_platform_failure` (the request envelope round-trips back so OCaml resolves the pending continuation with `Error` — do not regress this: a nil return without the failure call leaves OCaml parked forever). NOTE: the failure path resolves continuations but the app STILL lands on a blank dead-end on sim (see "Dead-end-on-all-failures signature" below) — the sign-in UI is NOT reachable on unentitled sims. Sign-in itself also dies at Amplify keychain -34018 ("client has neither application-identifier nor keychain-access-groups entitlements"). +- **Fix path (unverified until a cert exists)**: install an Apple Development cert (Xcode → Settings → Accounts → Manage Certificates → "+" — a free Apple ID works), then `JOURNAL_IOS_TEAM_ID=<10-char team> dune build @ios-app`. The script then signs with the identity (auto-detected, or set `JOURNAL_IOS_SIGN_IDENTITY`) plus `application-identifier` + `keychain-access-groups` entitlements instead of plain-adhoc. `JOURNAL_MACOS_TEAM_ID` already does the equivalent for the macOS app. - macOS app caveats: (a) `config/entitlements/macos-debug-profile.entitlements` historically shipped literal `$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)` — killed at exec (Killed:9, error 163). (b) Plain-adhoc macOS builds DO reach the sign-in dialog (generic-password reads to the app's own group work), but Amplify sign-in still fails -34018 for its access-group keychain ops; re-signing adhoc WITH `keychain-access-groups=[com.logseq.journal]` → AMFI spawn kill (error 163). No signing identity exists on the box → Cognito sign-in cannot complete on macOS either. - Startup wakeup storm (observed on iOS): ~25-30k `wakeup` callbacks in the first ~40s (~700/s) — OCaml cross-thread pump enqueue floods the MainActor task queue; the first `platformRequest` delivery waits behind the flood (~25s delay), then the system settles to 0% CPU. Not fatal, but adds startup latency and floods instrumented logs. - **Dead-end-on-all-failures signature (post-5676639)**: with `journal_ocaml_platform_failure` delivering Errors, OCaml's `managed_startup` ignores them (`Error _ -> Effect.ignore`), so `Reconcile_authenticated_user` never reaches the graph_service → `state.manager = None` → the root emits `timeline_page` with an INCOMPLETE tree → the `journal-chrome` extensions get fewer children than their guards require (`JournalChrome.View` renders `EmptyView` unless childIDs.count==3 for `feedback` / ==4 for `journal`) → whole subtree invisible → **blank screen that is a settled dead-end, not a hang** (idle process, no pending continuations). Diagnose by logging patch heads in `JournalRuntime.apply` and counting `insert-child` ops per extension id vs the guard counts in `JournalChrome.swift:91-123`. diff --git a/tool/build_journal_apple.sh b/tool/build_journal_apple.sh index d40c85c..4e7fd26 100755 --- a/tool/build_journal_apple.sh +++ b/tool/build_journal_apple.sh @@ -169,12 +169,35 @@ if [[ $platform == macos ]]; then "$app_dir" || true else # iOS bundles are flat; an empty Contents/ dir breaks install + codesign. - # The iOS 27 simulator refuses to exec any binary carrying an entitlements - # blob ("No such process"), so sign plain-adhoc without entitlements. mkdir -p "$app_dir" cp "$info_plist" "$app_dir/Info.plist" cp "$product_dir/JournalApp" "$app_dir/JournalApp" - codesign --force --sign - --timestamp=none "$app_dir" || true + # Signing has two modes: + # - With JOURNAL_IOS_TEAM_ID + a signing identity (JOURNAL_IOS_SIGN_IDENTITY, + # or the first identity security reports): sign with + # application-identifier + keychain-access-groups so keychain-backed flows + # (Amplify sign-in, localAccount) work. + # - Otherwise plain adhoc: iOS >=26.5 simulators refuse to exec adhoc binaries + # carrying an entitlements blob, and unentitled binaries get -34018 on every + # keychain read — launchable, but sign-in cannot complete. + bundle_id=$(plutil -extract CFBundleIdentifier raw "$info_plist") + sign_identity=${JOURNAL_IOS_SIGN_IDENTITY:-} + if [[ -z $sign_identity ]]; then + sign_identity=$(security find-identity -v -p codesigning 2>/dev/null | + sed -n 's/.*"\(.*\)"/\1/p' | head -1) + fi + if [[ -n ${JOURNAL_IOS_TEAM_ID:-} && -n $sign_identity ]]; then + ios_entitlements="$build_dir/ios-entitlements.plist" + cp "$entitlements_dir/ios-debug-profile.entitlements" "$ios_entitlements" + plutil -replace keychain-access-groups -json \ + "[\"$JOURNAL_IOS_TEAM_ID.$bundle_id\"]" "$ios_entitlements" + plutil -insert application-identifier -string \ + "$JOURNAL_IOS_TEAM_ID.$bundle_id" "$ios_entitlements" + codesign --force --sign "$sign_identity" --timestamp=none \ + --entitlements "$ios_entitlements" "$app_dir" + else + codesign --force --sign - --timestamp=none "$app_dir" || true + fi fi echo "$app_dir" From 6c80e5d077d40f98169ffe72e588ab654556beef Mon Sep 17 00:00:00 2001 From: zy C Date: Tue, 22 Sep 2026 23:58:24 -0700 Subject: [PATCH 24/40] Record DEVELOPMENT_TEAM=K378MFWK59 in iOS sim testing notes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .agents/skills/testing-ios-simulator/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/testing-ios-simulator/SKILL.md b/.agents/skills/testing-ios-simulator/SKILL.md index 93fc43e..7913ad8 100644 --- a/.agents/skills/testing-ios-simulator/SKILL.md +++ b/.agents/skills/testing-ios-simulator/SKILL.md @@ -25,7 +25,7 @@ description: How to build, sign, install, and drive the logseq_journal iOS app o - **Any `codesign --entitlements` blob makes the binary fail to exec on the iOS 27.0 sim** — `simctl launch` reports "No such process" / "Launchd job spawn failed". Verified with: the build script's expanded `keychain-access-groups=[com.logseq.journal]`, a `FAKETEAMID.`-prefixed variant with `application-identifier`, and even `get-task-allow` alone — ALL fail at exec. Only linker-signed (no entitlements) or plain-adhoc (`codesign --sign -` without `--entitlements`) binaries launch. - Launchable recipe: flat .app = `Info.plist` + raw swift product `swift/.build/arm64-apple-ios-simulator/debug/JournalApp`, NO codesign. iOS bundles are flat — a stray `Contents/` dir makes `simctl install` fail with "Missing bundle ID". - **KEYCHAIN TRAP (iOS ≥26.5 sim, confirmed on 26.5 AND 27.0): adhoc/unsigned sim binaries get `SecItemCopyMatching` → OSStatus -34018 (`errSecMissingEntitlement`) on EVERY keychain read — and no entitlement-bearing binary can exec.** The startup `localAccount` request throws inside `JournalLocalAccountBindingStore.load()` → `JournalApplicationPlatform.request()` returns nil → `deliverPlatformRequest` reports it via `journal_ocaml_platform_failure` (the request envelope round-trips back so OCaml resolves the pending continuation with `Error` — do not regress this: a nil return without the failure call leaves OCaml parked forever). NOTE: the failure path resolves continuations but the app STILL lands on a blank dead-end on sim (see "Dead-end-on-all-failures signature" below) — the sign-in UI is NOT reachable on unentitled sims. Sign-in itself also dies at Amplify keychain -34018 ("client has neither application-identifier nor keychain-access-groups entitlements"). -- **Fix path (unverified until a cert exists)**: install an Apple Development cert (Xcode → Settings → Accounts → Manage Certificates → "+" — a free Apple ID works), then `JOURNAL_IOS_TEAM_ID=<10-char team> dune build @ios-app`. The script then signs with the identity (auto-detected, or set `JOURNAL_IOS_SIGN_IDENTITY`) plus `application-identifier` + `keychain-access-groups` entitlements instead of plain-adhoc. `JOURNAL_MACOS_TEAM_ID` already does the equivalent for the macOS app. +- **Fix path (unverified until a cert exists)**: install an Apple Development cert (Xcode → Settings → Accounts → Manage Certificates → "+" — a free Apple ID works), then `JOURNAL_IOS_TEAM_ID=K378MFWK59 dune build @ios-app`. The script then signs with the identity (auto-detected, or set `JOURNAL_IOS_SIGN_IDENTITY`) plus `application-identifier` + `keychain-access-groups` entitlements instead of plain-adhoc. `JOURNAL_MACOS_TEAM_ID` already does the equivalent for the macOS app. The team's DEVELOPMENT_TEAM is `K378MFWK59` (confirmed 2026-09-23; a signing identity still needs to be installed on the box). - macOS app caveats: (a) `config/entitlements/macos-debug-profile.entitlements` historically shipped literal `$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)` — killed at exec (Killed:9, error 163). (b) Plain-adhoc macOS builds DO reach the sign-in dialog (generic-password reads to the app's own group work), but Amplify sign-in still fails -34018 for its access-group keychain ops; re-signing adhoc WITH `keychain-access-groups=[com.logseq.journal]` → AMFI spawn kill (error 163). No signing identity exists on the box → Cognito sign-in cannot complete on macOS either. - Startup wakeup storm (observed on iOS): ~25-30k `wakeup` callbacks in the first ~40s (~700/s) — OCaml cross-thread pump enqueue floods the MainActor task queue; the first `platformRequest` delivery waits behind the flood (~25s delay), then the system settles to 0% CPU. Not fatal, but adds startup latency and floods instrumented logs. - **Dead-end-on-all-failures signature (post-5676639)**: with `journal_ocaml_platform_failure` delivering Errors, OCaml's `managed_startup` ignores them (`Error _ -> Effect.ignore`), so `Reconcile_authenticated_user` never reaches the graph_service → `state.manager = None` → the root emits `timeline_page` with an INCOMPLETE tree → the `journal-chrome` extensions get fewer children than their guards require (`JournalChrome.View` renders `EmptyView` unless childIDs.count==3 for `feedback` / ==4 for `journal`) → whole subtree invisible → **blank screen that is a settled dead-end, not a hang** (idle process, no pending continuations). Diagnose by logging patch heads in `JournalRuntime.apply` and counting `insert-child` ops per extension id vs the guard counts in `JournalChrome.swift:91-123`. From b41b35dd74051daa09a1b01a1f42aee5c8f3c25d Mon Sep 17 00:00:00 2001 From: zy C Date: Wed, 23 Sep 2026 03:54:25 -0700 Subject: [PATCH 25/40] Fix iOS sim golden path: domain-lock wakeup, render-loop dedup, schema compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker->UI wakeup: replace the OCaml Condition waiter thread with a set_output_wakeup hook fired on the producing domain. The waiter deadlocked on iOS: while holding the shared output mutex it blocked re-acquiring its domain lock, which the UI thread holds inside CFRunLoop — download appeared stuck. Render loop: Lui_elements.dyn remounts the whole tree on every publish. Gate republish behind Signal.cutoff (==) and stop feeding mount-time echoes back into the model — Editor.apply_text_edit returns None for identical documents, the e2ee field and asset-settings refresh bail out when the new value is physically/equal to the old. Schema compliance (violations were swallowed by the C emit bridge and left blank screens): - Navigation_link list-item mounts its label as a child (schema requires text or children) and sets press-enabled so rows actually dispatch - toolbar role/variant hints limited to schema-representable values - back-button variant 'plain' -> 'ghost' - journal-media/journal-list declare standard children + extension children whitelists (OCaml/Swift/Flutter fingerprints in sync); JournalMedia now renders context.content - journal-chrome positional slots mount placeholder columns so the native child-index contract (feedback:3, journal:4) holds when a slot is empty iOS sim: embed entitlements via __TEXT,__entitlements at link time (the sim reads entitlements from the section, not the signature) so keychain works under adhoc signing. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/application.ml | 94 +++++++++------- app/journal_capture.ml | 25 +++-- app/journal_header.ml | 25 +++-- app/journal_lui_bridge.c | 113 +++++++++++++------- app/journal_lui_native.ml | 4 +- app/journal_view.ml | 92 +++++++++------- app/journal_view.mli | 1 + flutter/lib/journal_extension_registry.dart | 21 +++- logseq_db_worker/lui/journal_worker.ml | 25 ++++- logseq_db_worker/lui/journal_worker.mli | 9 ++ swift/JournalExtensions.swift | 4 +- swift/JournalMedia.swift | 1 + tool/build_journal_apple.sh | 64 +++++------ 13 files changed, 296 insertions(+), 182 deletions(-) diff --git a/app/application.ml b/app/application.ml index 714e434..98feaf0 100644 --- a/app/application.ml +++ b/app/application.ml @@ -1305,24 +1305,28 @@ module Presentation = struct ;; let list children = - V.Native_list.vertical - ~key:(Ui.Key.string "graph-list") - ~style:Inset - [ V.Native_list.section - ~key:(Ui.Key.string "graphs") - ~separator:Hidden - (List.mapi - (fun index child -> - let key = - match V.For_testing.test_id child with - | Some id -> Ui.Key.string id - | None -> Ui.Key.int index - in - V.Native_list.row ~key ~separator:Hidden child) - children) + V.Body.Vertical.create + [ V.Body.Vertical.fixed + (V.Native_list.vertical + ~key:(Ui.Key.string "graph-list") + ~style:Inset + [ V.Native_list.section + ~key:(Ui.Key.string "graphs") + ~separator:Hidden + (List.mapi + (fun index child -> + let key = + match V.For_testing.test_id child with + | Some id -> Ui.Key.string id + | None -> Ui.Key.int index + in + V.Native_list.row ~key ~separator:Hidden child) + children) + ]) ] - |> V.Body.Vertical.fill - |> fun content -> V.Body.Vertical.create [ content ] + |> V.Body.Private.to_widget + |> V.frame ~max_height:Fill + |> V.Body.static ;; let unavailable ~title ~symbol ~message ~actions = @@ -4134,13 +4138,18 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = (Journal_uploads.retry uploads operation)) | Some (Days settings) -> Effect.of_thunk (fun () -> - asset_settings := Some settings; - let current = !state_ref in - if current.graph_state.phase = Graph_open - then - refresh_assets - ~graph_generation:current.graph_state.generation - current.calendar)) + (* The extension re-emits its preference on every remount; only a + real change may refresh (each refresh republishes the model and + would loop under the full-remount view). *) + if !asset_settings <> Some settings + then ( + asset_settings := Some settings; + let current = !state_ref in + if current.graph_state.phase = Graph_open + then + refresh_assets + ~graph_generation:current.graph_state.generation + current.calendar))) | Ui.Event.Payload.Confirmation_response response -> set_state_and_effect (fun state -> match state.modal with @@ -4169,9 +4178,13 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = }) | _, Some { startup = { awaiting_e2ee_password = true; _ }; _ } | _, Some { startup = { failure = Some During_e2ee; _ }; _ } -> - { state with - e2ee_password = Journal_capture.apply_text_edit state.e2ee_password edit - } + let e2ee_password = Journal_capture.apply_text_edit state.e2ee_password edit in + (* A mount-time echo produces an identical capture; rebuilding the + record would republish the model and remount the field, which + echoes again — an unbounded render loop. *) + if e2ee_password == state.e2ee_password + then state + else { state with e2ee_password } | _ -> state) | Ui.Event.Payload.Text "capture-submit" -> (match snapshot.modal, snapshot.direct_capture with @@ -5118,7 +5131,12 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = dispatch timeline_scroll_completed detail_scroll_completed)) - model_signal + (* `dyn` remounts the whole tree on every publish (its key equality + is `fun _ _ -> false`). Reducers that return the identical record + must not republish — otherwise mount-time echoes (fresh text + fields, extension `.task` emits) loop forever: remount → echo → + publish → remount. *) + (Signal.cutoff ( == ) model_signal) ] in let os = @@ -5159,17 +5177,15 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = ignore (Worker.send client Graph_service.Get_graph_state : Worker.send_result); Worker.on_event client (fun event -> Journal_pump.enqueue pump (fun () -> Effect.run (handle_worker_event event))); - ignore - (Thread.create - (fun () -> - while !running do - (try Worker.For_testing.await_output client with - | _ -> ()); - if !running && not (Worker.For_testing.is_stopping client) - then Journal_pump.enqueue pump (fun () -> ()) - else running := false - done) - ()); + (* The worker fires the wakeup on its own domain whenever output lands; + enqueueing hops through the host wakeup onto the UI thread. An OCaml + Condition waiter thread would deadlock against the worker domain (the + woken waiter holds the output mutex while blocked on its domain lock + which the UI thread holds inside its runloop). *) + Worker.Private.set_output_wakeup client (fun () -> + Journal_pump.enqueue pump (fun () -> ())); + (* Kick once to drain any output emitted before the wakeup was installed. *) + Journal_pump.enqueue pump (fun () -> ()); ignore (Thread.create (fun () -> diff --git a/app/journal_capture.ml b/app/journal_capture.ml index 3dfa2a2..29f5945 100644 --- a/app/journal_capture.ml +++ b/app/journal_capture.ml @@ -77,15 +77,22 @@ module Editor = struct editor.document_revision > 0 then None - else - Some - { editor with - document_revision = - ID.Text_input.Document_revision.succ editor.document_revision - ; accepted_local_revision = edit.local_revision - ; update_mode = Ui.Text_editing.Ack - ; value = value_of_edit edit - } + else ( + let value = value_of_edit edit in + (* A mount-time echo reports the identical document; treating it as an + edit would republish the model and (under a full-remount view) spawn + a fresh node that echoes again — an unbounded render loop. *) + if Ui.Text_editing.Value.equal value editor.value + then None + else + Some + { editor with + document_revision = + ID.Text_input.Document_revision.succ editor.document_revision + ; accepted_local_revision = edit.local_revision + ; update_mode = Ui.Text_editing.Ack + ; value + }) ;; end diff --git a/app/journal_header.ml b/app/journal_header.ml index f6acb7a..e24013a 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -36,7 +36,10 @@ let feedback ~key ~top ~visible ~compact ~expanded body = ~props: (`Assoc [ "mode", `String "feedback"; "top", `Bool top; "visible", `Bool visible ]) ~on_event:(fun _ -> ()) - ~children:[ V.Body.Private.to_widget body; compact; expanded ] + (* Chrome slots are positional on the native side — absent slots must + still mount a (zero-size) node or the host's index lookup shifts. *) + ~children: + [ V.Body.Private.to_widget body; V.column [ compact ]; V.column [ expanded ] ] () |> V.Body.static ;; @@ -270,16 +273,20 @@ let view ; "error", `Bool (Option.is_some on_error_info) ]) ~on_event:(fun _ -> ()) + (* Chrome slots are positional on the native side — absent slots must + still mount a (zero-size) node or the host's index lookup shifts. *) ~children: [ V.Body.Private.to_widget body - ; (if platform = "ios" then account else V.empty ()) - ; (if platform = "ios" then error else V.empty ()) - ; (if sync_phase = Some Graph_service.Connecting - then - V.progress ~style:Circular () - |> V.semantics ~properties:(Ui.Semantics.create ~label:"Connecting" ()) - |> test_id "journal-header-sync-progress" - else V.empty ()) + ; (if platform = "ios" then V.column [ account ] else V.column []) + ; (if platform = "ios" then V.column [ error ] else V.column []) + ; V.column + [ (if sync_phase = Some Graph_service.Connecting + then + V.progress ~style:Circular () + |> V.semantics ~properties:(Ui.Semantics.create ~label:"Connecting" ()) + |> test_id "journal-header-sync-progress" + else V.empty ()) + ] ] () |> test_id "journal-floating-chrome" diff --git a/app/journal_lui_bridge.c b/app/journal_lui_bridge.c index 5a58c1c..61d1d7d 100644 --- a/app/journal_lui_bridge.c +++ b/app/journal_lui_bridge.c @@ -5,6 +5,7 @@ #include #include #include +#include #include #if defined(_WIN32) @@ -46,15 +47,19 @@ LUI_EXPORT int32_t lui_ocaml_start( int32_t host_code, const char *payload_data, int32_t payload_length) { + int32_t accepted; patch_callback = callback; if (!runtime_started) { char *arguments[] = {"journal_lui_ocaml", NULL}; caml_startup(arguments); runtime_started = 1; + } else { + caml_leave_blocking_section(); } const value *initialize = caml_named_value("lui_ocaml_init"); if (initialize == NULL) { + caml_enter_blocking_section(); return 0; } CAMLparam0(); @@ -65,16 +70,21 @@ LUI_EXPORT int32_t lui_ocaml_start( Val_long(platform_code), Val_long(host_code), payload_value); - int32_t accepted = emit_patch(result); - CAMLreturnT(int32_t, accepted); + accepted = emit_patch(result); + CAMLdrop; + caml_enter_blocking_section(); + return accepted; } static int dispatch_long(const char *name, int64_t node) { + int result = 0; + caml_leave_blocking_section(); const value *dispatch = caml_named_value(name); - if (dispatch == NULL) { - return 0; + if (dispatch != NULL) { + result = emit_patch(caml_callback_exn(*dispatch, Val_long(node))); } - return emit_patch(caml_callback_exn(*dispatch, Val_long(node))); + caml_enter_blocking_section(); + return result; } LUI_EXPORT int32_t lui_ocaml_appear(int64_t node) { @@ -90,12 +100,15 @@ LUI_EXPORT int32_t lui_ocaml_long_press(int64_t node) { } LUI_EXPORT int32_t lui_ocaml_text_changed(int64_t node, const char *text) { + int result = 0; + caml_leave_blocking_section(); const value *dispatch = caml_named_value("lui_ocaml_text_changed"); - if (dispatch == NULL) { - return 0; + if (dispatch != NULL) { + result = emit_patch(caml_callback2_exn( + *dispatch, Val_long(node), caml_copy_string(text))); } - return emit_patch(caml_callback2_exn( - *dispatch, Val_long(node), caml_copy_string(text))); + caml_enter_blocking_section(); + return result; } LUI_EXPORT int32_t lui_ocaml_submit(int64_t node) { @@ -111,12 +124,15 @@ LUI_EXPORT int32_t lui_ocaml_double_press(int64_t node) { } LUI_EXPORT int32_t lui_ocaml_toggle_changed(int64_t node, int32_t checked) { + int result = 0; + caml_leave_blocking_section(); const value *dispatch = caml_named_value("lui_ocaml_toggle_changed"); - if (dispatch == NULL) { - return 0; + if (dispatch != NULL) { + result = emit_patch(caml_callback2_exn( + *dispatch, Val_long(node), Val_bool(checked))); } - return emit_patch(caml_callback2_exn( - *dispatch, Val_long(node), Val_bool(checked))); + caml_enter_blocking_section(); + return result; } LUI_EXPORT int32_t lui_ocaml_radio_changed(int64_t node) { @@ -124,32 +140,40 @@ LUI_EXPORT int32_t lui_ocaml_radio_changed(int64_t node) { } LUI_EXPORT int32_t lui_ocaml_slider_changed(int64_t node, double fraction) { + int result = 0; + caml_leave_blocking_section(); const value *dispatch = caml_named_value("lui_ocaml_slider_changed"); - if (dispatch == NULL) { - return 0; + if (dispatch != NULL) { + result = emit_patch(caml_callback2_exn( + *dispatch, Val_long(node), caml_copy_double(fraction))); } - return emit_patch(caml_callback2_exn( - *dispatch, Val_long(node), caml_copy_double(fraction))); + caml_enter_blocking_section(); + return result; } LUI_EXPORT int32_t lui_ocaml_stop(void) { + int result = 0; + caml_leave_blocking_section(); const value *dispose = caml_named_value("lui_ocaml_dispose"); - if (dispose == NULL) { - return 0; + if (dispose != NULL) { + result = emit_patch(caml_callback_exn(*dispose, Val_unit)); } - return emit_patch(caml_callback_exn(*dispose, Val_unit)); + caml_enter_blocking_section(); + return result; } LUI_EXPORT int64_t lui_ocaml_root_node(void) { + int64_t node = 0; + caml_leave_blocking_section(); const value *root = caml_named_value("lui_ocaml_root_node"); - if (root == NULL) { - return 0; + if (root != NULL) { + value result = caml_callback_exn(*root, Val_unit); + if (!Is_exception_result(result)) { + node = (int64_t)Long_val(result); + } } - value result = caml_callback_exn(*root, Val_unit); - if (Is_exception_result(result)) { - return 0; - } - return (int64_t)Long_val(result); + caml_enter_blocking_section(); + return node; } /* ---- Journal-specific entries ---- */ @@ -160,37 +184,44 @@ LUI_EXPORT int32_t journal_ocaml_extension_event( int64_t node, const char *name, const char *payload) { + int result = 0; + caml_leave_blocking_section(); const value *dispatch = caml_named_value("journal_ocaml_extension_event"); - if (dispatch == NULL) { - return 0; + if (dispatch != NULL) { + result = emit_patch(caml_callback3_exn( + *dispatch, + Val_long(node), + caml_copy_string(name), + caml_copy_string(payload))); } - return emit_patch(caml_callback3_exn( - *dispatch, - Val_long(node), - caml_copy_string(name), - caml_copy_string(payload))); + caml_enter_blocking_section(); + return result; } /* Drains the cross-thread work queue on the app thread and flushes pending patches. The host schedules this on the UI thread when the wakeup callback fires. */ LUI_EXPORT int32_t journal_ocaml_pump(void) { + int result = 0; + caml_leave_blocking_section(); const value *pump = caml_named_value("journal_ocaml_pump"); - if (pump == NULL) { - return 0; + if (pump != NULL) { + result = emit_patch(caml_callback_exn(*pump, Val_unit)); } - return emit_patch(caml_callback_exn(*pump, Val_unit)); + caml_enter_blocking_section(); + return result; } /* Host -> OCaml LJP2 envelopes (binary safe). */ static void deliver_platform(const char *name, const char *data, int32_t length) { + caml_leave_blocking_section(); const value *handler = caml_named_value(name); - if (handler == NULL) { - return; + if (handler != NULL) { + value payload = copy_bytes(data, length); + caml_callback_exn(*handler, payload); } - value payload = copy_bytes(data, length); - caml_callback_exn(*handler, payload); + caml_enter_blocking_section(); } LUI_EXPORT void journal_ocaml_platform_event(const char *data, diff --git a/app/journal_lui_native.ml b/app/journal_lui_native.ml index 684bac5..3c4996b 100644 --- a/app/journal_lui_native.ml +++ b/app/journal_lui_native.ml @@ -61,7 +61,7 @@ let registry = (component media_identifier all_host_profiles - false + true children [ payload_property ] [ event_schema ]); @@ -79,7 +79,7 @@ let registry = (component list_identifier all_host_profiles - false + true children [ payload_property ] [ event_schema ]); diff --git a/app/journal_view.ml b/app/journal_view.ml index af2837a..59f2a86 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -463,8 +463,8 @@ module View = struct let variant = function | Destructive -> "destructive" - | Cancel -> "cancel" - | Normal -> "primary" + | Cancel -> "secondary" + | Normal -> "default" ;; end @@ -477,11 +477,11 @@ module View = struct | Button let variant = function - | Plain -> "plain" - | Bordered -> "bordered" - | Prominent -> "prominent" - | Button -> "button" - | Automatic -> "automatic" + | Plain -> "ghost" + | Bordered -> "outline" + | Prominent -> "primary" + | Button -> "default" + | Automatic -> "default" ;; end @@ -852,7 +852,7 @@ module View = struct let secure_field ?key - ~label + ~label:_ ?(prompt = "") ?keyboard:_ ?submit_label:_ @@ -877,7 +877,6 @@ module View = struct let node = Lui_ui.secure_field context in Lui_ui.text_property context node (Text_editing.Value.text value); Lui_ui.placeholder context node prompt; - Lui_ui.string_property context node Lui_protocol.TitleValue label; if not enabled then Lui_ui.disabled context node true; if autofocus then Lui_ui.bool_property context node Lui_protocol.Autofocus true; let local_revision = ref accepted_local_revision in @@ -1047,6 +1046,8 @@ module View = struct set of control children and no extension children, while items here include menus and extension nodes. *) let node = Lui_ui.row context in + Lui_ui.gap context node 12; + Lui_ui.padding_horizontal context node 16; (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); @@ -1055,36 +1056,31 @@ module View = struct let child = item.content in let mounted = child.mount context (Some node) in Lui_ui.key context mounted item.item_key; - let hint property value = - (* Placement/spacing hints only apply to node kinds that accept - them; menu items and extension nodes reject these props. *) + (match item.placement with + | Some Principal -> + if + Lui_protocol.property_supported + (Lui_ui.node_kind context mounted) + Lui_protocol.GrowValue + then Lui_ui.grow context mounted 1.0 + | _ -> ()); + (* Toolbar placement/spacing/grouping have no representation in + the lui schema: `role` accepts only treeitem/navigation/ + navigation-heading and `variant` only the button vocabulary. + The only representable hint is a navigation placement. *) + match item.placement with + | Some Navigation -> if Lui_protocol.property_supported (Lui_ui.node_kind context mounted) - property - then Lui_ui.string_property context mounted property value - in - (match item.placement with - | Some placement -> - hint - Lui_protocol.RoleValue - (match placement with - | Automatic -> "automatic" - | Principal -> "principal" - | Navigation -> "navigation" - | Primary_action -> "primary_action" - | Secondary_action -> "secondary_action" - | Status -> "status" - | Confirmation_action -> "confirmation_action" - | Cancellation_action -> "cancellation_action" - | Destructive_action -> "destructive_action" - | Bottom_bar -> "bottom_bar") - | None -> ()); - (match item.spacing with - | Some Fixed -> hint Lui_protocol.VariantValue "fixed_spacing" - | Some Flexible -> hint Lui_protocol.VariantValue "flexible_spacing" - | None -> ()); - if item.is_group then hint Lui_protocol.VariantValue "item_group") + Lui_protocol.RoleValue + then + Lui_ui.string_property + context + mounted + Lui_protocol.RoleValue + "navigation" + | _ -> ()) items; node) ;; @@ -1123,7 +1119,11 @@ module View = struct type child = t let fixed t = t - let fill ?weight:_ t = t + + let fill ?(weight = 1.) t = + modify (fun context node -> Lui_ui.grow context node weight) t + ;; + let create ?key children = column ?key children end @@ -1131,7 +1131,11 @@ module View = struct type child = t let fixed t = t - let fill ?weight:_ t = t + + let fill ?(weight = 1.) t = + modify (fun context node -> Lui_ui.grow context node weight) t + ;; + let create ?key children = row ?key children end @@ -1742,7 +1746,10 @@ module View = struct element ?key (fun context parent -> let node = Lui_ui.list_item context in if not enabled then Lui_ui.disabled context node true; - Option.iter (set_leaf_label context node) label.label_content; + Lui_ui.bool_property context node Lui_protocol.PressEnabled enabled; + (* A list-item must carry text or children; mount the label as the + item content so composite labels render too. *) + ignore (label.mount context (Some node)); Lui_ui.on_event context node (fun event -> if is_press event then invoke on_activate Event.Payload.Unit); (match parent with @@ -1773,7 +1780,7 @@ module View = struct element (fun context parent -> let node = Lui_ui.button context in Lui_ui.text_property context node "Back"; - Lui_ui.string_property context node Lui_protocol.VariantValue "plain"; + Lui_ui.string_property context node Lui_protocol.VariantValue "ghost"; Lui_ui.accessibility_identifier context node "journal-nav-back"; Lui_ui.on_event context node (fun event -> if is_press event @@ -1806,6 +1813,7 @@ module View = struct ?(interactive_dismiss = true) ?sizing:_ ?detents:_ + ?(title = "") ~content base = @@ -1818,6 +1826,10 @@ module View = struct if presented then ( let sheet = Lui_ui.sheet context in + Lui_ui.text_property + context + sheet + (if String.equal title "" then "Sheet" else title); Lui_ui.append context node sheet; if interactive_dismiss then diff --git a/app/journal_view.mli b/app/journal_view.mli index c474abb..87e1f3a 100644 --- a/app/journal_view.mli +++ b/app/journal_view.mli @@ -783,6 +783,7 @@ module View : sig -> ?interactive_dismiss:bool -> ?sizing:sizing -> ?detents:detent list + -> ?title:string -> content:t -> t -> t diff --git a/flutter/lib/journal_extension_registry.dart b/flutter/lib/journal_extension_registry.dart index 55815c9..d2e5cdb 100644 --- a/flutter/lib/journal_extension_registry.dart +++ b/flutter/lib/journal_extension_registry.dart @@ -45,7 +45,9 @@ LUIFlutterExtensionRegistry journalExtensionRegistry( identifier: 'journal-chrome', fingerprint: 'lui-extension-v1|14:journal-chrome|profiles:ios/swiftui,macos/swiftui' - '|standard-children:1|children:|properties:' + '|standard-children:1|children:20:journal-asset-import,' + '22:journal-asset-settings,14:journal-chrome,12:journal-list,' + '13:journal-media|properties:' '7:payload:string:required:none|events:', acceptsStandardChildren: true, properties: const [_payloadProperty], @@ -58,7 +60,9 @@ LUIFlutterExtensionRegistry journalExtensionRegistry( fingerprint: 'lui-extension-v1|20:journal-asset-import|profiles:' 'android/flutter,ios/flutter,ios/swiftui,macos/flutter,macos/swiftui' - '|standard-children:0|children:|properties:' + '|standard-children:0|children:20:journal-asset-import,' + '22:journal-asset-settings,14:journal-chrome,12:journal-list,' + '13:journal-media|properties:' '7:payload:string:required:none|events:' '5:event[2:id:int:required,7:payload:string:required]', properties: const [_payloadProperty], @@ -72,7 +76,9 @@ LUIFlutterExtensionRegistry journalExtensionRegistry( fingerprint: 'lui-extension-v1|13:journal-media|profiles:' 'android/flutter,ios/flutter,ios/swiftui,macos/flutter,macos/swiftui' - '|standard-children:0|children:|properties:' + '|standard-children:1|children:20:journal-asset-import,' + '22:journal-asset-settings,14:journal-chrome,12:journal-list,' + '13:journal-media|properties:' '7:payload:string:required:none|events:' '5:event[2:id:int:required,7:payload:string:required]', properties: const [_payloadProperty], @@ -86,7 +92,9 @@ LUIFlutterExtensionRegistry journalExtensionRegistry( fingerprint: 'lui-extension-v1|22:journal-asset-settings|profiles:' 'android/flutter,ios/flutter,ios/swiftui,macos/flutter,macos/swiftui' - '|standard-children:1|children:|properties:' + '|standard-children:1|children:20:journal-asset-import,' + '22:journal-asset-settings,14:journal-chrome,12:journal-list,' + '13:journal-media|properties:' '7:payload:string:required:none|events:' '5:event[2:id:int:required,7:payload:string:required]', acceptsStandardChildren: true, @@ -101,9 +109,12 @@ LUIFlutterExtensionRegistry journalExtensionRegistry( fingerprint: 'lui-extension-v1|12:journal-list|profiles:' 'android/flutter,ios/flutter,ios/swiftui,macos/flutter,macos/swiftui' - '|standard-children:0|children:|properties:' + '|standard-children:1|children:20:journal-asset-import,' + '22:journal-asset-settings,14:journal-chrome,12:journal-list,' + '13:journal-media|properties:' '7:payload:string:required:none|events:' '5:event[2:id:int:required,7:payload:string:required]', + acceptsStandardChildren: true, properties: const [_payloadProperty], events: const [_journalEvent], builder: (context) => buildJournalList(context, renderChild), diff --git a/logseq_db_worker/lui/journal_worker.ml b/logseq_db_worker/lui/journal_worker.ml index 89c7f1a..5bf444d 100644 --- a/logseq_db_worker/lui/journal_worker.ml +++ b/logseq_db_worker/lui/journal_worker.ml @@ -167,6 +167,7 @@ type ('request, 'response, 'push) client = ; terminal_requests : (ID.Worker.request_id, unit) Hashtbl.t ; output_mutex : Mutex.t ; output_condition : Condition.t + ; output_wakeup : (unit -> unit) Atomic.t ; pending_output_count : int Atomic.t ; stopped_mutex : Mutex.t ; stopped_condition : Condition.t @@ -273,6 +274,7 @@ let prepare ~runtime_epoch ~worker_generation service config = ; terminal_requests = Hashtbl.create request_capacity ; output_mutex = Mutex.create () ; output_condition = Condition.create () + ; output_wakeup = Atomic.make (fun () -> ()) ; pending_output_count = Atomic.make 0 ; stopped_mutex = Mutex.create () ; stopped_condition = Condition.create () @@ -462,6 +464,16 @@ let forget_direct_terminal client request_id = let on_event client handler = client.subscribers <- client.subscribers @ [ handler ] +(* [output_wakeup] runs outside the output lock on whichever domain produced + the event. Cross-thread delivery must not rely on an OCaml [Condition] + waited on by a systhread of a different domain: on wake the waiter re-takes + the shared mutex and then blocks re-acquiring its own domain lock, which a + native thread (the UI thread parked in its runloop) may hold indefinitely — + a three-way deadlock observed on iOS. The wakeup hook hops straight to the + host thread instead. *) +let notify_output client = (Atomic.get client.output_wakeup) () +let set_output_wakeup client wakeup = Atomic.set client.output_wakeup wakeup + let publish_response client request_id outcome = with_output_lock client (fun () -> Journal_bounded_mailbox.Reserved.publish @@ -472,7 +484,8 @@ let publish_response client request_id outcome = ; request_id ; outcome }); - increment_pending_output_locked client) + increment_pending_output_locked client); + notify_output client ;; let set_terminal_event client error = @@ -486,7 +499,8 @@ let set_terminal_event client error = ; worker_generation = client.worker_generation ; error }); - increment_pending_output_locked client)) + increment_pending_output_locked client)); + notify_output client ;; let mark_stopped client status = @@ -615,7 +629,8 @@ let run_direct_session with | `Added -> increment_pending_output_locked client | `Replaced -> Condition.broadcast client.output_condition - | `Full -> failwith "Worker push mailbox invariant failed") + | `Full -> failwith "Worker push mailbox invariant failed"); + notify_output client in let mono_clock = Journal_worker_eio_backend.mono_clock environment in let network = Journal_worker_eio_backend.net environment in @@ -1095,7 +1110,8 @@ let inject_push client ~runtime_epoch ~worker_generation ~push_sequence ~topic p with_output_lock client (fun () -> match Journal_bounded_mailbox.Fifo.try_push client.injected event with | `Ok -> increment_pending_output_locked client - | `Full | `Closed -> failwith "Worker test injection mailbox is unavailable") + | `Full | `Closed -> failwith "Worker test injection mailbox is unavailable"); + notify_output client ;; module Private = struct @@ -1136,6 +1152,7 @@ module Private = struct let await_stopped_packed = await_stopped_packed let fail_unrecoverable = fail_unrecoverable let deliver = deliver + let set_output_wakeup = set_output_wakeup end module For_testing = struct diff --git a/logseq_db_worker/lui/journal_worker.mli b/logseq_db_worker/lui/journal_worker.mli index 6f14d74..82748fc 100644 --- a/logseq_db_worker/lui/journal_worker.mli +++ b/logseq_db_worker/lui/journal_worker.mli @@ -163,6 +163,15 @@ module Private : sig in drain order. Must be called on the application thread (the lui pump entry point), never from worker fibers. *) val deliver : ('request, 'response, 'push) client -> max_events:int -> unit + + (** Registers the callback invoked on the producing domain whenever new + output (responses, pushes, terminal events) lands in the client's + mailboxes. Install a thread-safe thunk that hops to the application + thread and calls {!deliver}; do not wait on OCaml + [Mutex]/[Condition] from an application-domain systhread here — a + waiter that holds a shared mutex while blocked re-acquiring its own + domain lock deadlocks against producer domains. *) + val set_output_wakeup : ('request, 'response, 'push) client -> (unit -> unit) -> unit end module For_testing : sig diff --git a/swift/JournalExtensions.swift b/swift/JournalExtensions.swift index 7d81c76..9357d76 100644 --- a/swift/JournalExtensions.swift +++ b/swift/JournalExtensions.swift @@ -150,7 +150,7 @@ enum JournalExtensionFingerprint { static func mediaExtension() -> LUIAppleExtension { journalExtension(identifier: "journal-media", profiles: allHostProfiles, - standardChildren: false, events: true) { context in + standardChildren: true, events: true) { context in AnyView(JournalMedia.View(context: context)) } } @@ -164,7 +164,7 @@ enum JournalExtensionFingerprint { static func listExtension() -> LUIAppleExtension { journalExtension(identifier: "journal-list", profiles: allHostProfiles, - standardChildren: false, events: true) { context in + standardChildren: true, events: true) { context in AnyView(JournalList.View(context: context)) } } diff --git a/swift/JournalMedia.swift b/swift/JournalMedia.swift index eb5cc90..c7a5c63 100644 --- a/swift/JournalMedia.swift +++ b/swift/JournalMedia.swift @@ -117,6 +117,7 @@ private actor JournalMediaDecoder { var body: some SwiftUI.View { VStack(alignment: .leading, spacing: 8) { + context.content if let properties { if properties.editable { HStack(alignment: .top) { diff --git a/tool/build_journal_apple.sh b/tool/build_journal_apple.sh index 4e7fd26..ffe2cd0 100755 --- a/tool/build_journal_apple.sh +++ b/tool/build_journal_apple.sh @@ -138,6 +138,22 @@ swift_args=( ) if [[ $platform == ios-simulator ]]; then swift_args+=(--triple "$triple" --sdk "$sdk_path") + # Simulator binaries exec on the host macOS kernel, so entitlements baked + # into the code signature are validated as macOS entitlements and the exec + # is killed (error 163) no matter what identity signed them. The sim reads + # its entitlements from the __TEXT,__entitlements section instead — embed + # them at link time like Xcode does, then sign adhoc. This is what makes + # keychain (Amplify sign-in, localAccount) work on the sim. + ios_entitlements="$build_dir/ios-sim-entitlements.plist" + cp "$entitlements_dir/ios-debug-profile.entitlements" "$ios_entitlements" + bundle_id=$(plutil -extract CFBundleIdentifier raw "$info_plist") + ios_team_id=${JOURNAL_IOS_TEAM_ID:-K378MFWK59} + plutil -replace keychain-access-groups -json \ + "[\"$ios_team_id.$bundle_id\"]" "$ios_entitlements" + plutil -insert application-identifier -string \ + "$ios_team_id.$bundle_id" "$ios_entitlements" + swift_args+=(-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements + -Xlinker "$ios_entitlements") fi JOURNAL_LUI_PACKAGE_PATH=${JOURNAL_LUI_PACKAGE_PATH:-$repo_root/../lui/platform/apple} \ @@ -153,51 +169,37 @@ if [[ $platform == macos ]]; then mkdir -p "$app_dir/Contents/MacOS" "$app_dir/Contents/Resources" cp "$info_plist" "$app_dir/Contents/Info.plist" cp "$product_dir/JournalApp" "$app_dir/Contents/MacOS/JournalApp" - # Adhoc signing: keychain-access-groups needs a real team id; without one the - # group is invalid and AMFI kills the binary, so drop the key for local builds. + # keychain-access-groups needs a real team id; without one the group is + # invalid and AMFI kills the binary, so drop the key for local builds. With a + # team id, sign with the Apple Development identity so the entitlement is + # honored (Amplify/keychain then work on macOS too). macos_entitlements="$build_dir/macos-entitlements.plist" cp "$entitlements_dir/macos-debug-profile.entitlements" "$macos_entitlements" bundle_id=$(plutil -extract CFBundleIdentifier raw "$info_plist") if [[ -n ${JOURNAL_MACOS_TEAM_ID:-} ]]; then plutil -replace keychain-access-groups -json \ "[\"$JOURNAL_MACOS_TEAM_ID.$bundle_id\"]" "$macos_entitlements" + # macOS rejects the iOS-style `application-identifier` entitlement key; + # the application id is implied by the signature + keychain-access-groups. + plutil -remove application-identifier "$macos_entitlements" 2>/dev/null || true + codesign --force --sign "${JOURNAL_MACOS_SIGN_IDENTITY:-Apple Development}" \ + --timestamp=none --entitlements "$macos_entitlements" "$app_dir" || true else plutil -remove keychain-access-groups "$macos_entitlements" + codesign --force --sign - --timestamp=none \ + --entitlements "$macos_entitlements" \ + "$app_dir" || true fi - codesign --force --sign - --timestamp=none \ - --entitlements "$macos_entitlements" \ - "$app_dir" || true else # iOS bundles are flat; an empty Contents/ dir breaks install + codesign. mkdir -p "$app_dir" cp "$info_plist" "$app_dir/Info.plist" cp "$product_dir/JournalApp" "$app_dir/JournalApp" - # Signing has two modes: - # - With JOURNAL_IOS_TEAM_ID + a signing identity (JOURNAL_IOS_SIGN_IDENTITY, - # or the first identity security reports): sign with - # application-identifier + keychain-access-groups so keychain-backed flows - # (Amplify sign-in, localAccount) work. - # - Otherwise plain adhoc: iOS >=26.5 simulators refuse to exec adhoc binaries - # carrying an entitlements blob, and unentitled binaries get -34018 on every - # keychain read — launchable, but sign-in cannot complete. - bundle_id=$(plutil -extract CFBundleIdentifier raw "$info_plist") - sign_identity=${JOURNAL_IOS_SIGN_IDENTITY:-} - if [[ -z $sign_identity ]]; then - sign_identity=$(security find-identity -v -p codesigning 2>/dev/null | - sed -n 's/.*"\(.*\)"/\1/p' | head -1) - fi - if [[ -n ${JOURNAL_IOS_TEAM_ID:-} && -n $sign_identity ]]; then - ios_entitlements="$build_dir/ios-entitlements.plist" - cp "$entitlements_dir/ios-debug-profile.entitlements" "$ios_entitlements" - plutil -replace keychain-access-groups -json \ - "[\"$JOURNAL_IOS_TEAM_ID.$bundle_id\"]" "$ios_entitlements" - plutil -insert application-identifier -string \ - "$JOURNAL_IOS_TEAM_ID.$bundle_id" "$ios_entitlements" - codesign --force --sign "$sign_identity" --timestamp=none \ - --entitlements "$ios_entitlements" "$app_dir" - else - codesign --force --sign - --timestamp=none "$app_dir" || true - fi + # Plain adhoc signature — the sim's entitlements already live in the + # __TEXT,__entitlements section embedded at link time above. Do NOT pass + # --entitlements here: a signature-level entitlements blob is validated by + # the host kernel as macOS entitlements and the exec is killed. + codesign --force --sign - --timestamp=none "$app_dir" || true fi echo "$app_dir" From 716067de20d2c162c726ebb3ec14949e677e3d77 Mon Sep 17 00:00:00 2001 From: zy C Date: Wed, 23 Sep 2026 03:55:10 -0700 Subject: [PATCH 26/40] =?UTF-8?q?docs:=20update=20iOS=20sim=20testing=20sk?= =?UTF-8?q?ill=20=E2=80=94=20embedded=20entitlements=20+=20LUI=20runtime?= =?UTF-8?q?=20gotchas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .agents/skills/testing-ios-simulator/SKILL.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.agents/skills/testing-ios-simulator/SKILL.md b/.agents/skills/testing-ios-simulator/SKILL.md index 7913ad8..a1a1760 100644 --- a/.agents/skills/testing-ios-simulator/SKILL.md +++ b/.agents/skills/testing-ios-simulator/SKILL.md @@ -24,12 +24,24 @@ description: How to build, sign, install, and drive the logseq_journal iOS app o - **Any `codesign --entitlements` blob makes the binary fail to exec on the iOS 27.0 sim** — `simctl launch` reports "No such process" / "Launchd job spawn failed". Verified with: the build script's expanded `keychain-access-groups=[com.logseq.journal]`, a `FAKETEAMID.`-prefixed variant with `application-identifier`, and even `get-task-allow` alone — ALL fail at exec. Only linker-signed (no entitlements) or plain-adhoc (`codesign --sign -` without `--entitlements`) binaries launch. - Launchable recipe: flat .app = `Info.plist` + raw swift product `swift/.build/arm64-apple-ios-simulator/debug/JournalApp`, NO codesign. iOS bundles are flat — a stray `Contents/` dir makes `simctl install` fail with "Missing bundle ID". -- **KEYCHAIN TRAP (iOS ≥26.5 sim, confirmed on 26.5 AND 27.0): adhoc/unsigned sim binaries get `SecItemCopyMatching` → OSStatus -34018 (`errSecMissingEntitlement`) on EVERY keychain read — and no entitlement-bearing binary can exec.** The startup `localAccount` request throws inside `JournalLocalAccountBindingStore.load()` → `JournalApplicationPlatform.request()` returns nil → `deliverPlatformRequest` reports it via `journal_ocaml_platform_failure` (the request envelope round-trips back so OCaml resolves the pending continuation with `Error` — do not regress this: a nil return without the failure call leaves OCaml parked forever). NOTE: the failure path resolves continuations but the app STILL lands on a blank dead-end on sim (see "Dead-end-on-all-failures signature" below) — the sign-in UI is NOT reachable on unentitled sims. Sign-in itself also dies at Amplify keychain -34018 ("client has neither application-identifier nor keychain-access-groups entitlements"). -- **Fix path (unverified until a cert exists)**: install an Apple Development cert (Xcode → Settings → Accounts → Manage Certificates → "+" — a free Apple ID works), then `JOURNAL_IOS_TEAM_ID=K378MFWK59 dune build @ios-app`. The script then signs with the identity (auto-detected, or set `JOURNAL_IOS_SIGN_IDENTITY`) plus `application-identifier` + `keychain-access-groups` entitlements instead of plain-adhoc. `JOURNAL_MACOS_TEAM_ID` already does the equivalent for the macOS app. The team's DEVELOPMENT_TEAM is `K378MFWK59` (confirmed 2026-09-23; a signing identity still needs to be installed on the box). +- **SOLVED — embedded entitlements**: the sim reads entitlements from the `__TEXT,__entitlements` section, NOT the code signature (signature-carried entitlements are validated as *macOS* entitlements and get the exec killed, error 163 — that was the old "no entitlement-bearing binary can exec" trap). `tool/build_journal_apple.sh ios-simulator` now passes `-Xlinker -sectcreate -Xlinker __TEXT -Xlinker __entitlements` (like Xcode) with `application-identifier` + `keychain-access-groups`, then signs plain-adhoc. Result: `dune build @ios-app` produces a launchable binary whose keychain WORKS — Amplify sign-in, `localAccount`, E2EE graph-key storage all succeed with NO Apple certificate. DEVELOPMENT_TEAM default baked in is `K378MFWK59` (override with `JOURNAL_IOS_TEAM_ID`; the value is also baked into the section so it does not need a matching signature). +- A signing identity still helps for real-device runs: an Apple Development cert was installed for dev@logseq.com (Logseq Inc., team K378MFWK59) — `security find-identity` lists it. - macOS app caveats: (a) `config/entitlements/macos-debug-profile.entitlements` historically shipped literal `$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)` — killed at exec (Killed:9, error 163). (b) Plain-adhoc macOS builds DO reach the sign-in dialog (generic-password reads to the app's own group work), but Amplify sign-in still fails -34018 for its access-group keychain ops; re-signing adhoc WITH `keychain-access-groups=[com.logseq.journal]` → AMFI spawn kill (error 163). No signing identity exists on the box → Cognito sign-in cannot complete on macOS either. - Startup wakeup storm (observed on iOS): ~25-30k `wakeup` callbacks in the first ~40s (~700/s) — OCaml cross-thread pump enqueue floods the MainActor task queue; the first `platformRequest` delivery waits behind the flood (~25s delay), then the system settles to 0% CPU. Not fatal, but adds startup latency and floods instrumented logs. - **Dead-end-on-all-failures signature (post-5676639)**: with `journal_ocaml_platform_failure` delivering Errors, OCaml's `managed_startup` ignores them (`Error _ -> Effect.ignore`), so `Reconcile_authenticated_user` never reaches the graph_service → `state.manager = None` → the root emits `timeline_page` with an INCOMPLETE tree → the `journal-chrome` extensions get fewer children than their guards require (`JournalChrome.View` renders `EmptyView` unless childIDs.count==3 for `feedback` / ==4 for `journal`) → whole subtree invisible → **blank screen that is a settled dead-end, not a hang** (idle process, no pending continuations). Diagnose by logging patch heads in `JournalRuntime.apply` and counting `insert-child` ops per extension id vs the guard counts in `JournalChrome.swift:91-123`. +## LUI runtime gotchas (found 2026-09-23, golden path verified) + +- **Worker→UI wakeup deadlock (iOS)**: an OCaml `Condition` waiter thread calling `deliver` deadlocks — on wake it holds the shared output mutex while re-acquiring its domain lock, which the UI thread holds parked in CFRunLoop. Fix is `Worker.Private.set_output_wakeup`: the wakeup hook runs on the *producing* domain and hops straight onto the UI pump. Same class of bug as the earlier STW starvation — never block a host-domain systhread inside the worker mailbox path. +- **Full-remount render loop**: `Lui_elements.dyn`'s switch uses `equal=(fun _ _ -> false)` — EVERY publish remounts the entire tree (~30+ node ids/cycle). Mount-time emitters feed it: fresh `SecureField`/`Input` fire `TextChanged("")`, `journal-asset-settings` fires its `.task` `deliver()` on each mount, and naive reducers that rebuild identical records keep it alive (~10Hz, 100% CPU). Defenses now in place — `Signal.cutoff ( == )` on the model signal, `Editor.apply_text_edit` returns `None` for identical documents, `days:` extension handler gated by `!asset_settings <> Some settings`, e2ee `Text_edit` guarded by `==`. If a new mount-echo emitter appears, the same dedup pattern applies at the reducer. +- **`V.empty ()` mounts NOTHING (returns node 0)** — extension children are positional on the native side. `journal-chrome` Swift view requires exactly 3 children for `mode:feedback` and 4 for `mode:journal`; an "absent" slot must still mount a real (zero-size) node — `V.column []`/`V.column [x]` placeholders — or `childIDs.count` collapses and the host renders `EmptyView` (blank screen, zero errors). +- **`list-item` rows are inert without `press-enabled`** — `Navigation_link` must set `Lui_protocol.PressEnabled` (and mount the label as a child: `list-item` requires text or children, else the backend fatals). +- **Property-value vocabularies are small**: `RoleValue` accepts only `treeitem|navigation|navigation-heading` (NOT toolbar placements like `bottom_bar`); `VariantValue` only `default|primary|secondary|outline|ghost|destructive` (`plain`→`ghost`, `prominent`→`primary`). Unsupported values throw in `set_prop` during emit. +- **Emit errors were swallowed**: `emit_patch` in `journal_lui_bridge.c` returns 0 on exception → partial op stream → silently blank screens. To diagnose schema violations, add `caml_format_exception(Extract_exception(result))` print there and run a lui build with detailed `set_prop`/`insert_child` messages (local lui branch `devin/set-prop-error-detail` has them: kind/property/value and parent/child kinds — candidate for upstream PR). +- **Extension `standardChildren` must match actual children**: `journal-media`/`journal-list` mount standard children, so `standard_children=true` + the `children` extension-kind whitelist must stay in sync across `journal_lui_native.ml`, `JournalExtensions.swift`, and `journal_extension_registry.dart` (fingerprint mismatch → `unsupported child kind` at emit). +- **Password entry races**: typing into a remount-per-publish field loses focus / stale `''` echo can wipe it. Paste instead: `printf '%s' "$PW" | pbcopy && xcrun simctl pbsync host `, click field, `cmd+v`. +- **V.Sheet modal presentation freezes on the iOS sim** (menu open + sheet present → stuck overlay, 0% CPU, input dead; same hazard class as the old bonsai render loop). Workaround for sim-only modal testing: render `modal` content inline (`V.column [base; V.Navigation_stack.create ...]` in `application.ml`'s sheet wrapper) — reducer paths are identical. NEVER commit the patch. + ## Install / launch / record / diagnose - `xcrun simctl install `; `xcrun simctl launch com.logseq.journal`. From b006f8e88944505c0cef2406b8c8ded7c2eb7dd3 Mon Sep 17 00:00:00 2001 From: zy C Date: Wed, 23 Sep 2026 04:47:55 -0700 Subject: [PATCH 27/40] Make OCaml emit exceptions loud in journal_lui_bridge.c Every caml_callback*_exn result that failed with Is_exception_result was silently dropped: emit_patch returned 0, lui_ocaml_root_node returned 0, and deliver_platform ignored the result value entirely. When OCaml threw (e.g. schema validation errors on unsupported property values or child kinds) the patch stream truncated silently and the host rendered a blank or half-blank screen with zero diagnostic output. Add report_ocaml_exception(), which formats the exception with caml_format_exception(Extract_exception(result)) and fprintf()s it to stderr tagged with the callback name, so the message reaches the app's stderr/simctl launch --console-pty log. Return contracts are unchanged: callers still get the same failure code; the exception is no longer swallowed. --- app/journal_lui_bridge.c | 49 ++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/app/journal_lui_bridge.c b/app/journal_lui_bridge.c index 61d1d7d..e42cab7 100644 --- a/app/journal_lui_bridge.c +++ b/app/journal_lui_bridge.c @@ -1,10 +1,13 @@ #include +#include #include #include #include #include +#include #include +#include #include #include @@ -24,8 +27,16 @@ static lui_patch_callback patch_callback = NULL; static journal_wakeup_callback wakeup_callback = NULL; static journal_platform_request_callback platform_request_callback = NULL; -static int emit_patch(value result) { +static void report_ocaml_exception(const char *where, value result) { + char *message = caml_format_exception(Extract_exception(result)); + fprintf(stderr, "journal_lui_bridge: OCaml exception in %s: %s\n", + where, message); + caml_stat_free(message); +} + +static int emit_patch(const char *where, value result) { if (Is_exception_result(result)) { + report_ocaml_exception(where, result); return 0; } const char *json = String_val(result); @@ -70,7 +81,7 @@ LUI_EXPORT int32_t lui_ocaml_start( Val_long(platform_code), Val_long(host_code), payload_value); - accepted = emit_patch(result); + accepted = emit_patch("lui_ocaml_init", result); CAMLdrop; caml_enter_blocking_section(); return accepted; @@ -81,7 +92,7 @@ static int dispatch_long(const char *name, int64_t node) { caml_leave_blocking_section(); const value *dispatch = caml_named_value(name); if (dispatch != NULL) { - result = emit_patch(caml_callback_exn(*dispatch, Val_long(node))); + result = emit_patch(name, caml_callback_exn(*dispatch, Val_long(node))); } caml_enter_blocking_section(); return result; @@ -104,7 +115,7 @@ LUI_EXPORT int32_t lui_ocaml_text_changed(int64_t node, const char *text) { caml_leave_blocking_section(); const value *dispatch = caml_named_value("lui_ocaml_text_changed"); if (dispatch != NULL) { - result = emit_patch(caml_callback2_exn( + result = emit_patch("lui_ocaml_text_changed", caml_callback2_exn( *dispatch, Val_long(node), caml_copy_string(text))); } caml_enter_blocking_section(); @@ -128,7 +139,7 @@ LUI_EXPORT int32_t lui_ocaml_toggle_changed(int64_t node, int32_t checked) { caml_leave_blocking_section(); const value *dispatch = caml_named_value("lui_ocaml_toggle_changed"); if (dispatch != NULL) { - result = emit_patch(caml_callback2_exn( + result = emit_patch("lui_ocaml_toggle_changed", caml_callback2_exn( *dispatch, Val_long(node), Val_bool(checked))); } caml_enter_blocking_section(); @@ -144,7 +155,7 @@ LUI_EXPORT int32_t lui_ocaml_slider_changed(int64_t node, double fraction) { caml_leave_blocking_section(); const value *dispatch = caml_named_value("lui_ocaml_slider_changed"); if (dispatch != NULL) { - result = emit_patch(caml_callback2_exn( + result = emit_patch("lui_ocaml_slider_changed", caml_callback2_exn( *dispatch, Val_long(node), caml_copy_double(fraction))); } caml_enter_blocking_section(); @@ -156,7 +167,8 @@ LUI_EXPORT int32_t lui_ocaml_stop(void) { caml_leave_blocking_section(); const value *dispose = caml_named_value("lui_ocaml_dispose"); if (dispose != NULL) { - result = emit_patch(caml_callback_exn(*dispose, Val_unit)); + result = emit_patch("lui_ocaml_dispose", + caml_callback_exn(*dispose, Val_unit)); } caml_enter_blocking_section(); return result; @@ -168,7 +180,9 @@ LUI_EXPORT int64_t lui_ocaml_root_node(void) { const value *root = caml_named_value("lui_ocaml_root_node"); if (root != NULL) { value result = caml_callback_exn(*root, Val_unit); - if (!Is_exception_result(result)) { + if (Is_exception_result(result)) { + report_ocaml_exception("lui_ocaml_root_node", result); + } else { node = (int64_t)Long_val(result); } } @@ -188,11 +202,12 @@ LUI_EXPORT int32_t journal_ocaml_extension_event( caml_leave_blocking_section(); const value *dispatch = caml_named_value("journal_ocaml_extension_event"); if (dispatch != NULL) { - result = emit_patch(caml_callback3_exn( - *dispatch, - Val_long(node), - caml_copy_string(name), - caml_copy_string(payload))); + result = emit_patch("journal_ocaml_extension_event", + caml_callback3_exn( + *dispatch, + Val_long(node), + caml_copy_string(name), + caml_copy_string(payload))); } caml_enter_blocking_section(); return result; @@ -206,7 +221,8 @@ LUI_EXPORT int32_t journal_ocaml_pump(void) { caml_leave_blocking_section(); const value *pump = caml_named_value("journal_ocaml_pump"); if (pump != NULL) { - result = emit_patch(caml_callback_exn(*pump, Val_unit)); + result = emit_patch("journal_ocaml_pump", + caml_callback_exn(*pump, Val_unit)); } caml_enter_blocking_section(); return result; @@ -219,7 +235,10 @@ static void deliver_platform(const char *name, const char *data, const value *handler = caml_named_value(name); if (handler != NULL) { value payload = copy_bytes(data, length); - caml_callback_exn(*handler, payload); + value result = caml_callback_exn(*handler, payload); + if (Is_exception_result(result)) { + report_ocaml_exception(name, result); + } } caml_enter_blocking_section(); } From 46e941bb5070e5ab62f1dab7e24a44e5210f2a8b Mon Sep 17 00:00:00 2001 From: zy C Date: Wed, 23 Sep 2026 08:51:54 -0700 Subject: [PATCH 28/40] view: align iOS rendering with the bonsai-era UI Parity pass against the recorded bonsai build, confined to the shim and host Swift: - Section.create emits a heading + plain column card as direct list children so LUIListView groups them natively (panel/card kinds overlay children in a ZStack, which stacked the unlock texts). - Menu/toolbar/dock and capsule rows pin cross=center + measured widths so rows center instead of stretching half the bounded column. - content_unavailable centers deterministically (growing column + spacers + centered label/description/actions). - Navigation_link rows carry a trailing chevron icon to match NavigationLink's disclosure accessory. - Icon-only mounts get a uniform 40pt control width; icon-only button/menu-item labels stay on the a11y channel. - SectionDate uses an explicit label color: .primary inside a List section header resolves to adaptive gray on iOS 26. - Asset-import button shows icon-only; asset-settings root column fills the viewport and anchors top; new SF Symbol slugs registered. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitignore | 1 + app/application.ml | 4 +- app/journal_header.ml | 2 +- app/journal_view.ml | 503 ++++++++++++++++++++++++++----- app/journal_view.mli | 2 +- swift/JournalAssetImport.swift | 1 + swift/JournalAssetSettings.swift | 3 + swift/JournalChrome.swift | 2 +- swift/JournalIcons.swift | 5 + 9 files changed, 443 insertions(+), 80 deletions(-) diff --git a/.gitignore b/.gitignore index 85a725d..77e37b7 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ /journal.sqlite3-shm /journal.sqlite3-wal /.dir-locals.el +app/journal_complete_iossim.o diff --git a/app/application.ml b/app/application.ml index 98feaf0..97d7eda 100644 --- a/app/application.ml +++ b/app/application.ml @@ -1340,7 +1340,7 @@ module Presentation = struct let section ?key title children = V.Section.create ~key:(Option.value key ~default:(Ui.Key.string title)) - ~header:(V.text title) + ~header_text:title [ V.Keyed.create ~key:"content" (V.column ~alignment:Leading children |> V.text_selection ~enabled:true) @@ -5122,7 +5122,7 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = let view _context model_signal _send = (* Dynamic elements mount under a parent, so the root must be a static container. *) - Lui_elements.stack + Lui_elements.column [ Lui_elements.dyn (fun model -> Journal_view.mount diff --git a/app/journal_header.ml b/app/journal_header.ml index e24013a..28b0252 100644 --- a/app/journal_header.ml +++ b/app/journal_header.ml @@ -124,7 +124,7 @@ let view |> Option.iter (fun (_, _, _, action, _) -> Ui.Event.Handler.Private.invoke dispatch (Ui.Event.Payload.Text action)) | _ -> ())) - ~title:"Account menu" + ~title:"" ~icon:(Journal_symbols.name Journal_symbols.Account) (List.map (fun (id, title, symbol, _, role) -> diff --git a/app/journal_view.ml b/app/journal_view.ml index 59f2a86..d2c81c6 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -19,6 +19,7 @@ type t = ; test_id : string option ; mount : Lui_elements.t ; label_content : label_content option + ; menu_item_mount : Lui_elements.t option } module Key = struct @@ -36,7 +37,9 @@ module Test_id = struct let to_string s = s end -let element ?key ?test_id mount = { key; test_id; mount; label_content = None } +let element ?key ?test_id ?menu_item_mount mount = + { key; test_id; mount; label_content = None; menu_item_mount } +;; let mount t = t.mount let int_of_float_nan v = int_of_float (Float.round v) @@ -47,12 +50,24 @@ let journal_icon_name name = "app:" ^ String.map (fun c -> if c = '.' then '-' else c) name ;; +(* While a navigation bar or bottom bar mounts its items, labels collapse to + their icon — matching the icon-only affordances the system chrome showed. *) +let icon_only = ref false + (* Leaf controls carry their label/icon as properties; each kind only accepts a subset of them, so apply what the node kind supports. *) let set_leaf_label context node { title; icon } = let kind = Lui_ui.node_kind context node in let supported property = Lui_protocol.property_supported kind property in - if supported Lui_protocol.TextValue then Lui_ui.text_property context node title; + if not (!icon_only && Option.is_some icon) + then ( + if supported Lui_protocol.TextValue + then Lui_ui.text_property context node title) + else ( + (* Icon-only controls keep their name on the accessibility channel; the + schema rejects icon-only buttons with no accessible name. *) + if supported Lui_protocol.AccessibilityLabel + then Lui_ui.accessibility_label context node (if title = "" then " " else title)); Option.iter (fun name -> if supported Lui_protocol.InlineIconName @@ -446,6 +461,18 @@ end let invoke handler payload = Event.Handler.Private.invoke handler payload +(* The enclosing navigation stack publishes its pop affordance here so a + toolbar mounted inside the current page can render the model-level back + button, title and actions in the bar it emulates. *) +type nav_bar = + { nav_on_change : Event.Handler.t + ; nav_remaining : Journal_ids.Navigation.Page_key.t list + ; nav_can_pop : bool + ; nav_title : string + } + +let nav_bar = ref None + module View = struct type nonrec t = t type element_ = t @@ -784,6 +811,44 @@ module View = struct | Some parent -> Lui_ui.append context parent node | None -> ()); node) + |> fun element_ -> + { element_ with + menu_item_mount = + Some + (fun context parent -> + let node = Lui_ui.menu_item context in + if not enabled then Lui_ui.disabled context node true; + Option.iter + (fun (label : label_content) -> + Lui_ui.text_property + context + node + (if String.length label.title = 0 then " " else label.title); + Option.iter + (fun name -> + Lui_ui.string_property + context + node + Lui_protocol.InlineIconName + (journal_icon_name name)) + label.icon) + child.label_content; + (match role with + | Button_role.Normal -> () + | role -> + Lui_ui.string_property + context + node + Lui_protocol.VariantValue + (Button_role.variant role)); + Lui_ui.bool_property context node Lui_protocol.PressEnabled true; + Lui_ui.on_event context node (fun event -> + if is_press event then invoke on_press Event.Payload.Unit); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + node) + } ;; let toggle ?key ?style:_ ?(enabled = true) ~value ~on_changed ~label () = @@ -918,16 +983,63 @@ module View = struct let content_unavailable ?key ~label ?description ?actions () = element ?key (fun context parent -> let node = Lui_ui.column context in - Lui_ui.cross context node "center"; - Lui_ui.main context node "center"; + (* Grow so the column fills the page: without it the column shrinks to + its content and the centered children end up leading-aligned. *) + Lui_ui.grow context node 1.0; (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - ignore (label.mount context (Some node)); + let spacer () = + let spacer = Lui_ui.spacer context in + Lui_ui.append context node spacer + in + spacer (); + (* Center content via per-child mechanics. A cross=center column keeps + its natural width and lands leading under the stretch parent's + topLeading frame, so horizontal centering instead goes through a + main=center row: the row expands to the offered width and packs its + child between leading/trailing spacers. Text needs no wrapper — a + set text-alignment already stretches it to full width. *) + let center_horizontally t = + element (fun context parent -> + let row = Lui_ui.row context in + Lui_ui.gap context row 0; + Lui_ui.cross context row "center"; + Lui_ui.main context row "center"; + (match parent with + | Some parent -> Lui_ui.append context parent row + | None -> ()); + ignore (t.mount context (Some row)); + row) + in + (* The label is already a full-width row: center its own content rather + than nesting it (a wrapper would split the free space with the + label's own trailing spacer and leave the text off-center). *) + ignore + ((modify (fun context node -> Lui_ui.main context node "center") label) + .mount + context + (Some node)); Option.iter - (fun description -> ignore (description.mount context (Some node))) + (fun description -> + ignore + ((modify + (fun context node -> + Lui_ui.string_property + context + node + Lui_protocol.TextAlignment + "center") + description) + .mount + context + (Some node))) description; - Option.iter (fun actions -> ignore (actions.mount context (Some node))) actions; + Option.iter + (fun actions -> + ignore ((center_horizontally actions).mount context (Some node))) + actions; + spacer (); node) ;; @@ -954,18 +1066,27 @@ module View = struct end module Section = struct - let create ?key ?header ?footer entries = + let create ?key ?header_text ?footer entries = element ?key (fun context parent -> - let node = Lui_ui.panel context in + (* Sections mount inside a `list`: a `heading` child becomes the + native section header, following children the rows. Entries go in + one column so they render as a single grouped card — panel/card + kinds would overlay every child in a ZStack. *) + let card = Lui_ui.column context in + Lui_ui.gap context card 12; + (match header_text, parent with + | Some title, Some parent -> + let heading = Lui_ui.heading context 4 title in + Lui_ui.append context parent heading + | _ -> ()); (match parent with - | Some parent -> Lui_ui.append context parent node + | Some parent -> Lui_ui.append context parent card | None -> ()); - Option.iter (fun header -> ignore (header.mount context (Some node))) header; List.iter - (fun (entry : Keyed.t) -> ignore (entry.view.mount context (Some node))) + (fun (entry : Keyed.t) -> ignore (entry.view.mount context (Some card))) entries; - Option.iter (fun footer -> ignore (footer.mount context (Some node))) footer; - node) + Option.iter (fun footer -> ignore (footer.mount context parent)) footer; + card) ;; end @@ -1025,9 +1146,9 @@ module View = struct } ;; - let spacer ~key ?placement:_ _spacing = + let spacer ~key ?placement spacing = { item_key = key - ; placement = None + ; placement ; content = element (fun context parent -> let node = Lui_ui.spacer context in @@ -1035,52 +1156,211 @@ module View = struct | Some parent -> Lui_ui.append context parent node | None -> ()); node) - ; spacing = None + ; spacing = Some spacing ; is_group = false } ;; + (* System-chrome emulation: on iOS the previous renderer put toolbar items + into real navigation/bottom bars — leading back affordance, centered + principal title, and trailing icon-only actions grouped in a capsule. + lui has no chrome node, so the shim reproduces that layout inline. *) + let mount_icon_only parent context (item : item) = + let previous = !icon_only in + icon_only := true; + Fun.protect ~finally:(fun () -> icon_only := previous) (fun () -> + let mounted = item.content.mount context (Some parent) in + if mounted <> 0 + then ( + Lui_ui.key context mounted item.item_key; + (* Uniform 40pt control cell so capsule widths are predictable. *) + if node_is_standard context mounted + then Lui_ui.width context mounted 40); + mounted) + ;; + + let capsule context parent ~children_count mount_children = + (* lui container children always receive a flexible frame + (.frame(maxWidth: nil) expands like .infinity), so a row's background + would paint the whole region it is offered. The capsule therefore + pins an explicit content-sized width: the outer flexible frame still + takes the space, but the pill itself stays tight. *) + let row = Lui_ui.row context in + Lui_ui.gap context row 16; + (* Hug content height; the default stretch cross would soak the + parent column's split share (see Navigation_stack back row). *) + Lui_ui.cross context row "center"; + Lui_ui.padding_horizontal context row 14; + Lui_ui.padding_vertical context row 9; + Lui_ui.background context row "secondary"; + Lui_ui.corner_radius context row 20; + (* 14pt padding on each side, 40pt per child, 16pt gaps. *) + Lui_ui.width context row (12 + (56 * children_count)); + Lui_ui.append context parent row; + mount_children row; + row + ;; + + let circle_button context parent ~icon ~on_press = + let node = Lui_ui.button context in + Lui_ui.text_property context node ""; + (* Icon-only button: the schema rejects empty text + icon without an + accessibility label. *) + Lui_ui.accessibility_label context node "Back"; + Lui_ui.string_property + context + node + Lui_protocol.InlineIconName + (journal_icon_name icon); + Lui_ui.background context node "secondary"; + Lui_ui.corner_radius context node 20; + Lui_ui.width context node 40; + Lui_ui.height context node 40; + Lui_ui.on_event context node (fun event -> if is_press event then on_press ()); + Lui_ui.append context parent node; + node + ;; + + let flexible_space context parent = + let node = Lui_ui.spacer context in + Lui_ui.grow context node 1.0; + Lui_ui.append context parent node; + node + ;; + let mount_items items = element (fun context parent -> - (* A plain horizontal group: the toolbar node kind only accepts a fixed - set of control children and no extension children, while items here - include menus and extension nodes. *) let node = Lui_ui.row context in - Lui_ui.gap context node 12; - Lui_ui.padding_horizontal context node 16; + Lui_ui.gap context node 8; + Lui_ui.cross context node "center"; + Lui_ui.padding_horizontal context node 10; + Lui_ui.padding_vertical context node 4; (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); + let leading = + List.filter + (fun (item : item) -> + match item.placement with + | Some (Navigation | Cancellation_action) -> true + | _ -> false) + items + and principal = + List.filter + (fun (item : item) -> item.placement = Some Principal) + items + and secondary = + List.filter + (fun (item : item) -> item.placement = Some Secondary_action) + items + in + let trailing = + List.filter + (fun (item : item) -> + match item.placement with + | Some + ( Navigation | Cancellation_action | Principal + | Secondary_action | Bottom_bar ) + | None -> false + | Some _ -> true) + items + in + (match !nav_bar with + | Some { nav_can_pop = true; nav_on_change; nav_remaining; _ } -> + ignore + (circle_button + context + node + ~icon:"chevron.left" + ~on_press:(fun () -> + invoke nav_on_change + (Event.Payload.Navigation_path_changed nav_remaining))) + | _ -> ()); List.iter - (fun (item : item) -> - let child = item.content in - let mounted = child.mount context (Some node) in - Lui_ui.key context mounted item.item_key; - (match item.placement with - | Some Principal -> - if - Lui_protocol.property_supported - (Lui_ui.node_kind context mounted) - Lui_protocol.GrowValue - then Lui_ui.grow context mounted 1.0 - | _ -> ()); - (* Toolbar placement/spacing/grouping have no representation in - the lui schema: `role` accepts only treeitem/navigation/ - navigation-heading and `variant` only the button vocabulary. - The only representable hint is a navigation placement. *) - match item.placement with - | Some Navigation -> - if - Lui_protocol.property_supported - (Lui_ui.node_kind context mounted) - Lui_protocol.RoleValue - then + (fun item -> ignore (mount_icon_only node context item)) + leading; + ignore (flexible_space context node); + (match principal with + | [] -> + (match !nav_bar with + | Some { nav_title = title; _ } when title <> "" -> + let title_node = Lui_ui.text context title in + Lui_ui.style_class context title_node "semibold"; + Lui_ui.append context node title_node + | _ -> ()) + | _ -> + List.iter + (fun item -> + let mounted = item.content.mount context (Some node) in + if mounted <> 0 + then ( + Lui_ui.key context mounted item.item_key; + if node_is_standard context mounted + then Lui_ui.style_class context mounted "semibold")) + principal); + ignore (flexible_space context node); + if trailing <> [] || secondary <> [] + then + ignore + (capsule + context + node + ~children_count: + (List.length trailing + if secondary <> [] then 1 else 0) + (fun row -> + List.iter + (fun item -> ignore (mount_icon_only row context item)) + trailing; + if secondary <> [] + then ( + (* Secondary actions collapse into the "more" overflow the + system bar showed. *) + let trigger = Lui_ui.menu_item context in + Lui_ui.text_property context trigger " "; + (* accessibility-label is not in the menu-item schema; the + whitespace text is what the validator accepts. *) Lui_ui.string_property context - mounted - Lui_protocol.RoleValue - "navigation" - | _ -> ()) + trigger + Lui_protocol.InlineIconName + (journal_icon_name "ellipsis"); + (* The menu label grows to fill available space; cap it so + the trigger stays icon-sized inside the capsule. *) + Lui_ui.width context trigger 40; + Lui_ui.append context row trigger; + let menu = Lui_ui.dropdown_menu context in + Lui_ui.append context trigger menu; + List.iter + (fun (item : item) -> + match item.content.menu_item_mount with + | Some mount -> ignore (mount context (Some menu)) + | None -> ignore (mount_icon_only row context item)) + secondary))); + node) + ;; + + let mount_bottom_bar items = + element (fun context parent -> + let node = Lui_ui.row context in + Lui_ui.gap context node 10; + Lui_ui.cross context node "center"; + Lui_ui.padding_horizontal context node 12; + Lui_ui.padding_vertical context node 8; + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + List.iter + (fun (item : item) -> + match item.spacing with + | Some Flexible -> ignore (flexible_space context node) + | Some Fixed -> + let fixed = Lui_ui.spacer context in + Lui_ui.width context fixed 16; + Lui_ui.append context node fixed + | _ -> + ignore + (capsule context node ~children_count:1 (fun row -> + ignore (mount_icon_only row context item)))) items; node) ;; @@ -1088,11 +1368,21 @@ module View = struct let create ?key ~items t = element ?key (fun context parent -> let node = Lui_ui.column context in + Lui_ui.grow context node 1.0; (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - ignore ((mount_items items).mount context (Some node)); - ignore (t.mount context (Some node)); + let top, bottom = + List.partition + (fun (item : item) -> item.placement <> Some Bottom_bar) + items + in + if top <> [] || Option.fold ~none:false ~some:(fun bar -> bar.nav_can_pop) !nav_bar + then ignore ((mount_items top).mount context (Some node)); + let body = t.mount context (Some node) in + if node_is_standard context body then Lui_ui.grow context body 1.0; + if bottom <> [] + then ignore ((mount_bottom_bar bottom).mount context (Some node)); node) ;; end @@ -1747,6 +2037,14 @@ module View = struct let node = Lui_ui.list_item context in if not enabled then Lui_ui.disabled context node true; Lui_ui.bool_property context node Lui_protocol.PressEnabled enabled; + (* NavigationLink draws a trailing disclosure accessory; LUI list items + have none, so carry the chevron as an inline trailing icon. *) + Lui_ui.string_property + context + node + Lui_protocol.InlineIconName + (journal_icon_name "chevron.right"); + Lui_ui.string_property context node Lui_protocol.IconPlacementValue "trailing"; (* A list-item must carry text or children; mount the label as the item content so composite labels render too. *) ignore (label.mount context (Some node)); @@ -1760,38 +2058,84 @@ module View = struct end module Navigation_stack = struct - type destination = t + type destination = + { page_key : string + ; title : string + ; can_pop : bool + ; content : t + } - let destination ~page_key:_ ~title:_ ~can_pop:_ content = content + let destination ~page_key ~title ~can_pop content = + { page_key; title; can_pop; content } + ;; (* The lui widget set has no navigation-stack node. The router stays in the - model: the topmost destination renders, and interactive pops arrive as - [Navigation_path_changed] through the back affordance the shim renders. *) - let create ?key ~title:_ ~on_path_change ~path root = + model: the topmost destination renders, and the bar chrome is emulated — + the "‹ Logseq Journal" container back-link every page displayed plus the + pop affordance [Toolbar.mount_items] renders from [nav_bar]. *) + let create ?key ~title ~on_path_change ~path root = element ?key (fun context parent -> let node = Lui_ui.column context in + (* Fill the hosting column so the emulated bar rows pin to the top + instead of the whole page centering vertically. *) + Lui_ui.grow context node 1.0; (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - (match List.rev path with - | [] -> ignore (root.mount context (Some node)) - | top :: _ -> - let back = - element (fun context parent -> - let node = Lui_ui.button context in - Lui_ui.text_property context node "Back"; - Lui_ui.string_property context node Lui_protocol.VariantValue "ghost"; - Lui_ui.accessibility_identifier context node "journal-nav-back"; - Lui_ui.on_event context node (fun event -> - if is_press event - then invoke on_path_change (Event.Payload.Navigation_path_changed [])); - (match parent with - | Some parent -> Lui_ui.append context parent node - | None -> ()); - node) - in - ignore (back.mount context (Some node)); - ignore (top.mount context (Some node))); + if title = "" + then ( + let back = Lui_ui.row context in + Lui_ui.gap context back 4; + (* The default "stretch" cross axis wraps every child in + maxHeight:.infinity — inside a bounded column the row then soaks up + its share of the parent's height and vertically centers. *) + Lui_ui.cross context back "center"; + Lui_ui.padding_horizontal context back 10; + Lui_ui.padding_vertical context back 10; + Lui_ui.append context node back; + let icon = Lui_ui.icon context (journal_icon_name "chevron.left") in + Lui_ui.append context back icon; + let label = Lui_ui.text context "Logseq Journal" in + Lui_ui.append context back label; + Lui_ui.accessibility_identifier context back "journal-nav-back"; + Lui_ui.on_event context back (fun event -> + if is_press event + then invoke on_path_change (Event.Payload.Navigation_path_changed []))); + let top = + match List.rev path with + | [] -> None + | top :: _ -> Some top + in + let keys = List.map (fun (d : destination) -> d.page_key) path in + let remaining = + match List.rev keys with + | [] -> [] + | _ :: rest -> List.rev rest + in + let previous = !nav_bar in + nav_bar := + Some + { nav_on_change = on_path_change + ; nav_remaining = + List.map Journal_ids.Navigation.Page_key.of_string remaining + ; nav_can_pop = + (match top with + | Some destination -> destination.can_pop + | None -> false) + ; nav_title = + (match top with + | Some destination -> destination.title + | None -> title) + }; + Fun.protect ~finally:(fun () -> nav_bar := previous) (fun () -> + let mounted = + match top with + | None -> root.mount context (Some node) + | Some destination -> destination.content.mount context (Some node) + in + (* Extension nodes live outside the standard prop store — set_prop + on one raises; they expand through their own SwiftUI views. *) + if node_is_standard context mounted then Lui_ui.grow context mounted 1.0); node) ;; end @@ -1819,6 +2163,7 @@ module View = struct = element ?key (fun context parent -> let node = Lui_ui.column context in + Lui_ui.grow context node 1.0; (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); @@ -1958,6 +2303,9 @@ module View = struct let menu_item context ?key_opt ~title ~icon ~enabled ~role ~selected ?on_press () = let node = Lui_ui.menu_item context in Option.iter (Lui_ui.key context node) key_opt; + (* menu-item requires non-empty text; a blank space keeps icon-only + triggers visually identical without violating the schema. *) + let title = if String.length title = 0 then " " else title in Lui_ui.string_property context node Lui_protocol.TextValue title; Option.iter (fun name -> @@ -1997,6 +2345,11 @@ module View = struct ~selected:None () in + if String.length title = 0 + then + (* Icon-only trigger: keep the menu label from stretching to fill + the available width inside bar capsules. *) + Lui_ui.width context node 20; (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); diff --git a/app/journal_view.mli b/app/journal_view.mli index 87e1f3a..2f1f7f0 100644 --- a/app/journal_view.mli +++ b/app/journal_view.mli @@ -474,7 +474,7 @@ module View : sig end module Section : sig - val create : ?key:Key.t -> ?header:t -> ?footer:t -> Keyed.t list -> t + val create : ?key:Key.t -> ?header_text:string -> ?footer:t -> Keyed.t list -> t end module Form : sig diff --git a/swift/JournalAssetImport.swift b/swift/JournalAssetImport.swift index a9a4ad1..3832f65 100644 --- a/swift/JournalAssetImport.swift +++ b/swift/JournalAssetImport.swift @@ -57,6 +57,7 @@ import UniformTypeIdentifiers presented = true } label: { Label(selection.operation == nil ? "Attach file" : "Importing file", systemImage: "paperclip") + .labelStyle(.iconOnly) } .disabled(properties?.enabled == false || selection.operation != nil) .accessibilityIdentifier("journal-asset-import") diff --git a/swift/JournalAssetSettings.swift b/swift/JournalAssetSettings.swift index bef85b2..ef30687 100644 --- a/swift/JournalAssetSettings.swift +++ b/swift/JournalAssetSettings.swift @@ -33,6 +33,9 @@ import SwiftUI } var body: some SwiftUI.View { context.content + // The root column's proposal is the full viewport; expand to fill it + // and anchor the page at the top so bar rows don't drift to center. + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .task { deliver() } .onChange(of: days) { _, value in if preferences.save(recentDays: value) { deliver() } diff --git a/swift/JournalChrome.swift b/swift/JournalChrome.swift index 72f99f5..ae5d09a 100644 --- a/swift/JournalChrome.swift +++ b/swift/JournalChrome.swift @@ -65,7 +65,7 @@ import SwiftUI HStack(spacing: 0) { Text(title) .font(.title2.weight(.semibold)) - .foregroundStyle(.primary) + .foregroundStyle(Color(.label)) .monospacedDigit() .textCase(nil) .lineLimit(1) diff --git a/swift/JournalIcons.swift b/swift/JournalIcons.swift index a01de49..8256030 100644 --- a/swift/JournalIcons.swift +++ b/swift/JournalIcons.swift @@ -5,6 +5,7 @@ import LUIAppleBackend /// system symbol name. Keep in sync with `journal_icon_name` call sites. let journalIconNames: [String] = [ "arrow.clockwise", + "arrow.triangle.2.circlepath", "arrow.up", "book", "calendar", @@ -18,6 +19,7 @@ let journalIconNames: [String] = [ "clock", "doc", "doc.text", + "ellipsis", "exclamationmark.circle", "exclamationmark.triangle", "folder", @@ -27,8 +29,11 @@ let journalIconNames: [String] = [ "person.crop.circle", "plus", "questionmark.folder", + "rectangle.portrait.and.arrow.right", + "slider.horizontal.3", "square.and.pencil", "star", + "stethoscope", "trash", ] From c7c35ec4d501946476eb32521b5bf24fceb31c1c Mon Sep 17 00:00:00 2001 From: zy C Date: Wed, 23 Sep 2026 09:01:19 -0700 Subject: [PATCH 29/40] deps: bump lui pin to merged main (menu-item + icon-fit fixes) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- flutter/pubspec.lock | 4 ++-- flutter/pubspec.yaml | 2 +- logseq_journal.opam | 2 +- test/source_boundary_test.ml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 0271b17..84e38db 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -519,8 +519,8 @@ packages: dependency: "direct main" description: path: "platform/flutter" - ref: c4468ffdbb0e68319b90306933db7edb066b778b - resolved-ref: c4468ffdbb0e68319b90306933db7edb066b778b + ref: 38096c4fab853e462e60fc04d4daadd1f06aa09a + resolved-ref: 38096c4fab853e462e60fc04d4daadd1f06aa09a url: "https://github.com/logseq/lui.git" source: git version: "0.1.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index f99f5e8..7960b51 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: lui_flutter_backend: git: url: https://github.com/logseq/lui.git - ref: c4468ffdbb0e68319b90306933db7edb066b778b + ref: 38096c4fab853e462e60fc04d4daadd1f06aa09a path: platform/flutter # journal-lui:end packages diff --git a/logseq_journal.opam b/logseq_journal.opam index 3bd8692..05e1a6d 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#c4468ffdbb0e68319b90306933db7edb066b778b"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#38096c4fab853e462e60fc04d4daadd1f06aa09a"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index 60cd18c..389090d 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1170,7 +1170,7 @@ let () = require_occurrences root "logseq_journal.opam" - "git+https://github.com/logseq/lui.git#c4468ffdbb0e68319b90306933db7edb066b778b" + "git+https://github.com/logseq/lui.git#38096c4fab853e462e60fc04d4daadd1f06aa09a" 1; require_occurrences root From 300f975552abb7216493eb4bae72a1e14c045672 Mon Sep 17 00:00:00 2001 From: zy C Date: Wed, 23 Sep 2026 10:32:57 -0700 Subject: [PATCH 30/40] Align bar chrome and stop journal-list layout storm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the emulated '‹ Logseq Journal' back-link row: the system nav bar renders nothing for an empty title, so the shim's extra row had no old counterpart and pushed the nav row down. - Emit size="sm" on icon-only menu triggers (person icon, overflow …) so the glyph matches the old 16pt menu-item icon. - Replace .scrollPosition (two-way binding) with a ScrollViewReader + scrollTo for one-shot scroll requests. - Defer onAppear/onDisappear @State writes off the layout pass and debounce visible_range emits (80ms): a boundary row flickering during layout previously emitted a range oscillation that fed patches back and pinned the main thread at 100% CPU. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/journal_view.ml | 37 +++++++------------ swift/JournalList.swift | 78 ++++++++++++++++++++++++++--------------- 2 files changed, 63 insertions(+), 52 deletions(-) diff --git a/app/journal_view.ml b/app/journal_view.ml index d2c81c6..a311f6d 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -1327,6 +1327,11 @@ module View = struct (* The menu label grows to fill available space; cap it so the trigger stays icon-sized inside the capsule. *) Lui_ui.width context trigger 40; + Lui_ui.string_property + context + trigger + Lui_protocol.SizeValue + "sm"; Lui_ui.append context row trigger; let menu = Lui_ui.dropdown_menu context in Lui_ui.append context trigger menu; @@ -2070,9 +2075,10 @@ module View = struct ;; (* The lui widget set has no navigation-stack node. The router stays in the - model: the topmost destination renders, and the bar chrome is emulated — - the "‹ Logseq Journal" container back-link every page displayed plus the - pop affordance [Toolbar.mount_items] renders from [nav_bar]. *) + model: the topmost destination renders, and the pop affordance + [Toolbar.mount_items] renders from [nav_bar]. [title] maps to the + system navigation title, which the chrome hides — an empty title + renders no bar content. *) let create ?key ~title ~on_path_change ~path root = element ?key (fun context parent -> let node = Lui_ui.column context in @@ -2082,25 +2088,6 @@ module View = struct (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - if title = "" - then ( - let back = Lui_ui.row context in - Lui_ui.gap context back 4; - (* The default "stretch" cross axis wraps every child in - maxHeight:.infinity — inside a bounded column the row then soaks up - its share of the parent's height and vertically centers. *) - Lui_ui.cross context back "center"; - Lui_ui.padding_horizontal context back 10; - Lui_ui.padding_vertical context back 10; - Lui_ui.append context node back; - let icon = Lui_ui.icon context (journal_icon_name "chevron.left") in - Lui_ui.append context back icon; - let label = Lui_ui.text context "Logseq Journal" in - Lui_ui.append context back label; - Lui_ui.accessibility_identifier context back "journal-nav-back"; - Lui_ui.on_event context back (fun event -> - if is_press event - then invoke on_path_change (Event.Payload.Navigation_path_changed []))); let top = match List.rev path with | [] -> None @@ -2346,10 +2333,12 @@ module View = struct () in if String.length title = 0 - then + then ( (* Icon-only trigger: keep the menu label from stretching to fill - the available width inside bar capsules. *) + the available width inside bar capsules, and use the smaller + menu-item icon size the system bar showed. *) Lui_ui.width context node 20; + Lui_ui.string_property context node Lui_protocol.SizeValue "sm"); (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); diff --git a/swift/JournalList.swift b/swift/JournalList.swift index 0fff0ef..d274f9e 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -73,9 +73,10 @@ import SwiftUI let context: LUIAppleExtensionViewContext @State private var visible: Set = [] @State private var delivered: (first: Int, last: Int)? - @State private var scrolledID: String? - @State private var scrollAnchor: UnitPoint = .top @State private var handledScrollToken: Int64 = 0 + @State private var scrollProxy: ScrollViewProxy? + @State private var pendingScroll: (id: String, anchor: UnitPoint)? + @State private var visibleEmitTask: Task? private var properties: Properties? { JournalExtensions.decode(Properties.self, context: context) @@ -133,7 +134,14 @@ import SwiftUI let range = (first, last + 1) guard delivered?.first != range.0 || delivered?.last != range.1 else { return } delivered = range - emit(["type": "visible_range", "first": range.0, "last": range.1]) + // Cells flicker in/out while the collection re-layouts; emit only the + // settled range so an oscillating boundary row cannot flood the bridge. + visibleEmitTask?.cancel() + visibleEmitTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 80_000_000) + guard !Task.isCancelled else { return } + emit(["type": "visible_range", "first": range.0, "last": range.1]) + } } private func completeScroll(_ token: String, _ outcome: String) { @@ -174,18 +182,24 @@ import SwiftUI completeScroll(request.token, "missing_target") return } - scrollAnchor = + let anchor: UnitPoint = switch request.anchor { case "center": .center case "bottom": .bottom default: .top } - scrolledID = row.key + pendingScroll = (id: row.key, anchor: anchor) if properties.track_scroll_completion == true { completeScroll(request.token, "succeeded") } } + private func performPendingScroll() { + guard let pending = pendingScroll, let proxy = scrollProxy else { return } + pendingScroll = nil + proxy.scrollTo(pending.id, anchor: pending.anchor) + } + private func separatorVisibility(_ name: String?) -> Visibility { switch name { case "hidden": .hidden @@ -261,14 +275,18 @@ import SwiftUI let content = rowActions(row) .onAppear { if let position = positions[row.key] { - visible.insert(position) - updateVisibleRange() + DispatchQueue.main.async { + visible.insert(position) + updateVisibleRange() + } } } .onDisappear { if let position = positions[row.key] { - visible.remove(position) - updateVisibleRange() + DispatchQueue.main.async { + visible.remove(position) + updateVisibleRange() + } } } if row.isDisclosure { @@ -298,28 +316,32 @@ import SwiftUI } var body: some SwiftUI.View { - List { - ForEach(properties?.sections ?? []) { section in - Section { - ForEach(section.rows) { row in - rowBody(row) - } - } header: { - if let header = section.header_index { - childContent(header) - } - } footer: { - if let footer = section.footer_index { - childContent(footer) + ScrollViewReader { proxy in + List { + ForEach(properties?.sections ?? []) { section in + Section { + ForEach(section.rows) { row in + rowBody(row) + } + } header: { + if let header = section.header_index { + childContent(header) + } + } footer: { + if let footer = section.footer_index { + childContent(footer) + } } + .listSectionSeparator(separatorVisibility(section.separator)) } - .listSectionSeparator(separatorVisibility(section.separator)) } - } - .modifier(ListStyleModifier(style: style)) - .scrollPosition(id: $scrolledID, anchor: scrollAnchor) - .onAppear { - if let request = properties?.scroll_request { applyScrollRequest(request) } + .modifier(ListStyleModifier(style: style)) + .onAppear { + scrollProxy = proxy + performPendingScroll() + if let request = properties?.scroll_request { applyScrollRequest(request) } + } + .onChange(of: pendingScroll?.id) { _, _ in performPendingScroll() } } .onChange(of: properties?.scroll_request?.token) { _, _ in if let request = properties?.scroll_request { applyScrollRequest(request) } From 444788460999d83d5461a3d623aad91376219db4 Mon Sep 17 00:00:00 2001 From: zy C Date: Wed, 23 Sep 2026 11:43:14 -0700 Subject: [PATCH 31/40] ui: apply the 40pt icon cell to collapsed leaves only mount_icon_only recorded one boolean and then pinned the item's top mounted node to 40pt, which mis-measured two cases: a Toolbar.group mounts a row of controls (the row itself got the icon width while its leaves stayed unsized), and a text button ("Save", "Close") is not collapsed at all yet still inherits a fixed-width capsule. set_leaf_label now collects each leaf that actually fell back to its icon (icon_only_collapsed_nodes), mount_icon_only applies width=40 to just those leaves, and capsule drops its pinned width = 12 + 56*children_count so the pill hugs its content like the bonsai chrome bar did. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- app/journal_view.ml | 56 +++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/app/journal_view.ml b/app/journal_view.ml index a311f6d..6b9e482 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -54,6 +54,12 @@ let journal_icon_name name = their icon — matching the icon-only affordances the system chrome showed. *) let icon_only = ref false +(* [set_leaf_label] records each bar-mounted control that actually collapsed + its label to an icon; only those get the uniform 40pt control cell — a + text button like "Close" keeps its natural width, and a grouped row of + controls sizes its leaves rather than the row itself. *) +let icon_only_collapsed_nodes : int list ref = ref [] + (* Leaf controls carry their label/icon as properties; each kind only accepts a subset of them, so apply what the node kind supports. *) let set_leaf_label context node { title; icon } = @@ -64,6 +70,7 @@ let set_leaf_label context node { title; icon } = if supported Lui_protocol.TextValue then Lui_ui.text_property context node title) else ( + icon_only_collapsed_nodes := node :: !icon_only_collapsed_nodes; (* Icon-only controls keep their name on the accessibility channel; the schema rejects icon-only buttons with no accessible name. *) if supported Lui_protocol.AccessibilityLabel @@ -1166,25 +1173,31 @@ module View = struct principal title, and trailing icon-only actions grouped in a capsule. lui has no chrome node, so the shim reproduces that layout inline. *) let mount_icon_only parent context (item : item) = - let previous = !icon_only in + let previous = !icon_only and previous_nodes = !icon_only_collapsed_nodes in icon_only := true; - Fun.protect ~finally:(fun () -> icon_only := previous) (fun () -> - let mounted = item.content.mount context (Some parent) in - if mounted <> 0 - then ( - Lui_ui.key context mounted item.item_key; - (* Uniform 40pt control cell so capsule widths are predictable. *) - if node_is_standard context mounted - then Lui_ui.width context mounted 40); - mounted) + icon_only_collapsed_nodes := []; + Fun.protect + ~finally:(fun () -> + icon_only := previous; + icon_only_collapsed_nodes := previous_nodes) + (fun () -> + let mounted = item.content.mount context (Some parent) in + if mounted <> 0 + then ( + Lui_ui.key context mounted item.item_key; + (* Uniform 40pt control cell so capsule widths are predictable — + applied to each leaf control that collapsed to its icon. *) + List.iter + (fun node -> + if node_is_standard context node + then Lui_ui.width context node 40) + !icon_only_collapsed_nodes); + mounted) ;; - let capsule context parent ~children_count mount_children = - (* lui container children always receive a flexible frame - (.frame(maxWidth: nil) expands like .infinity), so a row's background - would paint the whole region it is offered. The capsule therefore - pins an explicit content-sized width: the outer flexible frame still - takes the space, but the pill itself stays tight. *) + let capsule context parent mount_children = + (* A row child of an HStack keeps its intrinsic width unless it grows, + so the pill hugs its controls without a pinned width. *) let row = Lui_ui.row context in Lui_ui.gap context row 16; (* Hug content height; the default stretch cross would soak the @@ -1194,8 +1207,6 @@ module View = struct Lui_ui.padding_vertical context row 9; Lui_ui.background context row "secondary"; Lui_ui.corner_radius context row 20; - (* 14pt padding on each side, 40pt per child, 16pt gaps. *) - Lui_ui.width context row (12 + (56 * children_count)); Lui_ui.append context parent row; mount_children row; row @@ -1302,12 +1313,7 @@ module View = struct if trailing <> [] || secondary <> [] then ignore - (capsule - context - node - ~children_count: - (List.length trailing + if secondary <> [] then 1 else 0) - (fun row -> + (capsule context node (fun row -> List.iter (fun item -> ignore (mount_icon_only row context item)) trailing; @@ -1364,7 +1370,7 @@ module View = struct Lui_ui.append context node fixed | _ -> ignore - (capsule context node ~children_count:1 (fun row -> + (capsule context node (fun row -> ignore (mount_icon_only row context item)))) items; node) From fb451721f1bdf4276e47ec096e043bc94c5a3e62 Mon Sep 17 00:00:00 2001 From: zy C Date: Wed, 23 Sep 2026 11:46:23 -0700 Subject: [PATCH 32/40] deps: pin lui to 6035f9e (covers menu-item expansion, spinner, menu-item size) The previous pin at 38096c4 only covered PR #9; the icon-size wire property (logseq/lui#11) and the UIActivityIndicatorView spinner (logseq/lui#10) merged after it, and the icon trigger cells rely on menu-item size. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- flutter/pubspec.lock | 4 ++-- flutter/pubspec.yaml | 2 +- logseq_journal.opam | 2 +- test/source_boundary_test.ml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 84e38db..7537599 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -519,8 +519,8 @@ packages: dependency: "direct main" description: path: "platform/flutter" - ref: 38096c4fab853e462e60fc04d4daadd1f06aa09a - resolved-ref: 38096c4fab853e462e60fc04d4daadd1f06aa09a + ref: 6035f9e6cd9ad788efd6324d7700f1ce1430b88c + resolved-ref: 6035f9e6cd9ad788efd6324d7700f1ce1430b88c url: "https://github.com/logseq/lui.git" source: git version: "0.1.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 7960b51..4b145b3 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: lui_flutter_backend: git: url: https://github.com/logseq/lui.git - ref: 38096c4fab853e462e60fc04d4daadd1f06aa09a + ref: 6035f9e6cd9ad788efd6324d7700f1ce1430b88c path: platform/flutter # journal-lui:end packages diff --git a/logseq_journal.opam b/logseq_journal.opam index 05e1a6d..1e6c88d 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#38096c4fab853e462e60fc04d4daadd1f06aa09a"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#6035f9e6cd9ad788efd6324d7700f1ce1430b88c"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index 389090d..100198a 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1170,7 +1170,7 @@ let () = require_occurrences root "logseq_journal.opam" - "git+https://github.com/logseq/lui.git#38096c4fab853e462e60fc04d4daadd1f06aa09a" + "git+https://github.com/logseq/lui.git#6035f9e6cd9ad788efd6324d7700f1ce1430b88c" 1; require_occurrences root From 597f3f26d0e545025a0507f09012fa4c885f909c Mon Sep 17 00:00:00 2001 From: zy C Date: Wed, 23 Sep 2026 20:02:21 -0700 Subject: [PATCH 33/40] deps: pin lui to b343560; adapt bar glyphs to the bordered default Latest lui maps a variantless button to .bordered (standard iOS chrome), which drew accent-colored button chrome inside the chrome capsules. Icon-collapsed bar controls and the back circle button now opt into variant=ghost + secondary foreground so they stay plain dark glyphs inside the pill. --- app/journal_view.ml | 12 +++++++++++- flutter/pubspec.lock | 4 ++-- flutter/pubspec.yaml | 2 +- logseq_journal.opam | 2 +- test/source_boundary_test.ml | 2 +- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/journal_view.ml b/app/journal_view.ml index 6b9e482..9f441fa 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -74,7 +74,15 @@ let set_leaf_label context node { title; icon } = (* Icon-only controls keep their name on the accessibility channel; the schema rejects icon-only buttons with no accessible name. *) if supported Lui_protocol.AccessibilityLabel - then Lui_ui.accessibility_label context node (if title = "" then " " else title)); + then Lui_ui.accessibility_label context node (if title = "" then " " else title); + (* Bar glyphs render chromeless inside the capsule; the default variant + now maps to a bordered accent button which would double-frame the + pill. *) + if supported Lui_protocol.VariantValue + then + Lui_ui.string_property context node Lui_protocol.VariantValue "ghost"; + if supported Lui_protocol.ForegroundValue + then Lui_ui.foreground context node "secondary"); Option.iter (fun name -> if supported Lui_protocol.InlineIconName @@ -1223,6 +1231,8 @@ module View = struct node Lui_protocol.InlineIconName (journal_icon_name icon); + Lui_ui.string_property context node Lui_protocol.VariantValue "ghost"; + Lui_ui.foreground context node "secondary"; Lui_ui.background context node "secondary"; Lui_ui.corner_radius context node 20; Lui_ui.width context node 40; diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 7537599..ec586f0 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -519,8 +519,8 @@ packages: dependency: "direct main" description: path: "platform/flutter" - ref: 6035f9e6cd9ad788efd6324d7700f1ce1430b88c - resolved-ref: 6035f9e6cd9ad788efd6324d7700f1ce1430b88c + ref: b3435604add8b1590ecfe4029bd496d4ccb395d8 + resolved-ref: b3435604add8b1590ecfe4029bd496d4ccb395d8 url: "https://github.com/logseq/lui.git" source: git version: "0.1.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 4b145b3..d6143f4 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: lui_flutter_backend: git: url: https://github.com/logseq/lui.git - ref: 6035f9e6cd9ad788efd6324d7700f1ce1430b88c + ref: b3435604add8b1590ecfe4029bd496d4ccb395d8 path: platform/flutter # journal-lui:end packages diff --git a/logseq_journal.opam b/logseq_journal.opam index 1e6c88d..7b0a21b 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#6035f9e6cd9ad788efd6324d7700f1ce1430b88c"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#b3435604add8b1590ecfe4029bd496d4ccb395d8"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index 100198a..15d1841 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1170,7 +1170,7 @@ let () = require_occurrences root "logseq_journal.opam" - "git+https://github.com/logseq/lui.git#6035f9e6cd9ad788efd6324d7700f1ce1430b88c" + "git+https://github.com/logseq/lui.git#b3435604add8b1590ecfe4029bd496d4ccb395d8" 1; require_occurrences root From 00ac54f067a4c33a372ff129643323cacba97a73 Mon Sep 17 00:00:00 2001 From: zy C Date: Wed, 23 Sep 2026 22:34:32 -0700 Subject: [PATCH 34/40] Mount bottom-bar capsules as lui toolbar inside box chrome Bottom-bar items that contain only controls now mount inside a lui toolbar node (the schema semantic for a control bar), wrapped in a box carrying the pill chrome. A row wrapper would auto-append a trailing spacer and stretch the capsule full width; a box hugs its content, so the pills stay compact. Groups mount their children flat into the enclosing toolbar since a wrapper row is not a legal toolbar child and a button-group would likewise stretch. --- app/journal_view.ml | 54 +++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/app/journal_view.ml b/app/journal_view.ml index 9f441fa..3108305 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -1155,7 +1155,19 @@ module View = struct let group ~key ?placement children = { item_key = key ; placement - ; content = row ~key children + ; content = + element ~key (fun context parent -> + (* Groups mount their children flat into the enclosing bar: the + toolbar capsule already is the group, and a wrapper + button-group/row is either not a legal toolbar child or would + stretch the capsule to full width. *) + (match parent with + | Some parent -> + List.fold_left + (fun _ child -> child.mount context (Some parent)) + 0 + children + | None -> 0)) ; spacing = None ; is_group = true } @@ -1203,21 +1215,35 @@ module View = struct mounted) ;; - let capsule context parent mount_children = + let capsule ?(toolbar_label = "") context parent mount_children = (* A row child of an HStack keeps its intrinsic width unless it grows, - so the pill hugs its controls without a pinned width. *) - let row = Lui_ui.row context in - Lui_ui.gap context row 16; + so the pill hugs its controls without a pinned width. When the + capsule holds only controls, children mount inside a [toolbar] + node — the schema semantic for a control bar — wrapped by a box + carrying the pill chrome: a toolbar accepts only + label/gap/orientation/style-class, and a row would auto-append a + trailing spacer and stretch to full width. *) + let node = + if toolbar_label = "" then Lui_ui.row context else Lui_ui.box context + in + Lui_ui.gap context node 16; (* Hug content height; the default stretch cross would soak the parent column's split share (see Navigation_stack back row). *) - Lui_ui.cross context row "center"; - Lui_ui.padding_horizontal context row 14; - Lui_ui.padding_vertical context row 9; - Lui_ui.background context row "secondary"; - Lui_ui.corner_radius context row 20; - Lui_ui.append context parent row; - mount_children row; - row + Lui_ui.cross context node "center"; + Lui_ui.padding_horizontal context node 14; + Lui_ui.padding_vertical context node 9; + Lui_ui.background context node "secondary"; + Lui_ui.corner_radius context node 20; + Lui_ui.append context parent node; + (match toolbar_label with + | "" -> mount_children node + | label -> + let toolbar = Lui_ui.toolbar context in + Lui_ui.accessibility_label context toolbar label; + Lui_ui.gap context toolbar 16; + Lui_ui.append context node toolbar; + mount_children toolbar); + node ;; let circle_button context parent ~icon ~on_press = @@ -1380,7 +1406,7 @@ module View = struct Lui_ui.append context node fixed | _ -> ignore - (capsule context node (fun row -> + (capsule ~toolbar_label:item.item_key context node (fun row -> ignore (mount_icon_only row context item)))) items; node) From b9a8b37bcf61bdcb3870ad524d037fba5b653856 Mon Sep 17 00:00:00 2001 From: zy C Date: Thu, 24 Sep 2026 01:46:46 -0700 Subject: [PATCH 35/40] ios: hoist journal chrome into native toolbars --- app/application.ml | 8 +- app/journal_view.ml | 285 ++++++++++++++++----------------- flutter/pubspec.lock | 4 +- flutter/pubspec.yaml | 2 +- logseq_journal.opam | 2 +- swift/JournalRuntimeHost.swift | 7 +- test/source_boundary_test.ml | 2 +- 7 files changed, 151 insertions(+), 159 deletions(-) diff --git a/app/application.ml b/app/application.ml index 97d7eda..078fbdd 100644 --- a/app/application.ml +++ b/app/application.ml @@ -5124,6 +5124,7 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = container. *) Lui_elements.column [ Lui_elements.dyn + ~equal:( == ) (fun model -> Journal_view.mount (body_view @@ -5131,12 +5132,7 @@ let start ~calendar_sampler ~client ~platform_code ~host_code : app_context = dispatch timeline_scroll_completed detail_scroll_completed)) - (* `dyn` remounts the whole tree on every publish (its key equality - is `fun _ _ -> false`). Reducers that return the identical record - must not republish — otherwise mount-time echoes (fresh text - fields, extension `.task` emits) loop forever: remount → echo → - publish → remount. *) - (Signal.cutoff ( == ) model_signal) + model_signal ] in let os = diff --git a/app/journal_view.ml b/app/journal_view.ml index 3108305..fe34bf5 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -82,7 +82,7 @@ let set_leaf_label context node { title; icon } = then Lui_ui.string_property context node Lui_protocol.VariantValue "ghost"; if supported Lui_protocol.ForegroundValue - then Lui_ui.foreground context node "secondary"); + then Lui_ui.foreground context node "foreground"); Option.iter (fun name -> if supported Lui_protocol.InlineIconName @@ -1215,54 +1215,19 @@ module View = struct mounted) ;; - let capsule ?(toolbar_label = "") context parent mount_children = - (* A row child of an HStack keeps its intrinsic width unless it grows, - so the pill hugs its controls without a pinned width. When the - capsule holds only controls, children mount inside a [toolbar] - node — the schema semantic for a control bar — wrapped by a box - carrying the pill chrome: a toolbar accepts only - label/gap/orientation/style-class, and a row would auto-append a - trailing spacer and stretch to full width. *) - let node = - if toolbar_label = "" then Lui_ui.row context else Lui_ui.box context - in - Lui_ui.gap context node 16; - (* Hug content height; the default stretch cross would soak the - parent column's split share (see Navigation_stack back row). *) - Lui_ui.cross context node "center"; - Lui_ui.padding_horizontal context node 14; - Lui_ui.padding_vertical context node 9; - Lui_ui.background context node "secondary"; - Lui_ui.corner_radius context node 20; - Lui_ui.append context parent node; - (match toolbar_label with - | "" -> mount_children node - | label -> - let toolbar = Lui_ui.toolbar context in - Lui_ui.accessibility_label context toolbar label; - Lui_ui.gap context toolbar 16; - Lui_ui.append context node toolbar; - mount_children toolbar); - node - ;; - - let circle_button context parent ~icon ~on_press = + let nav_button context parent ~icon ~label ~on_press = let node = Lui_ui.button context in Lui_ui.text_property context node ""; (* Icon-only button: the schema rejects empty text + icon without an accessibility label. *) - Lui_ui.accessibility_label context node "Back"; + Lui_ui.accessibility_label context node label; Lui_ui.string_property context node Lui_protocol.InlineIconName (journal_icon_name icon); Lui_ui.string_property context node Lui_protocol.VariantValue "ghost"; - Lui_ui.foreground context node "secondary"; - Lui_ui.background context node "secondary"; - Lui_ui.corner_radius context node 20; - Lui_ui.width context node 40; - Lui_ui.height context node 40; + Lui_ui.foreground context node "foreground"; Lui_ui.on_event context node (fun event -> if is_press event then on_press ()); Lui_ui.append context parent node; node @@ -1285,129 +1250,123 @@ module View = struct (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); - let leading = - List.filter - (fun (item : item) -> - match item.placement with - | Some (Navigation | Cancellation_action) -> true - | _ -> false) - items - and principal = - List.filter - (fun (item : item) -> item.placement = Some Principal) - items - and secondary = - List.filter - (fun (item : item) -> item.placement = Some Secondary_action) - items + (* Items hoist into the platform chrome by placement: each + `placement` toolbar emits its children as system ToolbarItems + (the navigation bar's leading/principal/trailing areas on iOS, + the window toolbar on macOS) — groups fuse into one capsule. + Hosts without chrome hoisting render the toolbar's inline row + content instead, so this row keeps the emulated arrangement. *) + let emit_bar placement mounts = + let toolbar = Lui_ui.toolbar context in + Lui_ui.accessibility_label context toolbar "navigation"; + Lui_ui.placement context toolbar placement; + Lui_ui.gap context toolbar 16; + Lui_ui.append context node toolbar; + mounts toolbar; + toolbar in - let trailing = - List.filter - (fun (item : item) -> - match item.placement with - | Some - ( Navigation | Cancellation_action | Principal - | Secondary_action | Bottom_bar ) - | None -> false - | Some _ -> true) + let of_placement p = + List.filter (fun (item : item) -> item.placement = Some p) items + in + let mount_bar_items toolbar items = + List.iter + (fun (item : item) -> ignore (mount_icon_only toolbar context item)) items in - (match !nav_bar with - | Some { nav_can_pop = true; nav_on_change; nav_remaining; _ } -> - ignore - (circle_button - context - node - ~icon:"chevron.left" - ~on_press:(fun () -> - invoke nav_on_change - (Event.Payload.Navigation_path_changed nav_remaining))) - | _ -> ()); - List.iter - (fun item -> ignore (mount_icon_only node context item)) - leading; - ignore (flexible_space context node); - (match principal with - | [] -> - (match !nav_bar with - | Some { nav_title = title; _ } when title <> "" -> - let title_node = Lui_ui.text context title in - Lui_ui.style_class context title_node "semibold"; - Lui_ui.append context node title_node - | _ -> ()) - | _ -> - List.iter - (fun item -> - let mounted = item.content.mount context (Some node) in - if mounted <> 0 - then ( - Lui_ui.key context mounted item.item_key; - if node_is_standard context mounted - then Lui_ui.style_class context mounted "semibold")) - principal); - ignore (flexible_space context node); - if trailing <> [] || secondary <> [] + let navigation = of_placement Navigation + and cancellation = of_placement Cancellation_action + and principal = of_placement Principal in + let nav_can_pop = + match !nav_bar with + | Some { nav_can_pop = true; _ } -> true + | _ -> false + in + if nav_can_pop || navigation <> [] then ignore - (capsule context node (fun row -> - List.iter - (fun item -> ignore (mount_icon_only row context item)) - trailing; - if secondary <> [] - then ( - (* Secondary actions collapse into the "more" overflow the - system bar showed. *) - let trigger = Lui_ui.menu_item context in - Lui_ui.text_property context trigger " "; - (* accessibility-label is not in the menu-item schema; the - whitespace text is what the validator accepts. *) - Lui_ui.string_property - context - trigger - Lui_protocol.InlineIconName - (journal_icon_name "ellipsis"); - (* The menu label grows to fill available space; cap it so - the trigger stays icon-sized inside the capsule. *) - Lui_ui.width context trigger 40; - Lui_ui.string_property - context - trigger - Lui_protocol.SizeValue - "sm"; - Lui_ui.append context row trigger; - let menu = Lui_ui.dropdown_menu context in - Lui_ui.append context trigger menu; - List.iter - (fun (item : item) -> - match item.content.menu_item_mount with - | Some mount -> ignore (mount context (Some menu)) - | None -> ignore (mount_icon_only row context item)) - secondary))); + (emit_bar "navigation" (fun toolbar -> + (match !nav_bar with + | Some { nav_can_pop = true; nav_on_change; nav_remaining; _ } -> + ignore + (nav_button + context + toolbar + ~icon:"chevron.left" + ~label:"Back" + ~on_press:(fun () -> + invoke nav_on_change + (Event.Payload.Navigation_path_changed nav_remaining))) + | _ -> ()); + mount_bar_items toolbar navigation)); + if cancellation <> [] + then + ignore + (emit_bar "cancellation-action" (fun toolbar -> + mount_bar_items toolbar cancellation)); + ignore (flexible_space context node); + (match principal, !nav_bar with + | [], Some { nav_title = title; _ } when title <> "" -> + ignore + (emit_bar "principal" (fun toolbar -> + let title_node = Lui_ui.text context title in + Lui_ui.style_class context title_node "semibold"; + Lui_ui.append context toolbar title_node)) + | [], _ -> () + | _ :: _, _ -> + ignore + (emit_bar "principal" (fun toolbar -> + List.iter + (fun (item : item) -> + let mounted = item.content.mount context (Some toolbar) in + if mounted <> 0 + then ( + Lui_ui.key context mounted item.item_key; + if node_is_standard context mounted + then Lui_ui.style_class context mounted "semibold")) + principal))); + ignore (flexible_space context node); + List.iter + (fun (placement, items) -> + if items <> [] + then + ignore (emit_bar placement (fun toolbar -> mount_bar_items toolbar items))) + [ "primary-action", of_placement Primary_action + ; "automatic", List.filter (fun (i : item) -> i.placement = None) items + ; "status", of_placement Status + ; "confirmation-action", of_placement Confirmation_action + ; "destructive-action", of_placement Destructive_action + ; "secondary-action", of_placement Secondary_action + ]; node) ;; let mount_bottom_bar items = element (fun context parent -> - let node = Lui_ui.row context in - Lui_ui.gap context node 10; - Lui_ui.cross context node "center"; - Lui_ui.padding_horizontal context node 12; - Lui_ui.padding_vertical context node 8; + (* A `placement "bottom"` toolbar maps to the platform bottom bar on + iOS — the system renders each group as a floating glass capsule, + spacers flex between them, and scroll content insets around the + bar. Groups need a button-group child so the bar renders one + capsule per group instead of one capsule per button. *) + let node = Lui_ui.toolbar context in + Lui_ui.accessibility_label context node "actions"; + Lui_ui.placement context node "bottom"; + Lui_ui.gap context node 16; (match parent with | Some parent -> Lui_ui.append context parent node | None -> ()); List.iter (fun (item : item) -> match item.spacing with - | Some Flexible -> ignore (flexible_space context node) - | Some Fixed -> - let fixed = Lui_ui.spacer context in - Lui_ui.width context fixed 16; - Lui_ui.append context node fixed - | _ -> - ignore - (capsule ~toolbar_label:item.item_key context node (fun row -> - ignore (mount_icon_only row context item)))) + | Some _ -> + let spacer = Lui_ui.spacer context in + Lui_ui.append context node spacer + | None -> + if item.is_group + then ( + let group = Lui_ui.button_group context in + Lui_ui.append context node group; + ignore (mount_icon_only group context item)) + else ignore (mount_icon_only node context item)) items; node) ;; @@ -2539,10 +2498,42 @@ module Native_widget = struct let mount extension ?key ~props ~on_event ~children context parent = let payload = Bytes.to_string (extension.Extension.encode_props props) in + (* journal-chrome slots 1..3 (account / error / progress) are the floating + chrome affordances the old host rendered as icon-only circles. Mount + them under the icon-only collapse; slot 0 is page content and stays + uncollapsed. *) + let chrome_slots = + extension.Extension.identifier = Journal_lui_native.chrome_identifier + in + let children = + List.mapi + (fun index element -> + if chrome_slots && index > 0 + then + fun context parent -> + let previous = !icon_only + and previous_nodes = !icon_only_collapsed_nodes in + icon_only := true; + icon_only_collapsed_nodes := []; + Fun.protect + ~finally:(fun () -> + icon_only := previous; + icon_only_collapsed_nodes := previous_nodes) + (fun () -> + let mounted = element.mount context parent in + List.iter + (fun node -> + if node_is_standard context node + then Lui_ui.width context node 40) + !icon_only_collapsed_nodes; + mounted) + else element.mount) + children + in Journal_lui_native.mount ?key ~payload - ~children:(List.map (fun element -> element.mount) children) + ~children ~on_event:(fun event -> match decode extension event with | Ok decoded -> on_event decoded diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index ec586f0..bb18724 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -519,8 +519,8 @@ packages: dependency: "direct main" description: path: "platform/flutter" - ref: b3435604add8b1590ecfe4029bd496d4ccb395d8 - resolved-ref: b3435604add8b1590ecfe4029bd496d4ccb395d8 + ref: 9181a9f2fada60af325a200be0dc94b6241fe85a + resolved-ref: 9181a9f2fada60af325a200be0dc94b6241fe85a url: "https://github.com/logseq/lui.git" source: git version: "0.1.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index d6143f4..01a6e18 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: lui_flutter_backend: git: url: https://github.com/logseq/lui.git - ref: b3435604add8b1590ecfe4029bd496d4ccb395d8 + ref: 9181a9f2fada60af325a200be0dc94b6241fe85a path: platform/flutter # journal-lui:end packages diff --git a/logseq_journal.opam b/logseq_journal.opam index 7b0a21b..d586a71 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#b3435604add8b1590ecfe4029bd496d4ccb395d8"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#9181a9f2fada60af325a200be0dc94b6241fe85a"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] diff --git a/swift/JournalRuntimeHost.swift b/swift/JournalRuntimeHost.swift index d1a7bd4..46e4d50 100644 --- a/swift/JournalRuntimeHost.swift +++ b/swift/JournalRuntimeHost.swift @@ -15,7 +15,12 @@ struct JournalRuntimeHost: View { var body: some SwiftUI.View { Group { if let runtime, let rootID = runtime.rootID { - LUISwiftUIRoot(backend: runtime.backend, rootID: rootID) + // The lui widget set has no navigation-stack node: the shell stack + // supplies the system nav bar and bottom bar so toolbar nodes with a + // `placement` prop hoist into real platform chrome. + NavigationStack { + LUISwiftUIRoot(backend: runtime.backend, rootID: rootID) + } } else { ProgressView("Opening journal") } diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index 15d1841..629b072 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1170,7 +1170,7 @@ let () = require_occurrences root "logseq_journal.opam" - "git+https://github.com/logseq/lui.git#b3435604add8b1590ecfe4029bd496d4ccb395d8" + "git+https://github.com/logseq/lui.git#9181a9f2fada60af325a200be0dc94b6241fe85a" 1; require_occurrences root From 6f2e733a4a3f29f50187034e42dd0ca074b8a9a0 Mon Sep 17 00:00:00 2001 From: zy C Date: Thu, 24 Sep 2026 01:50:37 -0700 Subject: [PATCH 36/40] ios: bump lui pin to toolbar artifact regeneration --- flutter/pubspec.lock | 4 ++-- flutter/pubspec.yaml | 2 +- logseq_journal.opam | 2 +- test/source_boundary_test.ml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index bb18724..4f56feb 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -519,8 +519,8 @@ packages: dependency: "direct main" description: path: "platform/flutter" - ref: 9181a9f2fada60af325a200be0dc94b6241fe85a - resolved-ref: 9181a9f2fada60af325a200be0dc94b6241fe85a + ref: fbd2441d7530e000b09b7090067eba94f37ea7ae + resolved-ref: fbd2441d7530e000b09b7090067eba94f37ea7ae url: "https://github.com/logseq/lui.git" source: git version: "0.1.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 01a6e18..e1fd699 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: lui_flutter_backend: git: url: https://github.com/logseq/lui.git - ref: 9181a9f2fada60af325a200be0dc94b6241fe85a + ref: fbd2441d7530e000b09b7090067eba94f37ea7ae path: platform/flutter # journal-lui:end packages diff --git a/logseq_journal.opam b/logseq_journal.opam index d586a71..ba4b24e 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#9181a9f2fada60af325a200be0dc94b6241fe85a"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#fbd2441d7530e000b09b7090067eba94f37ea7ae"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index 629b072..f956bd6 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1170,7 +1170,7 @@ let () = require_occurrences root "logseq_journal.opam" - "git+https://github.com/logseq/lui.git#9181a9f2fada60af325a200be0dc94b6241fe85a" + "git+https://github.com/logseq/lui.git#fbd2441d7530e000b09b7090067eba94f37ea7ae" 1; require_occurrences root From a3369950ca9bd664c2cb3237d00ebad7f6cfdd61 Mon Sep 17 00:00:00 2001 From: zy C Date: Thu, 24 Sep 2026 01:53:55 -0700 Subject: [PATCH 37/40] ios: bump lui pin to rebased toolbar branch --- flutter/pubspec.lock | 4 ++-- flutter/pubspec.yaml | 2 +- logseq_journal.opam | 2 +- test/source_boundary_test.ml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 4f56feb..8693963 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -519,8 +519,8 @@ packages: dependency: "direct main" description: path: "platform/flutter" - ref: fbd2441d7530e000b09b7090067eba94f37ea7ae - resolved-ref: fbd2441d7530e000b09b7090067eba94f37ea7ae + ref: e0dcf36eed3ca79ad2842e2066ccfdfbb60ab963 + resolved-ref: e0dcf36eed3ca79ad2842e2066ccfdfbb60ab963 url: "https://github.com/logseq/lui.git" source: git version: "0.1.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index e1fd699..bb0cf9d 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: lui_flutter_backend: git: url: https://github.com/logseq/lui.git - ref: fbd2441d7530e000b09b7090067eba94f37ea7ae + ref: e0dcf36eed3ca79ad2842e2066ccfdfbb60ab963 path: platform/flutter # journal-lui:end packages diff --git a/logseq_journal.opam b/logseq_journal.opam index ba4b24e..2177b56 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#fbd2441d7530e000b09b7090067eba94f37ea7ae"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#e0dcf36eed3ca79ad2842e2066ccfdfbb60ab963"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index f956bd6..0b01d67 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1170,7 +1170,7 @@ let () = require_occurrences root "logseq_journal.opam" - "git+https://github.com/logseq/lui.git#fbd2441d7530e000b09b7090067eba94f37ea7ae" + "git+https://github.com/logseq/lui.git#e0dcf36eed3ca79ad2842e2066ccfdfbb60ab963" 1; require_occurrences root From 9f6eae220b209719ff32cf3c6d6dabaef518f4c7 Mon Sep 17 00:00:00 2001 From: zy C Date: Thu, 24 Sep 2026 02:29:42 -0700 Subject: [PATCH 38/40] ios: bump lui pin to merged toolbar support --- flutter/pubspec.lock | 4 ++-- flutter/pubspec.yaml | 2 +- logseq_journal.opam | 2 +- test/source_boundary_test.ml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 8693963..952d709 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -519,8 +519,8 @@ packages: dependency: "direct main" description: path: "platform/flutter" - ref: e0dcf36eed3ca79ad2842e2066ccfdfbb60ab963 - resolved-ref: e0dcf36eed3ca79ad2842e2066ccfdfbb60ab963 + ref: ca653bade146822ea3df2afa31c6eeb9a6887857 + resolved-ref: ca653bade146822ea3df2afa31c6eeb9a6887857 url: "https://github.com/logseq/lui.git" source: git version: "0.1.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index bb0cf9d..28637e7 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: lui_flutter_backend: git: url: https://github.com/logseq/lui.git - ref: e0dcf36eed3ca79ad2842e2066ccfdfbb60ab963 + ref: ca653bade146822ea3df2afa31c6eeb9a6887857 path: platform/flutter # journal-lui:end packages diff --git a/logseq_journal.opam b/logseq_journal.opam index 2177b56..78c4605 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#e0dcf36eed3ca79ad2842e2066ccfdfbb60ab963"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#ca653bade146822ea3df2afa31c6eeb9a6887857"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index 0b01d67..c2c5084 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1170,7 +1170,7 @@ let () = require_occurrences root "logseq_journal.opam" - "git+https://github.com/logseq/lui.git#e0dcf36eed3ca79ad2842e2066ccfdfbb60ab963" + "git+https://github.com/logseq/lui.git#ca653bade146822ea3df2afa31c6eeb9a6887857" 1; require_occurrences root From 21347931fdf0abbaa5ee23f98baae72ad1e75177 Mon Sep 17 00:00:00 2001 From: zy C Date: Thu, 24 Sep 2026 03:26:22 -0700 Subject: [PATCH 39/40] lui: pin to 271fe33 (dyn remounts via reconcile_subtree) Fixes the timeline scroll regression: dyn branches previously dropped and recreated their whole subtree on every publish, which destroyed the journal-list UICollectionView and snapped scroll position to the top continuously. With reconcile, same-kind nodes keep their ids so the list survives patches. --- flutter/pubspec.lock | 4 ++-- flutter/pubspec.yaml | 2 +- logseq_journal.opam | 2 +- test/source_boundary_test.ml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 952d709..71b7539 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -519,8 +519,8 @@ packages: dependency: "direct main" description: path: "platform/flutter" - ref: ca653bade146822ea3df2afa31c6eeb9a6887857 - resolved-ref: ca653bade146822ea3df2afa31c6eeb9a6887857 + ref: 271fe332db6db6c0138593d75f267388b41c69b3 + resolved-ref: 271fe332db6db6c0138593d75f267388b41c69b3 url: "https://github.com/logseq/lui.git" source: git version: "0.1.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 28637e7..910610d 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: lui_flutter_backend: git: url: https://github.com/logseq/lui.git - ref: ca653bade146822ea3df2afa31c6eeb9a6887857 + ref: 271fe332db6db6c0138593d75f267388b41c69b3 path: platform/flutter # journal-lui:end packages diff --git a/logseq_journal.opam b/logseq_journal.opam index 78c4605..c8a8797 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#ca653bade146822ea3df2afa31c6eeb9a6887857"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#271fe332db6db6c0138593d75f267388b41c69b3"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index c2c5084..411e13b 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1170,7 +1170,7 @@ let () = require_occurrences root "logseq_journal.opam" - "git+https://github.com/logseq/lui.git#ca653bade146822ea3df2afa31c6eeb9a6887857" + "git+https://github.com/logseq/lui.git#271fe332db6db6c0138593d75f267388b41c69b3" 1; require_occurrences root From 74f1c0871a136b22e6fa4950ed45f38d70a09212 Mon Sep 17 00:00:00 2001 From: zy C Date: Thu, 24 Sep 2026 03:42:22 -0700 Subject: [PATCH 40/40] lui: pin to merged 38ada4a (dyn remounts via reconcile_subtree) Replaces the pre-merge pin 271fe33 with the squash-merged main commit. Same fix: dyn branches reconcile instead of drop+create, so the journal-list collection view survives publishes and timeline scrolling works. --- flutter/pubspec.lock | 4 ++-- flutter/pubspec.yaml | 2 +- logseq_journal.opam | 2 +- test/source_boundary_test.ml | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/flutter/pubspec.lock b/flutter/pubspec.lock index 71b7539..f1f5b5f 100644 --- a/flutter/pubspec.lock +++ b/flutter/pubspec.lock @@ -519,8 +519,8 @@ packages: dependency: "direct main" description: path: "platform/flutter" - ref: 271fe332db6db6c0138593d75f267388b41c69b3 - resolved-ref: 271fe332db6db6c0138593d75f267388b41c69b3 + ref: 38ada4a886d3d783821eba0dff58108c32284a1b + resolved-ref: 38ada4a886d3d783821eba0dff58108c32284a1b url: "https://github.com/logseq/lui.git" source: git version: "0.1.0" diff --git a/flutter/pubspec.yaml b/flutter/pubspec.yaml index 910610d..0369bf7 100644 --- a/flutter/pubspec.yaml +++ b/flutter/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: lui_flutter_backend: git: url: https://github.com/logseq/lui.git - ref: 271fe332db6db6c0138593d75f267388b41c69b3 + ref: 38ada4a886d3d783821eba0dff58108c32284a1b path: platform/flutter # journal-lui:end packages diff --git a/logseq_journal.opam b/logseq_journal.opam index c8a8797..ef7c629 100644 --- a/logseq_journal.opam +++ b/logseq_journal.opam @@ -46,7 +46,7 @@ depends: [ ] pin-depends: [ ["rrbvec.dev" "git+https://github.com/RCmerci/rrbvec.git#dd5ce904f91d53235b5136f7a771f3f074c3971d"] - ["lui.0.1.0" "git+https://github.com/logseq/lui.git#271fe332db6db6c0138593d75f267388b41c69b3"] + ["lui.0.1.0" "git+https://github.com/logseq/lui.git#38ada4a886d3d783821eba0dff58108c32284a1b"] ["ocaml-signal.0.1.0" "git+https://github.com/logseq/ocaml-signal.git#48a4a4d37f87addbb28d85a10a55bd13becf94be"] ["datascript_ocaml.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] ["datascript-ocaml-native.dev" "git+https://github.com/logseq/datascript-ocaml.git#40345cc2f59214daa88b33b8aec711337d20afa7"] diff --git a/test/source_boundary_test.ml b/test/source_boundary_test.ml index 411e13b..0e2264e 100644 --- a/test/source_boundary_test.ml +++ b/test/source_boundary_test.ml @@ -1170,7 +1170,7 @@ let () = require_occurrences root "logseq_journal.opam" - "git+https://github.com/logseq/lui.git#271fe332db6db6c0138593d75f267388b41c69b3" + "git+https://github.com/logseq/lui.git#38ada4a886d3d783821eba0dff58108c32284a1b" 1; require_occurrences root