rubylens 0.1.0 → 0.2.1

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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +67 -39
  3. data/assets/runtime/report.js +1607 -251
  4. data/assets/shells/report.html +9 -7
  5. data/assets/shells/showcase.html +1 -0
  6. data/assets/styles/report.css +51 -25
  7. data/assets/styles/showcase.css +3 -0
  8. data/lib/rubylens/art_model_builder.rb +214 -72
  9. data/lib/rubylens/artifact_marker.rb +17 -0
  10. data/lib/rubylens/atomic_output.rb +27 -0
  11. data/lib/rubylens/cli.rb +53 -36
  12. data/lib/rubylens/clip/chrome_page.rb +183 -0
  13. data/lib/rubylens/clip/deadline_io.rb +28 -0
  14. data/lib/rubylens/clip/renderer.rb +113 -0
  15. data/lib/rubylens/clip/toolchain.rb +82 -0
  16. data/lib/rubylens/clip/web_socket_channel.rb +116 -0
  17. data/lib/rubylens/clip_generator.rb +102 -0
  18. data/lib/rubylens/default_output.rb +24 -0
  19. data/lib/rubylens/generator.rb +9 -10
  20. data/lib/rubylens/git_repository.rb +13 -7
  21. data/lib/rubylens/index/constant_reference_collector.rb +150 -0
  22. data/lib/rubylens/index/declaration_collector.rb +288 -0
  23. data/lib/rubylens/index/git_package_source.rb +202 -0
  24. data/lib/rubylens/index/location_index.rb +103 -0
  25. data/lib/rubylens/index/manifest.rb +62 -157
  26. data/lib/rubylens/index/rspec_extractor.rb +12 -10
  27. data/lib/rubylens/index/rubydex_adapter.rb +111 -301
  28. data/lib/rubylens/index/source_path.rb +12 -11
  29. data/lib/rubylens/model/dependency_aggregation.rb +11 -7
  30. data/lib/rubylens/morphology_classifier.rb +52 -12
  31. data/lib/rubylens/report_writer.rb +5 -20
  32. data/lib/rubylens/showcase_generator.rb +5 -9
  33. data/lib/rubylens/showcase_model.rb +12 -14
  34. data/lib/rubylens/showcase_writer.rb +0 -6
  35. data/lib/rubylens/version.rb +1 -1
  36. data/lib/rubylens.rb +7 -0
  37. metadata +16 -3
data/lib/rubylens/cli.rb CHANGED
@@ -6,6 +6,35 @@ require_relative "../rubylens"
6
6
 
7
7
  module RubyLens
8
8
  class CLI
9
+ # One entry per generating command: everything that differs between
10
+ # report, showcase, and clip lives here, not in per-command methods.
11
+ COMMANDS = {
12
+ "report" => {
13
+ default_name: Generator::DEFAULT_REPORT_NAME,
14
+ generator: :generate_report,
15
+ warning: "RubyLens reports contain private codebase structure. Keep the output local unless you intend to share it.",
16
+ details_option: false,
17
+ output_kind: "HTML",
18
+ report_progress: false,
19
+ },
20
+ "clip" => {
21
+ default_name: ClipGenerator::DEFAULT_CLIP_NAME,
22
+ generator: :generate_clip,
23
+ warning: "RubyLens clips show the same picture as showcases: the project name plus the galaxy's shape and scale; --details adds aggregate statistics and selected Ruby and dependency names. Share them intentionally.",
24
+ details_option: true,
25
+ output_kind: "MP4",
26
+ report_progress: true,
27
+ },
28
+ "showcase" => {
29
+ default_name: ShowcaseGenerator::DEFAULT_SHOWCASE_NAME,
30
+ generator: :generate_showcase,
31
+ warning: "RubyLens showcases disclose the project name and visual codebase structure; --details also includes aggregate statistics and selected Ruby and dependency names. Share them intentionally.",
32
+ details_option: true,
33
+ output_kind: "HTML",
34
+ report_progress: false,
35
+ },
36
+ }.freeze
37
+
9
38
  def initialize(arguments)
10
39
  @arguments = arguments.dup
11
40
  end
@@ -15,8 +44,7 @@ module RubyLens
15
44
  command = arguments.shift
16
45
  return print_version if %w[-v --version version].include?(command)
17
46
  return print_help(0) if command.nil? || %w[-h --help help].include?(command)
