browserctl 0.8.4 → 0.9.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 +8 -0
- data/bin/browserd +17 -5
- data/lib/browserctl/detectors.rb +1 -1
- data/lib/browserctl/driver/base.rb +13 -0
- data/lib/browserctl/driver/cdp.rb +125 -0
- data/lib/browserctl/driver/cdp_page.rb +14 -0
- data/lib/browserctl/driver.rb +5 -0
- data/lib/browserctl/errors.rb +1 -0
- data/lib/browserctl/server/command_dispatcher.rb +2 -2
- data/lib/browserctl/server/handlers/devtools.rb +5 -2
- data/lib/browserctl/server/handlers/page_lifecycle.rb +2 -4
- data/lib/browserctl/server/handlers/session.rb +2 -2
- data/lib/browserctl/server.rb +7 -21
- data/lib/browserctl/version.rb +1 -1
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e27f4db200c9e26aaad474df3f55c1a7c91d602724d900d462a969ce4506ba9f
|
|
4
|
+
data.tar.gz: fba2acf3be3adf3a3a5ca9b42026abfcbfbeea7a8f8d48e51cd455b710fb403c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ac208ae09f276efbf624ed297ef725d5144ec9c13624127f7f949491b0156f7a712ee22dc45cbe218addfe88227e7aa9676feaa105ef1813743b30ac3d5cd29
|
|
7
|
+
data.tar.gz: e5de1ed64d9d88e69c59a6de15bcfefaeb17b28cb578a8f6cddc2f5c97a8ee6d752c92efb6eff5d533d5923bfdcf6f29464a9f8bf06400953f6f874681c75689
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,14 @@ 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.9.0](https://github.com/patrick204nqh/browserctl/compare/v0.8.4...v0.9.0) (2026-05-09)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
* split skills into automate + feedback, refresh for v0.9 driver ([#80](https://github.com/patrick204nqh/browserctl/issues/80)) ([65c1a8e](https://github.com/patrick204nqh/browserctl/commit/65c1a8ee25a81aa5337fb8c447781efd8f6ade2e))
|
|
19
|
+
* v0.9 browser-agnostic driver layer + Brave support ([#77](https://github.com/patrick204nqh/browserctl/issues/77)) ([0156c2d](https://github.com/patrick204nqh/browserctl/commit/0156c2defc105d49be038d108fa3da3ed1c4b402))
|
|
20
|
+
|
|
13
21
|
## [0.8.4](https://github.com/patrick204nqh/browserctl/compare/v0.8.3...v0.8.4) (2026-05-01)
|
|
14
22
|
|
|
15
23
|
|
data/bin/browserd
CHANGED
|
@@ -9,17 +9,24 @@ require "browserctl/logger"
|
|
|
9
9
|
require "browserctl/server"
|
|
10
10
|
require "browserctl/version"
|
|
11
11
|
|
|
12
|
+
SUPPORTED_BROWSERS = %w[chrome chromium brave].freeze
|
|
13
|
+
|
|
12
14
|
opts = Optimist.options do
|
|
13
15
|
version "browserd #{Browserctl::VERSION}"
|
|
14
16
|
opt :headed, "Run with a visible browser window", default: false, short: "-H"
|
|
15
17
|
opt :log_level, "Log verbosity: debug, info, warn, error", default: "info", short: "-l", type: :string
|
|
16
18
|
opt :name, "Daemon instance name for multi-agent use", default: nil, short: "-n", type: :string
|
|
19
|
+
opt :browser, "Browser to use: chrome, chromium, brave", default: "chrome", short: "-b", type: :string
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
if opts[:name] && opts[:name] !~ /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
20
23
|
abort "Invalid daemon name #{opts[:name].inspect} — use only letters, digits, _ or -"
|
|
21
24
|
end
|
|
22
25
|
|
|
26
|
+
unless SUPPORTED_BROWSERS.include?(opts[:browser])
|
|
27
|
+
abort "Unsupported browser #{opts[:browser].inspect} — choose one of: #{SUPPORTED_BROWSERS.join(', ')}"
|
|
28
|
+
end
|
|
29
|
+
|
|
23
30
|
assigned_name = opts[:name] || Browserctl.next_daemon_name
|
|
24
31
|
if assigned_name && !opts[:name]
|
|
25
32
|
warn "browserd: default slot taken — starting as '#{assigned_name}'"
|
|
@@ -29,8 +36,13 @@ end
|
|
|
29
36
|
log_path = Browserctl.log_path(assigned_name)
|
|
30
37
|
warn "browserd starting — log: #{log_path}"
|
|
31
38
|
Browserctl.logger = Browserctl.build_logger(opts[:log_level], log_path: log_path)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
39
|
+
begin
|
|
40
|
+
Browserctl::Server.new(
|
|
41
|
+
headless: !opts[:headed],
|
|
42
|
+
browser: opts[:browser],
|
|
43
|
+
socket_path: Browserctl.socket_path(assigned_name),
|
|
44
|
+
pid_path: Browserctl.pid_path(assigned_name)
|
|
45
|
+
).run
|
|
46
|
+
rescue Browserctl::BrowserNotFound => e
|
|
47
|
+
abort e.message
|
|
48
|
+
end
|
data/lib/browserctl/detectors.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Browserctl
|
|
|
11
11
|
|
|
12
12
|
# Returns true if the page appears to be showing a Cloudflare challenge.
|
|
13
13
|
# Checks both the current URL and the page body for known Cloudflare signals.
|
|
14
|
-
# @param page [
|
|
14
|
+
# @param page [#current_url, #body] the browser page to inspect
|
|
15
15
|
# @return [Boolean]
|
|
16
16
|
def self.cloudflare?(page)
|
|
17
17
|
url = page.current_url.to_s
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Driver
|
|
5
|
+
class Base
|
|
6
|
+
def create_page = raise NotImplementedError, "#{self.class.name}#create_page not implemented"
|
|
7
|
+
def quit = raise NotImplementedError, "#{self.class.name}#quit not implemented"
|
|
8
|
+
def headed? = raise NotImplementedError, "#{self.class.name}#headed? not implemented"
|
|
9
|
+
def supports?(_) = false
|
|
10
|
+
def devtools_info(_page) = raise NotImplementedError, "#{self.class.name}#devtools_info not implemented"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ferrum"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "cdp_page"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
|
|
8
|
+
module Browserctl
|
|
9
|
+
module Driver
|
|
10
|
+
class CDP < Base
|
|
11
|
+
BRAVE_PATHS = {
|
|
12
|
+
darwin: [
|
|
13
|
+
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
|
|
14
|
+
File.expand_path("~/Applications/Brave Browser.app/Contents/MacOS/Brave Browser")
|
|
15
|
+
],
|
|
16
|
+
linux: [
|
|
17
|
+
"/usr/bin/brave-browser",
|
|
18
|
+
"/usr/bin/brave",
|
|
19
|
+
"/snap/bin/brave"
|
|
20
|
+
],
|
|
21
|
+
windows: [
|
|
22
|
+
"C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe",
|
|
23
|
+
"C:/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe"
|
|
24
|
+
]
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def initialize(headless: true, browser: "chrome") # rubocop:disable Lint/MissingSuper
|
|
28
|
+
@headless = headless
|
|
29
|
+
@browser = browser
|
|
30
|
+
@ferrum = Ferrum::Browser.new(**ferrum_options)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create_page
|
|
34
|
+
CDPPage.new(@ferrum.create_page)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def quit
|
|
38
|
+
@ferrum.quit
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def headed?
|
|
42
|
+
!@headless
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def supports?(capability)
|
|
46
|
+
capability == :devtools
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def devtools_info(page)
|
|
50
|
+
port = @ferrum.process.port
|
|
51
|
+
target_id = page.target_id
|
|
52
|
+
{ port: port, target_id: target_id }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def ferrum_options
|
|
58
|
+
opts = {
|
|
59
|
+
timeout: 30,
|
|
60
|
+
process_timeout: 30,
|
|
61
|
+
browser_options: { "disable-dev-shm-usage" => nil, "disable-gpu" => nil }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if @browser != "chrome" && (path = resolve_browser_path)
|
|
65
|
+
opts[:browser_path] = path
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if ENV["CI"] || ENV["BROWSERCTL_NO_SANDBOX"]
|
|
69
|
+
Browserctl.logger.warn "no-sandbox enabled (CI or BROWSERCTL_NO_SANDBOX set)"
|
|
70
|
+
opts[:browser_options]["no-sandbox"] = nil
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
opts[:headless] = @headless
|
|
74
|
+
opts
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def resolve_browser_path
|
|
78
|
+
case @browser
|
|
79
|
+
when "chromium"
|
|
80
|
+
resolve_chromium_path
|
|
81
|
+
when "brave"
|
|
82
|
+
resolve_brave_path
|
|
83
|
+
else
|
|
84
|
+
raise ArgumentError, "Unknown browser: #{@browser.inspect}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def resolve_chromium_path
|
|
89
|
+
env_override("CHROMIUM_PATH")
|
|
90
|
+
# Returns nil when no override — Ferrum finds Chromium automatically on most platforms.
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def resolve_brave_path
|
|
94
|
+
override = env_override("BRAVE_PATH")
|
|
95
|
+
return override if override
|
|
96
|
+
|
|
97
|
+
platform = detect_platform
|
|
98
|
+
candidates = BRAVE_PATHS.fetch(platform, [])
|
|
99
|
+
path = candidates.find { |p| File.executable?(p) }
|
|
100
|
+
unless path
|
|
101
|
+
raise BrowserNotFound,
|
|
102
|
+
"Brave browser not found. Install Brave or set BRAVE_PATH to its executable."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
path
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def env_override(var)
|
|
109
|
+
value = ENV.fetch(var, nil)
|
|
110
|
+
return nil if value.nil? || value.empty?
|
|
111
|
+
raise BrowserNotFound, "#{var}=#{value} is not an executable file" unless File.executable?(value)
|
|
112
|
+
|
|
113
|
+
value
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def detect_platform
|
|
117
|
+
case RUBY_PLATFORM
|
|
118
|
+
when /darwin/ then :darwin
|
|
119
|
+
when /mingw|mswin|windows/ then :windows
|
|
120
|
+
else :linux
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Driver
|
|
7
|
+
# Wraps a Ferrum::Page so handlers receive a driver-namespaced object instead of
|
|
8
|
+
# a raw Ferrum type. Currently a transparent delegator — the seam exists so future
|
|
9
|
+
# CDP-specific behaviour (capability checks, instrumentation) can live here without
|
|
10
|
+
# touching every handler. Delete this class if no override lands by the next driver.
|
|
11
|
+
class CDPPage < SimpleDelegator
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
data/lib/browserctl/errors.rb
CHANGED
|
@@ -23,6 +23,7 @@ module Browserctl
|
|
|
23
23
|
class TimeoutError < Error; def self.default_code = "timeout" end
|
|
24
24
|
class KeyNotFound < Error; def self.default_code = "key_not_found" end
|
|
25
25
|
class DaemonUnavailableError < Error; def self.default_code = "daemon_unavailable" end
|
|
26
|
+
class BrowserNotFound < Error; def self.default_code = "browser_not_found" end
|
|
26
27
|
|
|
27
28
|
class WorkflowError < Error; def self.default_code = "workflow_error" end
|
|
28
29
|
class SecretResolverError < WorkflowError; def self.default_code = "secret_resolver_error" end
|
|
@@ -73,9 +73,9 @@ module Browserctl
|
|
|
73
73
|
SCREENSHOT_ROOTS = [SCREENSHOT_DIR, File.expand_path(".")].freeze
|
|
74
74
|
SCREENSHOT_EXTS = %w[.png .jpg .jpeg].freeze
|
|
75
75
|
|
|
76
|
-
def initialize(pages,
|
|
76
|
+
def initialize(pages, driver, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
|
|
77
77
|
@pages = pages
|
|
78
|
-
@
|
|
78
|
+
@driver = driver
|
|
79
79
|
@snapshot_builder = snapshot_builder
|
|
80
80
|
@global_mutex = global_mutex
|
|
81
81
|
@kv_store = {}
|
|
@@ -7,11 +7,14 @@ module Browserctl
|
|
|
7
7
|
private
|
|
8
8
|
|
|
9
9
|
def cmd_devtools(req)
|
|
10
|
+
return { error: "devtools is not supported by this driver" } unless @driver.supports?(:devtools)
|
|
11
|
+
|
|
10
12
|
session = @global_mutex.synchronize { @pages[req[:name]] }
|
|
11
13
|
return { error: "no page named '#{req[:name]}'" } unless session
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
info = @driver.devtools_info(session.page)
|
|
16
|
+
port = info[:port]
|
|
17
|
+
target_id = info[:target_id]
|
|
15
18
|
devtools_url = "http://127.0.0.1:#{port}/devtools/inspector.html" \
|
|
16
19
|
"?ws=127.0.0.1:#{port}/devtools/page/#{target_id}"
|
|
17
20
|
{ ok: true, devtools_url: devtools_url }
|
|
@@ -8,7 +8,7 @@ module Browserctl
|
|
|
8
8
|
|
|
9
9
|
def cmd_page_open(req)
|
|
10
10
|
session = @global_mutex.synchronize do
|
|
11
|
-
@pages[req[:name]] ||= PageSession.new(@
|
|
11
|
+
@pages[req[:name]] ||= PageSession.new(@driver.create_page)
|
|
12
12
|
end
|
|
13
13
|
session.page.go_to(req[:url]) if req[:url]
|
|
14
14
|
{ ok: true, name: req[:name] }
|
|
@@ -25,9 +25,7 @@ module Browserctl
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def cmd_page_focus(req)
|
|
28
|
-
unless @
|
|
29
|
-
return { error: "page focus requires headed mode — start browserd with --headed" }
|
|
30
|
-
end
|
|
28
|
+
return { error: "page focus requires headed mode — start browserd with --headed" } unless @driver.headed?
|
|
31
29
|
|
|
32
30
|
with_page(req[:name]) do |session|
|
|
33
31
|
session.page.activate
|
|
@@ -47,7 +47,7 @@ module Browserctl
|
|
|
47
47
|
if existing
|
|
48
48
|
existing.page.go_to(page_data[:url])
|
|
49
49
|
else
|
|
50
|
-
new_page = @
|
|
50
|
+
new_page = @driver.create_page
|
|
51
51
|
new_page.go_to(page_data[:url])
|
|
52
52
|
@global_mutex.synchronize { @pages[page_name.to_s] = PageSession.new(new_page) }
|
|
53
53
|
end
|
|
@@ -61,7 +61,7 @@ module Browserctl
|
|
|
61
61
|
data[:local_storage].each do |origin, keys|
|
|
62
62
|
next if keys.empty?
|
|
63
63
|
|
|
64
|
-
tmp_page = @
|
|
64
|
+
tmp_page = @driver.create_page
|
|
65
65
|
begin
|
|
66
66
|
tmp_page.go_to(origin)
|
|
67
67
|
keys.each do |k, v|
|
data/lib/browserctl/server.rb
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "ferrum"
|
|
4
3
|
require "socket"
|
|
5
4
|
require "json"
|
|
6
5
|
require "fileutils"
|
|
7
6
|
require "timeout"
|
|
8
7
|
require_relative "constants"
|
|
9
8
|
require_relative "logger"
|
|
9
|
+
require_relative "driver"
|
|
10
10
|
require_relative "server/command_dispatcher"
|
|
11
11
|
require_relative "server/idle_watcher"
|
|
12
12
|
require_relative "server/page_session"
|
|
13
13
|
|
|
14
14
|
module Browserctl
|
|
15
15
|
class Server
|
|
16
|
-
def initialize(headless: true, socket_path: SOCKET_PATH, pid_path: PID_PATH)
|
|
16
|
+
def initialize(headless: true, browser: "chrome", socket_path: SOCKET_PATH, pid_path: PID_PATH)
|
|
17
17
|
@socket_path = socket_path
|
|
18
18
|
@pid_path = pid_path
|
|
19
|
-
prepare_runtime(headless)
|
|
20
|
-
@dispatcher = CommandDispatcher.new(@pages, @
|
|
19
|
+
prepare_runtime(headless, browser)
|
|
20
|
+
@dispatcher = CommandDispatcher.new(@pages, @driver, global_mutex: @mutex)
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def run
|
|
@@ -33,26 +33,12 @@ module Browserctl
|
|
|
33
33
|
|
|
34
34
|
private
|
|
35
35
|
|
|
36
|
-
def prepare_runtime(headless)
|
|
36
|
+
def prepare_runtime(headless, browser)
|
|
37
37
|
FileUtils.mkdir_p(File.dirname(@socket_path))
|
|
38
|
-
@
|
|
38
|
+
@driver = Driver::CDP.new(headless: headless, browser: browser)
|
|
39
39
|
init_state
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
def init_browser(headless)
|
|
43
|
-
Ferrum::Browser.new(headless: headless, **ferrum_options)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def ferrum_options
|
|
47
|
-
opts = { timeout: 30, process_timeout: 30,
|
|
48
|
-
browser_options: { "disable-dev-shm-usage" => nil, "disable-gpu" => nil } }
|
|
49
|
-
if ENV["CI"] || ENV["BROWSERCTL_NO_SANDBOX"]
|
|
50
|
-
Browserctl.logger.warn "no-sandbox enabled (CI or BROWSERCTL_NO_SANDBOX set)"
|
|
51
|
-
opts[:browser_options]["no-sandbox"] = nil
|
|
52
|
-
end
|
|
53
|
-
opts
|
|
54
|
-
end
|
|
55
|
-
|
|
56
42
|
def init_state
|
|
57
43
|
@pages = {}
|
|
58
44
|
@last_used = Time.now
|
|
@@ -119,7 +105,7 @@ module Browserctl
|
|
|
119
105
|
def teardown(idle, server)
|
|
120
106
|
idle&.kill
|
|
121
107
|
quietly { server&.close }
|
|
122
|
-
quietly { Timeout.timeout(5) { @
|
|
108
|
+
quietly { Timeout.timeout(5) { @driver.quit } }
|
|
123
109
|
quietly { File.unlink(@socket_path) }
|
|
124
110
|
quietly { File.unlink(@pid_path) }
|
|
125
111
|
end
|
data/lib/browserctl/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: browserctl
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrick
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ferrum
|
|
@@ -123,7 +123,7 @@ dependencies:
|
|
|
123
123
|
- !ruby/object:Gem::Version
|
|
124
124
|
version: '0.9'
|
|
125
125
|
description: Named browser sessions, Ruby workflow DSL, and a token-efficient DOM
|
|
126
|
-
snapshot format. Built on
|
|
126
|
+
snapshot format. Built on a browser-agnostic driver layer (Ferrum/CDP backend).
|
|
127
127
|
email:
|
|
128
128
|
- patrick204nqh@gmail.com
|
|
129
129
|
executables:
|
|
@@ -185,6 +185,10 @@ files:
|
|
|
185
185
|
- lib/browserctl/commands/workflow.rb
|
|
186
186
|
- lib/browserctl/constants.rb
|
|
187
187
|
- lib/browserctl/detectors.rb
|
|
188
|
+
- lib/browserctl/driver.rb
|
|
189
|
+
- lib/browserctl/driver/base.rb
|
|
190
|
+
- lib/browserctl/driver/cdp.rb
|
|
191
|
+
- lib/browserctl/driver/cdp_page.rb
|
|
188
192
|
- lib/browserctl/errors.rb
|
|
189
193
|
- lib/browserctl/logger.rb
|
|
190
194
|
- lib/browserctl/policy.rb
|