Class: Scarpe::Webview::WebWrangler
- Inherits:
-
Object
- Object
- Scarpe::Webview::WebWrangler
- Includes:
- Shoes::Log
- Defined in:
- lib/scarpe/wv/web_wrangler.rb,
lib/scarpe/wv/web_wrangler.rb
Overview
The Scarpe WebWrangler, for Webview, manages a lot of Webviews quirks. It provides a simpler underlying abstraction for DOMWrangler and the Webview drawables. Webview can be picky - if you send it too many messages, it can crash. If the messages you send it are too large, it can crash. If you don't return control to its event loop, it can crash. It doesn't save references to all event handlers, so if you don't save references to them, garbage collection will cause it to crash.
As well, Webview only supports asynchronous JS code evaluation with no value being returned. One of WebWrangler's responsibilities is to make asynchronous JS calls, detect when they return a value or time out, and make the result clear to other Scarpe code.
Some Webview API functions will crash on some platforms if called from a background thread. Webview will halt all background threads when it runs its event loop. So it's best to assume no Ruby background threads will be available while Webview is running. If a Ruby app wants ongoing work to occur, that work should be registered via a heartbeat handler on the Webview.
A WebWrangler is initially in Setup mode, where the underlying Webview exists but does not yet control the event loop. In Setup mode you can bind JS functions, set up initialization code, but nothing is yet running.
Once run() is called on WebWrangler, we will hand control of the event loop to the Webview. This will also stop any background threads in Ruby.
Defined Under Namespace
Classes: DOMWrangler, ElementWrangler
Constant Summary collapse
- EVAL_RESULT =
This is the JS function name for eval results (internal-only)
"scarpeAsyncEvalResult"
- EVAL_DEFAULT_TIMEOUT =
Allow this many seconds for Webview to finish our JS eval before we decide it's not going to
0.5
Constants included from Shoes::Log
Shoes::Log::DEFAULT_COMPONENT, Shoes::Log::DEFAULT_DEBUG_LOG_CONFIG, Shoes::Log::DEFAULT_LOG_CONFIG
Instance Attribute Summary collapse
-
#control_interface ⇒ Object
A reference to the control_interface that manages internal Scarpe Webview events.
-
#empty_page ⇒ Object
writeonly
Sets the attribute empty_page.
-
#heartbeat ⇒ Object
readonly
This is the time between heartbeats in seconds, usually fractional.
-
#is_running ⇒ Object
readonly
Whether Webview has been started.
-
#is_terminated ⇒ Object
readonly
Once Webview is marked terminated, it's attempting to shut down.
Class Method Summary collapse
-
.js_wrapped_code(code, eval_id) ⇒ Object
This method takes a piece of Javascript code and wraps it in the WebWrangler boilerplate to see if it parses successfully, run it, and see if it succeeds.
Instance Method Summary collapse
-
#bind(name) { ... } ⇒ Object
Bind a Javascript-callable function by name.
-
#destroy ⇒ Object
Request destruction of WebWrangler, including terminating the underlying Webview and (when possible) destroying it.
-
#dom_change(js) ⇒ Scarpe::Promise
Request a DOM change - return a promise for when this has been done.
-
#dom_fully_updated? ⇒ Boolean
Return whether the DOM is, right this moment, confirmed to be fully up to date or not.
-
#dom_promise_redraw ⇒ Scarpe::Promise
Return a promise that will be fulfilled when all current DOM changes have committed.
-
#eval_js_async(code, timeout: EVAL_DEFAULT_TIMEOUT, wait_for: []) ⇒ Object
Eval a chunk of JS code asynchronously.
-
#init_code(name) { ... } ⇒ Object
Request that this block of code be run initially when the Webview is run.
-
#initialize(title:, width:, height:, resizable: false, heartbeat: 0.1) ⇒ WebWrangler
constructor
Create a new WebWrangler.
-
#inspect ⇒ Object
Shorter name for better stack trace messages.
-
#js_eventually(code) ⇒ void
js_eventually is a native Webview JS evaluation.
-
#on_every_redraw { ... } ⇒ void
DOMWrangler will frequently schedule and confirm small JS updates.
-
#periodic_code(name, interval = heartbeat) { ... } ⇒ Object
Run the specified code periodically, every "interval" seconds.
-
#promise_dom_fully_updated ⇒ Scarpe::Promise
Return a promise which will be fulfilled the next time the DOM is fully up to date.
-
#replace(html_text) ⇒ Scarpe::Promise
Replace the entire DOM - return a promise for when this has been done.
-
#run ⇒ Object
After setup, we call run to go to "running" mode.
Methods included from Shoes::Log
configure_logger, #log_init, logger
Constructor Details
#initialize(title:, width:, height:, resizable: false, heartbeat: 0.1) ⇒ WebWrangler
Create a new WebWrangler.
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 67 def initialize(title:, width:, height:, resizable: false, heartbeat: 0.1) log_init("Webview::WebWrangler") @log.debug("Creating WebWrangler...") # For now, always allow inspect element, so pass debug: true @webview = WebviewRuby::Webview.new debug: true @webview = Shoes::LoggedWrapper.new(@webview, "WebviewAPI") if ENV["SCARPE_DEBUG"] @init_refs = {} # Inits don't go away so keep a reference to them to prevent GC @title = title @width = width @height = height @resizable = resizable @heartbeat = heartbeat # JS setInterval uses RPC and is quite expensive. For many periodic operations # we can group them under a single heartbeat handler and avoid extra JS calls or RPC. @heartbeat_handlers = [] # Need to keep track of which WebView Javascript evals are still pending, # what handlers to call when they return, etc. @pending_evals = {} @eval_counter = 0 @dom_wrangler = DOMWrangler.new(self) bind("puts") do |*args| puts(*args) end @webview.bind(EVAL_RESULT) do |*results| receive_eval_result(*results) end # Ruby receives scarpeHeartbeat messages via the window library's main loop. # So this is a way for Ruby to be notified periodically, in time with that loop. @webview.bind("scarpeHeartbeat") do return unless @webview # I think GTK+ may continue to deliver events after shutdown periodic_js_callback @heartbeat_handlers.each(&:call) @control_interface.dispatch_event(:heartbeat) end js_interval = (heartbeat.to_f * 1_000.0).to_i @webview.init("setInterval(scarpeHeartbeat,#{js_interval})") end |
Instance Attribute Details
#control_interface ⇒ Object
A reference to the control_interface that manages internal Scarpe Webview events.
52 53 54 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 52 def control_interface @control_interface end |
#empty_page=(value) ⇒ Object (writeonly)
Sets the attribute empty_page
366 367 368 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 366 def empty_page=(value) @empty_page = value end |
#heartbeat ⇒ Object (readonly)
This is the time between heartbeats in seconds, usually fractional
49 50 51 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 49 def heartbeat @heartbeat end |
#is_running ⇒ Object (readonly)
Whether Webview has been started. Once Webview is running you can't add new Javascript bindings. Until it is running, you can't use eval to run Javascript.
42 43 44 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 42 def is_running @is_running end |
#is_terminated ⇒ Object (readonly)
Once Webview is marked terminated, it's attempting to shut down. If we get events (e.g. heartbeats) after that, we should ignore them.
46 47 48 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 46 def is_terminated @is_terminated end |
Class Method Details
.js_wrapped_code(code, eval_id) ⇒ Object
This method takes a piece of Javascript code and wraps it in the WebWrangler boilerplate to see if it parses successfully, run it, and see if it succeeds. This function would normally be used by testing code, to mock Webview and watch for code being run. Javascript code containing backticks could potentially break this abstraction layer, which would cause the resulting code to fail to parse and Webview would return no error. This should not be used for random or untrusted code.
283 284 285 286 287 288 289 290 291 292 293 294 295 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 283 def self.js_wrapped_code(code, eval_id) <<~JS_CODE (function() { var code_string = #{JSON.dump code}; try { result = eval(code_string); #{EVAL_RESULT}("success", #{eval_id}, result); } catch(error) { #{EVAL_RESULT}("error", #{eval_id}, error.message); } })(); JS_CODE end |
Instance Method Details
#bind(name) { ... } ⇒ Object
Bind a Javascript-callable function by name. When JS calls the function, an async message is sent to Ruby via RPC and will eventually cause the block to be called. This method only works in setup mode, before the underlying Webview has been told to run.
131 132 133 134 135 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 131 def bind(name, &block) raise Scarpe::JSBindingError, "App is running, javascript binding no longer works because it uses WebView init!" if @is_running @webview.bind(name, &block) end |
#destroy ⇒ Object
Request destruction of WebWrangler, including terminating the underlying Webview and (when possible) destroying it.
399 400 401 402 403 404 405 406 407 408 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 399 def destroy @log.debug("Destroying WebWrangler...") @log.debug(" (WebWrangler was already terminated)") if @is_terminated @log.debug(" (WebWrangler was already destroyed)") unless @webview if @webview && !@is_terminated @bindings = {} @webview.terminate @is_terminated = true end end |
#dom_change(js) ⇒ Scarpe::Promise
Request a DOM change - return a promise for when this has been done. If a full replacement (see #replace) is requested, this change may be lost. Only use it for changes that are preserved by a full update.
453 454 455 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 453 def dom_change(js) @dom_wrangler.request_change(js) end |
#dom_fully_updated? ⇒ Boolean
Return whether the DOM is, right this moment, confirmed to be fully up to date or not.
461 462 463 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 461 def dom_fully_updated? @dom_wrangler.fully_updated? end |
#dom_promise_redraw ⇒ Scarpe::Promise
Return a promise that will be fulfilled when all current DOM changes have committed. If other changes are requested before these complete, the promise will not wait for them. If you wish to wait until all changes from all sources have completed, use
promise_dom_fully_updated.
472 473 474 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 472 def dom_promise_redraw @dom_wrangler.promise_redraw end |
#eval_js_async(code, timeout: EVAL_DEFAULT_TIMEOUT, wait_for: []) ⇒ Object
Eval a chunk of JS code asynchronously. This method returns a promise which will be fulfilled or rejected after the JS executes or times out.
We both care whether the JS has finished after it was scheduled and whether it ever got scheduled at all. If it depends on tasks that never fulfill or reject then it will raise a timed-out exception.
Right now we can't/don't pass arguments through from previous fulfilled promises. To do that, you can schedule the JS to run after the other promises succeed.
Webview does not allow interacting with a JS eval once it has been scheduled. So there is no way to guarantee that a piece of JS has not executed, or will not execute in the future. A timeout exception only means that WebWrangler will no longer wait for confirmation or fulfill the promise if the JS later completes.
228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 228 def eval_js_async(code, timeout: EVAL_DEFAULT_TIMEOUT, wait_for: []) unless @is_running raise Scarpe::WebWranglerNotRunningError, "WebWrangler isn't running, so evaluating JS won't work!" end this_eval_serial = @eval_counter @eval_counter += 1 @pending_evals[this_eval_serial] = { id: this_eval_serial, code: code, start_time: Time.now, timeout_if_not_scheduled: Time.now + EVAL_DEFAULT_TIMEOUT, } # We'll need this inside the promise-scheduling block pending_evals = @pending_evals promise = Scarpe::Promise.new(parents: wait_for) do # Are we mid-shutdown? if @webview wrapped_code = WebWrangler.js_wrapped_code(code, this_eval_serial) # We've been scheduled! t_now = Time.now # Hard to be sure Webview keeps a proper reference to this, so we will pending_evals[this_eval_serial][:wrapped_code] = wrapped_code pending_evals[this_eval_serial][:scheduled_time] = t_now pending_evals[this_eval_serial].delete(:timeout_if_not_scheduled) pending_evals[this_eval_serial][:timeout_if_not_finished] = t_now + timeout @webview.eval(wrapped_code) @log.debug("Scheduled JS: (#{this_eval_serial})\n#{wrapped_code}") else # We're mid-shutdown. No more scheduling things. @log.warn "Mid-shutdown JS eval. Not scheduling JS!" end end @pending_evals[this_eval_serial][:promise] = promise promise end |
#init_code(name) { ... } ⇒ Object
Request that this block of code be run initially when the Webview is run. This operates via #init and will not work if Webview is already running.
142 143 144 145 146 147 148 149 150 151 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 142 def init_code(name, &block) raise Scarpe::JSInitError, "App is running, javascript init no longer works!" if @is_running # Save a reference to the init string so that it doesn't get GC'd code_str = "#{name}();" @init_refs[name] = code_str bind(name, &block) @webview.init(code_str) end |
#inspect ⇒ Object
Shorter name for better stack trace messages
116 117 118 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 116 def inspect "Scarpe::WebWrangler:#{object_id}" end |
#js_eventually(code) ⇒ void
This method returns an undefined value.
js_eventually is a native Webview JS evaluation. On syntax error, nothing happens. On runtime error, execution stops at the error with no further effect or notification. This is rarely what you want. The js_eventually code is run asynchronously, returning neither error nor value.
This method does not return a promise, and there is no way to track its progress or its success or failure.
198 199 200 201 202 203 204 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 198 def js_eventually(code) raise Scarpe::WebWranglerNotRunningError, "WebWrangler isn't running, eval doesn't work!" unless @is_running @log.warn "Deprecated: please do NOT use js_eventually, it's basically never what you want!" unless ENV["CI"] @webview.eval(code) end |
#on_every_redraw { ... } ⇒ void
This method returns an undefined value.
DOMWrangler will frequently schedule and confirm small JS updates. A handler registered with on_every_redraw will be called after each small update.
499 500 501 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 499 def on_every_redraw(&block) @dom_wrangler.on_every_redraw(&block) end |
#periodic_code(name, interval = heartbeat) { ... } ⇒ Object
Run the specified code periodically, every "interval" seconds. If interval is unspecified, run per-heartbeat. This avoids extra RPC and Javascript overhead. This may use the #init mechanism, so it should be invoked when the WebWrangler is in setup mode, before the Webview is running.
TODO: add a way to stop this loop and unsubscribe.
164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 164 def periodic_code(name, interval = heartbeat, &block) if interval == heartbeat @heartbeat_handlers << block else if @is_running # I *think* we need to use init because we want this done for every # new window. But will there ever be a new page/window? Can we just # use eval instead of init to set up a periodic handler and call it # good? raise Scarpe::PeriodicHandlerSetupError, "App is running, can't set up new periodic handlers with init!" end js_interval = (interval.to_f * 1_000.0).to_i code_str = "setInterval(#{name}, #{js_interval});" @init_refs[name] = code_str bind(name, &block) @webview.init(code_str) end end |
#promise_dom_fully_updated ⇒ Scarpe::Promise
Return a promise which will be fulfilled the next time the DOM is fully up to date. A slow trickle of changes can make this take a long time, since it includes all current and future changes, not just changes before this call.
If you want to know that some specific individual change is done, it's often easiest to use the promise returned by #dom_change, which will be fulfilled when that specific change is verified complete.
If no changes are pending, promise_dom_fully_updated will return a promise that is already fulfilled.
489 490 491 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 489 def promise_dom_fully_updated @dom_wrangler.promise_fully_updated end |
#replace(html_text) ⇒ Scarpe::Promise
Replace the entire DOM - return a promise for when this has been done. This will often get rid of smaller changes in the queue, which is a good thing since they won't have to be run.
443 444 445 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 443 def replace(html_text) @dom_wrangler.request_replace(html_text) end |
#run ⇒ Object
After setup, we call run to go to "running" mode. No more setup callbacks should be called, only running callbacks.
370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 |
# File 'lib/scarpe/wv/web_wrangler.rb', line 370 def run @log.debug("Run...") # From webview: # 0 - Width and height are default size # 1 - Width and height are minimum bounds # 2 - Width and height are maximum bounds # 3 - Window size can not be changed by a user hint = @resizable ? 0 : 3 @webview.set_title(@title) @webview.set_size(@width, @height, hint) unless @empty_page raise Scarpe::EmptyPageNotSetError, "No empty page markup was set!" end @webview.navigate("data:text/html, #{CGI.escape @empty_page}") monkey_patch_console(@webview) @is_running = true @webview.run @is_running = false @webview.destroy @webview = nil end |