tokra 0.0.1.pre.1 → 0.0.1.pre.2

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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSES/MIT-0.txt +16 -0
  3. data/REUSE.toml +6 -1
  4. data/Rakefile +2 -0
  5. data/Steepfile +2 -0
  6. data/clippy_exceptions.rb +75 -37
  7. data/doc/contributors/adr/004.md +63 -0
  8. data/doc/contributors/chats/002.md +177 -0
  9. data/doc/contributors/chats/003.md +2180 -0
  10. data/doc/contributors/chats/004.md +1992 -0
  11. data/doc/contributors/chats/005.md +1529 -0
  12. data/doc/contributors/plan/002.md +173 -0
  13. data/doc/contributors/plan/003.md +111 -0
  14. data/examples/verify_hello_world/index.html +15 -2
  15. data/examples/verify_ping_pong/app.rb +3 -1
  16. data/examples/verify_ping_pong/public/styles.css +36 -9
  17. data/examples/verify_ping_pong/views/layout.erb +1 -1
  18. data/examples/verify_rails_sqlite/.dockerignore +51 -0
  19. data/examples/verify_rails_sqlite/.gitattributes +9 -0
  20. data/examples/verify_rails_sqlite/.github/dependabot.yml +12 -0
  21. data/examples/verify_rails_sqlite/.github/workflows/ci.yml +124 -0
  22. data/examples/verify_rails_sqlite/.gitignore +35 -0
  23. data/examples/verify_rails_sqlite/.kamal/hooks/docker-setup.sample +3 -0
  24. data/examples/verify_rails_sqlite/.kamal/hooks/post-app-boot.sample +3 -0
  25. data/examples/verify_rails_sqlite/.kamal/hooks/post-deploy.sample +14 -0
  26. data/examples/verify_rails_sqlite/.kamal/hooks/post-proxy-reboot.sample +3 -0
  27. data/examples/verify_rails_sqlite/.kamal/hooks/pre-app-boot.sample +3 -0
  28. data/examples/verify_rails_sqlite/.kamal/hooks/pre-build.sample +51 -0
  29. data/examples/verify_rails_sqlite/.kamal/hooks/pre-connect.sample +47 -0
  30. data/examples/verify_rails_sqlite/.kamal/hooks/pre-deploy.sample +122 -0
  31. data/examples/verify_rails_sqlite/.kamal/hooks/pre-proxy-reboot.sample +3 -0
  32. data/examples/verify_rails_sqlite/.kamal/secrets +20 -0
  33. data/examples/verify_rails_sqlite/.rubocop.yml +2 -0
  34. data/examples/verify_rails_sqlite/.ruby-version +1 -0
  35. data/examples/verify_rails_sqlite/Dockerfile +77 -0
  36. data/examples/verify_rails_sqlite/Gemfile +66 -0
  37. data/examples/verify_rails_sqlite/Gemfile.lock +563 -0
  38. data/examples/verify_rails_sqlite/README.md +41 -0
  39. data/examples/verify_rails_sqlite/Rakefile +6 -0
  40. data/examples/verify_rails_sqlite/app/assets/images/.keep +0 -0
  41. data/examples/verify_rails_sqlite/app/assets/stylesheets/application.css +469 -0
  42. data/examples/verify_rails_sqlite/app/controllers/application_controller.rb +12 -0
  43. data/examples/verify_rails_sqlite/app/controllers/concerns/.keep +0 -0
  44. data/examples/verify_rails_sqlite/app/controllers/todos_controller.rb +70 -0
  45. data/examples/verify_rails_sqlite/app/helpers/application_helper.rb +2 -0
  46. data/examples/verify_rails_sqlite/app/helpers/todos_helper.rb +2 -0
  47. data/examples/verify_rails_sqlite/app/javascript/application.js +3 -0
  48. data/examples/verify_rails_sqlite/app/javascript/controllers/application.js +9 -0
  49. data/examples/verify_rails_sqlite/app/javascript/controllers/hello_controller.js +7 -0
  50. data/examples/verify_rails_sqlite/app/javascript/controllers/index.js +4 -0
  51. data/examples/verify_rails_sqlite/app/jobs/application_job.rb +7 -0
  52. data/examples/verify_rails_sqlite/app/mailers/application_mailer.rb +4 -0
  53. data/examples/verify_rails_sqlite/app/models/application_record.rb +3 -0
  54. data/examples/verify_rails_sqlite/app/models/concerns/.keep +0 -0
  55. data/examples/verify_rails_sqlite/app/models/todo.rb +10 -0
  56. data/examples/verify_rails_sqlite/app/views/layouts/application.html.erb +24 -0
  57. data/examples/verify_rails_sqlite/app/views/layouts/mailer.html.erb +13 -0
  58. data/examples/verify_rails_sqlite/app/views/layouts/mailer.text.erb +1 -0
  59. data/examples/verify_rails_sqlite/app/views/pwa/manifest.json.erb +22 -0
  60. data/examples/verify_rails_sqlite/app/views/pwa/service-worker.js +26 -0
  61. data/examples/verify_rails_sqlite/app/views/todos/_form.html.erb +27 -0
  62. data/examples/verify_rails_sqlite/app/views/todos/_todo.html.erb +18 -0
  63. data/examples/verify_rails_sqlite/app/views/todos/_todo.json.jbuilder +2 -0
  64. data/examples/verify_rails_sqlite/app/views/todos/edit.html.erb +7 -0
  65. data/examples/verify_rails_sqlite/app/views/todos/index.html.erb +22 -0
  66. data/examples/verify_rails_sqlite/app/views/todos/index.json.jbuilder +1 -0
  67. data/examples/verify_rails_sqlite/app/views/todos/new.html.erb +7 -0
  68. data/examples/verify_rails_sqlite/app/views/todos/show.html.erb +23 -0
  69. data/examples/verify_rails_sqlite/app/views/todos/show.json.jbuilder +1 -0
  70. data/examples/verify_rails_sqlite/bin/brakeman +7 -0
  71. data/examples/verify_rails_sqlite/bin/bundler-audit +6 -0
  72. data/examples/verify_rails_sqlite/bin/ci +6 -0
  73. data/examples/verify_rails_sqlite/bin/dev +2 -0
  74. data/examples/verify_rails_sqlite/bin/docker-entrypoint +8 -0
  75. data/examples/verify_rails_sqlite/bin/importmap +4 -0
  76. data/examples/verify_rails_sqlite/bin/jobs +6 -0
  77. data/examples/verify_rails_sqlite/bin/kamal +16 -0
  78. data/examples/verify_rails_sqlite/bin/rails +4 -0
  79. data/examples/verify_rails_sqlite/bin/rake +4 -0
  80. data/examples/verify_rails_sqlite/bin/rubocop +8 -0
  81. data/examples/verify_rails_sqlite/bin/setup +35 -0
  82. data/examples/verify_rails_sqlite/bin/thrust +5 -0
  83. data/examples/verify_rails_sqlite/config/application.rb +27 -0
  84. data/examples/verify_rails_sqlite/config/boot.rb +4 -0
  85. data/examples/verify_rails_sqlite/config/bundler-audit.yml +5 -0
  86. data/examples/verify_rails_sqlite/config/cable.yml +17 -0
  87. data/examples/verify_rails_sqlite/config/cache.yml +16 -0
  88. data/examples/verify_rails_sqlite/config/ci.rb +24 -0
  89. data/examples/verify_rails_sqlite/config/credentials.yml.enc +1 -0
  90. data/examples/verify_rails_sqlite/config/database.yml +40 -0
  91. data/examples/verify_rails_sqlite/config/deploy.yml +119 -0
  92. data/examples/verify_rails_sqlite/config/environment.rb +5 -0
  93. data/examples/verify_rails_sqlite/config/environments/development.rb +84 -0
  94. data/examples/verify_rails_sqlite/config/environments/production.rb +99 -0
  95. data/examples/verify_rails_sqlite/config/environments/test.rb +53 -0
  96. data/examples/verify_rails_sqlite/config/importmap.rb +7 -0
  97. data/examples/verify_rails_sqlite/config/initializers/assets.rb +7 -0
  98. data/examples/verify_rails_sqlite/config/initializers/content_security_policy.rb +29 -0
  99. data/examples/verify_rails_sqlite/config/initializers/filter_parameter_logging.rb +8 -0
  100. data/examples/verify_rails_sqlite/config/initializers/inflections.rb +16 -0
  101. data/examples/verify_rails_sqlite/config/locales/en.yml +31 -0
  102. data/examples/verify_rails_sqlite/config/puma.rb +42 -0
  103. data/examples/verify_rails_sqlite/config/queue.yml +18 -0
  104. data/examples/verify_rails_sqlite/config/recurring.yml +15 -0
  105. data/examples/verify_rails_sqlite/config/routes.rb +15 -0
  106. data/examples/verify_rails_sqlite/config/storage.yml +27 -0
  107. data/examples/verify_rails_sqlite/config.ru +6 -0
  108. data/examples/verify_rails_sqlite/db/cable_schema.rb +11 -0
  109. data/examples/verify_rails_sqlite/db/cache_schema.rb +12 -0
  110. data/examples/verify_rails_sqlite/db/migrate/20260130080933_create_todos.rb +10 -0
  111. data/examples/verify_rails_sqlite/db/queue_schema.rb +129 -0
  112. data/examples/verify_rails_sqlite/db/schema.rb +20 -0
  113. data/examples/verify_rails_sqlite/db/seeds.rb +9 -0
  114. data/examples/verify_rails_sqlite/lib/tasks/.keep +0 -0
  115. data/examples/verify_rails_sqlite/log/.keep +0 -0
  116. data/examples/verify_rails_sqlite/public/400.html +135 -0
  117. data/examples/verify_rails_sqlite/public/404.html +135 -0
  118. data/examples/verify_rails_sqlite/public/406-unsupported-browser.html +135 -0
  119. data/examples/verify_rails_sqlite/public/422.html +135 -0
  120. data/examples/verify_rails_sqlite/public/500.html +135 -0
  121. data/examples/verify_rails_sqlite/public/icon.png +0 -0
  122. data/examples/verify_rails_sqlite/public/icon.svg +3 -0
  123. data/examples/verify_rails_sqlite/public/robots.txt +1 -0
  124. data/examples/verify_rails_sqlite/public/styles.css +469 -0
  125. data/examples/verify_rails_sqlite/script/.keep +0 -0
  126. data/examples/verify_rails_sqlite/storage/.keep +0 -0
  127. data/examples/verify_rails_sqlite/test/controllers/.keep +0 -0
  128. data/examples/verify_rails_sqlite/test/controllers/todos_controller_test.rb +48 -0
  129. data/examples/verify_rails_sqlite/test/fixtures/files/.keep +0 -0
  130. data/examples/verify_rails_sqlite/test/fixtures/todos.yml +9 -0
  131. data/examples/verify_rails_sqlite/test/helpers/.keep +0 -0
  132. data/examples/verify_rails_sqlite/test/integration/.keep +0 -0
  133. data/examples/verify_rails_sqlite/test/mailers/.keep +0 -0
  134. data/examples/verify_rails_sqlite/test/models/.keep +0 -0
  135. data/examples/verify_rails_sqlite/test/models/todo_test.rb +7 -0
  136. data/examples/verify_rails_sqlite/test/test_helper.rb +15 -0
  137. data/examples/verify_rails_sqlite/tmp/.keep +0 -0
  138. data/examples/verify_rails_sqlite/tmp/pids/.keep +0 -0
  139. data/examples/verify_rails_sqlite/tmp/storage/.keep +0 -0
  140. data/examples/verify_rails_sqlite/tokra.rb +42 -0
  141. data/examples/verify_rails_sqlite/vendor/.keep +0 -0
  142. data/examples/verify_rails_sqlite/vendor/javascript/.keep +0 -0
  143. data/ext/tokra/src/event_loop.rs +206 -0
  144. data/ext/tokra/src/events.rs +430 -0
  145. data/ext/tokra/src/lib.rs +52 -664
  146. data/ext/tokra/src/proxy.rs +142 -0
  147. data/ext/tokra/src/responders.rs +86 -0
  148. data/ext/tokra/src/user_event.rs +313 -0
  149. data/ext/tokra/src/webview.rs +194 -0
  150. data/ext/tokra/src/window.rs +92 -0
  151. data/lib/tokra/rack/handler.rb +37 -14
  152. data/lib/tokra/version.rb +1 -1
  153. data/rbs_exceptions.rb +12 -0
  154. data/sig/tokra.rbs +95 -1
  155. data/tasks/lint.rake +2 -2
  156. data/tasks/lint.rb +49 -0
  157. data/tasks/rust.rake +6 -23
  158. data/tasks/steep.rake +25 -3
  159. data/tasks/test.rake +1 -1
  160. metadata +143 -1