18
- return generate_report(arguments) if command == "report"
19
- return generate_showcase(arguments) if command == "showcase"
47
+ return generate(command, arguments) if COMMANDS.key?(command)
20
48
 
21
49
  $stderr.puts "Unknown command: #{command}"
22
50
  print_help(2)
@@ -27,28 +55,8 @@ module RubyLens
27
55
 
28
56
  private
29
57
 
30
- def generate_report(arguments)
31
- generate_html(
32
- arguments,
33
- command: "report",
34
- default_name: "rubylens-report.html",
35
- generator: RubyLens.method(:generate_report),
36
- warning: "RubyLens reports contain private codebase structure. Keep the output local unless you intend to share it.",
37
- )
38
- end
39
-
40
- def generate_showcase(arguments)
41
- generate_html(
42
- arguments,
43
- command: "showcase",
44
- default_name: "rubylens-showcase.html",
45
- generator: RubyLens.method(:generate_showcase),
46
- warning: "RubyLens showcases disclose the project name and visual codebase structure; --details also includes aggregate statistics and selected Ruby and dependency names. Share them intentionally.",
47
- details_option: true,
48
- )
49
- end
50
-
51
- def generate_html(arguments, command:, default_name:, generator:, warning:, details_option: false)
58
+ def generate(command, arguments)
59
+ specification = COMMANDS.fetch(command)
52
60
  options = {}
53
61
  help = false
54
62
  parser = OptionParser.new do |option|
@@ -56,13 +64,13 @@ module RubyLens
56
64
  option.separator ""
57
65
  option.separator "TARGET defaults to the current working directory."
58
66
  option.separator ""
59
- option.on("-o", "--output FILE", "Output HTML (default: TARGET/#{default_name})") do |value|
67
+ option.on("-o", "--output FILE", "Output #{specification.fetch(:output_kind)} (default: TARGET/#{specification.fetch(:default_name)})") do |value|
60
68
  options[:output] = value
61
69
  end
62
70
  option.on("--lockfile FILE", "Gemfile.lock used to select exact dependency versions") do |value|
63
71
  options[:lockfile] = value
64
72
  end
65
- if details_option
73
+ if specification.fetch(:details_option)
66
74
  option.on("--details", "Include aggregate statistics and cinematic code/dependency labels") do
67
75
  options[:details] = true
68
76
  end
@@ -81,19 +89,27 @@ module RubyLens
81
89
  raise OptionParser::InvalidArgument, "unexpected argument: #{arguments.first}" unless arguments.empty?
82
90
 
83
91
  generator_options = { path: target, output: options[:output], lockfile: options[:lockfile] }
84
- generator_options[:details] = options.fetch(:details, false) if details_option
85
- result = generator.call(**generator_options)
92
+ generator_options[:details] = options.fetch(:details, false) if specification.fetch(:details_option)
93
+ generator_options[:progress] = progress_printer if specification.fetch(:report_progress)
94
+ result = RubyLens.public_send(specification.fetch(:generator), **generator_options)
86
95
  print_result(result)
87
- $stderr.puts warning
96
+ $stderr.puts specification.fetch(:warning)
88
97
  0
89
98
  end
90
99
 
100
+ def progress_printer
101
+ last_step = -1
102
+ lambda do |rendered, total|
103
+ step = rendered * 10 / total
104
+ next unless step > last_step
105
+
106
+ last_step = step
107
+ $stderr.puts "Rendering clip: #{rendered * 100 / total}% (#{rendered}/#{total} frames)"
108
+ end
109
+ end
110
+
91
111
  def print_result(result)
92
- $stdout.puts JSON.generate(
93
- output: result.output_path,
94
- counts: result.counts,
95
- warnings: result.warnings,
96
- )
112
+ $stdout.puts JSON.generate(result.to_payload)
97
113
  end
98
114
 
99
115
  def print_version
@@ -110,10 +126,11 @@ module RubyLens
110
126
 
111
127
  Commands:
112
128
  report [OPTIONS] [TARGET] Generate a private, interactive galaxy report
113
- showcase [OPTIONS] [TARGET] Generate a shareable, cinematic galaxy showcase
129
+ clip [OPTIONS] [TARGET] Generate a shareable galaxy video (MP4, needs Chrome + ffmpeg)
130
+ showcase [OPTIONS] [TARGET] Generate a shareable, cinematic galaxy showcase page
114
131
  version Print the RubyLens version
115
132
 
