roxane 0.0.1-x64-mingw-ucrt
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +79 -0
- data/assets/bridge.js +34 -0
- data/lib/roxane/ffi.rb +51 -0
- data/lib/roxane/static_server.rb +102 -0
- data/lib/roxane/version.rb +5 -0
- data/lib/roxane/window.rb +161 -0
- data/lib/roxane.rb +40 -0
- data/vendor/x64-mingw-ucrt/libwebview.dll +0 -0
- metadata +70 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e2da1266447e9382b95782d156ac447cfe3370b324132ae707f36247f4e88ca5
|
|
4
|
+
data.tar.gz: 0d359d442a7cb58dffffafa269bd2a45d3ed1b430a38dca96a88443d29ad537e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c41309310016de49bf210438955f330274fe0bda6a12cd819bd163d402b2069226ba46280593285ae7e579e0eec60ce5e8072cb0c037b60a280dcdcca4bed70b
|
|
7
|
+
data.tar.gz: 2f9eb6dc4c63eb87170f5baa5ce5ee8f089831777696726350fd925bdbb4853c621975049dd53ff4702f11e35ba222117d273b40f198b8bd4a72126771c2e3f2
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BKK Riese
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Roxane
|
|
2
|
+
|
|
3
|
+
A pure-Ruby desktop shell: a native window over the operating system's webview
|
|
4
|
+
(WebView2 / WKWebView / WebKitGTK, via the [webview](https://github.com/webview/webview)
|
|
5
|
+
C library), with a Ruby⇄JS **operation bridge**. A lightweight, Tauri-shaped way
|
|
6
|
+
to ship a web frontend as a desktop app — in Ruby.
|
|
7
|
+
|
|
8
|
+
Named for Cyrano's Roxane.
|
|
9
|
+
|
|
10
|
+
## What it is (and isn't)
|
|
11
|
+
|
|
12
|
+
Roxane is the **host and data plane** — a window, the system webview, an
|
|
13
|
+
`invoke`/`emit` bridge, and a loopback asset server. It serves whatever frontend
|
|
14
|
+
you give it and is **agnostic to look-and-feel** (no CSS, no widgets, no layout
|
|
15
|
+
opinions). The same frontend can run in the desktop shell and in a hosted browser.
|
|
16
|
+
|
|
17
|
+
## Example
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
require "roxane"
|
|
21
|
+
|
|
22
|
+
win = Roxane::Window.new(title: "Hello", size: [900, 600])
|
|
23
|
+
|
|
24
|
+
# JS → Ruby: await window.roxane.invoke("greet", "world")
|
|
25
|
+
win.on("greet") { |name| "Hello, #{name}!" }
|
|
26
|
+
|
|
27
|
+
# Ruby → JS: window.roxane.on("tick", ({ at }) => ...)
|
|
28
|
+
win.emit("tick", { at: Time.now.to_i })
|
|
29
|
+
|
|
30
|
+
win.serve(File.expand_path("ui")) # serve a built frontend bundle, or:
|
|
31
|
+
# win.load("http://localhost:5173") # point at a dev server, or:
|
|
32
|
+
# win.html("<!doctype html>…") # inline HTML
|
|
33
|
+
|
|
34
|
+
win.run
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Invocation handlers run **off the UI thread**, and `emit`/`eval` are safe from any
|
|
38
|
+
thread — so long-running work (e.g. an AI call) never freezes the window.
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
The `webview` C library (`libwebview`) must be available at runtime. The
|
|
43
|
+
precompiled platform gem (e.g. `x86_64-linux`) **vendors it**, so you only need
|
|
44
|
+
the system webview runtime (WebKitGTK on Linux). Otherwise set `ROXANE_LIBWEBVIEW`
|
|
45
|
+
to a `libwebview` path, or have it on the loader path.
|
|
46
|
+
|
|
47
|
+
## Packaging (precompiled platform gems)
|
|
48
|
+
|
|
49
|
+
Roxane ships a prebuilt `libwebview` per platform (the nokogiri model) so users
|
|
50
|
+
need no toolchain. For x86_64-linux:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
rake vendor:linux # build libwebview into vendor/x86_64-linux/
|
|
54
|
+
rake gem:linux # build roxane-<ver>-x86_64-linux.gem (vendoring the lib)
|
|
55
|
+
rake verify:linux # install that gem in a clean container and smoke-test it
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
macOS / Windows / aarch64 cross-builds are next.
|
|
59
|
+
|
|
60
|
+
## Testing
|
|
61
|
+
|
|
62
|
+
```sh
|
|
63
|
+
./run.sh # Docker: Ruby + libwebview + headless Xvfb → full suite
|
|
64
|
+
rake test # host: unit tests only (the asset server); the windowed
|
|
65
|
+
# smoke test self-skips unless ROXANE_INTEGRATION=1
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Status
|
|
69
|
+
|
|
70
|
+
Early but working. The runtime (window + system webview + invoke/emit bridge +
|
|
71
|
+
loopback asset server) is green headlessly, and **precompiled x86_64-linux
|
|
72
|
+
packaging works** — `rake verify:linux` builds the platform gem and proves it
|
|
73
|
+
installs and runs on a clean machine (system WebKitGTK only, no toolchain). Next:
|
|
74
|
+
macOS / Windows / aarch64 cross-builds + RubyGems publish, then richer window
|
|
75
|
+
options (menus, frameless).
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
data/assets/bridge.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Roxane bridge — injected before page scripts. Defines window.roxane, the
|
|
2
|
+
// symmetric counterpart to the Ruby Window API:
|
|
3
|
+
// window.roxane.invoke(op, payload) -> Promise (Ruby `win.on(op){}`)
|
|
4
|
+
// window.roxane.on(event, cb) / off(event, cb) (Ruby `win.emit(event, payload)`)
|
|
5
|
+
(function () {
|
|
6
|
+
if (window.roxane) return;
|
|
7
|
+
var handlers = {}; // event name -> array of callbacks
|
|
8
|
+
|
|
9
|
+
window.roxane = {
|
|
10
|
+
// Call a Ruby operation handler; resolves with its return value (rejects on error).
|
|
11
|
+
invoke: function (op, payload) {
|
|
12
|
+
return window.__roxane_dispatch(op, payload === undefined ? null : payload);
|
|
13
|
+
},
|
|
14
|
+
// Subscribe to an event emitted from Ruby; returns an unsubscribe function.
|
|
15
|
+
on: function (event, cb) {
|
|
16
|
+
(handlers[event] || (handlers[event] = [])).push(cb);
|
|
17
|
+
return function () { window.roxane.off(event, cb); };
|
|
18
|
+
},
|
|
19
|
+
off: function (event, cb) {
|
|
20
|
+
var list = handlers[event];
|
|
21
|
+
if (!list) return;
|
|
22
|
+
var i = list.indexOf(cb);
|
|
23
|
+
if (i !== -1) list.splice(i, 1);
|
|
24
|
+
},
|
|
25
|
+
// Called by Ruby (via eval) to deliver an emitted event.
|
|
26
|
+
__receive: function (event, payload) {
|
|
27
|
+
var list = handlers[event];
|
|
28
|
+
if (!list) return;
|
|
29
|
+
list.slice().forEach(function (cb) {
|
|
30
|
+
try { cb(payload); } catch (e) { console.error("roxane handler for '" + event + "':", e); }
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
})();
|
data/lib/roxane/ffi.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ffi"
|
|
4
|
+
|
|
5
|
+
module Roxane
|
|
6
|
+
# Thin FFI binding to the maintained webview/webview C library. Resilient at load
|
|
7
|
+
# time: if libwebview can't be found (e.g. on a host without it yet), the gem
|
|
8
|
+
# still loads — `AVAILABLE` is false and `Window.new` raises a clear error —
|
|
9
|
+
# so pure-Ruby parts (the asset server) remain usable and testable.
|
|
10
|
+
module FFI
|
|
11
|
+
extend ::FFI::Library
|
|
12
|
+
|
|
13
|
+
HINT_NONE = 0
|
|
14
|
+
HINT_MIN = 1
|
|
15
|
+
HINT_MAX = 2
|
|
16
|
+
HINT_FIXED = 3
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
ffi_lib Roxane.library_path
|
|
20
|
+
|
|
21
|
+
# webview_t is an opaque pointer; setters return webview_error_t (int).
|
|
22
|
+
attach_function :webview_create, [:int, :pointer], :pointer
|
|
23
|
+
attach_function :webview_destroy, [:pointer], :int
|
|
24
|
+
# blocking: true releases the GIL while the UI loop runs, so Ruby worker
|
|
25
|
+
# threads (engine/AI work) keep running alongside the webview.
|
|
26
|
+
attach_function :webview_run, [:pointer], :int, blocking: true
|
|
27
|
+
attach_function :webview_terminate, [:pointer], :int
|
|
28
|
+
attach_function :webview_set_title, [:pointer, :string], :int
|
|
29
|
+
attach_function :webview_set_size, [:pointer, :int, :int, :int], :int
|
|
30
|
+
attach_function :webview_navigate, [:pointer, :string], :int
|
|
31
|
+
attach_function :webview_set_html, [:pointer, :string], :int
|
|
32
|
+
attach_function :webview_init, [:pointer, :string], :int
|
|
33
|
+
attach_function :webview_eval, [:pointer, :string], :int
|
|
34
|
+
|
|
35
|
+
callback :bind_fn, [:string, :string, :pointer], :void
|
|
36
|
+
attach_function :webview_bind, [:pointer, :string, :bind_fn, :pointer], :int
|
|
37
|
+
attach_function :webview_return, [:pointer, :string, :int, :string], :int
|
|
38
|
+
|
|
39
|
+
# dispatch runs a function on the UI thread — the safe way to call webview
|
|
40
|
+
# from worker threads (eval / return).
|
|
41
|
+
callback :dispatch_fn, [:pointer, :pointer], :void
|
|
42
|
+
attach_function :webview_dispatch, [:pointer, :dispatch_fn, :pointer], :int
|
|
43
|
+
|
|
44
|
+
AVAILABLE = true
|
|
45
|
+
LOAD_ERROR = nil
|
|
46
|
+
rescue LoadError, ::FFI::NotFoundError => e
|
|
47
|
+
AVAILABLE = false
|
|
48
|
+
LOAD_ERROR = e
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module Roxane
|
|
7
|
+
# Tiny loopback-only static file server — dependency-free (stdlib sockets). It
|
|
8
|
+
# serves a built frontend bundle to the embedded webview over a real http://
|
|
9
|
+
# origin, so ES modules / fetch / SPA routing behave (which file:// breaks), and
|
|
10
|
+
# the desktop path mirrors the hosted-web one (same frontend, local origin).
|
|
11
|
+
# GET-only, bound to 127.0.0.1, path-traversal-safe.
|
|
12
|
+
class StaticServer
|
|
13
|
+
MIME = {
|
|
14
|
+
".html" => "text/html", ".htm" => "text/html",
|
|
15
|
+
".js" => "text/javascript", ".mjs" => "text/javascript",
|
|
16
|
+
".css" => "text/css", ".json" => "application/json", ".map" => "application/json",
|
|
17
|
+
".svg" => "image/svg+xml", ".png" => "image/png", ".jpg" => "image/jpeg",
|
|
18
|
+
".jpeg" => "image/jpeg", ".gif" => "image/gif", ".webp" => "image/webp",
|
|
19
|
+
".ico" => "image/x-icon", ".woff" => "font/woff", ".woff2" => "font/woff2",
|
|
20
|
+
".ttf" => "font/ttf", ".wasm" => "application/wasm", ".txt" => "text/plain"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
attr_reader :root, :port
|
|
24
|
+
|
|
25
|
+
def initialize(root, spa: true)
|
|
26
|
+
@root = File.expand_path(root)
|
|
27
|
+
@spa = spa
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Start on an ephemeral loopback port in a background thread; returns base URL.
|
|
31
|
+
def start
|
|
32
|
+
@server = TCPServer.new("127.0.0.1", 0)
|
|
33
|
+
@port = @server.addr[1]
|
|
34
|
+
@thread = Thread.new { accept_loop }
|
|
35
|
+
"http://127.0.0.1:#{@port}/"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def stop
|
|
39
|
+
@server&.close
|
|
40
|
+
@thread&.kill
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def accept_loop
|
|
46
|
+
loop { socket = @server.accept; Thread.new(socket) { |s| handle(s) } }
|
|
47
|
+
rescue IOError, Errno::EBADF
|
|
48
|
+
nil # server closed
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def handle(socket)
|
|
52
|
+
request_line = socket.gets
|
|
53
|
+
return unless request_line
|
|
54
|
+
|
|
55
|
+
method, path, = request_line.split
|
|
56
|
+
while (line = socket.gets) && line != "\r\n"; end # drain headers
|
|
57
|
+
return respond(socket, 405, "text/plain", "method not allowed") unless method == "GET"
|
|
58
|
+
|
|
59
|
+
file = resolve(path)
|
|
60
|
+
if file
|
|
61
|
+
respond(socket, 200, mime(file), File.binread(file))
|
|
62
|
+
else
|
|
63
|
+
respond(socket, 404, "text/plain", "not found")
|
|
64
|
+
end
|
|
65
|
+
rescue Errno::EPIPE, Errno::ECONNRESET
|
|
66
|
+
nil # client went away
|
|
67
|
+
ensure
|
|
68
|
+
socket.close rescue nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Map a URL path to a file inside root, blocking traversal; SPA-fallback to
|
|
72
|
+
# index.html for extension-less routes.
|
|
73
|
+
def resolve(path)
|
|
74
|
+
rel = URI.decode_www_form_component(path.to_s.split("?", 2).first.to_s)
|
|
75
|
+
rel = "/index.html" if rel == "/" || rel.empty?
|
|
76
|
+
candidate = File.expand_path(File.join(@root, rel))
|
|
77
|
+
return nil unless candidate == @root || candidate.start_with?(@root + File::SEPARATOR)
|
|
78
|
+
return candidate if File.file?(candidate)
|
|
79
|
+
|
|
80
|
+
if @spa && File.extname(rel).empty?
|
|
81
|
+
index = File.join(@root, "index.html")
|
|
82
|
+
return index if File.file?(index)
|
|
83
|
+
end
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def mime(file)
|
|
88
|
+
MIME.fetch(File.extname(file).downcase, "application/octet-stream")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def respond(socket, status, type, body)
|
|
92
|
+
reason = { 200 => "OK", 404 => "Not Found", 405 => "Method Not Allowed" }.fetch(status, "OK")
|
|
93
|
+
body = body.to_s
|
|
94
|
+
socket.write("HTTP/1.1 #{status} #{reason}\r\n")
|
|
95
|
+
socket.write("Content-Type: #{type}\r\n")
|
|
96
|
+
socket.write("Content-Length: #{body.bytesize}\r\n")
|
|
97
|
+
socket.write("Cache-Control: no-cache\r\n")
|
|
98
|
+
socket.write("Connection: close\r\n\r\n")
|
|
99
|
+
socket.write(body)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Roxane
|
|
6
|
+
# A native window hosting the system webview, with a Ruby<->JS operation bridge.
|
|
7
|
+
# The shell/transport only: it serves whatever frontend you give it and is
|
|
8
|
+
# agnostic to look-and-feel.
|
|
9
|
+
#
|
|
10
|
+
# win = Roxane::Window.new(title: "Hello", size: [900, 600])
|
|
11
|
+
# win.on("greet") { |name| "Hello, #{name}!" } # window.roxane.invoke("greet", "x")
|
|
12
|
+
# win.emit("tick", { at: 1 }) # window.roxane.on("tick", cb)
|
|
13
|
+
# win.serve(File.expand_path("ui")) # serve a built frontend dir
|
|
14
|
+
# win.run
|
|
15
|
+
class Window
|
|
16
|
+
DISPATCH = "__roxane_dispatch" # the single C binding behind window.roxane.invoke
|
|
17
|
+
|
|
18
|
+
def initialize(title: "Roxane", size: [800, 600], min_size: nil, resizable: true, debug: false)
|
|
19
|
+
unless FFI::AVAILABLE
|
|
20
|
+
raise Error, "libwebview is not available (#{FFI::LOAD_ERROR}). " \
|
|
21
|
+
"Set ROXANE_LIBWEBVIEW or install the webview C library."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@handlers = {} # operation name (String) -> handler block
|
|
25
|
+
@callbacks = [] # retained FFI::Function refs (must outlive the window)
|
|
26
|
+
@on_close = nil
|
|
27
|
+
@server = nil
|
|
28
|
+
@ui_queue = Queue.new # procs to run on the UI thread
|
|
29
|
+
|
|
30
|
+
@w = FFI.webview_create(debug ? 1 : 0, nil)
|
|
31
|
+
raise Error, "could not create webview" if @w.null?
|
|
32
|
+
|
|
33
|
+
FFI.webview_set_title(@w, title.to_s)
|
|
34
|
+
apply_size(size, min_size, resizable)
|
|
35
|
+
install_ui_pump
|
|
36
|
+
install_bridge
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# --- public API ---------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
# Handle window.roxane.invoke(op, payload). The block runs OFF the UI thread;
|
|
42
|
+
# its return value resolves the JS promise, a raised error rejects it.
|
|
43
|
+
def on(op, &block)
|
|
44
|
+
@handlers[op.to_s] = block
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Push an event to the frontend (delivered to window.roxane.on(event, cb)).
|
|
49
|
+
# Safe to call from any thread.
|
|
50
|
+
def emit(event, payload = nil)
|
|
51
|
+
eval("window.roxane && window.roxane.__receive(#{JSON.generate(event.to_s)}, #{JSON.generate(payload)})")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Run arbitrary JS in the page. Safe from any thread (marshalled to the UI thread).
|
|
55
|
+
def eval(js)
|
|
56
|
+
on_ui_thread { FFI.webview_eval(@w, js.to_s) }
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Low-level escape hatch: expose window.<name>(...args) -> Promise.
|
|
61
|
+
def bind(name, &block)
|
|
62
|
+
cb = ::FFI::Function.new(:void, [:string, :string, :pointer]) do |seq, req, _arg|
|
|
63
|
+
run_handler(seq) { block.call(*JSON.parse(req)) }
|
|
64
|
+
end
|
|
65
|
+
@callbacks << cb
|
|
66
|
+
FFI.webview_bind(@w, name.to_s, cb, nil)
|
|
67
|
+
self
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Serve a built frontend directory over loopback http and load it.
|
|
71
|
+
def serve(dir)
|
|
72
|
+
@server = StaticServer.new(dir)
|
|
73
|
+
load(@server.start)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def load(url)
|
|
77
|
+
FFI.webview_navigate(@w, url.to_s)
|
|
78
|
+
self
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def html(markup)
|
|
82
|
+
FFI.webview_set_html(@w, markup.to_s)
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def on_close(&block)
|
|
87
|
+
@on_close = block
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Run the native UI loop. Blocks the calling thread until the window closes.
|
|
92
|
+
def run
|
|
93
|
+
FFI.webview_run(@w)
|
|
94
|
+
ensure
|
|
95
|
+
@on_close&.call
|
|
96
|
+
@server&.stop
|
|
97
|
+
FFI.webview_destroy(@w)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Stop the loop / close the window. Safe from any thread.
|
|
101
|
+
def close
|
|
102
|
+
FFI.webview_terminate(@w)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# --- internals ----------------------------------------------------------
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def apply_size(size, min_size, resizable)
|
|
109
|
+
width, height = size
|
|
110
|
+
FFI.webview_set_size(@w, width, height, resizable ? FFI::HINT_NONE : FFI::HINT_FIXED)
|
|
111
|
+
FFI.webview_set_size(@w, min_size[0], min_size[1], FFI::HINT_MIN) if min_size && resizable
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# One persistent dispatch callback drains a queue of procs on the UI thread —
|
|
115
|
+
# the safe way to call into webview from worker threads.
|
|
116
|
+
def install_ui_pump
|
|
117
|
+
@ui_pump = ::FFI::Function.new(:void, [:pointer, :pointer]) do |_w, _arg|
|
|
118
|
+
job = (@ui_queue.pop(true) rescue nil)
|
|
119
|
+
job&.call
|
|
120
|
+
end
|
|
121
|
+
@callbacks << @ui_pump
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def on_ui_thread(&block)
|
|
125
|
+
@ui_queue << block
|
|
126
|
+
FFI.webview_dispatch(@w, @ui_pump, nil)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def install_bridge
|
|
130
|
+
cb = ::FFI::Function.new(:void, [:string, :string, :pointer]) do |seq, req, _arg|
|
|
131
|
+
op, payload = JSON.parse(req)
|
|
132
|
+
handler = @handlers[op]
|
|
133
|
+
if handler
|
|
134
|
+
run_handler(seq) { handler.call(payload) }
|
|
135
|
+
else
|
|
136
|
+
resolve(seq, 1, { "error" => "no handler for operation: #{op}" })
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
@callbacks << cb
|
|
140
|
+
FFI.webview_bind(@w, DISPATCH, cb, nil)
|
|
141
|
+
FFI.webview_init(@w, bridge_js)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Run a handler off the UI thread, then resolve/reject the JS promise.
|
|
145
|
+
def run_handler(seq)
|
|
146
|
+
Thread.new do
|
|
147
|
+
resolve(seq, 0, yield)
|
|
148
|
+
rescue StandardError => e
|
|
149
|
+
resolve(seq, 1, { "error" => e.message })
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def resolve(seq, status, value)
|
|
154
|
+
on_ui_thread { FFI.webview_return(@w, seq, status, JSON.generate(value)) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def bridge_js
|
|
158
|
+
@bridge_js ||= File.read(File.expand_path("../../assets/bridge.js", __dir__))
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
data/lib/roxane.rb
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require_relative "roxane/version"
|
|
6
|
+
|
|
7
|
+
# Roxane — a pure-Ruby desktop shell. A native window hosting the operating
|
|
8
|
+
# system's webview (WebView2 / WKWebView / WebKitGTK, via the `webview` C library),
|
|
9
|
+
# bridged to Ruby with an invoke/emit operation channel. The host/transport only:
|
|
10
|
+
# it serves whatever web frontend you give it and is agnostic to look-and-feel.
|
|
11
|
+
#
|
|
12
|
+
# Named for Cyrano's Roxane.
|
|
13
|
+
module Roxane
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
# Path to the libwebview shared library: an explicit override wins, then a
|
|
17
|
+
# vendored (precompiled) copy shipped with the gem, then the platform's default
|
|
18
|
+
# name resolved by the system loader.
|
|
19
|
+
def self.library_path
|
|
20
|
+
ENV["ROXANE_LIBWEBVIEW"] || bundled_library || default_library_name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.bundled_library
|
|
24
|
+
platform_dir = File.join(__dir__, "..", "vendor", Gem::Platform.local.to_s)
|
|
25
|
+
Dir[File.join(platform_dir, "libwebview.{so,dylib,dll}*")].sort.first ||
|
|
26
|
+
Dir[File.join(__dir__, "..", "vendor", "**", "libwebview.{so,dylib,dll}*")].sort.first
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.default_library_name
|
|
30
|
+
case RbConfig::CONFIG["host_os"]
|
|
31
|
+
when /darwin/ then "libwebview.dylib"
|
|
32
|
+
when /mswin|mingw|cygwin/ then "webview.dll"
|
|
33
|
+
else "libwebview.so"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
require_relative "roxane/ffi"
|
|
39
|
+
require_relative "roxane/static_server"
|
|
40
|
+
require_relative "roxane/window"
|
|
Binary file
|
metadata
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: roxane
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.1
|
|
5
|
+
platform: x64-mingw-ucrt
|
|
6
|
+
authors:
|
|
7
|
+
- BKK Riese
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-18 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: ffi
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '1.17'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '1.17'
|
|
27
|
+
description: Roxane embeds the operating system's webview (WebView2 / WKWebView /
|
|
28
|
+
WebKitGTK, via the webview C library) in a native window and bridges it to Ruby
|
|
29
|
+
with an invoke/emit operation channel — a lightweight, Tauri-shaped way to ship
|
|
30
|
+
a web frontend as a desktop app, in Ruby.
|
|
31
|
+
email:
|
|
32
|
+
- brian@quarterstack.com
|
|
33
|
+
executables: []
|
|
34
|
+
extensions: []
|
|
35
|
+
extra_rdoc_files: []
|
|
36
|
+
files:
|
|
37
|
+
- LICENSE
|
|
38
|
+
- README.md
|
|
39
|
+
- assets/bridge.js
|
|
40
|
+
- lib/roxane.rb
|
|
41
|
+
- lib/roxane/ffi.rb
|
|
42
|
+
- lib/roxane/static_server.rb
|
|
43
|
+
- lib/roxane/version.rb
|
|
44
|
+
- lib/roxane/window.rb
|
|
45
|
+
- vendor/x64-mingw-ucrt/libwebview.dll
|
|
46
|
+
homepage: https://github.com/quarterstack/roxane
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata: {}
|
|
50
|
+
post_install_message:
|
|
51
|
+
rdoc_options: []
|
|
52
|
+
require_paths:
|
|
53
|
+
- lib
|
|
54
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '3.1'
|
|
59
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '0'
|
|
64
|
+
requirements: []
|
|
65
|
+
rubygems_version: 3.5.22
|
|
66
|
+
signing_key:
|
|
67
|
+
specification_version: 4
|
|
68
|
+
summary: 'Pure-Ruby desktop shell: a native window over the system webview, bridged
|
|
69
|
+
to Ruby.'
|
|
70
|
+
test_files: []
|