@@ -0,0 +1,194 @@
1
+ // SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ //! `WebView` wrapper for wry.
5
+ //!
6
+ //! This module provides the Ruby-facing `Tokra::Native::WebView` class,
7
+ //! which wraps `wry::WebView` and implements the tokra:// custom protocol.
8
+
9
+ use magnus::{function, method, prelude::*, Error, Ruby, Value};
10
+ use wry::{PageLoadEvent, WebView, WebViewBuilder};
11
+
12
+ use crate::proxy::RbProxy;
13
+ use crate::responders::{get_responders, next_request_id};
14
+ use crate::runtime_error;
15
+ use crate::user_event::UserEvent;
16
+ use crate::window::RbWindow;
17
+
18
+ /// Ruby wrapper for `wry::WebView`.
19
+ #[magnus::wrap(class = "Tokra::Native::WebView", free_immediately, size)]
20
+ pub struct RbWebView {
21
+ inner: WebView,
22
+ }
23
+
24
+ // SPDX-SnippetBegin
25
+ // SPDX-License-Identifier: Apache-2.0 OR MIT
26
+ // SPDX-SnippetCopyrightText: 2020-2023 Tauri Programme within The Commons Conservancy
27
+ // SPDX-SnippetComment: Thread safety pattern adapted from tauri-runtime-wry/lib.rs unsafe impl Send for WindowsStore
28
+
29
+ // SAFETY: RbWebView is only accessed from the main thread. WebView operations
30
+ // are only performed within the event loop callback on the main thread.
31
+ // This matches Tauri's pattern in tauri-runtime-wry where they use:
32
+ // `unsafe impl Send for WindowsStore {}` with the comment:
33
+ // "SAFETY: we ensure this type is only used on the main thread."
34
+ // See clippy_exceptions.rb for full justification.
35
+ #[allow(unsafe_code)]
36
+ #[allow(clippy::non_send_fields_in_send_ty)]
37
+ unsafe impl Send for RbWebView {}
38
+
39
+ // SPDX-SnippetEnd
40
+
41
+ impl RbWebView {
42
+ /// Create a new `WebView` attached to the given `Window`.
43
+ ///
44
+ /// The `ipc_callback` must be a `Ractor.shareable_proc`.
45
+ ///
46
+ /// # Errors
47
+ ///
48
+ /// Returns an error if `WebView` creation fails.
49
+ // Magnus FFI requires owned types at the Ruby-Rust boundary.
50
+ // See clippy_exceptions.rb for justification.
51
+ #[allow(clippy::needless_pass_by_value)]
52
+ pub fn new(
53
+ window: &RbWindow,
54
+ url: String,
55
+ _ipc_callback: Value,
56
+ proxy: &RbProxy,
57
+ ) -> Result<Self, Error> {
58
+ let proxy_clone = proxy.inner().clone();
59
+
60
+ let webview = WebViewBuilder::new()
61
+ .with_url(&url)
62
+ .with_ipc_handler(move |msg| {
63
+ let _ = proxy_clone.send_event(UserEvent::IpcMessage(msg.body().clone()));
64
+ })
65
+ .build(&window.inner)
66
+ .map_err(|e| Error::new(runtime_error(), e.to_string()))?;
67
+
68
+ Ok(Self { inner: webview })
69
+ }
70
+
71
+ // SPDX-SnippetBegin
72
+ // SPDX-License-Identifier: Apache-2.0 OR MIT
73
+ // SPDX-SnippetCopyrightText: 2020-2023 Tauri Programme within The Commons Conservancy
74
+ // SPDX-SnippetComment: Async custom protocol pattern adapted from tauri/app.rs register_asynchronous_uri_scheme_protocol
75
+ // Initial URL pattern adapted from tauri/manager/webview.rs WebviewUrl::App
76
+ // Page load handler pattern adapted from tauri/webview/mod.rs on_page_load
77
+
78
+ /// Create a new `WebView` with the `tokra://` custom protocol.
79
+ ///
80
+ /// This is the primary constructor for Rack-style apps. The `WebView` navigates
81
+ /// to `tokra://localhost/` and Ruby handles all requests via `HttpRequestEvent`.
82
+ /// Ruby controls everything: initial HTML, form submissions, API responses.
83
+ ///
84
+ /// This matches Tauri's pattern where apps load from `tauri://localhost/`.
85
+ ///
86
+ /// # Errors
87
+ ///
88
+ /// Returns an error if `WebView` creation fails.
89
+ ///
90
+ /// # Panics
91
+ ///
92
+ /// Panics if the responders mutex is poisoned (internal invariant violation).
93
+ pub fn new_with_protocol(window: &RbWindow, proxy: &RbProxy) -> Result<Self, Error> {
94
+ let proxy_clone = proxy.inner().clone();
95
+ let proxy_for_protocol = proxy.inner().clone();
96
+ let proxy_for_page_load = proxy.inner().clone();
97
+
98
+ let webview = WebViewBuilder::new()
99
+ .with_url("tokra://localhost/")
100
+ .with_asynchronous_custom_protocol(
101
+ "tokra".into(),
102
+ move |_webview_id, request, responder| {
103
+ // Generate unique request ID
104
+ let request_id = next_request_id();
105
+
106
+ // Extract request details
107
+ let method = request.method().to_string();
108
+ let uri = request.uri().to_string();
109
+
110
+ // SPDX-SnippetBegin
111
+ // SPDX-License-Identifier: Apache-2.0 OR MIT
112
+ // SPDX-SnippetCopyrightText: 2019-2024 Tauri Programme within The Commons Conservancy
113
+ // SPDX-SnippetComment: Header extraction pattern adapted from tauri/src/protocol/tauri.rs request.headers() iteration
114
+
115
+ // Extract headers for CSRF/Cookie support (ADR 002 requirement)
116
+ let headers: Vec<(String, String)> = request
117
+ .headers()
118
+ .iter()
119
+ .map(|(name, value)| {
120
+ (name.to_string(), value.to_str().unwrap_or("").to_string())
121
+ })
122
+ .collect();
123
+
124
+ // SPDX-SnippetEnd
125
+
126
+ let body = String::from_utf8_lossy(request.body()).to_string();
127
+
128
+ // Store the responder for later (Ruby will call back with the response)
129
+ get_responders()
130
+ .lock()
131
+ .unwrap()
132
+ .insert(request_id, responder);
133
+
134
+ // Send HTTP request through the event loop to Ruby
135
+ // Ruby will process and send back an HttpResponse event
136
+ let _ = proxy_for_protocol.send_event(UserEvent::HttpRequest {
137
+ request_id,
138
+ method,
139
+ uri,
140
+ headers,
141
+ body,
142
+ });
143
+ },
144
+ )
145
+ .with_on_page_load_handler(move |event, url| {
146
+ let event_name = match event {
147
+ PageLoadEvent::Started => "started",
148
+ PageLoadEvent::Finished => "finished",
149
+ };
150
+ let _ = proxy_for_page_load.send_event(UserEvent::PageLoad {
151
+ event: event_name.to_string(),
152
+ url,
153
+ });
154
+ })
155
+ // SPDX-SnippetEnd
156
+ .with_ipc_handler(move |msg| {
157
+ let _ = proxy_clone.send_event(UserEvent::IpcMessage(msg.body().clone()));
158
+ })
159
+ .build(&window.inner)
160
+ .map_err(|e| Error::new(runtime_error(), e.to_string()))?;
161
+
162
+ Ok(Self { inner: webview })
163
+ }
164
+
165
+ /// Evaluate JavaScript in the `WebView`.
166
+ ///
167
+ /// # Errors
168
+ ///
169
+ /// Returns an error if JavaScript evaluation fails.
170
+ // Magnus FFI requires owned types at the Ruby-Rust boundary.
171
+ // See clippy_exceptions.rb for justification.
172
+ #[allow(clippy::needless_pass_by_value)]
173
+ pub fn eval(&self, js: String) -> Result<(), Error> {
174
+ self.inner
175
+ .evaluate_script(&js)
176
+ .map_err(|e| Error::new(runtime_error(), e.to_string()))
177
+ }
178
+ }
179
+
180
+ /// Define the `WebView` class methods on the given Ruby module.
181
+ ///
182
+ /// # Errors
183
+ ///
184
+ /// Returns an error if class or method definition fails.
185
+ pub fn define_class(ruby: &Ruby, native: magnus::RModule) -> Result<(), Error> {
186
+ let webview_class = native.define_class("WebView", ruby.class_object())?;
187
+ webview_class.define_singleton_method("new", function!(RbWebView::new, 4))?;
188
+ webview_class.define_singleton_method(
189
+ "new_with_protocol",
190
+ function!(RbWebView::new_with_protocol, 2),
191
+ )?;
192
+ webview_class.define_method("eval", method!(RbWebView::eval, 1))?;
193
+ Ok(())
194
+ }
@@ -0,0 +1,92 @@
1
+ // SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ //! Window wrapper for tao.
5
+ //!
6
+ //! This module provides the Ruby-facing `Tokra::Native::Window` class,
7
+ //! which wraps `tao::window::Window`.
8
+
9
+ use magnus::{function, method, prelude::*, Error, Ruby};
10
+ use tao::window::{Window, WindowBuilder};
11
+
12
+ use crate::event_loop::RbEventLoop;
13
+ use crate::runtime_error;
14
+
15
+ /// Ruby wrapper for `tao::window::Window`.
16
+ #[magnus::wrap(class = "Tokra::Native::Window", free_immediately, size)]
17
+ pub struct RbWindow {
18
+ pub(crate) inner: Window,
19
+ }
20
+
21
+ // SPDX-SnippetBegin
22
+ // SPDX-License-Identifier: Apache-2.0 OR MIT
23
+ // SPDX-SnippetCopyrightText: 2020-2023 Tauri Programme within The Commons Conservancy
24
+ // SPDX-SnippetComment: Thread safety pattern adapted from tauri-runtime-wry/lib.rs unsafe impl Send for WindowsStore/WindowBuilderWrapper
25
+
26
+ // SAFETY: RbWindow is only accessed from the main thread. Window operations
27
+ // are only performed within the event loop callback on the main thread.
28
+ // This matches Tauri's pattern in tauri-runtime-wry.
29
+ // See clippy_exceptions.rb for full justification.
30
+ #[allow(unsafe_code)]
31
+ unsafe impl Send for RbWindow {}
32
+
33
+ // SPDX-SnippetEnd
34
+
35
+ impl RbWindow {
36
+ /// Create a new `Window` attached to the given `EventLoop`.
37
+ ///
38
+ /// # Errors
39
+ ///
40
+ /// Returns an error if the event loop has been consumed or window creation fails.
41
+ pub fn new(event_loop: &RbEventLoop) -> Result<Self, Error> {
42
+ let el = event_loop.inner.borrow();
43
+ let el_ref = el.as_ref().ok_or_else(|| {
44
+ Error::new(
45
+ runtime_error(),
46
+ "EventLoop has already been consumed by run()",
47
+ )
48
+ })?;
49
+
50
+ let window = WindowBuilder::new()
51
+ .with_title("Tokra")
52
+ .build(el_ref)
53
+ .map_err(|e| Error::new(runtime_error(), e.to_string()))?;
54
+
55
+ Ok(Self { inner: window })
56
+ }
57
+
58
+ /// Set the window title.
59
+ // Magnus FFI requires owned types at the Ruby-Rust boundary.
60
+ // See clippy_exceptions.rb for justification.
61
+ #[allow(clippy::needless_pass_by_value)]
62
+ pub fn set_title(&self, title: String) {
63
+ self.inner.set_title(&title);
64
+ }
65
+
66
+ /// Set the window inner size.
67
+ pub fn set_size(&self, width: f64, height: f64) {
68
+ use tao::dpi::LogicalSize;
69
+ let size = LogicalSize::new(width, height);
70
+ self.inner.set_inner_size(size);
71
+ }
72
+
73
+ /// Get the window ID as a string.
74
+ #[must_use]
75
+ pub fn id(&self) -> String {
76
+ format!("{:?}", self.inner.id())
77
+ }
78
+ }
79
+
80
+ /// Define the `Window` class methods on the given Ruby module.
81
+ ///
82
+ /// # Errors
83
+ ///
84
+ /// Returns an error if class or method definition fails.
85
+ pub fn define_class(ruby: &Ruby, native: magnus::RModule) -> Result<(), Error> {
86
+ let window_class = native.define_class("Window", ruby.class_object())?;
87
+ window_class.define_singleton_method("new", function!(RbWindow::new, 1))?;
88
+ window_class.define_method("set_title", method!(RbWindow::set_title, 1))?;
89
+ window_class.define_method("set_size", method!(RbWindow::set_size, 2))?;
90
+ window_class.define_method("id", method!(RbWindow::id, 0))?;
91
+ Ok(())
92
+ }
@@ -63,16 +63,14 @@ module Rack # :nodoc:
63
63
  #
