browserctl 0.3.1 → 0.5.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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/README.md +120 -214
  4. data/bin/browserctl +35 -13
  5. data/bin/browserd +7 -1
  6. data/bin/setup +7 -3
  7. data/examples/cloudflare_hitl.rb +1 -1
  8. data/examples/smoke/params_file.rb +35 -0
  9. data/examples/smoke/store_fetch.rb +39 -0
  10. data/examples/the_internet/add_remove_elements.rb +3 -3
  11. data/examples/the_internet/checkboxes.rb +3 -3
  12. data/examples/the_internet/dropdown.rb +3 -3
  13. data/examples/the_internet/dynamic_loading.rb +3 -3
  14. data/examples/the_internet/login.rb +5 -5
  15. data/lib/browserctl/client.rb +38 -2
  16. data/lib/browserctl/commands/export_cookies.rb +18 -0
  17. data/lib/browserctl/commands/import_cookies.rb +23 -0
  18. data/lib/browserctl/commands/init.rb +11 -0
  19. data/lib/browserctl/commands/{pause_resume.rb → pause.rb} +2 -12
  20. data/lib/browserctl/commands/record.rb +2 -0
  21. data/lib/browserctl/commands/resume.rb +21 -0
  22. data/lib/browserctl/commands/snapshot.rb +5 -5
  23. data/lib/browserctl/commands/status.rb +30 -0
  24. data/lib/browserctl/constants.rb +9 -2
  25. data/lib/browserctl/detectors.rb +23 -0
  26. data/lib/browserctl/errors.rb +25 -0
  27. data/lib/browserctl/logger.rb +40 -5
  28. data/lib/browserctl/policy.rb +36 -0
  29. data/lib/browserctl/recording.rb +81 -15
  30. data/lib/browserctl/runner.rb +23 -4
  31. data/lib/browserctl/server/command_dispatcher.rb +31 -234
  32. data/lib/browserctl/server/handlers/cookies.rb +57 -0
  33. data/lib/browserctl/server/handlers/daemon_control.rb +29 -0
  34. data/lib/browserctl/server/handlers/devtools.rb +22 -0
  35. data/lib/browserctl/server/handlers/hitl.rb +30 -0
  36. data/lib/browserctl/server/handlers/navigation.rb +72 -0
  37. data/lib/browserctl/server/handlers/observation.rb +113 -0
  38. data/lib/browserctl/server/handlers/page_lifecycle.rb +29 -0
  39. data/lib/browserctl/server.rb +18 -2
  40. data/lib/browserctl/version.rb +1 -1
  41. data/lib/browserctl/workflow.rb +41 -3
  42. data/lib/browserctl.rb +12 -2
  43. metadata +48 -4
@@ -4,6 +4,7 @@ require "json"
4
4
  require "date"
5
5
  require "fileutils"
6
6
  require "tmpdir"
7
+ require "uri"
7
8
 
8
9
  module Browserctl
9
10
  class Recording
@@ -12,11 +13,15 @@ module Browserctl
12
13
 
13
14
  RECORDABLE = %w[open_page goto fill click screenshot evaluate].freeze
14
15
 
16
+ SENSITIVE_PARAM_PATTERN = /\A(token|key|secret|auth|code|access_token|api_key|client_secret|state)\z/ix
17
+
15
18
  def self.start(name)
16
- FileUtils.mkdir_p(RECORDINGS_DIR)
19
+ FileUtils.mkdir_p(RECORDINGS_DIR, mode: 0o700)
17
20
  FileUtils.mkdir_p(File.dirname(STATE_FILE))
18
21
  File.write(STATE_FILE, name)
19
22
  FileUtils.rm_f(log_path(name))
23
+ FileUtils.touch(log_path(name))
24
+ File.chmod(0o600, log_path(name))
20
25
  name
21
26
  end
22
27
 
@@ -36,10 +41,13 @@ module Browserctl
36
41
  name = active
37
42
  return unless name
38
43
  return unless RECORDABLE.include?(cmd.to_s)
