browserctl 0.4.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: aa3b71160a355802bb8e1fd6667506af34dc354c97b60eb2e82e0b418c02dbe6
4
- data.tar.gz: 83da78edf2d59d84be5a6fd617e5b2388928c77b005d7735f466ce4aad19699b
3
+ metadata.gz: ec75744264ce56f8c3f94ab95518a106c8225ec1dc11dd35a97f43186b590502
4
+ data.tar.gz: 973c3b270b5d1bba3dfa900623790e3c5cb9d5bd8ebe8faa50c28d09e48fbfff
5
5
  SHA512:
6
- metadata.gz: 1ff6294fbc0284a342d9d2377077a02f004aec2acfeb3c1908039b09753d203e8a641d1dd2cd8ab2fd1fc607c42af3aa86537fb47361bb6820cf9a04fa8d6159
7
- data.tar.gz: d029df7a2d280172543cb52dcee92326c97464d3b8d750d2f718710d46db16402b8e1a235c83c5da229a2ae047a4ab92738c3d736f741a4964c1d324f7871c15
6
+ metadata.gz: 16cd84c58a070de0b9d340ed2bb4e3a951216526dd77a9f18e0f9ea7292311eb51eddf1989c93c11d488cf4bf29163a60a13510f5949599d6027142cf808c1a0
7
+ data.tar.gz: 615213ac9ba1e694c9fb4597a348abebab9d8f2ba04801dd7d59c1dc00829111f0cce9f49391a7883e7502422ed0b246e86d8c553c62b9be406e0f1e23946e8c
data/CHANGELOG.md CHANGED
@@ -10,6 +10,27 @@ All notable changes to this project will be documented in this file.
10
10
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
11
11
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
12
 