64
64
  # Rack::Handler::Tokra.run(MyApp.freeze.app)
65
65
  #
66
- def run(app, **options)
67
- opts = DEFAULT_OPTIONS.merge(options)
68
-
66
+ def run(app, title: "Tokra", width: 800.0, height: 600.0, invoke_handler: nil)
69
67
  # Create Tokra components
70
68
  event_loop = ::Tokra::Native::EventLoop.new
71
69
  proxy = event_loop.create_proxy
72
70
 
73
71
  window = ::Tokra::Native::Window.new(event_loop)
74
- window.set_title(opts[:title])
75
- window.set_size(opts[:width].to_f, opts[:height].to_f)
72
+ window.set_title(title)
73
+ window.set_size(width.to_f, height.to_f)
76
74
 
77
75
  # Create the handler
78
76
  handler = new(app, proxy)
@@ -83,7 +81,13 @@ module Rack # :nodoc:
83
81
  # Run the event loop, dispatching HTTP requests to the Rack app
84
82
  event_loop.run(
85
83
  lambda { |event|
86
- handler.call(event) if event.is_a?(::Tokra::Native::HttpRequestEvent)
84
+ case event
85
+ when ::Tokra::Native::HttpRequestEvent
86
+ handler.call(event)
87
+ when ::Tokra::Native::IpcEvent
88
+ # If an invoke_handler is provided, call it with the message
89
+ invoke_handler&.call(event.message)
90
+ end
87
91
  }
88
92
  )
