Class: Scarpe::Webview::WebWrangler

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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.

Parameters:

  • title (String)

    window title

  • width (Integer)

    window width in pixels

  • height (Integer)

    window height in pixels

  • resizable (Boolean) (defaults to: false)

    whether the window should be resizable by the user

  • heartbeat (Float) (defaults to: 0.1)

    time between heartbeats in seconds



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_interfaceObject

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

Parameters:

  • value

    the value to set the attribute empty_page to.



366
367
368
# File 'lib/scarpe/wv/web_wrangler.rb', line 366

def empty_page=(value)
  @empty_page = value
end

#heartbeatObject (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_runningObject (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_terminatedObject (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.

Parameters:

  • code (String)

    the Javascript code to be wrapped

  • eval_id (Integer)

    the tracking code to use when calling EVAL_RESULT



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.

Parameters:

  • name (String)

    the Javascript name for the new function

Yields:

  • The Ruby block to be invoked when JS calls the function

Raises:



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

#destroyObject

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.

Parameters:

  • js (String)

    the JS to execute to alter the DOM

Returns:

  • (Scarpe::Promise)

    a promise that will be fulfilled when the update is complete



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.

Returns:

  • (Boolean)

    true if the window is fully updated, false if changes are pending



461
462
463
# File 'lib/scarpe/wv/web_wrangler.rb', line 461

def dom_fully_updated?
  @dom_wrangler.fully_updated?
end

#dom_promise_redrawScarpe::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.

Returns:

  • (Scarpe::Promise)

    a promise that will be fulfilled when all current changes complete



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.

Parameters:

  • code (String)

    the Javascript code to execute

  • timeout (Float) (defaults to: EVAL_DEFAULT_TIMEOUT)

    how long to allow before raising a timeout exception

  • wait_for (Array<Scarpe::Promise>) (defaults to: [])

    promises that must complete successfully before this JS is scheduled



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.

Parameters:

  • name (String)

    the Javascript name for the init function

Yields:

  • The Ruby block to be invoked when Webview runs

Raises:



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

#inspectObject

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.

Parameters:

  • code (String)

    the Javascript code to attempt to execute

Raises:



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.

Yields:

  • Called after each update or batch of updates is verified complete



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.

Parameters:

  • name (String)

    the name of the Javascript init function, if needed

  • interval (Float) (defaults to: heartbeat)

    the duration between invoking this block

Yields:

  • the Ruby block to invoke periodically



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_updatedScarpe::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.

Returns:

  • (Scarpe::Promise)

    a promise that will be fulfilled when all changes are complete



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.

Parameters:

  • html_text (String)

    The new HTML for the new full DOM

Returns:

  • (Scarpe::Promise)

    a promise that will be fulfilled when the update is complete



443
444
445
# File 'lib/scarpe/wv/web_wrangler.rb', line 443

def replace(html_text)
  @dom_wrangler.request_replace(html_text)
end

#runObject

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