13
+ ## [0.5.0](https://github.com/patrick204nqh/browserctl/compare/v0.4.0...v0.5.0) (2026-04-25)
14
+
15
+
16
+ ### Features
17
+
18
+ * add cookie export/import commands and refine interaction guidance ([1dc8b2c](https://github.com/patrick204nqh/browserctl/commit/1dc8b2c4c744a5f0930c28d3bcf93fd017c368b1))
19
+ * rename snapshot format 'ai' to 'elements' ([#22](https://github.com/patrick204nqh/browserctl/issues/22)) ([9fde6af](https://github.com/patrick204nqh/browserctl/commit/9fde6afb9e53b9556c84b4c24987777a5c266adf))
20
+ * v0.5 architecture & protocol lock ([#20](https://github.com/patrick204nqh/browserctl/issues/20)) ([1224f2f](https://github.com/patrick204nqh/browserctl/commit/1224f2fe2fb05119053831e7901b286fe93ad4fc))
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * add rake as development dependency ([13902d8](https://github.com/patrick204nqh/browserctl/commit/13902d81fdb986c6ca754fcb16ce61ee850e27ce))
26
+ * improve browser GIF quality and fix terminal font rendering ([17afdb2](https://github.com/patrick204nqh/browserctl/commit/17afdb210916d8ebdabd237130da1fc534cdbd3e))
27
+ * open PR for demo assets instead of pushing directly to main ([f051316](https://github.com/patrick204nqh/browserctl/commit/f051316962d308419c08f60550cfb9910b148f14))
28
+ * quote filtergraph and add -update 1 for palette in browser GIF pipeline ([053d074](https://github.com/patrick204nqh/browserctl/commit/053d074b5fdd3e0ea6fc51589593d72d5f7fcd74))
29
+ * replace undefined REGISTRY constant with Browserctl.registry_snapshot ([d19a6bf](https://github.com/patrick204nqh/browserctl/commit/d19a6bf7ded8a5211f5b2a75511a9532f94e3845))
30
+ * update README for improved clarity and add Quick Start section ([881d914](https://github.com/patrick204nqh/browserctl/commit/881d914c2bf46625ee4ff5c1ca8ef21b462c533d))
31
+ * use app-slug output instead of gh api /app in assets workflow ([ae6a0f8](https://github.com/patrick204nqh/browserctl/commit/ae6a0f80ba2b66c76d681b1f258de5d72bc34c72))
32
+ * use CSS selectors for browser GIF and add full login flow frames ([717b86b](https://github.com/patrick204nqh/browserctl/commit/717b86befa9af6bab1823f984668ebdc29f2d7f8))
33
+
13
34
  ## [0.4.0](https://github.com/patrick204nqh/browserctl/compare/v0.3.1...v0.4.0) (2026-04-25)
14
35
 
15
36
 
data/README.md CHANGED
@@ -2,15 +2,21 @@
2
2
  <img src=".github/logo.svg" width="96" height="96" alt="browserctl logo"/>
3
3
  </p>
4
4
 
5
- # browserctl
5
+ <h1 align="center">browserctl</h1>
6
6
 
7
- [![CI](https://github.com/patrick204nqh/browserctl/actions/workflows/ci.yml/badge.svg)](https://github.com/patrick204nqh/browserctl/actions/workflows/ci.yml)
8
- [![Gem Version](https://badge.fury.io/rb/browserctl.svg)](https://badge.fury.io/rb/browserctl)
9
- [![Downloads](https://img.shields.io/gem/dt/browserctl)](https://rubygems.org/gems/browserctl)
7
+ <p align="center">
8
+ A persistent browser daemon for AI agents and iterative dev workflows — the session stays alive between commands.
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://github.com/patrick204nqh/browserctl/actions/workflows/ci.yml"><img src="https://github.com/patrick204nqh/browserctl/actions/workflows/ci.yml/badge.svg" alt="CI"/></a>
13
+ <a href="https://badge.fury.io/rb/browserctl"><img src="https://badge.fury.io/rb/browserctl.svg" alt="Gem Version"/></a>
14
+ <a href="https://rubygems.org/gems/browserctl"><img src="https://img.shields.io/gem/dt/browserctl" alt="Downloads"/></a>
15
+ </p>
10
16
 
11
- A persistent browser automation daemon and CLI, purpose-built for AI agents and developer workflows.
17
+ ---
12
18
 
13
- Unlike tools that restart the browser on every script run, **browserctl keeps a named browser session alive** preserving cookies, localStorage, open tabs, and page state across discrete commands.
19
+ Every browser automation tool restarts the browser when your script ends. That means re-authenticating, re-navigating, re-loading state — on every run. browserctl doesn't restart. The session stays alive between commands, so you pick up exactly where you left off.
14
20
 
15
21
  ```bash
16
22
  browserd & # start the daemon (headless)
@@ -21,8 +27,79 @@ browserctl click login --ref e2
21
27
  browserctl shutdown
22
28
  ```
23
29
 
24
- ![browserctl capturing a login flow](docs/screenshots/the_internet_login.png)
25
- <p align="center"><sub>Login flow captured with <code>browserctl shot</code></sub></p>
30
+ ---
31
+
32
+ ## See it in action
33
+
34
+ <table align="center"><tr>
35
+ <td align="center" width="50%">
36
+
37
+ **Terminal**<br/>
38
+ <sub>CLI commands, live output, session persistence proof</sub>
39
+
40
+ <img src="docs/assets/terminal.webp" alt="browserctl terminal demo"/>
41
+
42
+ </td>
43
+ <td align="center" width="50%">
44
+
45
+ **Browser**<br/>
46
+ <sub>What the browser sees as those commands run</sub>
47
+
48
+ <img src="docs/assets/browser_demo.gif" alt="browserctl browser demo"/>
49
+
50
+ </td>
51
+ </tr></table>
52
+
53
+ > Demo assets are regenerated automatically on every push to `main` that touches `demo/` or the login example. To regenerate locally:
54
+ >
55
+ > ```bash
56
+ > rake demo # full pipeline: screenshots + browser GIF + terminal GIF
57
+ > rake demo:screenshots # smoke test screenshots only
58
+ > rake demo:browser_gif # browser animation only (requires: ffmpeg)
59
+ > rake demo:terminal # terminal GIF only (requires: vhs)
60
+ > ```
61
+
62
+ ---
63
+
64
+ ## Quick Start
65
+
66
+ ```bash
67
+ # 1. Install
68
+ gem install browserctl
69
+
70
+ # 2. Start the daemon
71
+ browserd &
72
+
73
+ # 3. Open a named page
74
+ browserctl open main --url https://the-internet.herokuapp.com/login
75
+
76
+ # 4. Snapshot the page — get AI-friendly JSON with ref IDs
77
+ browserctl snap main
78
+
79
+ # 5. Interact using refs
80
+ browserctl fill main --ref e1 --value tomsmith
81
+ browserctl fill main --ref e2 --value SuperSecretPassword!
82
+ browserctl click main --ref e3
83
+
84
+ # 6. Observe
85
+ browserctl url main
86
+ browserctl snap main --diff # only what changed
87
+
88
+ # 7. Done
89
+ browserctl shutdown
90
+ ```
91
+
92
+ → [Full Getting Started guide](docs/getting-started.md)
93
+
94
+ ---
95
+
96
+ ## Use cases
97
+
98
+ **AI coding agent authenticating into a staging environment** — the agent logs in once, the session persists, subsequent commands run inside the authenticated context without re-authenticating between steps.
99
+
100
+ **Developer reproducing a multi-step bug report** — navigate to the failure point once, then iterate on the fix with the browser already in the right state; no restarting from the home page each run.
101
+
102
+ **Automated smoke test that needs human sign-off** — the test runs until it hits something ambiguous, calls `browserctl pause`, lets a human inspect and act, then `browserctl resume` hands control back to the script with all state intact.
26
103
 
27
104
  ---
28
105
 
@@ -46,15 +123,10 @@ Most automation tools are stateless — every script spins up a fresh browser an
46
123
 
47
124
  ---
48
125
 
49
- ## Requirements
50
-
51
- - Ruby >= 3.3
52
- - Chrome or Chromium on your `PATH`
53
-
54
- ---
55
-
56
126
  ## Installation
57
127
 
128
+ **Requirements:** Ruby >= 3.3 · Chrome or Chromium on your `PATH`
129
+
58
130
  ```bash
59
131
  gem install browserctl
60
132
  ```
@@ -71,14 +143,14 @@ gem "browserctl"
71
143
 
72
144
  browserctl ships as a Claude Code plugin. Install it once and Claude automatically knows how to use the daemon, ref-based interaction, HITL patterns, and workflow authoring.
73
145
 
74
- **Install (interactive)**
146
+ **Interactive install**
75
147
 
76
148
  ```
77
149
  /plugin marketplace add patrick204nqh/browserctl
78
150
  /plugin install browserctl@browserctl
79
151
  ```
80
152
 
81
- **Install (project settings** — commit `.claude/settings.json` to share with your team)
153
+ **Project settings** — commit `.claude/settings.json` to share with your team:
82
154
 
83
155
  ```json
84
156
  {
@@ -97,35 +169,6 @@ Once installed, Claude Code loads the `browserctl` skill automatically — no `/
97
169
 
98
170
  ---
99
171
 
100
- ## Quick Start
101
-
102
- ```bash
103
- # 1. Start the daemon
104
- browserd &
105
-
106
- # 2. Open a named page
107
- browserctl open main --url https://the-internet.herokuapp.com/login
108
-
109
- # 3. Snapshot the page — get AI-friendly JSON with ref IDs
110
- browserctl snap main
111
-
112
- # 4. Interact using refs
113
- browserctl fill main --ref e1 --value tomsmith
114
- browserctl fill main --ref e2 --value SuperSecretPassword!
115
- browserctl click main --ref e3
116
-
117
- # 5. Observe
118
- browserctl url main
119
- browserctl snap main --diff # only what changed
120
-
121
- # 6. Done
122
- browserctl shutdown
123
- ```
124
-
125
- → [Full Getting Started guide](docs/getting-started.md)
126
-
127
- ---
128
-
129
172
  ## How it works
130
173
 
131
174
  `browserd` runs as a background process, listening on a Unix socket at `~/.browserctl/browserd.sock`. It manages a Ferrum (Chrome DevTools Protocol) browser instance with named page handles. `browserctl` sends JSON-RPC commands over the socket and prints the result.
@@ -162,10 +205,14 @@ The daemon shuts itself down after 30 minutes of inactivity.
162
205
  ```bash
163
206
  git clone https://github.com/patrick204nqh/browserctl
164
207
  cd browserctl
165
- bin/setup # install deps + check for Chrome
208
+ bin/setup # brew bundle (macOS) + bundle install + Chrome check
166
209
 
167
210
  bundle exec rspec # run tests
168
211
  bundle exec rubocop # lint
212
+
213
+ rake demo # regenerate screenshots + terminal GIF
214
+ rake demo:screenshots # screenshots only (no VHS required)
215
+ rake demo:terminal # terminal GIF only
169
216
  ```
170
217
 
171
218
  ---
data/bin/browserctl CHANGED
@@ -108,9 +108,9 @@ case cmd
108
108
  when "run"
109
109
  name = args.shift or abort "usage: browserctl run <workflow_name|file.rb> [--key value ...]"
110
110
  if File.exist?(name)
111
- before = Browserctl::REGISTRY.keys.dup
111
+ before = Browserctl.registry_snapshot.keys
112
112
  load File.expand_path(name)
113
- name = (Browserctl::REGISTRY.keys - before).first || File.basename(name, ".rb")
113
+ name = (Browserctl.registry_snapshot.keys - before).first || File.basename(name, ".rb")
114
114
  end
115
115
  params_file_idx = args.index("--params")
116
116
  file_params = {}
data/bin/setup CHANGED
@@ -1,14 +1,18 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
3
 
4
- echo "==> Installing dependencies..."
4
+ if [[ "$(uname)" == "Darwin" ]] && command -v brew &>/dev/null; then
5
+ echo "==> Installing Homebrew dependencies (Brewfile)..."
6
+ brew bundle --no-upgrade
7
+ fi
8
+
9
+ echo "==> Installing gem dependencies..."
5
10
  bundle install
6
11
 
7
12
  echo "==> Checking for Chrome/Chromium..."
8
13
  if ! command -v google-chrome &>/dev/null && ! command -v chromium-browser &>/dev/null && ! command -v chromium &>/dev/null; then
9
14
  echo "WARNING: Chrome/Chromium not found on PATH."
10
- echo " Install it before running browserctl."
11
- echo " macOS: brew install --cask google-chrome"
15
+ echo " macOS: brew bundle (includes google-chrome)"
12
16
  echo " Ubuntu: sudo apt-get install -y chromium-browser"
13
17
  fi
14
18
 
@@ -55,7 +55,7 @@ Browserctl.workflow "cloudflare_hitl" do
55
55
 
56
56
  step "wait for content and snapshot" do
57
57
  page(:main).wait_for(selector, timeout: 15)
58
- result = page(:main).snapshot(format: "ai")
58
+ result = page(:main).snapshot(format: "elements")
59
59
  $stdout.puts " Snapshot: #{result[:snapshot]&.length || 0} elements captured"
60
60
  end
61
61
 
@@ -74,10 +74,10 @@ module Browserctl
74
74
 
75
75
  # Takes a DOM snapshot. Returns `challenge: true` when Cloudflare is detected.
76
76
  # @param name [String] logical page name
77
- # @param format [String] "ai" (token-efficient JSON) or "html" (raw HTML)
77
+ # @param format [String] "elements" (interactable elements JSON) or "html" (raw HTML)
78
78
  # @param diff [Boolean] return only elements changed since last snapshot
79
79
  # @return [Hash] `{ ok: true, snapshot:, challenge: }` or `{ ok: true, html:, challenge: }` or `{ error: }`
80
- def snapshot(name, format: "ai", diff: false)
80
+ def snapshot(name, format: "elements", diff: false)
81
81
  call("snapshot", name: name, format: format, diff: diff)
82
82
  end
83
83
 
@@ -131,6 +131,17 @@ module Browserctl
131
131
  # @return [Hash] `{ ok: true, devtools_url: }` or `{ error: }`
132
132
  def inspect_page(name) = call("inspect", name: name)
133
133
 
134
+ # Stores a value in the daemon-scoped key-value store.
135
+ # @param key [String] storage key
136
+ # @param value [Object] value to store (must be JSON-serialisable)
137
+ # @return [Hash] `{ ok: true }` or `{ error: }`
138
+ def store(key, value) = call("store", key: key, value: value)
139
+
140
+ # Retrieves a value from the daemon-scoped key-value store.
141
+ # @param key [String] storage key
142
+ # @return [Hash] `{ ok: true, value: }` or `{ error:, code: "key_not_found" }`
143
+ def fetch(key) = call("fetch", key: key)
144
+
134
145
  # Returns all cookies for a named page.
135
146
  # @param name [String] logical page name
136
147
  # @return [Hash] `{ ok: true, cookies: [Hash] }` or `{ error: }`
@@ -6,15 +6,15 @@ require "optimist"
6
6
  module Browserctl
7
7
  module Commands
8
8
  class Snapshot
9
- VALID_FORMATS = %w[ai html].freeze
9
+ VALID_FORMATS = %w[elements html].freeze
10
10
 
11
11
  def self.run(client, args)
12
12
  opts = Optimist.options(args) do
13
- banner "Usage: browserctl snap <page> [--format ai|html] [--diff]"
14
- opt :format, "Output format: ai or html", default: "ai", short: "-f"
13
+ banner "Usage: browserctl snap <page> [--format elements|html] [--diff]"
14
+ opt :format, "Output format: elements (default) or html", default: "elements", short: "-f"
15
15
  opt :diff, "Return only changed elements", default: false, short: "-d"
16
16
  end
17
- name = args.shift or abort "usage: browserctl snap <page> [--format ai|html] [--diff]"
17
+ name = args.shift or abort "usage: browserctl snap <page> [--format elements|html] [--diff]"
18
18
  unless VALID_FORMATS.include?(opts[:format])
19
19
  warn "Error: --format must be one of: #{VALID_FORMATS.join(', ')}"
20
20
  exit 1
@@ -31,7 +31,7 @@ module Browserctl
31
31
  warn "Error: #{res[:error]}"
32
32
  exit 1
33
33
  end
34
- puts(format == "ai" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
34
+ puts(format == "elements" ? JSON.pretty_generate(res[:snapshot]) : res[:html])
35
35
  end
36
36
  end
37
37
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ module Detectors
5
+ CLOUDFLARE_SIGNALS = [
6
+ "cf-challenge-running",
7
+ "cf_chl_opt",
8
+ "__cf_chl_f_tk",
9
+ "Just a moment..."
10
+ ].freeze
11
+
12
+ # Returns true if the page appears to be showing a Cloudflare challenge.
13
+ # Checks both the current URL and the page body for known Cloudflare signals.
14
+ # @param page [Ferrum::Page] the browser page to inspect
15
+ # @return [Boolean]
16
+ def self.cloudflare?(page)
17
+ url = page.current_url.to_s
18
+ body = page.body.to_s
19
+ url.include?("challenge-platform") ||
20
+ CLOUDFLARE_SIGNALS.any? { |sig| body.include?(sig) }
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ # Base error class for all browserctl daemon errors.
5
+ # Subclasses carry a machine-readable `code` that appears in wire responses.
6
+ # @attr_reader code [String] machine-readable error code
7
+ class Error < StandardError
8
+ def self.default_code = "error"
9
+
10
+ attr_reader :code
11
+
12
+ def initialize(msg = nil, code: self.class.default_code)
13
+ @code = code
14
+ super(msg)
15
+ end
16
+ end
17
+
18
+ class PageNotFound < Error; def self.default_code = "page_not_found" end
19
+ class SelectorNotFound < Error; def self.default_code = "selector_not_found" end
20
+ class RefNotFound < Error; def self.default_code = "ref_not_found" end
21
+ class PathNotAllowed < Error; def self.default_code = "path_not_allowed" end
22
+ class DomainNotAllowed < Error; def self.default_code = "domain_not_allowed" end
23
+ class TimeoutError < Error; def self.default_code = "timeout" end
24
+ class KeyNotFound < Error; def self.default_code = "key_not_found" end
25
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Browserctl
6
+ module Policy
7
+ # Returns true if the URL is permitted by the domain policy.
8
+ # When BROWSERCTL_ALLOWED_DOMAINS is unset, all URLs are allowed.
9
+ # @param url [String] the URL to check
10
+ # @return [Boolean]
11
+ def self.allowed_navigation?(url)
12
+ domains = allowed_domains
13
+ return true if domains.empty?
14
+
15
+ host_matches?(URI.parse(url).host, domains)
16
+ rescue URI::InvalidURIError
17
+ false
18
+ end
19
+
20
+ def self.allowed_domains
21
+ raw = ENV.fetch("BROWSERCTL_ALLOWED_DOMAINS", nil)
22
+ return [] unless raw&.match?(/\S/)
23
+
24
+ raw.split(",").map(&:strip).reject(&:empty?)
25
+ end
26
+
27
+ def self.host_matches?(host, domains)
28
+ return false unless host
29
+
30
+ normalised = host.downcase
31
+ domains.any? { |d| normalised == d.downcase || normalised.end_with?(".#{d.downcase}") }
32
+ end
33
+
34
+ private_class_method :allowed_domains, :host_matches?
35
+ end
36
+ end
@@ -27,7 +27,7 @@ module Browserctl
27
27
  # @return [Array<Hash>] array of `{ name:, desc: }` hashes
28
28
  def list_workflows
29
29
  load_all_workflows
30
- REGISTRY.map { |name, defn| { name: name, desc: defn.description } }
30
+ Browserctl.registry_snapshot.map { |name, defn| { name: name, desc: defn.description } }
31
31
  end
32
32
 
33
33
  # Returns detailed information about a workflow.
@@ -67,15 +67,15 @@ module Browserctl
67
67
  end
68
68
 
69
69
  def fetch_workflow(name)
70
- 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)
71
71
 
72
72
  validate_name!(name)
73
73
  load_workflow_file(name)
74
- REGISTRY[name.to_s] || raise("workflow '#{name}' not found")
74
+ Browserctl.lookup_workflow(name.to_s) || raise("workflow '#{name}' not found")
75
75
  end
76
76
 
77
77
  def load_workflow_file(name)
78
- return if REGISTRY.key?(name.to_s)
78
+ return if Browserctl.lookup_workflow(name.to_s)
79
79
 
80
80
  path = workflow_path(name)
81
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,
@@ -26,26 +43,28 @@ module Browserctl
26
43
  "cookies" => :cmd_cookies,
27
44
  "set_cookie" => :cmd_set_cookie,
28
45
  "clear_cookies" => :cmd_clear_cookies,
29
- "import_cookies" => :cmd_import_cookies
46
+ "import_cookies" => :cmd_import_cookies,
47
+ "store" => :cmd_store,
48
+ "fetch" => :cmd_fetch
30
49
  }.freeze
31
50
 
32
51
  SCREENSHOT_DIR = File.expand_path("~/.browserctl/screenshots").freeze
33
52
  SCREENSHOT_ROOTS = [SCREENSHOT_DIR, File.expand_path(".")].freeze
34
- SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
35
- CLOUDFLARE_SIGNALS = [
36
- "cf-challenge-running",
37
- "cf_chl_opt",
38
- "__cf_chl_f_tk",
39
- "Just a moment..."
40
- ].freeze
53
+ SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
41
54
 
42
55
  def initialize(pages, browser, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
43
56
  @pages = pages
44
57
  @browser = browser
45
58
  @snapshot_builder = snapshot_builder
46
59
  @global_mutex = global_mutex
60
+ @kv_store = {}
61
+ @kv_mutex = Mutex.new
47
62
  end
48
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`
49
68
  def dispatch(req)
50
69
  handler = COMMAND_MAP[req[:cmd]]
51
70
  if handler
@@ -53,7 +72,7 @@ module Browserctl
53
72
  return send(handler, req)
54
73
  end
55
74
 
56
- if (plugin = Browserctl::PLUGIN_COMMANDS[req[:cmd]])
75
+ if (plugin = Browserctl.lookup_plugin_command(req[:cmd]))
57
76
  Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
58
77
  session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
59
78
  return plugin.call(session, req)
@@ -64,233 +83,6 @@ module Browserctl
64
83
 
65
84
  private
66
85
 
67
- def cmd_open_page(req)
68
- session = @global_mutex.synchronize do
69
- @pages[req[:name]] ||= PageSession.new(@browser.create_page)
70
- end
71
- session.page.go_to(req[:url]) if req[:url]
72
- { ok: true, name: req[:name] }
73
- end
74
-
75
- def cmd_close_page(req)
76
- session = @global_mutex.synchronize { @pages.delete(req[:name]) }
77
- session&.page&.close
78
- { ok: true }
79
- end
80
-
81
- def cmd_list_pages(_req)
82
- { pages: @global_mutex.synchronize { @pages.keys } }
83
- end
84
-
85
- def cmd_goto(req)
86
- with_page(req[:name]) do |session|
87
- session.page.go_to(req[:url])
88
- { ok: true, url: session.page.current_url, challenge: cloudflare_challenge?(session.page) }
89
- end
90
- end
91
-
92
- def cmd_snapshot(req)
93
- with_page(req[:name]) { |session| take_snapshot(session, req[:format], req[:diff]) }
94
- end
95
-
96
- def take_snapshot(session, format, diff)
97
- challenge = cloudflare_challenge?(session.page)
98
-
99
- return { ok: true, html: session.page.body, challenge: challenge } unless format == "ai"
100
-
101
- snapshot = @snapshot_builder.call(session.page)
102
- registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
103
-
104
- prev = session.prev_snapshot
105
- session.ref_registry = registry
106
- session.prev_snapshot = snapshot
107
- result = diff && prev ? compute_diff(prev, snapshot) : snapshot
108
-
109
- { ok: true, snapshot: result, challenge: challenge }
110
- end
111
-
112
- def compute_diff(prev, current)
113
- prev_by_sel = prev.to_h { |el| [el[:selector], el] }
114
- current.reject do |el|
115
- old = prev_by_sel[el[:selector]]
116
- old && old.slice(:text, :attrs) == el.slice(:text, :attrs)
117
- end
118
- end
119
-
120
- def cmd_evaluate(req)
121
- with_page(req[:name]) { |session| { ok: true, result: session.page.evaluate(req[:expression]) } }
122
- end
123
-
124
- def cmd_fill(req)
125
- with_page(req[:name]) do |session|
126
- sel = resolve_selector_from(session, req)
127
- return sel if sel.is_a?(Hash)
128
-
129
- type_into(session.page, sel, req[:value])
130
- end
131
- end
132
-
133
- def type_into(page, selector, value)
134
- el = page.at_css(selector)
135
- return { error: "selector not found: #{selector}" } unless el
136
-
137
- el.focus
138
- el.type(value)
139
- { ok: true }
140
- end
141
-
142
- def cmd_click(req)
143
- with_page(req[:name]) do |session|
144
- sel = resolve_selector_from(session, req)
145
- return sel if sel.is_a?(Hash)
146
-
147
- click_element(session.page, sel)
148
- end
149
- end
150
-
151
- def click_element(page, selector)
152
- el = page.at_css(selector)
153
- return { error: "selector not found: #{selector}" } unless el
154
-
155
- el.click
156
- { ok: true }
157
- end
158
-
159
- def cmd_screenshot(req)
160
- with_page(req[:name]) do |session|
161
- path = safe_screenshot_path(req[:path], req[:name])
162
- return path if path.is_a?(Hash)
163
-
164
- FileUtils.mkdir_p(File.dirname(path))
165
- session.page.screenshot(path: path, full: req.fetch(:full, false))
166
- { ok: true, path: path }
167
- end
168
- end
169
-
170
- def safe_screenshot_path(requested, page_name)
171
- if requested
172
- expanded = File.expand_path(requested)
173
- allowed = SCREENSHOT_ROOTS.any? { |d| expanded.start_with?("#{d}/") || expanded.start_with?(d) }
174
- return { error: "path outside allowed directory (#{SCREENSHOT_DIR} or project directory)" } unless allowed
175
- return { error: "invalid extension — use .png, .jpg, or .jpeg" } \
176
- unless SCREENSHOT_EXTS.include?(File.extname(expanded).downcase)
177
-
178
- expanded
179
- else
180
- name_safe = page_name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
181
- File.join(SCREENSHOT_DIR, "browserctl_shot_#{name_safe}_#{Time.now.to_i}.png")
182
- end
183
- end
184
-
185
- def cmd_wait_for(req)
186
- with_page(req[:name]) { |session| wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 10).to_f) }
187
- end
188
-
189
- def cmd_watch(req)
190
- with_page(req[:name]) do |session|
191
- result = wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 30).to_f)
192
- result[:error] ? result : { ok: true, selector: req[:selector] }
193
- end
194
- end
195
-
196
- def wait_for_selector(page, selector, timeout)
197
- deadline = Time.now + timeout
198
- loop do
199
- found = page.at_css(selector)
200
- break { ok: true } if found
201
- break { error: "wait_for timeout: selector '#{selector}' not found after #{timeout}s" } if Time.now >= deadline
202
-
203
- sleep 0.2
204
- end
205
- end
206
-
207
- def cmd_url(req)
208
- with_page(req[:name]) { |session| { ok: true, url: session.page.current_url } }
209
- end
210
-
211
- def cmd_cookies(req)
212
- session = @global_mutex.synchronize { @pages[req[:name]] }
213
- return { error: "no page named '#{req[:name]}'" } unless session
214
-
215
- all = session.page.cookies.all
216
- { ok: true, cookies: all.values.map(&:to_h) }
217
- end
218
-
219
- def cmd_set_cookie(req)
220
- session = @global_mutex.synchronize { @pages[req[:name]] }
221
- return { error: "no page named '#{req[:name]}'" } unless session
222
-
223
- session.page.cookies.set(
224
- name: req[:cookie_name],
225
- value: req[:value],
226
- domain: req[:domain],
227
- path: req.fetch(:path, "/")
228
- )
229
- { ok: true }
230
- end
231
-
232
- def cmd_clear_cookies(req)
233
- session = @global_mutex.synchronize { @pages[req[:name]] }
234
- return { error: "no page named '#{req[:name]}'" } unless session
235
-
236
- session.page.cookies.clear
237
- { ok: true }
238
- end
239
-
240
- def cmd_import_cookies(req)
241
- with_page(req[:name]) do |session|
242
- req[:cookies].each do |c|
243
- session.page.cookies.set(
244
- name: c[:name],
245
- value: c[:value],
246
- domain: c[:domain],
247
- path: c.fetch(:path, "/"),
248
- httponly: c[:httpOnly] == true,
249
- secure: c[:secure] == true,
250
- expires: c[:expires] ? Time.at(c[:expires].to_i) : nil
251
- )
252
- end
253
- { ok: true, count: req[:cookies].length }
254
- end
255
- end
256
-
257
- def cmd_inspect(req)
258
- session = @global_mutex.synchronize { @pages[req[:name]] }
259
- return { error: "no page named '#{req[:name]}'" } unless session
260
-
261
- port = @browser.process.port
262
- target_id = session.page.target_id
263
- devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
264
- "?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
265
- { ok: true, devtools_url: devtools_url }
266
- end
267
-
268
- def cmd_pause(req)
269
- session = @global_mutex.synchronize { @pages[req[:name]] }
270
- return { error: "no page named '#{req[:name]}'" } unless session
271
-
272
- session.mutex.synchronize { session.pause! }
273
- { ok: true, paused: true }
274
- end
275
-
276
- def cmd_resume(req)
277
- session = @global_mutex.synchronize { @pages[req[:name]] }
278
- return { error: "no page named '#{req[:name]}'" } unless session
279
-
280
- session.mutex.synchronize do
281
- session.resume!
282
- session.pause_cv.signal
283
- end
284
- { ok: true, paused: false }
285
- end
286
-
287
- def cmd_ping(_req) = { ok: true, pid: Process.pid, protocol_version: PROTOCOL_VERSION }
288
-
289
- def cmd_shutdown(_req)
290
- Process.kill("INT", Process.pid)
291
- { ok: true }
292
- end
293
-
294
86
  def with_page(name)
295
87
  session = @global_mutex.synchronize { @pages[name] }
296
88
  return { error: "no page named '#{name}'" } unless session
@@ -300,19 +92,5 @@ module Browserctl
300
92
  yield session
301
93
  end
302
94
  end
303
-
304
- def cloudflare_challenge?(page)
305
- url = page.current_url.to_s
306
- body = page.body.to_s
307
- url.include?("challenge-platform") ||
308
- CLOUDFLARE_SIGNALS.any? { |sig| body.include?(sig) }
309
- end
310
-
311
- def resolve_selector_from(session, req)
312
- return req[:selector] if req[:selector]
313
- return { error: "selector or ref required" } unless req[:ref]
314
-
315
- session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
316
- end
317
95
  end
318
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
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class CommandDispatcher
5
+ module Handlers
6
+ module Navigation
7
+ private
8
+
9
+ def cmd_goto(req)
10
+ unless Policy.allowed_navigation?(req[:url].to_s)
11
+ return { error: "navigation to '#{req[:url]}' blocked by domain policy", code: "domain_not_allowed" }
12
+ end
13
+
14
+ with_page(req[:name]) do |session|
15
+ session.page.go_to(req[:url])
16
+ { ok: true, url: session.page.current_url, challenge: Detectors.cloudflare?(session.page) }
17
+ end
18
+ end
19
+
20
+ def cmd_evaluate(req)
21
+ with_page(req[:name]) { |session| { ok: true, result: session.page.evaluate(req[:expression]) } }
22
+ end
23
+
24
+ def cmd_fill(req)
25
+ with_page(req[:name]) do |session|
26
+ sel = resolve_selector_from(session, req)
27
+ return sel if sel.is_a?(Hash)
28
+
29
+ type_into(session.page, sel, req[:value])
30
+ end
31
+ end
32
+
33
+ def cmd_click(req)
34
+ with_page(req[:name]) do |session|
35
+ sel = resolve_selector_from(session, req)
36
+ return sel if sel.is_a?(Hash)
37
+
38
+ click_element(session.page, sel)
39
+ end
40
+ end
41
+
42
+ def cmd_url(req)
43
+ with_page(req[:name]) { |session| { ok: true, url: session.page.current_url } }
44
+ end
45
+
46
+ def type_into(page, selector, value)
47
+ el = page.at_css(selector)
48
+ return { error: "selector not found: #{selector}" } unless el
49
+
50
+ el.focus
51
+ el.type(value)
52
+ { ok: true }
53
+ end
54
+
55
+ def click_element(page, selector)
56
+ el = page.at_css(selector)
57
+ return { error: "selector not found: #{selector}" } unless el
58
+
59
+ el.click
60
+ { ok: true }
61
+ end
62
+
63
+ def resolve_selector_from(session, req)
64
+ return req[:selector] if req[:selector]
65
+ return { error: "selector or ref required" } unless req[:ref]
66
+
67
+ session.ref_registry[req[:ref]] || { error: "ref '#{req[:ref]}' not found — run snap first" }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Browserctl
6
+ class CommandDispatcher
7
+ module Handlers
8
+ module Observation
9
+ private
10
+
11
+ def cmd_snapshot(req)
12
+ with_page(req[:name]) { |session| take_snapshot(session, req[:format], req[:diff]) }
13
+ end
14
+
15
+ def take_snapshot(session, format, diff)
16
+ nonce = SecureRandom.hex(8)
17
+ challenge = Detectors.cloudflare?(session.page)
18
+
19
+ return { ok: true, html: session.page.body, challenge: challenge, nonce: nonce } unless format == "elements"
20
+
21
+ snapshot = @snapshot_builder.call(session.page)
22
+ registry = snapshot.to_h { |el| [el[:ref], el[:selector]] }
23
+
24
+ prev = session.prev_snapshot
25
+ session.ref_registry = registry
26
+ session.prev_snapshot = snapshot
27
+ result = diff && prev ? compute_diff(prev, snapshot) : snapshot
28
+
29
+ { ok: true, snapshot: result, challenge: challenge, nonce: nonce }
30
+ end
31
+
32
+ def compute_diff(prev, current)
33
+ prev_by_sel = prev.to_h { |el| [el[:selector], el] }
34
+ current.reject do |el|
35
+ old = prev_by_sel[el[:selector]]
36
+ old && old.slice(:text, :attrs) == el.slice(:text, :attrs)
37
+ end
38
+ end
39
+
40
+ def cmd_screenshot(req)
41
+ with_page(req[:name]) do |session|
42
+ path = safe_screenshot_path(req[:path], req[:name])
43
+ return path if path.is_a?(Hash)
44
+
45
+ FileUtils.mkdir_p(File.dirname(path))
46
+ session.page.screenshot(path: path, full: req.fetch(:full, false))
47
+ { ok: true, path: path }
48
+ end
49
+ end
50
+
51
+ def safe_screenshot_path(requested, page_name)
52
+ return default_screenshot_path(page_name) unless requested
53
+
54
+ expanded = File.expand_path(requested)
55
+ return { error: "invalid extension — use .png, .jpg, or .jpeg" } unless valid_screenshot_ext?(expanded)
56
+ return { error: "path outside allowed directory (#{SCREENSHOT_DIR} or project directory)" } \
57
+ unless within_screenshot_roots?(expanded)
58
+
59
+ File.join(resolve_dir(File.dirname(expanded)), File.basename(expanded))
60
+ end
61
+
62
+ def valid_screenshot_ext?(path)
63
+ SCREENSHOT_EXTS.include?(File.extname(path).downcase)
64
+ end
65
+
66
+ def within_screenshot_roots?(path)
67
+ dir = resolve_dir(File.dirname(path))
68
+ SCREENSHOT_ROOTS.any? do |root|
69
+ real_root = resolve_dir(root)
70
+ dir.start_with?("#{real_root}/") || dir == real_root
71
+ end
72
+ end
73
+
74
+ def resolve_dir(dir)
75
+ File.realpath(dir)
76
+ rescue Errno::ENOENT
77
+ dir
78
+ end
79
+
80
+ def default_screenshot_path(page_name)
81
+ name_safe = page_name.to_s.gsub(/[^a-zA-Z0-9_-]/, "_")
82
+ File.join(SCREENSHOT_DIR, "browserctl_shot_#{name_safe}_#{Time.now.to_i}.png")
83
+ end
84
+
85
+ def cmd_wait_for(req)
86
+ with_page(req[:name]) do |session|
87
+ wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 10).to_f)
88
+ end
89
+ end
90
+
91
+ def cmd_watch(req)
92
+ with_page(req[:name]) do |session|
93
+ result = wait_for_selector(session.page, req[:selector], req.fetch(:timeout, 30).to_f)
94
+ result[:error] ? result : { ok: true, selector: req[:selector] }
95
+ end
96
+ end
97
+
98
+ def wait_for_selector(page, selector, timeout)
99
+ deadline = Time.now + timeout
100
+ loop do
101
+ found = page.at_css(selector)
102
+ break { ok: true } if found
103
+ if Time.now >= deadline
104
+ break { error: "wait_for timeout: selector '#{selector}' not found after #{timeout}s" }
105
+ end
106
+
107
+ sleep 0.2
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Browserctl
4
+ class CommandDispatcher
5
+ module Handlers
6
+ module PageLifecycle
7
+ private
8
+
9
+ def cmd_open_page(req)
10
+ session = @global_mutex.synchronize do
11
+ @pages[req[:name]] ||= PageSession.new(@browser.create_page)
12
+ end
13
+ session.page.go_to(req[:url]) if req[:url]
14
+ { ok: true, name: req[:name] }
15
+ end
16
+
17
+ def cmd_close_page(req)
18
+ session = @global_mutex.synchronize { @pages.delete(req[:name]) }
19
+ session&.page&.close
20
+ { ok: true }
21
+ end
22
+
23
+ def cmd_list_pages(_req)
24
+ { pages: @global_mutex.synchronize { @pages.keys } }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -62,8 +62,9 @@ module Browserctl
62
62
 
63
63
  def setup_socket
64
64
  FileUtils.rm_f(@socket_path)
65
+ old_umask = File.umask(0o177)
65
66
  server = UNIXServer.new(@socket_path)
66
- File.chmod(0o600, @socket_path)
67
+ File.umask(old_umask)
67
68
  Browserctl.logger.info "daemon ready — listening on #{@socket_path}"
68
69
  server
69
70
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -16,15 +16,20 @@ module Browserctl
16
16
  def initialize(params, client)
17
17
  @params = params
18
18
  @client = client
19
- @_store = {}
20
19
  end
21
20
 
22
21
  def store(key, value)
23
- @_store[key] = value
22
+ res = @client.store(key.to_s, value)
23
+ raise WorkflowError, res[:error] if res[:error]
24
+
25
+ value
24
26
  end
25
27
 
26
28
  def fetch(key)
27
- @_store.fetch(key) { raise KeyError, "no value stored for key #{key.inspect}" }
29
+ res = @client.fetch(key.to_s)
30
+ raise WorkflowError, res[:error] if res[:error]
31
+
32
+ res[:value]
28
33
  end
29
34
 
30
35
  def method_missing(name, *args)
@@ -137,7 +142,7 @@ module Browserctl
137
142
  end
138
143
 
139
144
  def compose(workflow_name)
140
- source = REGISTRY[workflow_name.to_s]
145
+ source = Browserctl.lookup_workflow(workflow_name.to_s)
141
146
  raise WorkflowError, "workflow '#{workflow_name}' not found for composition" unless source
142
147
 
143
148
  @steps.concat(source.steps)
@@ -187,11 +192,20 @@ module Browserctl
187
192
  end
188
193
  end
189
194
 
190
- REGISTRY = {} # rubocop:disable Style/MutableConstant
195
+ @registry_mutex = Mutex.new
196
+ @registry = {}
191
197
 
192
198
  def self.workflow(name, &)
193
199
  defn = WorkflowDefinition.new(name.to_s)
194
200
  defn.instance_exec(&)
195
- REGISTRY[name.to_s] = defn
201
+ @registry_mutex.synchronize { @registry[name.to_s] = defn }
202
+ end
203
+
204
+ def self.lookup_workflow(name)
205
+ @registry_mutex.synchronize { @registry[name.to_s] }
206
+ end
207
+
208
+ def self.registry_snapshot
209
+ @registry_mutex.synchronize { @registry.dup }
196
210
  end
197
211
  end
data/lib/browserctl.rb CHANGED
@@ -2,14 +2,24 @@
2
2
 
3
3
  require_relative "browserctl/version"
4
4
  require_relative "browserctl/constants"
5
+ require_relative "browserctl/errors"
5
6
  require_relative "browserctl/workflow"
6
7
  require_relative "browserctl/runner"
7
8
  require_relative "browserctl/client"
8
9
 
9
10
  module Browserctl
10
- PLUGIN_COMMANDS = {} # rubocop:disable Style/MutableConstant
11
+ @plugin_commands_mutex = Mutex.new
12
+ @plugin_commands = {}
11
13
 
12
14
  def self.register_command(name, &block)
13
- PLUGIN_COMMANDS[name.to_s] = block
15
+ @plugin_commands_mutex.synchronize { @plugin_commands[name.to_s] = block }
16
+ end
17
+
18
+ def self.lookup_plugin_command(name)
19
+ @plugin_commands_mutex.synchronize { @plugin_commands[name.to_s] }
20
+ end
21
+
22
+ def self.plugin_commands_snapshot
23
+ @plugin_commands_mutex.synchronize { @plugin_commands.dup }
14
24
  end
15
25
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '1.11'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '13.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '13.0'
69
83
  - !ruby/object:Gem::Dependency
70
84
  name: rspec
71
85
  requirement: !ruby/object:Gem::Requirement
@@ -150,11 +164,21 @@ files:
150
164
  - lib/browserctl/commands/status.rb
151
165
  - lib/browserctl/commands/watch.rb
152
166
  - lib/browserctl/constants.rb
167
+ - lib/browserctl/detectors.rb
168
+ - lib/browserctl/errors.rb
153
169
  - lib/browserctl/logger.rb
170
+ - lib/browserctl/policy.rb
154
171
  - lib/browserctl/recording.rb
155
172
  - lib/browserctl/runner.rb
156
173
  - lib/browserctl/server.rb
157
174
  - lib/browserctl/server/command_dispatcher.rb
175
+ - lib/browserctl/server/handlers/cookies.rb
176
+ - lib/browserctl/server/handlers/daemon_control.rb
177
+ - lib/browserctl/server/handlers/devtools.rb
178
+ - lib/browserctl/server/handlers/hitl.rb
179
+ - lib/browserctl/server/handlers/navigation.rb
180
+ - lib/browserctl/server/handlers/observation.rb
181
+ - lib/browserctl/server/handlers/page_lifecycle.rb
158
182
  - lib/browserctl/server/idle_watcher.rb
159
183
  - lib/browserctl/server/page_session.rb
160
184
  - lib/browserctl/server/snapshot_builder.rb