89
93
  end
@@ -110,7 +114,7 @@ module Rack # :nodoc:
110
114
  # 4. Sends the final response back through the protocol
111
115
  def call(event)
112
116
  uri = URI.parse(event.uri)
113
- env = build_env(uri, event.method, event.body)
117
+ env = build_env(uri, event.method, event.body, event)
114
118
 
115
119
  # Follow redirects internally (WKWebView doesn't follow for custom protocols)
116
120
  redirect_count = 0
@@ -133,15 +137,27 @@ module Rack # :nodoc:
133
137
 
134
138
  # Follow the redirect with GET (PRG pattern)
135
139
  new_uri = URI.parse(headers["location"])
136
- env = build_env(new_uri, "GET", "", cookies)
140
+ env = build_env(new_uri, "GET", "", nil, cookies)
137
141
  next
138
142
  end
139
143
 
140
144
  # Send the response
141
- header_pairs = headers.map { |k, v| [k.to_s, v.to_s] }
142
-
143
- body_str = String.new
144
- body.each { |chunk| body_str << chunk }
145
+ header_pairs = headers.map { |k, v| [k.to_s, v.to_s] } #: Array[[String, String]]
146
+
147
+ # Prefer to_ary over each - Rails RackBody#each triggers synchronization
148
+ # (sending!/sent!) that can cause rendering issues in single-threaded contexts.
149
+ # to_ary bypasses this and returns the raw body array directly.
150
+ body_str = if body.respond_to?(:to_ary)
151
+ body.to_ary.join
152
+ elsif body.respond_to?(:to_path)
153
+ # Rack::Files::Iterator provides to_path for sendfile optimization
154
+ # Read the file directly instead of iterating
155
+ File.read(body.to_path)
156
+ else
157
+ buf = String.new
158
+ body.each { |chunk| buf << chunk }
159
+ buf
160
+ end
145
161
  body.close if body.respond_to?(:close)
