Class: RubyLens::Clip::ChromePage

Inherits:
Object
  • Object
show all
Defined in:
lib/rubylens/clip/chrome_page.rb

Overview

Runs a headless Chrome/Chromium with an ephemeral DevTools port and a throwaway profile, attaches to the page that loads the showcase HTML, and exposes the two operations clip rendering needs: evaluate and screenshot. The port comes from the profile's DevToolsActivePort file, so parallel clips never race for a fixed port.

Constant Summary collapse

HEADLESS_FLAGS =
%w[
  --headless=new
  --remote-debugging-port=0
  --no-first-run
  --no-default-browser-check
  --disable-background-networking
  --disable-component-update
  --disable-sync
  --mute-audio
  --hide-scrollbars
  --force-device-scale-factor=1
  --force-color-profile=srgb
  --enable-unsafe-swiftshader
].freeze
LAUNCH_TIMEOUT_SECONDS =
30

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(executable:, url:, width:, height:) ⇒ ChromePage

Returns a new instance of ChromePage.



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/rubylens/clip/chrome_page.rb', line 46

def initialize(executable:, url:, width:, height:)
  @profile = Dir.mktmpdir("rubylens-clip-")
  @next_id = 0
  @responses = {}
  flags = HEADLESS_FLAGS.dup
  # Chrome refuses its sandbox as root (typical for containers and CI).
  flags << "--no-sandbox" if Process.euid.zero?
  flags << "--window-size=#{width},#{height}"
  flags << "--user-data-dir=#{@profile}"
  @pid = Process.spawn(executable, *flags, url, %i[out err] => File::NULL)
  port = wait_for_devtools_port
  @channel = WebSocketChannel.new("127.0.0.1", port, page_target_path(port))
  # The headless window includes non-viewport space; pin the viewport so
  # captures are exactly the stage size.
  command("Emulation.setDeviceMetricsOverride", {
    "width" => width, "height" => height, "deviceScaleFactor" => 1, "mobile" => false
  }, timeout: 30)
rescue Errno::ENOENT => error
  abort_start
  raise Error, "could not launch Chrome at #{executable}: #{error.message}"
rescue SystemCallError => error
  # A startup race (Chrome exiting or closing its port mid-attach) must
  # surface as a normal rubylens error, not a raw Errno stack trace.
  abort_start
  raise Error, "could not attach to Chrome's DevTools endpoint: #{error.message}"
rescue StandardError
  abort_start
  raise
end

Class Method Details

.open(executable:, url:, width:, height:) ⇒ Object



37
38
39
40
41
42
43
44
# File 'lib/rubylens/clip/chrome_page.rb', line 37

def self.open(executable:, url:, width:, height:)
  page = new(executable:, url:, width:, height:)
  begin
    yield page
  ensure
    page.close
  end
end

Instance Method Details

#closeObject



93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/rubylens/clip/chrome_page.rb', line 93

def close
  begin
    command("Browser.close", {}, timeout: 3) if @channel
  rescue Error
    terminate
  end
  Process.wait(@pid) if @pid
rescue Errno::ECHILD
  nil
ensure
  @channel&.close
  FileUtils.remove_entry(@profile) if @profile && File.directory?(@profile)
end

#evaluate(expression, await: false, timeout: 30) ⇒ Object



76
77
78
79
80
81
82
83
84
85
86
# File 'lib/rubylens/clip/chrome_page.rb', line 76

def evaluate(expression, await: false, timeout: 30)
  result = command("Runtime.evaluate", {
    "expression" => expression,
    "returnByValue" => true,
    "awaitPromise" => await,
  }, timeout:)
  if (details = result["exceptionDetails"])
    raise Error, "the showcase page raised: #{details.dig("exception", "description") || details["text"]}"
  end
  result.dig("result", "value")
end

#screenshot_png(timeout: 60) ⇒ Object



88
89
90
91
# File 'lib/rubylens/clip/chrome_page.rb', line 88

def screenshot_png(timeout: 60)
  data = command("Page.captureScreenshot", { "format" => "png", "optimizeForSpeed" => true }, timeout:)
  Base64.decode64(data.fetch("data"))
end