Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/application.ml
Original file line number Diff line number Diff line change
Expand Up @@ -3474,7 +3474,7 @@ let manager_page ?picker_signal state dispatch =
then "Deleting local copy…"
else (
match presence owner with
| Some (Ok Graph_service.Not_downloaded) -> "Not downloaded"
| Some (Ok Graph_service.Not_downloaded) -> "Download and open"
| Some (Error _) -> "Unable to check local copy"
| _ -> "")
in
Expand Down Expand Up @@ -3761,6 +3761,10 @@ let manager_page ?picker_signal state dispatch =
then V.column ~spacing:12. [ actions; choose_graph ]
else actions
in
let actions =
V.of_lui
(Lui_elements.row ~gap:0 ~main:`center ~cross:`center [ Ui.mount actions ])
in
unavailable
~title:"Unable to open graph"
~symbol:"questionmark.folder"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Direct explicit bootstrap actions

## Problem

On main 6a3695c, selecting a graph marked Not downloaded enters an expected local-mirror failure page and requires a second Download graph tap. After canceling authentication and closing the sheet, Sign in again opens another explanation sheet that requires Continue before the system browser. Both were reproduced on the isolated 1446 Simulator, with before screenshots 10/11 and 03/01 outside the repository.

## Decision

Track an explicit graph selection until local mirror inspection confirms absence, then request scoped native online permission before continuing download. Keep automatic recent-graph restore local-only, wait for native permission and verified identity before network effects, retain key/password validation, cancellation and bounded failures. Launch native reauthentication once when the user chooses Sign in again, while retaining the initial explanation, visible busy/cancel/error UI and account preparation.

The user explicitly authorized these two narrow fixes, before/after screenshots and a ready PR. Repeated Retry controls, title overlap and unrelated UX are excluded. No spec or Dune edit is planned.

## Alternatives considered

### Automatically retry every local failure

Would conflate missing data, corrupt storage and missing keys, and weaken explicit network admission. Only confirmed absent mirrors after user graph selection qualify.

### Permit networking for every graph selection

Would make cached graph switches network-dependent before Timeline. Preserve the existing local path for available mirrors.

## Acceptance criteria

- A selected missing mirror requests scoped online permission without the error/Download intermediate page; it performs no remote request before identity confirmation.
- Automatic restore, available local mirrors, missing local keys and arbitrary failures retain existing protections and do not auto-retry.
- Cancellation, stale completions and switching selections cannot continue the retired download.
- Sign in again starts authentication with one action and retains cancel, return, error retry and account preparation.
- Same-baseline synthetic Simulator evidence records before/after clicks; successful production login/download remains unverified without user action.
- Independent review and final-head CI/comments are checked before handoff; create a ready PR, do not merge or deploy.

## Risks

- Permission can race graph-generation changes; the pure owner must retain and consume only current explicit selection intent. Native automatic authentication must run only for an explicit retry presentation, once per presentation, without restarting after cancel or view recomputation. Experimental host-restamp retains cross_abi_validated=false.

## Questions

None. The user has authorized the concrete scope and the existing host-restamp test route.

## Consequences

The pure core retains a graph-scope selection intent only for explicit selection,
consumes it on current mirror absence, and waits for native permission and
verified identity before key recovery or snapshot effects. Automatic recent-graph
restore and missing-key handling keep their local-only behavior. Cancellation
clears the intent, stale scope completions cannot revive it, and real failures
remain bounded with explicit Retry. The picker labels the action Download and open.

An explicit Sign in again presentation starts authentication once. The initial
presentation still explains the secure sign-in flow; busy Cancel, Close, native
cancellation and visible errors remain. A failed attempt waits for manual retry.
No authentication, encryption, account-preparation or storage guard is bypassed.

The user additionally identified the error-page recovery actions being off-center.
The shared empty-state composite puts actions inside a row with leading main-axis
alignment; a centered inner column does not center that group in the offered width.
Only the Failed route wraps its existing action group in a native main=center
row, preserving action order and handlers. Pure sync events cannot reproduce pixel
layout; the actual one-action native screenshot reproduces the defect and a
temporary screenshot assertion checks the narrowest rendering boundary. Single and
multiple actions, a 320-point offered viewport and accessibility text sizing are
verified on the same isolated device. The narrow fixture offers the actual native
view 320 points, rather than cropping a screenshot. Accessibility-medium retains
centering and actionable full semantic labels; visible button text ellipsizes at
that size, which this layout-only correction does not change. The original large
text-size setting was restored. Shared components are unchanged.

