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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbfd167f7b8ad961fa91c544eb2636fa1bdc200b40f4738bbf27d9fa5a835cae
4
- data.tar.gz: d3fdf8af59edd0c73ea1c415926baefbbe22405343898b46c60b1a18f2e623ac
3
+ metadata.gz: e27f4db200c9e26aaad474df3f55c1a7c91d602724d900d462a969ce4506ba9f
4
+ data.tar.gz: fba2acf3be3adf3a3a5ca9b42026abfcbfbeea7a8f8d48e51cd455b710fb403c
5
5
  SHA512:
6
- metadata.gz: c46b3611cc0a29633558cf0547cd532cefcf4dbecddbbc62e348af27b9c4e5eec498f00bb90892e84a4665281cad57b20e1a73b14b50cff2e579f222301cf549
7
- data.tar.gz: f5b8451f0e909212acf63e1eecc49ca607a7d095ea44e4fd4ae42e9b2509b199b7ed35a7158a83764b825dfb6747970e20fe137be465063f50a42c39508bfb68
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
- Browserctl::Server.new(
33
- headless: !opts[:headed],
34
- socket_path: Browserctl.socket_path(assigned_name),
35
- pid_path: Browserctl.pid_path(assigned_name)
36
- ).run
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
@@ -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 [Ferrum::Page] the browser page to inspect
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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "driver/base"
4
+ require_relative "driver/cdp_page"
5
+ require_relative "driver/cdp"
@@ -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, browser, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
76
+ def initialize(pages, driver, snapshot_builder = SnapshotBuilder.new, global_mutex: Mutex.new)
77
77
  @pages = pages
78
- @browser = browser
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
- port = @browser.process.port
14
- target_id = session.page.target_id
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(@browser.create_page)
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 @browser.options.headless == false
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 = @browser.create_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 = @browser.create_page
64
+ tmp_page = @driver.create_page
65
65
  begin
66
66
  tmp_page.go_to(origin)
67
67
  keys.each do |k, v|
@@ -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, @browser, global_mutex: @mutex)
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
- @browser = init_browser(headless)
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) { @browser.quit } }
108
+ quietly { Timeout.timeout(5) { @driver.quit } }
123
109
  quietly { File.unlink(@socket_path) }
124
110
  quietly { File.unlink(@pid_path) }
125
111
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.8.4"
4
+ VERSION = "0.9.0"
5
5
  end
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.8.4
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-01 00:00:00.000000000 Z
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 Ferrum (Chrome DevTools Protocol).
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