screentake 0.1.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.
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Screentake
8
+ module Drivers
9
+ class Cloudflare < Base
10
+ MAX_RETRIES = 3
11
+ INITIAL_BACKOFF = 1.0 # seconds
12
+
13
+ def initialize(account_id: nil, api_token: nil)
14
+ super()
15
+ config = Screentake.configuration
16
+ @account_id = account_id || config.cloudflare_account_id
17
+ @api_token = api_token || config.cloudflare_api_token
18
+ validate_credentials!
19
+ end
20
+
21
+ def screenshot(source:, type:, options:)
22
+ body = build_request_body(source, type, options)
23
+ response = perform_request(body)
24
+ handle_response(response)
25
+ end
26
+
27
+ private
28
+
29
+ def validate_credentials!
30
+ if @account_id.nil? || @account_id.to_s.strip.empty?
31
+ raise AuthenticationError,
32
+ "Cloudflare account ID is required. Set it via Screentake.configure or CLOUDFLARE_ACCOUNT_ID env var."
33
+ end
34
+
35
+ return unless @api_token.nil? || @api_token.to_s.strip.empty?
36
+
37
+ raise AuthenticationError,
38
+ "Cloudflare API token is required. Set it via Screentake.configure or CLOUDFLARE_API_TOKEN env var."
39
+ end
40
+
41
+ def endpoint_uri
42
+ URI("https://api.cloudflare.com/client/v4/accounts/#{@account_id}/browser-rendering/screenshot")
43
+ end
44
+
45
+ def build_request_body(source, type, options)
46
+ body = {}
47
+
48
+ # Source
49
+ if type == :url
50
+ body["url"] = source
51
+ else
52
+ body["html"] = source
53
+ end
54
+
55
+ # Viewport
56
+ body["viewport"] = {
57
+ "width" => options[:width],
58
+ "height" => options[:height],
59
+ "deviceScaleFactor" => options[:scale],
60
+ }
61
+
62
+ # Screenshot options
63
+ screenshot_opts = {}
64
+ screenshot_opts["fullPage"] = options[:full_page] if options[:full_page]
65
+ screenshot_opts["omitBackground"] = true if options[:transparent]
66
+
67
+ format = options[:format]
68
+ screenshot_opts["type"] = format == :jpeg ? "jpeg" : format.to_s
69
+
70
+ screenshot_opts["quality"] = options[:quality] if options[:quality] && format != :png
71
+
72
+ if options[:clip]
73
+ screenshot_opts["clip"] = {
74
+ "x" => options[:clip][:x],
75
+ "y" => options[:clip][:y],
76
+ "width" => options[:clip][:width],
77
+ "height" => options[:clip][:height],
78
+ }
79
+ end
80
+
81
+ body["screenshotOptions"] = screenshot_opts unless screenshot_opts.empty?
82
+
83
+ # Selector
84
+ body["selector"] = options[:selector] if options[:selector]
85
+
86
+ # Navigation / wait options
87
+ goto_options = {}
88
+ goto_options["waitUntil"] = options[:wait_until].to_s if options[:wait_until]
89
+ goto_options["timeout"] = Screentake.configuration.timeout
90
+ body["gotoOptions"] = goto_options unless goto_options.empty?
91
+
92
+ # Wait for selector
93
+ if options[:wait_for_selector]
94
+ body["waitForSelector"] = { "selector" => options[:wait_for_selector] }
95
+ end
96
+
97
+ # Wait for timeout (additional delay)
98
+ body["waitForTimeout"] = options[:wait_for_timeout] if options[:wait_for_timeout]
99
+
100
+ body
101
+ end
102
+
103
+ def perform_request(body)
104
+ uri = endpoint_uri
105
+ retries = 0
106
+
107
+ loop do
108
+ http = Net::HTTP.new(uri.host, uri.port)
109
+ http.use_ssl = true
110
+ http.open_timeout = 10
111
+ http.read_timeout = (Screentake.configuration.timeout / 1000.0).ceil + 5
112
+
113
+ request = Net::HTTP::Post.new(uri.request_uri)
114
+ request["Authorization"] = "Bearer #{@api_token}"
115
+ request["Content-Type"] = "application/json"
116
+ request.body = JSON.generate(body)
117
+
118
+ log_request(body)
119
+
120
+ response = http.request(request)
121
+
122
+ if response.code == "429" && retries < MAX_RETRIES
123
+ retries += 1
124
+ backoff = INITIAL_BACKOFF * (2**(retries - 1))
125
+ log_retry(retries, backoff)
126
+ sleep(backoff)
127
+ next
128
+ end
129
+
130
+ return response
131
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
132
+ raise Screentake::TimeoutError, "Request to Cloudflare timed out: #{e.message}"
133
+ rescue StandardError => e
134
+ raise Screentake::DriverError, "Cloudflare request failed: #{e.message}"
135
+ end
136
+ end
137
+
138
+ def handle_response(response)
139
+ case response.code
140
+ when "200"
141
+ response.body
142
+ when "400"
143
+ error_message = parse_error(response)
144
+ if error_message&.include?("selector")
145
+ raise Screentake::ElementNotFoundError, "Element not found: #{error_message}"
146
+ end
147
+
148
+ raise Screentake::RenderError, "Cloudflare rendering failed: #{error_message}"
149
+ when "401", "403"
150
+ raise Screentake::AuthenticationError,
151
+ "Cloudflare authentication failed. Check your API token and account ID."
152
+ when "429"
153
+ raise Screentake::RateLimitError,
154
+ "Cloudflare rate limit exceeded. Max 2 browsers/minute, 2 concurrent."
155
+ else
156
+ error_message = parse_error(response)
157
+ raise Screentake::CloudflareError,
158
+ "Cloudflare API error (HTTP #{response.code}): #{error_message}"
159
+ end
160
+ end
161
+
162
+ def parse_error(response)
163
+ data = JSON.parse(response.body)
164
+ if data.is_a?(Hash) && data["errors"]
165
+ data["errors"].map { |e| e["message"] }.join(", ")
166
+ else
167
+ response.body
168
+ end
169
+ rescue JSON::ParserError
170
+ response.body
171
+ end
172
+
173
+ def log_request(body)
174
+ logger = Screentake.configuration.logger
175
+ return unless logger
176
+
177
+ logger.debug("[Screentake] Cloudflare screenshot request: #{body.reject { |k, _| k == "html" }.inspect}")
178
+ end
179
+
180
+ def log_retry(attempt, backoff)
181
+ logger = Screentake.configuration.logger
182
+ return unless logger
183
+
184
+ logger.warn("[Screentake] Rate limited, retrying (#{attempt}/#{MAX_RETRIES}) after #{backoff}s")
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Screentake
4
+ module Drivers
5
+ class Ferrum < Base
6
+ FERRUM_REQUIRE_ERROR = "Ferrum driver requires the 'ferrum' gem. Add gem 'ferrum' to your Gemfile."
7
+ CHROME_NOT_FOUND_MSG = "Chrome/Chromium not found. Install Chrome or set config.ferrum_options[:browser_path]."
8
+ WAIT_POLL_INTERVAL = 0.05 # seconds
9
+
10
+ def initialize
11
+ super()
12
+ require_ferrum!
13
+ at_exit { quit }
14
+ end
15
+
16
+ def screenshot(source:, type:, options:)
17
+ context = browser.contexts.create
18
+ page = context.create_page
19
+ begin
20
+ configure_viewport(page, options)
21
+ navigate(page, source, type)
22
+ apply_wait_strategies(page, options)
23
+ take_screenshot(page, options)
24
+ rescue ::Ferrum::TimeoutError => e
25
+ raise Screentake::TimeoutError, e.message
26
+ rescue ::Ferrum::NodeNotFoundError => e
27
+ raise Screentake::ElementNotFoundError, e.message
28
+ rescue ::Ferrum::StatusError => e
29
+ raise Screentake::RenderError, e.message
30
+ rescue ::Ferrum::DeadBrowserError => e
31
+ reset_browser!
32
+ raise Screentake::BrowserCrashedError, e.message
33
+ rescue ::Ferrum::ProcessTimeoutError => e
34
+ reset_browser!
35
+ raise Screentake::BrowserCrashedError, e.message
36
+ rescue ::Ferrum::Error => e
37
+ raise Screentake::FerrumError, e.message
38
+ ensure
39
+ context&.dispose
40
+ end
41
+ end
42
+
43
+ # Shut down the browser process. Called by reset_configuration! and at_exit.
44
+ def quit
45
+ return unless @browser
46
+
47
+ log(:info, "Quitting browser")
48
+ @browser.quit
49
+ @browser = nil
50
+ rescue StandardError
51
+ @browser = nil
52
+ end
53
+
54
+ private
55
+
56
+ def require_ferrum!
57
+ require "ferrum"
58
+ rescue LoadError
59
+ raise Screentake::DriverError, FERRUM_REQUIRE_ERROR
60
+ end
61
+
62
+ def browser
63
+ @browser ||= begin
64
+ opts = Screentake.configuration.ferrum_options.dup
65
+ log(:info, "Launching browser")
66
+ ::Ferrum::Browser.new(**opts)
67
+ rescue ::Ferrum::ProcessTimeoutError
68
+ raise Screentake::BrowserNotFoundError, CHROME_NOT_FOUND_MSG
69
+ end
70
+ end
71
+
72
+ def reset_browser!
73
+ @browser&.quit rescue nil # rubocop:disable Style/RescueModifier
74
+ @browser = nil
75
+ end
76
+
77
+ def configure_viewport(page, options)
78
+ page.set_viewport(
79
+ width: options[:width],
80
+ height: options[:height],
81
+ scale_factor: options[:scale],
82
+ )
83
+ end
84
+
85
+ def navigate(page, source, type)
86
+ if type == :url
87
+ log(:debug, "Navigating to #{source}")
88
+ page.go_to(source)
89
+ else
90
+ log(:debug, "Loading HTML content (#{source.length} bytes)")
91
+ page.content = source
92
+ end
93
+ end
94
+
95
+ def apply_wait_strategies(page, options)
96
+ apply_network_wait(page, options[:wait_until])
97
+ wait_for_selector(page, options[:wait_for_selector]) if options[:wait_for_selector]
98
+ sleep(options[:wait_for_timeout] / 1000.0) if options[:wait_for_timeout]
99
+ end
100
+
101
+ def apply_network_wait(page, wait_until)
102
+ case wait_until
103
+ when :networkidle0
104
+ page.network.wait_for_idle(connections: 0)
105
+ when :networkidle2
106
+ page.network.wait_for_idle(connections: 2)
107
+ when :domcontentloaded, :load
108
+ # Navigation already waited for load; no extra wait needed
109
+ nil
110
+ end
111
+ end
112
+
113
+ def wait_for_selector(page, selector)
114
+ timeout_sec = Screentake.configuration.timeout / 1000.0
115
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_sec
116
+
117
+ loop do
118
+ return if page.at_css(selector)
119
+
120
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
121
+ raise Screentake::TimeoutError, "Timed out waiting for selector: #{selector}"
122
+ end
123
+
124
+ sleep(WAIT_POLL_INTERVAL)
125
+ end
126
+ end
127
+
128
+ def take_screenshot(page, options)
129
+ screenshot_opts = build_screenshot_opts(options)
130
+ log(:debug, "Screenshot options: #{screenshot_opts.inspect}")
131
+ page.screenshot(**screenshot_opts)
132
+ end
133
+
134
+ def build_screenshot_opts(options)
135
+ opts = { encoding: :binary }
136
+
137
+ # Format
138
+ format = options[:format]
139
+ opts[:format] = format.to_s
140
+
141
+ # Quality (jpeg/webp only)
142
+ opts[:quality] = options[:quality] if options[:quality] && format != :png
143
+
144
+ # Full page
145
+ opts[:full] = true if options[:full_page]
146
+
147
+ # Selector
148
+ opts[:selector] = options[:selector] if options[:selector]
149
+
150
+ # Clip region
151
+ if options[:clip]
152
+ opts[:area] = {
153
+ x: options[:clip][:x].to_i,
154
+ y: options[:clip][:y].to_i,
155
+ width: options[:clip][:width].to_i,
156
+ height: options[:clip][:height].to_i,
157
+ }
158
+ end
159
+
160
+ # Transparent background
161
+ if options[:transparent]
162
+ opts[:background_color] = ::Ferrum::RGBA.new(0, 0, 0, 0.0)
163
+ end
164
+
165
+ opts
166
+ end
167
+
168
+ def log(level, message)
169
+ logger = Screentake.configuration.logger
170
+ return unless logger
171
+
172
+ logger.public_send(level, "[Screentake] Ferrum: #{message}")
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Screentake
4
+ # Base error class for all Screentake errors
5
+ class Error < StandardError; end
6
+
7
+ # Raised when a required method is not implemented by a subclass
8
+ class NotImplementedError < Error; end
9
+
10
+ # Raised when a URL is malformed or invalid
11
+ class InvalidUrlError < Error; end
12
+
13
+ # Raised when HTML content is invalid
14
+ class InvalidHtmlError < Error; end
15
+
16
+ # Raised when viewport dimensions are out of range
17
+ class InvalidDimensionError < Error; end
18
+
19
+ # Raised when an option value is invalid
20
+ class InvalidOptionError < Error; end
21
+
22
+ # Raised when a clip region is invalid
23
+ class InvalidClipError < Error; end
24
+
25
+ # Raised when a CSS selector matches no elements
26
+ class ElementNotFoundError < Error; end
27
+
28
+ # Raised when an operation exceeds its timeout
29
+ class TimeoutError < Error; end
30
+
31
+ # Raised when the rendering engine fails
32
+ class RenderError < Error; end
33
+
34
+ # Base error for driver-specific failures
35
+ class DriverError < Error; end
36
+
37
+ # Base error for Cloudflare driver failures
38
+ class CloudflareError < DriverError; end
39
+
40
+ # Raised when Cloudflare returns a 429 rate limit response
41
+ class RateLimitError < CloudflareError; end
42
+
43
+ # Raised when Cloudflare API authentication fails
44
+ class AuthenticationError < CloudflareError; end
45
+
46
+ # Base error for Ferrum driver failures
47
+ class FerrumError < DriverError; end
48
+
49
+ # Raised when Chrome/Chromium binary not found
50
+ class BrowserNotFoundError < FerrumError; end
51
+
52
+ # Raised when browser process crashes or becomes unresponsive
53
+ class BrowserCrashedError < FerrumError; end
54
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Screentake
4
+ class Railtie < ::Rails::Railtie
5
+ initializer "screentake.configure" do
6
+ config = Screentake.configuration
7
+ config.logger ||= Rails.logger
8
+
9
+ if config.cloudflare_account_id.nil?
10
+ config.cloudflare_account_id = ENV.fetch("CLOUDFLARE_ACCOUNT_ID", nil)
11
+ end
12
+
13
+ if config.cloudflare_api_token.nil?
14
+ config.cloudflare_api_token = ENV.fetch("CLOUDFLARE_API_TOKEN", nil)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "base64"
5
+ require "pathname"
6
+
7
+ module Screentake
8
+ class Screenshot
9
+ VALID_FORMATS = [:png, :jpeg, :webp].freeze
10
+ VALID_WAIT_UNTIL = [:load, :domcontentloaded, :networkidle0, :networkidle2].freeze
11
+ MIN_DIMENSION = 1
12
+ MAX_DIMENSION = 10_000
13
+ MIN_SCALE = 1
14
+ MAX_SCALE = 4
15
+ MIN_QUALITY = 1
16
+ MAX_QUALITY = 100
17
+ MAX_WAIT_TIMEOUT = 30_000
18
+
19
+ attr_reader :options
20
+
21
+ def initialize(**kwargs)
22
+ @options = build_options(**kwargs)
23
+ validate!
24
+ end
25
+
26
+ # Save screenshot to a file path. Returns the path.
27
+ def save(path)
28
+ path = Pathname(path)
29
+ resolved = resolve_format_from_path(path)
30
+ data = perform_capture(resolved)
31
+ path.dirname.mkpath
32
+ path.binwrite(data)
33
+ path.to_s
34
+ end
35
+
36
+ # Return raw binary screenshot data.
37
+ def capture
38
+ perform_capture(@options)
39
+ end
40
+
41
+ # Return base64-encoded screenshot data.
42
+ def base64
43
+ Base64.strict_encode64(capture)
44
+ end
45
+
46
+ # Return a hash of all resolved options for debugging.
47
+ def debug
48
+ {
49
+ source_type: @options[:source_type],
50
+ source: @options[:source_type] == :url ? @options[:url] : "(html)",
51
+ width: @options[:width],
52
+ height: @options[:height],
53
+ scale: @options[:scale],
54
+ format: @options[:format],
55
+ quality: @options[:quality],
56
+ full_page: @options[:full_page],
57
+ selector: @options[:selector],
58
+ clip: @options[:clip],
59
+ transparent: @options[:transparent],
60
+ wait_until: @options[:wait_until],
61
+ wait_for_selector: @options[:wait_for_selector],
62
+ wait_for_timeout: @options[:wait_for_timeout],
63
+ driver: Screentake.configuration.driver,
64
+ }
65
+ end
66
+
67
+ private
68
+
69
+ def build_options(**kwargs)
70
+ config = Screentake.configuration
71
+
72
+ source_type, source_value = resolve_source(kwargs[:url], kwargs[:html])
73
+ width, height = resolve_dimensions(kwargs, config)
74
+
75
+ {
76
+ source_type: source_type,
77
+ url: source_type == :url ? source_value : nil,
78
+ html: source_type == :html ? source_value : nil,
79
+ width: width,
80
+ height: height,
81
+ scale: kwargs.fetch(:scale, config.default_scale),
82
+ format: kwargs.fetch(:format, config.default_format),
83
+ quality: kwargs[:quality],
84
+ full_page: kwargs.fetch(:full_page, false),
85
+ selector: kwargs[:selector],
86
+ clip: kwargs[:clip],
87
+ transparent: kwargs.fetch(:transparent, false),
88
+ wait_until: kwargs.fetch(:wait_until, :networkidle0),
89
+ wait_for_selector: kwargs[:wait_for_selector],
90
+ wait_for_timeout: kwargs[:wait_for_timeout],
91
+ }
92
+ end
93
+
94
+ def resolve_source(url, html)
95
+ if url && html
96
+ raise ArgumentError, "Cannot specify both url: and html: options"
97
+ end
98
+
99
+ if url
100
+ [:url, url]
101
+ elsif html
102
+ [:html, html]
103
+ else
104
+ raise ArgumentError, "Must specify either url: or html: option"
105
+ end
106
+ end
107
+
108
+ def resolve_dimensions(kwargs, config)
109
+ if kwargs[:size]
110
+ size = kwargs[:size]
111
+ unless size.is_a?(Array) && size.length == 2
112
+ raise InvalidDimensionError, "size: must be a [width, height] array"
113
+ end
114
+
115
+ [size[0], size[1]]
116
+ else
117
+ [
118
+ kwargs.fetch(:width, config.default_viewport[:width]),
119
+ kwargs.fetch(:height, config.default_viewport[:height]),
120
+ ]
121
+ end
122
+ end
123
+
124
+ def validate!
125
+ validate_source!
126
+ validate_dimensions!
127
+ validate_scale!
128
+ validate_format!
129
+ validate_quality!
130
+ validate_clip!
131
+ validate_wait!
132
+ end
133
+
134
+ def validate_source!
135
+ if @options[:source_type] == :url
136
+ validate_url!(@options[:url])
137
+ else
138
+ validate_html!(@options[:html])
139
+ end
140
+ end
141
+
142
+ def validate_url!(url)
143
+ uri = URI.parse(url)
144
+ unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
145
+ raise InvalidUrlError, "URL must use http or https scheme: #{url}"
146
+ end
147
+ rescue URI::InvalidURIError
148
+ raise InvalidUrlError, "Malformed URL: #{url}"
149
+ end
150
+
151
+ def validate_html!(html)
152
+ unless html.is_a?(String) && !html.strip.empty?
153
+ raise InvalidHtmlError, "HTML must be a non-empty string"
154
+ end
155
+ end
156
+
157
+ def validate_dimensions!
158
+ [:width, :height].each do |dim|
159
+ value = @options[dim]
160
+ unless value.is_a?(Integer) && value >= MIN_DIMENSION && value <= MAX_DIMENSION
161
+ raise InvalidDimensionError,
162
+ "#{dim}: must be an integer between #{MIN_DIMENSION} and #{MAX_DIMENSION}, got #{value.inspect}"
163
+ end
164
+ end
165
+ end
166
+
167
+ def validate_scale!
168
+ scale = @options[:scale]
169
+ unless scale.is_a?(Numeric) && scale >= MIN_SCALE && scale <= MAX_SCALE
170
+ raise InvalidOptionError,
171
+ "scale: must be between #{MIN_SCALE} and #{MAX_SCALE}, got #{scale.inspect}"
172
+ end
173
+ end
174
+
175
+ def validate_format!
176
+ format = @options[:format]
177
+ unless VALID_FORMATS.include?(format)
178
+ raise InvalidOptionError,
179
+ "format: must be one of #{VALID_FORMATS.inspect}, got #{format.inspect}"
180
+ end
181
+ end
182
+
183
+ def validate_quality!
184
+ quality = @options[:quality]
185
+ return if quality.nil?
186
+
187
+ if @options[:format] == :png
188
+ raise InvalidOptionError, "quality: is not supported for PNG format"
189
+ end
190
+
191
+ unless quality.is_a?(Integer) && quality >= MIN_QUALITY && quality <= MAX_QUALITY
192
+ raise InvalidOptionError,
193
+ "quality: must be an integer between #{MIN_QUALITY} and #{MAX_QUALITY}, got #{quality.inspect}"
194
+ end
195
+ end
196
+
197
+ def validate_clip!
198
+ clip = @options[:clip]
199
+ return if clip.nil?
200
+
201
+ required_keys = [:x, :y, :width, :height]
202
+ missing = required_keys - clip.keys
203
+ unless missing.empty?
204
+ raise InvalidClipError, "clip: missing required keys: #{missing.inspect}"
205
+ end
206
+
207
+ clip.each do |key, value|
208
+ unless value.is_a?(Numeric) && value >= 0
209
+ raise InvalidClipError, "clip[:#{key}] must be a non-negative number, got #{value.inspect}"
210
+ end
211
+ end
212
+ end
213
+
214
+ def validate_wait!
215
+ wait_until = @options[:wait_until]
216
+ unless VALID_WAIT_UNTIL.include?(wait_until)
217
+ raise InvalidOptionError,
218
+ "wait_until: must be one of #{VALID_WAIT_UNTIL.inspect}, got #{wait_until.inspect}"
219
+ end
220
+
221
+ timeout = @options[:wait_for_timeout]
222
+ return if timeout.nil?
223
+
224
+ unless timeout.is_a?(Numeric) && timeout > 0 && timeout <= MAX_WAIT_TIMEOUT
225
+ raise InvalidOptionError,
226
+ "wait_for_timeout: must be between 1 and #{MAX_WAIT_TIMEOUT}, got #{timeout.inspect}"
227
+ end
228
+ end
229
+
230
+ def resolve_format_from_path(path)
231
+ ext = path.extname.downcase.delete(".")
232
+ format = case ext
233
+ when "jpg", "jpeg" then :jpeg
234
+ when "webp" then :webp
235
+ when "png" then :png
236
+ else @options[:format]
237
+ end
238
+ @options.merge(format: format)
239
+ end
240
+
241
+ def perform_capture(opts)
242
+ driver = Screentake.driver
243
+ driver.screenshot(
244
+ source: opts[:source_type] == :url ? opts[:url] : opts[:html],
245
+ type: opts[:source_type],
246
+ options: opts,
247
+ )
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Screentake
4
+ VERSION = "0.1.0"
5
+ end