116
- Run `rubylens report --help` or `rubylens showcase --help` for options.
133
+ Run `rubylens report --help`, `rubylens clip --help`, or `rubylens showcase --help` for options.
117
134
  HELP
118
135
  status
119
136
  end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "fileutils"
5
+ require "json"
6
+ require "net/http"
7
+ require "tmpdir"
8
+ require "uri"
9
+ require_relative "../errors"
10
+ require_relative "deadline_io"
11
+ require_relative "web_socket_channel"
12
+
13
+ module RubyLens
14
+ module Clip
15
+ # Runs a headless Chrome/Chromium with an ephemeral DevTools port and a
16
+ # throwaway profile, attaches to the page that loads the showcase HTML,
17
+ # and exposes the two operations clip rendering needs: evaluate and
18
+ # screenshot. The port comes from the profile's DevToolsActivePort file,
19
+ # so parallel clips never race for a fixed port.
20
+ class ChromePage
21
+ HEADLESS_FLAGS = %w[
22
+ --headless=new
23
+ --remote-debugging-port=0
24
+ --no-first-run
25
+ --no-default-browser-check
26
+ --disable-background-networking
27
+ --disable-component-update
28
+ --disable-sync
29
+ --mute-audio
30
+ --hide-scrollbars
31
+ --force-device-scale-factor=1
32
+ --force-color-profile=srgb
33
+ --enable-unsafe-swiftshader
34
+ ].freeze
35
+ LAUNCH_TIMEOUT_SECONDS = 30
36
+
37
+ def self.open(executable:, url:, width:, height:)
38
+ page = new(executable:, url:, width:, height:)
39
+ begin
40
+ yield page
41
+ ensure
42
+ page.close
43
+ end
44
+ end
45
+
46
+ def initialize(executable:, url:, width:, height:)
47
+ @profile = Dir.mktmpdir("rubylens-clip-")
48
+ @next_id = 0
49
+ @responses = {}
50
+ flags = HEADLESS_FLAGS.dup
51
+ # Chrome refuses its sandbox as root (typical for containers and CI).
52
+ flags << "--no-sandbox" if Process.euid.zero?
53
+ flags << "--window-size=#{width},#{height}"
54
+ flags << "--user-data-dir=#{@profile}"
55
+ @pid = Process.spawn(executable, *flags, url, %i[out err] => File::NULL)
56
+ port = wait_for_devtools_port
57
+ @channel = WebSocketChannel.new("127.0.0.1", port, page_target_path(port))
58
+ # The headless window includes non-viewport space; pin the viewport so
59
+ # captures are exactly the stage size.
60
+ command("Emulation.setDeviceMetricsOverride", {
61
+ "width" => width, "height" => height, "deviceScaleFactor" => 1, "mobile" => false
62
+ }, timeout: 30)
63
+ rescue Errno::ENOENT => error
64
+ abort_start
65
+ raise Error, "could not launch Chrome at #{executable}: #{error.message}"
66
+ rescue SystemCallError => error
67
+ # A startup race (Chrome exiting or closing its port mid-attach) must
68
+ # surface as a normal rubylens error, not a raw Errno stack trace.
69
+ abort_start
70
+ raise Error, "could not attach to Chrome's DevTools endpoint: #{error.message}"
71
+ rescue StandardError
72
+ abort_start
73
+ raise
74
+ end
75
+
76
+ def evaluate(expression, await: false, timeout: 30)
77
+ result = command("Runtime.evaluate", {
78
+ "expression" => expression,
79
+ "returnByValue" => true,
80
+ "awaitPromise" => await,
81
+ }, timeout:)
82
+ if (details = result["exceptionDetails"])
83
+ raise Error, "the showcase page raised: #{details.dig("exception", "description") || details["text"]}"
84
+ end
85
+ result.dig("result", "value")
86
+ end
87
+
88
+ def screenshot_png(timeout: 60)
89
+ data = command("Page.captureScreenshot", { "format" => "png", "optimizeForSpeed" => true }, timeout:)
90
+ Base64.decode64(data.fetch("data"))
91
+ end
92
+
93
+ def close
94
+ begin
95
+ command("Browser.close", {}, timeout: 3) if @channel
96
+ rescue Error
97
+ terminate
98
+ end
99
+ Process.wait(@pid) if @pid
100
+ rescue Errno::ECHILD
101
+ nil
102
+ ensure
103
+ @channel&.close
104
+ FileUtils.remove_entry(@profile) if @profile && File.directory?(@profile)
105
+ end
106
+
107
+ private
108
+
109
+ # A failed start never reaches ChromePage.open's ensure, so the spawned
110
+ # browser and throwaway profile must be cleaned up before re-raising.
111
+ # The unconditional kill keeps close's Process.wait from hanging on a
112
+ # live Chrome with no channel.
113
+ def abort_start
114
+ terminate if @pid
115
+ close
116
+ end
117
+
118
+ def terminate
119
+ Process.kill("KILL", @pid)
120
+ rescue Errno::ESRCH
121
+ nil
122
+ end
123
+
124
+ def command(method, params, timeout:)
125
+ id = (@next_id += 1)
126
+ @channel.send_text(JSON.generate({ "id" => id, "method" => method, "params" => params }))
127
+ loop do
128
+ if (message = @responses.delete(id))
129
+ if (error = message["error"])
130
+ raise Error, "Chrome rejected #{method}: #{error["message"]}"
131
+ end
132
+ return message.fetch("result")
133
+ end
134
+ message = JSON.parse(@channel.read_text(timeout:))
135
+ @responses[message["id"]] = message if message["id"]
136
+ end
137
+ end
138
+
139
+ def wait_for_devtools_port
140
+ port_path = File.join(@profile, "DevToolsActivePort")
141
+ deadline = DeadlineIO.deadline(LAUNCH_TIMEOUT_SECONDS)
142
+ while DeadlineIO.monotonic < deadline
143
+ if File.exist?(port_path)
144
+ # to_i reads the leading port line and tolerates a mid-write file.
145
+ port = File.read(port_path).to_i
146
+ return port if port.positive?
147
+ end
148
+ if Process.waitpid(@pid, Process::WNOHANG)
149
+ @pid = nil
150
+ raise Error, "Chrome exited while starting up; it may not support headless rendering here"
151
+ end
152
+ sleep 0.05
153
+ end
154
+ raise Error, "Chrome did not publish a DevTools port within #{LAUNCH_TIMEOUT_SECONDS} seconds"
155
+ end
156
+
157
+ def page_target_path(port)
158
+ deadline = DeadlineIO.deadline(LAUNCH_TIMEOUT_SECONDS)
159
+ while DeadlineIO.monotonic < deadline
160
+ begin
161
+ target = JSON.parse(http_get(port, "/json/list")).find { |info| info["type"] == "page" }
162
+ if (socket_url = target&.fetch("webSocketDebuggerUrl", nil))
163
+ return URI(socket_url).path
164
+ end
165
+ rescue Error, SystemCallError, JSON::ParserError, Timeout::Error, EOFError
166
+ # The HTTP endpoint can lag DevToolsActivePort; retry until the deadline.
167
+ end
168
+ sleep 0.05
169
+ end
170
+ raise Error, "Chrome never exposed the showcase page for capture"
171
+ end
172
+
173
+ # The explicit nil proxy argument keeps this loopback request off any
174
+ # configured HTTP proxy.
175
+ def http_get(port, path, timeout: 10)
176
+ http = Net::HTTP.new("127.0.0.1", port, nil)
177
+ http.open_timeout = timeout
178
+ http.read_timeout = timeout
179
+ http.get(path).body
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module RubyLens
6
+ module Clip
7
+ # Deadline-bounded, non-blocking socket reads shared by the DevTools
8
+ # WebSocket channel and the local DevTools HTTP client.
9
+ module DeadlineIO
10
+ module_function
11
+
12
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
13
+
14
+ def deadline(timeout) = monotonic + timeout
15
+
16
+ def read_chunk(socket, deadline, closed_message:, timeout_message:)
17
+ remaining = deadline - monotonic
18
+ raise Error, timeout_message if remaining <= 0
19
+ IO.select([socket], nil, nil, remaining) or raise Error, timeout_message
20
+ chunk = socket.read_nonblock(1 << 20, exception: false)
21
+ raise Error, closed_message if chunk.nil?
22
+ chunk.is_a?(String) ? chunk : "".b
23
+ rescue SystemCallError, IOError => error
24
+ raise Error, "#{closed_message}: #{error.message}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "uri"
5
+ require_relative "../errors"
6
+ require_relative "chrome_page"
7
+ require_relative "deadline_io"
8
+
9
+ module RubyLens
10
+ module Clip
11
+ # Renders a showcase HTML file into an H.264 MP4: headless Chrome steps
12
+ # the showcase's deterministic clip frames, and each captured frame is
13
+ # streamed straight into ffmpeg so no frame images ever touch disk. One
14
+ # render covers exactly one camera loop, so the video loops seamlessly.
15
+ class Renderer
16
+ STAGE_WIDTH = 1920
17
+ STAGE_HEIGHT = 1080
18
+ DEFAULT_FPS = 30
19
+ READY_TIMEOUT_SECONDS = 120
20
+ MARKER_COMMENT = "RubyLens clip"
21
+
22
+ def initialize(chrome:, ffmpeg:, fps: DEFAULT_FPS, frame_limit: nil, progress: nil)
23
+ @chrome = chrome
24
+ @ffmpeg = ffmpeg
25
+ @fps = Integer(fps)
26
+ @frame_limit = frame_limit
27
+ @progress = progress
28
+ end
29
+
30
+ def render(showcase_html:, output:)
31
+ ChromePage.open(executable: @chrome, url: file_url(showcase_html), width: STAGE_WIDTH, height: STAGE_HEIGHT) do |page|
32
+ wait_until_ready(page)
33
+ preset = begin_clip(page)
34
+ frames = frame_count(preset)
35
+ encode(page, frames, output)
36
+ end
37
+ output
38
+ end
39
+
40
+ private
41
+
42
+ def file_url(path)
43
+ "file://#{URI::DEFAULT_PARSER.escape(File.expand_path(path))}"
44
+ end
45
+
46
+ def wait_until_ready(page)
47
+ deadline = DeadlineIO.deadline(READY_TIMEOUT_SECONDS)
48
+ until page.evaluate("document.documentElement?.dataset.showcaseReady === 'true'")
49
+ raise Error, "the showcase page never finished loading" if DeadlineIO.monotonic > deadline
50
+
51
+ sleep 0.1
52
+ end
53
+ end
54
+
55
+ def begin_clip(page)
56
+ preset = page.evaluate("beginShowcaseClip()")
57
+ status = preset.is_a?(Hash) ? preset["status"] : nil
58
+ raise Error, "Chrome could not initialize WebGL2, which clip rendering requires" if status == "renderer-unavailable"
59
+ raise Error, "the page is not a RubyLens showcase" unless status == "ok"
60
+
61
+ preset
62
+ end
63
+
64
+ def frame_count(preset)
65
+ frames = Integer(preset.fetch("durationMs")) * @fps / 1000
66
+ @frame_limit ? [frames, Integer(@frame_limit)].min : frames
67
+ end
68
+
69
+ def ffmpeg_arguments(output)
70
+ [
71
+ @ffmpeg, "-loglevel", "error", "-y",
72
+ "-f", "image2pipe", "-c:v", "png", "-framerate", @fps.to_s, "-i", "-",
73
+ "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p",
74
+ "-movflags", "+faststart",
75
+ "-metadata", "comment=#{MARKER_COMMENT}",
76
+ "-f", "mp4", output
77
+ ]
78
+ end
79
+
80
+ def encode(page, frames, output)
81
+ stdin, stderr, waiter = Open3.popen3(*ffmpeg_arguments(output)).then { |i, o, e, t| o.close; [i, e, t] }
82
+ stdin.binmode
83
+ stderr_reader = Thread.new { stderr.read }
84
+ begin
85
+ frames.times do |index|
86
+ page.evaluate("renderShowcaseClipFrame(#{index}, #{@fps})", await: true)
87
+ stdin.write(page.screenshot_png)
88
+ @progress&.call(index + 1, frames)
89
+ end
90
+ stdin.close
91
+ raise Error, "ffmpeg failed while encoding the clip: #{failure_reason(stderr_reader.value)}" unless waiter.value.success?
92
+ rescue Errno::EPIPE
93
+ raise Error, "ffmpeg stopped accepting frames: #{failure_reason(stderr_reader.value)}"
94
+ ensure
95
+ # On a capture-side failure, closing stdin lets ffmpeg finish and
96
+ # exit, the waiter reaps it, and the stderr reader ends on EOF, so
97
+ # only the original error surfaces.
98
+ stdin.close unless stdin.closed?
99
+ waiter.value
100
+ stderr_reader.value
101
+ stderr.close unless stderr.closed?
102
+ end
103
+ rescue Errno::ENOENT => error
104
+ raise Error, "could not run ffmpeg at #{@ffmpeg}: #{error.message}"
105
+ end
106
+
107
+ def failure_reason(stderr_output)
108
+ lines = stderr_output.to_s.strip.lines.last(3).map(&:strip)
109
+ lines.empty? ? "no error output" : lines.join(" / ")
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+
5
+ module RubyLens
6
+ module Clip
7
+ # Locates the two external tools clip rendering needs: a Chrome/Chromium
8
+ # binary and ffmpeg. Environment overrides win, then PATH, then well-known
9
+ # install locations. Discovery failures raise with install guidance so the
10
+ # command fails fast, before any indexing work.
11
+ module Toolchain
12
+ CHROME_ENV = "RUBYLENS_CHROME"
13
+ FFMPEG_ENV = "RUBYLENS_FFMPEG"
14
+ CHROME_COMMANDS = %w[google-chrome google-chrome-stable chromium chromium-browser chrome].freeze
15
+ CHROME_KNOWN_PATHS = [
16
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
17
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
18
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
19
+ ].freeze
20
+ PLAYWRIGHT_CHROME_GLOBS = %w[
21
+ chromium
22
+ chromium-*/chrome-linux/chrome
23
+ chromium-*/chrome-mac/Chromium.app/Contents/MacOS/Chromium
24
+ ].freeze
25
+
26
+ module_function
27
+
28
+ def chrome_path
29
+ from_environment(CHROME_ENV) ||
30
+ from_path(CHROME_COMMANDS) ||
31
+ from_known_paths(CHROME_KNOWN_PATHS + playwright_chrome_candidates) ||
32
+ raise(Error, "clip rendering needs Chrome or Chromium; install Google Chrome, " \
33
+ "or point #{CHROME_ENV} at a Chrome-compatible browser binary")
34
+ end
35
+
36
+ def ffmpeg_path
37
+ from_environment(FFMPEG_ENV) ||
38
+ from_path(%w[ffmpeg]) ||
39
+ raise(Error, "clip rendering needs ffmpeg; install it (for example `brew install ffmpeg` " \
40
+ "or `apt install ffmpeg`), or point #{FFMPEG_ENV} at an ffmpeg binary")
41
+ end
42
+
43
+ def from_environment(name)
44
+ value = ENV.fetch(name, nil)
45
+ return nil if value.nil? || value.empty?
46
+ raise Error, "#{name} is set to #{value}, which is not an executable file" unless executable?(value)
47
+
48
+ value
49
+ end
50
+
51
+ def from_path(commands)
52
+ directories = ENV.fetch("PATH", "").split(File::PATH_SEPARATOR)
53
+ commands.each do |command|
54
+ directories.each do |directory|
55
+ next if directory.empty?
56
+
57
+ candidate = File.join(directory, command)
58
+ return candidate if executable?(candidate)
59
+ end
60
+ end
61
+ nil
62
+ end
63
+
64
+ def from_known_paths(paths)
65
+ paths.find { |path| executable?(path) }
66
+ end
67
+
68
+ def playwright_chrome_candidates
69
+ root = ENV.fetch("PLAYWRIGHT_BROWSERS_PATH", nil)
70
+ root = File.expand_path("~/.cache/ms-playwright") if root.nil? || root.empty?
71
+ PLAYWRIGHT_CHROME_GLOBS.flat_map { |pattern| Dir.glob(File.join(root, pattern)) }.sort
72
+ rescue ArgumentError
73
+ # File.expand_path("~") raises when HOME is unset.
74
+ []
75
+ end
76
+
77
+ def executable?(path)
78
+ File.file?(path) && File.executable?(path)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "digest/sha1"
5
+ require "securerandom"
6
+ require "socket"
7
+ require_relative "../errors"
8
+ require_relative "deadline_io"
9
+
10
+ module RubyLens
11
+ module Clip
12
+ # Minimal RFC 6455 client for Chrome's local DevTools WebSocket endpoint,
13
+ # so clip rendering needs no gem or Node dependencies. Client frames are
14
+ # masked text; ping, close, and fragmented server frames are handled;
15
+ # extensions are never negotiated.
16
+ class WebSocketChannel
17
+ HANDSHAKE_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
18
+ TEXT_OPCODE = 1
19
+ CONTINUATION_OPCODE = 0
20
+ CLOSE_OPCODE = 8
21
+ PING_OPCODE = 9
22
+ PONG_OPCODE = 10
23
+
24
+ def initialize(host, port, path, timeout: 15)
25
+ @socket = TCPSocket.new(host, port)
26
+ @socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
27
+ @buffer = +"".b
28
+ handshake(host, port, path, timeout)
29
+ end
30
+
31
+ def send_text(payload)
32
+ bytes = payload.b
33
+ length = bytes.bytesize
34
+ header = if length < 126 then [0x80 | TEXT_OPCODE, 0x80 | length].pack("CC")
35
+ elsif length < 65_536 then [0x80 | TEXT_OPCODE, 0x80 | 126, length].pack("CCn")
36
+ else [0x80 | TEXT_OPCODE, 0x80 | 127, length].pack("CCQ>")
37
+ end
38
+ mask = SecureRandom.bytes(4)
39
+ @socket.write(header + mask + apply_mask(bytes, mask))
40
+ rescue SystemCallError, IOError => error
41
+ raise Error, "lost the Chrome DevTools connection: #{error.message}"
42
+ end
43
+
44
+ def read_text(timeout: 30)
45
+ deadline = DeadlineIO.deadline(timeout)
46
+ message = +"".b
47
+ loop do
48
+ finished, opcode, payload = read_frame(deadline)
49
+ case opcode
50
+ when TEXT_OPCODE, CONTINUATION_OPCODE then message << payload
51
+ when CLOSE_OPCODE then raise Error, "Chrome closed the DevTools connection"
52
+ when PING_OPCODE then send_control(PONG_OPCODE, payload)
53
+ end
54
+ return message.force_encoding(Encoding::UTF_8) if finished && opcode <= TEXT_OPCODE && !message.empty?
55
+ end
56
+ end
57
+
58
+ def close
59
+ @socket.close unless @socket.closed?
60
+ rescue IOError
61
+ nil
62
+ end
63
+
64
+ private
65
+
66
+ def handshake(host, port, path, timeout)
67
+ deadline = DeadlineIO.deadline(timeout)
68
+ key = SecureRandom.base64(16)
69
+ @socket.write("GET #{path} HTTP/1.1\r\nHost: #{host}:#{port}\r\n" \
70
+ "Upgrade: websocket\r\nConnection: Upgrade\r\n" \
71
+ "Sec-WebSocket-Key: #{key}\r\nSec-WebSocket-Version: 13\r\n\r\n")
72
+ response = +"".b
73
+ response << read_chunk(deadline) until response.include?("\r\n\r\n")
74
+ head, _, rest = response.partition("\r\n\r\n")
75
+ raise Error, "Chrome refused the DevTools WebSocket: #{head.lines.first&.strip}" unless head.start_with?("HTTP/1.1 101")
76
+
77
+ expected = Base64.strict_encode64(Digest::SHA1.digest(key + HANDSHAKE_GUID))
78
+ unless head.match?(/^sec-websocket-accept:\s*#{Regexp.escape(expected)}\s*$/i)
79
+ raise Error, "Chrome DevTools WebSocket handshake failed verification"
80
+ end
81
+
82
+ @buffer << rest
83
+ end
84
+
85
+ def apply_mask(bytes, mask)
86
+ masked = bytes.dup
87
+ masked.bytesize.times { |index| masked.setbyte(index, masked.getbyte(index) ^ mask.getbyte(index % 4)) }
88
+ masked
89
+ end
90
+
91
+ def send_control(opcode, payload)
92
+ mask = SecureRandom.bytes(4)
93
+ @socket.write([0x80 | opcode, 0x80 | payload.bytesize].pack("CC") + mask + apply_mask(payload, mask))
94
+ end
95
+
96
+ def read_frame(deadline)
97
+ first, second = read_exact(2, deadline).unpack("CC")
98
+ length = second & 0x7F
99
+ length = read_exact(2, deadline).unpack1("n") if length == 126
100
+ length = read_exact(8, deadline).unpack1("Q>") if length == 127
101
+ [first.anybits?(0x80), first & 0x0F, read_exact(length, deadline)]
102
+ end
103
+
104
+ def read_exact(length, deadline)
105
+ @buffer << read_chunk(deadline) while @buffer.bytesize < length
106
+ @buffer.slice!(0, length)
107
+ end
108
+
109
+ def read_chunk(deadline)
110
+ DeadlineIO.read_chunk(@socket, deadline,
111
+ closed_message: "Chrome closed the DevTools connection",
112
+ timeout_message: "timed out waiting for Chrome")
113
+ end
114
+ end
115
+ end
116
+ end