@mobrowser/native 2.16.0 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,2 @@
1
- This package contains the configuration files for MōBrowser native integration.
1
+ This package contains the configuration files for MōBrowser native integration: the CMake toolchain
2
+ and headers for C++ modules, and the `mobrowser` and `mobrowser-build` crates for Rust modules.
@@ -30,6 +30,18 @@ endif ()
30
30
 
31
31
  cmake_path(ABSOLUTE_PATH MOBROWSER_SDK_DIR NORMALIZE)
32
32
  message(STATUS "MOBROWSER_SDK_DIR: ${MOBROWSER_SDK_DIR}")
33
+
34
+ # The SDK for the machine running the build. Build-time tools (protoc,
35
+ # cc_nativegen) come from here, so a cross-build doesn't need Rosetta.
36
+ if (OS_MAC AND CMAKE_HOST_SYSTEM_PROCESSOR MATCHES "x86_64")
37
+ set(MOBROWSER_HOST_SDK_DIR "${PROJECT_ROOT_DIR}/node_modules/@mobrowser/sdk-darwin-x64")
38
+ elseif (OS_MAC)
39
+ set(MOBROWSER_HOST_SDK_DIR "${PROJECT_ROOT_DIR}/node_modules/@mobrowser/sdk-darwin-arm64")
40
+ else ()
41
+ set(MOBROWSER_HOST_SDK_DIR "${MOBROWSER_SDK_DIR}")
42
+ endif ()
43
+ cmake_path(ABSOLUTE_PATH MOBROWSER_HOST_SDK_DIR NORMALIZE)
44
+ message(STATUS "MOBROWSER_HOST_SDK_DIR: ${MOBROWSER_HOST_SDK_DIR}")
33
45
  set(MOBROWSER_SDK_NATIVE_DIR "${PROJECT_ROOT_DIR}/node_modules/@mobrowser/native")
34
46
  cmake_path(ABSOLUTE_PATH MOBROWSER_SDK_NATIVE_DIR NORMALIZE)
35
47
 
@@ -107,9 +107,13 @@ if (MOBROWSER_USE_PREBUILT_PROTOBUF)
107
107
 
108
108
  # protoc is a host tool (toolchain-independent for codegen); prefer the one
109
109
  # bundled in the SDK (same one the TS codegen uses), falling back to the
110
- # downloaded prefix's protoc.
111
- set(MOBROWSER_PROTOC "${MOBROWSER_SDK_DIR}/bin/protoc${CMAKE_EXECUTABLE_SUFFIX}")
110
+ # downloaded prefix's protoc. The prefix is built for the target, so a
111
+ # cross-build can't fall back to it.
112
+ set(MOBROWSER_PROTOC "${MOBROWSER_HOST_SDK_DIR}/bin/protoc${CMAKE_EXECUTABLE_SUFFIX}")
112
113
  if (NOT EXISTS "${MOBROWSER_PROTOC}")
114
+ if (NOT MOBROWSER_HOST_SDK_DIR STREQUAL MOBROWSER_SDK_DIR)
115
+ message(FATAL_ERROR "protoc not found in the host SDK: ${MOBROWSER_PROTOC}")
116
+ endif ()
113
117
  set(MOBROWSER_PROTOC "${MOBROWSER_PROTOBUF_SELECTED_ROOT}/bin/protoc${CMAKE_EXECUTABLE_SUFFIX}")
114
118
  endif ()
115
119
  if (NOT EXISTS "${MOBROWSER_PROTOC}")
@@ -219,11 +223,15 @@ foreach(dir ${PROTOBUF_IMPORT_DIRS})
219
223
  list(APPEND PROTOC_ARGS --proto_path=${dir})
220
224
  endforeach()
221
225
 
222
- set(CC_NATIVEGEN_PLUGIN "${MOBROWSER_SDK_DIR}/bin/cc_nativegen${CMAKE_EXECUTABLE_SUFFIX}")
226
+ set(CC_NATIVEGEN_PLUGIN "${MOBROWSER_HOST_SDK_DIR}/bin/cc_nativegen${CMAKE_EXECUTABLE_SUFFIX}")
223
227
 
224
228
  if (MOBROWSER_USE_PREBUILT_PROTOBUF)
225
229
  set(_protoc_command "${MOBROWSER_PROTOC}")
226
230
  set(_protoc_depend "${MOBROWSER_PROTOC}")
231
+ elseif (NOT MOBROWSER_HOST_SDK_DIR STREQUAL MOBROWSER_SDK_DIR)
232
+ # Cross-build: the fetched protoc is built for the target and can't run here.
233
+ set(_protoc_command "${MOBROWSER_HOST_SDK_DIR}/bin/protoc${CMAKE_EXECUTABLE_SUFFIX}")
234
+ set(_protoc_depend "${_protoc_command}")
227
235
  else ()
228
236
  set(_protoc_command "$<TARGET_FILE:protoc>")
229
237
  set(_protoc_depend protoc)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mobrowser/native",
3
- "version": "2.16.0",
3
+ "version": "2.18.0",
4
4
  "description": "Configuration files for MōBrowser native integration",
5
5
  "mobrowserSbomRelationship": "BUILD_DEPENDENCY_OF",
6
6
  "main": "cmake/MoBrowser.cmake",
@@ -11,7 +11,11 @@
11
11
  "cmake/ProtobufCompat.cmake",
12
12
  "cmake/ProtobufDownload.cmake",
13
13
  "include",
14
- "internal"
14
+ "internal",
15
+ "rust/mobrowser/Cargo.toml",
16
+ "rust/mobrowser/src",
17
+ "rust/mobrowser-build/Cargo.toml",
18
+ "rust/mobrowser-build/src"
15
19
  ],
