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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.pre-commit-config.yaml +16 -0
  3. data/AGENTS.md +126 -0
  4. data/CHANGELOG.md +21 -0
  5. data/CODE_OF_CONDUCT.md +16 -0
  6. data/Cargo.toml +23 -0
  7. data/LICENSE +661 -0
  8. data/LICENSES/AGPL-3.0-or-later.txt +235 -0
  9. data/LICENSES/Apache-2.0.txt +73 -0
  10. data/LICENSES/CC-BY-SA-4.0.txt +170 -0
  11. data/LICENSES/CC0-1.0.txt +121 -0
  12. data/LICENSES/MIT.txt +18 -0
  13. data/README.md +45 -0
  14. data/README.rdoc +4 -0
  15. data/REUSE.toml +11 -0
  16. data/Rakefile +27 -0
  17. data/Steepfile +15 -0
  18. data/clippy.toml +5 -0
  19. data/clippy_exceptions.rb +59 -0
  20. data/doc/contributors/adr/001.md +187 -0
  21. data/doc/contributors/adr/002.md +132 -0
  22. data/doc/contributors/adr/003.md +116 -0
  23. data/doc/contributors/chats/001.md +3874 -0
  24. data/doc/contributors/plan/001.md +271 -0
  25. data/examples/verify_hello_world/app.rb +114 -0
  26. data/examples/verify_hello_world/index.html +88 -0
  27. data/examples/verify_ping_pong/README.md +0 -0
  28. data/examples/verify_ping_pong/app.rb +132 -0
  29. data/examples/verify_ping_pong/public/styles.css +182 -0
  30. data/examples/verify_ping_pong/views/index.erb +94 -0
  31. data/examples/verify_ping_pong/views/layout.erb +22 -0
  32. data/exe/semantic-highlight +0 -0
  33. data/ext/tokra/Cargo.toml +23 -0
  34. data/ext/tokra/extconf.rb +12 -0
  35. data/ext/tokra/src/lib.rs +719 -0
  36. data/lib/tokra/native.rb +79 -0
  37. data/lib/tokra/rack/handler.rb +177 -0
  38. data/lib/tokra/version.rb +12 -0
  39. data/lib/tokra.rb +19 -0
  40. data/mise.toml +8 -0
  41. data/rustfmt.toml +4 -0
  42. data/sig/tokra.rbs +7 -0
  43. data/tasks/lint.rake +151 -0
  44. data/tasks/rust.rake +63 -0
  45. data/tasks/steep.rake +11 -0
  46. data/tasks/test.rake +26 -0
  47. data/test_native.rb +37 -0
  48. data/vendor/goodcop/base.yml +1047 -0
  49. 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
+ }