Four regressions extend the existing public pure-core suite, with expected RED
failures before the change and 84 core cases passing afterward. The Swift owner
model suite passes. The native presentation defect cannot originate at the pure
OCaml boundary; its actual SwiftUI retry assertion failed on the baseline and
passed on the fixed synthetic host. Four existing graph-picker presentation
expectations were updated for the new action label; their 17 existing cases pass.
No duplicate UI regression cases were added. Independent review found no definite issue.

Same-condition UI evidence on the existing isolated iOS 26.1 Simulator shows
both actions reduced from two clicks to one. Cancel/Close/retry, synthetic
network failures, existing local graphs, restart, background return, picker
cancel and missing-key protection passed. A six-page before/after report was
delivered separately through Library; all screenshots contain synthetic data.
Fresh native objects were produced for the modified source before building the
experimental Simulator host; cross_abi_validated remains false. Real server
login/download success, password unlock, network restoration and download
interruption recovery remain unverified. Repeated Retry controls and title
layout are outside this decision.
62 changes: 44 additions & 18 deletions logseq_sync/lib/pure_reducer/core.ml
Original file line number Diff line number Diff line change
Expand Up @@ -800,6 +800,7 @@ type t =
; graph_key : graph_key_handle option
; pending_graph_open : mirror_request option
; pending_mirror_inspection : mirror_request option
; pending_selected_download : graph_scope option
; pending_attachment : mirror_request option
; pending_encrypted_graph_key : string option
; pending_private_key_package : string option
Expand Down Expand Up @@ -875,6 +876,7 @@ let initial config =
; selected_graph_value = None
; deletion_scope = None
; current_graph_scope = None
; pending_selected_download = None
; connection_generation = 0
; graph_key = None
; pending_graph_open = None
Expand Down Expand Up @@ -990,6 +992,7 @@ let fail core stage message =
; pull_wanted = None
; pull_retry_delay = 1.
; pending_mirror_inspection = None
; pending_selected_download = None
; pending_attachment = None
; pending_encrypted_graph_key = None
; pending_private_key_package = None
Expand Down Expand Up @@ -1198,6 +1201,7 @@ let authenticate_new_account core user_id =
; deletion_scope = None
; cached_selected_graph = None
; current_graph_scope = None
; pending_selected_download = None
; graph_key = None
; pending_graph_open = None
; pending_mirror_inspection = None
Expand Down Expand Up @@ -1285,6 +1289,7 @@ let restore_local core user_id =
; cached_selected_graph = None
; selected_graph_value = None
; current_graph_scope = None
; pending_selected_download = None
; graph_key = None
; pending_graph_open = None
; pending_mirror_inspection = None
Expand Down Expand Up @@ -1343,6 +1348,7 @@ let sign_out core =
; deletion_scope = None
; cached_selected_graph = None
; current_graph_scope = None
; pending_selected_download = None
; graph_key = None
; pending_graph_open = None
; pending_mirror_inspection = None
Expand Down Expand Up @@ -1434,6 +1440,7 @@ let select_graph_transition core ~persist graph_id =
; pending_effects = List.filter (fun pending -> not (is_graph_key_access pending)) core.pending_effects
; selected_graph_value = Some graph
; current_graph_scope = Some scope
; pending_selected_download = (if persist then Some scope else None)
; graph_key = None
; pending_graph_open = None
; pending_mirror_inspection = Some mirror_request
Expand Down Expand Up @@ -1464,11 +1471,26 @@ let select_graph_transition core ~persist graph_id =

let select_graph core graph_id = select_graph_transition core ~persist:true graph_id

let begin_missing_mirror_bootstrap core scope =
let startup =
{ core.public_state.snapshot.startup with restoring_local = false; bootstrapping = true }
in
let core = set_snapshot { core with pending_selected_download = None }
{ core.public_state.snapshot with startup } in
match core.selected_graph_value with
| Some graph when graph.encrypted ->
let next, request = issue_request core (Load_and_unlock_graph_key scope) in
{ next; effects = [ publish next; Run request ] }
| Some _ | None ->
let requested = begin_snapshot_bootstrap core scope in
{ requested with effects = publish requested.next :: requested.effects }
;;