146
162
 
147
163
  @proxy.respond(event.request_id, status, header_pairs, body_str)
@@ -149,11 +165,11 @@ module Rack # :nodoc:
149
165
  end
150
166
  end
151
167
 
152
- private def build_env(uri, method, body, cookies = nil)
168
+ private def build_env(uri, method, body, event = nil, cookies = nil)
153
169
  env = {
154
170
  "REQUEST_METHOD" => method,
155
171
  "SCRIPT_NAME" => "",
156
- "PATH_INFO" => uri.path.empty? ? "/" : uri.path,
172
+ "PATH_INFO" => (uri.path.nil? || uri.path.empty?) ? "/" : uri.path,
157
173
  "QUERY_STRING" => uri.query || "",
158
174
  "SERVER_NAME" => uri.host || "localhost",
159
175
  "SERVER_PORT" => (uri.port || 80).to_s,
@@ -165,6 +181,13 @@ module Rack # :nodoc:
165
181
  "rack.multiprocess" => false,
166
182
  "rack.run_once" => false,
167
183
  }
184
+
185
+ # Forward only the Cookie header from WebView
186
+ # Other headers (Accept-Encoding, etc.) caused rendering issues in WKWebView
187
+ event&.headers&.each do |(name, value)|
188
+ env["HTTP_COOKIE"] = value if name.downcase == "cookie"
189
+ end
190
+
168
191
  env["HTTP_COOKIE"] = cookies if cookies