39
- # ref-based interactions have no replayable selector — skip them
40
- return if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
41
44
 
42
- attrs = attrs.except(:value) if cmd.to_s == "fill"
45
+ if %w[click fill].include?(cmd.to_s) && attrs[:selector].nil?
46
+ record_ref_interaction(name, cmd.to_s, attrs)
47
+ return
48
+ end
49
+
50
+ attrs = prepare_attrs(cmd.to_s, attrs)
43
51
 
44
52
  File.open(log_path(name), "a") do |f|
45
53
  f.puts JSON.generate({ cmd: cmd.to_s }.merge(attrs.transform_keys(&:to_s)))
@@ -53,6 +61,13 @@ module Browserctl
53
61
  lines = File.readlines(log).map { |l| JSON.parse(l, symbolize_names: true) }
54
62
  ruby = build_workflow_ruby(name, lines)
55
63
  File.write(output_path, ruby) if output_path
64
+
65
+ ref_count = lines.count { |l| l[:cmd] == "_ref_interaction" }
66
+ if ref_count.positive?
67
+ warn "Warning: #{ref_count} ref-based interaction(s) were captured but cannot be replayed by ref."
68
+ warn "Search the generated workflow for 'TODO: ref-based' and replace with stable CSS selectors."
69
+ end
70
+
56
71
  ruby
57
72
  ensure
58
73
  FileUtils.rm_f(log) if log
@@ -65,6 +80,13 @@ module Browserctl
65
80
  File.join(RECORDINGS_DIR, "#{name}.jsonl")
66
81
  end
67
82
 
83
+ def record_ref_interaction(recording_name, cmd, attrs)
84
+ entry = { cmd: "_ref_interaction", action: cmd, ref: attrs[:ref], name: attrs[:name] }
85
+ File.open(log_path(recording_name), "a") do |f|
86
+ f.puts JSON.generate(entry)
87
+ end
88
+ end
89
+
68
90
  def build_workflow_ruby(name, commands)
69
91
  steps = commands.map { |c| build_step(c) }.join("\n\n")
70
92
  <<~RUBY
@@ -80,30 +102,74 @@ module Browserctl
80
102
 
81
103
  def build_step(cmd)
82
104
  label, body = step_parts(cmd)
83
- "step #{label.inspect} do\n #{body}\nend"
105
+
106
+ if body.nil?
107
+ page_sym = cmd[:name].to_s.gsub(/[^a-zA-Z0-9_]/, "_")
108
+ action = cmd[:action].to_s.gsub(/[^a-z_]/, "")
109
+ return "# TODO: ref-based #{action} on #{cmd[:name].inspect} (ref: #{cmd[:ref].inspect}) — " \
110
+ "replace with a stable CSS selector\n" \
111
+ "# step #{label.inspect} do\n" \
112
+ "# page(:#{page_sym}).#{action}(\"YOUR_SELECTOR_HERE\")\n" \
113
+ "# end"
114
+ end
115
+
116
+ url = cmd[:url].to_s
117
+ if url.include?("[REDACTED]")
118
+ "# NOTE: sensitive query params were redacted during recording\nstep #{label.inspect} do\n #{body}\nend"
119
+ else
120
+ "step #{label.inspect} do\n #{body}\nend"
121
+ end
84
122
  end
85
123
 
86
124
  def step_parts(cmd)
