wrangle 0.1.0

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.
@@ -0,0 +1,404 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "errors"
6
+ require_relative "jxa_bridge"
7
+ require_relative "observation"
8
+
9
+ module Wrangle
10
+ # One scoped Safari window, observed with snapshot.js and mutated only through guarded DOM actions.
11
+ #
12
+ # Scope is the whole safety boundary: one window id, one tab position, one expected URL, and one
13
+ # document epoch. Losing any of them ends the session rather than starting a search for a
14
+ # replacement, because the window this session was given is the only window it was given.
15
+ class Safari
16
+ MAX_TEXT = 2000
17
+ SETTLE_SECONDS = 5
18
+ ACTION_KINDS = %w[click fill select scroll wait].freeze
19
+ BLOCKED = {
20
+ "target" => "Target is gone, disabled, or no longer visible",
21
+ "readonly" => "Target is read-only",
22
+ "offscreen" => "Target is outside the viewport; scroll to it first",
23
+ "covered" => "Target is behind another element",
24
+ "option" => "That option is not selectable on this control"
25
+ }.freeze
26
+ STATE_KEYS = %w[url title text actions scroll marker page_key guards].freeze
27
+ # Kinds aimed at a particular element, and so checked against that element rather than the page.
28
+ # Scrolling and waiting have no target, so only the page as a whole can speak for them.
29
+ GUARDED = %w[click select fill].freeze
30
+ # The parts of a click's guard, outermost first, and what each one moving means. A decision is
31
+ # rejected when any of them stops matching, and which one it was decides whether the run should
32
+ # wait, look again, or give up.
33
+ MOVED = {
34
+ "origin" => "The document was replaced",
35
+ "route" => "The page navigated elsewhere",
36
+ "view" => "The page scrolled or resized",
37
+ "form" => "A field elsewhere on the page changed",
38
+ "self" => "The target itself changed",
39
+ "scope" => "The content around the target changed"
40
+ }.freeze
41
+ # Binding a new document and mutating one are verified against Safari; reads rely on the epoch.
42
+ VERIFIED_OPS = %w[install act].freeze
43
+
44
+ attr_reader :window_id, :tab_index, :mode, :epoch, :expected_url, :bounds
45
+
46
+ class << self
47
+ # Open a window this session owns. It is the only kind of window Wrangle will ever close.
48
+ def open(url, display: nil, bounds: nil, restore_focus: true, **, &block)
49
+ session = new(url: url, display: display, bounds: bounds, restore_focus: restore_focus, **)
50
+ block ? use(session, &block) : session
51
+ end
52
+
53
+ # Take over a window that is already open, already signed in, and already where it was left.
54
+ def attach(window_id:, url: nil, display: nil, bounds: nil, **, &block)
55
+ session = new(window_id: window_id, url: url, display: display, bounds: bounds, **)
56
+ block ? use(session, &block) : session
57
+ end
58
+
59
+ # Report Safari windows. Titles and URLs identify a tab to its owner, so they are opt-in.
60
+ def windows(titles: false, bridge: nil)
61
+ with_bridge(bridge) { |active| active.request("windows", titles: titles || nil).fetch("windows") }
62
+ end
63
+
64
+ # Report attached displays in AppleScript window coordinates.
65
+ def displays(bridge: nil)
66
+ with_bridge(bridge) { |active| active.request("displays").fetch("displays") }
67
+ end
68
+
69
+ private
70
+
71
+ def use(session)
72
+ yield session
73
+ ensure
74
+ session.close
75
+ end
76
+
77
+ def with_bridge(bridge)
78
+ return yield bridge if bridge
79
+
80
+ active = JxaBridge.new
81
+ active.start
82
+ begin
83
+ yield active
84
+ ensure
85
+ active.close
86
+ end
87
+ end
88
+ end
89
+
90
+ def initialize(url: nil, window_id: nil, display: nil, bounds: nil, bridge: nil,
91
+ load_timeout: 15, restore_focus: true, allow_multiple_safari: false)
92
+ @attaching = !window_id.nil?
93
+ validate!(url, window_id, display, bounds)
94
+
95
+ @bridge = bridge || JxaBridge.new
96
+ @mode = @attaching ? "attach" : "dedicated"
97
+ @owned = !@attaching
98
+ @closed = false
99
+ @poisoned = nil
100
+ @expect_navigation = false
101
+
102
+ begin
103
+ guard_single_safari(@bridge.start, allow_multiple_safari)
104
+ info = if @attaching
105
+ attach_window(window_id, url, display, bounds)
106
+ else
107
+ open_window(url, display, bounds,
108
+ restore_focus, load_timeout)
109
+ end
110
+ @window_id = integer(info["window_id"], "window_id")
111
+ @tab_index = integer(info["tab_index"], "tab_index")
112
+ @expected_url = info["url"] if info["url"].is_a?(String)
113
+ @bounds = info["bounds"]
114
+ install
115
+ rescue StandardError
116
+ shutdown(suppress: true)
117
+ raise
118
+ end
119
+ end
120
+
121
+ def owned? = @owned
122
+
123
+ # Return one snapshot.js observation of the scoped tab.
124
+ def observe
125
+ ensure_open
126
+ deadline = now + SETTLE_SECONDS
127
+ result = nil
128
+ loop do
129
+ result = page_request({ "op" => "observe" })
130
+ if result["status"] == "epoch_lost"
131
+ # Our own window may navigate and be rebound. A handed-over tab may not: if the user went
132
+ # somewhere else, the session is over.
133
+ unless @mode == "dedicated" || @expect_navigation
134
+ poison(ScopeLost.new("The handed-over Safari tab replaced its document"))
135
+ end
136
+ result = install
137
+ end
138
+ break if result["status"] == "ok"
139
+ raise StalePage, "The scoped Safari page did not settle" if now > deadline
140
+
141
+ sleep 0.05
142
+ end
143
+
144
+ state = result["state"]
145
+ unless state.is_a?(Hash) && STATE_KEYS.all? { |key| state.key?(key) }
146
+ raise BridgeError, "The scoped Safari page returned an incomplete observation"
147
+ end
148
+
149
+ state["fingerprint"] = Observation.fingerprint(state)
150
+ @expected_url = state["url"]
151
+ @expect_navigation = false
152
+ state
153
+ end
154
+
155
+ # Compare an observed decision with current page state, without mutating anything.
156
+ def fresh?(page, action = nil) = moved(page, action).nil?
157
+
158
+ # Why a decision went stale, or nil if it did not.
159
+ #
160
+ # Naming the difference is the difference between a transcript saying the page moved and one
161
+ # saying what moved, and only the second can be acted on. A calendar streaming its prices in, a
162
+ # field elsewhere being rewritten by an autocomplete, and a document being replaced all used to
163
+ # read as the same line.
164
+ def moved(page, action = nil)
165
+ ensure_open
166
+ # Anything aimed at a node is checked against that node. A fill used to fall through to the
167
+ # whole-page marker, which compares the document title, every word of text, and the full list
168
+ # of actions — so any banner, price, or result count arriving anywhere rejected a decision
169
+ # about a search box that had not moved. The guard is both narrower and more to the point: it
170
+ # asks whether this field is still this field.
171
+ unless action.is_a?(Hash) && GUARDED.include?(action["kind"])
172
+ result = page_request({ "op" => "marker" })
173
+ return "The page is still loading" unless result["status"] == "ok"
174
+
175
+ return result["marker"] == page["marker"] ? nil : "The page changed"
176
+ end
177
+
178
+ node = action["node"]
179
+ return "The target was never observed" unless node.is_a?(Integer)
180
+
181
+ result = page_request({ "op" => "guard", "node" => node })
182
+ return "The page is still loading" unless result["status"] == "ok"
183
+
184
+ difference(result["guard"], [page["page_key"], page["guards"][node.to_s]])
185
+ end
186
+
187
+ # Which part of the guard stopped matching. The parts are checked outermost first, because a
188
+ # replaced document explains every other difference and reporting the innermost one would send a
189
+ # reader looking at the wrong thing.
190
+ def difference(live, observed)
191
+ return nil if live == observed
192
+
193
+ live_key, live_guard = live
194
+ key, guard = observed
195
+ return "The target is gone" if live_guard.nil? || guard.nil?
196
+ return "The page changed" unless [live_key, key, live_guard, guard].all?(Hash)
197
+
198
+ part = MOVED.keys.find { |name| (live_key[name] || live_guard[name]) != (key[name] || guard[name]) }
199
+ MOVED.fetch(part, "The page changed")
200
+ end
201
+
202
+ def refuse_stale(page, observed)
203
+ why = moved(page, observed)
204
+ raise StalePage, "#{why}. Observe again." if why
205
+ end
206
+
207
+ # Execute exactly one observed action in the scoped tab.
208
+ def act(action, page, text: nil)
209
+ ensure_open
210
+ observed = Observation.require_observed(action, page)
211
+ kind = observed["kind"]
212
+ validate_action!(observed, kind, text)
213
+
214
+ refuse_stale(page, observed)
215
+
216
+ if kind == "wait"
217
+ sleep 0.1
218
+ return { "executed" => observed["id"] }
219
+ end
220
+
221
+ nonce = SecureRandom.hex(8)
222
+ request = { "op" => "act", "action" => observed, "nonce" => nonce }
223
+ request["text"] = text if kind == "fill"
224
+
225
+ result = begin
226
+ page_request(request)
227
+ rescue ScopeLost, DeliveryUnknown
228
+ raise
229
+ rescue BridgeError => e
230
+ resolve_delivery(nonce, e)
231
+ end
232
+
233
+ case result["status"]
234
+ when "executed"
235
+ # A click can navigate. Let exactly the next observation re-pin the document.
236
+ @expect_navigation = kind != "scroll"
237
+ { "executed" => observed["id"] }
238
+ when "blocked"
239
+ # Four different situations reach here, and a caller who cannot tell them apart cannot fix
240
+ # any of them: gone, read-only, scrolled away, or sitting under something else.
241
+ raise StalePage, "#{BLOCKED.fetch(result["reason"], "Target is not actionable")}. Observe again."
242
+ when "epoch_lost"
243
+ # The epoch is checked before anything is dispatched, so nothing was mutated.
244
+ raise StalePage, "The document changed before execution. Observe again."
245
+ else
246
+ poison(DeliveryUnknown.new("Safari reported #{result["status"].inspect} for a dispatched action"))
247
+ end
248
+ end
249
+
250
+ # Close only a window this session opened. An attached window is always left alone.
251
+ def close
252
+ return if @closed
253
+
254
+ shutdown(suppress: false)
255
+ end
256
+
257
+ private
258
+
259
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
260
+
261
+ def validate!(url, window_id, display, bounds)
262
+ if @attaching && !window_id.is_a?(Integer)
263
+ raise ArgumentError, "window_id must be the integer id of an existing Safari window"
264
+ end
265
+ if !@attaching && !(url.is_a?(String) && !url.strip.empty?)
266
+ raise ArgumentError, "Supply a URL for a dedicated Safari window"
267
+ end
268
+ raise ArgumentError, "Choose either a display or explicit bounds" if display && bounds
269
+ if display && !(display.is_a?(Integer) && !display.negative?)
270
+ raise ArgumentError, "display must be a non-negative index"
271
+ end
272
+ return unless bounds && !(bounds.size == 4 && bounds.all?(Integer))
273
+
274
+ raise ArgumentError, "bounds must be four integers: x, y, width, height"
275
+ end
276
+
277
+ def validate_action!(observed, kind, text)
278
+ raise ArgumentError, "Unsupported Safari action" unless ACTION_KINDS.include?(kind)
279
+
280
+ validate_text!(kind, text)
281
+ validate_target!(observed, kind)
282
+ end
283
+
284
+ def validate_text!(kind, text)
285
+ if kind == "fill"
286
+ unless text.is_a?(String) && !text.empty? && text.length <= MAX_TEXT
287
+ raise ArgumentError, "Safari text input requires a non-empty string of at most #{MAX_TEXT} characters"
288
+ end
289
+ elsif !text.nil?
290
+ raise ArgumentError, "Text is valid only for a Safari fill action"
291
+ end
292
+ end
293
+
294
+ def validate_target!(observed, kind)
295
+ raise ArgumentError, "Safari select requires an observed option value" if kind == "select" &&
296
+ !observed["value"].is_a?(String)
297
+ if kind == "scroll" && !(observed["delta"].is_a?(Integer) && !observed["delta"].zero?)
298
+ raise ArgumentError, "Safari scroll requires a non-zero integer delta"
299
+ end
300
+ return unless %w[click fill select].include?(kind) && !observed["node"].is_a?(Integer)
301
+
302
+ raise ArgumentError, "Safari actions require an observed node"
303
+ end
304
+
305
+ # safaridriver leaves extra Safari processes behind, and window ids are only unique within one.
306
+ def guard_single_safari(ping, allowed)
307
+ instances = ping.is_a?(Hash) ? ping["safari_instances"] : nil
308
+ return unless instances.is_a?(Integer) && instances > 1 && !allowed
309
+
310
+ raise Error, "#{instances} Safari processes are running, so window ids are ambiguous. " \
311
+ "Quit the extra instances or pass allow_multiple_safari: true."
312
+ end
313
+
314
+ def open_window(url, display, bounds, restore_focus, load_timeout)
315
+ @bridge.request("open", url: url, display: display, bounds: bounds,
316
+ restore_focus: restore_focus, timeout: load_timeout)
317
+ end
318
+
319
+ def attach_window(window_id, url, display, bounds)
320
+ info = @bridge.request("attach", window_id: window_id, url: url)
321
+ @bridge.request("bounds", window_id: info["window_id"], display: display, bounds: bounds) if display || bounds
322
+ info
323
+ end
324
+
325
+ def install
326
+ epoch = SecureRandom.hex(8)
327
+ result = page_request({ "op" => "install" }, epoch: epoch)
328
+ @epoch = epoch if result["status"] == "ok"
329
+ result
330
+ end
331
+
332
+ def scope
333
+ built = { "window_id" => @window_id, "mode" => @mode, "tab_index" => @tab_index }
334
+ built["url"] = @expected_url if @mode == "attach" && !@expect_navigation && @expected_url
335
+ built
336
+ end
337
+
338
+ def page_request(request, epoch: nil)
339
+ # Binding a document or changing one is worth the extra Apple Events; a read is guarded by the epoch.
340
+ @bridge.evaluate(scope, request.merge("epoch" => epoch || @epoch),
341
+ verify: VERIFIED_OPS.include?(request["op"]))
342
+ rescue BridgeCallError => e
343
+ poison(ScopeLost.new(e.message)) if e.scope?
344
+ raise
345
+ end
346
+
347
+ # A dispatched action with no reply is resolved by reading its nonce, never by repeating it.
348
+ def resolve_delivery(nonce, error)
349
+ poison(DeliveryUnknown.new("The bridge died while an action was in flight"), cause: error) unless @bridge.running?
350
+
351
+ probe = begin
352
+ page_request({ "op" => "probe" })
353
+ rescue Error => e
354
+ poison(DeliveryUnknown.new("The action's outcome could not be read back"), cause: e)
355
+ end
356
+
357
+ unless probe["status"] == "ok"
358
+ poison(DeliveryUnknown.new("The document changed while an action was in flight"), cause: error)
359
+ end
360
+
361
+ record = probe["act"]
362
+ raise Error, "Safari never executed the action" unless record.is_a?(Hash) && record["nonce"] == nonce
363
+ return { "status" => "executed" } if record["phase"] == "finished"
364
+
365
+ poison(DeliveryUnknown.new("An action started without confirming completion"), cause: error)
366
+ end
367
+
368
+ def poison(error, cause: nil)
369
+ @poisoned ||= error
370
+ raise error, cause: cause
371
+ end
372
+
373
+ def ensure_open
374
+ raise Error, "This Wrangle session is closed" if @closed
375
+ raise @poisoned if @poisoned
376
+ end
377
+
378
+ def shutdown(suppress:)
379
+ return if @closed
380
+
381
+ @closed = true
382
+ error = nil
383
+ # A session that may have mutated the page cannot describe what it would be closing.
384
+ if @owned && @window_id && !@poisoned.is_a?(DeliveryUnknown)
385
+ begin
386
+ @bridge.request("close", window_id: @window_id, owned: true)
387
+ rescue BridgeCallError => e
388
+ # The window is gone or no longer exclusively ours. Leaving it open is the right outcome.
389
+ error = e unless e.scope?
390
+ rescue StandardError => e
391
+ error = e
392
+ end
393
+ end
394
+ @bridge.close
395
+ raise error if error && !suppress
396
+ end
397
+
398
+ def integer(value, name)
399
+ raise BridgeError, "The bridge returned no #{name}" unless value.is_a?(Integer)
400
+
401
+ value
402
+ end
403
+ end
404
+ end