Skip to main content

appscale_core/
bridge.rs

1//! Hybrid Bridge — sync reads + async mutations communication model.
2//!
3//! The engine's architecture is batch-oriented and frame-driven (like Flutter).
4//! Enforcing fully synchronous communication would break the scheduler,
5//! layout computation, and "one commit per frame" design.
6//!
7//! The hybrid bridge splits JS↔Rust communication into two paths:
8//!
9//! ┌──────────────────────────────────────────────────────┐
10//! │  SYNC PATH (JSI-like, immediate return)              │
11//! │  JS ↔ Rust                                           │
12//! │  • Read-only queries: measure, focus, scroll offset  │
13//! │  • Native event → JS callback dispatch               │
14//! │  ❌ No UI mutations allowed on this path             │
15//! └──────────────────────────────────────────────────────┘
16//!
17//! ┌──────────────────────────────────────────────────────┐
18//! │  ASYNC PATH (IR / FlatBuffers, frame-batched)        │
19//! │  JS → Rust (one-way per frame)                       │
20//! │  • All mutations: create, update, remove, reparent   │
21//! │  • Navigation, layout changes, mount/unmount         │
22//! │  • Processed via scheduler priority lanes            │
23//! └──────────────────────────────────────────────────────┘
24
25use crate::tree::NodeId;
26use crate::layout::ComputedLayout;
27use crate::platform::TextStyle;
28use crate::navigation::NavigationAction;
29use serde::{Serialize, Deserialize};
30use std::collections::HashMap;
31use std::ffi::{CStr, CString};
32use std::os::raw::c_char;
33
34// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
35// Sync calls — read-only, immediate return
36// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
37
38/// Synchronous call from JS → Rust. Returns immediately.
39/// RULE: No mutation allowed. Read-only queries only.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(tag = "call")]
42pub enum SyncCall {
43    /// Measure a node's computed layout (x, y, width, height).
44    #[serde(rename = "measure")]
45    Measure { node_id: NodeId },
46
47    /// Measure text without creating a node (for layout calculations).
48    #[serde(rename = "measure_text")]
49    MeasureText {
50        text: String,
51        #[serde(default)]
52        style: TextStyleInput,
53        #[serde(default = "default_max_width")]
54        max_width: f32,
55    },
56
57    /// Check if a node currently has accessibility/keyboard focus.
58    #[serde(rename = "is_focused")]
59    IsFocused { node_id: NodeId },
60
61    /// Get the node that currently holds focus (if any).
62    #[serde(rename = "get_focused_node")]
63    GetFocusedNode,
64
65    /// Get the current scroll offset of a ScrollView node.
66    #[serde(rename = "get_scroll_offset")]
67    GetScrollOffset { node_id: NodeId },
68
69    /// Query a platform capability (haptics, biometrics, etc.).
70    #[serde(rename = "supports_capability")]
71    SupportsCapability { capability: String },
72
73    /// Get the current screen dimensions and scale factor.
74    #[serde(rename = "get_screen_info")]
75    GetScreenInfo,
76
77    /// Check if Rust scheduler is processing (backpressure signal).
78    #[serde(rename = "is_processing")]
79    IsProcessing,
80
81    /// Get the accessibility role assigned to a node.
82    #[serde(rename = "get_accessibility_role")]
83    GetAccessibilityRole { node_id: NodeId },
84
85    /// Get scheduler frame stats (for DevTools).
86    #[serde(rename = "get_frame_stats")]
87    GetFrameStats,
88
89    /// Check if a node exists in the shadow tree.
90    #[serde(rename = "node_exists")]
91    NodeExists { node_id: NodeId },
92
93    /// Get the child count of a node.
94    #[serde(rename = "get_child_count")]
95    GetChildCount { node_id: NodeId },
96
97    /// Check if back navigation is possible.
98    #[serde(rename = "can_go_back")]
99    CanGoBack,
100
101    /// Get the currently active route name and params.
102    #[serde(rename = "get_active_route")]
103    GetActiveRoute,
104}
105
106fn default_max_width() -> f32 { f32::INFINITY }
107
108/// Simplified text style for sync MeasureText calls (serde-compatible).
109#[derive(Debug, Clone, Default, Serialize, Deserialize)]
110pub struct TextStyleInput {
111    #[serde(default)]
112    pub font_size: Option<f32>,
113    #[serde(default)]
114    pub font_family: Option<String>,
115    #[serde(default)]
116    pub font_weight: Option<String>,
117}
118
119impl TextStyleInput {
120    pub fn to_platform_style(&self) -> TextStyle {
121        use crate::platform::FontWeight;
122        TextStyle {
123            font_size: self.font_size,
124            font_family: self.font_family.clone(),
125            font_weight: self.font_weight.as_deref().and_then(|w| match w {
126                "thin" => Some(FontWeight::Thin),
127                "light" => Some(FontWeight::Light),
128                "regular" => Some(FontWeight::Regular),
129                "medium" => Some(FontWeight::Medium),
130                "semibold" => Some(FontWeight::SemiBold),
131                "bold" => Some(FontWeight::Bold),
132                "heavy" => Some(FontWeight::Heavy),
133                _ => None,
134            }),
135            ..Default::default()
136        }
137    }
138}
139
140/// Result of a synchronous call.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(tag = "result")]
143pub enum SyncResult {
144    /// Layout measurement result.
145    #[serde(rename = "layout")]
146    Layout {
147        x: f32,
148        y: f32,
149        width: f32,
150        height: f32,
151    },
152
153    /// Text measurement result.
154    #[serde(rename = "text_metrics")]
155    TextMetrics {
156        width: f32,
157        height: f32,
158        baseline: f32,
159        line_count: u32,
160    },
161
162    /// Boolean result (isFocused, nodeExists, supportsCapability, isProcessing).
163    #[serde(rename = "bool")]
164    Bool { value: bool },
165
166    /// Node ID result (getFocusedNode).
167    #[serde(rename = "node_id")]
168    NodeIdResult { node_id: Option<u64> },
169
170    /// Scroll offset result.
171    #[serde(rename = "scroll_offset")]
172    ScrollOffset { x: f32, y: f32 },
173
174    /// Screen info result.
175    #[serde(rename = "screen_info")]
176    ScreenInfo {
177        width: f32,
178        height: f32,
179        scale: f32,
180    },
181
182    /// Accessibility role result.
183    #[serde(rename = "role")]
184    Role { role: String },
185
186    /// Frame stats result.
187    #[serde(rename = "frame_stats")]
188    FrameStats {
189        frame_count: u64,
190        frames_dropped: u32,
191        last_frame_ms: f64,
192        last_layout_ms: f64,
193        last_mount_ms: f64,
194    },
195
196    /// Integer result (child count, etc.).
197    #[serde(rename = "int")]
198    Int { value: u64 },
199
200    /// Active route result.
201    #[serde(rename = "active_route")]
202    ActiveRoute {
203        route_name: Option<String>,
204        params: HashMap<String, String>,
205    },
206
207    /// Node not found.
208    #[serde(rename = "not_found")]
209    NotFound,
210
211    /// Error result.
212    #[serde(rename = "error")]
213    Error { message: String },
214}
215
216impl SyncResult {
217    /// Convenience: create a layout result from a ComputedLayout.
218    pub fn from_layout(layout: &ComputedLayout) -> Self {
219        Self::Layout {
220            x: layout.x,
221            y: layout.y,
222            width: layout.width,
223            height: layout.height,
224        }
225    }
226}
227
228// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
229// Async calls — mutations, enqueued for next frame
230// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
231
232/// Asynchronous call from JS → Rust. Returns immediately, enqueues work.
233/// These are processed on the next frame via the scheduler.
234///
235/// RULE: ALL mutations go through this path. Never through SyncCall.
236#[derive(Debug, Clone, Serialize, Deserialize)]
237#[serde(tag = "call")]
238pub enum AsyncCall {
239    /// Navigate (push, pop, modal, deep link, etc.).
240    #[serde(rename = "navigate")]
241    Navigate {
242        action: String,
243        #[serde(default)]
244        route: Option<String>,
245        #[serde(default)]
246        params: HashMap<String, String>,
247        #[serde(default)]
248        url: Option<String>,
249        #[serde(default)]
250        index: Option<usize>,
251    },
252
253    /// Set focus to a specific node.
254    #[serde(rename = "set_focus")]
255    SetFocus { node_id: NodeId },
256
257    /// Move focus in a direction (Tab, Shift+Tab).
258    #[serde(rename = "move_focus")]
259    MoveFocus { direction: String },
260
261    /// Announce a message via screen reader (VoiceOver/TalkBack).
262    #[serde(rename = "announce")]
263    Announce { message: String },
264}
265
266impl AsyncCall {
267    /// Convert a Navigate async call into a NavigationAction.
268    pub fn to_navigation_action(&self) -> Option<NavigationAction> {
269        match self {
270            AsyncCall::Navigate { action, route, params, url, index } => {
271                match action.as_str() {
272                    "push" => route.as_ref().map(|r| NavigationAction::Push {
273                        route: r.clone(),
274                        params: params.clone(),
275                    }),
276                    "pop" => Some(NavigationAction::Pop),
277                    "popToRoot" => Some(NavigationAction::PopToRoot),
278                    "replace" => route.as_ref().map(|r| NavigationAction::Replace {
279                        route: r.clone(),
280                        params: params.clone(),
281                    }),
282                    "presentModal" => route.as_ref().map(|r| NavigationAction::PresentModal {
283                        route: r.clone(),
284                        params: params.clone(),
285                    }),
286                    "dismissModal" => Some(NavigationAction::DismissModal),
287                    "switchTab" => index.map(|i| NavigationAction::SwitchTab { index: i }),
288                    "deepLink" => url.as_ref().map(|u| NavigationAction::DeepLink {
289                        url: u.clone(),
290                    }),
291                    "goBack" => Some(NavigationAction::GoBack),
292                    _ => None,
293                }
294            }
295            _ => None,
296        }
297    }
298}
299
300// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
301// Native event callbacks — Rust → JS (sync dispatch)
302// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
303
304/// A callback event dispatched synchronously from Rust → JS.
305/// These are the native events that React needs to respond to immediately.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307#[serde(tag = "event")]
308pub enum NativeCallback {
309    /// Touch/pointer event on a node.
310    #[serde(rename = "pointer")]
311    Pointer {
312        node_id: NodeId,
313        event_type: String,    // "down", "move", "up", "cancel"
314        x: f32,
315        y: f32,
316    },
317
318    /// Keyboard event.
319    #[serde(rename = "keyboard")]
320    Keyboard {
321        node_id: NodeId,
322        event_type: String,    // "keydown", "keyup"
323        key: String,
324        code: String,
325    },
326
327    /// Focus change event.
328    #[serde(rename = "focus")]
329    FocusChange {
330        previous: Option<NodeId>,
331        current: NodeId,
332    },
333
334    /// Navigation event (back button, deep link).
335    #[serde(rename = "navigation")]
336    Navigation {
337        action: String,
338        url: Option<String>,
339    },
340
341    /// Scroll event.
342    #[serde(rename = "scroll")]
343    Scroll {
344        node_id: NodeId,
345        offset_x: f32,
346        offset_y: f32,
347    },
348
349    /// Text input change.
350    #[serde(rename = "text_change")]
351    TextChange {
352        node_id: NodeId,
353        text: String,
354    },
355}
356
357// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
358// Compile-time enforcement
359// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
360
361/// Marker trait: types that are safe for the sync path.
362/// Sync operations MUST NOT mutate the tree, layout, or native views.
363///
364/// This is enforced by `handle_sync` taking `&self` (not `&mut self`).
365pub trait SyncSafe {}
366
367impl SyncSafe for SyncCall {}
368
369// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
370// C FFI entry points — called by platform bridges via JSI
371// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
372
373/// Global engine pointer for FFI access.
374/// Safety: Set once during initialization, read-only afterwards.
375static mut ENGINE_PTR: *mut super::Engine = std::ptr::null_mut();
376
377/// Initialize the global engine pointer. Must be called once at startup.
378///
379/// # Safety
380/// Must be called exactly once, before any FFI calls, from the main thread.
381pub unsafe fn set_engine_ptr(engine: *mut super::Engine) {
382    ENGINE_PTR = engine;
383}
384
385/// Synchronous call from native → Rust. Blocks, returns JSON string.
386/// The caller MUST free the returned string with `appscale_free_string()`.
387///
388/// # Safety
389/// `json_input` must be a valid null-terminated C string.
390/// `set_engine_ptr` must have been called before this function.
391#[no_mangle]
392pub unsafe extern "C" fn appscale_sync_call(json_input: *const c_char) -> *mut c_char {
393    let c_str = unsafe { CStr::from_ptr(json_input) };
394    let input = match c_str.to_str() {
395        Ok(s) => s,
396        Err(_) => return to_c_string_or_null(r#"{"result":"error","message":"invalid UTF-8"}"#),
397    };
398
399    let call: SyncCall = match serde_json::from_str(input) {
400        Ok(c) => c,
401        Err(e) => {
402            let err = SyncResult::Error { message: format!("parse error: {}", e) };
403            return to_c_string_or_null(&serde_json::to_string(&err).unwrap_or_default());
404        }
405    };
406
407    let engine = unsafe { &*ENGINE_PTR };
408    let result = engine.handle_sync(&call);
409    to_c_string_or_null(&serde_json::to_string(&result).unwrap_or_default())
410}
411
412/// Asynchronous call from native → Rust. Returns immediately, enqueues work.
413///
414/// # Safety
415/// `json_input` must be a valid null-terminated C string.
416/// `set_engine_ptr` must have been called before this function.
417#[no_mangle]
418pub unsafe extern "C" fn appscale_async_call(json_input: *const c_char) {
419    let c_str = unsafe { CStr::from_ptr(json_input) };
420    let input = match c_str.to_str() {
421        Ok(s) => s,
422        Err(_) => return,
423    };
424
425    let call: AsyncCall = match serde_json::from_str(input) {
426        Ok(c) => c,
427        Err(_) => return,
428    };
429
430    let engine = unsafe { &mut *ENGINE_PTR };
431    engine.handle_async(call);
432}
433
434/// Free a string returned by `appscale_sync_call`.
435///
436/// # Safety
437/// `ptr` must have been returned by `appscale_sync_call` and not yet freed.
438#[no_mangle]
439pub unsafe extern "C" fn appscale_free_string(ptr: *mut c_char) {
440    if !ptr.is_null() {
441        drop(unsafe { CString::from_raw(ptr) });
442    }
443}
444
445/// Helper: convert a Rust string to a C string pointer (or null on failure).
446fn to_c_string_or_null(s: &str) -> *mut c_char {
447    CString::new(s).map(|c| c.into_raw()).unwrap_or(std::ptr::null_mut())
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn test_sync_call_roundtrip() {
456        let call = SyncCall::Measure { node_id: NodeId(42) };
457        let json = serde_json::to_string(&call).unwrap();
458        let decoded: SyncCall = serde_json::from_str(&json).unwrap();
459        match decoded {
460            SyncCall::Measure { node_id } => assert_eq!(node_id, NodeId(42)),
461            _ => panic!("wrong variant"),
462        }
463    }
464
465    #[test]
466    fn test_sync_result_roundtrip() {
467        let result = SyncResult::Layout { x: 10.0, y: 20.0, width: 100.0, height: 50.0 };
468        let json = serde_json::to_string(&result).unwrap();
469        let decoded: SyncResult = serde_json::from_str(&json).unwrap();
470        match decoded {
471            SyncResult::Layout { x, y, width, height } => {
472                assert_eq!(x, 10.0);
473                assert_eq!(y, 20.0);
474                assert_eq!(width, 100.0);
475                assert_eq!(height, 50.0);
476            },
477            _ => panic!("wrong variant"),
478        }
479    }
480
481    #[test]
482    fn test_native_callback_roundtrip() {
483        let cb = NativeCallback::Pointer {
484            node_id: NodeId(5),
485            event_type: "down".into(),
486            x: 150.0,
487            y: 300.0,
488        };
489        let json = serde_json::to_string(&cb).unwrap();
490        assert!(json.contains("\"event\":\"pointer\""));
491        let decoded: NativeCallback = serde_json::from_str(&json).unwrap();
492        match decoded {
493            NativeCallback::Pointer { node_id, .. } => assert_eq!(node_id, NodeId(5)),
494            _ => panic!("wrong variant"),
495        }
496    }
497
498    #[test]
499    fn test_async_call_roundtrip() {
500        // Navigate
501        let call = AsyncCall::Navigate {
502            action: "push".into(),
503            route: Some("Settings".into()),
504            params: HashMap::from([("id".into(), "42".into())]),
505            url: None,
506            index: None,
507        };
508        let json = serde_json::to_string(&call).unwrap();
509        assert!(json.contains("\"call\":\"navigate\""));
510        let decoded: AsyncCall = serde_json::from_str(&json).unwrap();
511        match &decoded {
512            AsyncCall::Navigate { action, route, params, .. } => {
513                assert_eq!(action, "push");
514                assert_eq!(route.as_deref(), Some("Settings"));
515                assert_eq!(params.get("id").map(|s| s.as_str()), Some("42"));
516            }
517            _ => panic!("wrong variant"),
518        }
519
520        // SetFocus
521        let call = AsyncCall::SetFocus { node_id: NodeId(99) };
522        let json = serde_json::to_string(&call).unwrap();
523        let decoded: AsyncCall = serde_json::from_str(&json).unwrap();
524        match decoded {
525            AsyncCall::SetFocus { node_id } => assert_eq!(node_id, NodeId(99)),
526            _ => panic!("wrong variant"),
527        }
528
529        // Announce
530        let call = AsyncCall::Announce { message: "Item added".into() };
531        let json = serde_json::to_string(&call).unwrap();
532        let decoded: AsyncCall = serde_json::from_str(&json).unwrap();
533        match decoded {
534            AsyncCall::Announce { message } => assert_eq!(message, "Item added"),
535            _ => panic!("wrong variant"),
536        }
537    }
538
539    #[test]
540    fn test_new_sync_calls_roundtrip() {
541        // MeasureText
542        let call = SyncCall::MeasureText {
543            text: "Hello".into(),
544            style: TextStyleInput {
545                font_size: Some(16.0),
546                font_family: Some("Inter".into()),
547                font_weight: None,
548            },
549            max_width: 200.0,
550        };
551        let json = serde_json::to_string(&call).unwrap();
552        assert!(json.contains("\"call\":\"measure_text\""));
553        let decoded: SyncCall = serde_json::from_str(&json).unwrap();
554        match decoded {
555            SyncCall::MeasureText { text, style, max_width } => {
556                assert_eq!(text, "Hello");
557                assert_eq!(style.font_size, Some(16.0));
558                assert_eq!(max_width, 200.0);
559            }
560            _ => panic!("wrong variant"),
561        }
562
563        // CanGoBack
564        let json = r#"{"call":"can_go_back"}"#;
565        let decoded: SyncCall = serde_json::from_str(json).unwrap();
566        assert!(matches!(decoded, SyncCall::CanGoBack));
567
568        // GetActiveRoute
569        let json = r#"{"call":"get_active_route"}"#;
570        let decoded: SyncCall = serde_json::from_str(json).unwrap();
571        assert!(matches!(decoded, SyncCall::GetActiveRoute));
572
573        // GetFocusedNode
574        let json = r#"{"call":"get_focused_node"}"#;
575        let decoded: SyncCall = serde_json::from_str(json).unwrap();
576        assert!(matches!(decoded, SyncCall::GetFocusedNode));
577    }
578
579    #[test]
580    fn test_new_sync_results_roundtrip() {
581        // TextMetrics
582        let result = SyncResult::TextMetrics {
583            width: 80.0,
584            height: 20.0,
585            baseline: 16.0,
586            line_count: 1,
587        };
588        let json = serde_json::to_string(&result).unwrap();
589        let decoded: SyncResult = serde_json::from_str(&json).unwrap();
590        match decoded {
591            SyncResult::TextMetrics { width, height, baseline, line_count } => {
592                assert_eq!(width, 80.0);
593                assert_eq!(height, 20.0);
594                assert_eq!(baseline, 16.0);
595                assert_eq!(line_count, 1);
596            }
597            _ => panic!("wrong variant"),
598        }
599
600        // ActiveRoute
601        let result = SyncResult::ActiveRoute {
602            route_name: Some("Home".into()),
603            params: HashMap::from([("tab".into(), "feed".into())]),
604        };
605        let json = serde_json::to_string(&result).unwrap();
606        let decoded: SyncResult = serde_json::from_str(&json).unwrap();
607        match decoded {
608            SyncResult::ActiveRoute { route_name, params } => {
609                assert_eq!(route_name.as_deref(), Some("Home"));
610                assert_eq!(params.get("tab").map(|s| s.as_str()), Some("feed"));
611            }
612            _ => panic!("wrong variant"),
613        }
614
615        // NodeIdResult
616        let result = SyncResult::NodeIdResult { node_id: Some(42) };
617        let json = serde_json::to_string(&result).unwrap();
618        let decoded: SyncResult = serde_json::from_str(&json).unwrap();
619        match decoded {
620            SyncResult::NodeIdResult { node_id } => assert_eq!(node_id, Some(42)),
621            _ => panic!("wrong variant"),
622        }
623    }
624
625    #[test]
626    fn test_navigate_to_action_conversion() {
627        let call = AsyncCall::Navigate {
628            action: "push".into(),
629            route: Some("Profile".into()),
630            params: HashMap::new(),
631            url: None,
632            index: None,
633        };
634        let action = call.to_navigation_action().unwrap();
635        match action {
636            crate::navigation::NavigationAction::Push { route, .. } => {
637                assert_eq!(route, "Profile");
638            }
639            _ => panic!("wrong action"),
640        }
641
642        let call = AsyncCall::Navigate {
643            action: "deepLink".into(),
644            route: None,
645            params: HashMap::new(),
646            url: Some("myapp://profile/42".into()),
647            index: None,
648        };
649        let action = call.to_navigation_action().unwrap();
650        match action {
651            crate::navigation::NavigationAction::DeepLink { url } => {
652                assert_eq!(url, "myapp://profile/42");
653            }
654            _ => panic!("wrong action"),
655        }
656
657        let call = AsyncCall::SetFocus { node_id: NodeId(1) };
658        assert!(call.to_navigation_action().is_none());
659    }
660}