diff --git a/.agents/skills/testing-ios-simulator/SKILL.md b/.agents/skills/testing-ios-simulator/SKILL.md index af192c3..a1a1760 100644 --- a/.agents/skills/testing-ios-simulator/SKILL.md +++ b/.agents/skills/testing-ios-simulator/SKILL.md @@ -5,24 +5,52 @@ 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 26.5+ simulator — verified 26.5 AND 27.0) -- `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". +- **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`. -## Install / launch / record +## LUI runtime gotchas (found 2026-09-23, golden path verified) -- `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`. +- **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`. +- 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 +72,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/.gitignore b/.gitignore index 0c9a01e..77e37b7 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 @@ -35,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 adc76b3..078fbdd 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 @@ -1289,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 (Ui.Test_id.to_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 = @@ -1320,9 +1340,9 @@ 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:(Ui.Key.string "content") + ~key:"content" (V.column ~alignment:Leading children |> V.text_selection ~enabled:true) ] ;; @@ -1520,10 +1540,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 @@ -2505,8 +2525,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 = @@ -2770,80 +2791,113 @@ 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 + 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 +;; + +(* 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; + 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 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 - })) + let set_state transition : unit Effect.t = + fun () -> send_action (Update (fun state -> transition state, Effect.ignore)) 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_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, 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 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 +2936,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 +2972,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 +3004,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 +3026,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 +3049,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 +3078,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 +3149,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 +3207,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 +3226,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 +3241,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 +3260,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 +3275,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 +3567,1635 @@ 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 () -> + (* 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 + | 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; _ }; _ } -> + 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 + | 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 = + (* Dynamic elements mount under a parent, so the root must be a static + container. *) + Lui_elements.column + [ Lui_elements.dyn + ~equal:( == ) + (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))); + (* 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 () -> + 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 +5208,139 @@ 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 := ""; + 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 + | 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 -> + (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 = + 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 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 + | 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 + ; platform_failure + ; dispose + ; root_node + } ;; module For_testing = struct @@ -5250,4 +5417,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/dune b/app/dune index 1d14330..774c324 100644 --- a/app/dune +++ b/app/dune @@ -3,6 +3,12 @@ (wrapped false) (modules application + journal_bridge + journal_environment + journal_ids + journal_lui_native + journal_pump + journal_view journal_uploads journal_asset_import journal_asset_settings @@ -33,35 +39,27 @@ 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) + (libraries app lui) (link_flags (:standard - -cclib - -L%{env:BONSAI_SWIFTUI_APPLE_SDK_ROOT=}/usr/lib (:include native_static_link_flags.sexp))) (modes (native object))) @@ -72,3 +70,20 @@ (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"))) + +; 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/app/journal_asset_import.ml b/app/journal_asset_import.ml index b4c4563..f27562f 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)) @@ -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_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 new file mode 100644 index 0000000..4b690c3 --- /dev/null +++ b/app/journal_bridge.ml @@ -0,0 +1,71 @@ +type hooks = + { init : int -> int -> string -> string + ; dispatch : Lui_protocol.event -> string + ; extension_event : int -> string -> string -> string + ; pump : unit -> string + ; platform_event : string -> unit + ; platform_response : string -> unit + ; platform_failure : 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 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 = + 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 platform_failure payload = (hooks ()).platform_failure 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; + Callback.register "journal_ocaml_platform_failure" platform_failure +;; diff --git a/app/journal_bridge.mli b/app/journal_bridge.mli new file mode 100644 index 0000000..f52b6a1 --- /dev/null +++ b/app/journal_bridge.mli @@ -0,0 +1,49 @@ +(** 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 = + { 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 + 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 + ; (* 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 + } + +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_capture.ml b/app/journal_capture.ml index 22e9102..29f5945 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 @@ -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_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..e138bb2 --- /dev/null +++ b/app/journal_environment.ml @@ -0,0 +1,204 @@ +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..28b0252 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) @@ -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 ;; @@ -121,18 +124,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:"" + ~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: @@ -277,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_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 new file mode 100644 index 0000000..e42cab7 --- /dev/null +++ b/app/journal_lui_bridge.c @@ -0,0 +1,290 @@ +#include +#include +#include +#include + +#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 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); + 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, + 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(); + 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), + payload_value); + accepted = emit_patch("lui_ocaml_init", 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) { + result = emit_patch(name, caml_callback_exn(*dispatch, Val_long(node))); + } + caml_enter_blocking_section(); + return result; +} + +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) { + int result = 0; + caml_leave_blocking_section(); + const value *dispatch = caml_named_value("lui_ocaml_text_changed"); + if (dispatch != NULL) { + result = emit_patch("lui_ocaml_text_changed", 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) { + 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) { + int result = 0; + caml_leave_blocking_section(); + const value *dispatch = caml_named_value("lui_ocaml_toggle_changed"); + if (dispatch != NULL) { + result = emit_patch("lui_ocaml_toggle_changed", 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) { + return dispatch_long("lui_ocaml_radio_changed", 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) { + result = emit_patch("lui_ocaml_slider_changed", 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) { + result = emit_patch("lui_ocaml_dispose", + 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) { + value result = caml_callback_exn(*root, Val_unit); + if (Is_exception_result(result)) { + report_ocaml_exception("lui_ocaml_root_node", result); + } else { + node = (int64_t)Long_val(result); + } + } + caml_enter_blocking_section(); + return node; +} + +/* ---- 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) { + int result = 0; + caml_leave_blocking_section(); + const value *dispatch = caml_named_value("journal_ocaml_extension_event"); + if (dispatch != NULL) { + 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; +} + +/* 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) { + result = emit_patch("journal_ocaml_pump", + 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) { + value payload = copy_bytes(data, length); + value result = caml_callback_exn(*handler, payload); + if (Is_exception_result(result)) { + report_ocaml_exception(name, result); + } + } + caml_enter_blocking_section(); +} + +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); +} + +/* 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) { + 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..3c4996b --- /dev/null +++ b/app/journal_lui_native.ml @@ -0,0 +1,153 @@ +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 + (* 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 children [ payload_property ] []); + register_component + registry + (component + asset_import_identifier + all_host_profiles + false + children + [ payload_property ] + [ event_schema ]); + register_component + registry + (component + media_identifier + all_host_profiles + true + children + [ payload_property ] + [ event_schema ]); + register_component + registry + (component + asset_settings_identifier + all_host_profiles + true + children + [ payload_property ] + [ event_schema ]); + register_component + registry + (component + list_identifier + all_host_profiles + true + children + [ payload_property ] + [ event_schema ]); + freeze registry; + registry +;; + +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 +;; + +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 new file mode 100644 index 0000000..3580a39 --- /dev/null +++ b/app/journal_lui_native.mli @@ -0,0 +1,83 @@ +(** 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 + +(** 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 + +(** 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..76d5d1e 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 @@ -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_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..c81ae29 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) @@ -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_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..044f4fa 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,65 @@ 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..af7f8b8 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,27 @@ 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_pump.ml b/app/journal_pump.ml new file mode 100644 index 0000000..9ea6e31 --- /dev/null +++ b/app/journal_pump.ml @@ -0,0 +1,39 @@ +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/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..fe34bf5 --- /dev/null +++ b/app/journal_view.ml @@ -0,0 +1,2570 @@ +[@@@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 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 + ; menu_item_mount : Lui_elements.t option + } + +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 ?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) + +(* 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 +;; + +(* 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 + +(* [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 } = + let kind = Lui_ui.node_kind context node in + let supported property = Lui_protocol.property_supported kind property in + 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_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 + 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 "foreground"); + 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 + if node_is_standard context node then 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 + +(* 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 + + 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 -> "secondary" + | Normal -> "default" + ;; + end + + module Button_style = struct + type t = + | Automatic + | Plain + | Bordered + | Prominent + | Button + + let variant = function + | Plain -> "ghost" + | Bordered -> "outline" + | Prominent -> "primary" + | Button -> "default" + | Automatic -> "default" + ;; + 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 + (* 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 } + ;; + + 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)) + 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 (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)) + 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 () = + 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 -> + 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 + ;; + + 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)); + (* 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 -> ()); + 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 () = + 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; + 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) -> + invoke on_changed (Event.Payload.Bool selected) + | _ -> ()); + (match parent with + | Some parent -> Lui_ui.append context parent node + | None -> ()); + 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; + 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 + (* 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 -> ()); + 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 + ((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 ((center_horizontally actions).mount context (Some node))) + actions; + spacer (); + 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_text ?footer entries = + element ?key (fun context parent -> + (* 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 card + | None -> ()); + List.iter + (fun (entry : Keyed.t) -> ignore (entry.view.mount context (Some card))) + entries; + Option.iter (fun footer -> ignore (footer.mount context parent)) footer; + card) + ;; + 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 = + 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 + } + ;; + + let spacer ~key ?placement spacing = + { item_key = key + ; placement + ; 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 = 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 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 = 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 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 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 "foreground"; + 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 -> + let node = Lui_ui.row context in + 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 -> ()); + (* 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 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 + 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 + (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 -> + (* 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 _ -> + 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) + ;; + + 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 -> ()); + 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 + + 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 = 1.) t = + modify (fun context node -> Lui_ui.grow context node weight) t + ;; + + let create ?key children = column ?key children + end + + module Horizontal = struct + type child = t + + let fixed 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 + + 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 = 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 -> + 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 menu item) + actions; + 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.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)); + 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 -> ()); + node) + ;; + end + + module Navigation_stack = struct + type destination = + { page_key : string + ; title : string + ; can_pop : bool + ; content : t + } + + 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 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 + (* 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 -> ()); + 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 + + module Sheet = struct + type sizing = + | Automatic + | Form + | Fitted + + type detent = + | Medium + | Large + + let create + ?key + ~presented + ~on_presented_changed + ?(interactive_dismiss = true) + ?sizing:_ + ?detents:_ + ?(title = "") + ~content + base + = + 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 (base.mount context (Some node)); + 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 + 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) + | _ -> ()); + 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 : label + ; enabled : bool + ; role : Button_role.t + } + | Choice of + { id : int64 + ; label : label + ; selected : bool + ; enabled : bool + } + | Divider of int64 + | Section of + { id : int64 + ; label : label option + ; entries : entry list + } + | Submenu of + { id : int64 + ; label : label + ; enabled : bool + ; entries : entry list + } + + let action ~id ~title ?icon ?(enabled = true) ?(role = Button_role.Normal) () = + Action { id; label = { title; icon }; enabled; role } + ;; + + let choice ~id ~title ?icon ~selected ?(enabled = true) () = + Choice { id; label = { title; icon }; selected; enabled } + ;; + + let divider ~id = Divider id + + let section ~id ?title ?icon entries = + Section { id; label = Option.map (fun title -> { title; icon }) title; 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; + (* 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 -> + 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 = + menu_item + context + ~title + ~icon + ~enabled + ~role:Button_role.Normal + ~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, 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 -> ()); + 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 "horizontal" in + Lui_ui.key context separator (Int64.to_string id); + Lui_ui.append context parent separator + | 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 menu) 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-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 + ~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..2f1f7f0 --- /dev/null +++ b/app/journal_view.mli @@ -0,0 +1,910 @@ +(** Journal view shim over lui elements, mirroring the BonsaiSwiftUI view API + used across the app layer. *) + +type t + +val mount : t -> Lui_elements.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_text:string -> ?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 + -> ?title:string + -> 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 + -> title:string + -> ?icon:string + -> ?enabled:bool + -> ?role:Button_role.t + -> 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 -> ?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 + -> title:string + -> ?icon:string + -> 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/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/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..b23f2fb 100644 --- a/apple-tests/amplify/hub_fixture.ml +++ b/apple-tests/amplify/hub_fixture.ml @@ -1,10 +1,105 @@ -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..3b62f57 100644 --- a/apple-tests/editor/composer_probe.ml +++ b/apple-tests/editor/composer_probe.ml @@ -1,54 +1,130 @@ -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..c24235e 100644 --- a/apple-tests/native-outline/outline_probe.ml +++ b/apple-tests/native-outline/outline_probe.ml @@ -1,70 +1,235 @@ -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) +(* 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 + 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 - let expansion = - Bonsai.Cont.map2 expanded set_expanded ~f:(fun expanded set_expanded -> - expanded, set_expanded) + 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 - 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" - ] - ] - 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 - ])) + 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/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/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/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..5362d82 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)) @@ -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)) @@ -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/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..d2e5cdb --- /dev/null +++ b/flutter/lib/journal_extension_registry.dart @@ -0,0 +1,124 @@ +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: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], + 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: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], + 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: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], + 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: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) => 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: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), + ), + ); + 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..f1f5b5f 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: 38ada4a886d3d783821eba0dff58108c32284a1b + resolved-ref: 38ada4a886d3d783821eba0dff58108c32284a1b + 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..0369bf7 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: 38ada4a886d3d783821eba0dff58108c32284a1b + 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, 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_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 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_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 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.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_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/lui/dune b/logseq_db_worker/lui/dune new file mode 100644 index 0000000..00d72c0 --- /dev/null +++ b/logseq_db_worker/lui/dune @@ -0,0 +1,27 @@ +(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 + logseq_db_worker_lui_service) + (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.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_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.ml b/logseq_db_worker/lui/journal_worker.ml new file mode 100644 index 0000000..5bf444d --- /dev/null +++ b/logseq_db_worker/lui/journal_worker.ml @@ -0,0 +1,1164 @@ +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 + ; output_wakeup : (unit -> unit) Atomic.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 () + ; output_wakeup = Atomic.make (fun () -> ()) + ; 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 ] + +(* [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 + client.responses + (Response + { runtime_epoch = client.runtime_epoch + ; worker_generation = client.worker_generation + ; request_id + ; outcome + }); + increment_pending_output_locked client); + notify_output 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)); + notify_output 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"); + notify_output client + 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"); + notify_output client +;; + +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 + let set_output_wakeup = set_output_wakeup +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.mli b/logseq_db_worker/lui/journal_worker.mli new file mode 100644 index 0000000..82748fc --- /dev/null +++ b/logseq_db_worker/lui/journal_worker.mli @@ -0,0 +1,195 @@ +(** 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 + + (** 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 + 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.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_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..9a59b1b --- /dev/null +++ b/logseq_db_worker/lui/journal_worker_ids.ml @@ -0,0 +1,102 @@ +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 + + 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.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 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/bonsai/logseq_db_worker_bonsai_service.ml b/logseq_db_worker/lui/logseq_db_worker_lui_service.ml similarity index 96% rename from logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml rename to logseq_db_worker/lui/logseq_db_worker_lui_service.ml index 8a0e956..088d216 100644 --- a/logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.ml +++ b/logseq_db_worker/lui/logseq_db_worker_lui_service.ml @@ -5,7 +5,7 @@ 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 +module ID = Journal_worker_ids type graph_id = Logseq_db_types.Graph_types.Uuid.t type graph = Logseq_db_types.Managed_graph.t @@ -307,24 +307,30 @@ let production_dependencies () = let publish context = function | Pure.Asset_notice (scope, notice) -> - Worker.Session_context.emit context ~topic:asset_topic (Asset_notice (scope, notice)) + Journal_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) + Journal_worker.Session_context.emit + context + ~topic:invalidation_topic + (Graph_push push) | Sync_output output -> (match output with | State_changed state -> - Worker.Session_context.emit + Journal_worker.Session_context.emit context ~topic:manager_topic (Client_state_changed (client_state state)) | Bootstrap_progressed progress -> - Worker.Session_context.emit + Journal_worker.Session_context.emit context ~topic:bootstrap_topic (Bootstrap_progress (bootstrap_progress progress))) | Graph_state_changed state -> - Worker.Session_context.emit + Journal_worker.Session_context.emit context ~topic:graph_state_topic (Graph_state_changed state) @@ -339,7 +345,7 @@ let sync_limits config = ;; let sync_dependencies dependencies context config id_token_provider = - let environment = Worker.Session_context.environment context in + let environment = Journal_worker.Session_context.environment context in let clock = Eio.Stdenv.clock environment in Result.bind (Sync_runner.runtime @@ -420,19 +426,22 @@ let create ~(dependencies : dependencies) = } end in - Worker.Service.create + Journal_worker.Service.create ~push_topic_count:6 - ~concurrency:(Worker.Service.Concurrent { max_in_flight = 2 }) + ~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 = Worker.Session_context.switch context in + 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 -> - Worker.Session_context.emit context ~topic:auth_topic (Need_id_token request)) + Journal_worker.Session_context.emit + context + ~topic:auth_topic + (Need_id_token request)) in let id_token_provider = Sync_runner.id_token_provider @@ -513,7 +522,7 @@ let create ~(dependencies : dependencies) = Worker_runner.runtime ~sleep: (Eio.Time.sleep - (Eio.Stdenv.clock (Worker.Session_context.environment context))) + (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) diff --git a/logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.mli b/logseq_db_worker/lui/logseq_db_worker_lui_service.mli similarity index 91% rename from logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.mli rename to logseq_db_worker/lui/logseq_db_worker_lui_service.mli index 55b4145..4744bf7 100644 --- a/logseq_db_worker/bonsai/logseq_db_worker_bonsai_service.mli +++ b/logseq_db_worker/lui/logseq_db_worker_lui_service.mli @@ -189,12 +189,12 @@ type push = 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 +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 @@ -207,6 +207,7 @@ val dependencies val create : dependencies:dependencies - -> (Logseq_db_worker.Config.t, request, response, push) Worker.Service.t + -> (Logseq_db_worker.Config.t, request, response, push) Journal_worker.Service.t -val service : (Logseq_db_worker.Config.t, request, response, push) Worker.Service.t +val service + : (Logseq_db_worker.Config.t, request, response, push) Journal_worker.Service.t 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 94% rename from logseq_db_worker/test/test_bonsai_service.ml rename to logseq_db_worker/test/test_lui_service.ml index ba3ae9a..55e67aa 100644 --- a/logseq_db_worker/test/test_bonsai_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/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 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/logseq_journal.opam b/logseq_journal.opam index 4a617e0..ef7c629 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#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"] ["persistent_sorted_set_ocaml.dev" "git+https://github.com/logseq/persistent-sorted-set-ocaml.git#4016dae1cdf4304207d8277ff9957656cdd8b177"] 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 b/logseq_overlay_db.opam index bb8b7ff..e3ff156 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"} @@ -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"} 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 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"} 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"] ] 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..e590598 100644 --- a/swift/JournalApplicationPlatform.swift +++ b/swift/JournalApplicationPlatform.swift @@ -1,53 +1,152 @@ 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 beginShutdown() throws -> BonsaiApplicationShutdown? { - guard let events else { return nil } + 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() + } + + /// 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 { + FileHandle.standardError.write( + Data("logseq_journal: decodeRequest failed bytes=\(bytes.count)\n".utf8)) + 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 +158,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 +178,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 +195,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 +227,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..3832f65 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,61 @@ 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") + .labelStyle(.iconOnly) } - .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 +95,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..ef30687 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,46 @@ 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 + // 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() } } .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 +96,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..ae5d09a 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,15 +57,15 @@ 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)) - .foregroundStyle(.primary) + .foregroundStyle(Color(.label)) .monospacedDigit() .textCase(nil) .lineLimit(1) @@ -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..9357d76 --- /dev/null +++ b/swift/JournalExtensions.swift @@ -0,0 +1,185 @@ +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.sorted().map(token).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: 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: [ + .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, + childIdentifiers: journalChildIdentifiers, + 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: true, 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: true, 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/JournalIcons.swift b/swift/JournalIcons.swift new file mode 100644 index 0000000..8256030 --- /dev/null +++ b/swift/JournalIcons.swift @@ -0,0 +1,44 @@ +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.triangle.2.circlepath", + "arrow.up", + "book", + "calendar", + "checkmark.circle", + "checkmark.square", + "chevron.down", + "chevron.left", + "chevron.right", + "circle", + "circle.fill", + "clock", + "doc", + "doc.text", + "ellipsis", + "exclamationmark.circle", + "exclamationmark.triangle", + "folder", + "lock.doc", + "lock.shield", + "minus.circle", + "person.crop.circle", + "plus", + "questionmark.folder", + "rectangle.portrait.and.arrow.right", + "slider.horizontal.3", + "square.and.pencil", + "star", + "stethoscope", + "trash", +] + +let journalAppIcons: [String: LUIAppleIconSource] = Dictionary( + uniqueKeysWithValues: journalIconNames.map { name in + (name.replacingOccurrences(of: ".", with: "-"), .systemName(name)) + } +) diff --git a/swift/JournalList.swift b/swift/JournalList.swift new file mode 100644 index 0000000..d274f9e --- /dev/null +++ b/swift/JournalList.swift @@ -0,0 +1,378 @@ +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 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) + } + + 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 + // 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) { + 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 + } + let anchor: UnitPoint = + switch request.anchor { + case "center": .center + case "bottom": .bottom + default: .top + } + 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 + 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] { + DispatchQueue.main.async { + visible.insert(position) + updateVisibleRange() + } + } + } + .onDisappear { + if let position = positions[row.key] { + DispatchQueue.main.async { + 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 { + 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)) + } + } + .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) } + } + } + } + + 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..c7a5c63 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,66 @@ 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) + context.content + 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..5d3c91e --- /dev/null +++ b/swift/JournalRuntime.swift @@ -0,0 +1,248 @@ +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_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") +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( + appIcons: journalAppIcons, + 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. 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 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), + 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..46e4d50 --- /dev/null +++ b/swift/JournalRuntimeHost.swift @@ -0,0 +1,47 @@ +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 { + // 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") + } + } + .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-packages/Package.resolved b/swift/Package.resolved similarity index 77% rename from swift-packages/Package.resolved rename to swift/Package.resolved index fda852c..61bb67f 100644 --- a/swift-packages/Package.resolved +++ b/swift/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "77d50a6650381af625b978c813365bcb03068fdba144e10e5653af5c7b3792d1", + "originHash" : "ee33eb1bc2436bb271c1e42a07f2d4a1cc07cb31d124815a8abff3ccef926239", "pins" : [ { "identity" : "amplify-swift", @@ -46,6 +46,60 @@ "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", @@ -87,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "d9a5b37470adc940d22c3bcd5ca6953a516b727f", - "version" : "1.7.2" + "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2", + "version" : "1.7.3" } }, { @@ -114,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "c8aece90ea05f9866bd392a5bf13b5cae56c0e03", - "version" : "1.20.0" + "revision" : "ff86b924ead66f853b8baf91f3c41926a8f36177", + "version" : "1.21.0" } }, { @@ -132,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-configuration.git", "state" : { - "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", - "version" : "1.2.0" + "revision" : "3533f65d3e36dcdffc91ce34ef4d3c9c1887fd4b", + "version" : "1.2.1" } }, { @@ -141,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "da9d28d69ebe3894b18376c8f2395c2f37b8448f", - "version" : "4.5.2" + "revision" : "a9d1d5ab8951ada40cafff8c8e2b551dfde4f390", + "version" : "5.0.0" } }, { @@ -150,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-distributed-tracing.git", "state" : { - "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", - "version" : "1.4.1" + "revision" : "cc504a45f6ce73ce6067837d7ac19fa67b229a56", + "version" : "1.5.0" } }, { @@ -186,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "a931f2c1de8dd49381ce3bf2e279d033f68d8865", - "version" : "2.102.0" + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" } }, { @@ -213,8 +267,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "03827c1a9fdb2b6b00a4e93ede8861520263af8c", - "version" : "2.37.4" + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", + "version" : "2.37.5" } }, { diff --git a/swift/Package.swift b/swift/Package.swift new file mode 100644 index 0000000..540f710 --- /dev/null +++ b/swift/Package.swift @@ -0,0 +1,46 @@ +// 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])), + .linkedLibrary("sqlite3", .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/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..0e2264e 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" ] ;; @@ -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#38ada4a886d3d783821eba0dff58108c32284a1b" + 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 @@ -1378,44 +1378,24 @@ 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_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" - ]; - forbid_text - root - "logseq_db_worker/tool/test_macos_runtime_flow.sh" - [ "logseq_db_worker_runtime_flow_test.dart" ]; + require_allowed_dart_files root "flutter/integration_test" []; 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 " ]; @@ -1509,10 +1489,8 @@ let () = ; "SliverList" ; "TextSpan" ; "WidgetSpan" - ; "Image.file" ; "package:sqflite/" ; "package:drift/" - ; "showModalBottomSheet" ; "JournalCapture" ; "CaptureController" ; "TextEditingController" @@ -1522,10 +1500,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 @@ -1691,10 +1665,10 @@ 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" - ; "Bonsai_swiftui.Host_effect.show_notice" + ; "Journal_platform.show_notice_request" ; "V.button" ; "local-cache-reset-confirmation" ; "journal-detail-route" @@ -1731,21 +1705,10 @@ 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" - [ "BonsaiApplicationView(entrypoint: \"logseq_journal\"" + [ "JournalRuntimeHost(" ; "applicationShouldTerminate" ; ".terminateLater" ; "reply(toApplicationShouldTerminate:" @@ -1882,7 +1845,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 +1853,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 +1907,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 +1974,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 diff --git a/test/test_native_static_gmp.sh b/test/test_native_static_gmp.sh index 8deb577..06564ad 100644 --- a/test/test_native_static_gmp.sh +++ b/test/test_native_static_gmp.sh @@ -66,10 +66,10 @@ 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 "$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" @@ -81,10 +81,10 @@ 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)' +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 new file mode 100755 index 0000000..ffe2cd0 --- /dev/null +++ b/tool/build_journal_apple.sh @@ -0,0 +1,205 @@ +#!/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} + 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 + +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 -f "$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") + # 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} \ +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" +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" + # 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 +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" + # 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" 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/native_static_link_flags.sh b/tool/native_static_link_flags.sh index 392dc67..4c0f0f4 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 @@ -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)" diff --git a/tool/test_macos_regressions.py b/tool/test_macos_regressions.py index 5849b25..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"), ] @@ -42,12 +41,15 @@ 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 through the Journal_view / + # Journal_ids shims. 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_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) 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)