169
192
  env
170
193
  end
data/lib/tokra/version.rb CHANGED
@@ -8,5 +8,5 @@
8
8
 
9
9
  module Tokra
10
10
  # TODO: Document me.
11
- VERSION = "0.0.1.pre.1"
11
+ VERSION = "0.0.1.pre.2"
12
12
  end
data/rbs_exceptions.rb ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # Approved RBS 'untyped' exceptions.
9
+ # Each entry must explain WHY the exception is unavoidable.
10
+ # Review carefully before adding new entries.
11
+
12
+ # Currently no exceptions - all types should be explicit.
data/sig/tokra.rbs CHANGED
@@ -3,5 +3,99 @@
3
3
 
4
4
  module Tokra
5
5
  VERSION: String
6
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
6
+ class Error < StandardError
7
+ end
8
+
9
+ # Native extension classes (implemented in Rust via Magnus)
10
+ module Native
11
+ class EventLoop
12
+ def initialize: () -> void
13
+ def create_proxy: () -> Proxy
14
+ def run: (^(HttpRequestEvent | IpcEvent | WakeUpEvent | WindowCloseEvent | PageLoadEvent) -> void) -> void
15
+ end
16
+
17
+ class Window
18
+ def initialize: (EventLoop) -> void
19
+ def set_title: (String) -> void
20
+ def set_size: (Float, Float) -> void
21
+ end
22
+
23
+ class WebView
24
+ def self.new_with_protocol: (Window, Proxy) -> WebView
25
+ def initialize: (Window, String, ^(String) -> void, Proxy) -> void
26
+ def eval: (String) -> void
27
+ end
28
+
29
+ class Proxy
30
+ def respond: (Integer, Integer, Array[[String, String]], String) -> void
31
+ def send_http_response: (Integer, Integer, Hash[String, String], String) -> void
32
+ end
33
+
34
+ class HttpRequestEvent
35
+ def request_id: () -> Integer
36
+ def method: () -> String
37
+ def uri: () -> String
38
+ def headers: () -> Array[[String, String]]
39
+ def body: () -> String
40
+ end
41
+
42
+ class IpcEvent
43
+ def message: () -> String
44
+ end
45
+
46
+ class WakeUpEvent
47
+ end
48
+
49
+ class WindowCloseEvent
50
+ end
51
+
52
+ class PageLoadEvent
53
+ def event: () -> String
54
+ def url: () -> String
55
+ end
56
+ end
57
+ end
58
+
59
+ # Rack types based on Rack 3.x spec
60
+ module Rack
61
+ type env = Hash[String, (String | Integer | Array[Integer] | IO | bool | nil | StringIO)]
62
+ type headers = Hash[String, String]
63
+ type status = Integer
64
+
65
+ # Rack body - any object that responds to each yielding strings
66
+ # We use Object as base to get respond_to? and other Object methods
67
+ class Body < Object
68
+ def each: () { (String) -> void } -> void
69
+ def close: () -> void
70
+ # Optional: Array-style bodies
71
+ def to_ary: () -> Array[String]
72
+ # Optional: File path for sendfile optimization
73
+ def to_path: () -> String
74
+ end
75
+
76
+ type response = [status, headers, Body]
77
+
78
+ interface _App
79
+ def call: (env) -> response
80
+ end
81
+
82
+ module Handler
83
+ class Tokra
84
+ MAX_REDIRECTS: Integer
85
+ DEFAULT_OPTIONS: { title: String, width: Float, height: Float }
86
+
87
+ @app: Rack::_App
88
+ @proxy: ::Tokra::Native::Proxy
89
+
90
+ def self.run: (Rack::_App app, ?title: String, ?width: Float, ?height: Float, ?invoke_handler: (^(String) -> void)?) -> void
91
+
92
+ def initialize: (Rack::_App app, ::Tokra::Native::Proxy proxy) -> void
93
+ def call: (::Tokra::Native::HttpRequestEvent event) -> void
94
+
95
+ private
96
+
97
+ def build_env: (URI::Generic uri, String method, String body, ?::Tokra::Native::HttpRequestEvent? event, ?String? cookies) -> Rack::env
98
+ def respond_with_error: (Integer request_id, Integer status, String message) -> void
99
+ end
100
+ end
7
101
  end
