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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +87 -0
- data/LICENSE.txt +48 -0
- data/README.md +473 -0
- data/exe/wrangle +374 -0
- data/lib/wrangle/action_space.rb +67 -0
- data/lib/wrangle/decider.rb +167 -0
- data/lib/wrangle/errors.rb +53 -0
- data/lib/wrangle/jev.rb +87 -0
- data/lib/wrangle/js/bridge.js +390 -0
- data/lib/wrangle/js/page.js +127 -0
- data/lib/wrangle/js/snapshot.js +118 -0
- data/lib/wrangle/jxa_bridge.rb +194 -0
- data/lib/wrangle/mcp_bridge.rb +300 -0
- data/lib/wrangle/observation.rb +50 -0
- data/lib/wrangle/run_loop.rb +298 -0
- data/lib/wrangle/safari.rb +404 -0
- data/lib/wrangle/session_server.rb +447 -0
- data/lib/wrangle/version.rb +5 -0
- data/lib/wrangle.rb +19 -0
- data/skills/wrangle/SKILL.md +206 -0
- metadata +69 -0
|
@@ -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
|