125
+ return ref_interaction_parts(cmd) if cmd[:cmd] == "_ref_interaction"
126
+ return selector_parts(cmd) if %w[fill click].include?(cmd[:cmd])
127
+
128
+ page = cmd[:name]
129
+ case cmd[:cmd]
130
+ when "open_page" then ["open #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
131
+ when "goto" then ["goto #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
132
+ when "screenshot" then ["screenshot #{page}", "page(:#{page}).screenshot"]
133
+ when "evaluate" then ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
134
+ else ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
135
+ end
136
+ end
137
+
138
+ def ref_interaction_parts(cmd)
139
+ ["TODO: ref-based #{cmd[:action]} on #{cmd[:name]} (ref: #{cmd[:ref]})", nil]
140
+ end
141
+
142
+ def selector_parts(cmd)
87
143
  page = cmd[:name]
88
144
  case cmd[:cmd]
89
- when "open_page"
90
- ["open #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
91
- when "goto"
92
- ["goto #{page}", "page(:#{page}).goto(#{cmd[:url].inspect})"]
93
145
  when "fill"
94
146
  ["fill #{cmd[:selector]} on #{page}",
95
147
  "page(:#{page}).fill(#{cmd[:selector].inspect}, params[:fill_value])"]
96
148
  when "click"
97
149
  ["click #{cmd[:selector]} on #{page}",
98
150
  "page(:#{page}).click(#{cmd[:selector].inspect})"]
99
- when "screenshot"
100
- ["screenshot #{page}", "page(:#{page}).screenshot"]
101
- when "evaluate"
102
- ["eval on #{page}", "page(:#{page}).evaluate(#{cmd[:expression].inspect})"]
103
- else
104
- ["#{cmd[:cmd]} on #{page}", "# unrecognised command: #{cmd.inspect}"]
105
151
  end
106
152
  end
153
+
154
+ def prepare_attrs(cmd, attrs)
155
+ attrs = attrs.except(:value) if cmd == "fill"
156
+ attrs[:url] = redact_url(attrs[:url]) if %w[goto open_page].include?(cmd) && attrs[:url]
157
+ attrs
158
+ end
159
+
160
+ def redact_url(url)
161
+ uri = URI.parse(url)
162
+ return url if uri.query.nil?
163
+
164
+ uri.query = uri.query.gsub(/([^&=]+)=([^&]*)/) do |full_match|
165
+ raw_key = ::Regexp.last_match(1)
166
+ key = URI.decode_www_form_component(raw_key)
167
+ key =~ SENSITIVE_PARAM_PATTERN ? "#{raw_key}=[REDACTED]" : full_match
168
+ end
169
+ uri.to_s
170
+ rescue URI::InvalidURIError
171
+ url
172
+ end
107
173
  end
108
174
  end
109
175
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "workflow"
4
5
  require_relative "client"
5
6
 
@@ -26,7 +27,7 @@ module Browserctl
26
27
  # @return [Array<Hash>] array of `{ name:, desc: }` hashes
27
28
  def list_workflows
28
29
  load_all_workflows
29
- REGISTRY.map { |name, defn| { name: name, desc: defn.description } }
30
+ Browserctl.registry_snapshot.map { |name, defn| { name: name, desc: defn.description } }
30
31
  end
31
32
 
32
33
  # Returns detailed information about a workflow.
@@ -39,6 +40,24 @@ module Browserctl
39
40
 
40
41
  SAFE_WORKFLOW_NAME = /\A[a-zA-Z0-9_-]+\z/
41
42
 
43
+ def self.load_params_file(path)
44
+ raise "params file not found: #{path}" unless File.exist?(path)
45
+
46
+ case File.extname(path).downcase
47
+ when ".yml", ".yaml"
48
+ require "yaml"
49
+ YAML.safe_load_file(path, symbolize_names: true)
50
+ when ".json"
51
+ JSON.parse(File.read(path), symbolize_names: true)
52
+ else
53
+ raise "unsupported params file format: #{path} (use .yml, .yaml, or .json)"
54
+ end
55
+ rescue Psych::SyntaxError => e
56
+ raise "invalid YAML in #{path}: #{e.message}"
57
+ rescue JSON::ParserError => e
58
+ raise "invalid JSON in #{path}: #{e.message}"
59
+ end
60
+
42
61
  private
43
62
 
44
63
  def validate_name!(name)
@@ -48,15 +67,15 @@ module Browserctl
48
67
  end
49
68
 
50
69
  def fetch_workflow(name)
51
- return REGISTRY[name.to_s] if REGISTRY.key?(name.to_s)
70
+ return Browserctl.lookup_workflow(name.to_s) if Browserctl.lookup_workflow(name.to_s)
52
71
 
53
72
  validate_name!(name)
54
73
  load_workflow_file(name)
55
- REGISTRY[name.to_s] || raise("workflow '#{name}' not found")
74
+ Browserctl.lookup_workflow(name.to_s) || raise("workflow '#{name}' not found")
56
75
  end
57
76
 
58
77
  def load_workflow_file(name)
59
- return if REGISTRY.key?(name.to_s)
78
+ return if Browserctl.lookup_workflow(name.to_s)
60
79
 
61
80
  path = workflow_path(name)
62
81
  load path if path
@@ -2,9 +2,26 @@
2
2
 
3
3
  require_relative "snapshot_builder"
4
4
  require_relative "page_session"
5
+ require_relative "handlers/page_lifecycle"
6
+ require_relative "handlers/navigation"
7
+ require_relative "handlers/observation"
8
+ require_relative "handlers/cookies"
9
+ require_relative "handlers/hitl"
10
+ require_relative "handlers/devtools"
11
+ require_relative "handlers/daemon_control"
12
+ require_relative "../detectors"
13
+ require_relative "../policy"
5
14
 
6
15
  module Browserctl
7
16
  class CommandDispatcher
17
+ include Handlers::PageLifecycle
18
+ include Handlers::Navigation
19
+ include Handlers::Observation
20
+ include Handlers::Cookies
21
+ include Handlers::Hitl
22
+ include Handlers::DevTools
23
+ include Handlers::DaemonControl
24
+
8
25
  COMMAND_MAP = {
9
26
  "open_page" => :cmd_open_page,
10
27
  "close_page" => :cmd_close_page,
@@ -25,25 +42,29 @@ module Browserctl
25
42
  "inspect" => :cmd_inspect,
26
43
  "cookies" => :cmd_cookies,
27
44
  "set_cookie" => :cmd_set_cookie,
28
- "clear_cookies" => :cmd_clear_cookies
45
+ "clear_cookies" => :cmd_clear_cookies,
46
+ "import_cookies" => :cmd_import_cookies,
47
+ "store" => :cmd_store,
48
+ "fetch" => :cmd_fetch
29
49
  }.freeze
30
50
 
31
- SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
32
- SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
33
- CLOUDFLARE_SIGNALS = [
34
- "cf-challenge-running",
35
- "cf_chl_opt",
36
- "__cf_chl_f_tk",
37
- "Just a moment..."
38
- ].freeze
51
+ SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
52
+ SCREENSHOT_ROOTS = [SCREENSHOT_DIR, File.expand_path(".")].freeze
53
+ SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
39
54
 
40
55
  def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
41
56
  @pages = pages
42
57
  @browser = browser
43
58
  @snapshot_builder = snapshot_builder
44
59
  @global_mutex = global_mutex
60
+ @kv_store = {}
61
+ @kv_mutex = Mutex.new
45
62
  end
46
63
 
64
+ # Dispatches a parsed request to the appropriate handler.
65
+ # Returns `{ error: String, code: String }` for unknown commands.
66
+ # @param req [Hash{Symbol => Object}] parsed request; must include `:cmd`
67
+ # @return [Hash{Symbol => Object}] response; always includes `:ok` or `:error`
47
68
  def dispatch(req)
48
69
  handler = COMMAND_MAP[req[:cmd]]
49
70
  if handler
@@ -51,7 +72,7 @@ module Browserctl
51
72
  return send(handler, req)
52
73
  end
53
74
 
54
- if (plugin = Browserctl::PLUGIN_COMMANDS[req[:cmd]])
75
+ if (plugin = Browserctl.lookup_plugin_command(req[:cmd]))
55
76
  Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
56
77
  session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
57
78
  return plugin.call(session, req)
@@ -62,216 +83,6 @@ module Browserctl
62
83
 
63
84
  private
64
85
 
65
- def cmd_open_page(req)
66
- session = @global_mutex.synchronize do
67
- @pages[req[:name]] ||= PageSession.new(@browser.create_page)
68
- end
69
- session.page.go_to(req[:url]) if req[:url]
70
- { ok: true, name: req[:name] }
71
- end
72
-
73
- def cmd_close_page(req)
74
- session = @global_mutex.synchronize { @pages.delete(req[:name]) }
75
- session&.page&.close
76
- { ok: true }
77
- end
78
-
79
- def cmd_list_pages(_req)
80
- { pages: @global_mutex.synchronize { @pages.keys } }
81
- end
82
-
83
- def cmd_goto(req)
84
- with_page(req[:name]) do |session|
85
- session.page.go_to(req[:url])
86
- { ok: true, url: session.page.current_url, challenge: cloudflare_challenge?(session.page) }
87
- end
88
- end
89
-
90
- def cmd_snapshot(req)
91
- with_page(req[:name]) { |session| take_snapshot(session, req[:format], req[:diff]) }
92
- end
93
-
94
- def take_snapshot(session, format, diff)
95
- challenge = cloudflare_challenge?(session.page)
96
-
97
- return { ok: true, html: session.page.body, challenge: challenge } unless format == "ai"
98
-
99
- snapshot = @snapshot_builder.call(session.page)
100
- registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
101
-
102
- prev = session.prev_snapshot
103
- session.ref_registry = registry
104
- session.prev_snapshot = snapshot
105
- result = diff && prev ? compute_diff(prev, snapshot) : snapshot
106
-
107
- { ok: true, snapshot: result, challenge: challenge }
108
- end
109
-
110
- def compute_diff(prev, current)
111
- prev_by_sel = prev.to_h { |el| [el[:selector], el] }
112
- current.reject do |el|
113
- old = prev_by_sel[el[:selector]]
114
- old && old.slice(:text, :attrs) == el.slice(:text, :attrs)
115
- end
116
- end
117
-
118
- def cmd_evaluate(req)
119
- with_page(req[:name]) { |session| { ok: true, result: session.page.evaluate(req[:expression]) } }
120
- end
121
-
122
- def cmd_fill(req)
123
- with_page(req[:name]) do |session|
124
- sel = resolve_selector_from(session, req)
125
- return sel if sel.is_a?(Hash)
126
-
127
- type_into(session.page, sel, req[:value])
128
- end
129
- end
130
-
131
- def type_into(page, selector, value)
132
- el = page.at_css(selector)
133
- return { error: "selector not found: #{selector}" } unless el
134
-
135
- el.focus
136
- el.type(value)
137
- { ok: true }
138
- end
139
-
140
- def cmd_click(req)
141
- with_page(req[:name]) do |session|
142
- sel = resolve_selector_from(session, req)
143
- return sel if sel.is_a?(Hash)
144
-
145
- click_element(session.page, sel)
146
- end
147
- end
148
-
149
- def click_element(page, selector)
150
- el = page.at_css(selector)
151
- return { error: "selector not found: #{selector}" } unless el
152
-
153
- el.click
154
- { ok: true }
155
- end
156
-
157
- def cmd_screenshot(req)
158
- with_page(req[:name]) do |session|
159
- path = safe_screenshot_path(req[:path], req[:name])
160
- return path if path.is_a?(Hash)
161
-
162
- FileUtils.mkdir_p(File.dirname(path))
163
- session.page.screenshot(path: path, full: req.fetch(:full, false))
164
- { ok: true, path: path }
165
- end
166
- end
167
-
168
- def safe_screenshot_path(requested, page_name)
169
- if requested
170
- expanded = File.expand_path(requested)
171
- return { error: "path outside allowed directory (#{SCREENSHOT_DIR})" } \
172
- unless expanded.start_with?(SCREENSHOT_DIR)
173
- return { error: "invalid extension — use .png, .jpg, or .jpeg" } \
174
- unless SCREENSHOT_EXTS.include?(File.extname(expanded).downcase)
175
-
176
- expanded
177
- else
178
- name_safe = page_name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
179
- File.join(SCREENSHOT_DIR, "browserctl_shot_#{name_safe}_#{Time.now.to_i}.png")
180
- end
181
- end
182
-
183
- def cmd_wait_for(req)
184
- with_page(req[:name]) { |session| wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 10).to_f) }
185
- end
186
-
187
- def cmd_watch(req)
188
- with_page(req[:name]) do |session|
189
- result = wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 30).to_f)
190
- result[:error] ? result : { ok: true, selector: req[:selector] }
191
- end
192
- end
193
-
194
- def wait_for_selector(page, selector, timeout)
195
- deadline = Time.now + timeout
196
- loop do
197
- found = page.at_css(selector)
198
- break { ok: true } if found
199
- break { error: "wait_for timeout: selector '#{selector}' not found after #{timeout}s" } if Time.now >= deadline
200
-
201
- sleep 0.2
202
- end
203
- end
204
-
205
- def cmd_url(req)
206
- with_page(req[:name]) { |session| { ok: true, url: session.page.current_url } }
207
- end
208
-
209
- def cmd_cookies(req)
210
- session = @global_mutex.synchronize { @pages[req[:name]] }
211
- return { error: "no page named '#{req[:name]}'" } unless session
212
-
213
- all = session.page.cookies.all
214
- { ok: true, cookies: all.values.map(&:to_h) }
215
- end
216
-
217
- def cmd_set_cookie(req)
218
- session = @global_mutex.synchronize { @pages[req[:name]] }
219
- return { error: "no page named '#{req[:name]}'" } unless session
220
-
221
- session.page.cookies.set(
222
- name: req[:cookie_name],
223
- value: req[:value],
224
- domain: req[:domain],
225
- path: req.fetch(:path, "/")
226
- )
227
- { ok: true }
228
- end
229
-
230
- def cmd_clear_cookies(req)
231
- session = @global_mutex.synchronize { @pages[req[:name]] }
232
- return { error: "no page named '#{req[:name]}'" } unless session
233
-
234
- session.page.cookies.clear
235
- { ok: true }
236
- end
237
-
238
- def cmd_inspect(req)
239
- session = @global_mutex.synchronize { @pages[req[:name]] }
240
- return { error: "no page named '#{req[:name]}'" } unless session
241
-
242
- port = @browser.process.port
243
- target_id = session.page.target_id
244
- devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
245
- "?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
246
- { ok: true, devtools_url: devtools_url }
247
- end
248
-
249
- def cmd_pause(req)
250
- session = @global_mutex.synchronize { @pages[req[:name]] }
251
- return { error: "no page named '#{req[:name]}'" } unless session
252
-
253
- session.mutex.synchronize { session.pause! }
254
- { ok: true, paused: true }
255
- end
256
-
257
- def cmd_resume(req)
258
- session = @global_mutex.synchronize { @pages[req[:name]] }
259
- return { error: "no page named '#{req[:name]}'" } unless session
260
-
261
- session.mutex.synchronize do
262
- session.resume!
263
- session.pause_cv.signal
264
- end
265
- { ok: true, paused: false }
266
- end
267
-
268
- def cmd_ping(_req) = { ok: true, pid: Process.pid }
269
-
270
- def cmd_shutdown(_req)
271
- Process.kill("INT", Process.pid)
272
- { ok: true }
273
- end
274
-
275
86
  def with_page(name)