16
20
  "keywords": [
17
21
  "mobrowser",
@@ -0,0 +1,12 @@
1
+ [package]
2
+ name = "mobrowser"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+ rust-version = "1.85"
6
+ description = "MōBrowser native module runtime"
7
+ publish = false
8
+
9
+ [dependencies]
10
+ prost = "0.14"
11
+ prost-types = "0.14"
12
+ tokio = { version = "1", default-features = false, features = ["rt-multi-thread"] }
@@ -0,0 +1,104 @@
1
+ // Copyright (c) 2000-2026 TeamDev. All rights reserved.
2
+ // TeamDev PROPRIETARY and CONFIDENTIAL.
3
+ // Use is subject to license terms.
4
+
5
+ //! The C ABI between MōBrowser and the native module. Used by [`crate::main!`].
6
+
7
+ use std::any::Any;
8
+ use std::borrow::Cow;
9
+ use std::cell::RefCell;
10
+ use std::ffi::{c_char, CStr};
11
+ use std::panic::{catch_unwind, AssertUnwindSafe};
12
+ use std::sync::Once;
13
+
14
+ use crate::responder::Responder;
15
+ use crate::transport;
16
+
17
+ pub type ReplyCallback = unsafe extern "C" fn(id: u64, data: *const c_char, size: usize);
18
+ pub type RejectCallback = unsafe extern "C" fn(id: u64, error: *const c_char);
19
+ pub type HostInvokeCallback = unsafe extern "C" fn(
20
+ id: u64,
21
+ service: *const c_char,
22
+ method: *const c_char,
23
+ data: *const c_char,
24
+ size: usize,
25
+ reply: ReplyCallback,
26
+ reject: RejectCallback,
27
+ );
28
+
29
+ thread_local! {
30
+ static LAST_PANIC: RefCell<String> = const { RefCell::new(String::new()) };
31
+ }
32
+
33
+ /// Records each panic's message for the rejection of the call it aborts,
34
+ /// keeping the previous hook, which reports it.
35
+ fn install_panic_hook() {
36
+ static ONCE: Once = Once::new();
37
+ ONCE.call_once(|| {
38
+ let previous = std::panic::take_hook();
39
+ std::panic::set_hook(Box::new(move |info| {
40
+ let message = panic_message(info.payload());
41
+ LAST_PANIC.with(|last| message.clone_into(&mut last.borrow_mut()));
42
+ previous(info);
43
+ }));
44
+ });
45
+ }
46
+
47
+ pub(crate) fn panic_message(payload: &(dyn Any + Send)) -> &str {
48
+ payload
49
+ .downcast_ref::<&str>()
50
+ .copied()
51
+ .or_else(|| payload.downcast_ref::<String>().map(String::as_str))
52
+ .unwrap_or("unknown panic")
53
+ }
54
+
55
+ pub(crate) fn last_panic_message() -> String {
56
+ LAST_PANIC.with(|last| last.borrow().clone())
57
+ }
58
+
59
+ pub fn app_main(launch: fn()) {
60
+ install_panic_hook();
61
+ if let Err(panic) = catch_unwind(launch) {
62
+ transport::set_launch_error(format!("panic: {}", panic_message(&*panic)));
63
+ }
64
+ }
65
+
66
+ /// # Safety
67
+ ///
68
+ /// `service` and `method` must be valid C strings and `data` must point to
69
+ /// `size` readable bytes, all for the duration of the call.
70
+ pub unsafe fn invoke(
71
+ id: u64,
72
+ service: *const c_char,
73
+ method: *const c_char,
74
+ data: *const c_char,
75
+ size: usize,
76
+ reply: ReplyCallback,
77
+ reject: RejectCallback,
78
+ ) {
79
+ install_panic_hook();
80
+ let responder = Responder::new(id, reply, reject);
81
+ let service = unsafe { c_str(service) };
82
+ let method = unsafe { c_str(method) };
83
+ let data = if data.is_null() || size == 0 {
84
+ &[]
85
+ } else {
86
+ unsafe { std::slice::from_raw_parts(data.cast::<u8>(), size) }
87
+ };
88
+ // A panicking handler drops the responder while unwinding, which rejects
89
+ // the call with the panic message.
90
+ let _ =
91
+ catch_unwind(AssertUnwindSafe(|| transport::invoke(&service, &method, data, responder)));
92
+ }
93
+
94
+ pub fn set_host_invoke(host_invoke: Option<HostInvokeCallback>) {
95
+ install_panic_hook();
96
+ transport::set_host_invoke(host_invoke);
97
+ }
98
+
99
+ unsafe fn c_str<'a>(s: *const c_char) -> Cow<'a, str> {
100
+ if s.is_null() {
101
+ return Cow::Borrowed("");
102
+ }
103
+ unsafe { CStr::from_ptr(s) }.to_string_lossy()
104
+ }
@@ -0,0 +1,103 @@
1
+ // Copyright (c) 2000-2026 TeamDev. All rights reserved.
2
+ // TeamDev PROPRIETARY and CONFIDENTIAL.
3
+ // Use is subject to license terms.
4
+
5
+ //! Runtime for MōBrowser native modules written in Rust.
6
+ //!
7
+ //! The module is a `cdylib` named `mobrowser_client` that MōBrowser loads on
8
+ //! startup. Declare the entry point with [`main!`], register services in it with
9
+ //! [`register_service`], and include the code generated by `mobrowser-build`
10
+ //! with [`include_proto!`]:
11
+ //!
12
+ //! ```ignore
13
+ //! mod proto {
14
+ //! mobrowser::include_proto!();
15
+ //! }
16
+ //!
17
+ //! mobrowser::main!(launch);
18
+ //!
19
+ //! fn launch() {
20
+ //! mobrowser::register_service(proto::GreetServiceServer::new(Greeter));
21
+ //! }
22
+ //! ```
23
+ //!
24
+ //! Service methods are `async fn`s that run on the Tokio [`runtime`].
25
+
26
+ mod oneshot;
27
+ mod responder;
28
+ mod transport;
29
+
30
+ pub mod runtime;
31
+
32
+ #[doc(hidden)]
33
+ pub mod ffi;
34
+
35
+ pub use responder::Responder;
36
+ pub use transport::{register_service, request};
37
+
38
+ pub use prost;
39
+ pub use prost_types;
40
+ pub use tokio;
41
+
42
+ /// A native service the main process can call.
43
+ ///
44
+ /// Implemented by the `<Name>Server` wrappers that `mobrowser-build` generates;
45
+ /// there is no need to implement it by hand.
46
+ pub trait Service: Send + Sync + 'static {
47
+ /// The fully qualified Protobuf name of the service, `package.Service`.
48
+ fn full_name(&self) -> &'static str;
49
+
50
+ /// Decodes `data` as the request of `method` and dispatches it. The
51
+ /// `responder` is completed or rejected exactly once.
52
+ fn call(&self, method: &str, data: &[u8], responder: Responder);
53
+ }
54
+
55
+ /// Includes the Rust code that `mobrowser-build` generated from the project's
56
+ /// `.proto` files. Messages and services of a `.proto` without a `package` end
57
+ /// up at the top level; the others in a module per package.
58
+ #[macro_export]
59
+ macro_rules! include_proto {
60
+ () => {
61
+ include!(concat!(env!("OUT_DIR"), "/mobrowser_proto.rs"));
62
+ };
63
+ }
64
+
65
+ /// Exports the entry points MōBrowser looks up in the native module and makes
66
+ /// `$launch` the function it calls once, on startup.
67
+ ///
68
+ /// Must be invoked exactly once, in the root of the `mobrowser_client` crate.
69
+ #[macro_export]
70
+ macro_rules! main {
71
+ ($launch:path) => {
72
+ #[unsafe(no_mangle)]
73
+ pub extern "C" fn AppMain() {
74
+ $crate::ffi::app_main($launch);
75
+ }
76
+
77
+ /// # Safety
78
+ ///
79
+ /// Called by MōBrowser with valid C strings and a buffer of `size` bytes.
80
+ #[unsafe(no_mangle)]
81
+ pub unsafe extern "C" fn invoke(
82
+ id: u64,
83
+ service: *const ::std::ffi::c_char,
84
+ method: *const ::std::ffi::c_char,
85
+ data: *const ::std::ffi::c_char,
86
+ size: usize,
87
+ reply: $crate::ffi::ReplyCallback,
88
+ reject: $crate::ffi::RejectCallback,
89
+ ) {
90
+ unsafe { $crate::ffi::invoke(id, service, method, data, size, reply, reject) }
91
+ }
92
+
93
+ #[unsafe(no_mangle)]
94
+ pub extern "C" fn set_host_invoke(
95
+ host_invoke: ::std::option::Option<$crate::ffi::HostInvokeCallback>,
96
+ ) {
97
+ $crate::ffi::set_host_invoke(host_invoke);
98
+ }
99
+ };
100
+ }
101
+
102
+ #[cfg(test)]
103
+ mod tests;
@@ -0,0 +1,65 @@
1
+ // Copyright (c) 2000-2026 TeamDev. All rights reserved.
2
+ // TeamDev PROPRIETARY and CONFIDENTIAL.
3
+ // Use is subject to license terms.
4
+
5
+ //! A minimal, runtime-agnostic one-shot channel.
6
+
7
+ use std::future::Future;
8
+ use std::pin::Pin;
9
+ use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
10
+ use std::task::{Context, Poll, Waker};
11
+
12
+ struct Shared<T> {
13
+ value: Option<T>,
14
+ closed: bool,
15
+ waker: Option<Waker>,
16
+ }
17
+
18
+ pub(crate) struct Sender<T>(Arc<Mutex<Shared<T>>>);
19
+
20
+ /// Resolves to the sent value, or to `None` if the sender was dropped without
21
+ /// sending.
22
+ pub(crate) struct Receiver<T>(Arc<Mutex<Shared<T>>>);
23
+
24
+ pub(crate) fn channel<T>() -> (Sender<T>, Receiver<T>) {
25
+ let shared = Arc::new(Mutex::new(Shared { value: None, closed: false, waker: None }));
26
+ (Sender(shared.clone()), Receiver(shared))
27
+ }
28
+
29
+ fn lock<T>(shared: &Mutex<Shared<T>>) -> MutexGuard<'_, Shared<T>> {
30
+ shared.lock().unwrap_or_else(PoisonError::into_inner)
31
+ }
32
+
33
+ impl<T> Sender<T> {
34
+ pub(crate) fn send(self, value: T) {
35
+ lock(&self.0).value = Some(value);
36
+ }
37
+ }
38
+
39
+ impl<T> Drop for Sender<T> {
40
+ fn drop(&mut self) {
41
+ let mut shared = lock(&self.0);
42
+ shared.closed = true;
43
+ let waker = shared.waker.take();
44
+ drop(shared);
45
+ if let Some(waker) = waker {
46
+ waker.wake();
47
+ }
48
+ }
49
+ }
50
+
51
+ impl<T> Future for Receiver<T> {
52
+ type Output = Option<T>;
53
+
54
+ fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<T>> {
55
+ let mut shared = lock(&self.0);
56
+ if let Some(value) = shared.value.take() {
57
+ Poll::Ready(Some(value))
58
+ } else if shared.closed {
59
+ Poll::Ready(None)
60
+ } else {
61
+ shared.waker = Some(cx.waker().clone());
62
+ Poll::Pending
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,63 @@
1
+ // Copyright (c) 2000-2026 TeamDev. All rights reserved.
2
+ // TeamDev PROPRIETARY and CONFIDENTIAL.
3
+ // Use is subject to license terms.
4
+
5
+ use std::ffi::{c_char, CString};
6
+
7
+ use crate::ffi::{self, RejectCallback, ReplyCallback};
8
+
9
+ const CANCELED: &str = "RPC canceled: callback destroyed without completion.";
10
+
11
+ /// The untyped, one-shot reply channel of a single call from the main process.
12
+ ///
13
+ /// Dropping it without replying rejects the call.
14
+ pub struct Responder {
15
+ id: u64,
16
+ handlers: Option<(ReplyCallback, RejectCallback)>,
17
+ }
18
+
19
+ impl Responder {
20
+ pub(crate) fn new(id: u64, reply: ReplyCallback, reject: RejectCallback) -> Self {
21
+ Self { id, handlers: Some((reply, reject)) }
22
+ }
23
+
24
+ /// Completes the call with a serialized response.
25
+ pub fn reply(mut self, data: &[u8]) {
26
+ if let Some((reply, _)) = self.handlers.take() {
27
+ // The host copies the bytes before returning.
28
+ let ptr = if data.is_empty() { b"".as_ptr() } else { data.as_ptr() };
29
+ unsafe { reply(self.id, ptr.cast::<c_char>(), data.len()) };
30
+ }
31
+ }
32
+
33
+ /// Fails the call with an error message.
34
+ pub fn reject(mut self, error: impl AsRef<str>) {
35
+ self.reject_with(error.as_ref());
36
+ }
37
+
38
+ fn reject_with(&mut self, error: &str) {
39
+ if let Some((_, reject)) = self.handlers.take() {
40
+ let error = to_cstring(error);
41
+ unsafe { reject(self.id, error.as_ptr()) };
42
+ }
43
+ }
44
+ }
45
+
46
+ impl Drop for Responder {
47
+ fn drop(&mut self) {
48
+ if self.handlers.is_none() {
49
+ return;
50
+ }
51
+ if std::thread::panicking() {
52
+ let message = format!("panic: {}", ffi::last_panic_message());
53
+ self.reject_with(&message);
54
+ } else {
55
+ self.reject_with(CANCELED);
56
+ }
57
+ }
58
+ }
59
+
60
+ /// Converts `s` to a C string, replacing interior NULs that would truncate it.
61
+ pub(crate) fn to_cstring(s: &str) -> CString {
62
+ CString::new(s.replace('\0', "\u{FFFD}")).expect("NULs were replaced")
63
+ }
@@ -0,0 +1,66 @@
1
+ // Copyright (c) 2000-2026 TeamDev. All rights reserved.
2
+ // TeamDev PROPRIETARY and CONFIDENTIAL.
3
+ // Use is subject to license terms.
4
+
5
+ //! The Tokio runtime that async service methods run on.
6
+
7
+ use std::future::Future;
8
+ use std::panic::{catch_unwind, AssertUnwindSafe};
9
+ use std::sync::OnceLock;
10
+ use std::task::Poll;
11
+
12
+ use tokio::runtime::{Builder, Handle};
13
+
14
+ use crate::ffi::panic_message;
15
+ use crate::Responder;
16
+
17
+ static HANDLE: OnceLock<Handle> = OnceLock::new();
18
+
19
+ /// Makes async service methods run on the runtime of `handle` instead of the
20
+ /// one MōBrowser creates. Call it in the entry point, before registering
21
+ /// services. Fails with `handle` if a runtime is already in use.
22
+ pub fn set_handle(handle: Handle) -> Result<(), Handle> {
23
+ HANDLE.set(handle)
24
+ }
25
+
26
+ /// The runtime async service methods run on: the one passed to [`set_handle`],
27
+ /// or a multi-threaded runtime created on first use.
28
+ pub fn handle() -> &'static Handle {
29
+ HANDLE.get_or_init(|| {
30
+ let runtime = Builder::new_multi_thread()
31
+ .enable_all()
32
+ .thread_name("mobrowser-tokio")
33
+ .build()
34
+ .expect("failed to start the Tokio runtime");
35
+ let handle = runtime.handle().clone();
36
+ // Keep the runtime until the process exits.
37
+ std::mem::forget(runtime);
38
+ handle
39
+ })
40
+ }
41
+
42
+ /// Runs `future` on the runtime and completes the call with its result. A panic
43
+ /// in `future` rejects the call with `panic: <message>`.
44
+ #[doc(hidden)]
45
+ pub fn spawn_call<T, F>(future: F, responder: Responder)
46
+ where
47
+ T: prost::Message,
48
+ F: Future<Output = Result<T, String>> + Send + 'static,
49
+ {
50
+ let mut future = Box::pin(future);
51
+ handle().spawn(async move {
52
+ let result = std::future::poll_fn(|cx| {
53
+ match catch_unwind(AssertUnwindSafe(|| future.as_mut().poll(cx))) {
54
+ Ok(Poll::Pending) => Poll::Pending,
55
+ Ok(Poll::Ready(result)) => Poll::Ready(Ok(result)),
56
+ Err(panic) => Poll::Ready(Err(panic)),
57
+ }
58
+ })
59
+ .await;
60
+ match result {
61
+ Ok(Ok(response)) => responder.reply(&response.encode_to_vec()),
62
+ Ok(Err(error)) => responder.reject(error),
63
+ Err(panic) => responder.reject(format!("panic: {}", panic_message(&*panic))),
64
+ }
65
+ });
66
+ }
@@ -0,0 +1,223 @@
1
+ // Copyright (c) 2000-2026 TeamDev. All rights reserved.
2
+ // TeamDev PROPRIETARY and CONFIDENTIAL.
3
+ // Use is subject to license terms.
4
+
5
+ use std::ffi::{c_char, CStr};
6
+ use std::future::Future;
7
+ use std::sync::atomic::{AtomicU64, Ordering};
8
+ use std::sync::{Arc, Mutex};
9
+ use std::task::{Context, Poll, Wake, Waker};
10
+ use std::thread::Thread;
11
+
12
+ use crate::{ffi, register_service, request, Responder, Service};
13
+
14
+ #[derive(Debug, PartialEq)]
15
+ enum Outcome {
16
+ Reply(Vec<u8>),
17
+ Reject(String),
18
+ }
19
+
20
+ static OUTCOMES: Mutex<Vec<(u64, Outcome)>> = Mutex::new(Vec::new());
21
+ static NEXT_ID: AtomicU64 = AtomicU64::new(1);
22
+
23
+ unsafe extern "C" fn record_reply(id: u64, data: *const c_char, size: usize) {
24
+ let data = unsafe { std::slice::from_raw_parts(data.cast::<u8>(), size) }.to_vec();
25
+ OUTCOMES.lock().unwrap().push((id, Outcome::Reply(data)));
26
+ }
27
+
28
+ unsafe extern "C" fn record_reject(id: u64, error: *const c_char) {
29
+ let error = unsafe { CStr::from_ptr(error) }.to_string_lossy().into_owned();
30
+ OUTCOMES.lock().unwrap().push((id, Outcome::Reject(error)));
31
+ }
32
+
33
+ fn outcomes(id: u64) -> Vec<Outcome> {
34
+ let mut all = OUTCOMES.lock().unwrap();
35
+ let (mine, rest) = all.drain(..).partition(|(i, _)| *i == id);
36
+ *all = rest;
37
+ mine.into_iter().map(|(_, outcome)| outcome).collect()
38
+ }
39
+
40
+ /// Echoes the request of "Echo", drops the responder of "Drop", and panics in
41
+ /// "Panic", without the runtime.
42
+ struct TestService;
43
+
44
+ impl Service for TestService {
45
+ fn full_name(&self) -> &'static str {
46
+ "test.TestService"
47
+ }
48
+
49
+ fn call(&self, method: &str, data: &[u8], responder: Responder) {
50
+ match method {
51
+ "Echo" => responder.reply(data),
52
+ "Drop" => drop(responder),
53
+ "Panic" => panic!("boom"),
54
+ _ => responder.reject("Method not found."),
55
+ }
56
+ }
57
+ }
58
+
59
+ fn call(service: &str, method: &str, data: &[u8]) -> Vec<Outcome> {
60
+ let id = start_call(service, method, data);
61
+ outcomes(id)
62
+ }
63
+
64
+ /// Invokes `method` and returns the id of the call without waiting for it.
65
+ fn start_call(service: &str, method: &str, data: &[u8]) -> u64 {
66
+ register_service(TestService);
67
+ register_service(AsyncTestService);
68
+ let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
69
+ let service = crate::responder::to_cstring(service);
70
+ let method = crate::responder::to_cstring(method);
71
+ unsafe {
72
+ ffi::invoke(
73
+ id,
74
+ service.as_ptr(),
75
+ method.as_ptr(),
76
+ data.as_ptr().cast(),
77
+ data.len(),
78
+ record_reply,
79
+ record_reject,
80
+ )
81
+ };
82
+ id
83
+ }
84
+
85
+ fn encode(s: &str) -> Vec<u8> {
86
+ prost::Message::encode_to_vec(&s.to_owned())
87
+ }
88
+
89
+ #[test]
90
+ fn completes_once() {
91
+ assert_eq!(call("test.TestService", "Echo", &encode("hi")), [Outcome::Reply(encode("hi"))]);
92
+ }
93
+
94
+ #[test]
95
+ fn dropped_responder_rejects() {
96
+ assert_eq!(
97
+ call("test.TestService", "Drop", &[]),
98
+ [Outcome::Reject("RPC canceled: callback destroyed without completion.".into())]
99
+ );
100
+ }
101
+
102
+ #[test]
103
+ fn panic_rejects() {
104
+ assert_eq!(call("test.TestService", "Panic", &[]), [Outcome::Reject("panic: boom".into())]);
105
+ }
106
+
107
+ #[test]
108
+ fn unknown_service_rejects() {
109
+ assert_eq!(call("test.Missing", "Echo", &[]), [Outcome::Reject("Service not found.".into())]);
110
+ }
111
+
112
+ #[test]
113
+ fn unknown_method_rejects() {
114
+ assert_eq!(
115
+ call("test.TestService", "Missing", &[]),
116
+ [Outcome::Reject("Method not found.".into())]
117
+ );
118
+ }
119
+
120
+ /// Replies to "Echo" with the request and rejects everything else.
121
+ unsafe extern "C" fn fake_host(
122
+ id: u64,
123
+ _service: *const c_char,
124
+ method: *const c_char,
125
+ data: *const c_char,
126
+ size: usize,
127
+ reply: ffi::ReplyCallback,
128
+ reject: ffi::RejectCallback,
129
+ ) {
130
+ if unsafe { CStr::from_ptr(method) } == c"Echo" {
131
+ unsafe { reply(id, data, size) };
132
+ } else {
133
+ unsafe { reject(id, c"no such method".as_ptr()) };
134
+ }
135
+ }
136
+
137
+ /// Runs `future` to completion on the current thread.
138
+ fn block_on<F: Future>(future: F) -> F::Output {
139
+ struct ThreadWaker(Thread);
140
+ impl Wake for ThreadWaker {
141
+ fn wake(self: Arc<Self>) {
142
+ self.0.unpark();
143
+ }
144
+ }
145
+ let waker = Waker::from(Arc::new(ThreadWaker(std::thread::current())));
146
+ let mut cx = Context::from_waker(&waker);
147
+ let mut future = std::pin::pin!(future);
148
+ loop {
149
+ match future.as_mut().poll(&mut cx) {
150
+ Poll::Ready(output) => return output,
151
+ Poll::Pending => std::thread::park(),
152
+ }
153
+ }
154
+ }
155
+
156
+ // One test, since the host is global.
157
+ #[test]
158
+ fn host_requests() {
159
+ let call = |method, text: &str| block_on(request::<_, String>("s", method, &text.to_owned()));
160
+
161
+ ffi::set_host_invoke(None);
162
+ assert_eq!(call("Echo", "a"), Err("Native RPC host is not available.".into()));
163
+
164
+ ffi::set_host_invoke(Some(fake_host));
165
+ assert_eq!(call("Echo", "b"), Ok("b".into()));
166
+ assert_eq!(call("Other", "c"), Err("no such method".into()));
167
+ }
168
+
169
+ /// Echoes the request of "Echo" after yielding, fails "Fail", and panics in
170
+ /// "Panic", like a generated async server.
171
+ struct AsyncTestService;
172
+
173
+ impl Service for AsyncTestService {
174
+ fn full_name(&self) -> &'static str {
175
+ "test.AsyncTestService"
176
+ }
177
+
178
+ fn call(&self, method: &str, data: &[u8], responder: Responder) {
179
+ let method = method.to_owned();
180
+ let request: String = prost::Message::decode(data).unwrap_or_default();
181
+ crate::runtime::spawn_call(
182
+ async move {
183
+ tokio::task::yield_now().await;
184
+ match method.as_str() {
185
+ "Echo" => Ok(request),
186
+ "Panic" => panic!("async boom"),
187
+ _ => Err("async failure".to_owned()),
188
+ }
189
+ },
190
+ responder,
191
+ );
192
+ }
193
+ }
194
+
195
+ /// Waits for the outcome of an async call.
196
+ fn wait_outcomes(id: u64) -> Vec<Outcome> {
197
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
198
+ loop {
199
+ let found = outcomes(id);
200
+ if !found.is_empty() || std::time::Instant::now() > deadline {
201
+ return found;
202
+ }
203
+ std::thread::sleep(std::time::Duration::from_millis(1));
204
+ }
205
+ }
206
+
207
+ #[test]
208
+ fn async_completes() {
209
+ let id = start_call("test.AsyncTestService", "Echo", &encode("hi"));
210
+ assert_eq!(wait_outcomes(id), [Outcome::Reply(encode("hi"))]);
211
+ }
212
+
213
+ #[test]
214
+ fn async_error_rejects() {
215
+ let id = start_call("test.AsyncTestService", "Fail", &[]);
216
+ assert_eq!(wait_outcomes(id), [Outcome::Reject("async failure".into())]);
217
+ }
218
+
219
+ #[test]
220
+ fn async_panic_rejects() {
221
+ let id = start_call("test.AsyncTestService", "Panic", &[]);
222
+ assert_eq!(wait_outcomes(id), [Outcome::Reject("panic: async boom".into())]);
223
+ }
@@ -0,0 +1,145 @@
1
+ // Copyright (c) 2000-2026 TeamDev. All rights reserved.
2
+ // TeamDev PROPRIETARY and CONFIDENTIAL.
3
+ // Use is subject to license terms.
4
+
5
+ use std::collections::HashMap;
6
+ use std::ffi::c_char;
7
+ use std::future::Future;
8
+ use std::panic::{catch_unwind, AssertUnwindSafe};
9
+ use std::sync::{Arc, LazyLock, Mutex, MutexGuard, OnceLock, PoisonError, RwLock};
10
+
11
+ use crate::ffi::HostInvokeCallback;
12
+ use crate::oneshot;
13
+ use crate::responder::{to_cstring, Responder};
14
+ use crate::Service;
15
+
16
+ type ServiceMap = HashMap<&'static str, Arc<dyn Service>>;
17
+
18
+ static SERVICES: LazyLock<RwLock<ServiceMap>> = LazyLock::new(Default::default);
19
+
20
+ /// Why the entry point failed, if it did; calls to services it didn't register
21
+ /// are rejected with it.
22
+ static LAUNCH_ERROR: OnceLock<String> = OnceLock::new();
23
+
24
+ type PendingRequest = Box<dyn FnOnce(Result<Vec<u8>, String>) + Send>;
25
+
26
+ #[derive(Default)]
27
+ struct Host {
28
+ invoke: Option<HostInvokeCallback>,
29
+ next_id: u64,
30
+ pending: HashMap<u64, PendingRequest>,
31
+ }
32
+
33
+ static HOST: LazyLock<Mutex<Host>> = LazyLock::new(Default::default);
34
+
35
+ fn host() -> MutexGuard<'static, Host> {
36
+ HOST.lock().unwrap_or_else(PoisonError::into_inner)
37
+ }
38
+
39
+ /// Makes `service` callable from the main process. Registering a service with
40
+ /// the name of one registered earlier replaces it.
41
+ pub fn register_service(service: impl Service) {
42
+ let service = Arc::new(service);
43
+ SERVICES.write().unwrap_or_else(PoisonError::into_inner).insert(service.full_name(), service);
44
+ }
45
+
46
+ pub(crate) fn invoke(service: &str, method: &str, data: &[u8], responder: Responder) {
47
+ let service = SERVICES.read().unwrap_or_else(PoisonError::into_inner).get(service).cloned();
48
+ match service {
49
+ Some(service) => service.call(method, data, responder),
50
+ None => match LAUNCH_ERROR.get() {
51
+ Some(error) => responder.reject(format!("Native module failed to start: {error}")),
52
+ None => responder.reject("Service not found."),
53
+ },
54
+ }
55
+ }
56
+
57
+ pub(crate) fn set_launch_error(error: String) {
58
+ let _ = LAUNCH_ERROR.set(error);
59
+ }
60
+
61
+ pub(crate) fn set_host_invoke(invoke: Option<HostInvokeCallback>) {
62
+ host().invoke = invoke;
63
+ }
64
+
65
+ /// Calls `method` of the main-process `service` with a typed request and
66
+ /// resolves to its response. Works with any async runtime.
67
+ pub fn request<Req, Resp>(
68
+ service: &str,
69
+ method: &str,
70
+ request: &Req,
71
+ ) -> impl Future<Output = Result<Resp, String>> + Send + 'static
72
+ where
73
+ Req: prost::Message,
74
+ Resp: prost::Message + Default + 'static,
75
+ {
76
+ let (sender, receiver) = oneshot::channel();
77
+ request_raw(service, method, &request.encode_to_vec(), move |result| sender.send(result));
78
+ async move {
79
+ let data = receiver.await.unwrap_or_else(|| Err("RPC canceled.".to_owned()))?;
80
+ Resp::decode(data.as_slice()).map_err(|_| "Failed to parse RPC response.".to_owned())
81
+ }
82
+ }
83
+
84
+ /// Calls `method` of the main-process `service` with a serialized request.
85
+ /// `callback` runs exactly once, on the main process's UI thread.
86
+ fn request_raw(
87
+ service: &str,
88
+ method: &str,
89
+ data: &[u8],
90
+ callback: impl FnOnce(Result<Vec<u8>, String>) + Send + 'static,
91
+ ) {
92
+ let mut host = host();
93
+ let Some(host_invoke) = host.invoke else {
94
+ drop(host);
95
+ callback(Err("Native RPC host is not available.".to_owned()));
96
+ return;
97
+ };
98
+ host.next_id += 1;
99
+ let id = host.next_id;
100
+ host.pending.insert(id, Box::new(callback));
101
+ drop(host);
102
+
103
+ let service = to_cstring(service);
104
+ let method = to_cstring(method);
105
+ let ptr = if data.is_empty() { b"".as_ptr() } else { data.as_ptr() };
106
+ // The host copies all arguments before returning.
107
+ unsafe {
108
+ host_invoke(
109
+ id,
110
+ service.as_ptr(),
111
+ method.as_ptr(),
112
+ ptr.cast::<c_char>(),
113
+ data.len(),
114
+ on_host_reply,
115
+ on_host_reject,
116
+ )
117
+ };
118
+ }
119
+
120
+ fn complete_request(id: u64, result: Result<Vec<u8>, String>) {
121
+ let Some(callback) = host().pending.remove(&id) else {
122
+ return;
123
+ };
124
+ // Unwinding into the host is undefined behavior; the panic hook has already
125
+ // reported the panic.
126
+ let _ = catch_unwind(AssertUnwindSafe(|| callback(result)));
127
+ }
128
+
129
+ unsafe extern "C" fn on_host_reply(id: u64, data: *const c_char, size: usize) {
130
+ let data = if data.is_null() || size == 0 {
131
+ Vec::new()
132
+ } else {
133
+ unsafe { std::slice::from_raw_parts(data.cast::<u8>(), size) }.to_vec()
134
+ };
135
+ complete_request(id, Ok(data));
136
+ }
137
+
138
+ unsafe extern "C" fn on_host_reject(id: u64, error: *const c_char) {
139
+ let error = if error.is_null() {
140
+ String::new()
141
+ } else {
142
+ unsafe { std::ffi::CStr::from_ptr(error) }.to_string_lossy().into_owned()
143
+ };
144
+ complete_request(id, Err(error));
145
+ }
@@ -0,0 +1,10 @@
1
+ [package]
2
+ name = "mobrowser-build"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+ rust-version = "1.85"
6
+ description = "Generates Rust code for MōBrowser native services from .proto files"
7
+ publish = false
8
+
9
+ [dependencies]
10
+ prost-build = "0.14"
@@ -0,0 +1,314 @@
1
+ // Copyright (c) 2000-2026 TeamDev. All rights reserved.
2
+ // TeamDev PROPRIETARY and CONFIDENTIAL.
3
+ // Use is subject to license terms.
4
+
5
+ //! Generates Rust messages and native service bindings from the `.proto` files
6
+ //! of a MōBrowser project. Call it from `build.rs`:
7
+ //!
8
+ //! ```ignore
9
+ //! fn main() -> std::io::Result<()> {
10
+ //! mobrowser_build::compile("src/native/proto")
11
+ //! }
12
+ //! ```
13
+ //!
14
+ //! and include the result with `mobrowser::include_proto!()`.
15
+ //!
16
+ //! For every service, the generated code has:
17
+ //! - `trait <Service>`: implement it, with `async fn`s, to serve calls from the
18
+ //! main process;
19
+ //! - `struct <Service>Server`: pass it to `mobrowser::register_service`;
20
+ //! - `mod <name>_client`: async functions that call the main-process
21
+ //! implementation of the service.
22
+
23
+ use std::fmt::Write as _;
24
+ use std::fs;
25
+ use std::io::{Error, ErrorKind, Result};
26
+ use std::path::{Path, PathBuf};
27
+
28
+ use prost_build::{Config, Method, Service, ServiceGenerator};
29
+
30
+ /// The file in `OUT_DIR` that `mobrowser::include_proto!` includes.
31
+ const INCLUDE_FILE: &str = "mobrowser_proto.rs";
32
+
33
+ /// Compiles every `.proto` file under `proto_dir`, relative to the crate root,
34
+ /// with the `protoc` of the MōBrowser SDK installed in `node_modules`. Set the
35
+ /// `PROTOC` environment variable to use another one.
36
+ pub fn compile(proto_dir: impl AsRef<Path>) -> Result<()> {
37
+ let manifest_dir = PathBuf::from(env("CARGO_MANIFEST_DIR")?);
38
+ let out_dir = PathBuf::from(env("OUT_DIR")?);
39
+ let proto_dir = manifest_dir.join(proto_dir);
40
+ println!("cargo:rerun-if-changed={}", proto_dir.display());
41
+ println!("cargo:rerun-if-env-changed=PROTOC");
42
+
43
+ let mut protos = Vec::new();
44
+ if proto_dir.is_dir() {
45
+ find_protos(&proto_dir, &mut protos)?;
46
+ }
47
+ protos.sort();
48
+ if protos.is_empty() {
49
+ // Keep include_proto!() compiling in a module without services yet.
50
+ return fs::write(out_dir.join(INCLUDE_FILE), "");
51
+ }
52
+
53
+ let mut includes = vec![proto_dir];
54
+ let mut config = Config::new();
55
+ if std::env::var_os("PROTOC").is_none() {
56
+ let sdk_dir = find_sdk_dir(&manifest_dir)?;
57
+ config.protoc_executable(sdk_dir.join("bin").join(protoc_name()));
58
+ // The well-known types, e.g. google/protobuf/wrappers.proto.
59
+ includes.push(sdk_dir.join("proto"));
60
+ }
61
+ config
62
+ .prost_path("::mobrowser::prost")
63
+ .prost_types_path("::mobrowser::prost_types")
64
+ .include_file(INCLUDE_FILE)
65
+ .service_generator(Box::new(NativeServiceGenerator))
66
+ .compile_protos(&protos, &includes)
67
+ }
68
+
69
+ fn env(name: &str) -> Result<String> {
70
+ std::env::var(name).map_err(|_| {
71
+ Error::new(ErrorKind::NotFound, format!("{name} is not set; call compile() from build.rs"))
72
+ })
73
+ }
74
+
75
+ fn find_protos(dir: &Path, protos: &mut Vec<PathBuf>) -> Result<()> {
76
+ for entry in fs::read_dir(dir)? {
77
+ let path = entry?.path();
78
+ if path.is_dir() {
79
+ find_protos(&path, protos)?;
80
+ } else if path.extension().is_some_and(|ext| ext == "proto") {
81
+ protos.push(path);
82
+ }
83
+ }
84
+ Ok(())
85
+ }
86
+
87
+ fn protoc_name() -> &'static str {
88
+ if cfg!(windows) {
89
+ "protoc.exe"
90
+ } else {
91
+ "protoc"
92
+ }
93
+ }
94
+
95
+ /// Finds the SDK package with a `protoc` for this machine in the project's own
96
+ /// `node_modules`, where `Cargo.toml` finds the crates too; a `protoc` elsewhere
97
+ /// is never run. On Apple Silicon, the x64 SDK runs under Rosetta, so it is a
98
+ /// fallback.
99
+ fn find_sdk_dir(project_dir: &Path) -> Result<PathBuf> {
100
+ let packages: &[&str] = match (std::env::consts::OS, std::env::consts::ARCH) {
101
+ ("macos", "aarch64") => &["sdk-darwin-arm64", "sdk-darwin-x64"],
102
+ ("macos", _) => &["sdk-darwin-x64"],
103
+ ("windows", _) => &["sdk-win-x64"],
104
+ _ => &["sdk-linux-x64"],
105
+ };
106
+ for package in packages {
107
+ let sdk_dir = project_dir.join("node_modules").join("@mobrowser").join(package);
108
+ if sdk_dir.join("bin").join(protoc_name()).is_file() {
109
+ return Ok(sdk_dir);
110
+ }
111
+ }
112
+ Err(Error::new(
113
+ ErrorKind::NotFound,
114
+ format!(
115
+ "protoc not found in node_modules/@mobrowser/{}. Run `npm install`, \
116
+ or point the PROTOC environment variable at a protoc executable.",
117
+ packages[0]
118
+ ),
119
+ ))
120
+ }
121
+
122
+ struct NativeServiceGenerator;
123
+
124
+ impl ServiceGenerator for NativeServiceGenerator {
125
+ fn generate(&mut self, service: Service, buf: &mut String) {
126
+ if let Some(method) =
127
+ service.methods.iter().find(|m| m.client_streaming || m.server_streaming)
128
+ {
129
+ panic!(
130
+ "{}.{}: streaming methods are not supported by native modules",
131
+ service.proto_name, method.proto_name
132
+ );
133
+ }
134
+ let full_name = if service.package.is_empty() {
135
+ service.proto_name.clone()
136
+ } else {
137
+ format!("{}.{}", service.package, service.proto_name)
138
+ };
139
+ generate_trait(&service, buf);
140
+ generate_server(&service, &full_name, buf);
141
+ generate_client(&service, &full_name, buf);
142
+ }
143
+
144
+ fn finalize_package(&mut self, package: &str, buf: &mut String) {
145
+ if let Some(name) = find_duplicate_item(buf) {
146
+ let package = if package.is_empty() { "the root package" } else { package };
147
+ panic!(
148
+ "`{name}` is defined twice in the code generated for {package}. For service \
149
+ `Foo`, it also defines `FooServer` and `mod foo_client`; rename a service or \
150
+ message so that the names don't clash."
151
+ );
152
+ }
153
+ }
154
+ }
155
+
156
+ /// Finds a top-level item defined twice, e.g. `foo_client` for services `Foo`
157
+ /// and `FooService`, which rustc would report against the generated file.
158
+ fn find_duplicate_item(buf: &str) -> Option<&str> {
159
+ const KINDS: &[&str] = &["pub struct ", "pub enum ", "pub trait ", "pub mod "];
160
+ let mut names = std::collections::HashSet::new();
161
+ buf.lines()
162
+ .filter_map(|line| KINDS.iter().find_map(|kind| line.strip_prefix(kind)))
163
+ .map(|rest| {
164
+ rest.split(|c: char| !(c.is_alphanumeric() || c == '_' || c == '#'))
165
+ .next()
166
+ .unwrap_or("")
167
+ })
168
+ .find(|name| !names.insert(*name))
169
+ }
170
+
171
+ fn generate_trait(service: &Service, buf: &mut String) {
172
+ service.comments.append_with_indent(0, buf);
173
+ let _ = writeln!(
174
+ buf,
175
+ "#[allow(clippy::all)]\npub trait {}: Send + Sync + 'static {{",
176
+ service.name
177
+ );
178
+ for method in &service.methods {
179
+ method.comments.append_with_indent(1, buf);
180
+ let _ = writeln!(
181
+ buf,
182
+ " fn {}(&self, request: {}) -> impl ::core::future::Future<Output = ::core::result::Result<{}, ::std::string::String>> + ::core::marker::Send;",
183
+ method.name, method.input_type, method.output_type
184
+ );
185
+ }
186
+ buf.push_str("}\n\n");
187
+ }
188
+
189
+ fn generate_server(service: &Service, full_name: &str, buf: &mut String) {
190
+ let name = &service.name;
191
+ let _ = write!(
192
+ buf,
193
+ "/// Makes a [`{name}`] implementation callable from the main process:\n\
194
+ /// `mobrowser::register_service({name}Server::new(service))`. Its methods\n\
195
+ /// run on `mobrowser::runtime`.\n\
196
+ pub struct {name}Server<T>(::std::sync::Arc<T>);\n\n\
197
+ #[allow(clippy::all)]\n\
198
+ impl<T: {name}> {name}Server<T> {{\n \
199
+ pub fn new(service: T) -> Self {{\n Self(::std::sync::Arc::new(service))\n }}\n\
200
+ }}\n\n\
201
+ #[allow(clippy::all)]\n\
202
+ impl<T: {name}> ::mobrowser::Service for {name}Server<T> {{\n \
203
+ fn full_name(&self) -> &'static str {{\n \"{full_name}\"\n }}\n\n \
204
+ fn call(&self, method: &str, data: &[u8], responder: ::mobrowser::Responder) {{\n \
205
+ match method {{\n"
206
+ );
207
+ for method in &service.methods {
208
+ let _ = write!(
209
+ buf,
210
+ " \"{proto_name}\" => match <{input} as ::mobrowser::prost::Message>::decode(data) {{\n \
211
+ ::core::result::Result::Ok(request) => {{\n \
212
+ let service = ::std::sync::Arc::clone(&self.0);\n \
213
+ ::mobrowser::runtime::spawn_call(async move {{ <T as {name}>::{rust_name}(&*service, request).await }}, responder)\n \
214
+ }}\n \
215
+ ::core::result::Result::Err(error) => responder.reject(::std::format!(\"Failed to parse request: {{error}}\")),\n \
216
+ }},\n",
217
+ proto_name = method.proto_name,
218
+ input = method.input_type,
219
+ rust_name = method.name,
220
+ name = service.name,
221
+ );
222
+ }
223
+ buf.push_str(
224
+ " _ => responder.reject(\"Method not found.\"),\n }\n }\n}\n\n",
225
+ );
226
+ }
227
+
228
+ fn generate_client(service: &Service, full_name: &str, buf: &mut String) {
229
+ let _ = writeln!(
230
+ buf,
231
+ "/// Calls the main-process implementation of `{full_name}`.\n\
232
+ #[allow(clippy::all)]\n\
233
+ pub mod {}_client {{",
234
+ client_module_name(&service.proto_name)
235
+ );
236
+ for method in &service.methods {
237
+ write_client_method(full_name, method, buf);
238
+ }
239
+ buf.push_str("}\n\n");
240
+ }
241
+
242
+ fn write_client_method(full_name: &str, method: &Method, buf: &mut String) {
243
+ let input = outer_type(&method.input_type);
244
+ let output = outer_type(&method.output_type);
245
+ method.comments.append_with_indent(1, buf);
246
+ let _ = write!(
247
+ buf,
248
+ " pub fn {name}(\n \
249
+ request: &{input},\n \
250
+ ) -> impl ::core::future::Future<Output = ::core::result::Result<{output}, ::std::string::String>> + ::core::marker::Send + 'static {{\n \
251
+ ::mobrowser::request::<{input}, {output}>(\"{full_name}\", \"{proto_name}\", request)\n \
252
+ }}\n",
253
+ name = method.name,
254
+ proto_name = method.proto_name,
255
+ );
256
+ }
257
+
258
+ /// Resolves a type path that prost made relative to the package module from the
259
+ /// client module nested in it.
260
+ fn outer_type(rust_type: &str) -> String {
261
+ const PRIMITIVES: &[&str] = &["()", "bool", "f32", "f64", "i32", "i64", "u32", "u64"];
262
+ if rust_type.starts_with("::") || PRIMITIVES.contains(&rust_type) {
263
+ rust_type.to_owned()
264
+ } else {
265
+ format!("super::{rust_type}")
266
+ }
267
+ }
268
+
269
+ /// `GreetService` → `greet`, `HTTPClientService` → `http_client`; the same name
270
+ /// as the C++ client instance.
271
+ fn client_module_name(service: &str) -> String {
272
+ let name = match service.strip_suffix("Service") {
273
+ Some(stripped) if !stripped.is_empty() => stripped,
274
+ _ => service,
275
+ };
276
+ let chars: Vec<char> = name.chars().collect();
277
+ let mut result = String::new();
278
+ for (i, &c) in chars.iter().enumerate() {
279
+ if c.is_ascii_uppercase() && !result.is_empty() {
280
+ let previous_is_lower_or_digit =
281
+ chars[i - 1].is_ascii_lowercase() || chars[i - 1].is_ascii_digit();
282
+ let next_is_lower = chars.get(i + 1).is_some_and(char::is_ascii_lowercase);
283
+ if previous_is_lower_or_digit || next_is_lower {
284
+ result.push('_');
285
+ }
286
+ }
287
+ result.push(c.to_ascii_lowercase());
288
+ }
289
+ result
290
+ }
291
+
292
+ #[cfg(test)]
293
+ mod tests {
294
+ use super::*;
295
+
296
+ #[test]
297
+ fn client_module_names() {
298
+ assert_eq!(client_module_name("GreetService"), "greet");
299
+ assert_eq!(client_module_name("HTTPClientService"), "http_client");
300
+ assert_eq!(client_module_name("Service"), "service");
301
+ assert_eq!(client_module_name("Files2Service"), "files2");
302
+ }
303
+
304
+ #[test]
305
+ fn outer_types() {
306
+ assert_eq!(outer_type("Person"), "super::Person");
307
+ assert_eq!(outer_type("super::other::Msg"), "super::super::other::Msg");
308
+ assert_eq!(outer_type("()"), "()");
309
+ assert_eq!(
310
+ outer_type("::mobrowser::prost::alloc::string::String"),
311
+ "::mobrowser::prost::alloc::string::String"
312
+ );
313
+ }
314
+ }