tokra 0.0.1.pre.1
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.
- checksums.yaml +7 -0
- data/.pre-commit-config.yaml +16 -0
- data/AGENTS.md +126 -0
- data/CHANGELOG.md +21 -0
- data/CODE_OF_CONDUCT.md +16 -0
- data/Cargo.toml +23 -0
- data/LICENSE +661 -0
- data/LICENSES/AGPL-3.0-or-later.txt +235 -0
- data/LICENSES/Apache-2.0.txt +73 -0
- data/LICENSES/CC-BY-SA-4.0.txt +170 -0
- data/LICENSES/CC0-1.0.txt +121 -0
- data/LICENSES/MIT.txt +18 -0
- data/README.md +45 -0
- data/README.rdoc +4 -0
- data/REUSE.toml +11 -0
- data/Rakefile +27 -0
- data/Steepfile +15 -0
- data/clippy.toml +5 -0
- data/clippy_exceptions.rb +59 -0
- data/doc/contributors/adr/001.md +187 -0
- data/doc/contributors/adr/002.md +132 -0
- data/doc/contributors/adr/003.md +116 -0
- data/doc/contributors/chats/001.md +3874 -0
- data/doc/contributors/plan/001.md +271 -0
- data/examples/verify_hello_world/app.rb +114 -0
- data/examples/verify_hello_world/index.html +88 -0
- data/examples/verify_ping_pong/README.md +0 -0
- data/examples/verify_ping_pong/app.rb +132 -0
- data/examples/verify_ping_pong/public/styles.css +182 -0
- data/examples/verify_ping_pong/views/index.erb +94 -0
- data/examples/verify_ping_pong/views/layout.erb +22 -0
- data/exe/semantic-highlight +0 -0
- data/ext/tokra/Cargo.toml +23 -0
- data/ext/tokra/extconf.rb +12 -0
- data/ext/tokra/src/lib.rs +719 -0
- data/lib/tokra/native.rb +79 -0
- data/lib/tokra/rack/handler.rb +177 -0
- data/lib/tokra/version.rb +12 -0
- data/lib/tokra.rb +19 -0
- data/mise.toml +8 -0
- data/rustfmt.toml +4 -0
- data/sig/tokra.rbs +7 -0
- data/tasks/lint.rake +151 -0
- data/tasks/rust.rake +63 -0
- data/tasks/steep.rake +11 -0
- data/tasks/test.rake +26 -0
- data/test_native.rb +37 -0
- data/vendor/goodcop/base.yml +1047 -0
- metadata +112 -0
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
//! Tokra Native Extension
|
|
5
|
+
//!
|
|
6
|
+
//! Thin Magnus bindings around `tao` (windowing) and `wry` (WebView).
|
|
7
|
+
//! Follows the "dumb pipe" principle: Rust handles platform-native operations
|
|
8
|
+
//! while Ruby manages all application logic, state, and routing.
|
|
9
|
+
//!
|
|
10
|
+
//! # Thread Safety Model
|
|
11
|
+
//!
|
|
12
|
+
//! This module follows the same thread safety patterns as Tauri. Window and
|
|
13
|
+
//! WebView types are not inherently Send-safe because they're bound to the
|
|
14
|
+
//! main OS thread. However, we mark our wrappers as Send with the following
|
|
15
|
+
//! invariant:
|
|
16
|
+
//!
|
|
17
|
+
//! **INVARIANT: All access to EventLoop, Window, and WebView happens
|
|
18
|
+
//! exclusively on the main thread via the tao event loop callback.**
|
|
19
|
+
//!
|
|
20
|
+
//! Ruby's Ractors communicate with the main thread through the Proxy, which
|
|
21
|
+
//! sends messages to wake the event loop. The event loop then processes these
|
|
22
|
+
//! on the main thread where Window/WebView access is safe.
|
|
23
|
+
//!
|
|
24
|
+
//! This pattern is identical to Tauri's `tauri-runtime-wry` crate:
|
|
25
|
+
//! - `unsafe impl Send for WindowsStore {}` (lib.rs:433)
|
|
26
|
+
//! - `unsafe impl Send for DispatcherMainThreadContext<T> {}` (lib.rs:451)
|
|
27
|
+
//! - `unsafe impl Send for WindowBuilderWrapper {}` (lib.rs:809)
|
|
28
|
+
|
|
29
|
+
use std::cell::RefCell;
|
|
30
|
+
use std::collections::HashMap;
|
|
31
|
+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
32
|
+
use std::sync::{Arc, Mutex};
|
|
33
|
+
|
|
34
|
+
use magnus::{function, method, prelude::*, Error, Ruby, Value};
|
|
35
|
+
use tao::{
|
|
36
|
+
event::{Event, WindowEvent},
|
|
37
|
+
event_loop::{ControlFlow, EventLoop, EventLoopBuilder, EventLoopProxy},
|
|
38
|
+
window::{Window, WindowBuilder},
|
|
39
|
+
};
|
|
40
|
+
use wry::{http, PageLoadEvent, RequestAsyncResponder, WebView, WebViewBuilder};
|
|
41
|
+
|
|
42
|
+
// =============================================================================
|
|
43
|
+
// UserEvent - Cross-thread communication payload
|
|
44
|
+
// =============================================================================
|
|
45
|
+
|
|
46
|
+
/// Custom event type for cross-thread/Ractor communication.
|
|
47
|
+
#[derive(Debug, Clone)]
|
|
48
|
+
pub enum UserEvent {
|
|
49
|
+
/// IPC message received from WebView JavaScript
|
|
50
|
+
IpcMessage(String),
|
|
51
|
+
/// HTTP request received from WebView (via custom protocol)
|
|
52
|
+
/// Contains: (request_id, method, uri, body)
|
|
53
|
+
/// The request_id is used to correlate with the async response
|
|
54
|
+
HttpRequest {
|
|
55
|
+
request_id: u64,
|
|
56
|
+
method: String,
|
|
57
|
+
uri: String,
|
|
58
|
+
body: String,
|
|
59
|
+
},
|
|
60
|
+
/// HTTP response from Ruby (for async protocol handling)
|
|
61
|
+
/// Contains: (request_id, status, headers, body)
|
|
62
|
+
HttpResponse {
|
|
63
|
+
request_id: u64,
|
|
64
|
+
status: u16,
|
|
65
|
+
headers: Vec<(String, String)>,
|
|
66
|
+
body: String,
|
|
67
|
+
},
|
|
68
|
+
/// Page load event (Started or Finished)
|
|
69
|
+
/// Matches Tauri's on_page_load API
|
|
70
|
+
PageLoad {
|
|
71
|
+
event: String, // "started" or "finished"
|
|
72
|
+
url: String,
|
|
73
|
+
},
|
|
74
|
+
/// Wake-up signal from a Ractor (via Proxy)
|
|
75
|
+
WakeUp(String),
|
|
76
|
+
/// Exit signal (from ctrlc handler)
|
|
77
|
+
Exit,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// =============================================================================
|
|
81
|
+
// Helper Functions
|
|
82
|
+
// =============================================================================
|
|
83
|
+
|
|
84
|
+
/// Get the Ruby RuntimeError exception class.
|
|
85
|
+
///
|
|
86
|
+
/// This is a helper to avoid using the deprecated `magnus::exception::runtime_error()`.
|
|
87
|
+
fn runtime_error() -> magnus::ExceptionClass {
|
|
88
|
+
Ruby::get()
|
|
89
|
+
.expect("Ruby VM not initialized")
|
|
90
|
+
.exception_runtime_error()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// =============================================================================
|
|
94
|
+
// Async Protocol Responder Storage
|
|
95
|
+
// =============================================================================
|
|
96
|
+
|
|
97
|
+
// SPDX-SnippetBegin
|
|
98
|
+
// SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
99
|
+
// SPDX-SnippetCopyrightText: 2020-2023 Tauri Programme within The Commons Conservancy
|
|
100
|
+
// SPDX-SnippetComment: Async responder pattern adapted from tauri/app.rs register_asynchronous_uri_scheme_protocol
|
|
101
|
+
|
|
102
|
+
/// Global counter for generating unique request IDs
|
|
103
|
+
static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1);
|
|
104
|
+
|
|
105
|
+
/// Thread-safe storage for pending async responders
|
|
106
|
+
/// Maps request_id -> RequestAsyncResponder
|
|
107
|
+
static PENDING_RESPONDERS: std::sync::OnceLock<Mutex<HashMap<u64, RequestAsyncResponder>>> =
|
|
108
|
+
std::sync::OnceLock::new();
|
|
109
|
+
|
|
110
|
+
fn get_responders() -> &'static Mutex<HashMap<u64, RequestAsyncResponder>> {
|
|
111
|
+
PENDING_RESPONDERS.get_or_init(|| Mutex::new(HashMap::new()))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// SPDX-SnippetEnd
|
|
115
|
+
|
|
116
|
+
// =============================================================================
|
|
117
|
+
// RbEventLoop - Wraps tao::event_loop::EventLoop
|
|
118
|
+
// =============================================================================
|
|
119
|
+
|
|
120
|
+
/// Ruby wrapper for `tao::event_loop::EventLoop<UserEvent>`.
|
|
121
|
+
///
|
|
122
|
+
/// The EventLoop owns the OS main thread and runs the window event pump.
|
|
123
|
+
#[magnus::wrap(class = "Tokra::Native::EventLoop", free_immediately, size)]
|
|
124
|
+
struct RbEventLoop {
|
|
125
|
+
/// The inner event loop. Wrapped in Option because `run` consumes it.
|
|
126
|
+
inner: RefCell<Option<EventLoop<UserEvent>>>,
|
|
127
|
+
/// Proxy for creating RbProxy instances before run() consumes the loop.
|
|
128
|
+
proxy: RefCell<Option<EventLoopProxy<UserEvent>>>,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// SAFETY: RbEventLoop is only accessed from the main thread. The event loop
|
|
132
|
+
// itself runs on the main thread and all Ruby interactions happen there.
|
|
133
|
+
// This matches Tauri's pattern in tauri-runtime-wry.
|
|
134
|
+
// See clippy_exceptions.rb for full justification.
|
|
135
|
+
#[allow(unsafe_code)]
|
|
136
|
+
unsafe impl Send for RbEventLoop {}
|
|
137
|
+
|
|
138
|
+
impl RbEventLoop {
|
|
139
|
+
/// Create a new EventLoop.
|
|
140
|
+
fn new() -> Result<Self, Error> {
|
|
141
|
+
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
|
|
142
|
+
|
|
143
|
+
let proxy = event_loop.create_proxy();
|
|
144
|
+
|
|
145
|
+
Ok(Self {
|
|
146
|
+
inner: RefCell::new(Some(event_loop)),
|
|
147
|
+
proxy: RefCell::new(Some(proxy)),
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Create a Proxy for cross-Ractor communication.
|
|
152
|
+
///
|
|
153
|
+
/// Must be called before `run()` since run consumes the event loop.
|
|
154
|
+
fn create_proxy(&self) -> Result<RbProxy, Error> {
|
|
155
|
+
let proxy = self.proxy.borrow().clone().ok_or_else(|| {
|
|
156
|
+
Error::new(
|
|
157
|
+
runtime_error(),
|
|
158
|
+
"EventLoop has already been consumed by run()",
|
|
159
|
+
)
|
|
160
|
+
})?;
|
|
161
|
+
|
|
162
|
+
Ok(RbProxy { inner: proxy })
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/// Run the event loop (blocking, never returns under normal operation).
|
|
166
|
+
///
|
|
167
|
+
/// The callback Proc is invoked for each event.
|
|
168
|
+
fn run(&self, callback: Value) -> Result<(), Error> {
|
|
169
|
+
let event_loop = self.inner.borrow_mut().take().ok_or_else(|| {
|
|
170
|
+
Error::new(
|
|
171
|
+
runtime_error(),
|
|
172
|
+
"EventLoop has already been consumed by run()",
|
|
173
|
+
)
|
|
174
|
+
})?;
|
|
175
|
+
|
|
176
|
+
// Setup ctrlc handler to send Exit event
|
|
177
|
+
let proxy_for_ctrlc = self.proxy.borrow().clone();
|
|
178
|
+
if let Some(proxy) = proxy_for_ctrlc {
|
|
179
|
+
let exit_sent = Arc::new(AtomicBool::new(false));
|
|
180
|
+
let exit_sent_clone = exit_sent.clone();
|
|
181
|
+
|
|
182
|
+
ctrlc::set_handler(move || {
|
|
183
|
+
if !exit_sent_clone.swap(true, Ordering::SeqCst) {
|
|
184
|
+
let _ = proxy.send_event(UserEvent::Exit);
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
.ok(); // Ignore if already set
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Run the event loop - this is a blocking call
|
|
191
|
+
event_loop.run(move |event, _, control_flow| {
|
|
192
|
+
*control_flow = ControlFlow::Wait;
|
|
193
|
+
|
|
194
|
+
match event {
|
|
195
|
+
Event::UserEvent(user_event) => match user_event {
|
|
196
|
+
UserEvent::IpcMessage(msg) => {
|
|
197
|
+
let event_obj = RbIpcEvent::new(msg);
|
|
198
|
+
let _ = callback.funcall::<_, _, Value>("call", (event_obj,));
|
|
199
|
+
}
|
|
200
|
+
UserEvent::HttpRequest {
|
|
201
|
+
request_id,
|
|
202
|
+
method,
|
|
203
|
+
uri,
|
|
204
|
+
body,
|
|
205
|
+
} => {
|
|
206
|
+
let event_obj = RbHttpRequestEvent::new(request_id, method, uri, body);
|
|
207
|
+
let _ = callback.funcall::<_, _, Value>("call", (event_obj,));
|
|
208
|
+
}
|
|
209
|
+
UserEvent::HttpResponse {
|
|
210
|
+
request_id,
|
|
211
|
+
status,
|
|
212
|
+
headers,
|
|
213
|
+
body,
|
|
214
|
+
} => {
|
|
215
|
+
if let Some(responder) =
|
|
216
|
+
get_responders().lock().unwrap().remove(&request_id)
|
|
217
|
+
{
|
|
218
|
+
let mut response_builder = http::Response::builder().status(status);
|
|
219
|
+
for (name, value) in headers {
|
|
220
|
+
response_builder = response_builder.header(name, value);
|
|
221
|
+
}
|
|
222
|
+
let response =
|
|
223
|
+
response_builder
|
|
224
|
+
.body(body.into_bytes())
|
|
225
|
+
.unwrap_or_else(|_| {
|
|
226
|
+
http::Response::builder()
|
|
227
|
+
.status(500)
|
|
228
|
+
.body(b"Internal Error".to_vec())
|
|
229
|
+
.unwrap()
|
|
230
|
+
});
|
|
231
|
+
responder.respond(response);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
UserEvent::PageLoad { event, url } => {
|
|
235
|
+
let event_obj = RbPageLoadEvent::new(event, url);
|
|
236
|
+
let _ = callback.funcall::<_, _, Value>("call", (event_obj,));
|
|
237
|
+
}
|
|
238
|
+
UserEvent::WakeUp(payload) => {
|
|
239
|
+
// Create a WakeUpEvent and call the Ruby callback
|
|
240
|
+
let event_obj = RbWakeUpEvent::new(payload);
|
|
241
|
+
let _ = callback.funcall::<_, _, Value>("call", (event_obj,));
|
|
242
|
+
}
|
|
243
|
+
UserEvent::Exit => {
|
|
244
|
+
*control_flow = ControlFlow::Exit;
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
Event::WindowEvent {
|
|
248
|
+
event: WindowEvent::CloseRequested,
|
|
249
|
+
..
|
|
250
|
+
} => {
|
|
251
|
+
// Create a WindowCloseEvent and call the Ruby callback
|
|
252
|
+
let event_obj = RbWindowCloseEvent::new();
|
|
253
|
+
let _ = callback.funcall::<_, _, Value>("call", (event_obj,));
|
|
254
|
+
*control_flow = ControlFlow::Exit;
|
|
255
|
+
}
|
|
256
|
+
_ => {}
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// =============================================================================
|
|
263
|
+
// RbWindow - Wraps tao::window::Window
|
|
264
|
+
// =============================================================================
|
|
265
|
+
|
|
266
|
+
/// Ruby wrapper for `tao::window::Window`.
|
|
267
|
+
#[magnus::wrap(class = "Tokra::Native::Window", free_immediately, size)]
|
|
268
|
+
struct RbWindow {
|
|
269
|
+
inner: Window,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// SAFETY: RbWindow is only accessed from the main thread. Window operations
|
|
273
|
+
// are only performed within the event loop callback on the main thread.
|
|
274
|
+
// This matches Tauri's pattern in tauri-runtime-wry.
|
|
275
|
+
// See clippy_exceptions.rb for full justification.
|
|
276
|
+
#[allow(unsafe_code)]
|
|
277
|
+
unsafe impl Send for RbWindow {}
|
|
278
|
+
|
|
279
|
+
impl RbWindow {
|
|
280
|
+
/// Create a new Window attached to the given EventLoop.
|
|
281
|
+
fn new(event_loop: &RbEventLoop) -> Result<Self, Error> {
|
|
282
|
+
let el = event_loop.inner.borrow();
|
|
283
|
+
let el_ref = el.as_ref().ok_or_else(|| {
|
|
284
|
+
Error::new(
|
|
285
|
+
runtime_error(),
|
|
286
|
+
"EventLoop has already been consumed by run()",
|
|
287
|
+
)
|
|
288
|
+
})?;
|
|
289
|
+
|
|
290
|
+
let window = WindowBuilder::new()
|
|
291
|
+
.with_title("Tokra")
|
|
292
|
+
.build(el_ref)
|
|
293
|
+
.map_err(|e| Error::new(runtime_error(), e.to_string()))?;
|
|
294
|
+
|
|
295
|
+
Ok(Self { inner: window })
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/// Set the window title.
|
|
299
|
+
fn set_title(&self, title: String) {
|
|
300
|
+
self.inner.set_title(&title);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/// Set the window inner size.
|
|
304
|
+
fn set_size(&self, width: f64, height: f64) {
|
|
305
|
+
use tao::dpi::LogicalSize;
|
|
306
|
+
let size = LogicalSize::new(width, height);
|
|
307
|
+
self.inner.set_inner_size(size);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// Get the window ID as a string.
|
|
311
|
+
fn id(&self) -> String {
|
|
312
|
+
format!("{:?}", self.inner.id())
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// =============================================================================
|
|
317
|
+
// RbWebView - Wraps wry::WebView
|
|
318
|
+
// =============================================================================
|
|
319
|
+
|
|
320
|
+
/// Ruby wrapper for `wry::WebView`.
|
|
321
|
+
#[magnus::wrap(class = "Tokra::Native::WebView", free_immediately, size)]
|
|
322
|
+
struct RbWebView {
|
|
323
|
+
inner: WebView,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// SAFETY: RbWebView is only accessed from the main thread. WebView operations
|
|
327
|
+
// are only performed within the event loop callback on the main thread.
|
|
328
|
+
// This matches Tauri's pattern in tauri-runtime-wry where they use:
|
|
329
|
+
// `unsafe impl Send for WindowsStore {}` with the comment:
|
|
330
|
+
// "SAFETY: we ensure this type is only used on the main thread."
|
|
331
|
+
// See clippy_exceptions.rb for full justification.
|
|
332
|
+
#[allow(unsafe_code)]
|
|
333
|
+
unsafe impl Send for RbWebView {}
|
|
334
|
+
|
|
335
|
+
impl RbWebView {
|
|
336
|
+
/// Create a new WebView attached to the given Window.
|
|
337
|
+
///
|
|
338
|
+
/// The `ipc_callback` must be a `Ractor.shareable_proc`.
|
|
339
|
+
fn new(
|
|
340
|
+
window: &RbWindow,
|
|
341
|
+
url: String,
|
|
342
|
+
_ipc_callback: Value,
|
|
343
|
+
proxy: &RbProxy,
|
|
344
|
+
) -> Result<Self, Error> {
|
|
345
|
+
let proxy_clone = proxy.inner.clone();
|
|
346
|
+
|
|
347
|
+
let webview = WebViewBuilder::new()
|
|
348
|
+
.with_url(&url)
|
|
349
|
+
.with_ipc_handler(move |msg| {
|
|
350
|
+
let _ = proxy_clone.send_event(UserEvent::IpcMessage(msg.body().to_string()));
|
|
351
|
+
})
|
|
352
|
+
.build(&window.inner)
|
|
353
|
+
.map_err(|e| Error::new(runtime_error(), e.to_string()))?;
|
|
354
|
+
|
|
355
|
+
Ok(Self { inner: webview })
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/// Create a new WebView with the `tokra://` custom protocol.
|
|
359
|
+
///
|
|
360
|
+
/// This is the primary constructor for Rack-style apps. The WebView navigates
|
|
361
|
+
/// to `tokra://localhost/` and Ruby handles all requests via HttpRequestEvent.
|
|
362
|
+
/// Ruby controls everything: initial HTML, form submissions, API responses.
|
|
363
|
+
///
|
|
364
|
+
/// This matches Tauri's pattern where apps load from `tauri://localhost/`.
|
|
365
|
+
fn new_with_protocol(window: &RbWindow, proxy: &RbProxy) -> Result<Self, Error> {
|
|
366
|
+
let proxy_clone = proxy.inner.clone();
|
|
367
|
+
let proxy_for_protocol = proxy.inner.clone();
|
|
368
|
+
let proxy_for_page_load = proxy.inner.clone();
|
|
369
|
+
|
|
370
|
+
// SPDX-SnippetBegin
|
|
371
|
+
// SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
372
|
+
// SPDX-SnippetCopyrightText: 2020-2023 Tauri Programme within The Commons Conservancy
|
|
373
|
+
// SPDX-SnippetComment: Async custom protocol pattern adapted from tauri/app.rs register_asynchronous_uri_scheme_protocol
|
|
374
|
+
// Initial URL pattern adapted from tauri/manager/webview.rs WebviewUrl::App
|
|
375
|
+
// Page load handler pattern adapted from tauri/webview/mod.rs on_page_load
|
|
376
|
+
let webview = WebViewBuilder::new()
|
|
377
|
+
.with_url("tokra://localhost/")
|
|
378
|
+
.with_asynchronous_custom_protocol(
|
|
379
|
+
"tokra".into(),
|
|
380
|
+
move |_webview_id, request, responder| {
|
|
381
|
+
// Generate unique request ID
|
|
382
|
+
let request_id = NEXT_REQUEST_ID.fetch_add(1, Ordering::SeqCst);
|
|
383
|
+
|
|
384
|
+
// Extract request details
|
|
385
|
+
let method = request.method().to_string();
|
|
386
|
+
let uri = request.uri().to_string();
|
|
387
|
+
let body = String::from_utf8_lossy(request.body()).to_string();
|
|
388
|
+
|
|
389
|
+
// Store the responder for later (Ruby will call back with the response)
|
|
390
|
+
get_responders()
|
|
391
|
+
.lock()
|
|
392
|
+
.unwrap()
|
|
393
|
+
.insert(request_id, responder);
|
|
394
|
+
|
|
395
|
+
// Send HTTP request through the event loop to Ruby
|
|
396
|
+
// Ruby will process and send back an HttpResponse event
|
|
397
|
+
let _ = proxy_for_protocol.send_event(UserEvent::HttpRequest {
|
|
398
|
+
request_id,
|
|
399
|
+
method,
|
|
400
|
+
uri,
|
|
401
|
+
body,
|
|
402
|
+
});
|
|
403
|
+
},
|
|
404
|
+
)
|
|
405
|
+
.with_on_page_load_handler(move |event, url| {
|
|
406
|
+
let event_name = match event {
|
|
407
|
+
PageLoadEvent::Started => "started",
|
|
408
|
+
PageLoadEvent::Finished => "finished",
|
|
409
|
+
};
|
|
410
|
+
let _ = proxy_for_page_load.send_event(UserEvent::PageLoad {
|
|
411
|
+
event: event_name.to_string(),
|
|
412
|
+
url,
|
|
413
|
+
});
|
|
414
|
+
})
|
|
415
|
+
// SPDX-SnippetEnd
|
|
416
|
+
.with_ipc_handler(move |msg| {
|
|
417
|
+
let _ = proxy_clone.send_event(UserEvent::IpcMessage(msg.body().to_string()));
|
|
418
|
+
})
|
|
419
|
+
.build(&window.inner)
|
|
420
|
+
.map_err(|e| Error::new(runtime_error(), e.to_string()))?;
|
|
421
|
+
|
|
422
|
+
Ok(Self { inner: webview })
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/// Evaluate JavaScript in the WebView.
|
|
426
|
+
fn eval(&self, js: String) -> Result<(), Error> {
|
|
427
|
+
self.inner
|
|
428
|
+
.evaluate_script(&js)
|
|
429
|
+
.map_err(|e| Error::new(runtime_error(), e.to_string()))
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// =============================================================================
|
|
434
|
+
// RbProxy - Wraps tao::event_loop::EventLoopProxy
|
|
435
|
+
// =============================================================================
|
|
436
|
+
|
|
437
|
+
/// Ruby wrapper for `tao::event_loop::EventLoopProxy<UserEvent>`.
|
|
438
|
+
///
|
|
439
|
+
/// Thread-safe handle for waking up the event loop from Ractors.
|
|
440
|
+
/// This is the ONLY type that is genuinely designed to be Send+Sync safe by tao.
|
|
441
|
+
///
|
|
442
|
+
/// This type uses `frozen_shareable` to be Ractor-shareable when frozen.
|
|
443
|
+
/// Ruby automatically freezes objects shared across Ractors.
|
|
444
|
+
#[derive(Clone)]
|
|
445
|
+
struct RbProxy {
|
|
446
|
+
inner: EventLoopProxy<UserEvent>,
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// RbProxy is genuinely Send+Sync because EventLoopProxy<T> is Send+Sync
|
|
450
|
+
// This is NOT an unsafe impl - tao guarantees this.
|
|
451
|
+
// SAFETY note: Sync is required for frozen_shareable in Magnus.
|
|
452
|
+
// EventLoopProxy is explicitly designed to be used from any thread.
|
|
453
|
+
|
|
454
|
+
impl magnus::DataTypeFunctions for RbProxy {}
|
|
455
|
+
|
|
456
|
+
// SAFETY: RbProxy wraps EventLoopProxy which is Send+Sync by design.
|
|
457
|
+
// This impl enables the frozen_shareable flag which makes the Ruby object
|
|
458
|
+
// Ractor-shareable when frozen. Ruby automatically freezes shareable objects.
|
|
459
|
+
#[allow(unsafe_code)]
|
|
460
|
+
unsafe impl magnus::TypedData for RbProxy {
|
|
461
|
+
fn class(ruby: &magnus::Ruby) -> magnus::RClass {
|
|
462
|
+
static CLASS: magnus::value::Lazy<magnus::RClass> = magnus::value::Lazy::new(|ruby| {
|
|
463
|
+
let tokra = ruby.define_module("Tokra").unwrap();
|
|
464
|
+
let native = tokra.define_module("Native").unwrap();
|
|
465
|
+
let class = native.define_class("Proxy", ruby.class_object()).unwrap();
|
|
466
|
+
class
|
|
467
|
+
});
|
|
468
|
+
ruby.get_inner(&CLASS)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
fn data_type() -> &'static magnus::DataType {
|
|
472
|
+
static DATA_TYPE: magnus::DataType =
|
|
473
|
+
magnus::typed_data::DataTypeBuilder::<RbProxy>::new(c"Tokra::Native::Proxy")
|
|
474
|
+
.free_immediately()
|
|
475
|
+
.size()
|
|
476
|
+
.frozen_shareable()
|
|
477
|
+
.build();
|
|
478
|
+
&DATA_TYPE
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
impl RbProxy {
|
|
483
|
+
/// Wake up the event loop with a payload.
|
|
484
|
+
///
|
|
485
|
+
/// This is the critical method for Worker Ractor → Main Ractor communication.
|
|
486
|
+
fn wake_up(&self, payload: String) -> Result<(), Error> {
|
|
487
|
+
self.inner
|
|
488
|
+
.send_event(UserEvent::WakeUp(payload))
|
|
489
|
+
.map_err(|_| {
|
|
490
|
+
Error::new(
|
|
491
|
+
runtime_error(),
|
|
492
|
+
"Failed to send wake up event: event loop closed",
|
|
493
|
+
)
|
|
494
|
+
})
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/// Send an HTTP response for a pending async protocol request.
|
|
498
|
+
///
|
|
499
|
+
/// This is used by Ruby to respond to tokra:// requests.
|
|
500
|
+
/// The request_id must match a pending HttpRequest event.
|
|
501
|
+
/// Headers should be an array of [name, value] pairs.
|
|
502
|
+
fn respond(
|
|
503
|
+
&self,
|
|
504
|
+
request_id: u64,
|
|
505
|
+
status: u16,
|
|
506
|
+
headers: Vec<(String, String)>,
|
|
507
|
+
body: String,
|
|
508
|
+
) -> Result<(), Error> {
|
|
509
|
+
self.inner
|
|
510
|
+
.send_event(UserEvent::HttpResponse {
|
|
511
|
+
request_id,
|
|
512
|
+
status,
|
|
513
|
+
headers,
|
|
514
|
+
body,
|
|
515
|
+
})
|
|
516
|
+
.map_err(|_| {
|
|
517
|
+
Error::new(
|
|
518
|
+
runtime_error(),
|
|
519
|
+
"Failed to send HTTP response: event loop closed",
|
|
520
|
+
)
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// =============================================================================
|
|
526
|
+
// Event Classes - Ruby event types for the callback
|
|
527
|
+
// =============================================================================
|
|
528
|
+
|
|
529
|
+
/// Simple Ruby class for IPC events.
|
|
530
|
+
#[magnus::wrap(class = "Tokra::Native::IpcEvent", free_immediately, size)]
|
|
531
|
+
struct RbIpcEvent {
|
|
532
|
+
message: String,
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
impl RbIpcEvent {
|
|
536
|
+
fn new(message: String) -> Self {
|
|
537
|
+
Self { message }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
fn message(&self) -> String {
|
|
541
|
+
self.message.clone()
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/// Simple Ruby class for wake-up events.
|
|
546
|
+
#[magnus::wrap(class = "Tokra::Native::WakeUpEvent", free_immediately, size)]
|
|
547
|
+
struct RbWakeUpEvent {
|
|
548
|
+
payload: String,
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
impl RbWakeUpEvent {
|
|
552
|
+
fn new(payload: String) -> Self {
|
|
553
|
+
Self { payload }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
fn payload(&self) -> String {
|
|
557
|
+
self.payload.clone()
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/// Simple Ruby class for window close events.
|
|
562
|
+
#[magnus::wrap(class = "Tokra::Native::WindowCloseEvent", free_immediately, size)]
|
|
563
|
+
struct RbWindowCloseEvent;
|
|
564
|
+
|
|
565
|
+
impl RbWindowCloseEvent {
|
|
566
|
+
fn new() -> Self {
|
|
567
|
+
Self
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/// Ruby class for HTTP request events (from tokra:// custom protocol).
|
|
572
|
+
/// Includes request_id for correlating async responses.
|
|
573
|
+
#[magnus::wrap(class = "Tokra::Native::HttpRequestEvent", free_immediately, size)]
|
|
574
|
+
struct RbHttpRequestEvent {
|
|
575
|
+
request_id: u64,
|
|
576
|
+
method: String,
|
|
577
|
+
uri: String,
|
|
578
|
+
body: String,
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
impl RbHttpRequestEvent {
|
|
582
|
+
fn new(request_id: u64, method: String, uri: String, body: String) -> Self {
|
|
583
|
+
Self {
|
|
584
|
+
request_id,
|
|
585
|
+
method,
|
|
586
|
+
uri,
|
|
587
|
+
body,
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
fn request_id(&self) -> u64 {
|
|
592
|
+
self.request_id
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
fn method(&self) -> String {
|
|
596
|
+
self.method.clone()
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
fn uri(&self) -> String {
|
|
600
|
+
self.uri.clone()
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
fn body(&self) -> String {
|
|
604
|
+
self.body.clone()
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// SPDX-SnippetBegin
|
|
609
|
+
// SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
610
|
+
// SPDX-SnippetCopyrightText: 2020-2023 Tauri Programme within The Commons Conservancy
|
|
611
|
+
// SPDX-SnippetComment: PageLoadEvent pattern adapted from tauri/webview/mod.rs on_page_load
|
|
612
|
+
|
|
613
|
+
/// Ruby class for page load events (started/finished).
|
|
614
|
+
/// Matches Tauri's on_page_load API.
|
|
615
|
+
#[magnus::wrap(class = "Tokra::Native::PageLoadEvent", free_immediately, size)]
|
|
616
|
+
struct RbPageLoadEvent {
|
|
617
|
+
event: String, // "started" or "finished"
|
|
618
|
+
url: String,
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
impl RbPageLoadEvent {
|
|
622
|
+
fn new(event: String, url: String) -> Self {
|
|
623
|
+
Self { event, url }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/// Returns "started" or "finished"
|
|
627
|
+
fn event(&self) -> String {
|
|
628
|
+
self.event.clone()
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
fn url(&self) -> String {
|
|
632
|
+
self.url.clone()
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/// Returns true if this is a "started" event
|
|
636
|
+
fn started(&self) -> bool {
|
|
637
|
+
self.event == "started"
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/// Returns true if this is a "finished" event
|
|
641
|
+
fn finished(&self) -> bool {
|
|
642
|
+
self.event == "finished"
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// SPDX-SnippetEnd
|
|
647
|
+
|
|
648
|
+
// =============================================================================
|
|
649
|
+
// Module Initialization
|
|
650
|
+
// =============================================================================
|
|
651
|
+
|
|
652
|
+
/// Initialize the Tokra Ruby module.
|
|
653
|
+
///
|
|
654
|
+
/// # Errors
|
|
655
|
+
///
|
|
656
|
+
/// Returns an error if module or method definition fails.
|
|
657
|
+
#[magnus::init]
|
|
658
|
+
fn init(ruby: &Ruby) -> Result<(), Error> {
|
|
659
|
+
let tokra = ruby.define_module("Tokra")?;
|
|
660
|
+
let native = tokra.define_module("Native")?;
|
|
661
|
+
|
|
662
|
+
// EventLoop
|
|
663
|
+
let event_loop_class = native.define_class("EventLoop", ruby.class_object())?;
|
|
664
|
+
event_loop_class.define_singleton_method("new", function!(RbEventLoop::new, 0))?;
|
|
665
|
+
event_loop_class.define_method("create_proxy", method!(RbEventLoop::create_proxy, 0))?;
|
|
666
|
+
event_loop_class.define_method("run", method!(RbEventLoop::run, 1))?;
|
|
667
|
+
|
|
668
|
+
// Window
|
|
669
|
+
let window_class = native.define_class("Window", ruby.class_object())?;
|
|
670
|
+
window_class.define_singleton_method("new", function!(RbWindow::new, 1))?;
|
|
671
|
+
window_class.define_method("set_title", method!(RbWindow::set_title, 1))?;
|
|
672
|
+
window_class.define_method("set_size", method!(RbWindow::set_size, 2))?;
|
|
673
|
+
window_class.define_method("id", method!(RbWindow::id, 0))?;
|
|
674
|
+
|
|
675
|
+
// WebView
|
|
676
|
+
let webview_class = native.define_class("WebView", ruby.class_object())?;
|
|
677
|
+
webview_class.define_singleton_method("new", function!(RbWebView::new, 4))?;
|
|
678
|
+
webview_class.define_singleton_method(
|
|
679
|
+
"new_with_protocol",
|
|
680
|
+
function!(RbWebView::new_with_protocol, 2),
|
|
681
|
+
)?;
|
|
682
|
+
webview_class.define_method("eval", method!(RbWebView::eval, 1))?;
|
|
683
|
+
|
|
684
|
+
// Proxy
|
|
685
|
+
let proxy_class = native.define_class("Proxy", ruby.class_object())?;
|
|
686
|
+
proxy_class.define_method("wake_up", method!(RbProxy::wake_up, 1))?;
|
|
687
|
+
proxy_class.define_method("respond", method!(RbProxy::respond, 4))?;
|
|
688
|
+
|
|
689
|
+
// Event classes
|
|
690
|
+
let ipc_event_class = native.define_class("IpcEvent", ruby.class_object())?;
|
|
691
|
+
ipc_event_class.define_singleton_method("new", function!(RbIpcEvent::new, 1))?;
|
|
692
|
+
ipc_event_class.define_method("message", method!(RbIpcEvent::message, 0))?;
|
|
693
|
+
|
|
694
|
+
let wake_up_event_class = native.define_class("WakeUpEvent", ruby.class_object())?;
|
|
695
|
+
wake_up_event_class.define_singleton_method("new", function!(RbWakeUpEvent::new, 1))?;
|
|
696
|
+
wake_up_event_class.define_method("payload", method!(RbWakeUpEvent::payload, 0))?;
|
|
697
|
+
|
|
698
|
+
let window_close_event_class = native.define_class("WindowCloseEvent", ruby.class_object())?;
|
|
699
|
+
window_close_event_class
|
|
700
|
+
.define_singleton_method("new", function!(RbWindowCloseEvent::new, 0))?;
|
|
701
|
+
|
|
702
|
+
let http_request_event_class = native.define_class("HttpRequestEvent", ruby.class_object())?;
|
|
703
|
+
http_request_event_class
|
|
704
|
+
.define_singleton_method("new", function!(RbHttpRequestEvent::new, 4))?;
|
|
705
|
+
http_request_event_class
|
|
706
|
+
.define_method("request_id", method!(RbHttpRequestEvent::request_id, 0))?;
|
|
707
|
+
http_request_event_class.define_method("method", method!(RbHttpRequestEvent::method, 0))?;
|
|
708
|
+
http_request_event_class.define_method("uri", method!(RbHttpRequestEvent::uri, 0))?;
|
|
709
|
+
http_request_event_class.define_method("body", method!(RbHttpRequestEvent::body, 0))?;
|
|
710
|
+
|
|
711
|
+
let page_load_event_class = native.define_class("PageLoadEvent", ruby.class_object())?;
|
|
712
|
+
page_load_event_class.define_singleton_method("new", function!(RbPageLoadEvent::new, 2))?;
|
|
713
|
+
page_load_event_class.define_method("event", method!(RbPageLoadEvent::event, 0))?;
|
|
714
|
+
page_load_event_class.define_method("url", method!(RbPageLoadEvent::url, 0))?;
|
|
715
|
+
page_load_event_class.define_method("started?", method!(RbPageLoadEvent::started, 0))?;
|
|
716
|
+
page_load_event_class.define_method("finished?", method!(RbPageLoadEvent::finished, 0))?;
|
|
717
|
+
|
|
718
|
+
Ok(())
|
|
719
|
+
}
|