data/tasks/lint.rake CHANGED
@@ -122,10 +122,10 @@ namespace :lint do
122
122
  sh "bundle exec rake rdoc:coverage"
123
123
  end
124
124
 
125
- task docs: %w[rdoc:coverage rubycritic reuse:lint]
125
+ task docs: %w[safe_rdoc_coverage rubycritic reuse:lint]
126
126
  task code: %w[rubocop rubycritic lint:rust]
127
127
  task licenses: %w[reuse:lint]
128
- task all: %w[docs code licenses]
128
+ task all: %w[docs code licenses rbs]
129
129
 
130
130
  namespace :fix do
131
131
  desc "Auto-fix RuboCop offenses (most aggressive)"
data/tasks/lint.rb ADDED
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: AGPL-3.0-or-later
6
+ #++
7
+
8
+ # Declared lint exceptions with justifications.
9
+ class Lint
10
+ def self.load(path) = new(path)
11
+
12
+ def initialize(path)
13
+ @path = path
14
+ instance_eval(File.read(path))
15
+ end
16
+
17
+ def allow(path, line: nil, lines: nil, lint: nil, reason:)
18
+ line_numbers = lines || [line]
19
+ line_numbers.each { |n| list << "#{path}:#{n}" }
20
+ end
21
+
22
+ # Enforce no violations. Aborts with message if any found, otherwise prints success.
23
+ def enforce!(glob:, pattern:, reject_pattern: nil, violation_name:)
24
+ found = violations(glob:, pattern:, reject_pattern:)
25
+
26
+ abort <<~ERROR if found.any?
27
+ ❌ Unapproved #{violation_name} found:
28
+ #{found.join("\n")}
29
+
30
+ Add to #{File.basename(@path)} with justification if truly unavoidable.
31
+ ERROR
32
+
33
+ puts "✅ No unapproved #{violation_name} found"
34
+ end
35
+
36
+ def violations(glob:, pattern:, reject_pattern: nil)
37
+ Dir.glob(glob)
38
+ .reject { |f| f.include?("/target/") }
39
+ .flat_map { |file| File.readlines(file).map.with_index(1) { |line, n| [file, n, line] } }
40
+ .select { |_, _, line| pattern.match?(line) }
41
+ .reject { |_, _, line| reject_pattern&.match?(line) }
42
+ .reject { |file, n, _| include?("#{file}:#{n}") }
43
+ .map { |file, n, line| " #{file}:#{n}: #{line.strip}" }
44
+ end
45
+
46
+ def include?(location) = list.any? { |ex| location.start_with?(ex) }
47
+ def to_a = list
48
+ def list = @list ||= []
49
+ end
data/tasks/rust.rake CHANGED
@@ -5,12 +5,7 @@
5
5
  # SPDX-License-Identifier: AGPL-3.0-or-later