let mirror_inspected core = function
| Mirror_available request
when graph_scope_is_current core request.scope
&& core.pending_mirror_inspection = Some request ->
let core = { core with pending_mirror_inspection = None } in
let core = { core with pending_mirror_inspection = None; pending_selected_download = None } in
(match core.selected_graph_value, core.graph_key with
| Some graph, Some key
when graph.encrypted && graph_key_handle_scope key = request.scope ->
Expand All @@ -1494,22 +1516,19 @@ let mirror_inspected core = function
core.pending_mirror_inspection ->
let core = { core with pending_mirror_inspection = None } in
if not (network_allowed core) then
local_unavailable core Local_mirror_unavailable "This graph is not downloaded. Download it to open it on this device."
else
let startup =
{ core.public_state.snapshot.startup with
restoring_local = false
; bootstrapping = true
}
in
let core = set_snapshot core { core.public_state.snapshot with startup } in
(match core.selected_graph_value with
| Some graph when graph.encrypted ->
let next, request = issue_request core (Load_and_unlock_graph_key scope) in
{ next; effects = [ publish next; Run request ] }
| Some _ | None ->
let requested = begin_snapshot_bootstrap core scope in
{ requested with effects = publish requested.next :: requested.effects })
if core.pending_selected_download = Some scope then
(* Only the user's current selection may request online admission. Native
permission and verified identity still precede any remote effect. *)
let core = network_phase core Online_by_user in
let snapshot = core.public_state.snapshot in
let startup = { snapshot.startup with authenticated = false;
restoring_local = false; bootstrapping = true } in
let next = set_snapshot { core with snapshot_scope = Some scope }
{ snapshot with startup } in
{ next; effects = [publish next] }
else
local_unavailable core Local_mirror_unavailable "This graph is not downloaded. Download it to open it on this device."
else begin_missing_mirror_bootstrap core scope
| _ -> unchanged core
;;

Expand Down Expand Up @@ -2401,6 +2420,7 @@ let clear_selected_graph_for_catalog core catalog =
; cached_selected_graph = None
; selected_graph_value = None
; current_graph_scope = None
; pending_selected_download = None
; graph_key = None
; pending_graph_open = None
; pending_mirror_inspection = None
Expand Down Expand Up @@ -2801,6 +2821,7 @@ let return_to_graph_picker core =
{ core with
selected_graph_value = None
; current_graph_scope = None
; pending_selected_download = None
; graph_key = None
; pending_graph_open = None
; pending_mirror_inspection = None
Expand Down Expand Up @@ -2974,8 +2995,10 @@ let step core event =
presentation_generation = snapshot.startup.presentation_generation + 1;
restoring_local = snapshot.startup.restoring_local && local_restore_pending;
bootstrapping = false; catalog_loading = next.catalog_cache_loading;
awaiting_e2ee_password = false; local_access } in
awaiting_e2ee_password = false; local_access;
failure = (if Option.is_some next.pending_selected_download then Some During_local_restore else snapshot.startup.failure) } in
let next = set_snapshot { next with
pending_selected_download = None;
pending_effects = local_pending; websocket_live = false; deferred_catalog_reconciliation = false;
pending_encrypted_graph_key = (if List.exists (fun (Pending_effect ticket) ->
match ticket.kind with Unlock_private_key_kind -> true | _ -> false) local_pending
Expand All @@ -2997,6 +3020,9 @@ let step core event =
let authenticated=authenticate core user_id in
let next=authenticated.next in
let resumed = match next.current_graph_scope,next.sync_view,core.public_state.snapshot.startup.local_access with
| Some scope,None,_ when next.pending_selected_download = Some scope
&& next.public_state.snapshot.startup.bootstrapping ->
begin_missing_mirror_bootstrap next scope
| Some scope,Some _,_ when not next.websocket_live && (match next.websocket_owner with
| None | Some {phase=Retiring;_} -> true | Some _ -> false) -> start_websocket next scope
| Some scope,None,Local_unavailable Local_key_unavailable
Expand Down
Loading
Loading