276
87
  session = @global_mutex.synchronize { @pages[name] }
277
88
  return { error: "no page named '#{name}'" } unless session
@@ -281,19 +92,5 @@ module Browserctl
281
92
  yield session
282
93
  end
283
94
  end
284
-
285
- def cloudflare_challenge?(page)
286
- url = page.current_url.to_s
287
- body = page.body.to_s
288
- url.include?("challenge-platform") ||
289
- CLOUDFLARE_SIGNALS.any? { |sig| body.include?(sig) }
290
- end
291
-
292
- def resolve_selector_from(session, req)
293
- return req[:selector] if req[:selector]
294
- return { error: "selector or ref required" } unless req[:ref]
295
-
296
- session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
297
- end
298
95
  end
299
96
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class CommandDispatcher
5
+ module Handlers
6
+ module Cookies
7
+ private
8
+
9
+ def cmd_cookies(req)
10
+ session = @global_mutex.synchronize { @pages[req[:name]] }
11
+ return { error: "no page named '#{req[:name]}'" } unless session
12
+
13
+ all = session.page.cookies.all
14
+ { ok: true, cookies: all.values.map(&:to_h) }
15
+ end
16
+
17
+ def cmd_set_cookie(req)
18
+ session = @global_mutex.synchronize { @pages[req[:name]] }
19
+ return { error: "no page named '#{req[:name]}'" } unless session
20
+
21
+ session.page.cookies.set(
22
+ name: req[:cookie_name],
23
+ value: req[:value],
24
+ domain: req[:domain],
25
+ path: req.fetch(:path, "/")
26
+ )
27
+ { ok: true }
28
+ end
29
+
30
+ def cmd_clear_cookies(req)
31
+ session = @global_mutex.synchronize { @pages[req[:name]] }
32
+ return { error: "no page named '#{req[:name]}'" } unless session
33
+
34
+ session.page.cookies.clear
35
+ { ok: true }
36
+ end
37
+
38
+ def cmd_import_cookies(req)
39
+ with_page(req[:name]) do |session|
40
+ req[:cookies].each do |c|
41
+ session.page.cookies.set(
42
+ name: c[:name],
43
+ value: c[:value],
44
+ domain: c[:domain],
45
+ path: c.fetch(:path, "/"),
46
+ httponly: c[:httpOnly] == true,
47
+ secure: c[:secure] == true,
48
+ expires: c[:expires] ? Time.at(c[:expires].to_i) : nil
49
+ )
50
+ end
51
+ { ok: true, count: req[:cookies].length }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class CommandDispatcher
5
+ module Handlers
6
+ module DaemonControl
7
+ private
8
+
9
+ def cmd_ping(_req) = { ok: true, pid: Process.pid, protocol_version: PROTOCOL_VERSION }
10
+
11
+ def cmd_shutdown(_req)
12
+ Process.kill("INT", Process.pid)
13
+ { ok: true }
14
+ end
15
+
16
+ def cmd_store(req)
17
+ @kv_mutex.synchronize { @kv_store[req[:key].to_s] = req[:value] }
18
+ { ok: true }
19
+ end
20
+
21
+ def cmd_fetch(req)
22
+ key = req[:key].to_s
23
+ found = @kv_mutex.synchronize { @kv_store.key?(key) ? { ok: true, value: @kv_store[key] } : nil }
24
+ found || { error: "key '#{key}' not found", code: "key_not_found" }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class CommandDispatcher
5
+ module Handlers
6
+ module DevTools
7
+ private
8
+
9
+ def cmd_inspect(req)
10
+ session = @global_mutex.synchronize { @pages[req[:name]] }
11
+ return { error: "no page named '#{req[:name]}'" } unless session
12
+
13
+ port = @browser.process.port
14
+ target_id = session.page.target_id
15
+ devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
16
+ "?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
17
+ { ok: true, devtools_url: devtools_url }
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class CommandDispatcher
5
+ module Handlers
6
+ module Hitl
7
+ private
8
+
9
+ def cmd_pause(req)
10
+ session = @global_mutex.synchronize { @pages[req[:name]] }
11
+ return { error: "no page named '#{req[:name]}'" } unless session
12
+
13
+ session.mutex.synchronize { session.pause! }
14
+ { ok: true, paused: true }
15
+ end
16
+
17
+ def cmd_resume(req)
18
+ session = @global_mutex.synchronize { @pages[req[:name]] }
19
+ return { error: "no page named '#{req[:name]}'" } unless session
20
+
21
+ session.mutex.synchronize do
22
+ session.resume!
23
+ session.pause_cv.signal
24
+ end
25
+ { ok: true, paused: false }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end