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.
- checksums.yaml +4 -4
- data/LICENSES/MIT-0.txt +16 -0
- data/REUSE.toml +6 -1
- data/Rakefile +2 -0
- data/Steepfile +2 -0
- data/clippy_exceptions.rb +75 -37
- data/doc/contributors/adr/004.md +63 -0
- data/doc/contributors/chats/002.md +177 -0
- data/doc/contributors/chats/003.md +2180 -0
- data/doc/contributors/chats/004.md +1992 -0
- data/doc/contributors/chats/005.md +1529 -0
- data/doc/contributors/plan/002.md +173 -0
- data/doc/contributors/plan/003.md +111 -0
- data/examples/verify_hello_world/index.html +15 -2
- data/examples/verify_ping_pong/app.rb +3 -1
- data/examples/verify_ping_pong/public/styles.css +36 -9
- data/examples/verify_ping_pong/views/layout.erb +1 -1
- data/examples/verify_rails_sqlite/.dockerignore +51 -0
- data/examples/verify_rails_sqlite/.gitattributes +9 -0
- data/examples/verify_rails_sqlite/.github/dependabot.yml +12 -0
- data/examples/verify_rails_sqlite/.github/workflows/ci.yml +124 -0
- data/examples/verify_rails_sqlite/.gitignore +35 -0
- data/examples/verify_rails_sqlite/.kamal/hooks/docker-setup.sample +3 -0
- data/examples/verify_rails_sqlite/.kamal/hooks/post-app-boot.sample +3 -0
- data/examples/verify_rails_sqlite/.kamal/hooks/post-deploy.sample +14 -0
- data/examples/verify_rails_sqlite/.kamal/hooks/post-proxy-reboot.sample +3 -0
- data/examples/verify_rails_sqlite/.kamal/hooks/pre-app-boot.sample +3 -0
- data/examples/verify_rails_sqlite/.kamal/hooks/pre-build.sample +51 -0
- data/examples/verify_rails_sqlite/.kamal/hooks/pre-connect.sample +47 -0
- data/examples/verify_rails_sqlite/.kamal/hooks/pre-deploy.sample +122 -0
- data/examples/verify_rails_sqlite/.kamal/hooks/pre-proxy-reboot.sample +3 -0
- data/examples/verify_rails_sqlite/.kamal/secrets +20 -0
- data/examples/verify_rails_sqlite/.rubocop.yml +2 -0
- data/examples/verify_rails_sqlite/.ruby-version +1 -0
- data/examples/verify_rails_sqlite/Dockerfile +77 -0
- data/examples/verify_rails_sqlite/Gemfile +66 -0
- data/examples/verify_rails_sqlite/Gemfile.lock +563 -0
- data/examples/verify_rails_sqlite/README.md +41 -0
- data/examples/verify_rails_sqlite/Rakefile +6 -0
- data/examples/verify_rails_sqlite/app/assets/images/.keep +0 -0
- data/examples/verify_rails_sqlite/app/assets/stylesheets/application.css +469 -0
- data/examples/verify_rails_sqlite/app/controllers/application_controller.rb +12 -0
- data/examples/verify_rails_sqlite/app/controllers/concerns/.keep +0 -0
- data/examples/verify_rails_sqlite/app/controllers/todos_controller.rb +70 -0
- data/examples/verify_rails_sqlite/app/helpers/application_helper.rb +2 -0
- data/examples/verify_rails_sqlite/app/helpers/todos_helper.rb +2 -0
- data/examples/verify_rails_sqlite/app/javascript/application.js +3 -0
- data/examples/verify_rails_sqlite/app/javascript/controllers/application.js +9 -0
- data/examples/verify_rails_sqlite/app/javascript/controllers/hello_controller.js +7 -0
- data/examples/verify_rails_sqlite/app/javascript/controllers/index.js +4 -0
- data/examples/verify_rails_sqlite/app/jobs/application_job.rb +7 -0
- data/examples/verify_rails_sqlite/app/mailers/application_mailer.rb +4 -0
- data/examples/verify_rails_sqlite/app/models/application_record.rb +3 -0
- data/examples/verify_rails_sqlite/app/models/concerns/.keep +0 -0
- data/examples/verify_rails_sqlite/app/models/todo.rb +10 -0
- data/examples/verify_rails_sqlite/app/views/layouts/application.html.erb +24 -0
- data/examples/verify_rails_sqlite/app/views/layouts/mailer.html.erb +13 -0
- data/examples/verify_rails_sqlite/app/views/layouts/mailer.text.erb +1 -0
- data/examples/verify_rails_sqlite/app/views/pwa/manifest.json.erb +22 -0
- data/examples/verify_rails_sqlite/app/views/pwa/service-worker.js +26 -0
- data/examples/verify_rails_sqlite/app/views/todos/_form.html.erb +27 -0
- data/examples/verify_rails_sqlite/app/views/todos/_todo.html.erb +18 -0
- data/examples/verify_rails_sqlite/app/views/todos/_todo.json.jbuilder +2 -0
- data/examples/verify_rails_sqlite/app/views/todos/edit.html.erb +7 -0
- data/examples/verify_rails_sqlite/app/views/todos/index.html.erb +22 -0
- data/examples/verify_rails_sqlite/app/views/todos/index.json.jbuilder +1 -0
- data/examples/verify_rails_sqlite/app/views/todos/new.html.erb +7 -0
- data/examples/verify_rails_sqlite/app/views/todos/show.html.erb +23 -0
- data/examples/verify_rails_sqlite/app/views/todos/show.json.jbuilder +1 -0
- data/examples/verify_rails_sqlite/bin/brakeman +7 -0
- data/examples/verify_rails_sqlite/bin/bundler-audit +6 -0
- data/examples/verify_rails_sqlite/bin/ci +6 -0
- data/examples/verify_rails_sqlite/bin/dev +2 -0
- data/examples/verify_rails_sqlite/bin/docker-entrypoint +8 -0
- data/examples/verify_rails_sqlite/bin/importmap +4 -0
- data/examples/verify_rails_sqlite/bin/jobs +6 -0
- data/examples/verify_rails_sqlite/bin/kamal +16 -0
- data/examples/verify_rails_sqlite/bin/rails +4 -0
- data/examples/verify_rails_sqlite/bin/rake +4 -0
- data/examples/verify_rails_sqlite/bin/rubocop +8 -0
- data/examples/verify_rails_sqlite/bin/setup +35 -0
- data/examples/verify_rails_sqlite/bin/thrust +5 -0
- data/examples/verify_rails_sqlite/config/application.rb +27 -0
- data/examples/verify_rails_sqlite/config/boot.rb +4 -0
- data/examples/verify_rails_sqlite/config/bundler-audit.yml +5 -0
- data/examples/verify_rails_sqlite/config/cable.yml +17 -0
- data/examples/verify_rails_sqlite/config/cache.yml +16 -0
- data/examples/verify_rails_sqlite/config/ci.rb +24 -0
- data/examples/verify_rails_sqlite/config/credentials.yml.enc +1 -0
- data/examples/verify_rails_sqlite/config/database.yml +40 -0
- data/examples/verify_rails_sqlite/config/deploy.yml +119 -0
- data/examples/verify_rails_sqlite/config/environment.rb +5 -0
- data/examples/verify_rails_sqlite/config/environments/development.rb +84 -0
- data/examples/verify_rails_sqlite/config/environments/production.rb +99 -0
- data/examples/verify_rails_sqlite/config/environments/test.rb +53 -0
- data/examples/verify_rails_sqlite/config/importmap.rb +7 -0
- data/examples/verify_rails_sqlite/config/initializers/assets.rb +7 -0
- data/examples/verify_rails_sqlite/config/initializers/content_security_policy.rb +29 -0
- data/examples/verify_rails_sqlite/config/initializers/filter_parameter_logging.rb +8 -0
- data/examples/verify_rails_sqlite/config/initializers/inflections.rb +16 -0
- data/examples/verify_rails_sqlite/config/locales/en.yml +31 -0
- data/examples/verify_rails_sqlite/config/puma.rb +42 -0
- data/examples/verify_rails_sqlite/config/queue.yml +18 -0
- data/examples/verify_rails_sqlite/config/recurring.yml +15 -0
- data/examples/verify_rails_sqlite/config/routes.rb +15 -0
- data/examples/verify_rails_sqlite/config/storage.yml +27 -0
- data/examples/verify_rails_sqlite/config.ru +6 -0
- data/examples/verify_rails_sqlite/db/cable_schema.rb +11 -0
- data/examples/verify_rails_sqlite/db/cache_schema.rb +12 -0
- data/examples/verify_rails_sqlite/db/migrate/20260130080933_create_todos.rb +10 -0
- data/examples/verify_rails_sqlite/db/queue_schema.rb +129 -0
- data/examples/verify_rails_sqlite/db/schema.rb +20 -0
- data/examples/verify_rails_sqlite/db/seeds.rb +9 -0
- data/examples/verify_rails_sqlite/lib/tasks/.keep +0 -0
- data/examples/verify_rails_sqlite/log/.keep +0 -0
- data/examples/verify_rails_sqlite/public/400.html +135 -0
- data/examples/verify_rails_sqlite/public/404.html +135 -0
- data/examples/verify_rails_sqlite/public/406-unsupported-browser.html +135 -0
- data/examples/verify_rails_sqlite/public/422.html +135 -0
- data/examples/verify_rails_sqlite/public/500.html +135 -0
- data/examples/verify_rails_sqlite/public/icon.png +0 -0
- data/examples/verify_rails_sqlite/public/icon.svg +3 -0
- data/examples/verify_rails_sqlite/public/robots.txt +1 -0
- data/examples/verify_rails_sqlite/public/styles.css +469 -0
- data/examples/verify_rails_sqlite/script/.keep +0 -0
- data/examples/verify_rails_sqlite/storage/.keep +0 -0
- data/examples/verify_rails_sqlite/test/controllers/.keep +0 -0
- data/examples/verify_rails_sqlite/test/controllers/todos_controller_test.rb +48 -0
- data/examples/verify_rails_sqlite/test/fixtures/files/.keep +0 -0
- data/examples/verify_rails_sqlite/test/fixtures/todos.yml +9 -0
- data/examples/verify_rails_sqlite/test/helpers/.keep +0 -0
- data/examples/verify_rails_sqlite/test/integration/.keep +0 -0
- data/examples/verify_rails_sqlite/test/mailers/.keep +0 -0
- data/examples/verify_rails_sqlite/test/models/.keep +0 -0
- data/examples/verify_rails_sqlite/test/models/todo_test.rb +7 -0
- data/examples/verify_rails_sqlite/test/test_helper.rb +15 -0
- data/examples/verify_rails_sqlite/tmp/.keep +0 -0
- data/examples/verify_rails_sqlite/tmp/pids/.keep +0 -0
- data/examples/verify_rails_sqlite/tmp/storage/.keep +0 -0
- data/examples/verify_rails_sqlite/tokra.rb +42 -0
- data/examples/verify_rails_sqlite/vendor/.keep +0 -0
- data/examples/verify_rails_sqlite/vendor/javascript/.keep +0 -0
- data/ext/tokra/src/event_loop.rs +206 -0
- data/ext/tokra/src/events.rs +430 -0
- data/ext/tokra/src/lib.rs +52 -664
- data/ext/tokra/src/proxy.rs +142 -0
- data/ext/tokra/src/responders.rs +86 -0
- data/ext/tokra/src/user_event.rs +313 -0
- data/ext/tokra/src/webview.rs +194 -0
- data/ext/tokra/src/window.rs +92 -0
- data/lib/tokra/rack/handler.rb +37 -14
- data/lib/tokra/version.rb +1 -1
- data/rbs_exceptions.rb +12 -0
- data/sig/tokra.rbs +95 -1
- data/tasks/lint.rake +2 -2
- data/tasks/lint.rb +49 -0
- data/tasks/rust.rake +6 -23
- data/tasks/steep.rake +25 -3
- data/tasks/test.rake +1 -1
- 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
|
+
}
|
data/lib/tokra/rack/handler.rb
CHANGED
|
@@ -63,16 +63,14 @@ module Rack # :nodoc:
|
|
|
63
63
|
#
|
|
64
64
|
# Rack::Handler::Tokra.run(MyApp.freeze.app)
|
|
65
65
|
#
|
|
66
|
-
def run(app,
|
|
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(
|
|
75
|
-
window.set_size(
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
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
|
-
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|