6
6
  #++
7
7
 
8
- class ClippyExceptions
9
- def self.load(path) = new.tap { |e| e.instance_eval(File.read(path)) }
10
- def allow(path, line:, reason:) = list << "#{path}:#{line}"
11
- def to_a = list
12
- def list = @list ||= []
13
- end
8
+ require_relative "lint"
14
9
 
15
10
  namespace :rust do
16
11
  desc "Check Rust formatting with cargo fmt"
@@ -37,23 +32,11 @@ namespace :rust do
37
32
 
38
33
  desc "Forbid #[allow(clippy::...)] except approved exceptions"
39
34
  task :no_allows do
40
- exceptions = ClippyExceptions.load(File.expand_path("../clippy_exceptions.rb", __dir__))
41
-
42
- violations = Dir.glob("ext/**/*.rs")
43
- .reject { |f| f.include?("/target/") }
44
- .flat_map { |file| File.readlines(file).map.with_index(1) { |line, n| [file, n, line] } }
45
- .select { |_, _, line| line.include?("#[allow(clippy::") }
46
- .reject { |file, n, _| exceptions.to_a.any? { |ex| "#{file}:#{n}".start_with?(ex) } }
47
- .map { |file, n, line| " #{file}:#{n}: #{line.strip}" }
48
-
49
- abort <<~ERROR if violations.any?
50
- ❌ Unapproved #[allow(clippy::...)] found:
51
- #{violations.join("\n")}
52
-
53
- Add to clippy_exceptions.rb with justification if truly unavoidable.
54
- ERROR
55
-
56
- puts "✅ No unapproved Clippy allows found"
35
+ Lint.load(File.expand_path("../clippy_exceptions.rb", __dir__)).enforce!(
36
+ glob: "ext/**/*.rs",
37
+ pattern: /#\[allow\(clippy::/,
38
+ violation_name: "#[allow(clippy::...)]"
39
+ )
57
40
  end
58
41
  end
59
42
 
data/tasks/steep.rake CHANGED
@@ -5,7 +5,29 @@
5
5
  # SPDX-License-Identifier: AGPL-3.0-or-later
6
6
  #++
7
7
 
8
- desc "Run Steep type checker"
9
- task :steep do
10
- sh "bundle exec steep check"
8
+ require_relative "lint"
9
+
10
+ namespace :steep do
11
+ desc "Run Steep type checker"
12
+ task :check do
13
+ sh "bundle exec steep check"
14
+ end
15
+
16
+ desc "Forbid 'untyped' in RBS files except approved exceptions"
17
+ task :no_untyped do
18
+ Lint.load(File.expand_path("../rbs_exceptions.rb", __dir__)).enforce!(
19
+ glob: "sig/**/*.rbs",
20
+ pattern: /\buntyped\b/,
21
+ reject_pattern: /^\s*#/,
22
+ violation_name: "'untyped' in RBS files"
23
+ )
24
+ end
25
+ end
26
+
27
+ desc "Run Steep type checker (with no_untyped check)"
28
+ task steep: %w[steep:check steep:no_untyped]
29
+
30
+ namespace :lint do
31
+ desc "Run RBS linting (no unapproved untyped)"
32
+ task rbs: "steep:no_untyped"
11
33
  end
data/tasks/test.rake CHANGED
@@ -21,6 +21,6 @@ namespace :test do
21
21
 
22
22
  # Create a specific Minitest task for Ruby tests
23
23
  Minitest::TestTask.create(:ruby) do |t|
24
- t.test_globs = ["test/**/*.rb", "examples/**/test_*.rb"]
24
+ t.test_globs = ["test/**/*.rb"]
25
25
  end
26
26
  end