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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +94 -47
- data/bin/browserctl +2 -2
- data/bin/setup +7 -3
- data/examples/cloudflare_hitl.rb +1 -1
- data/lib/browserctl/client.rb +13 -2
- data/lib/browserctl/commands/snapshot.rb +5 -5
- data/lib/browserctl/detectors.rb +23 -0
- data/lib/browserctl/errors.rb +25 -0
- data/lib/browserctl/policy.rb +36 -0
- data/lib/browserctl/runner.rb +4 -4
- data/lib/browserctl/server/command_dispatcher.rb +28 -250
- data/lib/browserctl/server/handlers/cookies.rb +57 -0
- data/lib/browserctl/server/handlers/daemon_control.rb +29 -0
- data/lib/browserctl/server/handlers/devtools.rb +22 -0
- data/lib/browserctl/server/handlers/hitl.rb +30 -0
- data/lib/browserctl/server/handlers/navigation.rb +72 -0
- data/lib/browserctl/server/handlers/observation.rb +113 -0
- data/lib/browserctl/server/handlers/page_lifecycle.rb +29 -0
- data/lib/browserctl/server.rb +2 -1
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +20 -6
- data/lib/browserctl.rb +12 -2
- metadata +25 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec75744264ce56f8c3f94ab95518a106c8225ec1dc11dd35a97f43186b590502
|
|
4
|
+
data.tar.gz: 973c3b270b5d1bba3dfa900623790e3c5cb9d5bd8ebe8faa50c28d09e48fbfff
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
5
|
+
<h1 align="center">browserctl</h1>
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
17
|
+
---
|
|
12
18
|
|
|
13
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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 #
|
|
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
|
|
111
|
+
before = Browserctl.registry_snapshot.keys
|
|
112
112
|
load File.expand_path(name)
|
|
113
|
-
name = (Browserctl
|
|
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
|
-
|
|
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 "
|
|
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
|
|
data/examples/cloudflare_hitl.rb
CHANGED
|
@@ -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: "
|
|
58
|
+
result = page(:main).snapshot(format: "elements")
|
|
59
59
|
$stdout.puts " Snapshot: #{result[:snapshot]&.length || 0} elements captured"
|
|
60
60
|
end
|
|
61
61
|
|
data/lib/browserctl/client.rb
CHANGED
|
@@ -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] "
|
|
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: "
|
|
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[
|
|
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
|
|
14
|
-
opt :format, "Output format:
|
|
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
|
|
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 == "
|
|
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
|
data/lib/browserctl/runner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
data/lib/browserctl/server.rb
CHANGED
|
@@ -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.
|
|
67
|
+
File.umask(old_umask)
|
|
67
68
|
Browserctl.logger.info "daemon ready — listening on #{@socket_path}"
|
|
68
69
|
server
|
|
69
70
|
end
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
-
@
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
+
@plugin_commands_mutex = Mutex.new
|
|
12
|
+
@plugin_commands = {}
|
|
11
13
|
|
|
12
14
|
def self.register_command(name, &block)
|
|
13
|
-
|
|
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
|
+
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
|